diff --git a/.pi/memory-vault/packs/paperclip-harness/20-context/home.md b/.pi/memory-vault/packs/paperclip-harness/20-context/home.md index c930233..aeb5477 100644 --- a/.pi/memory-vault/packs/paperclip-harness/20-context/home.md +++ b/.pi/memory-vault/packs/paperclip-harness/20-context/home.md @@ -95,3 +95,76 @@ Answer those four and I will build Board first, in the order the document lists, - session: [[packs/paperclip-harness/80-sessions/rich-discovery-home-2026-11-10-02-04-26|Rich discovery: home 2026-11-10 02:04:26]] - context: [[packs/paperclip-harness/20-context/home|home]] - observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|home repository structure and configuration patterns]] + + +--- + +## Update (2026-11-10 02:26:18) + +## Rich discovery update (2026-11-10 02:26:18) + +Taken, and folded into `docs/UI-LAYOUT.md` (now 440 lines, committed as `5b82900`). + +## The four-panel rule is gone + +A workspace holds what the work in it needs. Four side by side is a good default because more than four stops being readable, but a workspace that needs a fifth thing has three allowed answers — a tab, a tool panel that closes when done, or TUIOS's zoom. Tabs are specified as first-class: same frame, same footer, same focus marker, switched with `[` and `]` while the panel has focus, and named with words rather than numbers. + +The only rule left is that everything in a workspace helps answer the same question. If it does not, it belongs in another workspace. + +## Your first point, in plain words + +I meant exactly what you said: **choosing a task changes what the other panels show.** Pick a task in the tree, and the summary, the thread, the run log, the diff and the worktree facts all switch to it. Nothing has to be typed twice. + +The spec now leads with that sentence. The mechanism is described after it, in one short paragraph: the chosen task is remembered as one line in the project's own `.harness/harness.toml`, the other panels re-read when it changes, and the choice survives a restart or a lost pane. If a chosen task is deleted, the panels fall back to the most active task instead of showing an error. + +## Glyphs: you are wrong about one thing, and it matters + +Measured just now on this machine: + +- `starship` is **not installed** and there is no `~/.config/starship.toml`. +- No Nerd Font is present — `fc-list | grep -i nerd` returns nothing. +- TUIOS offers four glyph sets: `ascii`, `default`, `heavy`, `unicode`. `default` is active and uses ordinary Unicode: `╭ ╮ ✕ ▎ ● › …`. + +So Starship-style icons would draw empty boxes today. The spec says: use TUIOS's `default` set with `ascii` as the fallback, and if you want the Starship look, installing a Nerd Font is a system change to do deliberately first, then we pick a glyph set. Tell me if you want that and I will prepare it. + +## Hunk and Lazygit: yes, and they are installed + +They were already in your own product statement — "We need a page where we can interact with the Gitea and similar. Hunk, LazyGit etc." — and both are present: `lazygit` in the system packages, `hunk` as a flake input at `/etc/profiles/per-user/sam/bin/hunk`. + +They are now specified as **tool panels**: opened with `t` in the current workspace, they take focus when they open, run the real program rather than a copy, and give focus back to exactly where it was when closed. Also available this way: a shell, `git log` and `git diff` for the task's branch, and the review as it runs. + +## Two arrangements I added, because they are what you actually do + +- **Reading a claim and its code** — task tree left, thread upper right, diff lower right. The only place a claim and the change behind it are on screen at once. +- **Deciding** — Needs you on the left, the task it concerns beside it, so a decision and its evidence never sit on different workspaces. + +Zoom stays TUIOS's own key; we rely on it rather than reimplementing it, and the same is true of switching workspaces. + +## Next + +I build the Board workspace first, and the visual language with it — frame, focus, tab strip, footer hints, stale marker, rows and badges — so the look is settled once instead of drifting screen by screen. Then I put it in your session and you look at it before I start the second workspace. Say go. + +## Source paths observed + +- `etc/profiles/per-user/sam/bin/hunk` +- `run/current-system/sw/bin/lazygit` +- `run/current-system/sw/bin/git` +- `home/sam/.local/bin/ocr` +- `etc/nixos/configuration.nix` +- `etc/nixos/flake.nix` +- `home/sam/.config/tuios/glyphs` + +## Evidence + +- Confidence: 84% +- Reason: Update component/repository context with detailed discovered structure and configuration + +## Related learned memories + +- observation: [[packs/paperclip-harness/60-observations/environment-facts-starship-absent-no-nerd-font-tuios-glyph-s|Environment facts: starship absent, no Nerd Font, TUIOS glyph sets, hunk/lazygit installed]] +- decision: [[packs/paperclip-harness/50-decisions/paperclip-harness-ui-six-workspaces-spec-first-four-panel-li|Panels grouped by shared question; tabs/zoom/closable tool panels instead of hard 4-panel cap]] +- action: [[packs/paperclip-harness/40-actions/2026-11-10-wrote-docs-ui-layout-md-and-three-live-layout-mockups|Rewrote docs/UI-LAYOUT.md per user feedback; committed 5b82900]] +- session: [[packs/paperclip-harness/80-sessions/tuios-paperclip-harness-ui-spec-workspaces-tool-panels-tab-z|TUIOS Paperclip harness UI spec: workspaces, tool panels, tab/zoom, glyphs]] +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-etc-2026-11-10-02-26-18|Rich discovery: etc 2026-11-10 02:26:18]] +- context: [[packs/paperclip-harness/20-context/home|etc]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|etc repository structure and configuration patterns]] diff --git a/.pi/memory-vault/packs/paperclip-harness/40-actions/2026-11-10-wrote-docs-ui-layout-md-and-three-live-layout-mockups.md b/.pi/memory-vault/packs/paperclip-harness/40-actions/2026-11-10-wrote-docs-ui-layout-md-and-three-live-layout-mockups.md index 9d98fa8..2a13981 100644 --- a/.pi/memory-vault/packs/paperclip-harness/40-actions/2026-11-10-wrote-docs-ui-layout-md-and-three-live-layout-mockups.md +++ b/.pi/memory-vault/packs/paperclip-harness/40-actions/2026-11-10-wrote-docs-ui-layout-md-and-three-live-layout-mockups.md @@ -115,3 +115,31 @@ Follow-up: four open decisions (tree-driven navigation via `.harness/harness.tom - session: [[packs/paperclip-harness/80-sessions/rich-discovery-home-2026-11-10-02-04-26|Rich discovery: home 2026-11-10 02:04:26]] - context: [[packs/paperclip-harness/20-context/home|home]] - observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|home repository structure and configuration patterns]] + + +--- + +## Update (2026-11-10 02:26:18) + +Completed: updated the Paperclip harness interface spec in docs/UI-LAYOUT.md (now ~440 lines), committed as 5b82900 "Interface spec: workspaces grouped by question, tabs and tool panels, plain-language task selection". + +Changes: removed the four-panel hard limit; specified tabs as first-class (frame/footer/focus marker, '[' ']' switching, word names); specified tool panels (Hunk, Lazygit, shell, git log/diff for task branch, review) opened with 't' and restoring focus on close; specified that selecting a task updates all other panels via a 'watching' setting in .harness/harness.toml with fallback to most active task; added two arrangements ('Reading a claim and its code', 'Deciding'); kept responsive width-based layouts and deliberate exclusions. + +Validation: checked tool availability and glyph/font environment via bash before finalizing glyph guidance (starship absent, no Nerd Font, TUIOS default glyph set). + +Follow-up: build the Board workspace and visual language first, have the user review it, then proceed to the second workspace. + +## Evidence + +- Confidence: 90% +- Reason: Concrete deliverable with commit hash, artifact path, changes, and follow-up. + +## Related learned memories + +- observation: [[packs/paperclip-harness/60-observations/environment-facts-starship-absent-no-nerd-font-tuios-glyph-s|Environment facts: starship absent, no Nerd Font, TUIOS glyph sets, hunk/lazygit installed]] +- decision: [[packs/paperclip-harness/50-decisions/paperclip-harness-ui-six-workspaces-spec-first-four-panel-li|Panels grouped by shared question; tabs/zoom/closable tool panels instead of hard 4-panel cap]] +- action: [[packs/paperclip-harness/40-actions/2026-11-10-wrote-docs-ui-layout-md-and-three-live-layout-mockups|Rewrote docs/UI-LAYOUT.md per user feedback; committed 5b82900]] +- session: [[packs/paperclip-harness/80-sessions/tuios-paperclip-harness-ui-spec-workspaces-tool-panels-tab-z|TUIOS Paperclip harness UI spec: workspaces, tool panels, tab/zoom, glyphs]] +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-etc-2026-11-10-02-26-18|Rich discovery: etc 2026-11-10 02:26:18]] +- context: [[packs/paperclip-harness/20-context/home|etc]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|etc repository structure and configuration patterns]] diff --git a/.pi/memory-vault/packs/paperclip-harness/50-decisions/paperclip-harness-ui-six-workspaces-spec-first-four-panel-li.md b/.pi/memory-vault/packs/paperclip-harness/50-decisions/paperclip-harness-ui-six-workspaces-spec-first-four-panel-li.md index f115035..2bd6d95 100644 --- a/.pi/memory-vault/packs/paperclip-harness/50-decisions/paperclip-harness-ui-six-workspaces-spec-first-four-panel-li.md +++ b/.pi/memory-vault/packs/paperclip-harness/50-decisions/paperclip-harness-ui-six-workspaces-spec-first-four-panel-li.md @@ -56,3 +56,29 @@ Review caveat: all of this is a specification, not yet implemented. - session: [[packs/paperclip-harness/80-sessions/rich-discovery-home-2026-11-10-02-04-26|Rich discovery: home 2026-11-10 02:04:26]] - context: [[packs/paperclip-harness/20-context/home|home]] - observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|home repository structure and configuration patterns]] + + +--- + +## Update (2026-11-10 02:26:18) + +Decision: drop the previous hard rule of at most 4 panels per workspace. A workspace holds whatever its work needs; more than four side-by-side stops being readable, so extra items are expressed as (1) first-class tabs inside a panel (switched with '[' / ']' while focused, word-named), (2) tool panels that close when done (opened with 't', focus restored on close), or (3) TUIOS's native zoom key. The only remaining rule: everything in a workspace helps answer the same question, else it moves to another workspace. + +Context: user said 4 panels was never a hard limit, is happy with tabs and zoom, and prioritizes well-grouped meaningful items and usability over arbitrary counts. User also rejected esoteric backend terminology in UI copy, so selection behavior is stated plainly ('choosing a task changes what the other panels show') with the mechanism (watching line in .harness/harness.toml) described afterward. + +Consequences: responsive layouts by terminal width (>=150 wide, 110-149 two panels + tab, 80-109 one panel ordered Board, Work, Conversation, Author, <80x24 single message); rely on TUIOS zoom and workspace switching rather than reimplementing. Review caveat: Starship-style glyphs deferred until a Nerd Font is deliberately installed. + +## Evidence + +- Confidence: 90% +- Reason: Architectural/convention decision with rationale, alternatives (tabs vs more panels), and consequences. + +## Related learned memories + +- observation: [[packs/paperclip-harness/60-observations/environment-facts-starship-absent-no-nerd-font-tuios-glyph-s|Environment facts: starship absent, no Nerd Font, TUIOS glyph sets, hunk/lazygit installed]] +- decision: [[packs/paperclip-harness/50-decisions/paperclip-harness-ui-six-workspaces-spec-first-four-panel-li|Panels grouped by shared question; tabs/zoom/closable tool panels instead of hard 4-panel cap]] +- action: [[packs/paperclip-harness/40-actions/2026-11-10-wrote-docs-ui-layout-md-and-three-live-layout-mockups|Rewrote docs/UI-LAYOUT.md per user feedback; committed 5b82900]] +- session: [[packs/paperclip-harness/80-sessions/tuios-paperclip-harness-ui-spec-workspaces-tool-panels-tab-z|TUIOS Paperclip harness UI spec: workspaces, tool panels, tab/zoom, glyphs]] +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-etc-2026-11-10-02-26-18|Rich discovery: etc 2026-11-10 02:26:18]] +- context: [[packs/paperclip-harness/20-context/home|etc]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|etc repository structure and configuration patterns]] diff --git a/.pi/memory-vault/packs/paperclip-harness/60-observations/environment-facts-starship-absent-no-nerd-font-tuios-glyph-s.md b/.pi/memory-vault/packs/paperclip-harness/60-observations/environment-facts-starship-absent-no-nerd-font-tuios-glyph-s.md new file mode 100644 index 0000000..d4bd243 --- /dev/null +++ b/.pi/memory-vault/packs/paperclip-harness/60-observations/environment-facts-starship-absent-no-nerd-font-tuios-glyph-s.md @@ -0,0 +1,50 @@ +--- +type: observation +id: observation.paperclip-harness.environment-facts-starship-absent-no-nerd-font-tuios-glyph-s +title: Environment facts: starship absent, no Nerd Font, TUIOS glyph sets, hunk/lazygit installed +status: active +source_of_truth: false +freshness: current +last_reviewed: 2026-11-10 +tags: + - pack/paperclip-harness + - agent-memory/observation + - environment + - tuios + - glyphs + - nerd-font + - starship + - hunk + - lazygit + - nixos +--- + +# Environment facts: starship absent, no Nerd Font, TUIOS glyph sets, hunk/lazygit installed + +Verified on the user's NixOS machine this turn: +- starship is NOT installed (command missing) and there is no ~/.config/starship.toml. So Starship-style prompt glyphs/icons would render as empty boxes. +- No Nerd Font present: `fc-list | grep -i nerd` returns 0 results. +- TUIOS offers 4 glyph sets: ascii, default, heavy, unicode. Active is `default`, using ordinary Unicode: add '+', arrow_left '‹', arrow_right '›', attention '▎', borders '╭ ╮ ╰ ╯ ─ │ ┼ ├ ┤ ┬ ┴', bullet '·', close '✕', collapse '«', dash_rule '╌', dot '●', ellipsis '…'. Glyphs dir: /home/sam/.config/tuios/glyphs. Many roles exist (border.*, state_*, section_*, tree_*, arrow_*, etc.). +- hunk installed at /etc/profiles/per-user/sam/bin/hunk (flake input hunk.url = "github:modem-dev/hunk" in /etc/nixos/flake.nix). +- lazygit installed at /run/current-system/sw/bin/lazygit (listed in /etc/nixos/configuration.nix packages). +- git at /run/current-system/sw/bin/git; ocr at /home/sam/.local/bin/ocr. +Spec decision from this: use TUIOS `default` glyph set with `ascii` fallback; Starship look requires deliberately installing a Nerd Font first. + +## Evidence + +- Confidence: 95% +- Reason: Concrete environment discovery with paths and tool availability that affects UI design decisions. + +## Related + +- [[packs/paperclip-harness/00-system/pi-agent/memory-manifest|Memory Manifest]] + +## Related learned memories + +- observation: [[packs/paperclip-harness/60-observations/environment-facts-starship-absent-no-nerd-font-tuios-glyph-s|Environment facts: starship absent, no Nerd Font, TUIOS glyph sets, hunk/lazygit installed]] +- decision: [[packs/paperclip-harness/50-decisions/paperclip-harness-ui-six-workspaces-spec-first-four-panel-li|Panels grouped by shared question; tabs/zoom/closable tool panels instead of hard 4-panel cap]] +- action: [[packs/paperclip-harness/40-actions/2026-11-10-wrote-docs-ui-layout-md-and-three-live-layout-mockups|Rewrote docs/UI-LAYOUT.md per user feedback; committed 5b82900]] +- session: [[packs/paperclip-harness/80-sessions/tuios-paperclip-harness-ui-spec-workspaces-tool-panels-tab-z|TUIOS Paperclip harness UI spec: workspaces, tool panels, tab/zoom, glyphs]] +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-etc-2026-11-10-02-26-18|Rich discovery: etc 2026-11-10 02:26:18]] +- context: [[packs/paperclip-harness/20-context/home|etc]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|etc 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 c5b51af..b4b6bdc 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 @@ -173,3 +173,76 @@ Answer those four and I will build Board first, in the order the document lists, - session: [[packs/paperclip-harness/80-sessions/rich-discovery-home-2026-11-10-02-04-26|Rich discovery: home 2026-11-10 02:04:26]] - context: [[packs/paperclip-harness/20-context/home|home]] - observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|home repository structure and configuration patterns]] + + +--- + +## Update (2026-11-10 02:26:18) + +## Structural/configuration observations (2026-11-10 02:26:18) + +Taken, and folded into `docs/UI-LAYOUT.md` (now 440 lines, committed as `5b82900`). + +## The four-panel rule is gone + +A workspace holds what the work in it needs. Four side by side is a good default because more than four stops being readable, but a workspace that needs a fifth thing has three allowed answers — a tab, a tool panel that closes when done, or TUIOS's zoom. Tabs are specified as first-class: same frame, same footer, same focus marker, switched with `[` and `]` while the panel has focus, and named with words rather than numbers. + +The only rule left is that everything in a workspace helps answer the same question. If it does not, it belongs in another workspace. + +## Your first point, in plain words + +I meant exactly what you said: **choosing a task changes what the other panels show.** Pick a task in the tree, and the summary, the thread, the run log, the diff and the worktree facts all switch to it. Nothing has to be typed twice. + +The spec now leads with that sentence. The mechanism is described after it, in one short paragraph: the chosen task is remembered as one line in the project's own `.harness/harness.toml`, the other panels re-read when it changes, and the choice survives a restart or a lost pane. If a chosen task is deleted, the panels fall back to the most active task instead of showing an error. + +## Glyphs: you are wrong about one thing, and it matters + +Measured just now on this machine: + +- `starship` is **not installed** and there is no `~/.config/starship.toml`. +- No Nerd Font is present — `fc-list | grep -i nerd` returns nothing. +- TUIOS offers four glyph sets: `ascii`, `default`, `heavy`, `unicode`. `default` is active and uses ordinary Unicode: `╭ ╮ ✕ ▎ ● › …`. + +So Starship-style icons would draw empty boxes today. The spec says: use TUIOS's `default` set with `ascii` as the fallback, and if you want the Starship look, installing a Nerd Font is a system change to do deliberately first, then we pick a glyph set. Tell me if you want that and I will prepare it. + +## Hunk and Lazygit: yes, and they are installed + +They were already in your own product statement — "We need a page where we can interact with the Gitea and similar. Hunk, LazyGit etc." — and both are present: `lazygit` in the system packages, `hunk` as a flake input at `/etc/profiles/per-user/sam/bin/hunk`. + +They are now specified as **tool panels**: opened with `t` in the current workspace, they take focus when they open, run the real program rather than a copy, and give focus back to exactly where it was when closed. Also available this way: a shell, `git log` and `git diff` for the task's branch, and the review as it runs. + +## Two arrangements I added, because they are what you actually do + +- **Reading a claim and its code** — task tree left, thread upper right, diff lower right. The only place a claim and the change behind it are on screen at once. +- **Deciding** — Needs you on the left, the task it concerns beside it, so a decision and its evidence never sit on different workspaces. + +Zoom stays TUIOS's own key; we rely on it rather than reimplementing it, and the same is true of switching workspaces. + +## Next + +I build the Board workspace first, and the visual language with it — frame, focus, tab strip, footer hints, stale marker, rows and badges — so the look is settled once instead of drifting screen by screen. Then I put it in your session and you look at it before I start the second workspace. Say go. + +## Source paths observed + +- `etc/profiles/per-user/sam/bin/hunk` +- `run/current-system/sw/bin/lazygit` +- `run/current-system/sw/bin/git` +- `home/sam/.local/bin/ocr` +- `etc/nixos/configuration.nix` +- `etc/nixos/flake.nix` +- `home/sam/.config/tuios/glyphs` + +## Evidence + +- Confidence: 82% +- Reason: Captured factual repo structure/configuration patterns from rich discovery + +## Related learned memories + +- observation: [[packs/paperclip-harness/60-observations/environment-facts-starship-absent-no-nerd-font-tuios-glyph-s|Environment facts: starship absent, no Nerd Font, TUIOS glyph sets, hunk/lazygit installed]] +- decision: [[packs/paperclip-harness/50-decisions/paperclip-harness-ui-six-workspaces-spec-first-four-panel-li|Panels grouped by shared question; tabs/zoom/closable tool panels instead of hard 4-panel cap]] +- action: [[packs/paperclip-harness/40-actions/2026-11-10-wrote-docs-ui-layout-md-and-three-live-layout-mockups|Rewrote docs/UI-LAYOUT.md per user feedback; committed 5b82900]] +- session: [[packs/paperclip-harness/80-sessions/tuios-paperclip-harness-ui-spec-workspaces-tool-panels-tab-z|TUIOS Paperclip harness UI spec: workspaces, tool panels, tab/zoom, glyphs]] +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-etc-2026-11-10-02-26-18|Rich discovery: etc 2026-11-10 02:26:18]] +- context: [[packs/paperclip-harness/20-context/home|etc]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|etc repository structure and configuration patterns]] diff --git a/.pi/memory-vault/packs/paperclip-harness/80-sessions/rich-discovery-etc-2026-11-10-02-26-18.md b/.pi/memory-vault/packs/paperclip-harness/80-sessions/rich-discovery-etc-2026-11-10-02-26-18.md new file mode 100644 index 0000000..7040812 --- /dev/null +++ b/.pi/memory-vault/packs/paperclip-harness/80-sessions/rich-discovery-etc-2026-11-10-02-26-18.md @@ -0,0 +1,92 @@ +--- +type: session +id: session.paperclip-harness.rich-discovery-etc-2026-11-10-02-26-18 +title: Rich discovery: etc 2026-11-10 02:26:18 +status: active +source_of_truth: false +freshness: current +last_reviewed: 2026-11-10 +tags: + - pack/paperclip-harness + - agent-memory/session + - autolearn + - rich-discovery + - repo/etc +--- + +# Rich discovery: etc 2026-11-10 02:26:18 + +Sanitized rich discovery snapshot captured automatically from the completed turn. + + +## Source paths observed + +- `etc/profiles/per-user/sam/bin/hunk` +- `run/current-system/sw/bin/lazygit` +- `run/current-system/sw/bin/git` +- `home/sam/.local/bin/ocr` +- `etc/nixos/configuration.nix` +- `etc/nixos/flake.nix` +- `home/sam/.config/tuios/glyphs` + +## Final answer snapshot + +Taken, and folded into `docs/UI-LAYOUT.md` (now 440 lines, committed as `5b82900`). + +## The four-panel rule is gone + +A workspace holds what the work in it needs. Four side by side is a good default because more than four stops being readable, but a workspace that needs a fifth thing has three allowed answers — a tab, a tool panel that closes when done, or TUIOS's zoom. Tabs are specified as first-class: same frame, same footer, same focus marker, switched with `[` and `]` while the panel has focus, and named with words rather than numbers. + +The only rule left is that everything in a workspace helps answer the same question. If it does not, it belongs in another workspace. + +## Your first point, in plain words + +I meant exactly what you said: **choosing a task changes what the other panels show.** Pick a task in the tree, and the summary, the thread, the run log, the diff and the worktree facts all switch to it. Nothing has to be typed twice. + +The spec now leads with that sentence. The mechanism is described after it, in one short paragraph: the chosen task is remembered as one line in the project's own `.harness/harness.toml`, the other panels re-read when it changes, and the choice survives a restart or a lost pane. If a chosen task is deleted, the panels fall back to the most active task instead of showing an error. + +## Glyphs: you are wrong about one thing, and it matters + +Measured just now on this machine: + +- `starship` is **not installed** and there is no `~/.config/starship.toml`. +- No Nerd Font is present — `fc-list | grep -i nerd` returns nothing. +- TUIOS offers four glyph sets: `ascii`, `default`, `heavy`, `unicode`. `default` is active and uses ordinary Unicode: `╭ ╮ ✕ ▎ ● › …`. + +So Starship-style icons would draw empty boxes today. The spec says: use TUIOS's `default` set with `ascii` as the fallback, and if you want the Starship look, installing a Nerd Font is a system change to do deliberately first, then we pick a glyph set. Tell me if you want that and I will prepare it. + +## Hunk and Lazygit: yes, and they are installed + +They were already in your own product statement — "We need a page where we can interact with the Gitea and similar. Hunk, LazyGit etc." — and both are present: `lazygit` in the system packages, `hunk` as a flake input at `/etc/profiles/per-user/sam/bin/hunk`. + +They are now specified as **tool panels**: opened with `t` in the current workspace, they take focus when they open, run the real program rather than a copy, and give focus back to exactly where it was when closed. Also available this way: a shell, `git log` and `git diff` for the task's branch, and the review as it runs. + +## Two arrangements I added, because they are what you actually do + +- **Reading a claim and its code** — task tree left, thread upper right, diff lower right. The only place a claim and the change behind it are on screen at once. +- **Deciding** — Needs you on the left, the task it concerns beside it, so a decision and its evidence never sit on different workspaces. + +Zoom stays TUIOS's own key; we rely on it rather than reimplementing it, and the same is true of switching workspaces. + +## Next + +I build the Board workspace first, and the visual language with it — frame, focus, tab strip, footer hints, stale marker, rows and badges — so the look is settled once instead of drifting screen by screen. Then I put it in your session and you look at it before I start the second workspace. Say go. + +## 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 + +- observation: [[packs/paperclip-harness/60-observations/environment-facts-starship-absent-no-nerd-font-tuios-glyph-s|Environment facts: starship absent, no Nerd Font, TUIOS glyph sets, hunk/lazygit installed]] +- decision: [[packs/paperclip-harness/50-decisions/paperclip-harness-ui-six-workspaces-spec-first-four-panel-li|Panels grouped by shared question; tabs/zoom/closable tool panels instead of hard 4-panel cap]] +- action: [[packs/paperclip-harness/40-actions/2026-11-10-wrote-docs-ui-layout-md-and-three-live-layout-mockups|Rewrote docs/UI-LAYOUT.md per user feedback; committed 5b82900]] +- session: [[packs/paperclip-harness/80-sessions/tuios-paperclip-harness-ui-spec-workspaces-tool-panels-tab-z|TUIOS Paperclip harness UI spec: workspaces, tool panels, tab/zoom, glyphs]] +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-etc-2026-11-10-02-26-18|Rich discovery: etc 2026-11-10 02:26:18]] +- context: [[packs/paperclip-harness/20-context/home|etc]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|etc repository structure and configuration patterns]] diff --git a/.pi/memory-vault/packs/paperclip-harness/80-sessions/tuios-paperclip-harness-ui-spec-workspaces-tool-panels-tab-z.md b/.pi/memory-vault/packs/paperclip-harness/80-sessions/tuios-paperclip-harness-ui-spec-workspaces-tool-panels-tab-z.md new file mode 100644 index 0000000..57664fe --- /dev/null +++ b/.pi/memory-vault/packs/paperclip-harness/80-sessions/tuios-paperclip-harness-ui-spec-workspaces-tool-panels-tab-z.md @@ -0,0 +1,61 @@ +--- +type: session +id: session.paperclip-harness.tuios-paperclip-harness-ui-spec-workspaces-tool-panels-tab-z +title: TUIOS Paperclip harness UI spec: workspaces, tool panels, tab/zoom, glyphs +status: active +source_of_truth: false +freshness: current +last_reviewed: 2026-11-10 +tags: + - pack/paperclip-harness + - agent-memory/session + - paperclip-harness + - tuios + - ui-spec + - docs/UI-LAYOUT.md + - workspaces + - tabs + - tool-panels +--- + +# TUIOS Paperclip harness UI spec: workspaces, tool panels, tab/zoom, glyphs + +Turn snapshot: revised UI specification for the Paperclip harness on TUIOS, written to docs/UI-LAYOUT.md (grown to ~440 lines; commit 5b82900 "Interface spec: workspaces grouped by question, tabs and tool panels, plain-language task selection"). + +User preferences expressed: +- 4 panels was NOT a hard limit; more is fine via tabs, zoom, or closable tool panels. Priority is WELL GROUPED items that are meaningful to the work; usability/speed matters. +- Dislikes esoteric/backend-derived terminology in UI copy (e.g. 'tree drives', 'Enter writes watching - AUD-163'); wants plain meaningful language. +- Happy to switch between workspaces; logical workspaces matter more than count. +- Mentioned Hunk and Lazygit as tools to integrate; opening a new panel inside a workspace is fine if easily closed; tabs are convenient. +- Thought the UI glyphs were Starship glyphs (verified: starship is not installed). + +Spec content settled this turn: +- Workspaces grouped by the question they answer: Board (task tree + summary + Needs you), Work (run log + diff), Conversation (thread above composer + task header), Author (plan/task description writing), plus added arrangements 'Reading a claim and its code' (tree left, thread upper-right, diff lower-right) and 'Deciding' (Needs you left, related task beside it). +- Only rule: everything in a workspace helps answer the same question; otherwise it belongs elsewhere. +- Tabs are first-class: same frame/footer/focus marker, switched with '[' and ']' while panel focused, named with words not numbers. +- Tool panels: opened with 't' in current workspace, take focus on open, run the real program (Hunk, Lazygit, shell, git log, git diff for task branch, review), restore focus on close. +- Selecting a task in the tree updates all other panels (summary, thread, run log, diff, worktree facts). Mechanism: chosen task stored as 'watching' line in the project's .harness/harness.toml; other panels reread on change; survives restart/lost pane; deleted task falls back to most active task. +- Responsive layout by terminal width: >=150 cols wide layouts; 110-149 two panels max with third as tab; 80-109 one panel per workspace in order Board, Work, Conversation, Author; <80x24 a single message. +- Uses TUIOS's own zoom and workspace-switch keys rather than reimplementing. +- Deliberately excluded: separate agent chat (Conversation is the chat), plan editor (plan = task description), second task board, per-panel colors (one theme, two variants), actionless dashboards. + +Next step agreed: build the Board workspace first plus the visual language (frame, focus, tab strip, footer hints, stale marker, rows, badges); then show user before building second workspace. + +## Evidence + +- Confidence: 90% +- Reason: Rich discovery/planning turn with durable UI conventions, user preferences, and committed artifact details. + +## Related + +- [[packs/paperclip-harness/00-system/pi-agent/memory-manifest|Memory Manifest]] + +## Related learned memories + +- observation: [[packs/paperclip-harness/60-observations/environment-facts-starship-absent-no-nerd-font-tuios-glyph-s|Environment facts: starship absent, no Nerd Font, TUIOS glyph sets, hunk/lazygit installed]] +- decision: [[packs/paperclip-harness/50-decisions/paperclip-harness-ui-six-workspaces-spec-first-four-panel-li|Panels grouped by shared question; tabs/zoom/closable tool panels instead of hard 4-panel cap]] +- action: [[packs/paperclip-harness/40-actions/2026-11-10-wrote-docs-ui-layout-md-and-three-live-layout-mockups|Rewrote docs/UI-LAYOUT.md per user feedback; committed 5b82900]] +- session: [[packs/paperclip-harness/80-sessions/tuios-paperclip-harness-ui-spec-workspaces-tool-panels-tab-z|TUIOS Paperclip harness UI spec: workspaces, tool panels, tab/zoom, glyphs]] +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-etc-2026-11-10-02-26-18|Rich discovery: etc 2026-11-10 02:26:18]] +- context: [[packs/paperclip-harness/20-context/home|etc]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|etc repository structure and configuration patterns]] diff --git a/bin/harness b/bin/harness index 4734cf7..02985cd 100755 Binary files a/bin/harness and b/bin/harness differ diff --git a/docs/ENVIRONMENT-FACTS.md b/docs/ENVIRONMENT-FACTS.md index fdfd92c..3014462 100644 --- a/docs/ENVIRONMENT-FACTS.md +++ b/docs/ENVIRONMENT-FACTS.md @@ -92,6 +92,29 @@ deliveryState merged_by_ancestry (no commit was needed; the branch equals mai worktree is Paperclip's job after all, once the instance allows it, and the terminal program never needs to run `git worktree` itself. +## Fonts, 2026-11-10 + +Measured, because the answer was "I thought I had installed them": + +- No Nerd Font was installed (`fc-list | grep -i nerd` returned nothing) and no JetBrains + Mono either. Ghostty ships a plain JetBrains Mono of its own, which is why a terminal can + look right while every icon renders as a box. +- Starship is **not installed**, and there is no `~/.config/starship.toml`. +- TUIOS has four glyph sets — `ascii`, `default`, `heavy`, `unicode` — and `default` is + active, which is ordinary Unicode, not Nerd Font icons. +- TUIOS has 30 themes including six Catppuccin variants, and **no look is applied**: + `tuios look show` says "No look is applied". The session is not on Catppuccin. + +What was changed: + +- `fonts.packages = [ nerd-fonts.jetbrains-mono ]` and `fonts.fontconfig.enable = true` in + `/etc/nixos/configuration.nix`, so every machine built from this flake gets it: `.13`, + `.27`, `.51`. `nix build --dry-run` evaluates and wants + `nerd-fonts-jetbrains-mono-3.4.0+2.304`. **The rebuild is still to be run** (`sudo` + needs a password here). +- `~/.config/ghostty/config` sets `font-family = "JetBrainsMono Nerd Font"`. Until the + rebuild, Ghostty falls back to its own font and nothing looks different. + ## Toolchain - **Go is installed** (2026-11-10), by `nix profile install nixpkgs#go` → `go1.26.5` at diff --git a/docs/UI-LAYOUT.md b/docs/UI-LAYOUT.md index 3fcd351..87884ef 100644 --- a/docs/UI-LAYOUT.md +++ b/docs/UI-LAYOUT.md @@ -221,12 +221,12 @@ with focus where it was. **27. Glyphs.** A small set used consistently: running, waiting, blocked, done, failed, unread. Never the only carrier of meaning. -Measured on this machine, and this matters: TUIOS offers four glyph sets — `ascii`, -`default`, `heavy` and `unicode` — and `default` is active. Starship is **not installed -here**, and no Nerd Font is present (`fc-list` finds none), so an icon set that expects a -patched font would draw empty boxes. Until a Nerd Font is installed deliberately, the -interface uses TUIOS's `default` set, which is ordinary Unicode: `╭ ╮ ✕ ▎ ● › …`, with -`ascii` as the fallback. +Measured, then fixed: no Nerd Font was installed and Starship was not either. JetBrains +Mono Nerd Font is now declared in `/etc/nixos/configuration.nix` (so `.13`, `.27` and +`.51` all get it on rebuild) and Ghostty's `font-family` points at it. Until that rebuild, +TUIOS's `default` glyph set — ordinary Unicode such as `╭ ╮ ✕ ▎ ● › …` — is what renders, +with `ascii` as the fallback. The program therefore uses plain Unicode symbols today and +may adopt richer glyphs once the font is present, but never depends on them. --- diff --git a/tui/board.go b/tui/board.go new file mode 100644 index 0000000..cf71b5c --- /dev/null +++ b/tui/board.go @@ -0,0 +1,568 @@ +package main + +// The Board workspace: the state of the work, and what needs a person. +// +// Three panels on one page: the task tree, the summary of the chosen task, and +// the list of things waiting for a decision. Choosing a task changes what the +// other panels show, here and in every other workspace, because the choice is +// written to the project's own .harness file. + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "paperclip-harness/tui/paperclip" +) + +type boardDetailMsg struct { + issueID string + runs []paperclip.Run + cost paperclip.Cost + activity []paperclip.Activity + err error +} + +type boardModel struct { + client *paperclip.Client + project paperclip.Project + folder string + + rows []Row + cursor int + focus int // 0 tree, 1 summary, 2 needs-you + loading bool + readAt time.Time + notice string + err error + + detailID string + runs []paperclip.Run + cost paperclip.Cost + activity []paperclip.Activity + needsTab int + + width, height int +} + +const ( + focusTree = iota + focusSummary + focusNeeds + focusCount +) + +func newBoardModel(c *paperclip.Client, proj paperclip.Project, folder string) boardModel { + return boardModel{client: c, project: proj, folder: folder, loading: true} +} + +func (m boardModel) Init() tea.Cmd { + return tea.Batch(m.reload(), tickEvery(5*time.Second)) +} + +func (m boardModel) reload() tea.Cmd { + c, proj, want := m.client, m.project, m.selectedID() + return func() tea.Msg { + rows, err := loadRows(c, proj) + if err != nil { + return errMsg{err} + } + _ = want + return rowsMsg(rows) + } +} + +func (m boardModel) loadDetail(id string) tea.Cmd { + c := m.client + return func() tea.Msg { + if id == "" { + return boardDetailMsg{} + } + runs, err := c.Runs(id) + if err != nil { + return boardDetailMsg{issueID: id, err: err} + } + cost, _ := c.CostSummary(id) + act, _ := c.Activity(id) + return boardDetailMsg{issueID: id, runs: runs, cost: cost, activity: act} + } +} + +func (m boardModel) selectedID() string { + if m.cursor >= 0 && m.cursor < len(m.rows) { + return m.rows[m.cursor].Issue.ID + } + return "" +} + +func (m boardModel) selectedTask() *Row { + if m.cursor >= 0 && m.cursor < len(m.rows) { + return &m.rows[m.cursor] + } + return nil +} + +func (m boardModel) 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.reload(), tickEvery(5*time.Second)) + + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "tab": + m.focus = (m.focus + 1) % focusCount + return m, nil + case "shift+tab": + m.focus = (m.focus + focusCount - 1) % focusCount + return m, nil + case "r": + m.loading = true + return m, m.reload() + case "up": + if m.cursor > 0 { + m.cursor-- + return m, m.loadDetail(m.selectedID()) + } + return m, nil + case "down": + if m.cursor < len(m.rows)-1 { + m.cursor++ + return m, m.loadDetail(m.selectedID()) + } + return m, nil + case "a": + if r := m.selectedTask(); r != nil && r.Issue.AssigneeAgent == "" { + c, id, agent := m.client, r.Issue.ID, defaultAgent + return m, func() tea.Msg { + if _, err := c.Assign(id, agent); err != nil { + return errMsg{err} + } + rows, err := loadRows(c, m.project) + if err != nil { + return errMsg{err} + } + return rowsMsg(rows) + } + } + return m, nil + case "enter": + if r := m.selectedTask(); r != nil { + if err := rememberTask(m.folder, r.Issue.Identifier); err != nil { + m.err = err + return m, nil + } + m.notice = "showing " + r.Issue.Identifier + " in every workspace" + } + return m, nil + case "[": + if m.focus == focusNeeds && m.needsTab > 0 { + m.needsTab-- + } + return m, nil + case "]": + if m.focus == focusNeeds && m.needsTab < 1 { + m.needsTab++ + } + return m, nil + case "esc": + m.notice = "" + return m, nil + } + + case rowsMsg: + m.rows = msg + m.loading = false + m.readAt = time.Now() + m.err = nil + if m.cursor >= len(m.rows) { + m.cursor = max(0, len(m.rows)-1) + } + if id := m.selectedID(); id != "" && id != m.detailID { + return m, m.loadDetail(id) + } + return m, nil + + case boardDetailMsg: + m.detailID = msg.issueID + m.runs = msg.runs + m.cost = msg.cost + m.activity = msg.activity + if msg.err != nil { + m.err = msg.err + } + return m, nil + + case errMsg: + m.err = msg.err + m.loading = false + return m, nil + } + return m, nil +} + +func tickEvery(d time.Duration) tea.Cmd { + return tea.Tick(d, func(t time.Time) tea.Msg { return tickMsg(t) }) +} + +func (m boardModel) 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.\n\nMake it larger, or use Tab and zoom.", + m.width, m.height)) + } + + header := m.headerLine() + footer := m.footerLine() + bodyHeight := m.height - 2 + + leftWidth := max(34, m.width*40/100) + rightWidth := max(30, m.width-leftWidth-1) + + // Left: the task tree. + tree := panel{title: "tasks", width: leftWidth, height: bodyHeight, focused: m.focus == focusTree} + treeLines := m.treeLines(leftWidth - 2) + left := tree.render(treeLines) + + // Right, upper: the summary of the chosen task. + sumHeight := max(10, bodyHeight*58/100) + sum := panel{title: "the chosen task", width: rightWidth, height: sumHeight, focused: m.focus == focusSummary} + // Right, lower: what needs a person. + needsHeight := bodyHeight - sumHeight + needs := panel{ + title: "needs you", width: rightWidth, height: needsHeight, + tabs: []string{"decisions", "activity"}, active: m.needsTab, focused: m.focus == focusNeeds, + } + needsBody := m.needsLines(rightWidth-2, needsHeight-2) + if m.needsTab == 1 { + needsBody = m.activityLines(rightWidth-2, needsHeight-2) + } + + right := lipgloss.JoinVertical(lipgloss.Left, + sum.render(m.summaryLines(rightWidth-2)), + needs.render(needsBody), + ) + + page := lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right) + return strings.Join([]string{header, page, footer}, "\n") +} + +func (m boardModel) headerLine() string { + left := panelTitleStyle().Render("Board") + mutedStyle2().Render(" · "+m.project.Name) + state := "reading…" + if !m.readAt.IsZero() { + state = "read " + m.readAt.Format("15:04:05") + } + if m.loading { + state = "reading…" + } + if m.err != nil { + state = "last read failed" + } + right := mutedStyle2().Render(state) + gap := max(2, m.width-lipgloss.Width(left)-lipgloss.Width(right)-2) + if m.notice != "" { + right = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render(m.notice) + gap = max(2, m.width-lipgloss.Width(left)-lipgloss.Width(right)-2) + } + return left + strings.Repeat(" ", gap) + right +} + +func (m boardModel) footerLine() string { + switch m.focus { + case focusTree: + return hintLine([]string{ + keyHint("↑↓", "choose a task"), + keyHint("enter", "show it everywhere"), + keyHint("a", "assign"), + keyHint("tab", "next panel"), + keyHint("r", "read again"), + keyHint("q", "leave"), + }, m.width) + case focusSummary: + return hintLine([]string{ + keyHint("↑↓", "choose a task"), + keyHint("tab", "next panel"), + keyHint("r", "read again"), + keyHint("q", "leave"), + }, m.width) + default: + return hintLine([]string{ + keyHint("tab", "next panel"), + keyHint("[ ]", "switch tab"), + keyHint("enter", "show this task everywhere"), + keyHint("r", "read again"), + keyHint("q", "leave"), + }, m.width) + } +} + +// treeLines draws the grouped task list. +func (m boardModel) treeLines(width int) []string { + var out []string + if len(m.rows) == 0 && !m.loading { + return []string{mutedStyle2().Render(" no tasks in this project")} + } + index := 0 + for _, g := range groupOrder { + var group []Row + for _, r := range m.rows { + if r.Group == g { + group = append(group, r) + } + } + if len(group) == 0 { + continue + } + out = append(out, groupHeading(fmt.Sprintf("%s %d", strings.ToUpper(g), len(group)))) + for _, r := range group { + out = append(out, m.taskLine(r, index, width)...) + index++ + } + out = append(out, "") + } + return out +} + +func groupHeading(s string) string { + return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(active.Mauve)).Render(s) +} + +func (m boardModel) taskLine(r Row, index, width int) []string { + chosen := index == m.cursor + cursor := " " + firstStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)) + secondStyle := mutedStyle2() + if chosen { + cursor = "▸ " + if m.focus == focusTree { + firstStyle = selectedStyle2() + secondStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Base)) + } else { + firstStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Bold(true) + secondStyle = mutedStyle2() + } + } + + status, _ := shortStatus(r.Issue.Status) + who := r.Who + if who == "" || who == "—" { + who = "unassigned" + } + first := fmt.Sprintf("%s%-8s %-8s %s", cursor, r.Issue.Identifier, status, who) + second := " " + r.Issue.Title + if note := blockerNote(r); note != "" { + second += " " + note + } + + if lipgloss.Width(first) > width { + first = truncateVisible(first, width) + } + if lipgloss.Width(second) > width { + second = truncateVisible(second, width) + } + if chosen { + first, second = padTo(first, width), padTo(second, width) + } + return []string{firstStyle.Render(first), secondStyle.Render(second)} +} + +// shortStatus is the one word a row has room for. +func shortStatus(status string) (string, string) { + switch status { + case "in_progress": + return "running", active.Info + case "blocked": + return "blocked", active.Error + case "in_review": + return "review", active.Warning + case "done": + return "✓ done", active.Success + case "cancelled": + return "✕ gone", active.Overlay + case "backlog": + return "backlog", active.Muted + default: + return "todo", active.Text + } +} + +// isReadyButBlocked is the state that confuses people most: everything it was +// waiting for has finished, and the task still says blocked. +func isReadyButBlocked(r Row) bool { + return r.Blocker != nil && + len(r.Blocker.BlockingIdentities) > 0 && + r.Blocker.UnresolvedCount == 0 && + r.Blocker.AllBlockersDone && + r.Issue.Status == "blocked" +} + +func blockerNote(r Row) string { + if r.Blocker == nil { + return "" + } + if r.Blocker.UnresolvedCount > 0 { + return dimStyle().Render("waits on " + strings.Join(r.Blocker.BlockingIdentities, ", ")) + } + if r.Blocker.AllBlockersDone && r.Issue.Status == "blocked" { + return badge("ready", active.Warning) + } + return "" +} + +func (m boardModel) summaryLines(width int) []string { + r := m.selectedTask() + if r == nil { + return []string{mutedStyle2().Render(" nothing chosen")} + } + var out []string + out = append(out, panelTitleStyle().Render(truncateVisible(r.Issue.Identifier+" "+r.Issue.Title, width))) + out = append(out, "") + who := r.Who + if who == "" || who == "—" { + who = "nobody yet" + } + out = append(out, " "+statusPill(r.Issue.Status)+" "+ + mutedStyle2().Render("assigned to ")+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(who)) + if r.Blocker != nil && len(r.Blocker.BlockingIdentities) > 0 { + out = append(out, " "+mutedStyle2().Render("waits on ")+ + lipgloss.NewStyle().Foreground(lipgloss.Color(active.Warning)).Render(strings.Join(r.Blocker.BlockingIdentities, ", "))) + } else if isReadyButBlocked(*r) { + out = append(out, " "+badge("ready to start", active.Warning)+" "+ + mutedStyle2().Render("its blocker finished, but it is still marked blocked")) + } + out = append(out, "") + + // The command that decides the task is finished, from the task itself. + if cmd := verifyFromDescription(r.Issue.Description); cmd != "" { + out = append(out, " "+mutedStyle2().Render("done when")) + for _, ln := range wrap(cmd, width-4) { + out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Info)).Render(ln)) + } + } + if len(m.runs) > 0 { + run := m.runs[0] + line := " " + mutedStyle2().Render("last run") + " " + + lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(run.Status+" · "+run.Source) + if run.StartedAt != "" { + if t, err := time.Parse(time.RFC3339, run.StartedAt); err == nil { + line += mutedStyle2().Render(" · " + t.Local().Format("15:04")) + } + } + out = append(out, line) + } else { + out = append(out, " "+mutedStyle2().Render("never run")) + } + if m.cost.RunCount > 0 { + out = append(out, " "+mutedStyle2().Render("cost")+" "+ + lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render( + fmt.Sprintf("%d cent(s) over %d run(s), %d tokens out", m.cost.CostCents, m.cost.RunCount, m.cost.OutputTokens))) + } + return out +} + +func (m boardModel) needsLines(width, height int) []string { + var out []string + n := 0 + add := func(r Row, why string) { + n++ + if len(out) < height { + out = append(out, " "+badge(r.Issue.Identifier, active.Accent)+" "+lipgloss.NewStyle(). + Foreground(lipgloss.Color(active.Text)).Render(truncateVisible(r.Issue.Title, width-14))) + out = append(out, " "+mutedStyle2().Render(why)) + } + } + for _, r := range m.rows { + if isReadyButBlocked(r) { + add(r, "its blocker finished; it is still marked blocked") + } + } + for _, r := range m.rows { + if r.Issue.Status == "in_review" { + add(r, "waiting for a review") + } + } + for _, r := range m.rows { + if r.Issue.AssigneeUser != "" && r.Issue.Status != "done" && r.Issue.Status != "cancelled" { + add(r, "assigned to you") + } + } + if n == 0 { + return []string{" " + lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render("nothing needs you")} + } + if len(out) < height { + out = append(out, "") + out = append(out, dimStyle().Render(fmt.Sprintf(" %s waiting", fmtCount(n, "thing", "things")))) + } + return out +} + +func (m boardModel) activityLines(width, height int) []string { + if m.detailID == "" || len(m.activity) == 0 { + return []string{" " + mutedStyle2().Render("nothing recorded for this task yet")} + } + var out []string + for i, a := range m.activity { + if len(out) >= height-1 || i >= 10 { + break + } + when := a.CreatedAt + if t, err := time.Parse(time.RFC3339, a.CreatedAt); err == nil { + when = t.Local().Format("15:04") + } + who := a.ActorType + switch who { + case "user": + who = "you" + case "agent": + who = "agent" + case "system": + who = "system" + } + out = append(out, " "+dimStyle().Render(when)+" "+ + mutedStyle2().Render(fmt.Sprintf("%-7s", who))+" "+ + lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(a.Action)) + } + return out +} + +var verifyRE = regexp.MustCompile(`(?is)Done when this command succeeds:\s*\n+\s*(.+)`) + +func verifyFromDescription(desc string) string { + if m := verifyRE.FindStringSubmatch(desc); m != nil { + return strings.TrimSpace(m[1]) + } + return "" +} + +// rememberTask writes the chosen task into the project's harness file, so the +// other workspaces follow without being told twice. +func rememberTask(folder, identifier string) error { + path := filepath.Join(folder, ".harness", "harness.toml") + b, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("cannot remember the choice: %w", err) + } + line := fmt.Sprintf("watching = %q", identifier) + re := regexp.MustCompile(`(?m)^watching\s*=\s*".*"`) + if re.Match(b) { + b = re.ReplaceAll(b, []byte(line)) + } else { + b = append(b, []byte("\n"+line+"\n")...) + } + return os.WriteFile(path, b, 0o644) +} diff --git a/tui/frame.go b/tui/frame.go new file mode 100644 index 0000000..3178519 --- /dev/null +++ b/tui/frame.go @@ -0,0 +1,241 @@ +package main + +// The frame every panel is drawn in, and the small pieces that sit inside one. +// +// A frame is drawn by hand rather than with lipgloss borders, because the title +// and the tab names live inside the top border and must line up with the body +// exactly, whatever the width. + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +type panel struct { + title string + tabs []string // empty when the panel has one view + active int + focused bool + width int + height int +} + +func (p panel) bodyHeight() int { return max(1, p.height-2) } + +func (p panel) bodyWidth() int { return max(1, p.width-2) } + +// render wraps already-styled lines in the frame. +func (p panel) render(lines []string) string { + bw, bh := p.bodyWidth(), p.bodyHeight() + + border := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Surface)) + mark := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Muted)) + if p.focused { + border = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)) + mark = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Bold(true) + } + + // Top edge: the title, then the tabs, then the focus mark on the right. + // When there is not enough room, the focus mark goes first and the title is + // cut second, because a missing word reads better than a broken frame. + title := " " + p.title + " " + tabs := "" + if len(p.tabs) > 1 { + var b strings.Builder + for i, t := range p.tabs { + if i > 0 { + b.WriteString(" ") + } + if i == p.active { + b.WriteString(tabActiveStyle().Render(" " + t + " ")) + } else { + b.WriteString(tabStyle().Render(" " + t + " ")) + } + } + tabs = " " + b.String() + } + right := "" + if p.focused { + right = mark.Render("·focus· ") + } + + // Two corners and a lead-in take three columns. + avail := p.width - 3 + if lipgloss.Width(title)+lipgloss.Width(tabs)+lipgloss.Width(right) > avail { + right = "" + } + if w := lipgloss.Width(title) + lipgloss.Width(tabs); w > avail { + if aw := lipgloss.Width(tabs); aw < avail { + title = truncateVisible(title, avail-aw) + } else { + title = "" + tabs = truncateVisible(tabs, avail) + } + } + used := lipgloss.Width(title) + lipgloss.Width(tabs) + lipgloss.Width(right) + fill := max(0, avail-used) + top := border.Render("╭─") + panelTitleStyle().Render(title) + tabs + + border.Render(strings.Repeat("─", fill)) + right + border.Render("╮") + + // Body: every line exactly the inner width, no exceptions. + out := make([]string, 0, p.height) + out = append(out, top) + side := border.Render("│") + for i := 0; i < bh; i++ { + line := "" + if i < len(lines) { + line = lines[i] + } + out = append(out, side+padTo(line, bw)+side) + } + out = append(out, border.Render("╰"+strings.Repeat("─", p.width-2)+"╯")) + return strings.Join(out, "\n") +} + +// padTo makes a line exactly w columns wide, cutting it if it is too long. +// Widths are measured on the visible text, so styled strings work. +func padTo(s string, w int) string { + visible := lipgloss.Width(s) + if visible > w { + s = truncateVisible(s, w) + visible = lipgloss.Width(s) + } + if visible < w { + return s + strings.Repeat(" ", w-visible) + } + return s +} + +// truncateVisible cuts a styled string to w columns, keeping the styling of the +// runes it keeps. It works rune by rune because a byte slice would split an +// escape sequence and break the terminal. +func truncateVisible(s string, w int) string { + var out strings.Builder + visible, i := 0, 0 + for i < len(s) && visible < w-1 { + if s[i] == 0x1b { + // copy the whole escape sequence + j := i + 1 + if j < len(s) && s[j] == '[' { + j++ + for j < len(s) && (s[j] < '@' || s[j] > '~') { + j++ + } + if j < len(s) { + j++ + } + } + out.WriteString(s[i:j]) + i = j + continue + } + r := []rune(s[i:]) + out.WriteRune(r[0]) + i += len(string(r[0])) + visible++ + } + out.WriteString("…") + // close any styling that is still open + out.WriteString("\x1b[0m") + return out.String() +} + +func panelTitleStyle() lipgloss.Style { + return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(active.Text)) +} + +func tabStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color(active.Muted)) +} + +func tabActiveStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(active.Base)). + Background(lipgloss.Color(active.Accent)). + Bold(true) +} + +func mutedStyle2() lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color(active.Muted)) +} + +func dimStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color(active.Overlay)) +} + +func selectedStyle2() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(active.Base)). + Background(lipgloss.Color(active.Accent)). + Bold(true) +} + +// statusPill is a state in two letters of shape and one of colour, so it still +// reads in a monochrome terminal. +func statusPill(status string) string { + label, colour := statusLabel(status) + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(colour)). + Render(label) +} + +func statusLabel(status string) (string, string) { + switch status { + case "in_progress": + return "▶ running ", active.Info + case "blocked": + return "■ blocked ", active.Error + case "in_review": + return "◆ review ", active.Warning + case "done": + return "✓ done ", active.Success + case "cancelled": + return "✕ cancel ", active.Overlay + case "backlog": + return "· backlog ", active.Muted + default: + return "· todo ", active.Text + } +} + +func badge(text, colour string) string { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(colour)). + Render("[" + text + "]") +} + +func keyHint(keys, what string) string { + k := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Bold(true).Render(keys) + w := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Muted)).Render(what) + return k + " " + w +} + +// hintLine joins key hints with a dot, dropping the ones that do not apply. +func hintLine(hints []string, width int) string { + joined := strings.Join(hints, dimStyle().Render(" · ")) + if lipgloss.Width(joined) > width { + return truncateVisible(joined, width) + } + return joined +} + +// stack joins rendered blocks vertically, padding each to the same width so a +// short block does not leave the frame ragged. +func stack(width int, blocks ...string) []string { + var out []string + for _, b := range blocks { + for _, ln := range strings.Split(b, "\n") { + out = append(out, padTo(ln, width)) + } + } + return out +} + +func fmtCount(n int, one, many string) string { + if n == 1 { + return fmt.Sprintf("%d %s", n, one) + } + return fmt.Sprintf("%d %s", n, many) +} diff --git a/tui/frame_test.go b/tui/frame_test.go new file mode 100644 index 0000000..d78cee3 --- /dev/null +++ b/tui/frame_test.go @@ -0,0 +1,58 @@ +package main + +// The frame is the one place where an off-by-one becomes a broken screen, so it +// is checked rather than eyeballed: every rendered line must be exactly the +// width asked for, at every size, with and without tabs. + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" +) + +func lines(s string) []string { return strings.Split(s, "\n") } + +func TestFrameEveryLineIsExactWidth(t *testing.T) { + for w := 10; w <= 90; w++ { + for h := 3; h <= 20; h++ { + for _, tabs := range [][]string{nil, {"one", "two"}, {"a"}} { + for _, focused := range []bool{false, true} { + p := panel{title: "tasks", tabs: tabs, width: w, height: h, focused: focused} + out := p.render([]string{"first", "second"}) + got := lines(out) + if len(got) != h { + t.Fatalf("width %d height %d: got %d lines, want %d", w, h, len(got), h) + } + for i, ln := range got { + if lipgloss.Width(ln) != w { + t.Fatalf("width %d height %d tab=%v focus=%v line %d: width %d (%q)", + w, h, tabs, focused, i, lipgloss.Width(ln), ln) + } + } + } + } + } + } +} + +func TestFrameBodyIsClippedNotWrapped(t *testing.T) { + p := panel{title: "t", width: 20, height: 4} + out := p.render([]string{"a line that is much too long for the frame"}) + for i, ln := range lines(out) { + if lipgloss.Width(ln) != 20 { + t.Fatalf("line %d is %d wide, want 20: %q", i, lipgloss.Width(ln), ln) + } + } +} + +func TestTruncateVisibleKeepsStylingAndWidth(t *testing.T) { + styled := lipgloss.NewStyle().Bold(true).Render("abcdefghij") + got := truncateVisible(styled, 5) + if lipgloss.Width(got) > 5 { + t.Fatalf("truncated width %d, want at most 5: %q", lipgloss.Width(got), got) + } + if !strings.Contains(got, "…") { + t.Fatalf("truncation should say it truncated: %q", got) + } +} diff --git a/tui/main.go b/tui/main.go index b8bf884..452f391 100644 --- a/tui/main.go +++ b/tui/main.go @@ -15,13 +15,19 @@ import ( "paperclip-harness/tui/paperclip" ) +// defaultAgent is Builder, the agent this project assigns work to. +const defaultAgent = "403de1f7-645c-4d35-bc7e-19b01d77bacc" + func main() { - screen := flag.String("screen", "tree", "which screen to run: tree, reply, new or log") - agent := flag.String("agent", "403de1f7-645c-4d35-bc7e-19b01d77bacc", "agent id to assign to, default Builder") + screen := flag.String("screen", "board", "which screen to run: board, tree, reply, new or log") + 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") + 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) client, err := paperclip.New() if err != nil { @@ -34,6 +40,18 @@ func main() { var m tea.Model switch *screen { + case "board": + proj, err := client.ResolveProject(*project) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + dir := *folder + if dir == "" { + dir, _ = os.Getwd() + } + m = newBoardModel(client, proj, dir) + case "tree": proj, err := client.ResolveProject(*project) if err != nil { diff --git a/tui/model.go b/tui/model.go index b42be3d..58ef2a0 100644 --- a/tui/model.go +++ b/tui/model.go @@ -232,11 +232,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } var ( - titleStyle = lipgloss.NewStyle().Bold(true) - groupStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) + titleStyle = lipgloss.NewStyle().Bold(true) + groupStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("12")) - mutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - blockedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + mutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + blockedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) ) // minWidth and minHeight are the smallest box that still shows something diff --git a/tui/paperclip/client.go b/tui/paperclip/client.go index a6bc340..252f6b0 100644 --- a/tui/paperclip/client.go +++ b/tui/paperclip/client.go @@ -189,9 +189,9 @@ type BlockerInfo struct { type blockerResponse struct { Diagnosis string `json:"diagnosis"` Readiness struct { - AllBlockersDone bool `json:"allBlockersDone"` - DependencyReady bool `json:"isDependencyReady"` - UnresolvedBlockerCount int `json:"unresolvedBlockerCount"` + AllBlockersDone bool `json:"allBlockersDone"` + DependencyReady bool `json:"isDependencyReady"` + UnresolvedBlockerCount int `json:"unresolvedBlockerCount"` } `json:"readiness"` Blockers []struct { Identifier string `json:"identifier"` @@ -350,3 +350,19 @@ func (c *Client) Issue(issueID string) (Issue, error) { err := c.do("GET", "/api/issues/"+issueID, nil, &out) return out, err } + +// Cost is what one task has cost so far. +type Cost struct { + CostCents int `json:"costCents"` + InputTokens int `json:"inputTokens"` + OutputTokens int `json:"outputTokens"` + RunCount int `json:"runCount"` + RuntimeMs int `json:"runtimeMs"` +} + +// CostSummary returns the task's recorded spend. +func (c *Client) CostSummary(issueID string) (Cost, error) { + var out Cost + err := c.do("GET", "/api/issues/"+issueID+"/cost-summary", nil, &out) + return out, err +} diff --git a/tui/theme.go b/tui/theme.go new file mode 100644 index 0000000..105c7a4 --- /dev/null +++ b/tui/theme.go @@ -0,0 +1,82 @@ +package main + +// One palette, two variants, named by role. +// +// Catppuccin, because it is the scheme people already run in this terminal and +// because its two variants are designed together: the same roles keep the same +// meaning in light and dark. Panels ask for a role and never for a colour, so a +// light terminal needs no second design. + +import ( + "os" + "strings" +) + +type palette struct { + Base string + Surface string + Overlay string + Text string + Muted string + Accent string + Info string + Success string + Warning string + Error string + Mauve string + Selection string +} + +var mocha = palette{ + Base: "#1e1e2e", + Surface: "#313244", + Overlay: "#6c7086", + Text: "#cdd6f4", + Muted: "#a6adc8", + Accent: "#89b4fa", + Info: "#94e2d5", + Success: "#a6e3a1", + Warning: "#f9e2af", + Error: "#f38ba8", + Mauve: "#cba6f7", + Selection: "#45475a", +} + +var latte = palette{ + Base: "#eff1f5", + Surface: "#ccd0da", + Overlay: "#8c8fa1", + Text: "#4c4f69", + Muted: "#6c6f85", + Accent: "#1e66f5", + Info: "#179299", + Success: "#40a02b", + Warning: "#df8e1d", + Error: "#d20f39", + Mauve: "#8839ef", + Selection: "#bcc0cc", +} + +// active is the palette this run draws with. It is chosen once, at startup, so +// nothing downstream has to think about it. +var active = mocha + +func choosePalette(force string) { + switch strings.ToLower(force) { + case "light": + active = latte + case "dark": + active = mocha + default: + // COLORFGBG is what many terminals export: "foreground;background". + if v := os.Getenv("COLORFGBG"); v != "" { + parts := strings.Split(v, ";") + bg := parts[len(parts)-1] + // A light background is one of the high numbers in the 16-colour + // range; anything above 6 is light in practice. + if bg == "15" || bg == "7" { + active = latte + } + } + } +}