Board workspace with real panels, tabs and theme; Nerd Font declared for all three machines
This commit is contained in:
@@ -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]]
|
- 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]]
|
- 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]]
|
- 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]]
|
||||||
|
|||||||
@@ -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]]
|
- 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]]
|
- 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]]
|
- 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]]
|
||||||
|
|||||||
@@ -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]]
|
- 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]]
|
- 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]]
|
- 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]]
|
||||||
|
|||||||
@@ -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]]
|
||||||
@@ -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]]
|
- 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]]
|
- 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]]
|
- 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]]
|
||||||
|
|||||||
@@ -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]]
|
||||||
@@ -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]]
|
||||||
BIN
bin/harness
BIN
bin/harness
Binary file not shown.
@@ -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
|
worktree is Paperclip's job after all, once the instance allows it, and the terminal
|
||||||
program never needs to run `git worktree` itself.
|
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
|
## Toolchain
|
||||||
|
|
||||||
- **Go is installed** (2026-11-10), by `nix profile install nixpkgs#go` → `go1.26.5` at
|
- **Go is installed** (2026-11-10), by `nix profile install nixpkgs#go` → `go1.26.5` at
|
||||||
|
|||||||
@@ -221,12 +221,12 @@ with focus where it was.
|
|||||||
**27. Glyphs.** A small set used consistently: running, waiting, blocked, done, failed,
|
**27. Glyphs.** A small set used consistently: running, waiting, blocked, done, failed,
|
||||||
unread. Never the only carrier of meaning.
|
unread. Never the only carrier of meaning.
|
||||||
|
|
||||||
Measured on this machine, and this matters: TUIOS offers four glyph sets — `ascii`,
|
Measured, then fixed: no Nerd Font was installed and Starship was not either. JetBrains
|
||||||
`default`, `heavy` and `unicode` — and `default` is active. Starship is **not installed
|
Mono Nerd Font is now declared in `/etc/nixos/configuration.nix` (so `.13`, `.27` and
|
||||||
here**, and no Nerd Font is present (`fc-list` finds none), so an icon set that expects a
|
`.51` all get it on rebuild) and Ghostty's `font-family` points at it. Until that rebuild,
|
||||||
patched font would draw empty boxes. Until a Nerd Font is installed deliberately, the
|
TUIOS's `default` glyph set — ordinary Unicode such as `╭ ╮ ✕ ▎ ● › …` — is what renders,
|
||||||
interface uses TUIOS's `default` set, which is ordinary Unicode: `╭ ╮ ✕ ▎ ● › …`, with
|
with `ascii` as the fallback. The program therefore uses plain Unicode symbols today and
|
||||||
`ascii` as the fallback.
|
may adopt richer glyphs once the font is present, but never depends on them.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
568
tui/board.go
Normal file
568
tui/board.go
Normal 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
241
tui/frame.go
Normal 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
58
tui/frame_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
22
tui/main.go
22
tui/main.go
@@ -15,13 +15,19 @@ import (
|
|||||||
"paperclip-harness/tui/paperclip"
|
"paperclip-harness/tui/paperclip"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// defaultAgent is Builder, the agent this project assigns work to.
|
||||||
|
const defaultAgent = "403de1f7-645c-4d35-bc7e-19b01d77bacc"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
screen := flag.String("screen", "tree", "which screen to run: tree, reply, new or log")
|
screen := flag.String("screen", "board", "which screen to run: board, tree, reply, new or log")
|
||||||
agent := flag.String("agent", "403de1f7-645c-4d35-bc7e-19b01d77bacc", "agent id to assign to, default Builder")
|
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")
|
project := flag.String("project", "", "project id or url key, for the tree screen")
|
||||||
issue := flag.String("issue", "", "task id or identifier such as AUD-160, for the reply screen")
|
issue := flag.String("issue", "", "task id or identifier such as AUD-160, for the reply screen")
|
||||||
dump := flag.Bool("dump", false, "print the tree as plain text and exit (no full-screen interface)")
|
dump := flag.Bool("dump", false, "print the tree as plain text and exit (no full-screen interface)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
choosePalette(*light)
|
||||||
|
|
||||||
client, err := paperclip.New()
|
client, err := paperclip.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -34,6 +40,18 @@ func main() {
|
|||||||
|
|
||||||
var m tea.Model
|
var m tea.Model
|
||||||
switch *screen {
|
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":
|
case "tree":
|
||||||
proj, err := client.ResolveProject(*project)
|
proj, err := client.ResolveProject(*project)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -232,11 +232,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
titleStyle = lipgloss.NewStyle().Bold(true)
|
titleStyle = lipgloss.NewStyle().Bold(true)
|
||||||
groupStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
|
groupStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
|
||||||
selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("12"))
|
selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("12"))
|
||||||
mutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
|
mutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
|
||||||
blockedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
|
blockedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
|
||||||
)
|
)
|
||||||
|
|
||||||
// minWidth and minHeight are the smallest box that still shows something
|
// minWidth and minHeight are the smallest box that still shows something
|
||||||
|
|||||||
@@ -189,9 +189,9 @@ type BlockerInfo struct {
|
|||||||
type blockerResponse struct {
|
type blockerResponse struct {
|
||||||
Diagnosis string `json:"diagnosis"`
|
Diagnosis string `json:"diagnosis"`
|
||||||
Readiness struct {
|
Readiness struct {
|
||||||
AllBlockersDone bool `json:"allBlockersDone"`
|
AllBlockersDone bool `json:"allBlockersDone"`
|
||||||
DependencyReady bool `json:"isDependencyReady"`
|
DependencyReady bool `json:"isDependencyReady"`
|
||||||
UnresolvedBlockerCount int `json:"unresolvedBlockerCount"`
|
UnresolvedBlockerCount int `json:"unresolvedBlockerCount"`
|
||||||
} `json:"readiness"`
|
} `json:"readiness"`
|
||||||
Blockers []struct {
|
Blockers []struct {
|
||||||
Identifier string `json:"identifier"`
|
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)
|
err := c.do("GET", "/api/issues/"+issueID, nil, &out)
|
||||||
return out, err
|
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
82
tui/theme.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user