Board workspace with real panels, tabs and theme; Nerd Font declared for all three machines

This commit is contained in:
Sam
2026-11-10 13:45:33 +11:00
parent 5b829000ae
commit 1bfb9b05ed
17 changed files with 1424 additions and 15 deletions

View File

@@ -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 <!-- same-turn-2026-11-10 02:26:18 -->
- 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]]

View File

@@ -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 <!-- same-turn-2026-11-10 02:26:18 -->
- 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]]

View File

@@ -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 <!-- same-turn-2026-11-10 02:26:18 -->
- 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]]

View File

@@ -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 <!-- same-turn-2026-11-10 02:26:18 -->
- 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]]

View File

@@ -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 <!-- same-turn-2026-11-10 02:26:18 -->
- 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]]

View File

@@ -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 <!-- same-turn-2026-11-10 02:26:18 -->
- 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]]

View File

@@ -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 <!-- same-turn-2026-11-10 02:26:18 -->
- 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]]

Binary file not shown.

View File

@@ -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

View File

@@ -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.
---

568
tui/board.go Normal file
View File

@@ -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)
}

241
tui/frame.go Normal file
View File

@@ -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)
}

58
tui/frame_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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
}

82
tui/theme.go Normal file
View File

@@ -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
}
}
}
}