chill-agent 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/.claude-plugin/contrast-mcp-launcher.mjs +31 -0
  2. package/.claude-plugin/plugin.json +8 -3
  3. package/.cline-plugin/plugin.js +6 -0
  4. package/.codex-plugin/plugin.json +4 -3
  5. package/.cursor-plugin/marketplace.json +16 -0
  6. package/.cursor-plugin/plugin.json +2 -2
  7. package/.devin-plugin/plugin.json +2 -2
  8. package/.kimi-plugin/plugin.json +11 -29
  9. package/.omp-plugin/marketplace.json +15 -0
  10. package/.openclaw/skills/ponytail/SKILL.md +2 -7
  11. package/.openclaw/skills/ponytail-audit/SKILL.md +3 -6
  12. package/.openclaw/skills/ponytail-debt/SKILL.md +3 -4
  13. package/.openclaw/skills/ponytail-gain/SKILL.md +3 -4
  14. package/.openclaw/skills/ponytail-help/SKILL.md +3 -3
  15. package/.openclaw/skills/ponytail-review/SKILL.md +3 -6
  16. package/.opencode/plugins/ponytail.mjs +1 -1
  17. package/.qoder-plugin/plugin.json +12 -26
  18. package/AGENTS.md +32 -19
  19. package/__init__.py +1 -1
  20. package/assets/antislop-banner.png +0 -0
  21. package/assets/band/band.png +0 -0
  22. package/assets/band/board.png +0 -0
  23. package/assets/benchmark-3model.svg +21 -0
  24. package/assets/benchmark-agentic.svg +62 -0
  25. package/assets/codex-icon.png +0 -0
  26. package/assets/compare/code/after.webp +0 -0
  27. package/assets/compare/code/before.webp +0 -0
  28. package/assets/compare/text/after.webp +0 -0
  29. package/assets/compare/text/before.webp +0 -0
  30. package/assets/compare/ui/w-all.webp +0 -0
  31. package/assets/compare/ui/w-antislop.webp +0 -0
  32. package/assets/compare/ui/w-design.webp +0 -0
  33. package/assets/compare/ui/without.webp +0 -0
  34. package/assets/logo-dark.png +0 -0
  35. package/assets/logo-dark.svg +115 -0
  36. package/assets/logo-greenpt-dark.svg +27 -0
  37. package/assets/logo-greenpt.svg +27 -0
  38. package/assets/logo.png +0 -0
  39. package/assets/photoreunion-logo-dark.svg +4 -0
  40. package/assets/photoreunion-logo-light.svg +4 -0
  41. package/assets/profile.png +0 -0
  42. package/assets/retriever-logo-dark.svg +4 -0
  43. package/assets/retriever-logo-light.svg +4 -0
  44. package/assets/stories-banner.png +0 -0
  45. package/assets/v5/audit-es.png +0 -0
  46. package/assets/v5/audit-ja.png +0 -0
  47. package/assets/v5/audit-ko.png +0 -0
  48. package/assets/v5/audit-zh-CN.png +0 -0
  49. package/assets/v5/audit.png +0 -0
  50. package/assets/v5/beforeafter-es.png +0 -0
  51. package/assets/v5/beforeafter-ja.png +0 -0
  52. package/assets/v5/beforeafter-ko.png +0 -0
  53. package/assets/v5/beforeafter-zh-CN.png +0 -0
  54. package/assets/v5/beforeafter.png +0 -0
  55. package/assets/v5/chart-es.png +0 -0
  56. package/assets/v5/chart-ja.png +0 -0
  57. package/assets/v5/chart-ko.png +0 -0
  58. package/assets/v5/chart-zh-CN.png +0 -0
  59. package/assets/v5/chart.png +0 -0
  60. package/assets/v5/hero-es.jpg +0 -0
  61. package/assets/v5/hero-ja.jpg +0 -0
  62. package/assets/v5/hero-ko.jpg +0 -0
  63. package/assets/v5/hero-zh-CN.jpg +0 -0
  64. package/assets/v5/hero.jpg +0 -0
  65. package/assets/v5/ladder-es.png +0 -0
  66. package/assets/v5/ladder-ja.png +0 -0
  67. package/assets/v5/ladder-ko.png +0 -0
  68. package/assets/v5/ladder-zh-CN.png +0 -0
  69. package/assets/v5/ladder.png +0 -0
  70. package/assets/v5/review-es.png +0 -0
  71. package/assets/v5/review-ja.png +0 -0
  72. package/assets/v5/review-ko.png +0 -0
  73. package/assets/v5/review-zh-CN.png +0 -0
  74. package/assets/v5/review.png +0 -0
  75. package/assets/v5/tests-es.png +0 -0
  76. package/assets/v5/tests-ja.png +0 -0
  77. package/assets/v5/tests-ko.png +0 -0
  78. package/assets/v5/tests-zh-CN.png +0 -0
  79. package/assets/v5/tests.png +0 -0
  80. package/assets/waitlist-banner-es.png +0 -0
  81. package/assets/waitlist-banner-ja.png +0 -0
  82. package/assets/waitlist-banner-ko.png +0 -0
  83. package/assets/waitlist-banner-zh-CN.png +0 -0
  84. package/assets/waitlist-banner.png +0 -0
  85. package/benchmarks/.env.example +2 -0
  86. package/benchmarks/agentic/README.md +201 -0
  87. package/benchmarks/agentic/answer.py +288 -0
  88. package/benchmarks/agentic/compare.py +93 -0
  89. package/benchmarks/agentic/complete.py +178 -0
  90. package/benchmarks/agentic/judge.py +198 -0
  91. package/benchmarks/agentic/run.py +543 -0
  92. package/benchmarks/agentic/tasks.py +1544 -0
  93. package/benchmarks/arms/baseline.js +2 -0
  94. package/benchmarks/arms/caveman-SKILL.md +67 -0
  95. package/benchmarks/arms/caveman.js +8 -0
  96. package/benchmarks/arms/ponytail.js +8 -0
  97. package/benchmarks/behavior.js +54 -0
  98. package/benchmarks/behavior.yaml +40 -0
  99. package/benchmarks/correctness.js +291 -0
  100. package/benchmarks/loc.js +17 -19
  101. package/benchmarks/promptfooconfig.gemini.yaml +32 -0
  102. package/benchmarks/promptfooconfig.gpt-newest.yaml +33 -0
  103. package/benchmarks/promptfooconfig.yaml +41 -0
  104. package/benchmarks/results/2026-06-12-caveman-vs-ponytail.md +71 -0
  105. package/benchmarks/results/2026-06-12-v4-hardening-vs-caveman.md +125 -0
  106. package/benchmarks/results/2026-06-15-llama3.2-local.md +82 -0
  107. package/benchmarks/results/2026-06-16-correctness-gate-fix.md +113 -0
  108. package/benchmarks/results/2026-06-16-robustness-audit.md +135 -0
  109. package/benchmarks/results/2026-06-17-agentic-safety.md +164 -0
  110. package/benchmarks/results/2026-06-17-cost-verification.md +93 -0
  111. package/benchmarks/results/2026-06-18-agentic.md +219 -0
  112. package/benchmarks/results/2026-06-22-issue-245-217-comprehension.md +98 -0
  113. package/benchmarks/results/2026-10-07-agentic.md +186 -0
  114. package/cli/index.mjs +241 -0
  115. package/cli/lib/banner.mjs +21 -0
  116. package/cli/lib/install.mjs +498 -0
  117. package/cli/lib/settings.mjs +48 -0
  118. package/cli/package-lock.json +87 -0
  119. package/cli/package.json +42 -0
  120. package/cli/scripts/smoke-test.mjs +95 -0
  121. package/cli/scripts/smoke-worker.mjs +411 -0
  122. package/cli/scripts/sync-skills.mjs +81 -0
  123. package/examples/email-validation.md +156 -0
  124. package/examples/group-by.md +35 -0
  125. package/examples/infinite-scroll.md +58 -0
  126. package/examples/number-formatting.md +37 -0
  127. package/examples/rate-limit.md +272 -0
  128. package/examples/react-countdown.md +390 -0
  129. package/examples/url-params.md +41 -0
  130. package/gemini-extension.json +2 -2
  131. package/hooks/band/board.ts +560 -0
  132. package/hooks/band/logo.ts +3 -0
  133. package/hooks/band/metrics.ts +311 -0
  134. package/hooks/band/register.tsx +495 -0
  135. package/hooks/band/types.d.ts +34 -0
  136. package/hooks/claude-band.json +6 -0
  137. package/hooks/claude-codex-hooks.json +34 -4
  138. package/hooks/copilot-hooks.json +12 -2
  139. package/hooks/cursor-hooks.json +2 -2
  140. package/hooks/ponytail-band-import.js +274 -0
  141. package/hooks/ponytail-config.js +175 -0
  142. package/hooks/ponytail-map.js +111 -0
  143. package/hooks/ponytail-subagent.js +94 -0
  144. package/hooks/qoder-hooks.json +19 -4
  145. package/i18n/README.ko.md +230 -0
  146. package/package.json +12 -7
  147. package/pi-extension/test/extension.test.js +367 -0
  148. package/pi-extension/test/helpers.test.js +179 -0
  149. package/plugin.yaml +21 -12
  150. package/rules/antislop.mdc +18 -0
  151. package/scripts/build-openclaw-skills.js +60 -0
  152. package/scripts/check-repo.mjs +389 -0
  153. package/scripts/publish-openclaw-skills.js +75 -0
  154. package/scripts/skill-index.mjs +81 -0
  155. package/skills/antislop/SKILL.md +700 -0
  156. package/skills/antislop/VERSION +1 -0
  157. package/skills/antislop-code/SKILL.md +128 -0
  158. package/skills/antislop-copywriting/SKILL.md +407 -0
  159. package/skills/antislop-human/LICENSE-web-quality-skills +21 -0
  160. package/skills/antislop-human/SKILL.md +204 -0
  161. package/skills/antislop-human/contrast-check.py +168 -0
  162. package/skills/antislop-human/contrast-mcp.py +121 -0
  163. package/skills/antislop-human/references/A11Y-PATTERNS.md +249 -0
  164. package/skills/antislop-human/references/WCAG.md +127 -0
  165. package/skills/antislop-layoutmobile/SKILL.md +170 -0
  166. package/skills/antislop-ui/LICENSE-web-quality-skills +21 -0
  167. package/skills/antislop-ui/SKILL.md +344 -0
  168. package/skills/antislop-ui/references/CORE-WEB-VITALS.md +181 -0
  169. package/skills/antislop-ui/references/WEB-BASELINE.md +28 -0
  170. package/skills/plugin.json +5 -0
  171. package/src/package.json +3 -0
  172. package/tests/activation-map.test.js +63 -0
  173. package/tests/band-import.test.js +111 -0
  174. package/tests/band.test.tsx +520 -0
  175. package/tests/behavior.test.js +80 -0
  176. package/tests/gemini-extension.test.js +2 -2
  177. package/tests/grok-plugin.test.js +1 -1
  178. package/tests/hermes-plugin.test.js +1 -1
  179. package/tests/hooks-windows.test.js +205 -0
  180. package/tests/kimi-plugin.test.js +1 -1
  181. package/tests/map.test.js +30 -0
  182. package/tests/package-scripts.test.js +35 -0
  183. package/tests/package.test.js +23 -0
  184. package/tests/uninstall.test.js +202 -0
  185. /package/bin/{chill-agent.js → chill-agent.mjs} +0 -0
  186. /package/tests/{manifests.test.js → manifests.test.mjs} +0 -0
  187. /package/tests/{rules.test.js → rules.test.mjs} +0 -0
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ // Past sessions into the band's board: numbers per session, prices, the CSV the band reads.
3
+
4
+ const assert = require('assert');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+
9
+ const root = path.join(__dirname, '..');
10
+ const { CSV_HEADER, replyCost, sessionStats, csvRow, mergeRows, run } = require('../hooks/ponytail-band-import');
11
+
12
+ // The import writes the band's own columns.
13
+ const metrics = fs.readFileSync(path.join(root, 'hooks', 'band', 'metrics.ts'), 'utf8');
14
+ assert.equal(CSV_HEADER, /export const CSV_HEADER = '([^']+)'/.exec(metrics)[1], 'import and band must write the same CSV columns');
15
+
16
+ // Prices per reply: the official table, Haiku 5.5's long-prompt tier, unknown and fast replies left out.
17
+ assert.equal(replyCost('claude-opus-5-5', { input_tokens: 1e6, output_tokens: 1e6 }), 24);
18
+ assert.equal(replyCost('claude-opus-5-5', { cache_read_input_tokens: 1e6, cache_creation: { ephemeral_5m_input_tokens: 1e6, ephemeral_1h_input_tokens: 1e6 } }), 0.2 + 5 + 8);
19
+ assert.equal(replyCost('claude-haiku-4-5-20251001', { input_tokens: 1e6 }), 1, 'a dated model id is the same model');
20
+ assert.equal(replyCost('claude-haiku-5-5', { input_tokens: 100000 }), 0.01, 'up to 100,000 prompt tokens: the short price');
21
+ assert.equal(replyCost('claude-haiku-5-5', { input_tokens: 200000 }), 0.1, 'over 100,000 prompt tokens: the long price');
22
+ assert.equal(replyCost('claude-opus-5-5', { output_tokens: 0, server_tool_use: { web_search_requests: 3 } }), 0.03);
23
+ assert.equal(replyCost('claude-unknown-9', { input_tokens: 1 }), null);
24
+ assert.equal(replyCost('claude-opus-5-5', { input_tokens: 1, speed: 'fast' }), null);
25
+
26
+ // A session: two typed prompts, a reply repeated per content block, a tool round trip, a subagent.
27
+ const at = s => `2026-10-01T10:${s}Z`;
28
+ const prompt = (t, text) => ({ type: 'user', timestamp: at(t), origin: { kind: 'human' }, message: { content: text } });
29
+ const reply = (t, id, usage, model = 'claude-opus-5-5') => ({ type: 'assistant', timestamp: at(t), message: { id, model, usage } });
30
+ const tool = t => ({ type: 'user', timestamp: at(t), message: { content: [{ type: 'tool_result' }] } });
31
+ const u = (input, output) => ({ input_tokens: input, output_tokens: output });
32
+ const records = [
33
+ { type: 'user', timestamp: at('00:00'), message: { content: '<command-name>/ponytail</command-name>' } },
34
+ prompt('00:00', 'Fix the cart totals\nplease'),
35
+ reply('00:10', 'm1', u(1000, 100)),
36
+ reply('00:10', 'm1', u(1000, 100)),
37
+ tool('00:20'),
38
+ reply('01:00', 'm2', u(2000, 200)),
39
+ prompt('10:00', 'and the tax'),
40
+ reply('10:30', 'm3', u(3000, 300)),
41
+ prompt('20:00', 'thanks, that is all'),
42
+ ];
43
+ const sub = [reply('00:15', 's1', u(500, 50), 'claude-haiku-5-5'), reply('00:15', 's1', u(500, 50), 'claude-haiku-5-5')];
44
+ const s = sessionStats(records, sub, 'abcd1234-0000');
45
+ assert.equal(s.turns, 2, 'only prompts the model answered are turns');
46
+ assert.equal(s.agentMs, 60000 + 30000, 'agent time runs from each prompt to its last reply or tool result');
47
+ assert.equal(s.tokens, 1100 + 2200 + 3300 + 550, 'each message counts once, subagents included');
48
+ assert.equal(s.output, 650);
49
+ assert.equal(s.name, 'Fix the cart totals');
50
+ assert.equal(s.model, 'claude-opus-5-5');
51
+ const est = (6000 * 4 + 600 * 20) / 1e6 + (500 * 0.1 + 50 * 0.5) / 1e6;
52
+ assert.ok(Math.abs(s.cost - est) < 1e-9, 'without an own total the estimate is the cost');
53
+
54
+ // Claude Code's own total, when final, wins over a lower estimate; a stale $0 never does.
55
+ const owned = sessionStats([...records, { type: 'cost-state', sessionId: 'abcd1234-0000', totalCostUSD: 1.5 }], sub, 'abcd1234-0000');
56
+ assert.equal(owned.cost, 1.5);
57
+ const zero = sessionStats([...records, { type: 'cost-state', sessionId: 'abcd1234-0000', totalCostUSD: 0 }], sub, 'abcd1234-0000');
58
+ assert.ok(Math.abs(zero.cost - est) < 1e-9);
59
+ const early = sessionStats([records[1], { type: 'cost-state', sessionId: 'abcd1234-0000', totalCostUSD: 9 }, ...records.slice(2)], sub, 'abcd1234-0000');
60
+ assert.ok(Math.abs(early.cost - est) < 1e-9, 'a total written before later replies is not final');
61
+ assert.equal(sessionStats([...records, { type: 'custom-title', customTitle: 'Cart totals' }]).name, 'Cart totals', 'the sidebar name wins');
62
+
63
+ // A session reopened a day later with a slash command: a new turn, and the day between is not work.
64
+ const later = sessionStats([...records,
65
+ { type: 'user', timestamp: '2026-10-02T10:00:00Z', message: { content: '<command-name>/ponytail</command-name>' } },
66
+ { type: 'user', timestamp: '2026-10-02T10:00:00Z', isMeta: true, message: { content: [{ type: 'text', text: 'skill body' }] } },
67
+ { type: 'assistant', timestamp: '2026-10-02T10:00:05Z', message: { id: 'm9', model: 'claude-opus-5-5', usage: u(10, 1) } },
68
+ ]);
69
+ assert.equal(later.turns, 3, 'the slash command the model answered is a turn');
70
+ assert.equal(later.agentMs, 60000 + 30000 + 5000, 'only the 5 seconds of work count, not the day before');
71
+
72
+ // A permission prompt left open for hours inside a turn is not work either.
73
+ const waited = sessionStats([prompt('00:00', 'deploy it'), reply('00:10', 'w1', u(1, 1)),
74
+ { ...tool('00:20'), timestamp: '2026-10-01T15:00:20Z' }, { ...reply('00:00', 'w2', u(1, 1)), timestamp: '2026-10-01T15:00:30Z' }]);
75
+ assert.equal(waited.agentMs, 10000 + 10000, 'the five hours waiting for approval drop out');
76
+
77
+ // Nobody typed into it: a benchmark or `claude -p` run is not imported.
78
+ assert.equal(sessionStats(records.filter(r => !r.origin)), null);
79
+
80
+ // CSV: the band's format, an older header replaced, a recorded session never imported twice.
81
+ const row = csvRow('abcd1234-0000', 'shop', s);
82
+ assert.ok(row.startsWith(`abcd1234-${s.startedMs},`) && row.endsWith(',import,claude-opus-5-5'));
83
+ const old = 'key,started,project,name\nabcd1234-1,2026-10-01 10:00,shop,Live\n';
84
+ const merged = mergeRows(old, [row, csvRow('ffff0000-0000', 'shop', s)]);
85
+ assert.equal(merged.added, 1);
86
+ assert.equal(merged.text.split('\n')[0], CSV_HEADER);
87
+ assert.equal(mergeRows('', [row], new Set(['abcd1234-'])).added, 0, 'a session in another project file is not imported again');
88
+
89
+ // End to end: transcripts under CLAUDE_CONFIG_DIR, the ledger under XDG_CONFIG_HOME.
90
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-import-'));
91
+ const projects = path.join(temp, 'claude', 'projects', '-work-shop');
92
+ fs.mkdirSync(path.join(projects, 'abcd1234-0000', 'subagents'), { recursive: true });
93
+ const withCwd = r => ({ ...r, cwd: '/work/shop/.claude/worktrees/brave-otter' });
94
+ fs.writeFileSync(path.join(projects, 'abcd1234-0000.jsonl'), records.map(withCwd).map(r => JSON.stringify(r)).join('\n') + '\n');
95
+ fs.writeFileSync(path.join(projects, 'abcd1234-0000', 'subagents', 'agent-1.jsonl'), sub.map(r => JSON.stringify(r)).join('\n'));
96
+ fs.writeFileSync(path.join(projects, 'beef0000-1111.jsonl'), records.filter(r => !r.origin).map(withCwd).map(r => JSON.stringify(r)).join('\n'));
97
+ const env = { CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME };
98
+ process.env.CLAUDE_CONFIG_DIR = path.join(temp, 'claude');
99
+ process.env.XDG_CONFIG_HOME = path.join(temp, 'config');
100
+ try {
101
+ assert.deepEqual(run(), { sessions: 1, added: 1 }, 'the typed session is imported, the automated one is not');
102
+ const csv = fs.readFileSync(path.join(temp, 'config', 'ponytail', 'band', 'shop.csv'), 'utf8');
103
+ assert.equal(csv.split('\n')[0], CSV_HEADER, 'a desktop worktree counts for its repo');
104
+ assert.ok(csv.includes(',shop,Fix the cart totals,'));
105
+ assert.deepEqual(run(), { sessions: 1, added: 0 }, 'a second import adds nothing');
106
+ } finally {
107
+ for (const [k, v] of Object.entries(env)) if (v === undefined) delete process.env[k]; else process.env[k] = v;
108
+ fs.rmSync(temp, { recursive: true, force: true });
109
+ }
110
+
111
+ console.log('band import checks passed');
@@ -0,0 +1,520 @@
1
+ import { describe, expect, mock, test } from 'claude-code/testing'
2
+
3
+ import { boardHtml, parseCsv, tasksFrom } from '../hooks/band/board'
4
+ import type { Band } from '../hooks/band/types'
5
+ import {
6
+ NO_TOKENS,
7
+ addTokens,
8
+ addedLines,
9
+ callsIn,
10
+ defsIn,
11
+ duration,
12
+ elapsed,
13
+ fmtTokens,
14
+ inProject,
15
+ isTestCommand,
16
+ mainModel,
17
+ markdown,
18
+ metricsSvg,
19
+ parseDefs,
20
+ percent,
21
+ resume,
22
+ sessionTitle,
23
+ snapshot,
24
+ testCounts,
25
+ totalTokens,
26
+ upsertCsv,
27
+ CSV_HEADER,
28
+ webhookRequest,
29
+ } from '../hooks/band/metrics'
30
+
31
+ describe('board', () => {
32
+ const CSV = 'key,started,project,name,cost_usd,turns,agent_minutes,tokens_total,tokens_output,reuse_pct,reused,new,callers_open,tests,level\n' +
33
+ 'a-1,2026-10-15 12:00,shop,"Fix ""cart"", totals\nand tax",3.10,6,26.0,1200000,85000,83,5,1,0,14 passed,full\n' +
34
+ 'b-2,2026-10-14 09:30,shop,<script>alert(1)</script>,0.50,2,1.5,40000,900,,0,0,0,not run,lite\n'
35
+
36
+ test('reads quoted CSV fields with commas, quotes and line breaks', () => {
37
+ const rows = parseCsv(CSV)
38
+ expect(rows.length).toBe(3)
39
+ expect(rows[1]?.[3]).toBe('Fix "cart", totals\nand tax')
40
+ const tasks = tasksFrom(CSV)
41
+ expect(tasks[0]).toEqual({ started: '2026-10-15 12:00', project: 'shop', name: 'Fix "cart", totals\nand tax', cost: 3.1, turns: 6, agentMinutes: 26, tokens: 1_200_000, reuse: 83, tests: '14 passed', level: 'full', model: '' })
42
+ expect(tasks[1]?.reuse).toBeNull()
43
+ expect(tasksFrom('not,a,ledger\n1,2,3')).toEqual([])
44
+ })
45
+
46
+ const dataOf = (html: string) => JSON.parse(/<script type="application\/json" id="board-data">([\s\S]*?)<\/script>/.exec(html)?.[1] ?? 'null')
47
+
48
+ test('the board carries every task as data no name can break out of', () => {
49
+ const html = boardHtml(tasksFrom(CSV), Date.UTC(2026, 9, 15, 12, 0))
50
+ const data = dataOf(html)
51
+ expect(data.tasks.length).toBe(2)
52
+ expect(data.tasks[1].name).toBe('<script>alert(1)</script>')
53
+ expect(html).not.toContain('<script>alert(1)')
54
+ expect(html).toContain('\\u003cscript>alert(1)\\u003c/script>')
55
+ expect(html).toContain('not a bill')
56
+ expect(html).toContain('id="card"')
57
+ expect(html.split('id="pt-logo"').length).toBe(2)
58
+ expect(html).toContain('href="#pt-logo"')
59
+ expect(html).toContain('<link rel="icon" href="data:image/svg+xml,')
60
+ expect(html).toContain('id="c-time"')
61
+ expect(html).not.toContain('innerHTML')
62
+ expect(html).toContain('id="theme"')
63
+ expect(html).toContain('<html lang="en" data-theme="dark">')
64
+ expect(html).toContain(':root[data-theme=light]{')
65
+ expect(html).toContain(':root[data-theme=system]{')
66
+ })
67
+
68
+ test('an empty board still loads and says there are no sessions yet', () => {
69
+ const html = boardHtml([], Date.UTC(2026, 9, 15, 12, 0))
70
+ expect(dataOf(html).tasks).toEqual([])
71
+ expect(html).toContain('No sessions yet')
72
+ })
73
+ })
74
+
75
+ describe('metrics', () => {
76
+ test('adds up tokens and prints them short', () => {
77
+ const t = addTokens(addTokens(NO_TOKENS, { input_tokens: 10, output_tokens: 90, cache_read_input_tokens: 1000, cache_creation_input_tokens: 0 }), { input_tokens: 0, output_tokens: 10, cache_read_input_tokens: 140, cache_creation_input_tokens: 0 })
78
+ expect(totalTokens(t)).toBe(1250)
79
+ expect(t.output).toBe(100)
80
+ expect([fmtTokens(950), fmtTokens(1234), fmtTokens(1_240_000), fmtTokens(2000)]).toEqual(['950', '1.2k', '1.2M', '2k'])
81
+ })
82
+
83
+ const BASE: Band = {
84
+ level: 'full', model: 'claude-opus-5-5', cost: 31.9, spent: 3.1, turns: 6, turnCosts: [0.42], tokens: { input: 5000, output: 85_000, cacheRead: 1_100_000, cacheWrite: 10_000 },
85
+ busyMs: 1_560_000, name: 'Fix the cart | totals, again', project: 'shop', session: '4c69e3db-57a0', transcript: '', reused: ['to_cents'], created: [],
86
+ removed: [], openCallers: [], tests: { state: 'pass', passed: 14 }, details: false,
87
+ }
88
+
89
+ test('a band that starts again mid-session goes on from its row instead of from zero', () => {
90
+ const csv = `${CSV_HEADER}\nother-1,2026-10-14 09:00,shop,Other,1.00,2,3.0,500,50,,0,0,0,not run,full\n4c69e3db-7,2026-10-15 01:10,shop,"Fix the cart, again",46.09,32,55.5,107900000,250000,,0,0,0,stale,full\n`
91
+ const fresh: Band = { ...BASE, name: '', busyMs: 0, tokens: NO_TOKENS }
92
+ const b = resume(fresh, csv, '4c69e3db-7')
93
+ expect(b.name).toBe('Fix the cart, again')
94
+ expect(b.busyMs).toBe(55.5 * 60000)
95
+ expect(totalTokens(b.tokens)).toBe(107_900_000)
96
+ expect(b.tokens.output).toBe(250_000)
97
+ // A band that already counted more keeps its own numbers and name.
98
+ const ahead: Band = { ...BASE, busyMs: 4_000_000, tokens: { input: 0, output: 1, cacheRead: 200_000_000, cacheWrite: 0 } }
99
+ expect(resume(ahead, csv, '4c69e3db-7')).toEqual(ahead)
100
+ expect(resume(fresh, csv, 'missing-1')).toEqual(fresh)
101
+ expect(resume(fresh, '', '4c69e3db-7')).toEqual(fresh)
102
+ })
103
+
104
+ test('the row names the model that wrote most replies, and an older ledger keeps its rows', () => {
105
+ expect(mainModel('"model":"claude-opus-5-5"\n"model":"claude-haiku-5-5"\n"model":"claude-opus-5-5"\n')).toBe('claude-opus-5-5')
106
+ expect(mainModel('')).toBe('')
107
+ const old = 'key,started,project,name,cost_usd,turns,agent_minutes,tokens_total,tokens_output,reuse_pct,reused,new,callers_open,tests,level\n' +
108
+ 'other-1,2026-10-14 09:00,shop,Other,1.00,2,3.0,500,50,,0,0,0,not run,full\n'
109
+ const csv = upsertCsv(old, snapshot(BASE, Date.UTC(2026, 9, 15, 12, 0)))
110
+ expect(csv.split('\n')[0]).toBe(CSV_HEADER)
111
+ expect(csv.split('\n').filter(line => line.startsWith('key,')).length).toBe(1)
112
+ expect(tasksFrom(csv).map(t => t.model)).toEqual(['', 'claude-opus-5-5'])
113
+ expect(markdown(snapshot(BASE, Date.UTC(2026, 9, 15, 12, 0)))).toContain('| Model | claude-opus-5-5 |')
114
+ })
115
+
116
+ test('the webhook sends the token only over https, or to this machine', () => {
117
+ const row = snapshot(BASE, Date.UTC(2026, 9, 15, 12, 0))
118
+ const sent = webhookRequest('https://board.example.com/tasks', 'ptb_x', true, row)
119
+ expect('refused' in sent).toBe(false)
120
+ if (!('refused' in sent)) {
121
+ expect(sent.init.headers.authorization).toBe('Bearer ptb_x')
122
+ expect(JSON.parse(sent.init.body).name).toBe('Fix the cart | totals, again')
123
+ }
124
+ expect(webhookRequest('http://board.example.com/tasks', 'ptb_x', true, row)).toEqual({ refused: 'webhook needs https' })
125
+ expect(webhookRequest('not a url', 'ptb_x', true, row)).toEqual({ refused: 'webhook URL is not a URL' })
126
+ expect('refused' in webhookRequest('http://127.0.0.1:4711/tasks', 'ptb_x', true, row)).toBe(false)
127
+ const quiet = webhookRequest('https://board.example.com/tasks', '', false, row)
128
+ if (!('refused' in quiet)) {
129
+ expect(quiet.init.headers.authorization).toBeUndefined()
130
+ expect(JSON.parse(quiet.init.body).name).toBe('')
131
+ }
132
+ })
133
+
134
+ test('agent time reads in seconds, minutes, hours', () => {
135
+ expect([elapsed(45_000), elapsed(130_000), elapsed(4_320_000)]).toEqual(['45s', '2m 10s', '1h 12m'])
136
+ })
137
+
138
+ test('the sidebar name is the last custom-title record of the transcript', () => {
139
+ const lines = [
140
+ JSON.stringify({ type: 'custom-title', customTitle: 'First name', sessionId: 's' }),
141
+ JSON.stringify({ type: 'assistant', meta: { type: 'custom-title', customTitle: 'not a record' } }),
142
+ JSON.stringify({ type: 'custom-title', customTitle: ' GitHub issues triage ' }),
143
+ '{"type":"custom-title", broken',
144
+ ]
145
+ expect(sessionTitle(lines.join('\n'))).toBe('GitHub issues triage')
146
+ expect(sessionTitle(lines.slice(0, 2).join('\n'))).toBe('First name')
147
+ expect(sessionTitle(JSON.stringify({ type: 'custom-title', customTitle: ' ' }))).toBeUndefined()
148
+ expect(sessionTitle(JSON.stringify({ type: 'user', message: { content: 'no title yet' } }))).toBeUndefined()
149
+ expect(sessionTitle(JSON.stringify({ type: 'custom-title', customTitle: 'x'.repeat(80) }))?.length).toBe(60)
150
+ })
151
+
152
+ test('the Markdown export holds every value and the task name', () => {
153
+ const md = markdown(snapshot(BASE, Date.UTC(2026, 9, 15, 12, 0)))
154
+ expect(md.split('\n')[0]).toBe('## Fix the cart \\| totals, again')
155
+ expect(md).toContain('| Cost | $3.10 · last turn $0.42 · ⌀ $0.52 / turn |')
156
+ expect(md).toContain('| Agent time | 26m 0s · ⌀ 4m 20s / turn |')
157
+ expect(md).toContain('| Tokens | 1.2M (input 5k, output 85k, cache read 1.1M, cache write 10k) |')
158
+ expect(md).toContain('| Tests | 14 passed |')
159
+ })
160
+
161
+ test('the CSV keeps one row per task and replaces it on every turn', () => {
162
+ const at = Date.UTC(2026, 9, 15, 12, 0)
163
+ const first = upsertCsv('', snapshot(BASE, at))
164
+ const lines = first.trim().split('\n')
165
+ expect(lines.length).toBe(2)
166
+ expect(lines[1]).toContain('"Fix the cart | totals, again"')
167
+ const again = upsertCsv(first, snapshot({ ...BASE, spent: 4.2, turns: 7 }, at))
168
+ expect(again.trim().split('\n').length).toBe(2)
169
+ expect(again).toContain(',4.20,7,')
170
+ const other = upsertCsv(again, snapshot({ ...BASE, session: 'ffff0000' }, at + 1))
171
+ expect(other.trim().split('\n').length).toBe(3)
172
+ })
173
+
174
+ test('the desktop row is one SVG with real dividers, escaped and cut to fit', () => {
175
+ const svg = metricsSvg(
176
+ [
177
+ { key: 'cost', label: 'cost', value: '$1.20', sub: '' },
178
+ { key: 'callers', label: 'callers', value: '2 open', sub: 'a&b.py:1, a very long path/that/does/not/fit.py:9', tone: 'warning' },
179
+ ],
180
+ [0.1, 0.4],
181
+ 300,
182
+ )
183
+ expect(svg.startsWith('<svg')).toBe(true)
184
+ expect(svg.match(/<line /g)?.length).toBe(2)
185
+ expect(svg).toContain('a&amp;b.py')
186
+ expect(svg).toContain('…')
187
+ expect(svg).toContain('class="v warning"')
188
+ expect(svg.match(/<rect /g)?.length).toBe(2)
189
+ })
190
+
191
+ test('finds top-level definitions per language', () => {
192
+ expect(defsIn('a.py', 'def to_cents(x):\n pass\nclass Cart:\n def total(self): pass')).toEqual(['to_cents', 'Cart'])
193
+ expect(defsIn('a.ts', 'export function formatMoney() {}\nfunction local() {}\nconst x = 1')).toEqual(['formatMoney', 'local'])
194
+ expect(defsIn('README.md', 'def nope():')).toEqual([])
195
+ })
196
+
197
+ test('counts calls of existing helpers, never new ones, test files or generic names', () => {
198
+ const known = parseDefs('utils/money.py:4:def to_cents(v):\nutils/io.py:1:def load(p):\ntests/test_x.py:1:def helper():')
199
+ expect([...known.keys()]).toEqual(['to_cents', 'load'])
200
+ const added = addedLines('x = 1', 'x = 1\ny = to_cents(p)\nz = load(f)\nw = parse_price(s)')
201
+ expect(added).not.toContain('x = 1')
202
+ expect(callsIn(added, known, ['parse_price'])).toEqual(['to_cents'])
203
+ })
204
+
205
+ test('only files under the project root count, with either slash', () => {
206
+ expect(inProject('/repo/shop/cart.py', '/repo/shop')).toBe(true)
207
+ expect(inProject('/repo/shop/cart.py', '/repo/shop/')).toBe(true)
208
+ expect(inProject('/tmp/scratch/drive.py', '/repo/shop')).toBe(false)
209
+ expect(inProject('/repo/shop-old/cart.py', '/repo/shop')).toBe(false)
210
+ expect(inProject('C:\\Users\\a\\shop\\cart.py', 'C:/Users/a/shop')).toBe(true)
211
+ })
212
+
213
+ test('recognises test runs and their counts', () => {
214
+ expect(isTestCommand('npm test')).toBe(true)
215
+ expect(isTestCommand('cd api && python -m pytest -q')).toBe(true)
216
+ expect(isTestCommand('npm install')).toBe(false)
217
+ expect(testCounts('===== 14 passed, 1 failed in 0.31s =====')).toEqual({ passed: 14, failed: 1 })
218
+ expect(testCounts('ℹ pass 141\nℹ fail 1')).toEqual({ passed: 141, failed: 1 })
219
+ expect(testCounts('ok')).toEqual({ passed: undefined, failed: undefined })
220
+ })
221
+
222
+ test('shows the reuse share only once there is something to share, and a readable time', () => {
223
+ expect(percent(5, 1)).toBe(83)
224
+ expect(percent(0, 1)).toBeUndefined()
225
+ expect(percent(0, 0)).toBeUndefined()
226
+ expect(duration(45)).toBe('45m')
227
+ expect(duration(210)).toBe('3h 30m')
228
+ })
229
+ })
230
+
231
+ const BAND = {
232
+ component: 'AbovePrompt',
233
+ props: { hasSurvey: false, isWorking: false, maxRows: 12, bodyColumns: 100, scroll: { offset: 0, bodyRows: 12 }, view: {} },
234
+ } as const
235
+
236
+ test('the band switches between compact and details on terminal and desktop', async ($, on) => {
237
+ mock.clock(on)
238
+ mock.store(on)
239
+ for (const surface of ['terminal', 'desktop'] as const) {
240
+ const ui = await $.ui.mount({ plugin: 'ponytail', surface, ...BAND })
241
+ expect(await ui.find({ key: 'level' })).toBeDefined()
242
+ expect(await ui.find({ key: 'board' })).toBeDefined()
243
+ expect(await ui.find({ key: 'level-ultra' })).toBeUndefined()
244
+
245
+ await ui.press({ key: 'details' })
246
+ expect(await ui.find({ key: 'level-ultra' })).toBeDefined()
247
+ expect(await ui.find({ key: 'board' })).toBeDefined()
248
+ if (surface === 'desktop') expect(await ui.find({ type: 'Svg' })).toBeDefined()
249
+ else expect(await ui.find({ type: 'Text', text: /cost/ })).toBeDefined()
250
+
251
+ await ui.press({ key: 'details' })
252
+ expect(await ui.find({ key: 'level' })).toBeDefined()
253
+ await ui.unmount()
254
+ }
255
+ })
256
+
257
+ test('the row takes bodyColumns only in the terminal, where a Button is as wide as its cells', async ($, on) => {
258
+ mock.clock(on)
259
+ mock.store(on)
260
+ for (const [surface, width] of [['terminal', 100], ['vscode', undefined]] as const) {
261
+ const ui = await $.ui.mount({ plugin: 'ponytail', surface, ...BAND })
262
+ expect((await ui.find({ type: 'Box' }))?.props.width).toBe(width)
263
+ await ui.press({ key: 'details' })
264
+ expect((await ui.find({ type: 'Box' }))?.props.width).toBe(width)
265
+ await ui.unmount()
266
+ }
267
+ })
268
+
269
+ test('the details view has its line without empty rows, and off the terminal its columns share the row', async ($, on) => {
270
+ mock.clock(on)
271
+ mock.store(on)
272
+ for (const surface of ['terminal', 'vscode'] as const) {
273
+ const ui = await $.ui.mount({ plugin: 'ponytail', surface, ...BAND })
274
+ await ui.press({ key: 'details' })
275
+ expect(await ui.find({ type: 'Text', text: /─/ })).toBeDefined()
276
+ expect((await ui.find({ type: 'Box' }))?.props.gap).toBe(0)
277
+ const cost = (await ui.find({ key: 'cost' }))?.props
278
+ expect(surface === 'terminal' ? cost?.width : cost?.flexGrow).toBe(surface === 'terminal' ? 31 : 1)
279
+ // The view is stored, so the next surface would start in details: switch back.
280
+ await ui.press({ key: 'details' })
281
+ await ui.unmount()
282
+ }
283
+ // The desktop keeps a gap above its SVG, whose top line would otherwise touch the buttons.
284
+ const ui = await $.ui.mount({ plugin: 'ponytail', surface: 'desktop', ...BAND })
285
+ await ui.press({ key: 'details' })
286
+ expect((await ui.find({ type: 'Box' }))?.props.gap).toBe(1)
287
+ await ui.unmount()
288
+ })
289
+
290
+ test('after /ponytail band the band draws nothing', async ($, on) => {
291
+ mock.clock(on)
292
+ mock.store(on)
293
+ on('env.get', ($, e) => ({ value: e.name === 'HOME' ? '/home/u' : undefined }))
294
+ on('fs.read', ($, e, next) => (e.path === '/home/u/.claude/.ponytail-band' ? { value: 'off' } : next(e)))
295
+ // What Claude Code draws without the band.
296
+ on('ui.render', ($, e) => {
297
+ const { Text } = $.ui.resolve(e)
298
+ return <Text>prompt only</Text>
299
+ })
300
+ const ui = await $.ui.mount({ plugin: 'ponytail', surface: 'terminal', ...BAND })
301
+ expect(await ui.find({ type: 'Text', text: /prompt only/ })).toBeDefined()
302
+ expect(await ui.find({ key: 'level' })).toBeUndefined()
303
+ expect(await ui.find({ key: 'board' })).toBeUndefined()
304
+ await ui.unmount()
305
+ })
306
+
307
+ for (const command of ['clear', 'resume', 'branch']) {
308
+ test(`after /${command} the band shows the level again, with no session.start`, async ($, on) => {
309
+ mock.clock(on)
310
+ mock.store(on)
311
+ let session = 's1'
312
+ on('env.get', ($, e) => ({ value: e.name === 'HOME' ? '/home/u' : undefined }))
313
+ on('fs.read', ($, e, next) => (e.path === '/home/u/.claude/.ponytail-active' ? { value: 'full' } : next(e)))
314
+ on('session.repo', () => ({ value: { root: '/repo/shop' } }) as never)
315
+ on('session.id', () => ({ value: session }))
316
+ // /clear, /resume and /branch go on under another session id; /help stays in this one.
317
+ on('command.run', ($, e) => {
318
+ if (e.command !== 'help') session = 's2'
319
+ return { text: '' }
320
+ })
321
+ const ui = await $.ui.mount({ plugin: 'ponytail', surface: 'terminal', ...BAND })
322
+ await $.command.run({ command: 'help', args: '' })
323
+ expect(await ui.find({ key: 'level', text: /full/ })).toBeUndefined()
324
+ await $.command.run({ command, args: '' })
325
+ expect(await ui.find({ key: 'level', text: /full/ })).toBeDefined()
326
+ await ui.unmount()
327
+ })
328
+ }
329
+
330
+ test("another plugin's command runs without the band's hook, so its output is not labelled ponytail", async ($, on) => {
331
+ let reads = 0
332
+ on('session.id', () => (reads++, { value: 's1' }))
333
+ on('command.run', () => ({ text: '' }))
334
+ await $.command.run({ command: 'anymodel', args: '' })
335
+ expect(reads).toBe(0)
336
+ })
337
+
338
+ test('the ledger is written on Windows, where there is no mkdir', async ($, on) => {
339
+ mock.clock(on)
340
+ mock.store(on)
341
+ const env: Record<string, string> = { OS: 'Windows_NT', XDG_CONFIG_HOME: 'C:/Users/u/.config' }
342
+ on('env.get', ($, e) => ({ value: env[e.name] }))
343
+ on('session.repo', () => ({ value: { root: 'C:/repo/shop' } }) as never)
344
+ on('session.id', () => ({ value: 's1' }))
345
+ on('session.turns', () => ({ value: 1 }))
346
+ on('session.usage', () => ({ value: { startedAt: 0, cost: { usd: 0.5 } } }) as never)
347
+ on('process.run', ($, e) => (e.argv[0] === 'mkdir' ? { deny: 'mkdir: not found' } : ({ value: { exitCode: 1, stdout: '', stderr: '' } } as never)))
348
+ on('fs.read', () => ({ deny: 'missing' }) as never)
349
+ on('fs.list', () => ({ value: [] }))
350
+ const written: string[] = []
351
+ on('fs.write', ($, e) => {
352
+ // The engine writes the path with this OS's separator.
353
+ written.push(e.path.replace(/\\/g, '/'))
354
+ return { value: undefined }
355
+ })
356
+ const status: unknown[] = []
357
+ on('ui.status', ($, e) => {
358
+ status.push(e)
359
+ return { value: undefined }
360
+ })
361
+ on('session.start', ($, e) => ({ cwd: e.cwd }))
362
+ on('turn.complete', () => ({ text: '' }))
363
+ await $.session.start({ cwd: 'C:/repo/shop', surface: null } as never)
364
+ await $.turn.complete({ answer: '', durationMs: 1000 } as never)
365
+ expect(JSON.stringify(status)).not.toContain('could not write the task ledger')
366
+ // Off Windows a C: path is relative, so the working dir comes in front of it: compare the end.
367
+ expect(written.length).toBe(2)
368
+ expect(written[0]?.endsWith('C:/Users/u/.config/ponytail/band/shop.csv')).toBe(true)
369
+ expect(written[1]?.endsWith('C:/Users/u/.config/ponytail/band/board.html')).toBe(true)
370
+ })
371
+
372
+ test('the transcript is found with no classic hook, in its named folder or any other', async ($, on) => {
373
+ mock.clock(on)
374
+ mock.store(on)
375
+ let session = 's1'
376
+ const projects = '/home/u/.claude/projects'
377
+ let titles: Record<string, string> = { [`${projects}/-repo-shop-v2/s1.jsonl`]: 'Cart totals' }
378
+ on('env.get', ($, e) => ({ value: e.name === 'HOME' ? '/home/u' : undefined }))
379
+ on('session.repo', () => ({ value: { root: '/repo/shop' } }) as never)
380
+ on('session.root', () => ({ value: '/repo/shop.v2' }))
381
+ on('session.id', () => ({ value: session }))
382
+ on('session.turns', () => ({ value: 1 }))
383
+ on('session.usage', () => ({ value: { startedAt: 0, cost: { usd: 0.5 } } }) as never)
384
+ const listed: string[] = []
385
+ on('fs.list', ($, e) => {
386
+ listed.push(e.path)
387
+ return { value: e.path === projects ? [{ name: '-repo-shop-v2', kind: 'dir' }, { name: 'notes.txt', kind: 'file' }, { name: '-wt', kind: 'dir' }] : [] } as never
388
+ })
389
+ on('fs.exists', ($, e) => ({ value: e.path in titles }))
390
+ on('process.run', ($, e) => {
391
+ const title = e.argv[0] === 'grep' && e.argv[1] === '-F' ? titles[e.argv.at(-1) ?? ''] : undefined
392
+ return { value: { exitCode: title ? 0 : 1, stdout: title ? `${JSON.stringify({ type: 'custom-title', customTitle: title })}\n` : '', stderr: '' } } as never
393
+ })
394
+ on('fs.read', () => ({ deny: 'missing' }) as never)
395
+ const rows: string[] = []
396
+ on('fs.write', ($, e) => {
397
+ if (e.path.endsWith('shop.csv')) rows.push(e.text)
398
+ return { value: undefined }
399
+ })
400
+ on('session.start', ($, e) => ({ cwd: e.cwd }))
401
+ on('turn.complete', () => ({ text: '' }))
402
+ // Only non-classic events fire, as when cc-plugin-sec-default skips every classic.* hook.
403
+ await $.session.start({ cwd: '/repo/shop.v2', surface: null } as never)
404
+ await $.turn.complete({ answer: '', durationMs: 1000 } as never)
405
+ expect(rows.at(-1)).toContain('Cart totals')
406
+ // The folder its rule names held the file, so no folder was listed.
407
+ expect(listed).not.toContain(projects)
408
+ // A worktree moves the file under the same id, and the session is renamed there.
409
+ titles = { [`${projects}/-wt/s1.jsonl`]: 'Cart totals, split' }
410
+ await $.turn.complete({ answer: '', durationMs: 1000 } as never)
411
+ expect(rows.at(-1)).toContain('Cart totals, split')
412
+ // /clear goes on under another id, in a folder the rule does not name (a hashed long name, say).
413
+ session = 's2'
414
+ titles = { ...titles, [`${projects}/-wt/s2.jsonl`]: 'Refunds' }
415
+ await $.turn.complete({ answer: '', durationMs: 1000 } as never)
416
+ expect(rows.at(-1)).toContain('Refunds')
417
+ })
418
+
419
+ test('without HOME (Windows) the band reads its flags under USERPROFILE', async ($, on) => {
420
+ mock.clock(on)
421
+ mock.store(on)
422
+ on('env.get', ($, e) => ({ value: e.name === 'USERPROFILE' ? 'C:\\Users\\u' : undefined }))
423
+ // The engine may hand the path on with either separator, and off Windows a C: path is relative,
424
+ // so the working dir comes in front of it: compare the end.
425
+ const reads: string[] = []
426
+ on('fs.read', ($, e, next) => {
427
+ reads.push(e.path.replaceAll('\\', '/'))
428
+ return reads.at(-1)?.endsWith('C:/Users/u/.claude/.ponytail-band') ? { value: 'off' } : next(e)
429
+ })
430
+ on('ui.render', ($, e) => {
431
+ const { Text } = $.ui.resolve(e)
432
+ return <Text>prompt only</Text>
433
+ })
434
+ const ui = await $.ui.mount({ plugin: 'ponytail', surface: 'terminal', ...BAND })
435
+ expect(reads.some(path => path.endsWith('C:/Users/u/.claude/.ponytail-band'))).toBe(true)
436
+ expect(await ui.find({ key: 'level' })).toBeUndefined()
437
+ await ui.unmount()
438
+ })
439
+
440
+ test('another mod drawing above the prompt shows below the band, in compact and in details', async ($, on) => {
441
+ mock.clock(on)
442
+ mock.store(on)
443
+ // Another mod in the same slot, beneath ponytail in the chain.
444
+ on('ui.render', ($, e) => {
445
+ const { Text } = $.ui.resolve(e)
446
+ return <Text>other band</Text>
447
+ })
448
+ for (const surface of ['terminal', 'desktop'] as const) {
449
+ const ui = await $.ui.mount({ plugin: 'ponytail', surface, ...BAND })
450
+ expect(await ui.find({ key: 'level' })).toBeDefined()
451
+ expect(await ui.find({ type: 'Text', text: /other band/ })).toBeDefined()
452
+
453
+ await ui.press({ key: 'details' })
454
+ expect(await ui.find({ key: 'level-ultra' })).toBeDefined()
455
+ expect(await ui.find({ type: 'Text', text: /other band/ })).toBeDefined()
456
+ // back to compact for the next surface
457
+ await ui.press({ key: 'details' })
458
+ await ui.unmount()
459
+ }
460
+ })
461
+
462
+ test('review and audit run the same commands a person types', async ($, on) => {
463
+ mock.clock(on)
464
+ mock.store(on)
465
+ const sent: string[] = []
466
+ on('command.run', ($, e) => {
467
+ sent.push(`/${e.command} ${e.args}`.trim())
468
+ return { text: '' }
469
+ })
470
+ const ui = await $.ui.mount({ plugin: 'ponytail', surface: 'terminal', ...BAND })
471
+ // The level pill opens the four levels instead of switching: no command, no turn.
472
+ await ui.press({ key: 'level' })
473
+ expect(await ui.find({ key: 'level-ultra' })).toBeDefined()
474
+ expect(sent).toEqual([])
475
+ await ui.press({ key: 'level-lite' })
476
+ expect(sent).toEqual(['/ponytail:ponytail lite'])
477
+ sent.length = 0
478
+ await ui.press({ key: 'review' })
479
+ await ui.press({ key: 'audit' })
480
+ expect(sent).toEqual(['/ponytail:ponytail-review', '/ponytail:ponytail-audit'])
481
+ await ui.unmount()
482
+ })
483
+
484
+ test('copy hands the task to the clipboard as Markdown', async ($, on) => {
485
+ mock.clock(on)
486
+ mock.store(on)
487
+ const copied: string[] = []
488
+ on('ui.copy', ($, e) => {
489
+ copied.push(e.text)
490
+ return { value: { isCopied: true as const } }
491
+ })
492
+ on('ui.toast', () => ({ value: undefined }))
493
+ const ui = await $.ui.mount({ plugin: 'ponytail', surface: 'terminal', ...BAND })
494
+ await ui.press({ key: 'details' })
495
+ expect(await ui.find({ key: 'task' })).toBeUndefined()
496
+ await ui.press({ key: 'copy' })
497
+ expect(copied.length).toBe(1)
498
+ expect(copied[0]).toContain('## Untitled session')
499
+ expect(copied[0]).toContain('| Turns | 0 |')
500
+ await ui.unmount()
501
+ })
502
+
503
+ test('a test run shows its count, and an edit in the project after it marks the tests stale', async ($, on) => {
504
+ mock.clock(on)
505
+ on('session.repo', () => ({ value: { root: '/repo' } }) as never)
506
+ on('tool.call', { tool: 'Bash' }, () => ({ result: { stdout: 'ℹ pass 14', stderr: '', interrupted: false }, text: 'ℹ pass 14' }) as never)
507
+ on('tool.call', { tool: 'Edit' }, () => ({ result: {}, text: 'ok' }) as never)
508
+ const ui = await $.ui.mount({ plugin: 'ponytail', surface: 'terminal', ...BAND })
509
+
510
+ await $.tool.call({ tool: 'Bash', command: 'npm test' } as never)
511
+ expect(await ui.find({ type: 'Text', text: /✓ 14 tests/ })).toBeDefined()
512
+
513
+ // A scratch script outside the project changes nothing (#1300).
514
+ await $.tool.call({ tool: 'Edit', file_path: '/tmp/scratch/drive.py', old_string: 'a = 1', new_string: 'def send():\n pass', replace_all: false } as never)
515
+ expect(await ui.find({ type: 'Text', text: /✓ 14 tests/ })).toBeDefined()
516
+
517
+ await $.tool.call({ tool: 'Edit', file_path: '/repo/cart.py', old_string: 'a = 1', new_string: 'a = 2', replace_all: false } as never)
518
+ expect(await ui.find({ type: 'Text', text: /no test since change/ })).toBeDefined()
519
+ await ui.unmount()
520
+ })