diff --git a/.pi/memory-vault/packs/paperclip-harness/20-context/home.md b/.pi/memory-vault/packs/paperclip-harness/20-context/home.md index aeb5477..31c123a 100644 --- a/.pi/memory-vault/packs/paperclip-harness/20-context/home.md +++ b/.pi/memory-vault/packs/paperclip-harness/20-context/home.md @@ -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 -folder ` launches a given screen against a project folder. +- A project folder carries project state under `.paperclip/`, including worktrees at `.paperclip/worktrees//`, 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 + +- 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]] diff --git a/.pi/memory-vault/packs/paperclip-harness/20-context/tmp.md b/.pi/memory-vault/packs/paperclip-harness/20-context/tmp.md index 27ad639..78a091f 100644 --- a/.pi/memory-vault/packs/paperclip-harness/20-context/tmp.md +++ b/.pi/memory-vault/packs/paperclip-harness/20-context/tmp.md @@ -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 + +- 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]] diff --git a/.pi/memory-vault/packs/paperclip-harness/40-actions/2026-11-10-wrote-docs-ui-layout-md-and-three-live-layout-mockups.md b/.pi/memory-vault/packs/paperclip-harness/40-actions/2026-11-10-wrote-docs-ui-layout-md-and-three-live-layout-mockups.md index 2a13981..53bfd98 100644 --- a/.pi/memory-vault/packs/paperclip-harness/40-actions/2026-11-10-wrote-docs-ui-layout-md-and-three-live-layout-mockups.md +++ b/.pi/memory-vault/packs/paperclip-harness/40-actions/2026-11-10-wrote-docs-ui-layout-md-and-three-live-layout-mockups.md @@ -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 + +- 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]] diff --git a/.pi/memory-vault/packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns.md b/.pi/memory-vault/packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns.md index b4b6bdc..ae38ddb 100644 --- a/.pi/memory-vault/packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns.md +++ b/.pi/memory-vault/packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns.md @@ -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 + +- 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]] diff --git a/.pi/memory-vault/packs/paperclip-harness/80-sessions/rich-discovery-config-2026-11-10-03-01-16.md b/.pi/memory-vault/packs/paperclip-harness/80-sessions/rich-discovery-config-2026-11-10-03-01-16.md new file mode 100644 index 0000000..0eeb767 --- /dev/null +++ b/.pi/memory-vault/packs/paperclip-harness/80-sessions/rich-discovery-config-2026-11-10-03-01-16.md @@ -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 + +- 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]] diff --git a/bin/harness b/bin/harness index 17f0f29..17582d9 100755 Binary files a/bin/harness and b/bin/harness differ diff --git a/docs/ENVIRONMENT-FACTS.md b/docs/ENVIRONMENT-FACTS.md index 3014462..7320a43 100644 --- a/docs/ENVIRONMENT-FACTS.md +++ b/docs/ENVIRONMENT-FACTS.md @@ -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 diff --git a/tui/board.go b/tui/board.go index 05cd9c7..b47d702 100644 --- a/tui/board.go +++ b/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 } } diff --git a/tui/frame.go b/tui/frame.go index 45eb12a..0b4c16b 100644 --- a/tui/frame.go +++ b/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 +} diff --git a/tui/main.go b/tui/main.go index 67bffe9..be7f325 100644 --- a/tui/main.go +++ b/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) diff --git a/tui/newtask.go b/tui/newtask.go index f4a8988..b4d8530 100644 --- a/tui/newtask.go +++ b/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") } diff --git a/tui/reply.go b/tui/reply.go index 989a9b0..fd9a445 100644 --- a/tui/reply.go +++ b/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 -} diff --git a/tui/theme.go b/tui/theme.go index 105c7a4..369b1b7 100644 --- a/tui/theme.go +++ b/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 +}