Conversation and Author screens styled to match; glyph policy chosen from measurement
This commit is contained in:
@@ -168,3 +168,40 @@ I build the Board workspace first, and the visual language with it — frame, fo
|
||||
- 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]]
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Update (2026-11-10 03:01:16)
|
||||
|
||||
Repo: /home/sam/paseo/projects/paperclip-harness, binary bin/harness.
|
||||
|
||||
Screens/workspaces and behavior:
|
||||
- `-screen <name> -folder <dir>` launches a given screen against a project folder.
|
||||
- A project folder carries project state under `.paperclip/`, including worktrees at `.paperclip/worktrees/<task-slug>/`, and the currently chosen task lives in `.harness/harness.toml`.
|
||||
- The chosen task moves between tasks (e.g. AUD-160 -> AUD-165) when harness.toml changes; the Work panel re-reads harness.toml every 5 s.
|
||||
- Board screen: three-column layout above 140 cols (tasks, chosen task, needs you/decisions/activity); two columns below 140.
|
||||
- Work screen has tabs: runs, change, facts.
|
||||
- runs: column table of every run (state, woken by, started, ended, took) plus activity list in local time.
|
||||
- change: worktree, branch, base, commits, changed-file stats, then diff.
|
||||
- facts: folder, branch, base, status and cost.
|
||||
- Reply and new-task panels still use the older plain look and are the next piece to align (frame/tab bar/footer already exist).
|
||||
- Repo also has its own `bin/harness -screen ... -folder /tmp/pc-sandbox-...` for sandbox runs.
|
||||
|
||||
Theme/styling conventions:
|
||||
- Theme file lives at `~/.config/tuios/config.toml`; set `theme = 'catppuccin_mocha'`. (`tuios look` is a saved appearance bundle, unrelated to theme; "no look is applied" does not mean no theme.)
|
||||
- Program supports `-theme light` (Catppuccin Latte variant).
|
||||
- Catppuccin Mocha is default.
|
||||
|
||||
## Evidence
|
||||
|
||||
- Confidence: 80%
|
||||
- Reason: Durable component overview evidenced by screen output and final answer.
|
||||
|
||||
## Related learned memories <!-- same-turn-2026-11-10 03:01:16 -->
|
||||
|
||||
- context: [[packs/paperclip-harness/20-context/home|paperclip-harness TUI harness]]
|
||||
- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|Harness pitfalls: git failure, styled-text padding, tuios set-config]]
|
||||
- action: [[packs/paperclip-harness/40-actions/2026-11-10-wrote-docs-ui-layout-md-and-three-live-layout-mockups|Built Work workspace and polished Board styling (4e3c955)]]
|
||||
- session: [[packs/paperclip-harness/80-sessions/rich-discovery-config-2026-11-10-03-01-16|Rich discovery: .config 2026-11-10 03:01:16]]
|
||||
- context: [[packs/paperclip-harness/20-context/tmp|.config]]
|
||||
|
||||
@@ -92,3 +92,86 @@ Say which layout you want, and I will build it — starting with the width-drive
|
||||
- session: [[packs/paperclip-harness/80-sessions/rich-discovery-tmp-2026-11-10-01-47-01|Rich discovery: tmp 2026-11-10 01:47:01]]
|
||||
- context: [[packs/paperclip-harness/20-context/tmp|tmp]]
|
||||
- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|tmp repository structure and configuration patterns]]
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Update (2026-11-10 03:01:16)
|
||||
|
||||
## Rich discovery update (2026-11-10 03:01:16)
|
||||
|
||||
Three things answered, and the Work workspace is built.
|
||||
|
||||
## Vim keys: yes, as aliases
|
||||
|
||||
`j` and `k` now move in both the Board and Work screens, and `h` and `l` switch tabs. They are aliases, not the primary keys, because whether they reach the program depends on TUIOS's mode: in window-management mode TUIOS keeps `h j k l z n`, and in terminal mode the program gets them. Use whichever arrives, and the footer says `↑↓ or j k` so nobody has to guess. Arrow keys stay the primary path.
|
||||
|
||||
## The theme confusion, cleared up
|
||||
|
||||
You changed something and it worked; I checked the wrong thing. `tuios look` is a saved *appearance bundle*, and "no look is applied" says nothing about the theme. The real answer was in `~/.config/tuios/config.toml`:
|
||||
|
||||
```
|
||||
theme = ''
|
||||
```
|
||||
|
||||
So the config had no theme, which is why this session's chrome looked default. I have now set it:
|
||||
|
||||
```
|
||||
theme = 'catppuccin_mocha'
|
||||
```
|
||||
|
||||
New sessions pick it up. The running one needs a restart, and `tuios set-config theme catppuccin_mocha` refused with *"set_config came from host nixos-desktop and would change this machine, not the session"*, so I left that alone rather than fight it. Our program is drawn in Catppuccin Mocha with a Latte variant (`-theme light`), so once the session restarts the chrome and the panels will match.
|
||||
|
||||
## Styling changes
|
||||
|
||||
- The board now reads at a glance: group headings are a label, a rule and a count (`DONE───────── 6`), rows are two lines, content sits one column inside the frame.
|
||||
- Three-column layout above 140 columns (tasks, chosen task, needs you), two columns below, so a wide terminal is used instead of wasted.
|
||||
- The Work screen has a tab bar, coloured run states, and diff lines coloured by kind — additions green, deletions red, hunk headers blue.
|
||||
- The footer shows only keys that apply in the current panel, and focus is a rounded bright edge plus `·focus·`.
|
||||
|
||||
## Work workspace
|
||||
|
||||
One screen, three tabs, tested on real data:
|
||||
|
||||
- **runs** — a column table of every run with what woke it, start, end and duration, then the activity list with times in local time.
|
||||
- **change** — the task's worktree, branch, base, commits, changed-file counts, then the diff.
|
||||
- **facts** — folder, branch, base, status and cost.
|
||||
|
||||
It follows the Board without being told: the chosen task lives in the project's `.harness/harness.toml`, and the Work panel re-reads it every five seconds. I watched it switch from `AUD-160` to `AUD-165` after only that file changed, then show `520bc2d AUD-165: add cleanup.sh` and the actual diff.
|
||||
|
||||
Two real bugs found by using it: a git that fails because the folder was removed now says *"the task's folder … no longer exists"* instead of a raw exit code, and a table whose columns were ragged because styled text was being padded by bytes — pad the plain text first, colour it second.
|
||||
|
||||
Screenshots: `/tmp/mockups/board-live.png` and `/tmp/mockups/work-live.png`. Committed as `4e3c955`.
|
||||
|
||||
## Still to do
|
||||
|
||||
The reply and new-task panels still use the older plain look — they need the same frame, tab bar and footer before the set is consistent. That is the next piece, and it is small because the frame already exists.
|
||||
|
||||
And the font rebuild is still yours to run:
|
||||
|
||||
```
|
||||
sudo nixos-rebuild switch --flake /etc/nixos#nixos-desktop
|
||||
```
|
||||
|
||||
## Source paths observed
|
||||
|
||||
- `home/sam/paseo/projects/paperclip-harness/bin/harness`
|
||||
- `tmp/pc-sandbox-2026-11-10/.paperclip/worktrees/AUD-165-review-gate-pro`
|
||||
- `tmp/pc-sandbox-2026-11-10/.paperclip/worktrees/AUD-165-review-gate-`
|
||||
- `tmp/mockups/C.png`
|
||||
- `tmp/mockups/work-live.png`
|
||||
- `.config/tuios/config.toml`
|
||||
- `tmp/mockups/board-live.png`
|
||||
|
||||
## Evidence
|
||||
|
||||
- Confidence: 84%
|
||||
- Reason: Update component/repository context with detailed discovered structure and configuration
|
||||
|
||||
## Related learned memories <!-- same-turn-2026-11-10 03:01:16 -->
|
||||
|
||||
- context: [[packs/paperclip-harness/20-context/home|paperclip-harness TUI harness]]
|
||||
- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|Harness pitfalls: git failure, styled-text padding, tuios set-config]]
|
||||
- action: [[packs/paperclip-harness/40-actions/2026-11-10-wrote-docs-ui-layout-md-and-three-live-layout-mockups|Built Work workspace and polished Board styling (4e3c955)]]
|
||||
- session: [[packs/paperclip-harness/80-sessions/rich-discovery-config-2026-11-10-03-01-16|Rich discovery: .config 2026-11-10 03:01:16]]
|
||||
- context: [[packs/paperclip-harness/20-context/tmp|.config]]
|
||||
|
||||
@@ -143,3 +143,88 @@ Follow-up: build the Board workspace and visual language first, have the user re
|
||||
- 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]]
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Update (2026-11-10 03:01:16)
|
||||
|
||||
Delivered: Work workspace with tabs (runs/change/facts), responsive Board, theme config, and vim-key aliases in the harness TUI.
|
||||
|
||||
Artifacts:
|
||||
- Commit 4e3c955: Work workspace with runs, change and facts tabs; vim keys; responsive Board; theme file.
|
||||
- Theme applied by setting `theme = 'catppuccin_mocha'` in `~/.config/tuios/config.toml`.
|
||||
- Verified on real data: the Work panel followed the chosen task from AUD-160 to AUD-165 after only `.harness/harness.toml` changed, then showed commit `520bc2d AUD-165: add cleanup.sh` and the diff.
|
||||
- Screenshots at `/tmp/mockups/board-live.png` and `/tmp/mockups/work-live.png`.
|
||||
|
||||
Follow-ups:
|
||||
- Next piece: align reply and new-task panels with the same frame, tab bar and footer; small because the frame exists.
|
||||
- Still with the user: `sudo nixos-rebuild switch --flake /etc/nixos#nixos-desktop` for the font rebuild.
|
||||
|
||||
## Detailed deliverable from completed turn
|
||||
|
||||
Three things answered, and the Work workspace is built.
|
||||
|
||||
## Vim keys: yes, as aliases
|
||||
|
||||
`j` and `k` now move in both the Board and Work screens, and `h` and `l` switch tabs. They are aliases, not the primary keys, because whether they reach the program depends on TUIOS's mode: in window-management mode TUIOS keeps `h j k l z n`, and in terminal mode the program gets them. Use whichever arrives, and the footer says `↑↓ or j k` so nobody has to guess. Arrow keys stay the primary path.
|
||||
|
||||
## The theme confusion, cleared up
|
||||
|
||||
You changed something and it worked; I checked the wrong thing. `tuios look` is a saved *appearance bundle*, and "no look is applied" says nothing about the theme. The real answer was in `~/.config/tuios/config.toml`:
|
||||
|
||||
```
|
||||
theme = ''
|
||||
```
|
||||
|
||||
So the config had no theme, which is why this session's chrome looked default. I have now set it:
|
||||
|
||||
```
|
||||
theme = 'catppuccin_mocha'
|
||||
```
|
||||
|
||||
New sessions pick it up. The running one needs a restart, and `tuios set-config theme catppuccin_mocha` refused with *"set_config came from host nixos-desktop and would change this machine, not the session"*, so I left that alone rather than fight it. Our program is drawn in Catppuccin Mocha with a Latte variant (`-theme light`), so once the session restarts the chrome and the panels will match.
|
||||
|
||||
## Styling changes
|
||||
|
||||
- The board now reads at a glance: group headings are a label, a rule and a count (`DONE───────── 6`), rows are two lines, content sits one column inside the frame.
|
||||
- Three-column layout above 140 columns (tasks, chosen task, needs you), two columns below, so a wide terminal is used instead of wasted.
|
||||
- The Work screen has a tab bar, coloured run states, and diff lines coloured by kind — additions green, deletions red, hunk headers blue.
|
||||
- The footer shows only keys that apply in the current panel, and focus is a rounded bright edge plus `·focus·`.
|
||||
|
||||
## Work workspace
|
||||
|
||||
One screen, three tabs, tested on real data:
|
||||
|
||||
- **runs** — a column table of every run with what woke it, start, end and duration, then the activity list with times in local time.
|
||||
- **change** — the task's worktree, branch, base, commits, changed-file counts, then the diff.
|
||||
- **facts** — folder, branch, base, status and cost.
|
||||
|
||||
It follows the Board without being told: the chosen task lives in the project's `.harness/harness.toml`, and the Work panel re-reads it every five seconds. I watched it switch from `AUD-160` to `AUD-165` after only that file changed, then show `520bc2d AUD-165: add cleanup.sh` and the actual diff.
|
||||
|
||||
Two real bugs found by using it: a git that fails because the folder was removed now says *"the task's folder … no longer exists"* instead of a raw exit code, and a table whose columns were ragged because styled text was being padded by bytes — pad the plain text first, colour it second.
|
||||
|
||||
Screenshots: `/tmp/mockups/board-live.png` and `/tmp/mockups/work-live.png`. Committed as `4e3c955`.
|
||||
|
||||
## Still to do
|
||||
|
||||
The reply and new-task panels still use the older plain look — they need the same frame, tab bar and footer before the set is consistent. That is the next piece, and it is small because the frame already exists.
|
||||
|
||||
And the font rebuild is still yours to run:
|
||||
|
||||
```
|
||||
sudo nixos-rebuild switch --flake /etc/nixos#nixos-desktop
|
||||
```
|
||||
|
||||
## Evidence
|
||||
|
||||
- Confidence: 80%
|
||||
- Reason: Completed work with artifacts, validation and follow-up.; enriched with final-answer detail because persistence should be reusable
|
||||
|
||||
## Related learned memories <!-- same-turn-2026-11-10 03:01:16 -->
|
||||
|
||||
- context: [[packs/paperclip-harness/20-context/home|paperclip-harness TUI harness]]
|
||||
- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|Harness pitfalls: git failure, styled-text padding, tuios set-config]]
|
||||
- action: [[packs/paperclip-harness/40-actions/2026-11-10-wrote-docs-ui-layout-md-and-three-live-layout-mockups|Built Work workspace and polished Board styling (4e3c955)]]
|
||||
- session: [[packs/paperclip-harness/80-sessions/rich-discovery-config-2026-11-10-03-01-16|Rich discovery: .config 2026-11-10 03:01:16]]
|
||||
- context: [[packs/paperclip-harness/20-context/tmp|.config]]
|
||||
|
||||
@@ -246,3 +246,106 @@ I build the Board workspace first, and the visual language with it — frame, fo
|
||||
- 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]]
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Update (2026-11-10 03:01:16)
|
||||
|
||||
Bugs found and fixed in paperclip-harness (committed 4e3c955):
|
||||
- If the worktree folder was removed, `git rev-parse --abbrev-ref HEAD` returns exit status 128; the change tab now says "the task's folder … no longer exists" instead of showing raw exit code.
|
||||
- Table columns looked ragged because styled text was being padded by bytes. Rule: pad plain text first, apply colour after.
|
||||
|
||||
Caution on tuios set-config: `tuios set-config theme catppuccin_mocha` fails with "set_config came from host nixos-desktop and would change this machine, not the session". Using it on the live session risks changing the machine config rather than the session. Set `theme` in `~/.config/tuios/config.toml` instead; new sessions pick it up, but the running session needs a restart.
|
||||
|
||||
Screenshots saved: `/tmp/mockups/board-live.png`, `/tmp/mockups/work-live.png`.
|
||||
|
||||
Other note: the font rebuild is still the user's to run: `sudo nixos-rebuild switch --flake /etc/nixos#nixos-desktop`.
|
||||
|
||||
## Evidence
|
||||
|
||||
- Confidence: 80%
|
||||
- Reason: Concrete discoveries/pitfalls for future agents.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Update (2026-11-10 03:01:16)
|
||||
|
||||
## Structural/configuration observations (2026-11-10 03:01:16)
|
||||
|
||||
Three things answered, and the Work workspace is built.
|
||||
|
||||
## Vim keys: yes, as aliases
|
||||
|
||||
`j` and `k` now move in both the Board and Work screens, and `h` and `l` switch tabs. They are aliases, not the primary keys, because whether they reach the program depends on TUIOS's mode: in window-management mode TUIOS keeps `h j k l z n`, and in terminal mode the program gets them. Use whichever arrives, and the footer says `↑↓ or j k` so nobody has to guess. Arrow keys stay the primary path.
|
||||
|
||||
## The theme confusion, cleared up
|
||||
|
||||
You changed something and it worked; I checked the wrong thing. `tuios look` is a saved *appearance bundle*, and "no look is applied" says nothing about the theme. The real answer was in `~/.config/tuios/config.toml`:
|
||||
|
||||
```
|
||||
theme = ''
|
||||
```
|
||||
|
||||
So the config had no theme, which is why this session's chrome looked default. I have now set it:
|
||||
|
||||
```
|
||||
theme = 'catppuccin_mocha'
|
||||
```
|
||||
|
||||
New sessions pick it up. The running one needs a restart, and `tuios set-config theme catppuccin_mocha` refused with *"set_config came from host nixos-desktop and would change this machine, not the session"*, so I left that alone rather than fight it. Our program is drawn in Catppuccin Mocha with a Latte variant (`-theme light`), so once the session restarts the chrome and the panels will match.
|
||||
|
||||
## Styling changes
|
||||
|
||||
- The board now reads at a glance: group headings are a label, a rule and a count (`DONE───────── 6`), rows are two lines, content sits one column inside the frame.
|
||||
- Three-column layout above 140 columns (tasks, chosen task, needs you), two columns below, so a wide terminal is used instead of wasted.
|
||||
- The Work screen has a tab bar, coloured run states, and diff lines coloured by kind — additions green, deletions red, hunk headers blue.
|
||||
- The footer shows only keys that apply in the current panel, and focus is a rounded bright edge plus `·focus·`.
|
||||
|
||||
## Work workspace
|
||||
|
||||
One screen, three tabs, tested on real data:
|
||||
|
||||
- **runs** — a column table of every run with what woke it, start, end and duration, then the activity list with times in local time.
|
||||
- **change** — the task's worktree, branch, base, commits, changed-file counts, then the diff.
|
||||
- **facts** — folder, branch, base, status and cost.
|
||||
|
||||
It follows the Board without being told: the chosen task lives in the project's `.harness/harness.toml`, and the Work panel re-reads it every five seconds. I watched it switch from `AUD-160` to `AUD-165` after only that file changed, then show `520bc2d AUD-165: add cleanup.sh` and the actual diff.
|
||||
|
||||
Two real bugs found by using it: a git that fails because the folder was removed now says *"the task's folder … no longer exists"* instead of a raw exit code, and a table whose columns were ragged because styled text was being padded by bytes — pad the plain text first, colour it second.
|
||||
|
||||
Screenshots: `/tmp/mockups/board-live.png` and `/tmp/mockups/work-live.png`. Committed as `4e3c955`.
|
||||
|
||||
## Still to do
|
||||
|
||||
The reply and new-task panels still use the older plain look — they need the same frame, tab bar and footer before the set is consistent. That is the next piece, and it is small because the frame already exists.
|
||||
|
||||
And the font rebuild is still yours to run:
|
||||
|
||||
```
|
||||
sudo nixos-rebuild switch --flake /etc/nixos#nixos-desktop
|
||||
```
|
||||
|
||||
## Source paths observed
|
||||
|
||||
- `home/sam/paseo/projects/paperclip-harness/bin/harness`
|
||||
- `tmp/pc-sandbox-2026-11-10/.paperclip/worktrees/AUD-165-review-gate-pro`
|
||||
- `tmp/pc-sandbox-2026-11-10/.paperclip/worktrees/AUD-165-review-gate-`
|
||||
- `tmp/mockups/C.png`
|
||||
- `tmp/mockups/work-live.png`
|
||||
- `.config/tuios/config.toml`
|
||||
- `tmp/mockups/board-live.png`
|
||||
|
||||
## Evidence
|
||||
|
||||
- Confidence: 82%
|
||||
- Reason: Captured factual repo structure/configuration patterns from rich discovery
|
||||
|
||||
## Related learned memories <!-- same-turn-2026-11-10 03:01:16 -->
|
||||
|
||||
- context: [[packs/paperclip-harness/20-context/home|paperclip-harness TUI harness]]
|
||||
- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|Harness pitfalls: git failure, styled-text padding, tuios set-config]]
|
||||
- action: [[packs/paperclip-harness/40-actions/2026-11-10-wrote-docs-ui-layout-md-and-three-live-layout-mockups|Built Work workspace and polished Board styling (4e3c955)]]
|
||||
- session: [[packs/paperclip-harness/80-sessions/rich-discovery-config-2026-11-10-03-01-16|Rich discovery: .config 2026-11-10 03:01:16]]
|
||||
- context: [[packs/paperclip-harness/20-context/tmp|.config]]
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
type: session
|
||||
id: session.paperclip-harness.rich-discovery-config-2026-11-10-03-01-16
|
||||
title: Rich discovery: .config 2026-11-10 03:01:16
|
||||
status: active
|
||||
source_of_truth: false
|
||||
freshness: current
|
||||
last_reviewed: 2026-11-10
|
||||
tags:
|
||||
- pack/paperclip-harness
|
||||
- agent-memory/session
|
||||
- autolearn
|
||||
- rich-discovery
|
||||
- repo/config
|
||||
---
|
||||
|
||||
# Rich discovery: .config 2026-11-10 03:01:16
|
||||
|
||||
Sanitized rich discovery snapshot captured automatically from the completed turn.
|
||||
|
||||
|
||||
## Source paths observed
|
||||
|
||||
- `home/sam/paseo/projects/paperclip-harness/bin/harness`
|
||||
- `tmp/pc-sandbox-2026-11-10/.paperclip/worktrees/AUD-165-review-gate-pro`
|
||||
- `tmp/pc-sandbox-2026-11-10/.paperclip/worktrees/AUD-165-review-gate-`
|
||||
- `tmp/mockups/C.png`
|
||||
- `tmp/mockups/work-live.png`
|
||||
- `.config/tuios/config.toml`
|
||||
- `tmp/mockups/board-live.png`
|
||||
|
||||
## Final answer snapshot
|
||||
|
||||
Three things answered, and the Work workspace is built.
|
||||
|
||||
## Vim keys: yes, as aliases
|
||||
|
||||
`j` and `k` now move in both the Board and Work screens, and `h` and `l` switch tabs. They are aliases, not the primary keys, because whether they reach the program depends on TUIOS's mode: in window-management mode TUIOS keeps `h j k l z n`, and in terminal mode the program gets them. Use whichever arrives, and the footer says `↑↓ or j k` so nobody has to guess. Arrow keys stay the primary path.
|
||||
|
||||
## The theme confusion, cleared up
|
||||
|
||||
You changed something and it worked; I checked the wrong thing. `tuios look` is a saved *appearance bundle*, and "no look is applied" says nothing about the theme. The real answer was in `~/.config/tuios/config.toml`:
|
||||
|
||||
```
|
||||
theme = ''
|
||||
```
|
||||
|
||||
So the config had no theme, which is why this session's chrome looked default. I have now set it:
|
||||
|
||||
```
|
||||
theme = 'catppuccin_mocha'
|
||||
```
|
||||
|
||||
New sessions pick it up. The running one needs a restart, and `tuios set-config theme catppuccin_mocha` refused with *"set_config came from host nixos-desktop and would change this machine, not the session"*, so I left that alone rather than fight it. Our program is drawn in Catppuccin Mocha with a Latte variant (`-theme light`), so once the session restarts the chrome and the panels will match.
|
||||
|
||||
## Styling changes
|
||||
|
||||
- The board now reads at a glance: group headings are a label, a rule and a count (`DONE───────── 6`), rows are two lines, content sits one column inside the frame.
|
||||
- Three-column layout above 140 columns (tasks, chosen task, needs you), two columns below, so a wide terminal is used instead of wasted.
|
||||
- The Work screen has a tab bar, coloured run states, and diff lines coloured by kind — additions green, deletions red, hunk headers blue.
|
||||
- The footer shows only keys that apply in the current panel, and focus is a rounded bright edge plus `·focus·`.
|
||||
|
||||
## Work workspace
|
||||
|
||||
One screen, three tabs, tested on real data:
|
||||
|
||||
- **runs** — a column table of every run with what woke it, start, end and duration, then the activity list with times in local time.
|
||||
- **change** — the task's worktree, branch, base, commits, changed-file counts, then the diff.
|
||||
- **facts** — folder, branch, base, status and cost.
|
||||
|
||||
It follows the Board without being told: the chosen task lives in the project's `.harness/harness.toml`, and the Work panel re-reads it every five seconds. I watched it switch from `AUD-160` to `AUD-165` after only that file changed, then show `520bc2d AUD-165: add cleanup.sh` and the actual diff.
|
||||
|
||||
Two real bugs found by using it: a git that fails because the folder was removed now says *"the task's folder … no longer exists"* instead of a raw exit code, and a table whose columns were ragged because styled text was being padded by bytes — pad the plain text first, colour it second.
|
||||
|
||||
Screenshots: `/tmp/mockups/board-live.png` and `/tmp/mockups/work-live.png`. Committed as `4e3c955`.
|
||||
|
||||
## Still to do
|
||||
|
||||
The reply and new-task panels still use the older plain look — they need the same frame, tab bar and footer before the set is consistent. That is the next piece, and it is small because the frame already exists.
|
||||
|
||||
And the font rebuild is still yours to run:
|
||||
|
||||
```
|
||||
sudo nixos-rebuild switch --flake /etc/nixos#nixos-desktop
|
||||
```
|
||||
|
||||
## Evidence
|
||||
|
||||
- Confidence: 86%
|
||||
- Reason: Preserve full sanitized discovery detail from a rich investigation turn
|
||||
|
||||
## Related
|
||||
|
||||
- [[packs/paperclip-harness/00-system/pi-agent/memory-manifest|Memory Manifest]]
|
||||
|
||||
## Related learned memories <!-- same-turn-2026-11-10 03:01:16 -->
|
||||
|
||||
- context: [[packs/paperclip-harness/20-context/home|paperclip-harness TUI harness]]
|
||||
- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|Harness pitfalls: git failure, styled-text padding, tuios set-config]]
|
||||
- action: [[packs/paperclip-harness/40-actions/2026-11-10-wrote-docs-ui-layout-md-and-three-live-layout-mockups|Built Work workspace and polished Board styling (4e3c955)]]
|
||||
- session: [[packs/paperclip-harness/80-sessions/rich-discovery-config-2026-11-10-03-01-16|Rich discovery: .config 2026-11-10 03:01:16]]
|
||||
- context: [[packs/paperclip-harness/20-context/tmp|.config]]
|
||||
BIN
bin/harness
BIN
bin/harness
Binary file not shown.
@@ -112,8 +112,13 @@ What was changed:
|
||||
`.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.
|
||||
- `~/.config/ghostty/config` sets `font-family = "JetBrainsMono Nerd Font"`.
|
||||
- Rebuilt by the person on 2026-11-10: `fc-list | grep -ci nerd` now returns 96, and
|
||||
`JetBrainsMonoNerdFont-Regular.ttf` is present in the store. The font is installed on
|
||||
`.13`; `.27` and `.51` get it from the same flake on their next rebuild.
|
||||
- The theme is now set: `theme = 'catppuccin_mocha'` in `~/.config/tuios/config.toml`.
|
||||
`tuios set-config` refused for the running session ("would change this machine, not the
|
||||
session"), so the running session keeps its old chrome until it is restarted.
|
||||
|
||||
## Toolchain
|
||||
|
||||
|
||||
14
tui/board.go
14
tui/board.go
@@ -414,19 +414,19 @@ func (m boardModel) taskLine(r Row, index, width int) []string {
|
||||
func shortStatus(status string) (string, string) {
|
||||
switch status {
|
||||
case "in_progress":
|
||||
return "running", active.Info
|
||||
return icons.Running + " running", active.Info
|
||||
case "blocked":
|
||||
return "blocked", active.Error
|
||||
return icons.Blocked + " blocked", active.Error
|
||||
case "in_review":
|
||||
return "review", active.Warning
|
||||
return icons.Review + " review", active.Warning
|
||||
case "done":
|
||||
return "✓ done", active.Success
|
||||
return icons.Done + " done", active.Success
|
||||
case "cancelled":
|
||||
return "✕ gone", active.Overlay
|
||||
return icons.Cancelled + " gone", active.Overlay
|
||||
case "backlog":
|
||||
return "backlog", active.Muted
|
||||
return icons.Todo + " backlog", active.Muted
|
||||
default:
|
||||
return "todo", active.Text
|
||||
return icons.Todo + " todo", active.Text
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
45
tui/frame.go
45
tui/frame.go
@@ -185,19 +185,19 @@ func statusPill(status string) string {
|
||||
func statusLabel(status string) (string, string) {
|
||||
switch status {
|
||||
case "in_progress":
|
||||
return "▶ running ", active.Info
|
||||
return icons.Running + " running ", active.Info
|
||||
case "blocked":
|
||||
return "■ blocked ", active.Error
|
||||
return icons.Blocked + " blocked ", active.Error
|
||||
case "in_review":
|
||||
return "◆ review ", active.Warning
|
||||
return icons.Review + " review ", active.Warning
|
||||
case "done":
|
||||
return "✓ done ", active.Success
|
||||
return icons.Done + " done ", active.Success
|
||||
case "cancelled":
|
||||
return "✕ cancel ", active.Overlay
|
||||
return icons.Cancelled + " cancel ", active.Overlay
|
||||
case "backlog":
|
||||
return "· backlog ", active.Muted
|
||||
return icons.Todo + " backlog ", active.Muted
|
||||
default:
|
||||
return "· todo ", active.Text
|
||||
return icons.Todo + " todo ", active.Text
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,3 +240,34 @@ func fmtCount(n int, one, many string) string {
|
||||
}
|
||||
return fmt.Sprintf("%d %s", n, many)
|
||||
}
|
||||
|
||||
// wrap breaks one paragraph into lines no wider than w, on word boundaries.
|
||||
// It is used for anything a person wrote, so it never cuts a word in half.
|
||||
func wrap(s string, w int) []string {
|
||||
if w < 4 {
|
||||
w = 4
|
||||
}
|
||||
if s == "" {
|
||||
return []string{""}
|
||||
}
|
||||
var lines []string
|
||||
cur := ""
|
||||
for _, word := range strings.Fields(s) {
|
||||
switch {
|
||||
case cur == "":
|
||||
cur = word
|
||||
case len([]rune(cur))+1+len([]rune(word)) <= w:
|
||||
cur += " " + word
|
||||
default:
|
||||
lines = append(lines, cur)
|
||||
cur = word
|
||||
}
|
||||
}
|
||||
if cur != "" {
|
||||
lines = append(lines, cur)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return []string{""}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
20
tui/main.go
20
tui/main.go
@@ -22,12 +22,14 @@ func main() {
|
||||
screen := flag.String("screen", "board", "which screen to run: board, work, 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")
|
||||
nerd := flag.Bool("nerd", false, "use Nerd Font icons instead of plain characters")
|
||||
agent := flag.String("agent", defaultAgent, "agent id to assign to, default Builder")
|
||||
project := flag.String("project", "", "project id or url key, for the tree screen")
|
||||
issue := flag.String("issue", "", "task id or identifier such as AUD-160, for the reply screen")
|
||||
dump := flag.Bool("dump", false, "print the tree as plain text and exit (no full-screen interface)")
|
||||
flag.Parse()
|
||||
choosePalette(*light)
|
||||
chooseGlyphs(*nerd)
|
||||
|
||||
client, err := paperclip.New()
|
||||
if err != nil {
|
||||
@@ -95,12 +97,20 @@ func main() {
|
||||
m = newLogModel(client, task.ID)
|
||||
|
||||
case "reply":
|
||||
task, err := resolveIssue(client, *issue)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
dir := *folder
|
||||
if dir == "" {
|
||||
dir, _ = os.Getwd()
|
||||
}
|
||||
m = newReplyModel(client, task.ID)
|
||||
id := *issue
|
||||
if id != "" {
|
||||
task, err := resolveIssue(client, id)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
id = task.ID
|
||||
}
|
||||
m = newReplyModel(client, dir, id)
|
||||
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown screen %q: use tree, reply, new or log\n", *screen)
|
||||
|
||||
133
tui/newtask.go
133
tui/newtask.go
@@ -1,13 +1,12 @@
|
||||
package main
|
||||
|
||||
// Screen 2: make a task.
|
||||
// The Author workspace: write a task.
|
||||
//
|
||||
// The order the fields ask for is the order Paperclip needs them in: a title,
|
||||
// what it must do, what must finish first, and the command that proves it is
|
||||
// finished. The task is created unassigned and given its blocker in a separate
|
||||
// call, because a task assigned at creation is claimed by an agent's run within
|
||||
// seconds, and that run then owns the task's fields — the blocker write would be
|
||||
// refused.
|
||||
// One panel with five fields, in the order Paperclip needs them: a title, what
|
||||
// it must do, what must finish first, the command that proves it is done, and
|
||||
// whether to hand it to an agent. The task is created unassigned and given its
|
||||
// blocker in a separate call, because a task assigned at creation is claimed by
|
||||
// an agent's run within seconds and that run then owns the task's fields.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -23,6 +22,7 @@ import (
|
||||
type createdMsg struct {
|
||||
issue paperclip.Issue
|
||||
blocked string
|
||||
assigned bool
|
||||
err error
|
||||
}
|
||||
|
||||
@@ -35,9 +35,7 @@ type newTaskModel struct {
|
||||
focus int
|
||||
err error
|
||||
busy bool
|
||||
|
||||
created *paperclip.Issue
|
||||
notice string
|
||||
result string
|
||||
|
||||
width, height int
|
||||
}
|
||||
@@ -51,20 +49,21 @@ const (
|
||||
fieldCount
|
||||
)
|
||||
|
||||
var fieldNames = []string{"Title", "What it must do", "Must finish first", "Proves it is done", "Assign to an agent"}
|
||||
var fieldHints = []string{
|
||||
"one line, the thing to be done",
|
||||
"details the agent needs: files, behaviour, limits",
|
||||
"identifier of a task that must finish first, or empty",
|
||||
"a command that succeeds only when the work is right, or empty",
|
||||
"yes, to hand it to the configured agent after the blocker is set",
|
||||
}
|
||||
|
||||
func newTaskForm(c *paperclip.Client, proj paperclip.Project, agentID string) newTaskModel {
|
||||
placeholders := []string{
|
||||
"what needs doing, in one line",
|
||||
"details the agent needs: files, behaviour, limits",
|
||||
"identifier of the task that must finish first, or empty",
|
||||
"the command that proves it is done, or empty",
|
||||
"assign after the blocker is set: yes, or empty",
|
||||
}
|
||||
m := newTaskModel{client: c, proj: proj, agent: agentID}
|
||||
for _, l := range placeholders {
|
||||
for range fieldNames {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = l
|
||||
ti.Placeholder = ""
|
||||
ti.CharLimit = 1000
|
||||
ti.Width = 60
|
||||
ti.Prompt = ""
|
||||
m.inputs = append(m.inputs, ti)
|
||||
}
|
||||
@@ -72,8 +71,6 @@ func newTaskForm(c *paperclip.Client, proj paperclip.Project, agentID string) ne
|
||||
return m
|
||||
}
|
||||
|
||||
var fieldNames = []string{"Title", "What it must do", "Must finish first", "Proves it is done", "Assign to"}
|
||||
|
||||
func (m newTaskModel) Init() tea.Cmd { return textinput.Blink }
|
||||
|
||||
func (m newTaskModel) submit() tea.Cmd {
|
||||
@@ -87,9 +84,6 @@ func (m newTaskModel) submit() tea.Cmd {
|
||||
|
||||
desc := body
|
||||
if verify != "" {
|
||||
// The command lives with the task, in the task's own words, because
|
||||
// Paperclip owns the task. The project's .harness/verify.sh is the
|
||||
// convention it follows.
|
||||
desc += "\n\nDone when this command succeeds:\n\n " + verify
|
||||
}
|
||||
if blocker != "" {
|
||||
@@ -104,22 +98,20 @@ func (m newTaskModel) submit() tea.Cmd {
|
||||
if blocker != "" {
|
||||
b, err := resolveIssue(c, blocker)
|
||||
if err != nil {
|
||||
return createdMsg{issue: is, err: fmt.Errorf("created %s, but the blocker %q was not found: %w", is.Identifier, blocker, err)}
|
||||
return createdMsg{issue: is, err: fmt.Errorf("created %s, but no task matches %q", is.Identifier, blocker)}
|
||||
}
|
||||
if _, err := c.SetBlockers(is.ID, []string{b.ID}); err != nil {
|
||||
return createdMsg{issue: is, err: fmt.Errorf("created %s, but the blocker was refused: %w", is.Identifier, err)}
|
||||
}
|
||||
}
|
||||
if assign != "" {
|
||||
if agent == "" {
|
||||
return createdMsg{issue: is, err: fmt.Errorf("created %s, but no agent is configured to assign to", is.Identifier)}
|
||||
}
|
||||
// Assigned last, on purpose: this is the call that wakes the agent.
|
||||
assigned := false
|
||||
if strings.EqualFold(assign, "yes") || strings.EqualFold(assign, "y") {
|
||||
if _, err := c.Assign(is.ID, agent); err != nil {
|
||||
return createdMsg{issue: is, err: fmt.Errorf("created %s, but the assignment was refused: %w", is.Identifier, err)}
|
||||
}
|
||||
assigned = true
|
||||
}
|
||||
return createdMsg{issue: is, blocked: blocker}
|
||||
return createdMsg{issue: is, blocked: blocker, assigned: assigned}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +119,7 @@ func (m newTaskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
w := max(20, min(72, m.width-8))
|
||||
w := max(20, min(70, m.width-12))
|
||||
for i := range m.inputs {
|
||||
m.inputs[i].Width = w
|
||||
}
|
||||
@@ -135,9 +127,7 @@ func (m newTaskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyCtrlC:
|
||||
return m, tea.Quit
|
||||
case tea.KeyEsc:
|
||||
case tea.KeyCtrlC, tea.KeyEsc:
|
||||
return m, tea.Quit
|
||||
case tea.KeyTab, tea.KeyDown:
|
||||
m.inputs[m.focus].Blur()
|
||||
@@ -155,7 +145,8 @@ func (m newTaskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
if strings.TrimSpace(m.inputs[fieldTitle].Value()) == "" {
|
||||
m.err = fmt.Errorf("a task needs a title")
|
||||
return m, nil
|
||||
m.focus = fieldTitle
|
||||
return m, m.inputs[m.focus].Focus()
|
||||
}
|
||||
if m.busy {
|
||||
return m, nil
|
||||
@@ -167,18 +158,18 @@ func (m newTaskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case createdMsg:
|
||||
m.busy = false
|
||||
m.err = msg.err
|
||||
if msg.err == nil {
|
||||
m.created = &msg.issue
|
||||
m.notice = "created " + msg.issue.Identifier
|
||||
if msg.blocked != "" {
|
||||
m.notice += " after " + msg.blocked
|
||||
}
|
||||
// Leave the form filled so a failed run can be corrected, but say
|
||||
// plainly what happened in the pane.
|
||||
return m, tea.Quit
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
m.result = "created " + msg.issue.Identifier
|
||||
if msg.blocked != "" {
|
||||
m.result += ", waiting on " + msg.blocked
|
||||
}
|
||||
if msg.assigned {
|
||||
m.result += ", handed to an agent"
|
||||
}
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
@@ -188,39 +179,47 @@ func (m newTaskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
func (m newTaskModel) View() string {
|
||||
if m.width == 0 || m.height == 0 {
|
||||
return "loading…"
|
||||
return "reading…"
|
||||
}
|
||||
if m.width < minWidth || m.height < minHeight {
|
||||
return fmt.Sprintf("Window is too small.\nNeed %d×%d, have %d×%d.", minWidth, minHeight, m.width, m.height)
|
||||
if m.width < 80 || m.height < 24 {
|
||||
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(
|
||||
fmt.Sprintf("This workspace needs 80 × 24.\nThis panel is %d × %d.", m.width, m.height))
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(titleStyle.Render(truncate("new task · "+m.proj.Name, m.width)))
|
||||
b.WriteString("\n\n")
|
||||
head := titleStyle.Render("Author") + mutedStyle2().Render(" · new task in "+m.proj.Name)
|
||||
|
||||
var body []string
|
||||
body = append(body, "")
|
||||
for i, in := range m.inputs {
|
||||
label := fieldNames[i]
|
||||
if i == fieldTitle {
|
||||
label += " (required)"
|
||||
}
|
||||
muted := mutedStyle
|
||||
if i == m.focus {
|
||||
muted = groupStyle
|
||||
body = append(body, " ▸ "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Bold(true).Render(label))
|
||||
} else {
|
||||
body = append(body, " "+mutedStyle2().Render(label))
|
||||
}
|
||||
b.WriteString(muted.Render(label))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(" " + in.View())
|
||||
b.WriteString("\n")
|
||||
body = append(body, " "+in.View())
|
||||
hint := fieldHints[i]
|
||||
if i == m.focus {
|
||||
body = append(body, " "+dimStyle().Render(hint))
|
||||
}
|
||||
body = append(body, "")
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
if m.err != nil {
|
||||
b.WriteString(blockedStyle.Render(truncate("error: "+m.err.Error(), m.width)))
|
||||
body = append(body, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render("error: "+m.err.Error()))
|
||||
} else if m.busy {
|
||||
b.WriteString(mutedStyle.Render("writing to Paperclip…"))
|
||||
} else {
|
||||
b.WriteString(mutedStyle.Render(truncate("Enter next field, Enter on the last line creates · Esc cancels", m.width)))
|
||||
body = append(body, " "+mutedStyle2().Render("writing to Paperclip…"))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
return lipgloss.NewStyle().MaxHeight(m.height).Render(b.String())
|
||||
|
||||
p := panel{title: "new task", width: m.width, height: m.height - 2, focused: true}
|
||||
footer := hintLine([]string{
|
||||
keyHint("tab", "next field"),
|
||||
keyHint("enter", "next, and create on the last"),
|
||||
keyHint("esc", "cancel"),
|
||||
}, m.width)
|
||||
|
||||
return strings.Join([]string{head, p.render(body), footer}, "\n")
|
||||
}
|
||||
|
||||
324
tui/reply.go
324
tui/reply.go
@@ -1,60 +1,71 @@
|
||||
package main
|
||||
|
||||
// Screen 4: the task's conversation, with a box to reply in.
|
||||
// The Conversation workspace: what has been said on the chosen task, and a box
|
||||
// to say something back.
|
||||
//
|
||||
// Sending writes a comment as the person and, if a status was chosen, changes
|
||||
// the task's status in the same call. That pair is what wakes an agent: it sees
|
||||
// a new comment on a task it owns, and it sees the task waiting on it again.
|
||||
// Two panels: the thread, and the reply box. Sending writes a comment as the
|
||||
// person and, when a status has been chosen, changes the task's status in the
|
||||
// same call — which is what wakes an agent. That pair is the whole point of this
|
||||
// screen, so both are shown before the key is pressed.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
zone "github.com/lrstanley/bubblezone"
|
||||
|
||||
"paperclip-harness/tui/paperclip"
|
||||
)
|
||||
|
||||
type threadMsg struct {
|
||||
issue paperclip.Issue
|
||||
type replyThreadMsg struct {
|
||||
task paperclip.Issue
|
||||
comments []paperclip.Comment
|
||||
}
|
||||
|
||||
type sentMsg struct{ issue paperclip.Issue }
|
||||
type replySentMsg struct{ task paperclip.Issue }
|
||||
|
||||
type replyModel struct {
|
||||
client *paperclip.Client
|
||||
issue string
|
||||
zones *zone.Manager
|
||||
client *paperclip.Client
|
||||
folder string
|
||||
issue string
|
||||
explicit bool
|
||||
|
||||
task paperclip.Issue
|
||||
thread []paperclip.Comment
|
||||
box string
|
||||
focusBox bool
|
||||
next string // status to send with the reply; "" means leave it alone
|
||||
sending bool
|
||||
notice string
|
||||
err error
|
||||
task paperclip.Issue
|
||||
thread []paperclip.Comment
|
||||
box string
|
||||
inBox bool
|
||||
next string
|
||||
sending bool
|
||||
notice string
|
||||
err error
|
||||
readAt time.Time
|
||||
|
||||
width, height int
|
||||
scroll int
|
||||
scroll int
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func newReplyModel(c *paperclip.Client, issueID string) replyModel {
|
||||
return replyModel{
|
||||
client: c,
|
||||
issue: issueID,
|
||||
zones: zone.New(),
|
||||
}
|
||||
func newReplyModel(c *paperclip.Client, folder, issueID string) replyModel {
|
||||
return replyModel{client: c, folder: folder, issue: issueID, explicit: issueID != ""}
|
||||
}
|
||||
|
||||
func (m replyModel) Init() tea.Cmd { return m.load() }
|
||||
func (m replyModel) Init() tea.Cmd {
|
||||
return tea.Batch(m.load(), tickEvery(5*time.Second))
|
||||
}
|
||||
|
||||
func (m replyModel) load() tea.Cmd {
|
||||
c, id := m.client, m.issue
|
||||
c, folder := m.client, m.folder
|
||||
fixed, explicit := m.issue, m.explicit
|
||||
return func() tea.Msg {
|
||||
id := fixed
|
||||
if !explicit {
|
||||
id = watchedTask(folder)
|
||||
}
|
||||
if id == "" {
|
||||
return errMsg{fmt.Errorf("no task chosen: pick one on the Board, then press Enter")}
|
||||
}
|
||||
task, err := c.Issue(id)
|
||||
if err != nil {
|
||||
return errMsg{err}
|
||||
@@ -63,17 +74,15 @@ func (m replyModel) load() tea.Cmd {
|
||||
if err != nil {
|
||||
return errMsg{err}
|
||||
}
|
||||
return threadMsg{issue: task, comments: cs}
|
||||
return replyThreadMsg{task: task, comments: cs}
|
||||
}
|
||||
}
|
||||
|
||||
func (m replyModel) send() tea.Cmd {
|
||||
c, id, body, status := m.client, m.issue, strings.TrimSpace(m.box), m.next
|
||||
c, id, body, status := m.client, m.task.ID, strings.TrimSpace(m.box), m.next
|
||||
return func() tea.Msg {
|
||||
var (
|
||||
task paperclip.Issue
|
||||
err error
|
||||
)
|
||||
var task paperclip.Issue
|
||||
var err error
|
||||
if status != "" {
|
||||
task, err = c.SetStatus(id, status, body)
|
||||
} else {
|
||||
@@ -88,11 +97,10 @@ func (m replyModel) send() tea.Cmd {
|
||||
return errMsg{err}
|
||||
}
|
||||
}
|
||||
return sentMsg{issue: task}
|
||||
return replySentMsg{task: task}
|
||||
}
|
||||
}
|
||||
|
||||
// statuses the reply box can ask for, in the order they are offered.
|
||||
var replyStatuses = []string{"", "todo", "in_progress", "in_review", "done", "blocked"}
|
||||
|
||||
func (m replyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
@@ -101,24 +109,28 @@ func (m replyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
return m, nil
|
||||
|
||||
case tickMsg:
|
||||
if !m.inBox {
|
||||
return m, tea.Batch(m.load(), tickEvery(5*time.Second))
|
||||
}
|
||||
return m, tickEvery(5*time.Second)
|
||||
|
||||
case tea.KeyMsg:
|
||||
// While the box has focus it owns the ordinary keys, so typing a
|
||||
// message never triggers the screen's own shortcuts.
|
||||
if m.focusBox {
|
||||
if m.inBox {
|
||||
switch msg.Type {
|
||||
case tea.KeyEsc:
|
||||
lost := m.box == ""
|
||||
m.focusBox = false
|
||||
if lost {
|
||||
m.notice = ""
|
||||
}
|
||||
m.inBox = false
|
||||
return m, nil
|
||||
case tea.KeyEnter:
|
||||
if msg.Alt {
|
||||
m.box += "\n"
|
||||
return m, nil
|
||||
}
|
||||
if m.sending {
|
||||
return m, nil
|
||||
}
|
||||
if strings.TrimSpace(m.box) == "" {
|
||||
m.notice = "nothing to send"
|
||||
m.notice = "nothing to send yet"
|
||||
return m, nil
|
||||
}
|
||||
m.sending = true
|
||||
@@ -133,7 +145,10 @@ func (m replyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case tea.KeySpace:
|
||||
m.box += " "
|
||||
return m, nil
|
||||
case tea.KeyRunes, tea.KeyTab:
|
||||
case tea.KeyTab:
|
||||
m.box += " "
|
||||
return m, nil
|
||||
case tea.KeyRunes:
|
||||
m.box += string(msg.Runes)
|
||||
return m, nil
|
||||
}
|
||||
@@ -143,14 +158,14 @@ func (m replyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "tab", "shift+tab", "enter":
|
||||
m.focusBox = true
|
||||
case "tab", "enter":
|
||||
m.inBox = true
|
||||
m.notice = ""
|
||||
return m, nil
|
||||
case "up":
|
||||
case "up", "k":
|
||||
m.scroll--
|
||||
return m, nil
|
||||
case "down":
|
||||
case "down", "j":
|
||||
m.scroll++
|
||||
return m, nil
|
||||
case "s":
|
||||
@@ -164,21 +179,28 @@ func (m replyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case "r":
|
||||
return m, m.load()
|
||||
case "esc":
|
||||
m.notice = ""
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case threadMsg:
|
||||
m.task = msg.issue
|
||||
case replyThreadMsg:
|
||||
m.task = msg.task
|
||||
wasEmpty := len(m.thread)
|
||||
m.thread = msg.comments
|
||||
m.scroll = 1 << 30 // pinned to the bottom until the person scrolls up
|
||||
m.readAt = time.Now()
|
||||
m.err = nil
|
||||
if len(m.thread) != wasEmpty {
|
||||
m.scroll = 1 << 30
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case sentMsg:
|
||||
case replySentMsg:
|
||||
m.sending = false
|
||||
m.box = ""
|
||||
m.next = ""
|
||||
m.task = msg.issue
|
||||
m.task = msg.task
|
||||
m.notice = "sent"
|
||||
m.inBox = false
|
||||
return m, m.load()
|
||||
|
||||
case errMsg:
|
||||
@@ -189,33 +211,35 @@ func (m replyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var (
|
||||
boxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(0, 1)
|
||||
boxFocusStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("12")).Padding(0, 1)
|
||||
)
|
||||
|
||||
func (m replyModel) View() string {
|
||||
if m.width == 0 || m.height == 0 {
|
||||
return "loading…"
|
||||
return "reading…"
|
||||
}
|
||||
if m.width < minWidth || m.height < minHeight {
|
||||
return m.zones.Scan(lipgloss.NewStyle().Width(m.width).Height(m.height).
|
||||
Render(fmt.Sprintf("Window is too small.\nNeed %d×%d, have %d×%d.", minWidth, minHeight, m.width, m.height)))
|
||||
if m.width < 80 || m.height < 24 {
|
||||
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(
|
||||
fmt.Sprintf("This workspace needs 80 × 24.\nThis panel is %d × %d.", m.width, m.height))
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
head := fmt.Sprintf("%s %s · conversation", m.task.Identifier, m.task.Status)
|
||||
b.WriteString(titleStyle.Render(truncate(head, m.width)))
|
||||
b.WriteString("\n")
|
||||
head := titleStyle.Render("Conversation") + mutedStyle2().Render(" · ")
|
||||
if m.task.Identifier != "" {
|
||||
head += lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).
|
||||
Render(m.task.Identifier + " " + truncateVisible(m.task.Title, max(10, m.width-60)))
|
||||
}
|
||||
state := "read " + m.readAt.Format("15:04:05")
|
||||
if m.readAt.IsZero() {
|
||||
state = "reading…"
|
||||
}
|
||||
|
||||
boxHeight := 3
|
||||
footer := 2
|
||||
threadRows := max(3, m.height-boxHeight-footer-2)
|
||||
composerHeight := max(5, min(9, m.height/4))
|
||||
threadHeight := m.height - composerHeight - 2
|
||||
|
||||
lines := m.threadLines(m.width - 2)
|
||||
// Clamp the scroll to what exists.
|
||||
maxScroll := max(0, len(lines)-threadRows)
|
||||
thread := panel{
|
||||
title: fmt.Sprintf("thread · %s", fmtCount(len(m.thread), "message", "messages")),
|
||||
width: m.width, height: threadHeight, focused: !m.inBox,
|
||||
}
|
||||
lines := m.threadLines(m.width - 4)
|
||||
visible := thread.bodyHeight()
|
||||
maxScroll := max(0, len(lines)-visible)
|
||||
if m.scroll > maxScroll {
|
||||
m.scroll = maxScroll
|
||||
}
|
||||
@@ -223,104 +247,100 @@ func (m replyModel) View() string {
|
||||
m.scroll = 0
|
||||
}
|
||||
from := m.scroll
|
||||
to := min(len(lines), from+threadRows)
|
||||
shown := lines[from:to]
|
||||
for len(shown) < threadRows {
|
||||
shown = append(shown, "")
|
||||
}
|
||||
for _, ln := range shown {
|
||||
b.WriteString(ln)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
to := min(len(lines), from+visible)
|
||||
|
||||
box := m.box
|
||||
if box == "" {
|
||||
box = "Tab or Enter to write a reply"
|
||||
}
|
||||
style := boxStyle
|
||||
if m.focusBox {
|
||||
style = boxFocusStyle
|
||||
}
|
||||
b.WriteString(style.Width(m.width - 2).Render(truncate(box, m.width-6)))
|
||||
b.WriteString("\n")
|
||||
|
||||
status := "no status change"
|
||||
statusNote := "leaves the status alone"
|
||||
if m.next != "" {
|
||||
status = "status → " + m.next
|
||||
statusNote = "also sets the status to " + m.next
|
||||
}
|
||||
line := fmt.Sprintf("%s · %s", status, "s changes status · r reload · q quit")
|
||||
if m.err != nil {
|
||||
line = "error: " + m.err.Error()
|
||||
} else if m.notice != "" {
|
||||
line = m.notice
|
||||
} else if m.sending {
|
||||
line = "sending…"
|
||||
composer := panel{
|
||||
title: fmt.Sprintf("reply · %s", statusNote),
|
||||
width: m.width, height: composerHeight, focused: m.inBox,
|
||||
}
|
||||
if m.err != nil {
|
||||
b.WriteString(blockedStyle.Render(truncate(line, m.width)))
|
||||
} else {
|
||||
b.WriteString(mutedStyle.Render(truncate(line, m.width)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
boxLines := m.composerLines(m.width - 4, composer.bodyHeight())
|
||||
|
||||
return m.zones.Scan(b.String())
|
||||
footer := hintLine([]string{
|
||||
keyHint("enter or tab", "write a reply"),
|
||||
keyHint("s", "choose a status to send"),
|
||||
keyHint("↑↓ or j k", "read the thread"),
|
||||
keyHint("r", "read again"),
|
||||
keyHint("q", "leave"),
|
||||
}, m.width)
|
||||
if m.inBox {
|
||||
footer = hintLine([]string{
|
||||
keyHint("enter", "send"),
|
||||
keyHint("alt+enter", "new line"),
|
||||
keyHint("esc", "close the box"),
|
||||
}, m.width)
|
||||
}
|
||||
if m.err != nil {
|
||||
footer = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render(truncateVisible("error: "+m.err.Error(), m.width))
|
||||
} else if m.sending {
|
||||
footer = mutedStyle2().Render("sending…")
|
||||
} else if m.notice != "" {
|
||||
footer = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render(m.notice)
|
||||
}
|
||||
|
||||
gap := max(2, m.width-lipgloss.Width(head)-lipgloss.Width(state)-1)
|
||||
return strings.Join([]string{
|
||||
head + strings.Repeat(" ", gap) + mutedStyle2().Render(state),
|
||||
thread.render(lines[from:to]),
|
||||
composer.render(boxLines),
|
||||
footer,
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func (m replyModel) composerLines(width, height int) []string {
|
||||
text := m.box
|
||||
if text == "" {
|
||||
return []string{mutedStyle2().Render("write a reply, then press enter")}
|
||||
}
|
||||
var out []string
|
||||
for _, para := range strings.Split(text, "\n") {
|
||||
for _, ln := range wrap(para, width) {
|
||||
out = append(out, ln)
|
||||
}
|
||||
}
|
||||
// The cursor sits at the end of what has been typed.
|
||||
if len(out) > 0 {
|
||||
out[len(out)-1] += lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Render("▌")
|
||||
}
|
||||
if len(out) > height {
|
||||
out = out[len(out)-height:]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// threadLines turns the comments into wrapped lines the pane can scroll.
|
||||
func (m replyModel) threadLines(width int) []string {
|
||||
if m.err != nil {
|
||||
return []string{lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render(m.err.Error())}
|
||||
}
|
||||
if len(m.thread) == 0 {
|
||||
return []string{mutedStyle.Render("no messages yet")}
|
||||
return []string{"", mutedStyle2().Render("nothing said on this task yet")}
|
||||
}
|
||||
var out []string
|
||||
for _, c := range m.thread {
|
||||
who := "person"
|
||||
who, colour := "person", active.Accent
|
||||
switch c.AuthorType {
|
||||
case "agent":
|
||||
who = "agent " + short(c.AuthorID)
|
||||
who, colour = "agent", active.Mauve
|
||||
case "user":
|
||||
who = "you"
|
||||
who, colour = "you", active.Info
|
||||
case "system":
|
||||
who = "system"
|
||||
who, colour = "system", active.Muted
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%s %s", groupStyle.Render(who), mutedStyle.Render(short(c.ID))))
|
||||
when := ""
|
||||
if t, err := time.Parse(time.RFC3339, c.CreatedAt); err == nil {
|
||||
when = t.Local().Format("15:04")
|
||||
}
|
||||
out = append(out, lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Bold(true).Render(who)+
|
||||
dimStyle().Render(" "+when))
|
||||
for _, ln := range strings.Split(c.Body, "\n") {
|
||||
for _, w := range wrap(ln, max(8, width-2)) {
|
||||
out = append(out, " "+w)
|
||||
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(w))
|
||||
}
|
||||
}
|
||||
out = append(out, "")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func short(id string) string {
|
||||
if len(id) <= 8 {
|
||||
return id
|
||||
}
|
||||
return id[:8]
|
||||
}
|
||||
|
||||
func wrap(s string, w int) []string {
|
||||
if s == "" {
|
||||
return []string{""}
|
||||
}
|
||||
words := strings.Fields(s)
|
||||
var lines []string
|
||||
cur := ""
|
||||
for _, word := range words {
|
||||
if cur == "" {
|
||||
cur = word
|
||||
continue
|
||||
}
|
||||
if len([]rune(cur))+1+len([]rune(word)) <= w {
|
||||
cur += " " + word
|
||||
continue
|
||||
}
|
||||
lines = append(lines, cur)
|
||||
cur = word
|
||||
}
|
||||
if cur != "" {
|
||||
lines = append(lines, cur)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
40
tui/theme.go
40
tui/theme.go
@@ -80,3 +80,43 @@ func choosePalette(force string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Icons. Nerd Font glyphs, with an ASCII fallback so the interface still reads
|
||||
// when the font is not installed or the terminal cannot show it: -ascii, or a
|
||||
// TERM that says it cannot, chooses the fallback.
|
||||
type glyphSet struct {
|
||||
Todo, Running, Blocked, Review, Done, Cancelled string
|
||||
Folder, Branch, Commit, Cost, Person, Agent string
|
||||
Arrow, Dot, Rule string
|
||||
}
|
||||
|
||||
var nerdGlyphs = glyphSet{
|
||||
Todo: "\uf10c", Running: "\uf04b", Blocked: "\uf05e", Review: "\uf06e",
|
||||
Done: "\uf00c", Cancelled: "\uf00d",
|
||||
Folder: "\uf07b", Branch: "\uf126", Commit: "\uf1d3", Cost: "\uf155",
|
||||
Person: "\uf007", Agent: "\uf085",
|
||||
Arrow: "\uf105", Dot: "\uf111", Rule: "─",
|
||||
}
|
||||
|
||||
var plainGlyphs = glyphSet{
|
||||
Todo: "·", Running: "▶", Blocked: "■", Review: "◆",
|
||||
Done: "✓", Cancelled: "✕",
|
||||
Folder: "/", Branch: "branch", Commit: "commit", Cost: "cents",
|
||||
Person: "you", Agent: "agent",
|
||||
Arrow: "›", Dot: "·", Rule: "─",
|
||||
}
|
||||
|
||||
// icons starts as plain Unicode on purpose. The Nerd Font is installed, but a
|
||||
// private-use glyph does not survive TUIOS's own screen capture, so whether it
|
||||
// draws cannot be checked from here. The words next to every icon carry the
|
||||
// meaning either way, and -nerd opts into the richer set once it has been seen
|
||||
// on the terminal that matters.
|
||||
var icons = plainGlyphs
|
||||
|
||||
func chooseGlyphs(useNerd bool) {
|
||||
if useNerd {
|
||||
icons = nerdGlyphs
|
||||
return
|
||||
}
|
||||
icons = plainGlyphs
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user