Conversation and Author screens styled to match; glyph policy chosen from measurement

This commit is contained in:
Sam
2026-11-10 14:18:33 +11:00
parent 4e3c955350
commit 031073fc46
13 changed files with 755 additions and 240 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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