diff --git a/package.json b/package.json
index bd4933a..ce29c6d 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,9 @@
"typecheck": "tsc --noEmit",
"registry:build": "shadcn build registry.json --output ./public/r",
"postinstall": "fumadocs-mdx",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:coverage": "vitest run --coverage",
"check": "ultracite check",
"fix": "ultracite fix",
"prepare": "lefthook install"
@@ -60,13 +63,16 @@
"@types/node": "^25",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
+ "@vitest/coverage-v8": "^4.1.2",
+ "ink-testing": "^0.2.0",
"lefthook": "^2.1.4",
"oxfmt": "^0.43.0",
"oxlint": "^1.58.0",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0",
"typescript": "^6",
- "ultracite": "7.4.3"
+ "ultracite": "7.4.3",
+ "vitest": "^4.1.2"
},
"engines": {
"node": ">=20.9.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2b30d55..a2c9183 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -138,6 +138,12 @@ importers:
'@types/react-dom':
specifier: 19.2.3
version: 19.2.3(@types/react@19.2.14)
+ '@vitest/coverage-v8':
+ specifier: ^4.1.2
+ version: 4.1.2(vitest@4.1.2(@types/node@25.5.2)(msw@2.12.7(@types/node@25.5.2)(typescript@6.0.2))(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.8.0)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)))
+ ink-testing:
+ specifier: ^0.2.0
+ version: 0.2.0(@types/react@19.2.14)(ink@6.8.0(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
lefthook:
specifier: ^2.1.4
version: 2.1.4
@@ -159,6 +165,9 @@ importers:
ultracite:
specifier: 7.4.3
version: 7.4.3(oxlint@1.58.0)
+ vitest:
+ specifier: ^4.1.2
+ version: 4.1.2(@types/node@25.5.2)(msw@2.12.7(@types/node@25.5.2)(typescript@6.0.2))(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.8.0)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1))
packages:
@@ -338,6 +347,10 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
+ '@bcoe/v8-coverage@1.0.2':
+ resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
+ engines: {node: '>=18'}
+
'@clack/core@1.2.0':
resolution: {integrity: sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==}
@@ -2098,9 +2111,15 @@ packages:
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
+ '@types/chai@5.2.3':
+ resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
+ '@types/deep-eql@4.0.2':
+ resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+
'@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
@@ -2174,6 +2193,44 @@ packages:
vue-router:
optional: true
+ '@vitest/coverage-v8@4.1.2':
+ resolution: {integrity: sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==}
+ peerDependencies:
+ '@vitest/browser': 4.1.2
+ vitest: 4.1.2
+ peerDependenciesMeta:
+ '@vitest/browser':
+ optional: true
+
+ '@vitest/expect@4.1.2':
+ resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==}
+
+ '@vitest/mocker@4.1.2':
+ resolution: {integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
+ '@vitest/pretty-format@4.1.2':
+ resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==}
+
+ '@vitest/runner@4.1.2':
+ resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==}
+
+ '@vitest/snapshot@4.1.2':
+ resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==}
+
+ '@vitest/spy@4.1.2':
+ resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==}
+
+ '@vitest/utils@4.1.2':
+ resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==}
+
'@xterm/addon-fit@0.11.0':
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
@@ -2236,10 +2293,17 @@ packages:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
+ assertion-error@2.0.1:
+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
+ engines: {node: '>=12'}
+
ast-types@0.16.1:
resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==}
engines: {node: '>=4'}
+ ast-v8-to-istanbul@1.0.0:
+ resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==}
+
astring@1.9.0:
resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==}
hasBin: true
@@ -2307,6 +2371,10 @@ packages:
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
+ chai@6.2.2:
+ resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
+ engines: {node: '>=18'}
+
chalk@5.6.2:
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
@@ -2578,6 +2646,9 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
+ es-module-lexer@2.0.0:
+ resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
+
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
@@ -2660,6 +2731,10 @@ packages:
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
engines: {node: ^18.19.0 || >=20.5.0}
+ expect-type@1.3.0:
+ resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
+ engines: {node: '>=12.0.0'}
+
express-rate-limit@8.3.2:
resolution: {integrity: sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==}
engines: {node: '>= 16'}
@@ -2899,6 +2974,10 @@ packages:
resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
+ has-flag@4.0.0:
+ resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+ engines: {node: '>=8'}
+
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
@@ -2941,6 +3020,9 @@ packages:
resolution: {integrity: sha512-mx/p18PLy5og9ufies2GOSUqep98Td9q4i/EF6X7yJgAiIopxqdfIO3jbqsi3jRgTgw88jMDEzVKi+V2EF+27w==}
engines: {node: '>=16.9.0'}
+ html-escaper@2.0.2:
+ resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
+
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
@@ -2984,6 +3066,22 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+ ink-testing-library@4.0.0:
+ resolution: {integrity: sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@types/react': '>=18.0.0'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ ink-testing@0.2.0:
+ resolution: {integrity: sha512-yMWorDfNFB1C52g0nl0T7C+Kkcz4Mee/P4KTzXkO/WbdSlGpye4tu6bk3LptXZFNZJIINkZV9semA/wms1qGHA==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ ink: '>=6.0.0'
+ react: '>=19.0.0'
+
ink-web@0.2.0:
resolution: {integrity: sha512-WPLK/RuW8LgPcZeBqoNZkKer3K3QPr846wFUOyx7K2WcCb3TT+lqjOhcoDaWioYX6r5OeasPmLJnKDfghtmQ6w==}
engines: {node: '>=18.0.0'}
@@ -3122,6 +3220,18 @@ packages:
resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==}
engines: {node: '>=16'}
+ istanbul-lib-coverage@3.2.2:
+ resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
+ engines: {node: '>=8'}
+
+ istanbul-lib-report@3.0.1:
+ resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
+ engines: {node: '>=10'}
+
+ istanbul-reports@3.2.0:
+ resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
+ engines: {node: '>=8'}
+
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
@@ -3147,6 +3257,9 @@ packages:
react:
optional: true
+ js-tokens@10.0.0:
+ resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -3340,6 +3453,13 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+ magicast@0.5.2:
+ resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==}
+
+ make-dir@4.0.0:
+ resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
+ engines: {node: '>=10'}
+
markdown-extensions@2.0.0:
resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==}
engines: {node: '>=16'}
@@ -3655,6 +3775,9 @@ packages:
obliterator@2.0.5:
resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==}
+ obug@2.1.1:
+ resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
+
on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
@@ -4050,6 +4173,9 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
+ siginfo@2.0.0:
+ resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
@@ -4089,10 +4215,16 @@ packages:
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
engines: {node: '>=10'}
+ stackback@0.0.2:
+ resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
+ std-env@4.0.0:
+ resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
+
stdin-discarder@0.2.2:
resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==}
engines: {node: '>=18'}
@@ -4158,6 +4290,10 @@ packages:
babel-plugin-macros:
optional: true
+ supports-color@7.2.0:
+ resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+ engines: {node: '>=8'}
+
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
@@ -4179,6 +4315,9 @@ packages:
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
+ tinybench@2.9.0:
+ resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+
tinyexec@1.0.4:
resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==}
engines: {node: '>=18'}
@@ -4191,6 +4330,10 @@ packages:
resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==}
engines: {node: ^20.0.0 || >=22.0.0}
+ tinyrainbow@3.1.0:
+ resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
+ engines: {node: '>=14.0.0'}
+
tldts-core@7.0.19:
resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==}
@@ -4394,6 +4537,41 @@ packages:
yaml:
optional: true
+ vitest@4.1.2:
+ resolution: {integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==}
+ engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
+ hasBin: true
+ peerDependencies:
+ '@edge-runtime/vm': '*'
+ '@opentelemetry/api': ^1.9.0
+ '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
+ '@vitest/browser-playwright': 4.1.2
+ '@vitest/browser-preview': 4.1.2
+ '@vitest/browser-webdriverio': 4.1.2
+ '@vitest/ui': 4.1.2
+ happy-dom: '*'
+ jsdom: '*'
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ '@edge-runtime/vm':
+ optional: true
+ '@opentelemetry/api':
+ optional: true
+ '@types/node':
+ optional: true
+ '@vitest/browser-playwright':
+ optional: true
+ '@vitest/browser-preview':
+ optional: true
+ '@vitest/browser-webdriverio':
+ optional: true
+ '@vitest/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
@@ -4411,6 +4589,11 @@ packages:
engines: {node: ^16.13.0 || >=18.0.0}
hasBin: true
+ why-is-node-running@2.3.0:
+ resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
+ engines: {node: '>=8'}
+ hasBin: true
+
widest-line@6.0.0:
resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==}
engines: {node: '>=20'}
@@ -4740,6 +4923,8 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
+ '@bcoe/v8-coverage@1.0.2': {}
+
'@clack/core@1.2.0':
dependencies:
fast-wrap-ansi: 0.1.6
@@ -6251,10 +6436,17 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@types/chai@5.2.3':
+ dependencies:
+ '@types/deep-eql': 4.0.2
+ assertion-error: 2.0.1
+
'@types/debug@4.1.12':
dependencies:
'@types/ms': 2.1.0
+ '@types/deep-eql@4.0.2': {}
+
'@types/estree-jsx@1.0.5':
dependencies:
'@types/estree': 1.0.8
@@ -6300,6 +6492,62 @@ snapshots:
next: 16.2.2(@babel/core@7.28.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
+ '@vitest/coverage-v8@4.1.2(vitest@4.1.2(@types/node@25.5.2)(msw@2.12.7(@types/node@25.5.2)(typescript@6.0.2))(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.8.0)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)))':
+ dependencies:
+ '@bcoe/v8-coverage': 1.0.2
+ '@vitest/utils': 4.1.2
+ ast-v8-to-istanbul: 1.0.0
+ istanbul-lib-coverage: 3.2.2
+ istanbul-lib-report: 3.0.1
+ istanbul-reports: 3.2.0
+ magicast: 0.5.2
+ obug: 2.1.1
+ std-env: 4.0.0
+ tinyrainbow: 3.1.0
+ vitest: 4.1.2(@types/node@25.5.2)(msw@2.12.7(@types/node@25.5.2)(typescript@6.0.2))(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.8.0)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1))
+
+ '@vitest/expect@4.1.2':
+ dependencies:
+ '@standard-schema/spec': 1.1.0
+ '@types/chai': 5.2.3
+ '@vitest/spy': 4.1.2
+ '@vitest/utils': 4.1.2
+ chai: 6.2.2
+ tinyrainbow: 3.1.0
+
+ '@vitest/mocker@4.1.2(msw@2.12.7(@types/node@25.5.2)(typescript@6.0.2))(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.8.0)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1))':
+ dependencies:
+ '@vitest/spy': 4.1.2
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ msw: 2.12.7(@types/node@25.5.2)(typescript@6.0.2)
+ vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.8.0)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)
+
+ '@vitest/pretty-format@4.1.2':
+ dependencies:
+ tinyrainbow: 3.1.0
+
+ '@vitest/runner@4.1.2':
+ dependencies:
+ '@vitest/utils': 4.1.2
+ pathe: 2.0.3
+
+ '@vitest/snapshot@4.1.2':
+ dependencies:
+ '@vitest/pretty-format': 4.1.2
+ '@vitest/utils': 4.1.2
+ magic-string: 0.30.21
+ pathe: 2.0.3
+
+ '@vitest/spy@4.1.2': {}
+
+ '@vitest/utils@4.1.2':
+ dependencies:
+ '@vitest/pretty-format': 4.1.2
+ convert-source-map: 2.0.0
+ tinyrainbow: 3.1.0
+
'@xterm/addon-fit@0.11.0': {}
'@xterm/xterm@6.0.0': {}
@@ -6348,10 +6596,18 @@ snapshots:
dependencies:
tslib: 2.8.1
+ assertion-error@2.0.1: {}
+
ast-types@0.16.1:
dependencies:
tslib: 2.8.1
+ ast-v8-to-istanbul@1.0.0:
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.31
+ estree-walker: 3.0.3
+ js-tokens: 10.0.0
+
astring@1.9.0: {}
auto-bind@5.0.1: {}
@@ -6416,6 +6672,8 @@ snapshots:
ccount@2.0.1: {}
+ chai@6.2.2: {}
+
chalk@5.6.2: {}
character-entities-html4@2.1.0: {}
@@ -6620,6 +6878,8 @@ snapshots:
es-errors@1.3.0: {}
+ es-module-lexer@2.0.0: {}
+
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
@@ -6751,6 +7011,8 @@ snapshots:
strip-final-newline: 4.0.0
yoctocolors: 2.1.2
+ expect-type@1.3.0: {}
+
express-rate-limit@8.3.2(express@5.2.1):
dependencies:
express: 5.2.1
@@ -6990,6 +7252,8 @@ snapshots:
graphql@16.12.0: {}
+ has-flag@4.0.0: {}
+
has-symbols@1.1.0: {}
hasown@2.0.2:
@@ -7095,6 +7359,8 @@ snapshots:
hono@4.12.10: {}
+ html-escaper@2.0.2: {}
+
html-void-elements@3.0.0: {}
http-errors@2.0.1:
@@ -7133,6 +7399,19 @@ snapshots:
inherits@2.0.4: {}
+ ink-testing-library@4.0.0(@types/react@19.2.14):
+ optionalDependencies:
+ '@types/react': 19.2.14
+
+ ink-testing@0.2.0(@types/react@19.2.14)(ink@6.8.0(@types/react@19.2.14)(react@19.2.4))(react@19.2.4):
+ dependencies:
+ ink: 6.8.0(@types/react@19.2.14)(react@19.2.4)
+ ink-testing-library: 4.0.0(@types/react@19.2.14)
+ react: 19.2.4
+ strip-ansi: 7.2.0
+ transitivePeerDependencies:
+ - '@types/react'
+
ink-web@0.2.0(@xterm/xterm@6.0.0)(ink@6.8.0(@types/react@19.2.14)(react@19.2.4))(react-reconciler@0.33.0(react@19.2.4))(react@19.2.4)(scheduler@0.27.0)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.8.0)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)):
dependencies:
'@xterm/addon-fit': 0.11.0
@@ -7248,6 +7527,19 @@ snapshots:
isexe@3.1.1: {}
+ istanbul-lib-coverage@3.2.2: {}
+
+ istanbul-lib-report@3.0.1:
+ dependencies:
+ istanbul-lib-coverage: 3.2.2
+ make-dir: 4.0.0
+ supports-color: 7.2.0
+
+ istanbul-reports@3.2.0:
+ dependencies:
+ html-escaper: 2.0.2
+ istanbul-lib-report: 3.0.1
+
jiti@2.6.1: {}
jose@6.1.3: {}
@@ -7259,6 +7551,8 @@ snapshots:
'@types/react': 19.2.14
react: 19.2.4
+ js-tokens@10.0.0: {}
+
js-tokens@4.0.0: {}
js-yaml@4.1.1:
@@ -7402,6 +7696,16 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
+ magicast@0.5.2:
+ dependencies:
+ '@babel/parser': 7.29.2
+ '@babel/types': 7.29.0
+ source-map-js: 1.2.1
+
+ make-dir@4.0.0:
+ dependencies:
+ semver: 7.7.3
+
markdown-extensions@2.0.0: {}
markdown-table@3.0.4: {}
@@ -7969,6 +8273,8 @@ snapshots:
obliterator@2.0.5: {}
+ obug@2.1.1: {}
+
on-finished@2.4.1:
dependencies:
ee-first: 1.1.1
@@ -8469,8 +8775,7 @@ snapshots:
semver@6.3.1: {}
- semver@7.7.3:
- optional: true
+ semver@7.7.3: {}
send@1.2.1:
dependencies:
@@ -8619,6 +8924,8 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
+ siginfo@2.0.0: {}
+
signal-exit@3.0.7: {}
signal-exit@4.1.0: {}
@@ -8647,8 +8954,12 @@ snapshots:
dependencies:
escape-string-regexp: 2.0.0
+ stackback@0.0.2: {}
+
statuses@2.0.2: {}
+ std-env@4.0.0: {}
+
stdin-discarder@0.2.2: {}
strict-event-emitter@0.5.1: {}
@@ -8710,6 +9021,10 @@ snapshots:
optionalDependencies:
'@babel/core': 7.28.5
+ supports-color@7.2.0:
+ dependencies:
+ has-flag: 4.0.0
+
tagged-tag@1.0.0: {}
tailwind-merge@3.5.0: {}
@@ -8722,6 +9037,8 @@ snapshots:
tiny-invariant@1.3.3: {}
+ tinybench@2.9.0: {}
+
tinyexec@1.0.4: {}
tinyglobby@0.2.15:
@@ -8731,6 +9048,8 @@ snapshots:
tinypool@2.1.0: {}
+ tinyrainbow@3.1.0: {}
+
tldts-core@7.0.19: {}
tldts@7.0.19:
@@ -8914,6 +9233,33 @@ snapshots:
- '@emnapi/core'
- '@emnapi/runtime'
+ vitest@4.1.2(@types/node@25.5.2)(msw@2.12.7(@types/node@25.5.2)(typescript@6.0.2))(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.8.0)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)):
+ dependencies:
+ '@vitest/expect': 4.1.2
+ '@vitest/mocker': 4.1.2(msw@2.12.7(@types/node@25.5.2)(typescript@6.0.2))(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.8.0)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1))
+ '@vitest/pretty-format': 4.1.2
+ '@vitest/runner': 4.1.2
+ '@vitest/snapshot': 4.1.2
+ '@vitest/spy': 4.1.2
+ '@vitest/utils': 4.1.2
+ es-module-lexer: 2.0.0
+ expect-type: 1.3.0
+ magic-string: 0.30.21
+ obug: 2.1.1
+ pathe: 2.0.3
+ picomatch: 4.0.4
+ std-env: 4.0.0
+ tinybench: 2.9.0
+ tinyexec: 1.0.4
+ tinyglobby: 0.2.15
+ tinyrainbow: 3.1.0
+ vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.8.0)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/node': 25.5.2
+ transitivePeerDependencies:
+ - msw
+
web-namespaces@2.0.1: {}
web-streams-polyfill@3.3.3: {}
@@ -8926,6 +9272,11 @@ snapshots:
dependencies:
isexe: 3.1.1
+ why-is-node-running@2.3.0:
+ dependencies:
+ siginfo: 2.0.0
+ stackback: 0.0.2
+
widest-line@6.0.0:
dependencies:
string-width: 8.2.0
diff --git a/registry/ui/__tests__/alert.test.tsx b/registry/ui/__tests__/alert.test.tsx
new file mode 100644
index 0000000..09fe454
--- /dev/null
+++ b/registry/ui/__tests__/alert.test.tsx
@@ -0,0 +1,53 @@
+import { Text } from "ink";
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { Alert } from "../alert";
+import { renderTui } from "./render-tui";
+
+describe("Alert", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders children", () => {
+ const tui = renderTui(
+
+ body text
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("body text")).toBe(true);
+ });
+
+ it("renders title", () => {
+ const tui = renderTui(
+
+ msg
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Notice")).toBe(true);
+ });
+
+ it.each([
+ ["success", "✓"],
+ ["error", "✗"],
+ ["warning", "⚠"],
+ ["info", "ℹ"],
+ ] as const)("renders variant icon for %s", (variant, icon) => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains(icon)).toBe(true);
+ });
+
+ it("renders without border", () => {
+ const tui = renderTui(
+
+ inside
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("inside")).toBe(true);
+ expect(tui.screen.contains("T")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/app-shell.test.tsx b/registry/ui/__tests__/app-shell.test.tsx
new file mode 100644
index 0000000..974eba5
--- /dev/null
+++ b/registry/ui/__tests__/app-shell.test.tsx
@@ -0,0 +1,180 @@
+import { Text } from "ink";
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { AppShell } from "../app-shell";
+import type { TuiInstance } from "./render-tui";
+import { renderTui } from "./render-tui";
+
+const typeChars = async (tui: TuiInstance, text: string) => {
+ for (const char of text) {
+ tui.keys.press(char);
+ await tui.flush();
+ }
+};
+
+describe("AppShell", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders children", () => {
+ const tui = renderTui(
+
+ App Content
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("App Content")).toBe(true);
+ });
+
+ describe("AppShell.Header", () => {
+ it("renders header children", () => {
+ const tui = renderTui(
+
+
+ My App Header
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("My App Header")).toBe(true);
+ });
+ });
+
+ describe("AppShell.Content", () => {
+ it("renders content children", () => {
+ const tui = renderTui(
+
+
+ Main content area
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Main content area")).toBe(true);
+ });
+ });
+
+ describe("AppShell.Input", () => {
+ it("renders placeholder", () => {
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Enter command...")).toBe(true);
+ });
+
+ it("renders prefix", () => {
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("$")).toBe(true);
+ });
+
+ it("accepts typed input", async () => {
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ await typeChars(tui, "hello");
+ expect(tui.screen.contains("hello")).toBe(true);
+ });
+
+ it("calls onChange", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ tui.keys.press("x");
+ await tui.flush();
+ expect(onChange).toHaveBeenCalledWith("x");
+ });
+
+ it("calls onSubmit on enter", async () => {
+ const onSubmit = vi.fn();
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ await typeChars(tui, "cmd");
+ tui.keys.enter();
+ await tui.flush();
+ expect(onSubmit).toHaveBeenCalledWith("cmd");
+ });
+
+ it("clears input after uncontrolled submit", async () => {
+ const onSubmit = vi.fn();
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ await typeChars(tui, "test");
+ tui.keys.enter();
+ await tui.flush();
+ expect(tui.screen.contains("Type something...")).toBe(true);
+ });
+
+ it("handles backspace", async () => {
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ await typeChars(tui, "ab");
+ tui.keys.backspace();
+ await tui.flush();
+ expect(tui.screen.contains("a")).toBe(true);
+ });
+ });
+
+ describe("AppShell.Hints", () => {
+ it("renders hint items", () => {
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Ctrl+C: quit")).toBe(true);
+ expect(tui.screen.contains("?: help")).toBe(true);
+ });
+
+ it("renders children as content", () => {
+ const tui = renderTui(
+
+ Press q to quit
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Press q to quit")).toBe(true);
+ });
+ });
+
+ describe("AppShell.Tip", () => {
+ it("renders tip text", () => {
+ const tui = renderTui(
+
+ Use tab to navigate
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Tip:")).toBe(true);
+ expect(tui.screen.contains("Use tab to navigate")).toBe(true);
+ });
+ });
+});
diff --git a/registry/ui/__tests__/aspect-ratio.test.tsx b/registry/ui/__tests__/aspect-ratio.test.tsx
new file mode 100644
index 0000000..1191e08
--- /dev/null
+++ b/registry/ui/__tests__/aspect-ratio.test.tsx
@@ -0,0 +1,31 @@
+import { Text } from "ink";
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { AspectRatio } from "../aspect-ratio";
+import { renderTui } from "./render-tui";
+
+describe("AspectRatio", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders children", () => {
+ const tui = renderTui(
+
+ child
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("child")).toBe(true);
+ });
+
+ it("renders without crashing", () => {
+ const tui = renderTui(
+
+ ok
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.text().length).toBeGreaterThan(0);
+ });
+});
diff --git a/registry/ui/__tests__/badge.test.tsx b/registry/ui/__tests__/badge.test.tsx
new file mode 100644
index 0000000..dec7cbe
--- /dev/null
+++ b/registry/ui/__tests__/badge.test.tsx
@@ -0,0 +1,37 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { Badge } from "../badge";
+import { renderTui } from "./render-tui";
+
+describe("Badge", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders children text", () => {
+ const tui = renderTui(hello);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("hello")).toBe(true);
+ });
+
+ it("renders without border when bordered=false", () => {
+ const tui = renderTui(plain);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("plain")).toBe(true);
+ });
+
+ it("renders all variants", () => {
+ for (const variant of [
+ "default",
+ "success",
+ "warning",
+ "error",
+ "info",
+ "secondary",
+ ] as const) {
+ const tui = renderTui(v);
+ expect(tui.screen.text().length).toBeGreaterThan(0);
+ tui.unmount();
+ }
+ });
+});
diff --git a/registry/ui/__tests__/banner.test.tsx b/registry/ui/__tests__/banner.test.tsx
new file mode 100644
index 0000000..f944a9d
--- /dev/null
+++ b/registry/ui/__tests__/banner.test.tsx
@@ -0,0 +1,87 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { Banner } from "../banner";
+import { renderTui } from "./render-tui";
+
+describe("Banner", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders children", () => {
+ const tui = renderTui(Important message);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Important message")).toBe(true);
+ });
+
+ it("renders title", () => {
+ const tui = renderTui(body);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Alert")).toBe(true);
+ });
+
+ it("renders accent character", () => {
+ const tui = renderTui(text);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("┃")).toBe(true);
+ });
+
+ it("renders custom accent character", () => {
+ const tui = renderTui(text);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("|")).toBe(true);
+ });
+
+ it.each([
+ ["error", "✗"],
+ ["info", "ℹ"],
+ ["neutral", "·"],
+ ["success", "✓"],
+ ["warning", "⚠"],
+ ] as const)("renders %s variant icon: %s", (variant, icon) => {
+ const tui = renderTui(text);
+ ({ unmount } = tui);
+ expect(tui.screen.contains(icon)).toBe(true);
+ });
+
+ it("renders custom icon", () => {
+ const tui = renderTui(text);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("🔔")).toBe(true);
+ });
+
+ it("shows dismiss hint when dismissible", () => {
+ const tui = renderTui(text);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("press Esc to dismiss")).toBe(true);
+ });
+
+ it("does not show dismiss hint by default", () => {
+ const tui = renderTui(text);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("press Esc to dismiss")).toBe(false);
+ });
+
+ it("dismisses on Escape when dismissible", async () => {
+ const onDismiss = vi.fn();
+ const tui = renderTui(
+
+ Dismissable
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Dismissable")).toBe(true);
+ tui.keys.escape();
+ await tui.flush();
+ expect(onDismiss).toHaveBeenCalledOnce();
+ expect(tui.screen.contains("Dismissable")).toBe(false);
+ });
+
+ it("does not dismiss when not dismissible", async () => {
+ const tui = renderTui(Persistent);
+ ({ unmount } = tui);
+ tui.keys.escape();
+ await tui.flush();
+ expect(tui.screen.contains("Persistent")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/bar-chart.test.tsx b/registry/ui/__tests__/bar-chart.test.tsx
new file mode 100644
index 0000000..2944cec
--- /dev/null
+++ b/registry/ui/__tests__/bar-chart.test.tsx
@@ -0,0 +1,64 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { BarChart } from "../bar-chart";
+import { renderTui } from "./render-tui";
+
+describe("BarChart", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ const sampleData = [
+ { label: "A", value: 10 },
+ { label: "B", value: 20 },
+ { label: "C", value: 15 },
+ ];
+
+ it("renders bar characters for horizontal direction", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("█")).toBe(true);
+ });
+
+ it("renders labels", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("A")).toBe(true);
+ expect(tui.screen.contains("B")).toBe(true);
+ expect(tui.screen.contains("C")).toBe(true);
+ });
+
+ it("renders values when showValues is true", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("10")).toBe(true);
+ expect(tui.screen.contains("20")).toBe(true);
+ });
+
+ it("hides values when showValues is false", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("999")).toBe(false);
+ });
+
+ it("renders title", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Sales Report")).toBe(true);
+ });
+
+ it("renders empty state", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("No data")).toBe(true);
+ });
+
+ it("renders vertical direction", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("A")).toBe(true);
+ expect(tui.screen.contains("B")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/big-text.test.tsx b/registry/ui/__tests__/big-text.test.tsx
new file mode 100644
index 0000000..671799a
--- /dev/null
+++ b/registry/ui/__tests__/big-text.test.tsx
@@ -0,0 +1,49 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { BigText } from "../big-text";
+import { renderTui } from "./render-tui";
+
+describe("BigText", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders block font with filled characters", () => {
+ const tui = renderTui(A);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("█")).toBe(true);
+ });
+
+ it("renders multiple characters", () => {
+ const tui = renderTui(HI);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("█")).toBe(true);
+ const lines = tui.screen.lines();
+ expect(lines.length).toBeGreaterThanOrEqual(5);
+ });
+
+ it("renders slim font with box drawing chars", () => {
+ const tui = renderTui(A);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("╔")).toBe(true);
+ });
+
+ it("renders shade font", () => {
+ const tui = renderTui(A);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("▓")).toBe(true);
+ });
+
+ it("renders simple font same as block", () => {
+ const tui = renderTui(T);
+ ({ unmount } = tui);
+ const lines = tui.screen.lines();
+ expect(lines.length).toBeGreaterThanOrEqual(5);
+ });
+
+ it("handles lowercase by converting to uppercase", () => {
+ const tui = renderTui(abc);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("█")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/box.test.tsx b/registry/ui/__tests__/box.test.tsx
new file mode 100644
index 0000000..661933c
--- /dev/null
+++ b/registry/ui/__tests__/box.test.tsx
@@ -0,0 +1,42 @@
+import { Text } from "ink";
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { Box } from "../box";
+import { renderTui } from "./render-tui";
+
+describe("Box", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders children", () => {
+ const tui = renderTui(
+
+ boxed
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("boxed")).toBe(true);
+ });
+
+ it("renders with border=true", () => {
+ const tui = renderTui(
+
+ with border
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("with border")).toBe(true);
+ expect(tui.screen.text().length).toBeGreaterThan("with border".length);
+ });
+
+ it("renders without border", () => {
+ const tui = renderTui(
+
+ no border
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("no border")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/breadcrumb.test.tsx b/registry/ui/__tests__/breadcrumb.test.tsx
new file mode 100644
index 0000000..6110151
--- /dev/null
+++ b/registry/ui/__tests__/breadcrumb.test.tsx
@@ -0,0 +1,62 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { Breadcrumb } from "../breadcrumb";
+import { renderTui } from "./render-tui";
+
+describe("Breadcrumb", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ const items = [
+ { key: "home", label: "Home" },
+ { key: "docs", label: "Docs" },
+ { key: "api", label: "API" },
+ ];
+
+ it("renders all breadcrumb labels", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Home")).toBe(true);
+ expect(tui.screen.contains("Docs")).toBe(true);
+ expect(tui.screen.contains("API")).toBe(true);
+ });
+
+ it("renders default separator", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("›")).toBe(true);
+ });
+
+ it("renders custom separator", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("/")).toBe(true);
+ });
+
+ it("defaults active to last item", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("API")).toBe(true);
+ });
+
+ it("supports activeKey prop", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Docs")).toBe(true);
+ });
+
+ it("calls onSelect on left arrow navigating to previous item", () => {
+ const onSelect = vi.fn();
+ const itemsWithSelect = [
+ { key: "home", label: "Home", onSelect },
+ { key: "docs", label: "Docs" },
+ ];
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.left();
+ expect(onSelect).toHaveBeenCalled();
+ });
+});
diff --git a/registry/ui/__tests__/bullet-list.test.tsx b/registry/ui/__tests__/bullet-list.test.tsx
new file mode 100644
index 0000000..3d691f0
--- /dev/null
+++ b/registry/ui/__tests__/bullet-list.test.tsx
@@ -0,0 +1,46 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { BulletList } from "../bullet-list";
+import { renderTui } from "./render-tui";
+
+describe("BulletList", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders items with bullet ●", () => {
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("●")).toBe(true);
+ expect(tui.screen.contains("First")).toBe(true);
+ });
+
+ it("renders check items with ■ when done and □ when not", () => {
+ const tui = renderTui(
+
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("■")).toBe(true);
+ expect(tui.screen.contains("□")).toBe(true);
+ expect(tui.screen.contains("Done task")).toBe(true);
+ expect(tui.screen.contains("Todo")).toBe(true);
+ });
+
+ it("renders tree items with └", () => {
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("└")).toBe(true);
+ expect(tui.screen.contains("leaf")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/card.test.tsx b/registry/ui/__tests__/card.test.tsx
new file mode 100644
index 0000000..605e906
--- /dev/null
+++ b/registry/ui/__tests__/card.test.tsx
@@ -0,0 +1,51 @@
+import { Text } from "ink";
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { Card } from "../card";
+import { renderTui } from "./render-tui";
+
+describe("Card", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders children", () => {
+ const tui = renderTui(
+
+ main
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("main")).toBe(true);
+ });
+
+ it("renders title", () => {
+ const tui = renderTui(
+
+ x
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Card title")).toBe(true);
+ });
+
+ it("renders subtitle", () => {
+ const tui = renderTui(
+
+ x
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Sub here")).toBe(true);
+ });
+
+ it("renders footer", () => {
+ const tui = renderTui(
+ foot}>
+ body
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("foot")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/center.test.tsx b/registry/ui/__tests__/center.test.tsx
new file mode 100644
index 0000000..26b991e
--- /dev/null
+++ b/registry/ui/__tests__/center.test.tsx
@@ -0,0 +1,21 @@
+import { Text } from "ink";
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { Center } from "../center";
+import { renderTui } from "./render-tui";
+
+describe("Center", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders children", () => {
+ const tui = renderTui(
+
+ centered-ish
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("centered-ish")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/chat-message.test.tsx b/registry/ui/__tests__/chat-message.test.tsx
new file mode 100644
index 0000000..23fcb81
--- /dev/null
+++ b/registry/ui/__tests__/chat-message.test.tsx
@@ -0,0 +1,74 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { ChatMessage } from "../chat-message";
+import { renderTui } from "./render-tui";
+
+describe("ChatMessage", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders sender role label", () => {
+ const tui = renderTui(Hello);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("user")).toBe(true);
+ expect(tui.screen.contains("Hello")).toBe(true);
+ });
+
+ it("renders custom name instead of role", () => {
+ const tui = renderTui(
+
+ Hi
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Claude")).toBe(true);
+ });
+
+ it("shows streaming indicator when streaming with no children", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("assistant")).toBe(true);
+ });
+
+ it("shows children when streaming with content", () => {
+ const tui = renderTui(
+
+ partial response
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("partial response")).toBe(true);
+ });
+
+ it("renders collapsed state with expand hint", () => {
+ const tui = renderTui(
+
+ Some long message
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("[expand]")).toBe(true);
+ });
+
+ it("toggles collapsed state on enter", async () => {
+ const tui = renderTui(
+
+ Some long message
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("[expand]")).toBe(true);
+ tui.keys.enter();
+ await tui.flush();
+ expect(tui.screen.contains("[expand]")).toBe(false);
+ });
+
+ it("renders all sender roles", () => {
+ for (const role of ["user", "assistant", "system", "error"] as const) {
+ const tui = renderTui(content);
+ expect(tui.screen.contains(role)).toBe(true);
+ tui.unmount();
+ }
+ });
+});
diff --git a/registry/ui/__tests__/chat-thread.test.tsx b/registry/ui/__tests__/chat-thread.test.tsx
new file mode 100644
index 0000000..c79f427
--- /dev/null
+++ b/registry/ui/__tests__/chat-thread.test.tsx
@@ -0,0 +1,21 @@
+import { Text } from "ink";
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { ChatThread } from "../chat-thread";
+import { renderTui } from "./render-tui";
+
+describe("ChatThread", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders children", () => {
+ const tui = renderTui(
+
+ hello thread
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("hello thread")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/checkbox-group.test.tsx b/registry/ui/__tests__/checkbox-group.test.tsx
new file mode 100644
index 0000000..a7946c4
--- /dev/null
+++ b/registry/ui/__tests__/checkbox-group.test.tsx
@@ -0,0 +1,102 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { CheckboxGroup } from "../checkbox-group";
+import { renderTui } from "./render-tui";
+
+describe("CheckboxGroup", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ const options = [
+ { label: "Alpha", value: "a" },
+ { label: "Beta", value: "b" },
+ { label: "Gamma", value: "c" },
+ ];
+
+ it("renders all option labels", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Alpha")).toBe(true);
+ expect(tui.screen.contains("Beta")).toBe(true);
+ expect(tui.screen.contains("Gamma")).toBe(true);
+ });
+
+ it("renders label when provided", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Pick items")).toBe(true);
+ });
+
+ it("shows cursor indicator on first item", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("›")).toBe(true);
+ });
+
+ it("toggles selection with space", () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.space();
+ expect(onChange).toHaveBeenCalledWith(["a"]);
+ });
+
+ it("navigates down and selects second option", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.space();
+ expect(onChange).toHaveBeenCalledWith(["b"]);
+ });
+
+ it("skips disabled options when navigating", async () => {
+ const opts = [
+ { label: "Alpha", value: "a" },
+ { disabled: true, label: "Beta", value: "b" },
+ { label: "Gamma", value: "c" },
+ ];
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.space();
+ expect(onChange).toHaveBeenCalledWith(["c"]);
+ });
+
+ it("shows error when selecting fewer than min", async () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.space();
+ await tui.flush();
+ expect(tui.screen.contains("Select at least 2 options.")).toBe(true);
+ });
+
+ it("prevents exceeding max selections", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.space();
+ expect(onChange).not.toHaveBeenCalled();
+ });
+});
diff --git a/registry/ui/__tests__/checkbox.test.tsx b/registry/ui/__tests__/checkbox.test.tsx
new file mode 100644
index 0000000..c0c8d48
--- /dev/null
+++ b/registry/ui/__tests__/checkbox.test.tsx
@@ -0,0 +1,74 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { Checkbox } from "../checkbox";
+import { renderTui } from "./render-tui";
+
+describe("Checkbox", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders unchecked icon by default", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("□")).toBe(true);
+ });
+
+ it("renders checked icon when checked", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("■")).toBe(true);
+ });
+
+ it("renders label", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Accept terms")).toBe(true);
+ });
+
+ it("renders indeterminate icon", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("▪")).toBe(true);
+ });
+
+ it("toggles on space when focused", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.tab();
+ await tui.flush();
+ tui.keys.space();
+ expect(onChange).toHaveBeenCalledWith(true);
+ });
+
+ it("unchecks on space when already checked", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.tab();
+ await tui.flush();
+ tui.keys.space();
+ expect(onChange).toHaveBeenCalledWith(false);
+ });
+
+ it("does not toggle when disabled", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.tab();
+ await tui.flush();
+ tui.keys.space();
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it("renders custom icons", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("✔")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/clipboard.test.tsx b/registry/ui/__tests__/clipboard.test.tsx
new file mode 100644
index 0000000..45fa22d
--- /dev/null
+++ b/registry/ui/__tests__/clipboard.test.tsx
@@ -0,0 +1,46 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { Clipboard } from "../clipboard";
+import { renderTui } from "./render-tui";
+
+vi.mock("@/hooks/use-clipboard", () => ({
+ useClipboard: () => ({ write: vi.fn() }),
+}));
+
+describe("Clipboard", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders value", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("abc123")).toBe(true);
+ });
+
+ it("renders label", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Token")).toBe(true);
+ });
+
+ it("renders Copy button", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Copy")).toBe(true);
+ });
+
+ it("renders copy hint", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("press c or space to copy")).toBe(true);
+ });
+
+ it("shows success message after pressing c", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.press("c");
+ await tui.flush();
+ expect(tui.screen.contains("Done!")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/clock.test.tsx b/registry/ui/__tests__/clock.test.tsx
new file mode 100644
index 0000000..94467ff
--- /dev/null
+++ b/registry/ui/__tests__/clock.test.tsx
@@ -0,0 +1,61 @@
+import React from "react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+import { Clock } from "../clock";
+import { renderTui } from "./render-tui";
+
+describe("Clock", () => {
+ let unmount: () => void;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2025-06-15T14:30:45"));
+ });
+
+ afterEach(() => {
+ unmount?.();
+ vi.useRealTimers();
+ });
+
+ it("renders time in 24h format with seconds", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("14:30:45")).toBe(true);
+ });
+
+ it("renders time in 12h format with AM/PM", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("02:30:45")).toBe(true);
+ expect(tui.screen.contains("PM")).toBe(true);
+ });
+
+ it("hides seconds when showSeconds is false", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("14:30")).toBe(true);
+ expect(tui.screen.contains("14:30:45")).toBe(false);
+ });
+
+ it("shows date when showDate is true", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("2025")).toBe(true);
+ expect(tui.screen.contains("Jun")).toBe(true);
+ });
+
+ it("re-renders on interval tick", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ const framesBefore = tui.screen.frames().length;
+ await vi.advanceTimersByTimeAsync(2000);
+ const framesAfter = tui.screen.frames().length;
+ expect(framesAfter).toBeGreaterThan(framesBefore);
+ });
+
+ it("renders large size with big digits", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("╔")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/code.test.tsx b/registry/ui/__tests__/code.test.tsx
new file mode 100644
index 0000000..894aad7
--- /dev/null
+++ b/registry/ui/__tests__/code.test.tsx
@@ -0,0 +1,23 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { Code } from "../code";
+import { renderTui } from "./render-tui";
+
+describe("Code", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders code text", () => {
+ const tui = renderTui(const x = 1);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("const")).toBe(true);
+ expect(tui.screen.contains("x")).toBe(true);
+ });
+
+ it("renders inline code", () => {
+ const tui = renderTui(inline snippet);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("inline snippet")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/color-picker.test.tsx b/registry/ui/__tests__/color-picker.test.tsx
new file mode 100644
index 0000000..d933cb6
--- /dev/null
+++ b/registry/ui/__tests__/color-picker.test.tsx
@@ -0,0 +1,115 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { ColorPicker } from "../color-picker";
+import type { TuiInstance } from "./render-tui";
+import { renderTui } from "./render-tui";
+
+const typeChars = async (tui: TuiInstance, text: string) => {
+ for (const char of text) {
+ tui.keys.press(char);
+ await tui.flush();
+ }
+};
+
+describe("ColorPicker", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders label", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Color")).toBe(true);
+ });
+
+ it("renders selected color value", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Selected:")).toBe(true);
+ expect(tui.screen.contains("#000000")).toBe(true);
+ });
+
+ it("renders hex input area", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("#")).toBe(true);
+ });
+
+ it("navigates palette with arrow keys", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.right();
+ await tui.flush();
+ tui.keys.enter();
+ await tui.flush();
+ expect(onChange).toHaveBeenCalledWith("#800000");
+ });
+
+ it("selects color on enter", async () => {
+ const onSubmit = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.enter();
+ await tui.flush();
+ expect(onSubmit).toHaveBeenCalledWith("#000000");
+ });
+
+ it("switches to hex mode on tab", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ await tui.flush();
+ expect(tui.screen.contains("navigate")).toBe(true);
+ tui.keys.tab();
+ await tui.flush();
+ expect(tui.screen.contains("Type hex")).toBe(true);
+ });
+
+ it("accepts hex input in hex mode", async () => {
+ const onSubmit = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.tab();
+ await tui.flush();
+ await typeChars(tui, "ff0000");
+ tui.keys.enter();
+ await tui.flush();
+ expect(onSubmit).toHaveBeenCalledWith("#ff0000");
+ });
+
+ it("handles custom palette", () => {
+ const palette = ["#111111", "#222222"];
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("#111111")).toBe(true);
+ });
+
+ it("navigates palette down", async () => {
+ const onChange = vi.fn();
+ const palette = [
+ "#000000",
+ "#111111",
+ "#222222",
+ "#333333",
+ "#444444",
+ "#555555",
+ "#666666",
+ "#777777",
+ "#888888",
+ "#999999",
+ ];
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.enter();
+ await tui.flush();
+ expect(onChange).toHaveBeenCalledWith("#888888");
+ });
+});
diff --git a/registry/ui/__tests__/columns.test.tsx b/registry/ui/__tests__/columns.test.tsx
new file mode 100644
index 0000000..997f974
--- /dev/null
+++ b/registry/ui/__tests__/columns.test.tsx
@@ -0,0 +1,23 @@
+import { Text } from "ink";
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { Columns } from "../columns";
+import { renderTui } from "./render-tui";
+
+describe("Columns", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders children", () => {
+ const tui = renderTui(
+
+ left col
+ right col
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("left col")).toBe(true);
+ expect(tui.screen.contains("right col")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/command-palette.test.tsx b/registry/ui/__tests__/command-palette.test.tsx
new file mode 100644
index 0000000..40497c2
--- /dev/null
+++ b/registry/ui/__tests__/command-palette.test.tsx
@@ -0,0 +1,115 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { CommandPalette } from "../command-palette";
+import type { TuiInstance } from "./render-tui";
+import { renderTui } from "./render-tui";
+
+const typeChars = async (tui: TuiInstance, text: string) => {
+ for (const char of text) {
+ tui.keys.press(char);
+ await tui.flush();
+ }
+};
+
+describe("CommandPalette", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ const commands = [
+ { id: "save", label: "Save File", shortcut: "Ctrl+S" },
+ { id: "open", label: "Open File", shortcut: "Ctrl+O" },
+ { id: "close", label: "Close Tab" },
+ { description: "Find in files", id: "search", label: "Search" },
+ ];
+
+ it("renders nothing when closed", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Save File")).toBe(false);
+ });
+
+ it("renders commands when open", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Save File")).toBe(true);
+ expect(tui.screen.contains("Open File")).toBe(true);
+ });
+
+ it("renders placeholder", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Run...")).toBe(true);
+ });
+
+ it("renders shortcut badges", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Ctrl+S")).toBe(true);
+ });
+
+ it("renders command description", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Find in files")).toBe(true);
+ });
+
+ it("filters commands by query", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ await typeChars(tui, "save");
+ expect(tui.screen.contains("Save File")).toBe(true);
+ expect(tui.screen.contains("Close Tab")).toBe(false);
+ });
+
+ it("shows no commands found when filter yields nothing", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ await typeChars(tui, "zzzzz");
+ expect(tui.screen.contains("No commands found")).toBe(true);
+ });
+
+ it("selects command on enter", async () => {
+ const onSelect = vi.fn();
+ const cmds = [
+ { id: "a", label: "Alpha", onSelect },
+ { id: "b", label: "Beta" },
+ ];
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.enter();
+ await tui.flush();
+ expect(onSelect).toHaveBeenCalled();
+ });
+
+ it("calls onClose on escape", async () => {
+ const onClose = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.escape();
+ await tui.flush();
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it("navigates with up/down arrows", async () => {
+ const onSelectA = vi.fn();
+ const onSelectB = vi.fn();
+ const cmds = [
+ { id: "a", label: "Alpha", onSelect: onSelectA },
+ { id: "b", label: "Beta", onSelect: onSelectB },
+ ];
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.enter();
+ await tui.flush();
+ expect(onSelectB).toHaveBeenCalled();
+ });
+});
diff --git a/registry/ui/__tests__/confirm.test.tsx b/registry/ui/__tests__/confirm.test.tsx
new file mode 100644
index 0000000..fffdce1
--- /dev/null
+++ b/registry/ui/__tests__/confirm.test.tsx
@@ -0,0 +1,101 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { Confirm } from "../confirm";
+import { renderTui } from "./render-tui";
+
+describe("Confirm", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders message and default labels", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Delete file?")).toBe(true);
+ expect(tui.screen.contains("Yes")).toBe(true);
+ expect(tui.screen.contains("No")).toBe(true);
+ });
+
+ it("renders custom labels", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Yep")).toBe(true);
+ expect(tui.screen.contains("Nah")).toBe(true);
+ });
+
+ it("calls onConfirm when Enter pressed with defaultValue=true", () => {
+ const onConfirm = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.enter();
+ expect(onConfirm).toHaveBeenCalledOnce();
+ });
+
+ it("calls onCancel when Enter pressed with defaultValue=false", () => {
+ const onCancel = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.enter();
+ expect(onCancel).toHaveBeenCalledOnce();
+ });
+
+ it("toggles selection with arrow keys", async () => {
+ const onConfirm = vi.fn();
+ const onCancel = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.right();
+ await tui.flush();
+ tui.keys.enter();
+ expect(onConfirm).toHaveBeenCalledOnce();
+ });
+
+ it("toggles back with left arrow", async () => {
+ const onCancel = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.left();
+ await tui.flush();
+ tui.keys.enter();
+ expect(onCancel).toHaveBeenCalledOnce();
+ });
+
+ it("'y' shortcut calls onConfirm", () => {
+ const onConfirm = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.press("y");
+ expect(onConfirm).toHaveBeenCalledOnce();
+ });
+
+ it("'n' shortcut calls onCancel", () => {
+ const onCancel = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.press("n");
+ expect(onCancel).toHaveBeenCalledOnce();
+ });
+
+ it("'Y' shortcut also works", () => {
+ const onConfirm = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.press("Y");
+ expect(onConfirm).toHaveBeenCalledOnce();
+ });
+});
diff --git a/registry/ui/__tests__/data-grid.test.tsx b/registry/ui/__tests__/data-grid.test.tsx
new file mode 100644
index 0000000..a35a2af
--- /dev/null
+++ b/registry/ui/__tests__/data-grid.test.tsx
@@ -0,0 +1,131 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { DataGrid } from "../data-grid";
+import type { TuiInstance } from "./render-tui";
+import { renderTui } from "./render-tui";
+
+const typeChars = async (tui: TuiInstance, text: string) => {
+ for (const char of text) {
+ tui.keys.press(char);
+ await tui.flush();
+ }
+};
+
+describe("DataGrid", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ const columns = [
+ { header: "Name", key: "name" as const },
+ { header: "Age", key: "age" as const },
+ ];
+
+ const data = [
+ { age: 30, name: "Alice" },
+ { age: 25, name: "Bob" },
+ { age: 35, name: "Carol" },
+ ];
+
+ it("renders column headers", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Name")).toBe(true);
+ expect(tui.screen.contains("Age")).toBe(true);
+ });
+
+ it("renders data rows", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Alice")).toBe(true);
+ expect(tui.screen.contains("Bob")).toBe(true);
+ expect(tui.screen.contains("Carol")).toBe(true);
+ });
+
+ it("shows no data message when empty", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("No data")).toBe(true);
+ });
+
+ it("navigates rows with arrow keys", async () => {
+ const onRowSelect = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.enter();
+ await tui.flush();
+ expect(onRowSelect).toHaveBeenCalledWith(
+ expect.objectContaining({ name: "Bob" })
+ );
+ });
+
+ it("selects first row on enter by default", async () => {
+ const onRowSelect = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.enter();
+ await tui.flush();
+ expect(onRowSelect).toHaveBeenCalledWith(
+ expect.objectContaining({ name: "Alice" })
+ );
+ });
+
+ it("paginates with n/p keys", async () => {
+ const largeData = Array.from({ length: 15 }, (_, i) => ({
+ age: 20 + i,
+ name: `Person ${i}`,
+ }));
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Page 1/")).toBe(true);
+ tui.keys.press("n");
+ await tui.flush();
+ expect(tui.screen.contains("Page 2/")).toBe(true);
+ tui.keys.press("p");
+ await tui.flush();
+ expect(tui.screen.contains("Page 1/")).toBe(true);
+ });
+
+ it("enters filter mode with / key", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.press("/");
+ await tui.flush();
+ expect(tui.screen.contains("Filter:")).toBe(true);
+ });
+
+ it("filters data in filter mode", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.press("/");
+ await tui.flush();
+ await typeChars(tui, "Alice");
+ await tui.flush();
+ tui.keys.enter();
+ await tui.flush();
+ expect(tui.screen.contains("Alice")).toBe(true);
+ expect(tui.screen.contains("1 rows")).toBe(true);
+ });
+
+ it("shows row numbers when enabled", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("1")).toBe(true);
+ });
+
+ it("shows page info", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("3 rows")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/date-picker.test.tsx b/registry/ui/__tests__/date-picker.test.tsx
new file mode 100644
index 0000000..7fb81a2
--- /dev/null
+++ b/registry/ui/__tests__/date-picker.test.tsx
@@ -0,0 +1,122 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { DatePicker } from "../date-picker";
+import { renderTui } from "./render-tui";
+
+describe("DatePicker", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders label", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Start Date")).toBe(true);
+ });
+
+ it("renders month/day/year fields", () => {
+ const date = new Date(2025, 0, 15);
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Jan")).toBe(true);
+ expect(tui.screen.contains("15")).toBe(true);
+ expect(tui.screen.contains("2025")).toBe(true);
+ });
+
+ it("renders navigation hint when focused", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ await tui.flush();
+ expect(tui.screen.contains("Tab: next field")).toBe(true);
+ });
+
+ it("increments month with up arrow", async () => {
+ const onChange = vi.fn();
+ const date = new Date(2025, 0, 15);
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.up();
+ await tui.flush();
+ expect(onChange).toHaveBeenCalled();
+ const calledDate = onChange.mock.calls[0][0] as Date;
+ expect(calledDate.getMonth()).toBe(1);
+ });
+
+ it("decrements month with down arrow", async () => {
+ const onChange = vi.fn();
+ const date = new Date(2025, 3, 10);
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.down();
+ await tui.flush();
+ const calledDate = onChange.mock.calls[0][0] as Date;
+ expect(calledDate.getMonth()).toBe(2);
+ });
+
+ it("switches field with tab", async () => {
+ const onChange = vi.fn();
+ const date = new Date(2025, 0, 15);
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.tab();
+ await tui.flush();
+ tui.keys.up();
+ await tui.flush();
+ const calledDate = onChange.mock.calls[0][0] as Date;
+ expect(calledDate.getDate()).toBe(16);
+ });
+
+ it("wraps month from January down to December", async () => {
+ const onChange = vi.fn();
+ const date = new Date(2025, 0, 15);
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.down();
+ await tui.flush();
+ const calledDate = onChange.mock.calls[0][0] as Date;
+ expect(calledDate.getMonth()).toBe(11);
+ });
+
+ it("calls onSubmit on enter", async () => {
+ const onSubmit = vi.fn();
+ const date = new Date(2025, 5, 20);
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.enter();
+ await tui.flush();
+ expect(onSubmit).toHaveBeenCalled();
+ });
+
+ it("changes year when year field is active", async () => {
+ const onChange = vi.fn();
+ const date = new Date(2025, 0, 1);
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.tab();
+ await tui.flush();
+ tui.keys.tab();
+ await tui.flush();
+ tui.keys.up();
+ await tui.flush();
+ const calledDate = onChange.mock.calls[0][0] as Date;
+ expect(calledDate.getFullYear()).toBe(2026);
+ });
+});
diff --git a/registry/ui/__tests__/definition.test.tsx b/registry/ui/__tests__/definition.test.tsx
new file mode 100644
index 0000000..967b753
--- /dev/null
+++ b/registry/ui/__tests__/definition.test.tsx
@@ -0,0 +1,26 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { Definition } from "../definition";
+import { renderTui } from "./render-tui";
+
+describe("Definition", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders terms and descriptions", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("API")).toBe(true);
+ expect(tui.screen.contains("Application Programming Interface")).toBe(true);
+ expect(tui.screen.contains("CLI")).toBe(true);
+ expect(tui.screen.contains("Command Line Interface")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/dialog.test.tsx b/registry/ui/__tests__/dialog.test.tsx
new file mode 100644
index 0000000..abd02cf
--- /dev/null
+++ b/registry/ui/__tests__/dialog.test.tsx
@@ -0,0 +1,136 @@
+import { Text } from "ink";
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { Dialog } from "../dialog";
+import { renderTui } from "./render-tui";
+
+describe("Dialog", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders nothing when closed", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Hidden")).toBe(false);
+ });
+
+ it("renders children when open", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Dialog body")).toBe(true);
+ });
+
+ it("renders title", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Confirm Action")).toBe(true);
+ });
+
+ it("renders default button labels", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("OK")).toBe(true);
+ expect(tui.screen.contains("Cancel")).toBe(true);
+ });
+
+ it("renders custom button labels", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Delete")).toBe(true);
+ expect(tui.screen.contains("Keep")).toBe(true);
+ });
+
+ it("calls onCancel on Enter when cancel button focused (default)", async () => {
+ const onCancel = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.enter();
+ expect(onCancel).toHaveBeenCalledOnce();
+ });
+
+ it("calls onConfirm on Enter after Tab to confirm button", async () => {
+ const onConfirm = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.tab();
+ await tui.flush();
+ tui.keys.enter();
+ expect(onConfirm).toHaveBeenCalledOnce();
+ });
+
+ it("toggles focus with arrow keys", async () => {
+ const onConfirm = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.right();
+ await tui.flush();
+ tui.keys.enter();
+ expect(onConfirm).toHaveBeenCalledOnce();
+ });
+
+ it("calls onCancel on Escape", async () => {
+ const onCancel = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.escape();
+ await tui.flush();
+ expect(onCancel).toHaveBeenCalledOnce();
+ });
+
+ it("does not fire callbacks when closed", async () => {
+ const onConfirm = vi.fn();
+ const onCancel = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.enter();
+ tui.keys.escape();
+ await tui.flush();
+ expect(onConfirm).not.toHaveBeenCalled();
+ expect(onCancel).not.toHaveBeenCalled();
+ });
+});
diff --git a/registry/ui/__tests__/diff-view.test.tsx b/registry/ui/__tests__/diff-view.test.tsx
new file mode 100644
index 0000000..33b4e54
--- /dev/null
+++ b/registry/ui/__tests__/diff-view.test.tsx
@@ -0,0 +1,59 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { DiffView } from "../diff-view";
+import { renderTui } from "./render-tui";
+
+describe("DiffView", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders filename", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("test.txt")).toBe(true);
+ });
+
+ it("shows added lines with + prefix", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("+added line")).toBe(true);
+ });
+
+ it("shows removed lines with - prefix", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("-removed line")).toBe(true);
+ });
+
+ it("shows no differences when texts are equal", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("No differences")).toBe(true);
+ });
+
+ it("renders unified mode by default", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("@@")).toBe(true);
+ });
+
+ it("renders split mode", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("│")).toBe(true);
+ });
+
+ it("renders inline mode", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("-old")).toBe(true);
+ expect(tui.screen.contains("+new")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/digits.test.tsx b/registry/ui/__tests__/digits.test.tsx
new file mode 100644
index 0000000..b0b3525
--- /dev/null
+++ b/registry/ui/__tests__/digits.test.tsx
@@ -0,0 +1,48 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { Digits } from "../digits";
+import { renderTui } from "./render-tui";
+
+describe("Digits", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders sm size as plain text", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("42")).toBe(true);
+ });
+
+ it("renders md size with box drawing characters", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("╭")).toBe(true);
+ expect(tui.screen.contains("╰")).toBe(true);
+ });
+
+ it("renders lg size with wider segments", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("╭───╮")).toBe(true);
+ });
+
+ it("renders numeric value", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("123")).toBe(true);
+ });
+
+ it("renders colon character", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("●")).toBe(true);
+ });
+
+ it("renders multiple digits in a row", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ const lines = tui.screen.lines();
+ expect(lines.length).toBeGreaterThanOrEqual(5);
+ });
+});
diff --git a/registry/ui/__tests__/directory-tree.test.tsx b/registry/ui/__tests__/directory-tree.test.tsx
new file mode 100644
index 0000000..68092c6
--- /dev/null
+++ b/registry/ui/__tests__/directory-tree.test.tsx
@@ -0,0 +1,63 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { DirectoryTree } from "../directory-tree";
+import { renderTui } from "./render-tui";
+
+vi.mock("node:fs", () => ({
+ readdirSync: vi.fn((dir: string) => {
+ if (dir === "/mock") {
+ return ["docs", "src", "readme.md"];
+ }
+ if (dir === "/mock/src") {
+ return ["index.ts", "utils.ts"];
+ }
+ return [];
+ }),
+ statSync: vi.fn((p: string) => ({
+ isDirectory: () => p === "/mock/docs" || p === "/mock/src",
+ })),
+}));
+
+describe("DirectoryTree", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders root path", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("/mock")).toBe(true);
+ });
+
+ it("renders label", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Files")).toBe(true);
+ });
+
+ it("renders directory entries", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("docs")).toBe(true);
+ expect(tui.screen.contains("src")).toBe(true);
+ expect(tui.screen.contains("readme.md")).toBe(true);
+ });
+
+ it("shows directory icons", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("▶")).toBe(true);
+ });
+
+ it("shows file icons", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("·")).toBe(true);
+ });
+
+ it("renders navigation hint", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("navigate")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/divider.test.tsx b/registry/ui/__tests__/divider.test.tsx
new file mode 100644
index 0000000..d7c7616
--- /dev/null
+++ b/registry/ui/__tests__/divider.test.tsx
@@ -0,0 +1,24 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { Divider } from "../divider";
+import { renderTui } from "./render-tui";
+
+describe("Divider", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders horizontal divider", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.lines().length).toBeGreaterThan(0);
+ });
+
+ it("renders with label", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Section")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/drawer.test.tsx b/registry/ui/__tests__/drawer.test.tsx
new file mode 100644
index 0000000..15775fe
--- /dev/null
+++ b/registry/ui/__tests__/drawer.test.tsx
@@ -0,0 +1,77 @@
+import { Text } from "ink";
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { Drawer } from "../drawer";
+import { renderTui } from "./render-tui";
+
+describe("Drawer", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders nothing when closed", () => {
+ const tui = renderTui(
+
+ Hidden
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Hidden")).toBe(false);
+ });
+
+ it("renders children when open", () => {
+ const tui = renderTui(
+
+ Drawer content
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Drawer content")).toBe(true);
+ });
+
+ it("renders title", () => {
+ const tui = renderTui(
+
+ body
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Settings")).toBe(true);
+ });
+
+ it("shows Esc to close hint", () => {
+ const tui = renderTui(
+
+ body
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Esc to close")).toBe(true);
+ });
+
+ it("calls onClose on Escape", async () => {
+ const onClose = vi.fn();
+ const tui = renderTui(
+
+ body
+
+ );
+ ({ unmount } = tui);
+ tui.keys.escape();
+ await tui.flush();
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+
+ it("does not call onClose when closed", async () => {
+ const onClose = vi.fn();
+ const tui = renderTui(
+
+ body
+
+ );
+ ({ unmount } = tui);
+ tui.keys.escape();
+ await tui.flush();
+ expect(onClose).not.toHaveBeenCalled();
+ });
+});
diff --git a/registry/ui/__tests__/email-input.test.tsx b/registry/ui/__tests__/email-input.test.tsx
new file mode 100644
index 0000000..69ffcec
--- /dev/null
+++ b/registry/ui/__tests__/email-input.test.tsx
@@ -0,0 +1,91 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { EmailInput } from "../email-input";
+import { renderTui } from "./render-tui";
+
+describe("EmailInput", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders placeholder when empty", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("you@example.com")).toBe(true);
+ });
+
+ it("renders custom placeholder", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("email...")).toBe(true);
+ });
+
+ it("renders label", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Email")).toBe(true);
+ });
+
+ it("renders controlled value", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("user@test.com")).toBe(true);
+ });
+
+ it("calls onChange when typing", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.type("a");
+ expect(onChange).toHaveBeenCalledWith("a");
+ });
+
+ it("calls onSubmit with valid email", async () => {
+ const onSubmit = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.enter();
+ expect(onSubmit).toHaveBeenCalledWith("user@test.com");
+ });
+
+ it("shows error for invalid email on submit", async () => {
+ const onSubmit = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.enter();
+ await tui.flush();
+ expect(onSubmit).not.toHaveBeenCalled();
+ expect(tui.screen.contains("valid email")).toBe(true);
+ });
+
+ it("completes domain on Tab", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.tab();
+ expect(onChange).toHaveBeenCalledWith("user@gmail.com");
+ });
+
+ it("handles backspace", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.backspace();
+ expect(onChange).toHaveBeenCalledWith("ab");
+ });
+});
diff --git a/registry/ui/__tests__/error-boundary.test.tsx b/registry/ui/__tests__/error-boundary.test.tsx
new file mode 100644
index 0000000..da731f3
--- /dev/null
+++ b/registry/ui/__tests__/error-boundary.test.tsx
@@ -0,0 +1,78 @@
+import { Text } from "ink";
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { ErrorBoundary } from "../error-boundary";
+import { renderTui } from "./render-tui";
+
+const ThrowingComponent = () => {
+ throw new Error("test explosion");
+};
+
+describe("ErrorBoundary", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders children when no error", () => {
+ const tui = renderTui(
+
+ safe content
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("safe content")).toBe(true);
+ });
+
+ it("renders default error UI on error", () => {
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("test explosion")).toBe(true);
+ expect(tui.screen.contains("Error")).toBe(true);
+ spy.mockRestore();
+ });
+
+ it("renders custom fallback on error", () => {
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
+ const tui = renderTui(
+ oops}>
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("oops")).toBe(true);
+ spy.mockRestore();
+ });
+
+ it("calls onError callback", () => {
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
+ const onError = vi.fn();
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(onError).toHaveBeenCalledWith(
+ expect.any(Error),
+ expect.objectContaining({ componentStack: expect.any(String) })
+ );
+ spy.mockRestore();
+ });
+
+ it("renders custom title", () => {
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Crash")).toBe(true);
+ spy.mockRestore();
+ });
+});
diff --git a/registry/ui/__tests__/file-change.test.tsx b/registry/ui/__tests__/file-change.test.tsx
new file mode 100644
index 0000000..00d9a01
--- /dev/null
+++ b/registry/ui/__tests__/file-change.test.tsx
@@ -0,0 +1,129 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { FileChange } from "../file-change";
+import { renderTui } from "./render-tui";
+
+describe("FileChange", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ const changes = [
+ { diff: "-old\n+new", path: "src/app.tsx", type: "modify" as const },
+ {
+ content: "export const x = 1;",
+ path: "src/utils.ts",
+ type: "create" as const,
+ },
+ { path: "src/legacy.ts", type: "delete" as const },
+ ];
+
+ it("renders file change count", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("File Changes (3)")).toBe(true);
+ });
+
+ it("renders file paths", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("src/app.tsx")).toBe(true);
+ expect(tui.screen.contains("src/utils.ts")).toBe(true);
+ expect(tui.screen.contains("src/legacy.ts")).toBe(true);
+ });
+
+ it("renders type indicators", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("M")).toBe(true);
+ expect(tui.screen.contains("A")).toBe(true);
+ expect(tui.screen.contains("D")).toBe(true);
+ });
+
+ it("renders cursor on active item", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("›")).toBe(true);
+ });
+
+ it("navigates with arrow keys", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.down();
+ await tui.flush();
+ const lines = tui.screen.lines();
+ const legacyLine = lines.find((l: string) => l.includes("src/legacy.ts"));
+ expect(legacyLine).toContain("›");
+ });
+
+ it("expands diff on enter", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.enter();
+ await tui.flush();
+ expect(tui.screen.contains("▼")).toBe(true);
+ });
+
+ it("collapses on second enter", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.enter();
+ await tui.flush();
+ expect(tui.screen.contains("▼")).toBe(true);
+ tui.keys.enter();
+ await tui.flush();
+ expect(tui.screen.contains("▶")).toBe(true);
+ });
+
+ it("accepts a change with y key", async () => {
+ const onAccept = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.press("y");
+ await tui.flush();
+ expect(onAccept).toHaveBeenCalledWith("src/app.tsx");
+ expect(tui.screen.contains("accepted")).toBe(true);
+ });
+
+ it("rejects a change with n key", async () => {
+ const onReject = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.press("n");
+ await tui.flush();
+ expect(onReject).toHaveBeenCalledWith("src/app.tsx");
+ expect(tui.screen.contains("rejected")).toBe(true);
+ });
+
+ it("calls onAcceptAll with a key", async () => {
+ const onAcceptAll = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.press("a");
+ await tui.flush();
+ expect(onAcceptAll).toHaveBeenCalled();
+ });
+
+ it("renders help instructions", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("navigate")).toBe(true);
+ expect(tui.screen.contains("accept")).toBe(true);
+ expect(tui.screen.contains("reject")).toBe(true);
+ });
+
+ it("expands file with content (create type)", async () => {
+ const createOnly = [
+ { content: "hello world", path: "new.ts", type: "create" as const },
+ ];
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.enter();
+ await tui.flush();
+ expect(tui.screen.contains("hello world")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/file-picker.test.tsx b/registry/ui/__tests__/file-picker.test.tsx
new file mode 100644
index 0000000..bc898b3
--- /dev/null
+++ b/registry/ui/__tests__/file-picker.test.tsx
@@ -0,0 +1,49 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { FilePicker } from "../file-picker";
+import { renderTui } from "./render-tui";
+
+vi.mock("node:fs", () => ({
+ readdirSync: vi.fn(() => ["folder", "file.txt", "data.json"]),
+ statSync: vi.fn((p: string) => ({
+ isDirectory: () => p.endsWith("folder"),
+ })),
+}));
+
+describe("FilePicker", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders label", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Choose file")).toBe(true);
+ });
+
+ it("renders current directory", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("/test")).toBe(true);
+ });
+
+ it("renders file entries", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("file.txt")).toBe(true);
+ });
+
+ it("renders parent directory entry", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("..")).toBe(true);
+ });
+
+ it("renders directory icon", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("▶")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/form-field.test.tsx b/registry/ui/__tests__/form-field.test.tsx
new file mode 100644
index 0000000..a713a43
--- /dev/null
+++ b/registry/ui/__tests__/form-field.test.tsx
@@ -0,0 +1,51 @@
+import { Text } from "ink";
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { FormField } from "../form-field";
+import { renderTui } from "./render-tui";
+
+describe("FormField", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders label", () => {
+ const tui = renderTui(
+
+ field
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Username")).toBe(true);
+ });
+
+ it("shows error", () => {
+ const tui = renderTui(
+
+ input
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Invalid email")).toBe(true);
+ });
+
+ it("shows hint when there is no error", () => {
+ const tui = renderTui(
+
+ input
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Use your legal name")).toBe(true);
+ });
+
+ it("shows asterisk when required", () => {
+ const tui = renderTui(
+
+ secret
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("*")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/form.test.tsx b/registry/ui/__tests__/form.test.tsx
new file mode 100644
index 0000000..446ff2e
--- /dev/null
+++ b/registry/ui/__tests__/form.test.tsx
@@ -0,0 +1,107 @@
+import { Text } from "ink";
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { Form, useFormContext } from "../form";
+import { renderTui } from "./render-tui";
+
+const FieldDisplay = ({ name }: { name: string }) => {
+ const { values, errors } = useFormContext();
+ return (
+
+ {name}:{String(values[name] ?? "")}
+ {errors[name] ? ` error:${errors[name]}` : ""}
+
+ );
+};
+
+describe("Form", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders children and submit hint", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("child content")).toBe(true);
+ expect(tui.screen.contains("Ctrl+S")).toBe(true);
+ });
+
+ it("provides initial values via context", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("name:Alice")).toBe(true);
+ });
+
+ it("calls onSubmit with values on Ctrl+S", async () => {
+ const onSubmit = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.raw("\u0013");
+ await tui.flush();
+ expect(onSubmit).toHaveBeenCalledWith(
+ expect.objectContaining({ name: "Bob" })
+ );
+ });
+
+ it("validates fields before submit", async () => {
+ const onSubmit = vi.fn();
+ const fields = [
+ {
+ name: "email",
+ validate: (v: unknown) =>
+ typeof v === "string" && v.includes("@") ? null : "Invalid email",
+ },
+ ];
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.raw("\u0013");
+ await tui.flush();
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it("submits when validation passes", async () => {
+ const onSubmit = vi.fn();
+ const fields = [
+ {
+ name: "email",
+ validate: (v: unknown) =>
+ typeof v === "string" && v.includes("@") ? null : "Invalid",
+ },
+ ];
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.raw("\u0013");
+ await tui.flush();
+ expect(onSubmit).toHaveBeenCalledWith(
+ expect.objectContaining({ email: "a@b.com" })
+ );
+ });
+});
diff --git a/registry/ui/__tests__/gauge.test.tsx b/registry/ui/__tests__/gauge.test.tsx
new file mode 100644
index 0000000..36aef62
--- /dev/null
+++ b/registry/ui/__tests__/gauge.test.tsx
@@ -0,0 +1,55 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { Gauge } from "../gauge";
+import { renderTui } from "./render-tui";
+
+describe("Gauge", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders bar fill characters for sm", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("█")).toBe(true);
+ expect(tui.screen.contains("░")).toBe(true);
+ });
+
+ it("shows percentage", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("75%")).toBe(true);
+ });
+
+ it("renders label", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("CPU")).toBe(true);
+ });
+
+ it("clamps value to min/max range", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("100%")).toBe(true);
+ });
+
+ it("renders md size with border characters", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("╭")).toBe(true);
+ expect(tui.screen.contains("╯")).toBe(true);
+ });
+
+ it("renders lg size", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("╱")).toBe(true);
+ expect(tui.screen.contains("╲")).toBe(true);
+ });
+
+ it("supports custom min/max", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("50%")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/git-status.test.tsx b/registry/ui/__tests__/git-status.test.tsx
new file mode 100644
index 0000000..1f3685e
--- /dev/null
+++ b/registry/ui/__tests__/git-status.test.tsx
@@ -0,0 +1,27 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { GitStatus } from "../git-status";
+import { renderTui } from "./render-tui";
+
+describe("GitStatus", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders branch name", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("feature/foo")).toBe(true);
+ });
+
+ it("renders counts", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("1↑")).toBe(true);
+ expect(tui.screen.contains("4↓")).toBe(true);
+ expect(tui.screen.contains("staged 2")).toBe(true);
+ expect(tui.screen.contains("modified 3")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/gradient.test.tsx b/registry/ui/__tests__/gradient.test.tsx
new file mode 100644
index 0000000..8ff9b0d
--- /dev/null
+++ b/registry/ui/__tests__/gradient.test.tsx
@@ -0,0 +1,25 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { Gradient } from "../gradient";
+import { renderTui } from "./render-tui";
+
+describe("Gradient", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders text characters", () => {
+ const tui = renderTui(
+ Hi
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("H")).toBe(true);
+ expect(tui.screen.contains("i")).toBe(true);
+ });
+
+ it("renders with preset name", () => {
+ const tui = renderTui(preset);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("preset")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/grid.test.tsx b/registry/ui/__tests__/grid.test.tsx
new file mode 100644
index 0000000..0302ebc
--- /dev/null
+++ b/registry/ui/__tests__/grid.test.tsx
@@ -0,0 +1,25 @@
+import { Text } from "ink";
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { Grid } from "../grid";
+import { renderTui } from "./render-tui";
+
+describe("Grid", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders children", () => {
+ const tui = renderTui(
+
+ a
+ b
+ c
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("a")).toBe(true);
+ expect(tui.screen.contains("b")).toBe(true);
+ expect(tui.screen.contains("c")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/heading.test.tsx b/registry/ui/__tests__/heading.test.tsx
new file mode 100644
index 0000000..7959c2e
--- /dev/null
+++ b/registry/ui/__tests__/heading.test.tsx
@@ -0,0 +1,37 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { Heading } from "../heading";
+import { renderTui } from "./render-tui";
+
+describe("Heading", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders level 1 with uppercase", () => {
+ const tui = renderTui(section);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("██")).toBe(true);
+ expect(tui.screen.contains("SECTION")).toBe(true);
+ });
+
+ it("renders level 2", () => {
+ const tui = renderTui(Two);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("▌")).toBe(true);
+ expect(tui.screen.contains("Two")).toBe(true);
+ });
+
+ it("renders level 3", () => {
+ const tui = renderTui(Three);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("›")).toBe(true);
+ expect(tui.screen.contains("Three")).toBe(true);
+ });
+
+ it("renders level 4", () => {
+ const tui = renderTui(Small);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Small")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/heat-map.test.tsx b/registry/ui/__tests__/heat-map.test.tsx
new file mode 100644
index 0000000..59b281a
--- /dev/null
+++ b/registry/ui/__tests__/heat-map.test.tsx
@@ -0,0 +1,61 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { HeatMap } from "../heat-map";
+import { renderTui } from "./render-tui";
+
+describe("HeatMap", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ const sampleData = [
+ [1, 5, 9],
+ [3, 7, 2],
+ ];
+
+ it("renders shade characters", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ const text = tui.screen.text();
+ const hasShades = /[░▒▓█]/.test(text);
+ expect(hasShades).toBe(true);
+ });
+
+ it("renders row labels", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Mon")).toBe(true);
+ expect(tui.screen.contains("Tue")).toBe(true);
+ });
+
+ it("renders column labels", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("AM")).toBe(true);
+ expect(tui.screen.contains("PM")).toBe(true);
+ });
+
+ it("shows values when showValues is true", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("5")).toBe(true);
+ expect(tui.screen.contains("9")).toBe(true);
+ });
+
+ it("renders color scale legend", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Low")).toBe(true);
+ expect(tui.screen.contains("High")).toBe(true);
+ });
+
+ it("renders empty state", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("No data")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/help-screen.test.tsx b/registry/ui/__tests__/help-screen.test.tsx
new file mode 100644
index 0000000..7d178dd
--- /dev/null
+++ b/registry/ui/__tests__/help-screen.test.tsx
@@ -0,0 +1,88 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { HelpScreen } from "../help-screen";
+import { renderTui } from "./render-tui";
+
+describe("HelpScreen", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders section label", () => {
+ const tui = renderTui(
+
+
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Options")).toBe(true);
+ });
+
+ it("renders row flag and description", () => {
+ const tui = renderTui(
+
+
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("--verbose")).toBe(true);
+ expect(tui.screen.contains("Enable verbose")).toBe(true);
+ });
+
+ it("renders tagline", () => {
+ const tui = renderTui(
+
+
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("A handy tool")).toBe(true);
+ });
+
+ it("renders usage", () => {
+ const tui = renderTui(
+
+
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Usage:")).toBe(true);
+ expect(tui.screen.contains("cli [opts]")).toBe(true);
+ });
+
+ it("renders description", () => {
+ const tui = renderTui(
+
+
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Does things")).toBe(true);
+ });
+
+ it("renders multiple sections", () => {
+ const tui = renderTui(
+
+
+
+
+
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Commands")).toBe(true);
+ expect(tui.screen.contains("Options")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/help.test.tsx b/registry/ui/__tests__/help.test.tsx
new file mode 100644
index 0000000..041be74
--- /dev/null
+++ b/registry/ui/__tests__/help.test.tsx
@@ -0,0 +1,37 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { Help } from "../help";
+import { renderTui } from "./render-tui";
+
+describe("Help", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders keymap entries", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("q")).toBe(true);
+ expect(tui.screen.contains("quit")).toBe(true);
+ expect(tui.screen.contains("j")).toBe(true);
+ expect(tui.screen.contains("down")).toBe(true);
+ });
+
+ it("renders title", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Shortcuts")).toBe(true);
+ });
+
+ it("renders compact mode with · separator", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("·")).toBe(true);
+ expect(tui.screen.contains("x: exit")).toBe(true);
+ expect(tui.screen.contains("y: yank")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/image.test.tsx b/registry/ui/__tests__/image.test.tsx
new file mode 100644
index 0000000..75386c4
--- /dev/null
+++ b/registry/ui/__tests__/image.test.tsx
@@ -0,0 +1,47 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { Image } from "../image";
+import { renderTui } from "./render-tui";
+
+vi.mock("node:fs", () => ({
+ readFileSync: vi.fn(() => Buffer.from("fakeimage")),
+}));
+
+describe("Image", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders ascii fallback box", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("┌")).toBe(true);
+ expect(tui.screen.contains("┘")).toBe(true);
+ });
+
+ it("renders alt text", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("My Photo")).toBe(true);
+ });
+
+ it("renders filename in ascii fallback", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("photo.png")).toBe(true);
+ });
+
+ it("shows ascii fallback label", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("ascii fallback")).toBe(true);
+ });
+
+ it("renders extension", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains(".jpg")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/info-box.test.tsx b/registry/ui/__tests__/info-box.test.tsx
new file mode 100644
index 0000000..016146e
--- /dev/null
+++ b/registry/ui/__tests__/info-box.test.tsx
@@ -0,0 +1,46 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { InfoBox } from "../info-box";
+import { renderTui } from "./render-tui";
+
+describe("InfoBox", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders with header label", () => {
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("My service")).toBe(true);
+ });
+
+ it("renders rows", () => {
+ const tui = renderTui(
+
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Host")).toBe(true);
+ expect(tui.screen.contains("localhost")).toBe(true);
+ expect(tui.screen.contains("Port")).toBe(true);
+ expect(tui.screen.contains("3000")).toBe(true);
+ });
+
+ it("renders tree rows with └", () => {
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("└")).toBe(true);
+ expect(tui.screen.contains("nested")).toBe(true);
+ expect(tui.screen.contains("leaf")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/json.test.tsx b/registry/ui/__tests__/json.test.tsx
new file mode 100644
index 0000000..87e191d
--- /dev/null
+++ b/registry/ui/__tests__/json.test.tsx
@@ -0,0 +1,92 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { JSONView } from "../json";
+import { renderTui } from "./render-tui";
+
+describe("JSONView", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders primitive string value", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("hello")).toBe(true);
+ });
+
+ it("renders number value", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("42")).toBe(true);
+ });
+
+ it("renders null value", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("null")).toBe(true);
+ });
+
+ it("renders boolean value", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("true")).toBe(true);
+ });
+
+ it("renders object keys", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("name")).toBe(true);
+ expect(tui.screen.contains("Alice")).toBe(true);
+ expect(tui.screen.contains("age")).toBe(true);
+ expect(tui.screen.contains("30")).toBe(true);
+ });
+
+ it("renders object with braces", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("{")).toBe(true);
+ expect(tui.screen.contains("}")).toBe(true);
+ });
+
+ it("renders array with brackets", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("[")).toBe(true);
+ expect(tui.screen.contains("]")).toBe(true);
+ });
+
+ it("renders label when provided", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Config")).toBe(true);
+ });
+
+ it("collapses object when collapsed is true", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("...")).toBe(true);
+ });
+
+ it("toggles collapse on space for nested object", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Alice")).toBe(true);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.space();
+ await tui.flush();
+ expect(tui.screen.contains("...")).toBe(true);
+ });
+
+ it("navigates with arrow keys", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.down();
+ await tui.flush();
+ expect(tui.screen.contains("b")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/key-value.test.tsx b/registry/ui/__tests__/key-value.test.tsx
new file mode 100644
index 0000000..e22b60b
--- /dev/null
+++ b/registry/ui/__tests__/key-value.test.tsx
@@ -0,0 +1,34 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { KeyValue } from "../key-value";
+import { renderTui } from "./render-tui";
+
+describe("KeyValue", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders keys and values", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("name")).toBe(true);
+ expect(tui.screen.contains("Ada")).toBe(true);
+ expect(tui.screen.contains("role")).toBe(true);
+ expect(tui.screen.contains("dev")).toBe(true);
+ });
+
+ it("renders separator", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains(" => ")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/keyboard-shortcuts.test.tsx b/registry/ui/__tests__/keyboard-shortcuts.test.tsx
new file mode 100644
index 0000000..4401c2a
--- /dev/null
+++ b/registry/ui/__tests__/keyboard-shortcuts.test.tsx
@@ -0,0 +1,38 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { KeyboardShortcuts } from "../keyboard-shortcuts";
+import { renderTui } from "./render-tui";
+
+describe("KeyboardShortcuts", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders shortcut keys and descriptions", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("q")).toBe(true);
+ expect(tui.screen.contains("Quit")).toBe(true);
+ expect(tui.screen.contains("?")).toBe(true);
+ expect(tui.screen.contains("Help")).toBe(true);
+ });
+
+ it('renders title with "⌨"', () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("⌨")).toBe(true);
+ expect(tui.screen.contains("Shortcuts")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/line-chart.test.tsx b/registry/ui/__tests__/line-chart.test.tsx
new file mode 100644
index 0000000..32e8097
--- /dev/null
+++ b/registry/ui/__tests__/line-chart.test.tsx
@@ -0,0 +1,47 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { LineChart } from "../line-chart";
+import { renderTui } from "./render-tui";
+
+describe("LineChart", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders plot characters", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("●")).toBe(true);
+ });
+
+ it("renders title", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("CPU Usage")).toBe(true);
+ });
+
+ it("renders axes by default", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("│")).toBe(true);
+ expect(tui.screen.contains("└")).toBe(true);
+ });
+
+ it("hides axes when showAxes is false", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("└")).toBe(false);
+ });
+
+ it("renders empty state", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("No data")).toBe(true);
+ });
+
+ it("handles single data point", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("●")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/link.test.tsx b/registry/ui/__tests__/link.test.tsx
new file mode 100644
index 0000000..2ba2354
--- /dev/null
+++ b/registry/ui/__tests__/link.test.tsx
@@ -0,0 +1,31 @@
+import React from "react";
+import { describe, it, expect, afterEach, vi } from "vitest";
+
+import { Link } from "../link";
+import { renderTui } from "./render-tui";
+
+describe("Link", () => {
+ let unmount: () => void;
+ afterEach(() => {
+ unmount?.();
+ vi.unstubAllEnvs();
+ });
+
+ it("renders children text", () => {
+ vi.stubEnv("TERM", "dumb");
+ vi.stubEnv("TERM_PROGRAM", "");
+ const tui = renderTui(
+ Documentation
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Documentation")).toBe(true);
+ });
+
+ it("renders href in fallback mode", () => {
+ vi.stubEnv("TERM", "dumb");
+ vi.stubEnv("TERM_PROGRAM", "");
+ const tui = renderTui(Open);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("https://example.com/page")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/list.test.tsx b/registry/ui/__tests__/list.test.tsx
new file mode 100644
index 0000000..4fa03bf
--- /dev/null
+++ b/registry/ui/__tests__/list.test.tsx
@@ -0,0 +1,116 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { List } from "../list";
+import { renderTui } from "./render-tui";
+
+const items = [
+ { key: "1", label: "Apple" },
+ { key: "2", label: "Banana" },
+ { key: "3", label: "Cherry" },
+];
+
+describe("List", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders all item labels", () => {
+ const tui = renderTui(
);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Apple")).toBe(true);
+ expect(tui.screen.contains("Banana")).toBe(true);
+ expect(tui.screen.contains("Cherry")).toBe(true);
+ });
+
+ it("shows cursor on first item", () => {
+ const tui = renderTui(
);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("›")).toBe(true);
+ });
+
+ it("selects item on Enter", () => {
+ const onSelect = vi.fn();
+ const tui = renderTui(
);
+ ({ unmount } = tui);
+ tui.keys.enter();
+ expect(onSelect).toHaveBeenCalledWith(
+ expect.objectContaining({ key: "1", label: "Apple" })
+ );
+ });
+
+ it("navigates down and selects", async () => {
+ const onSelect = vi.fn();
+ const tui = renderTui(
);
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.enter();
+ expect(onSelect).toHaveBeenCalledWith(
+ expect.objectContaining({ key: "2", label: "Banana" })
+ );
+ });
+
+ it("navigates up", async () => {
+ const onSelect = vi.fn();
+ const tui = renderTui(
);
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.up();
+ await tui.flush();
+ tui.keys.enter();
+ expect(onSelect).toHaveBeenCalledWith(
+ expect.objectContaining({ key: "2", label: "Banana" })
+ );
+ });
+
+ it("renders description", () => {
+ const withDesc = [{ description: "A fruit", key: "1", label: "Apple" }];
+ const tui = renderTui(
);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("A fruit")).toBe(true);
+ });
+
+ it("filters items when filterable", async () => {
+ const tui = renderTui(
);
+ ({ unmount } = tui);
+ tui.keys.type("ban");
+ await tui.flush();
+ expect(tui.screen.contains("Banana")).toBe(true);
+ expect(tui.screen.contains("Apple")).toBe(false);
+ expect(tui.screen.contains("Cherry")).toBe(false);
+ });
+
+ it("shows filter input when filterable", () => {
+ const tui = renderTui(
);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Type to filter…")).toBe(true);
+ });
+
+ it("clears filter with backspace", async () => {
+ const tui = renderTui(
);
+ ({ unmount } = tui);
+ tui.keys.type("ch");
+ await tui.flush();
+ expect(tui.screen.contains("Cherry")).toBe(true);
+ expect(tui.screen.contains("Apple")).toBe(false);
+ tui.keys.raw("\u0008");
+ await tui.flush();
+ tui.keys.raw("\u0008");
+ await tui.flush();
+ expect(tui.screen.contains("Apple")).toBe(true);
+ expect(tui.screen.contains("Cherry")).toBe(true);
+ });
+
+ it("shows overflow indicator", () => {
+ const manyItems = Array.from({ length: 15 }, (_, i) => ({
+ key: String(i),
+ label: `Item ${i}`,
+ }));
+ const tui = renderTui(
);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("more…")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/log.test.tsx b/registry/ui/__tests__/log.test.tsx
new file mode 100644
index 0000000..f2136e6
--- /dev/null
+++ b/registry/ui/__tests__/log.test.tsx
@@ -0,0 +1,91 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { Log } from "../log";
+import { renderTui } from "./render-tui";
+
+describe("Log", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ const entries = [
+ { level: "info" as const, message: "Server started" },
+ { level: "warn" as const, message: "Deprecated API used" },
+ { level: "error" as const, message: "Connection failed" },
+ { level: "debug" as const, message: "Parsing config" },
+ ];
+
+ it("renders log messages", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Server started")).toBe(true);
+ expect(tui.screen.contains("Deprecated API used")).toBe(true);
+ expect(tui.screen.contains("Connection failed")).toBe(true);
+ });
+
+ it("renders level labels", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("INF")).toBe(true);
+ expect(tui.screen.contains("WRN")).toBe(true);
+ expect(tui.screen.contains("ERR")).toBe(true);
+ expect(tui.screen.contains("DBG")).toBe(true);
+ });
+
+ it("shows scroll position indicator", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("1–4/4")).toBe(true);
+ });
+
+ it("shows follow indicator", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("↓ follow")).toBe(true);
+ });
+
+ it("shows scroll hint", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("j/k scroll")).toBe(true);
+ });
+
+ it("filters entries by filter string", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Connection failed")).toBe(true);
+ expect(tui.screen.contains("Server started")).toBe(false);
+ });
+
+ it("scrolls down with down arrow", async () => {
+ const manyEntries = Array.from({ length: 20 }, (_, i) => ({
+ level: "info" as const,
+ message: `Line ${i}`,
+ }));
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Line 0")).toBe(true);
+ tui.keys.down();
+ await tui.flush();
+ expect(tui.screen.contains("2–6/20")).toBe(true);
+ });
+
+ it("shows filter text in status bar", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("filter: warn")).toBe(true);
+ });
+
+ it("renders timestamps when provided", () => {
+ const entriesWithTime = [
+ {
+ level: "info" as const,
+ message: "test",
+ timestamp: new Date(2025, 0, 1, 14, 30, 0),
+ },
+ ];
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("14:30:00")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/login-flow.test.tsx b/registry/ui/__tests__/login-flow.test.tsx
new file mode 100644
index 0000000..c79ba86
--- /dev/null
+++ b/registry/ui/__tests__/login-flow.test.tsx
@@ -0,0 +1,152 @@
+import { Text } from "ink";
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { LoginFlow } from "../login-flow";
+import { renderTui } from "./render-tui";
+
+describe("LoginFlow", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ describe("LoginFlow (root)", () => {
+ it("renders children", () => {
+ const tui = renderTui(
+
+ Welcome back
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Welcome back")).toBe(true);
+ });
+
+ it("renders title via BigText", () => {
+ const tui = renderTui(
+
+ content
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("content")).toBe(true);
+ const text = tui.screen.text();
+ expect(text.length).toBeGreaterThan("content".length);
+ });
+ });
+
+ describe("LoginFlow.Announcement", () => {
+ it("renders announcement with icon", () => {
+ const tui = renderTui(
+
+
+ New version available!
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("!")).toBe(true);
+ expect(tui.screen.contains("New version available!")).toBe(true);
+ });
+
+ it("renders with default icon", () => {
+ const tui = renderTui(
+
+ Notice
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("*")).toBe(true);
+ expect(tui.screen.contains("Notice")).toBe(true);
+ });
+ });
+
+ describe("LoginFlow.Description", () => {
+ it("renders description text", () => {
+ const tui = renderTui(
+
+ Please sign in
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Please sign in")).toBe(true);
+ });
+ });
+
+ describe("LoginFlow.Select", () => {
+ const options = ["GitHub", "Google", "Email"];
+
+ it("renders options", () => {
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("GitHub")).toBe(true);
+ expect(tui.screen.contains("Google")).toBe(true);
+ expect(tui.screen.contains("Email")).toBe(true);
+ });
+
+ it("renders label", () => {
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Sign in with:")).toBe(true);
+ });
+
+ it("renders cursor on active item", () => {
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("›")).toBe(true);
+ });
+
+ it("navigates with arrow keys", async () => {
+ const onSelect = vi.fn();
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.enter();
+ await tui.flush();
+ expect(onSelect).toHaveBeenCalledWith(1);
+ });
+
+ it("selects by number key", async () => {
+ const onSelect = vi.fn();
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ tui.keys.press("3");
+ await tui.flush();
+ expect(onSelect).toHaveBeenCalledWith(2);
+ });
+
+ it("does not go above first option", async () => {
+ const onSelect = vi.fn();
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ tui.keys.up();
+ await tui.flush();
+ tui.keys.enter();
+ await tui.flush();
+ expect(onSelect).toHaveBeenCalledWith(0);
+ });
+ });
+});
diff --git a/registry/ui/__tests__/markdown.test.tsx b/registry/ui/__tests__/markdown.test.tsx
new file mode 100644
index 0000000..f60220d
--- /dev/null
+++ b/registry/ui/__tests__/markdown.test.tsx
@@ -0,0 +1,61 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { Markdown } from "../markdown";
+import { renderTui } from "./render-tui";
+
+describe("Markdown", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders heading text", () => {
+ const tui = renderTui(# Hello World);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Hello World")).toBe(true);
+ });
+
+ it("renders h2 heading", () => {
+ const tui = renderTui(## Subtitle);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Subtitle")).toBe(true);
+ });
+
+ it("renders list items with bullet", () => {
+ const tui = renderTui({"- item one\n- item two"});
+ ({ unmount } = tui);
+ expect(tui.screen.contains("•")).toBe(true);
+ expect(tui.screen.contains("item one")).toBe(true);
+ expect(tui.screen.contains("item two")).toBe(true);
+ });
+
+ it("renders bold text", () => {
+ const tui = renderTui(**bold text**);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("bold text")).toBe(true);
+ });
+
+ it("renders inline code", () => {
+ const tui = renderTui(`code here`);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("code here")).toBe(true);
+ });
+
+ it("renders blockquote with pipe", () => {
+ const tui = renderTui({"> quoted text"});
+ ({ unmount } = tui);
+ expect(tui.screen.contains("│")).toBe(true);
+ expect(tui.screen.contains("quoted text")).toBe(true);
+ });
+
+ it("renders horizontal rule", () => {
+ const tui = renderTui(---);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("─")).toBe(true);
+ });
+
+ it("renders plain paragraph text", () => {
+ const tui = renderTui(Just a paragraph.);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Just a paragraph.")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/masked-input.test.tsx b/registry/ui/__tests__/masked-input.test.tsx
new file mode 100644
index 0000000..2ef0400
--- /dev/null
+++ b/registry/ui/__tests__/masked-input.test.tsx
@@ -0,0 +1,105 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { MaskedInput } from "../masked-input";
+import type { TuiInstance } from "./render-tui";
+import { renderTui } from "./render-tui";
+
+const typeChars = async (tui: TuiInstance, text: string) => {
+ for (const char of text) {
+ tui.keys.press(char);
+ await tui.flush();
+ }
+};
+
+describe("MaskedInput", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders label", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Phone")).toBe(true);
+ });
+
+ it("renders placeholder when empty", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Enter digits")).toBe(true);
+ });
+
+ it("formats digits through mask", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ await tui.flush();
+ await typeChars(tui, "123");
+ expect(tui.screen.contains("(123")).toBe(true);
+ });
+
+ it("calls onChange with raw digits", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.press("5");
+ await tui.flush();
+ expect(onChange).toHaveBeenCalledWith("5");
+ });
+
+ it("handles backspace", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.backspace();
+ await tui.flush();
+ expect(onChange).toHaveBeenCalledWith("1");
+ });
+
+ it("calls onSubmit on enter", async () => {
+ const onSubmit = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.enter();
+ await tui.flush();
+ expect(onSubmit).toHaveBeenCalledWith("1234567");
+ });
+
+ it("ignores non-digit characters", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.press("a");
+ await tui.flush();
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it("does not exceed max digits", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.press("4");
+ await tui.flush();
+ expect(onChange).not.toHaveBeenCalled();
+ });
+});
diff --git a/registry/ui/__tests__/menu.test.tsx b/registry/ui/__tests__/menu.test.tsx
new file mode 100644
index 0000000..00dd748
--- /dev/null
+++ b/registry/ui/__tests__/menu.test.tsx
@@ -0,0 +1,120 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { Menu } from "../menu";
+import { renderTui } from "./render-tui";
+
+describe("Menu", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ const items = [
+ { key: "copy", label: "Copy" },
+ { key: "paste", label: "Paste" },
+ { key: "cut", label: "Cut" },
+ ];
+
+ it("renders all items", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Copy")).toBe(true);
+ expect(tui.screen.contains("Paste")).toBe(true);
+ expect(tui.screen.contains("Cut")).toBe(true);
+ });
+
+ it("renders title", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Edit")).toBe(true);
+ });
+
+ it("renders shortcuts", () => {
+ const itemsWithShortcuts = [
+ { key: "copy", label: "Copy", shortcut: "Ctrl+C" },
+ ];
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Ctrl+C")).toBe(true);
+ });
+
+ it("renders separator", () => {
+ const itemsWithSep = [
+ { key: "copy", label: "Copy" },
+ { key: "sep", label: "", separator: true },
+ { key: "paste", label: "Paste" },
+ ];
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("─")).toBe(true);
+ });
+
+ it("navigates with arrow keys and selects on enter", async () => {
+ const onSelect = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.enter();
+ await tui.flush();
+ expect(onSelect).toHaveBeenCalledWith(
+ expect.objectContaining({ key: "paste" })
+ );
+ });
+
+ it("skips disabled items during navigation", async () => {
+ const onSelect = vi.fn();
+ const itemsWithDisabled = [
+ { key: "a", label: "A" },
+ { disabled: true, key: "b", label: "B" },
+ { key: "c", label: "C" },
+ ];
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.enter();
+ await tui.flush();
+ expect(onSelect).toHaveBeenCalledWith(
+ expect.objectContaining({ key: "c" })
+ );
+ });
+
+ it("opens submenu with right arrow", async () => {
+ const itemsWithSub = [
+ {
+ children: [
+ { key: "new", label: "New" },
+ { key: "open", label: "Open" },
+ ],
+ key: "file",
+ label: "File",
+ },
+ ];
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.right();
+ await tui.flush();
+ expect(tui.screen.contains("New")).toBe(true);
+ expect(tui.screen.contains("Open")).toBe(true);
+ });
+
+ it("closes submenu with escape", async () => {
+ const itemsWithSub = [
+ {
+ children: [{ key: "new", label: "New File" }],
+ key: "file",
+ label: "File",
+ },
+ ];
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.right();
+ await tui.flush();
+ expect(tui.screen.contains("New File")).toBe(true);
+ tui.keys.escape();
+ await tui.flush();
+ expect(tui.screen.contains("File")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/modal.test.tsx b/registry/ui/__tests__/modal.test.tsx
new file mode 100644
index 0000000..a73a2c7
--- /dev/null
+++ b/registry/ui/__tests__/modal.test.tsx
@@ -0,0 +1,97 @@
+import { Text } from "ink";
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { Modal } from "../modal";
+import { renderTui } from "./render-tui";
+
+describe("Modal", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders nothing when closed", () => {
+ const tui = renderTui(
+
+ Hidden content
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Hidden content")).toBe(false);
+ });
+
+ it("renders children when open", () => {
+ const tui = renderTui(
+
+ Visible content
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Visible content")).toBe(true);
+ });
+
+ it("renders title", () => {
+ const tui = renderTui(
+
+ body
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("My Modal")).toBe(true);
+ });
+
+ it("shows close hint by default", () => {
+ const tui = renderTui(
+
+ body
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Press Esc to close")).toBe(true);
+ });
+
+ it("hides close hint when closeHint=false", () => {
+ const tui = renderTui(
+
+ body
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Press Esc to close")).toBe(false);
+ });
+
+ it("renders custom close hint", () => {
+ const tui = renderTui(
+
+ body
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Hit Esc")).toBe(true);
+ });
+
+ it("calls onClose on Escape", async () => {
+ const onClose = vi.fn();
+ const tui = renderTui(
+
+ body
+
+ );
+ ({ unmount } = tui);
+ tui.keys.escape();
+ await tui.flush();
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+
+ it("does not call onClose when closed", async () => {
+ const onClose = vi.fn();
+ const tui = renderTui(
+
+ body
+
+ );
+ ({ unmount } = tui);
+ tui.keys.escape();
+ await tui.flush();
+ expect(onClose).not.toHaveBeenCalled();
+ });
+});
diff --git a/registry/ui/__tests__/model-selector.test.tsx b/registry/ui/__tests__/model-selector.test.tsx
new file mode 100644
index 0000000..cb5579d
--- /dev/null
+++ b/registry/ui/__tests__/model-selector.test.tsx
@@ -0,0 +1,98 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { ModelSelector } from "../model-selector";
+import { renderTui } from "./render-tui";
+
+describe("ModelSelector", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ const models = [
+ { context: 128_000, id: "gpt4", name: "GPT-4", provider: "OpenAI" },
+ { context: 200_000, id: "claude", name: "Claude", provider: "Anthropic" },
+ { context: 1_000_000, id: "gemini", name: "Gemini", provider: "Google" },
+ ];
+
+ it("renders model names", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("GPT-4")).toBe(true);
+ expect(tui.screen.contains("Claude")).toBe(true);
+ expect(tui.screen.contains("Gemini")).toBe(true);
+ });
+
+ it("shows checkmark on selected model", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("✓")).toBe(true);
+ });
+
+ it("shows provider when showProvider is true", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("OpenAI")).toBe(true);
+ expect(tui.screen.contains("Anthropic")).toBe(true);
+ });
+
+ it("shows context size", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("128k ctx")).toBe(true);
+ expect(tui.screen.contains("1M ctx")).toBe(true);
+ });
+
+ it("hides context when showContext is false", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("128k ctx")).toBe(false);
+ });
+
+ it("calls onSelect on enter", async () => {
+ const onSelect = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.enter();
+ expect(onSelect).toHaveBeenCalledWith("claude");
+ });
+
+ it("navigates with arrow keys", async () => {
+ const onSelect = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.enter();
+ expect(onSelect).toHaveBeenCalledWith("gemini");
+ });
+
+ it("shows cursor on active item", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("›")).toBe(true);
+ });
+
+ it("renders grouped by provider", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("OpenAI")).toBe(true);
+ expect(tui.screen.contains("Anthropic")).toBe(true);
+ expect(tui.screen.contains("Google")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/multi-progress.test.tsx b/registry/ui/__tests__/multi-progress.test.tsx
new file mode 100644
index 0000000..67dfd87
--- /dev/null
+++ b/registry/ui/__tests__/multi-progress.test.tsx
@@ -0,0 +1,48 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { MultiProgress } from "../multi-progress";
+import { renderTui } from "./render-tui";
+
+describe("MultiProgress", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders item labels", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Download")).toBe(true);
+ expect(tui.screen.contains("Install")).toBe(true);
+ });
+
+ it('renders progress bars with "█" and "░"', () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("█")).toBe(true);
+ expect(tui.screen.contains("░")).toBe(true);
+ });
+
+ it("renders percentage", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("50%")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/multi-select.test.tsx b/registry/ui/__tests__/multi-select.test.tsx
new file mode 100644
index 0000000..4d5cd94
--- /dev/null
+++ b/registry/ui/__tests__/multi-select.test.tsx
@@ -0,0 +1,115 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { MultiSelect } from "../multi-select";
+import { renderTui } from "./render-tui";
+
+const options = [
+ { label: "Alpha", value: "a" },
+ { label: "Beta", value: "b" },
+ { label: "Charlie", value: "c" },
+];
+
+describe("MultiSelect", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders all option labels", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Alpha")).toBe(true);
+ expect(tui.screen.contains("Beta")).toBe(true);
+ expect(tui.screen.contains("Charlie")).toBe(true);
+ });
+
+ it("shows cursor on first option", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("›")).toBe(true);
+ });
+
+ it("renders ○ for unselected options", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("○")).toBe(true);
+ });
+
+ it("renders checkmark for selected options", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("◉")).toBe(true);
+ });
+
+ it("toggles selection with space", () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.space();
+ expect(onChange).toHaveBeenCalledWith(["a"]);
+ });
+
+ it("deselects on second space press", () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.space();
+ expect(onChange).toHaveBeenCalledWith([]);
+ });
+
+ it("navigates down and toggles", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.space();
+ expect(onChange).toHaveBeenCalledWith(["b"]);
+ });
+
+ it("submits selection on Enter", () => {
+ const onSubmit = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.enter();
+ expect(onSubmit).toHaveBeenCalledWith(["a", "c"]);
+ });
+
+ it("skips disabled options when navigating", async () => {
+ const opts = [
+ { label: "Alpha", value: "a" },
+ { disabled: true, label: "Beta", value: "b" },
+ { label: "Charlie", value: "c" },
+ ];
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.space();
+ expect(onChange).toHaveBeenCalledWith(["c"]);
+ });
+
+ it("does not toggle disabled option", () => {
+ const opts = [{ disabled: true, label: "Alpha", value: "a" }];
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.space();
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it("renders hint text", () => {
+ const opts = [{ hint: "popular", label: "Alpha", value: "a" }];
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("popular")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/notification-badge.test.tsx b/registry/ui/__tests__/notification-badge.test.tsx
new file mode 100644
index 0000000..2844647
--- /dev/null
+++ b/registry/ui/__tests__/notification-badge.test.tsx
@@ -0,0 +1,22 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { NotificationBadge } from "../notification-badge";
+import { renderTui } from "./render-tui";
+
+describe("NotificationBadge", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders count in brackets like [3]", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("[3]")).toBe(true);
+ });
+
+ it("renders nothing when count=0", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.text().trim()).toBe("");
+ });
+});
diff --git a/registry/ui/__tests__/notification-center.test.tsx b/registry/ui/__tests__/notification-center.test.tsx
new file mode 100644
index 0000000..c29f037
--- /dev/null
+++ b/registry/ui/__tests__/notification-center.test.tsx
@@ -0,0 +1,24 @@
+import React from "react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+import { NotificationCenter } from "../notification-center";
+import { renderTui } from "./render-tui";
+
+describe("NotificationCenter", () => {
+ let unmount: () => void;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ unmount?.();
+ vi.useRealTimers();
+ });
+
+ it("renders nothing when no notifications", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.text().trim()).toBe("");
+ });
+});
diff --git a/registry/ui/__tests__/number-input.test.tsx b/registry/ui/__tests__/number-input.test.tsx
new file mode 100644
index 0000000..5087bb1
--- /dev/null
+++ b/registry/ui/__tests__/number-input.test.tsx
@@ -0,0 +1,103 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { NumberInput } from "../number-input";
+import { renderTui } from "./render-tui";
+
+describe("NumberInput", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders placeholder when empty", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Enter number")).toBe(true);
+ });
+
+ it("renders label", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Quantity")).toBe(true);
+ });
+
+ it("renders controlled value", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("42")).toBe(true);
+ });
+
+ it("increments on up arrow", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.tab();
+ await tui.flush();
+ tui.keys.up();
+ expect(onChange).toHaveBeenCalledWith(11);
+ });
+
+ it("decrements on down arrow", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.tab();
+ await tui.flush();
+ tui.keys.down();
+ expect(onChange).toHaveBeenCalledWith(9);
+ });
+
+ it("respects step value", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.tab();
+ await tui.flush();
+ tui.keys.up();
+ expect(onChange).toHaveBeenCalledWith(15);
+ });
+
+ it("clamps to max", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.tab();
+ await tui.flush();
+ tui.keys.up();
+ expect(onChange).toHaveBeenCalledWith(100);
+ });
+
+ it("clamps to min", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.tab();
+ await tui.flush();
+ tui.keys.down();
+ expect(onChange).toHaveBeenCalledWith(0);
+ });
+
+ it("calls onSubmit on Enter", async () => {
+ const onSubmit = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.tab();
+ await tui.flush();
+ tui.keys.enter();
+ expect(onSubmit).toHaveBeenCalledWith(42);
+ });
+
+ it("shows step hint when focused", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.tab();
+ await tui.flush();
+ expect(tui.screen.contains("↑ +5")).toBe(true);
+ expect(tui.screen.contains("↓ -5")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/pagination.test.tsx b/registry/ui/__tests__/pagination.test.tsx
new file mode 100644
index 0000000..1296b80
--- /dev/null
+++ b/registry/ui/__tests__/pagination.test.tsx
@@ -0,0 +1,76 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { Pagination } from "../pagination";
+import { renderTui } from "./render-tui";
+
+describe("Pagination", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders current page highlighted", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("[3]")).toBe(true);
+ });
+
+ it("renders navigation arrows", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("‹")).toBe(true);
+ expect(tui.screen.contains("›")).toBe(true);
+ });
+
+ it("renders all pages when total <= 7", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("[1]")).toBe(true);
+ expect(tui.screen.contains("5")).toBe(true);
+ });
+
+ it("shows ellipsis for large page counts", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("…")).toBe(true);
+ });
+
+ it("calls onChange on right arrow", () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.right();
+ expect(onChange).toHaveBeenCalledWith(3);
+ });
+
+ it("calls onChange on left arrow", () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.left();
+ expect(onChange).toHaveBeenCalledWith(2);
+ });
+
+ it("does not go below page 1", () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.left();
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it("does not go above total", () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.right();
+ expect(onChange).not.toHaveBeenCalled();
+ });
+});
diff --git a/registry/ui/__tests__/panel.test.tsx b/registry/ui/__tests__/panel.test.tsx
new file mode 100644
index 0000000..9f90650
--- /dev/null
+++ b/registry/ui/__tests__/panel.test.tsx
@@ -0,0 +1,42 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { Panel } from "../panel";
+import { Text } from "../text";
+import { renderTui } from "./render-tui";
+
+describe("Panel", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders children", () => {
+ const tui = renderTui(
+
+ Panel body
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Panel body")).toBe(true);
+ });
+
+ it("renders title", () => {
+ const tui = renderTui(
+
+ content
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Settings")).toBe(true);
+ });
+
+ it("renders without border", () => {
+ const tui = renderTui(
+
+ inside
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("inside")).toBe(true);
+ expect(tui.screen.contains("No outer")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/password-input.test.tsx b/registry/ui/__tests__/password-input.test.tsx
new file mode 100644
index 0000000..17b8e28
--- /dev/null
+++ b/registry/ui/__tests__/password-input.test.tsx
@@ -0,0 +1,67 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { PasswordInput } from "../password-input";
+import { renderTui } from "./render-tui";
+
+describe("PasswordInput", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders placeholder when empty", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Enter password")).toBe(true);
+ });
+
+ it("renders label", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Password")).toBe(true);
+ });
+
+ it("masks input with default mask character", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("●●●●●●")).toBe(true);
+ expect(tui.screen.contains("secret")).toBe(false);
+ });
+
+ it("masks input with custom mask character", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("***")).toBe(true);
+ });
+
+ it("calls onChange when typing", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.tab();
+ await tui.flush();
+ tui.keys.type("a");
+ expect(onChange).toHaveBeenCalledWith("a");
+ });
+
+ it("calls onChange on backspace", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.tab();
+ await tui.flush();
+ tui.keys.backspace();
+ expect(onChange).toHaveBeenCalledWith("ab");
+ });
+
+ it("calls onSubmit on Enter", async () => {
+ const onSubmit = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.tab();
+ await tui.flush();
+ tui.keys.enter();
+ expect(onSubmit).toHaveBeenCalledWith("pass123");
+ });
+});
diff --git a/registry/ui/__tests__/path-input.test.tsx b/registry/ui/__tests__/path-input.test.tsx
new file mode 100644
index 0000000..4921390
--- /dev/null
+++ b/registry/ui/__tests__/path-input.test.tsx
@@ -0,0 +1,50 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { PathInput } from "../path-input";
+import { renderTui } from "./render-tui";
+
+vi.mock("node:fs", () => ({
+ readdirSync: vi.fn(() => []),
+ statSync: vi.fn(() => ({ isDirectory: () => false })),
+}));
+
+describe("PathInput", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders label", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Path")).toBe(true);
+ });
+
+ it("renders placeholder", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("/home")).toBe(true);
+ });
+
+ it("renders default placeholder", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("/")).toBe(true);
+ });
+
+ it("renders cursor when focused", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ await tui.flush();
+ expect(tui.screen.contains("█")).toBe(true);
+ });
+
+ it("calls onChange on typing", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.type("/");
+ await tui.flush();
+ expect(onChange).toHaveBeenCalled();
+ });
+});
diff --git a/registry/ui/__tests__/pie-chart.test.tsx b/registry/ui/__tests__/pie-chart.test.tsx
new file mode 100644
index 0000000..4fff1d6
--- /dev/null
+++ b/registry/ui/__tests__/pie-chart.test.tsx
@@ -0,0 +1,54 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { PieChart } from "../pie-chart";
+import { renderTui } from "./render-tui";
+
+describe("PieChart", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ const sampleData = [
+ { label: "React", value: 60 },
+ { label: "Vue", value: 25 },
+ { label: "Svelte", value: 15 },
+ ];
+
+ it("renders pie chart block characters", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("█")).toBe(true);
+ });
+
+ it("renders legend labels", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("React")).toBe(true);
+ expect(tui.screen.contains("Vue")).toBe(true);
+ expect(tui.screen.contains("Svelte")).toBe(true);
+ });
+
+ it("shows percentages", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("60.0%")).toBe(true);
+ });
+
+ it("hides legend", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("■")).toBe(false);
+ });
+
+ it("renders empty state", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("No data")).toBe(true);
+ });
+
+ it("renders legend square markers", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("■")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/popover.test.tsx b/registry/ui/__tests__/popover.test.tsx
new file mode 100644
index 0000000..88fa298
--- /dev/null
+++ b/registry/ui/__tests__/popover.test.tsx
@@ -0,0 +1,87 @@
+import { Text } from "ink";
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { Popover } from "../popover";
+import { renderTui } from "./render-tui";
+
+describe("Popover", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("always renders trigger", () => {
+ const tui = renderTui(
+ Open}>
+ Content
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Open")).toBe(true);
+ });
+
+ it("hides children when closed", () => {
+ const tui = renderTui(
+ Open}>
+ Hidden content
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Hidden content")).toBe(false);
+ });
+
+ it("shows children when open", () => {
+ const tui = renderTui(
+ Open} isOpen>
+ Visible content
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Visible content")).toBe(true);
+ });
+
+ it("renders title when open", () => {
+ const tui = renderTui(
+ Open} isOpen title="Pop Title">
+ body
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Pop Title")).toBe(true);
+ });
+
+ it("shows close hint when open", () => {
+ const tui = renderTui(
+ Open} isOpen>
+ body
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Press Esc to close")).toBe(true);
+ });
+
+ it("calls onClose on Escape", async () => {
+ const onClose = vi.fn();
+ const tui = renderTui(
+ Open} isOpen onClose={onClose}>
+ body
+
+ );
+ ({ unmount } = tui);
+ tui.keys.escape();
+ await tui.flush();
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+
+ it("does not call onClose when closed", async () => {
+ const onClose = vi.fn();
+ const tui = renderTui(
+ Open} onClose={onClose}>
+ body
+
+ );
+ ({ unmount } = tui);
+ tui.keys.escape();
+ await tui.flush();
+ expect(onClose).not.toHaveBeenCalled();
+ });
+});
diff --git a/registry/ui/__tests__/progress-bar.test.tsx b/registry/ui/__tests__/progress-bar.test.tsx
new file mode 100644
index 0000000..2594231
--- /dev/null
+++ b/registry/ui/__tests__/progress-bar.test.tsx
@@ -0,0 +1,49 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { ProgressBar } from "../progress-bar";
+import { renderTui } from "./render-tui";
+
+describe("ProgressBar", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders bar characters", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("█")).toBe(true);
+ expect(tui.screen.contains("░")).toBe(true);
+ });
+
+ it("renders percentage", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("25%")).toBe(true);
+ });
+
+ it("renders label", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Uploading")).toBe(true);
+ });
+
+ it("renders value/total", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("3/10")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/progress-circle.test.tsx b/registry/ui/__tests__/progress-circle.test.tsx
new file mode 100644
index 0000000..ac1b4ea
--- /dev/null
+++ b/registry/ui/__tests__/progress-circle.test.tsx
@@ -0,0 +1,49 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { ProgressCircle } from "../progress-circle";
+import { renderTui } from "./render-tui";
+
+describe("ProgressCircle", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders small braille character", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ const text = tui.screen.text();
+ const hasBraille = /[○◔◑◕●◉⬤]/.test(text);
+ expect(hasBraille).toBe(true);
+ });
+
+ it("shows percent when showPercent is true", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("75%")).toBe(true);
+ });
+
+ it("renders label", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Upload")).toBe(true);
+ });
+
+ it("clamps value to 0-100", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("100%")).toBe(true);
+ });
+
+ it("renders md size with angle brackets", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("⟨")).toBe(true);
+ expect(tui.screen.contains("⟩")).toBe(true);
+ });
+
+ it("renders lg size with block art", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("▄")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/qr-code.test.tsx b/registry/ui/__tests__/qr-code.test.tsx
new file mode 100644
index 0000000..f33a0aa
--- /dev/null
+++ b/registry/ui/__tests__/qr-code.test.tsx
@@ -0,0 +1,40 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { QRCode } from "../qr-code";
+import { renderTui } from "./render-tui";
+
+describe("QRCode", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders non-empty output", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.text().length).toBeGreaterThan(0);
+ });
+
+ it("renders label", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Scan me")).toBe(true);
+ });
+
+ it("renders in small size", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.text().length).toBeGreaterThan(0);
+ });
+
+ it("renders in large size", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.text().length).toBeGreaterThan(0);
+ });
+
+ it("contains block characters", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("█")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/radio-group.test.tsx b/registry/ui/__tests__/radio-group.test.tsx
new file mode 100644
index 0000000..34e3679
--- /dev/null
+++ b/registry/ui/__tests__/radio-group.test.tsx
@@ -0,0 +1,113 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { RadioGroup } from "../radio-group";
+import { renderTui } from "./render-tui";
+
+const options = [
+ { label: "Alpha", value: "a" },
+ { label: "Beta", value: "b" },
+ { label: "Charlie", value: "c" },
+];
+
+describe("RadioGroup", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders all option labels", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Alpha")).toBe(true);
+ expect(tui.screen.contains("Beta")).toBe(true);
+ expect(tui.screen.contains("Charlie")).toBe(true);
+ });
+
+ it("shows cursor on first option", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("›")).toBe(true);
+ });
+
+ it("renders ○ for unselected options", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("○")).toBe(true);
+ });
+
+ it("renders ◉ for selected option", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("◉")).toBe(true);
+ });
+
+ it("selects option with space", () => {
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.space();
+ expect(onChange).toHaveBeenCalledWith("a");
+ });
+
+ it("selects option with Enter", () => {
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.enter();
+ expect(onChange).toHaveBeenCalledWith("a");
+ });
+
+ it("navigates down and selects", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.space();
+ expect(onChange).toHaveBeenCalledWith("b");
+ });
+
+ it("navigates up and selects", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.up();
+ await tui.flush();
+ tui.keys.space();
+ expect(onChange).toHaveBeenCalledWith("b");
+ });
+
+ it("skips disabled options when navigating", async () => {
+ const opts = [
+ { label: "Alpha", value: "a" },
+ { disabled: true, label: "Beta", value: "b" },
+ { label: "Charlie", value: "c" },
+ ];
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.space();
+ expect(onChange).toHaveBeenCalledWith("c");
+ });
+
+ it("does not select disabled option", () => {
+ const opts = [{ disabled: true, label: "Alpha", value: "a" }];
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.space();
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it("renders hint text", () => {
+ const opts = [{ hint: "recommended", label: "Alpha", value: "a" }];
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("recommended")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/render-tui.tsx b/registry/ui/__tests__/render-tui.tsx
new file mode 100644
index 0000000..c44c9a4
--- /dev/null
+++ b/registry/ui/__tests__/render-tui.tsx
@@ -0,0 +1,11 @@
+import { renderTui as baseRenderTui, cleanup } from "ink-testing";
+import type { TuiInstance } from "ink-testing";
+import React from "react";
+
+import { ThemeProvider } from "../theme-provider";
+
+export { cleanup, KEY } from "ink-testing";
+export type { TuiInstance, Screen, KeySender } from "ink-testing";
+
+export const renderTui = (ui: React.ReactElement): TuiInstance =>
+ baseRenderTui({ui});
diff --git a/registry/ui/__tests__/scroll-view.test.tsx b/registry/ui/__tests__/scroll-view.test.tsx
new file mode 100644
index 0000000..96b59b1
--- /dev/null
+++ b/registry/ui/__tests__/scroll-view.test.tsx
@@ -0,0 +1,67 @@
+import { Text } from "ink";
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { ScrollView } from "../scroll-view";
+import { renderTui } from "./render-tui";
+
+describe("ScrollView", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders children", () => {
+ const tui = renderTui(
+
+ Hello scroll
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Hello scroll")).toBe(true);
+ });
+
+ it("shows scrollbar by default", () => {
+ const tui = renderTui(
+
+ line 1
+ line 2
+ line 3
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("█")).toBe(true);
+ });
+
+ it("hides scrollbar when showScrollbar is false", () => {
+ const tui = renderTui(
+
+ line 1
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("█")).toBe(false);
+ });
+
+ it("uses custom track and thumb characters", () => {
+ const tui = renderTui(
+
+ line 1
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("#")).toBe(true);
+ });
+
+ it("renders content without scrollbar when content fits", () => {
+ const tui = renderTui(
+
+ line A
+ line B
+ line C
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("line A")).toBe(true);
+ expect(tui.screen.contains("line B")).toBe(true);
+ expect(tui.screen.contains("line C")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/search-input.test.tsx b/registry/ui/__tests__/search-input.test.tsx
new file mode 100644
index 0000000..a33d1dc
--- /dev/null
+++ b/registry/ui/__tests__/search-input.test.tsx
@@ -0,0 +1,110 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { SearchInput } from "../search-input";
+import type { TuiInstance } from "./render-tui";
+import { renderTui } from "./render-tui";
+
+const typeChars = async (tui: TuiInstance, text: string) => {
+ for (const char of text) {
+ tui.keys.press(char);
+ await tui.flush();
+ }
+};
+
+const focusAndFlush = async (tui: TuiInstance) => {
+ await tui.flush();
+ tui.keys.tab();
+ await tui.flush();
+};
+
+describe("SearchInput", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ const fruits = ["Apple", "Banana", "Cherry", "Date", "Elderberry"];
+
+ it("renders label", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Find")).toBe(true);
+ });
+
+ it("renders placeholder", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Type to search")).toBe(true);
+ });
+
+ it("renders search icon", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("🔍")).toBe(true);
+ });
+
+ it("filters options as user types", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ await focusAndFlush(tui);
+ await typeChars(tui, "ban");
+ expect(tui.screen.contains("Banana")).toBe(true);
+ expect(tui.screen.contains("Cherry")).toBe(false);
+ });
+
+ it("shows results on down arrow", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ await focusAndFlush(tui);
+ tui.keys.down();
+ await tui.flush();
+ expect(tui.screen.contains("Apple")).toBe(true);
+ });
+
+ it("calls onSelect when selecting a result", async () => {
+ const onSelect = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await focusAndFlush(tui);
+ await typeChars(tui, "ch");
+ tui.keys.enter();
+ await tui.flush();
+ expect(onSelect).toHaveBeenCalledWith("Cherry");
+ });
+
+ it("clears query on escape", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ await focusAndFlush(tui);
+ await typeChars(tui, "app");
+ expect(tui.screen.contains("app")).toBe(true);
+ tui.keys.escape();
+ await tui.flush();
+ expect(tui.screen.contains("Search...")).toBe(true);
+ });
+
+ it("calls onChange for controlled value", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ await focusAndFlush(tui);
+ tui.keys.press("x");
+ await tui.flush();
+ expect(onChange).toHaveBeenCalledWith("x");
+ });
+
+ it("limits results to maxResults", async () => {
+ const many = ["A1", "A2", "A3", "A4", "A5", "A6", "A7"];
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await focusAndFlush(tui);
+ tui.keys.down();
+ await tui.flush();
+ const text = tui.screen.text();
+ const matches = many.filter((item) => text.includes(item));
+ expect(matches.length).toBeLessThanOrEqual(3);
+ });
+});
diff --git a/registry/ui/__tests__/select.test.tsx b/registry/ui/__tests__/select.test.tsx
new file mode 100644
index 0000000..48cca31
--- /dev/null
+++ b/registry/ui/__tests__/select.test.tsx
@@ -0,0 +1,132 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { Select } from "../select";
+import { renderTui } from "./render-tui";
+
+const options = [
+ { label: "Alpha", value: "a" },
+ { label: "Beta", value: "b" },
+ { label: "Charlie", value: "c" },
+];
+
+describe("Select", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders all option labels", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Alpha")).toBe(true);
+ expect(tui.screen.contains("Beta")).toBe(true);
+ expect(tui.screen.contains("Charlie")).toBe(true);
+ });
+
+ it("renders label", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Pick one")).toBe(true);
+ });
+
+ it("shows cursor on first option", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("›")).toBe(true);
+ });
+
+ it("navigates down with arrow key", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.enter();
+ expect(onChange).toHaveBeenCalledWith("b");
+ });
+
+ it("navigates up with arrow key", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.up();
+ await tui.flush();
+ tui.keys.enter();
+ expect(onChange).toHaveBeenCalledWith("b");
+ });
+
+ it("calls onSubmit on Enter", () => {
+ const onSubmit = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.enter();
+ expect(onSubmit).toHaveBeenCalledWith("a");
+ });
+
+ it("skips disabled options when navigating down", async () => {
+ const opts = [
+ { label: "Alpha", value: "a" },
+ { disabled: true, label: "Beta", value: "b" },
+ { label: "Charlie", value: "c" },
+ ];
+ const onSubmit = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.enter();
+ expect(onSubmit).toHaveBeenCalledWith("c");
+ });
+
+ it("skips disabled options when navigating up", async () => {
+ const opts = [
+ { label: "Alpha", value: "a" },
+ { disabled: true, label: "Beta", value: "b" },
+ { label: "Charlie", value: "c" },
+ ];
+ const onSubmit = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.up();
+ await tui.flush();
+ tui.keys.enter();
+ expect(onSubmit).toHaveBeenCalledWith("a");
+ });
+
+ it("does not go past first option", () => {
+ const onSubmit = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.up();
+ tui.keys.enter();
+ expect(onSubmit).toHaveBeenCalledWith("a");
+ });
+
+ it("does not go past last option", async () => {
+ const onSubmit = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.enter();
+ expect(onSubmit).toHaveBeenCalledWith("c");
+ });
+
+ it("renders hint text", () => {
+ const opts = [{ hint: "first letter", label: "Alpha", value: "a" }];
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("first letter")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/setup-flow.test.tsx b/registry/ui/__tests__/setup-flow.test.tsx
new file mode 100644
index 0000000..b448dda
--- /dev/null
+++ b/registry/ui/__tests__/setup-flow.test.tsx
@@ -0,0 +1,236 @@
+import { Text } from "ink";
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { SetupFlow } from "../setup-flow";
+import { renderTui } from "./render-tui";
+
+describe("SetupFlow", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ describe("SetupFlow (root)", () => {
+ it("renders children", () => {
+ const tui = renderTui(
+
+ Step content
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Step content")).toBe(true);
+ });
+
+ it("renders connector between children", () => {
+ const tui = renderTui(
+
+ A
+ B
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("│")).toBe(true);
+ });
+
+ it("renders custom connector", () => {
+ const tui = renderTui(
+
+ A
+ B
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("|")).toBe(true);
+ });
+ });
+
+ describe("SetupFlow.Badge", () => {
+ it("renders badge label", () => {
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("v1.0.0")).toBe(true);
+ });
+ });
+
+ describe("SetupFlow.Step", () => {
+ it("renders step with done status", () => {
+ const tui = renderTui(
+
+ Completed step
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Completed step")).toBe(true);
+ expect(tui.screen.contains("◇")).toBe(true);
+ });
+
+ it("renders step with active status", () => {
+ const tui = renderTui(
+
+ Current step
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Current step")).toBe(true);
+ expect(tui.screen.contains("◆")).toBe(true);
+ });
+
+ it("renders step with success status", () => {
+ const tui = renderTui(
+
+ Passed
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("✓")).toBe(true);
+ });
+
+ it("renders step with error status", () => {
+ const tui = renderTui(
+
+ Failed
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("✗")).toBe(true);
+ });
+
+ it("renders custom icon", () => {
+ const tui = renderTui(
+
+ Custom
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("★")).toBe(true);
+ });
+ });
+
+ describe("SetupFlow.MultiSelect", () => {
+ const options = [
+ { label: "TypeScript", value: "ts" },
+ { label: "JavaScript", value: "js" },
+ { label: "Python", value: "py" },
+ ];
+
+ it("renders label and options", () => {
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Languages")).toBe(true);
+ expect(tui.screen.contains("TypeScript")).toBe(true);
+ expect(tui.screen.contains("JavaScript")).toBe(true);
+ expect(tui.screen.contains("Python")).toBe(true);
+ });
+
+ it("renders hint", () => {
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("space to toggle")).toBe(true);
+ });
+
+ it("renders unchecked by default", () => {
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("□")).toBe(true);
+ });
+
+ it("toggles selection with space", async () => {
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ tui.keys.space();
+ await tui.flush();
+ expect(tui.screen.contains("■")).toBe(true);
+ });
+
+ it("calls onChange on toggle", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ tui.keys.space();
+ await tui.flush();
+ expect(onChange).toHaveBeenCalledWith(["ts"]);
+ });
+
+ it("navigates with arrow keys", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.space();
+ await tui.flush();
+ expect(onChange).toHaveBeenCalledWith(["js"]);
+ });
+
+ it("calls onSubmit on enter", async () => {
+ const onSubmit = vi.fn();
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ tui.keys.space();
+ await tui.flush();
+ tui.keys.enter();
+ await tui.flush();
+ expect(onSubmit).toHaveBeenCalledWith(["ts"]);
+ });
+
+ it("renders option descriptions", () => {
+ const optsWithDesc = [
+ { description: "Typed JS", label: "TypeScript", value: "ts" },
+ ];
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Typed JS")).toBe(true);
+ });
+ });
+});
diff --git a/registry/ui/__tests__/sidebar.test.tsx b/registry/ui/__tests__/sidebar.test.tsx
new file mode 100644
index 0000000..75b56f1
--- /dev/null
+++ b/registry/ui/__tests__/sidebar.test.tsx
@@ -0,0 +1,113 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { Sidebar } from "../sidebar";
+import { renderTui } from "./render-tui";
+
+describe("Sidebar", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ const items = [
+ { icon: "🏠", key: "home", label: "Home" },
+ { key: "settings", label: "Settings" },
+ {
+ children: [
+ { key: "docs", label: "Documents" },
+ { key: "images", label: "Images" },
+ ],
+ key: "files",
+ label: "Files",
+ },
+ ];
+
+ it("renders item labels", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Home")).toBe(true);
+ expect(tui.screen.contains("Settings")).toBe(true);
+ expect(tui.screen.contains("Files")).toBe(true);
+ });
+
+ it("renders title when provided", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Navigation")).toBe(true);
+ });
+
+ it("renders icon for item", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("🏠")).toBe(true);
+ });
+
+ it("highlights active item", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("▌")).toBe(true);
+ });
+
+ it("navigates with arrow keys", async () => {
+ const onSelect = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.enter();
+ expect(onSelect).toHaveBeenCalledWith("settings");
+ });
+
+ it("expands children on right arrow", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.right();
+ await tui.flush();
+ expect(tui.screen.contains("Documents")).toBe(true);
+ expect(tui.screen.contains("Images")).toBe(true);
+ });
+
+ it("collapses children on left arrow", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.right();
+ await tui.flush();
+ expect(tui.screen.contains("Documents")).toBe(true);
+ tui.keys.left();
+ await tui.flush();
+ expect(tui.screen.contains("Documents")).toBe(false);
+ });
+
+ it("toggles expand on enter for parent items", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.enter();
+ await tui.flush();
+ expect(tui.screen.contains("Documents")).toBe(true);
+ });
+
+ it("renders collapsed mode showing first char", () => {
+ const simpleItems = [{ key: "home", label: "Home" }];
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("H")).toBe(true);
+ });
+
+ it("renders badge when provided", () => {
+ const badgedItems = [{ badge: 5, key: "inbox", label: "Inbox" }];
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("5")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/skeleton.test.tsx b/registry/ui/__tests__/skeleton.test.tsx
new file mode 100644
index 0000000..0d68648
--- /dev/null
+++ b/registry/ui/__tests__/skeleton.test.tsx
@@ -0,0 +1,46 @@
+import React from "react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+import { Skeleton } from "../skeleton";
+import { renderTui } from "./render-tui";
+
+describe("Skeleton", () => {
+ let unmount: () => void;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ unmount?.();
+ vi.useRealTimers();
+ });
+
+ it("renders block characters", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("░")).toBe(true);
+ });
+
+ it("renders multiple rows", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ const lines = tui.screen.lines();
+ expect(lines.length).toBeGreaterThanOrEqual(3);
+ });
+
+ it("animates shimmer over time", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ const before = tui.screen.text();
+ await vi.advanceTimersByTimeAsync(500);
+ const after = tui.screen.text();
+ expect(after).not.toBe(before);
+ });
+
+ it("renders static when animated is false", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("░")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/spacer.test.tsx b/registry/ui/__tests__/spacer.test.tsx
new file mode 100644
index 0000000..ff48dcf
--- /dev/null
+++ b/registry/ui/__tests__/spacer.test.tsx
@@ -0,0 +1,24 @@
+import { Box, Text } from "ink";
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { Spacer } from "../spacer";
+import { renderTui } from "./render-tui";
+
+describe("Spacer", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders without crashing", () => {
+ const tui = renderTui(
+
+ a
+
+ b
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("a")).toBe(true);
+ expect(tui.screen.contains("b")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/sparkline.test.tsx b/registry/ui/__tests__/sparkline.test.tsx
new file mode 100644
index 0000000..b270303
--- /dev/null
+++ b/registry/ui/__tests__/sparkline.test.tsx
@@ -0,0 +1,29 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { Sparkline } from "../sparkline";
+import { renderTui } from "./render-tui";
+
+describe("Sparkline", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders sparkline characters (braille)", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ const text = tui.screen.text();
+ expect(/[⣀-⣿]/.test(text)).toBe(true);
+ });
+
+ it("renders label", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("cpu")).toBe(true);
+ });
+
+ it('renders empty state with "─"', () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("────────")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/spinner.test.tsx b/registry/ui/__tests__/spinner.test.tsx
new file mode 100644
index 0000000..84ca2fa
--- /dev/null
+++ b/registry/ui/__tests__/spinner.test.tsx
@@ -0,0 +1,57 @@
+import React from "react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+import { Spinner } from "../spinner";
+import { renderTui } from "./render-tui";
+
+describe("Spinner", () => {
+ let unmount: () => void;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ unmount?.();
+ vi.useRealTimers();
+ });
+
+ it("renders the first dots frame by default", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("⠋")).toBe(true);
+ });
+
+ it("renders label alongside spinner", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Loading...")).toBe(true);
+ });
+
+ it("cycles through frames when time advances", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("⠋")).toBe(true);
+ await vi.advanceTimersByTimeAsync(200);
+ const text = tui.screen.text();
+ expect(text.includes("⠋")).toBe(false);
+ });
+
+ it("renders custom frames", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("A")).toBe(true);
+ });
+
+ it("renders line style", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("—")).toBe(true);
+ });
+
+ it("renders star style", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("✶")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/splash-screen.test.tsx b/registry/ui/__tests__/splash-screen.test.tsx
new file mode 100644
index 0000000..b062490
--- /dev/null
+++ b/registry/ui/__tests__/splash-screen.test.tsx
@@ -0,0 +1,39 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { SplashScreen } from "../splash-screen";
+import { renderTui } from "./render-tui";
+
+describe("SplashScreen", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders big text output", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.text().length).toBeGreaterThan(0);
+ });
+
+ it("renders subtitle", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("A cool app")).toBe(true);
+ });
+
+ it("renders author name", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("John")).toBe(true);
+ expect(tui.screen.contains("Made with")).toBe(true);
+ });
+
+ it("renders status line", () => {
+ const tui = renderTui(
+ Loading...>} />
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Loading...")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/stack.test.tsx b/registry/ui/__tests__/stack.test.tsx
new file mode 100644
index 0000000..e17a237
--- /dev/null
+++ b/registry/ui/__tests__/stack.test.tsx
@@ -0,0 +1,23 @@
+import { Text } from "ink";
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { Stack } from "../stack";
+import { renderTui } from "./render-tui";
+
+describe("Stack", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders children", () => {
+ const tui = renderTui(
+
+ one
+ two
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("one")).toBe(true);
+ expect(tui.screen.contains("two")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/status-message.test.tsx b/registry/ui/__tests__/status-message.test.tsx
new file mode 100644
index 0000000..a31d691
--- /dev/null
+++ b/registry/ui/__tests__/status-message.test.tsx
@@ -0,0 +1,77 @@
+import React from "react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+import { StatusMessage } from "../status-message";
+import { renderTui } from "./render-tui";
+
+describe("StatusMessage", () => {
+ let unmount: () => void;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ unmount?.();
+ vi.useRealTimers();
+ });
+
+ it("renders children text", () => {
+ const tui = renderTui(Installing);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Installing")).toBe(true);
+ });
+
+ it("renders info icon by default", () => {
+ const tui = renderTui(Info);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("ℹ")).toBe(true);
+ });
+
+ it("renders success icon", () => {
+ const tui = renderTui(
+ Done
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("✓")).toBe(true);
+ });
+
+ it("renders error icon", () => {
+ const tui = renderTui(
+ Failed
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("✗")).toBe(true);
+ });
+
+ it("renders warning icon", () => {
+ const tui = renderTui(
+ Caution
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("⚠")).toBe(true);
+ });
+
+ it("renders pending icon", () => {
+ const tui = renderTui(
+ Waiting
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("○")).toBe(true);
+ });
+
+ it("renders spinner for loading variant", () => {
+ const tui = renderTui(
+ Loading data
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Loading data")).toBe(true);
+ expect(tui.screen.contains("⠋")).toBe(true);
+ });
+
+ it("renders custom icon", () => {
+ const tui = renderTui(Deploying);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("🚀")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/stopwatch.test.tsx b/registry/ui/__tests__/stopwatch.test.tsx
new file mode 100644
index 0000000..e540ebf
--- /dev/null
+++ b/registry/ui/__tests__/stopwatch.test.tsx
@@ -0,0 +1,57 @@
+import React from "react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+import { Stopwatch } from "../stopwatch";
+import { renderTui } from "./render-tui";
+
+describe("Stopwatch", () => {
+ let unmount: () => void;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ unmount?.();
+ vi.useRealTimers();
+ });
+
+ it("renders initial state as Ready", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("[Ready]")).toBe(true);
+ expect(tui.screen.contains("00:00:00.00")).toBe(true);
+ });
+
+ it("starts running with autoStart", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("[Running]")).toBe(true);
+ });
+
+ it("toggles running with space key", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("[Ready]")).toBe(true);
+ tui.keys.space();
+ await vi.advanceTimersByTimeAsync(100);
+ expect(tui.screen.contains("[Running]")).toBe(true);
+ });
+
+ it("resets with r key", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.space();
+ await vi.advanceTimersByTimeAsync(1000);
+ tui.keys.press("r");
+ await vi.advanceTimersByTimeAsync(0);
+ expect(tui.screen.contains("[Ready]")).toBe(true);
+ expect(tui.screen.contains("00:00:00.00")).toBe(true);
+ });
+
+ it("shows help text", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("space start/stop")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/streaming-text.test.tsx b/registry/ui/__tests__/streaming-text.test.tsx
new file mode 100644
index 0000000..1d2e167
--- /dev/null
+++ b/registry/ui/__tests__/streaming-text.test.tsx
@@ -0,0 +1,65 @@
+import React from "react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+import { StreamingText } from "../streaming-text";
+import { renderTui } from "./render-tui";
+
+describe("StreamingText", () => {
+ let unmount: () => void;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ unmount?.();
+ vi.useRealTimers();
+ });
+
+ it("renders full text when animate is false", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Hello World")).toBe(true);
+ });
+
+ it("animates text character by character", async () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("ABCDE")).toBe(false);
+ await vi.advanceTimersByTimeAsync(50);
+ expect(tui.screen.contains("A")).toBe(true);
+ await vi.advanceTimersByTimeAsync(200);
+ expect(tui.screen.contains("ABCDE")).toBe(true);
+ });
+
+ it("calls onComplete when animation finishes", async () => {
+ const onComplete = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await vi.advanceTimersByTimeAsync(150);
+ expect(onComplete).toHaveBeenCalledWith("AB");
+ });
+
+ it("renders cursor when animating", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ await vi.advanceTimersByTimeAsync(50);
+ expect(tui.screen.contains("▌")).toBe(true);
+ });
+
+ it("renders without cursor when cursor is false", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("▌")).toBe(false);
+ });
+});
diff --git a/registry/ui/__tests__/tabbed-content.test.tsx b/registry/ui/__tests__/tabbed-content.test.tsx
new file mode 100644
index 0000000..550a137
--- /dev/null
+++ b/registry/ui/__tests__/tabbed-content.test.tsx
@@ -0,0 +1,88 @@
+import { Text } from "ink";
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { TabbedContent } from "../tabbed-content";
+import { renderTui } from "./render-tui";
+
+describe("TabbedContent", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ const tabs = [
+ { content: Content A, id: "tab1", label: "First" },
+ { content: Content B, id: "tab2", label: "Second" },
+ { content: Content C, id: "tab3", label: "Third" },
+ ];
+
+ it("renders tab labels", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("First")).toBe(true);
+ expect(tui.screen.contains("Second")).toBe(true);
+ expect(tui.screen.contains("Third")).toBe(true);
+ });
+
+ it("shows first tab content by default", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Content A")).toBe(true);
+ });
+
+ it("switches tab on right arrow", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.right();
+ await tui.flush();
+ expect(tui.screen.contains("Content B")).toBe(true);
+ });
+
+ it("switches tab on left arrow", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Content B")).toBe(true);
+ tui.keys.left();
+ await tui.flush();
+ expect(tui.screen.contains("Content A")).toBe(true);
+ });
+
+ it("calls onChange when switching tabs", () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.right();
+ expect(onChange).toHaveBeenCalledWith("tab2");
+ });
+
+ it("skips disabled tabs", async () => {
+ const disabledTabs = [
+ { content: Content A, id: "tab1", label: "First" },
+ {
+ content: Content B,
+ disabled: true,
+ id: "tab2",
+ label: "Second",
+ },
+ { content: Content C, id: "tab3", label: "Third" },
+ ];
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.right();
+ await tui.flush();
+ expect(tui.screen.contains("Content C")).toBe(true);
+ });
+
+ it("renders tab switch hint", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("←→ or Tab to switch tabs")).toBe(true);
+ });
+
+ it("uses controlled activeTab", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Content C")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/table.test.tsx b/registry/ui/__tests__/table.test.tsx
new file mode 100644
index 0000000..04271ae
--- /dev/null
+++ b/registry/ui/__tests__/table.test.tsx
@@ -0,0 +1,70 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { Table } from "../table";
+import { renderTui } from "./render-tui";
+
+describe("Table", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ const columns = [
+ { header: "Name", key: "name" as const },
+ { header: "Age", key: "age" as const },
+ ];
+
+ const data = [
+ { age: 30, name: "Alice" },
+ { age: 25, name: "Bob" },
+ { age: 35, name: "Charlie" },
+ ];
+
+ it("renders column headers", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Name")).toBe(true);
+ expect(tui.screen.contains("Age")).toBe(true);
+ });
+
+ it("renders row data", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Alice")).toBe(true);
+ expect(tui.screen.contains("Bob")).toBe(true);
+ expect(tui.screen.contains("Charlie")).toBe(true);
+ });
+
+ it("shows row overflow message when exceeding maxRows", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("more rows")).toBe(true);
+ });
+
+ it("calls onSelect on enter when selectable", () => {
+ const onSelect = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.enter();
+ expect(onSelect).toHaveBeenCalledWith(data[0]);
+ });
+
+ it("navigates rows with arrow keys and selects", async () => {
+ const onSelect = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.enter();
+ expect(onSelect).toHaveBeenCalledWith(data[1]);
+ });
+
+ it("renders separator between header and data", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("─")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/tabs.test.tsx b/registry/ui/__tests__/tabs.test.tsx
new file mode 100644
index 0000000..37e2cc4
--- /dev/null
+++ b/registry/ui/__tests__/tabs.test.tsx
@@ -0,0 +1,109 @@
+import { Text } from "ink";
+import type * as Ink from "ink";
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { Tabs } from "../tabs";
+import { renderTui } from "./render-tui";
+
+vi.mock("ink", async () => {
+ const actual = await vi.importActual("ink");
+ return {
+ ...actual,
+ useStdout: () => ({
+ stdout: {
+ columns: 80,
+ rows: 24,
+ write: vi.fn(),
+ },
+ }),
+ };
+});
+
+describe("Tabs", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ const tabs = [
+ { content: General Content, key: "tab1", label: "General" },
+ { content: Advanced Content, key: "tab2", label: "Advanced" },
+ { content: About Content, key: "tab3", label: "About" },
+ ];
+
+ it("renders tab labels", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("General")).toBe(true);
+ expect(tui.screen.contains("Advanced")).toBe(true);
+ expect(tui.screen.contains("About")).toBe(true);
+ });
+
+ it("renders first tab content by default", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("General Content")).toBe(true);
+ });
+
+ it("renders defaultTab content", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Advanced Content")).toBe(true);
+ });
+
+ it("switches tab on right arrow", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.right();
+ await tui.flush();
+ expect(tui.screen.contains("Advanced Content")).toBe(true);
+ });
+
+ it("switches tab on left arrow", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.left();
+ await tui.flush();
+ expect(tui.screen.contains("General Content")).toBe(true);
+ });
+
+ it("switches tab with tab key", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.tab();
+ await tui.flush();
+ expect(tui.screen.contains("Advanced Content")).toBe(true);
+ });
+
+ it("calls onTabChange when controlled", async () => {
+ const onTabChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.right();
+ await tui.flush();
+ expect(onTabChange).toHaveBeenCalledWith("tab2");
+ });
+
+ it("does not go past last tab", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.right();
+ await tui.flush();
+ expect(tui.screen.contains("About Content")).toBe(true);
+ });
+
+ it("does not go before first tab", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.left();
+ await tui.flush();
+ expect(tui.screen.contains("General Content")).toBe(true);
+ });
+
+ it("renders separator between tabs", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("|")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/tag-input.test.tsx b/registry/ui/__tests__/tag-input.test.tsx
new file mode 100644
index 0000000..75beac6
--- /dev/null
+++ b/registry/ui/__tests__/tag-input.test.tsx
@@ -0,0 +1,94 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { TagInput } from "../tag-input";
+import { renderTui } from "./render-tui";
+
+describe("TagInput", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders placeholder when empty", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Type and press Enter…")).toBe(true);
+ });
+
+ it("renders custom placeholder", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Add tag…")).toBe(true);
+ });
+
+ it("renders existing tags", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("react")).toBe(true);
+ expect(tui.screen.contains("vue")).toBe(true);
+ });
+
+ it("adds tag on Enter", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.type("foo");
+ await tui.flush();
+ tui.keys.enter();
+ await tui.flush();
+ expect(onChange).toHaveBeenCalledWith(["foo"]);
+ });
+
+ it("does not add empty tag", () => {
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.enter();
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it("removes last tag on backspace when input is empty", () => {
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.backspace();
+ expect(onChange).toHaveBeenCalledWith(["a"]);
+ });
+
+ it("backspace removes input text first", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.type("ab");
+ await tui.flush();
+ tui.keys.backspace();
+ await tui.flush();
+ expect(onChange).not.toHaveBeenCalled();
+ expect(tui.screen.contains("a")).toBe(true);
+ });
+
+ it("respects maxTags", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.type("c");
+ await tui.flush();
+ tui.keys.enter();
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it("shows max tags message", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Max 2 tags reached")).toBe(true);
+ });
+
+ it("renders × in tag display", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("×")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/tag.test.tsx b/registry/ui/__tests__/tag.test.tsx
new file mode 100644
index 0000000..30f5e31
--- /dev/null
+++ b/registry/ui/__tests__/tag.test.tsx
@@ -0,0 +1,24 @@
+import React from "react";
+import { describe, it, expect, afterEach, vi } from "vitest";
+
+import { Tag } from "../tag";
+import { renderTui } from "./render-tui";
+
+describe("Tag", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders children text", () => {
+ const tui = renderTui(typescript);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("typescript")).toBe(true);
+ });
+
+ it('renders remove glyph "×" when onRemove provided', () => {
+ const onRemove = vi.fn();
+ const tui = renderTui(beta);
+ ({ unmount } = tui);
+ expect(tui.screen.contains("×")).toBe(true);
+ expect(tui.screen.contains("beta")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/text-area.test.tsx b/registry/ui/__tests__/text-area.test.tsx
new file mode 100644
index 0000000..d1d7aae
--- /dev/null
+++ b/registry/ui/__tests__/text-area.test.tsx
@@ -0,0 +1,98 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { TextArea } from "../text-area";
+import type { TuiInstance } from "./render-tui";
+import { renderTui } from "./render-tui";
+
+const typeChars = async (tui: TuiInstance, text: string) => {
+ for (const char of text) {
+ tui.keys.press(char);
+ await tui.flush();
+ }
+};
+
+const focusAndFlush = async (tui: TuiInstance) => {
+ await tui.flush();
+ tui.keys.tab();
+ await tui.flush();
+};
+
+describe("TextArea", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders label", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Notes")).toBe(true);
+ });
+
+ it("renders placeholder when empty", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Write here...")).toBe(true);
+ });
+
+ it("accepts typed input (uncontrolled)", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ await focusAndFlush(tui);
+ await typeChars(tui, "hello");
+ expect(tui.screen.contains("hello")).toBe(true);
+ });
+
+ it("calls onChange with new value", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ await focusAndFlush(tui);
+ tui.keys.press("x");
+ await tui.flush();
+ expect(onChange).toHaveBeenCalledWith("x");
+ });
+
+ it("handles backspace on uncontrolled", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ await focusAndFlush(tui);
+ await typeChars(tui, "ab");
+ tui.keys.backspace();
+ await tui.flush();
+ expect(tui.screen.contains("a")).toBe(true);
+ });
+
+ it("handles new line via enter", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ await focusAndFlush(tui);
+ await typeChars(tui, "line1");
+ tui.keys.enter();
+ await tui.flush();
+ await typeChars(tui, "line2");
+ expect(tui.screen.contains("line1")).toBe(true);
+ expect(tui.screen.contains("line2")).toBe(true);
+ });
+
+ it("navigates cursor with arrow keys", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ await focusAndFlush(tui);
+ await typeChars(tui, "abc");
+ tui.keys.enter();
+ await tui.flush();
+ await typeChars(tui, "def");
+ tui.keys.up();
+ await tui.flush();
+ tui.keys.press("X");
+ await tui.flush();
+ expect(tui.screen.contains("abcX")).toBe(true);
+ });
+
+ it("renders cursor when focused", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ await focusAndFlush(tui);
+ expect(tui.screen.contains("|")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/text-input.test.tsx b/registry/ui/__tests__/text-input.test.tsx
new file mode 100644
index 0000000..389cd68
--- /dev/null
+++ b/registry/ui/__tests__/text-input.test.tsx
@@ -0,0 +1,106 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { TextInput } from "../text-input";
+import { renderTui } from "./render-tui";
+
+const validateMinLength = (v: string) => (v.length < 3 ? "Too short" : null);
+
+describe("TextInput", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders placeholder when empty", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Enter name")).toBe(true);
+ });
+
+ it("renders label", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Name")).toBe(true);
+ });
+
+ it("renders controlled value", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("hello")).toBe(true);
+ });
+
+ it("calls onChange when typing", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.type("a");
+ expect(onChange).toHaveBeenCalledWith("a");
+ });
+
+ it("calls onSubmit on Enter", async () => {
+ const onSubmit = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.enter();
+ expect(onSubmit).toHaveBeenCalledWith("done");
+ });
+
+ it("handles backspace via onChange", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.right();
+ await tui.flush();
+ tui.keys.right();
+ await tui.flush();
+ tui.keys.backspace();
+ expect(onChange).toHaveBeenCalledWith("a");
+ });
+
+ it("masks value when mask prop is set", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("******")).toBe(true);
+ expect(tui.screen.contains("secret")).toBe(false);
+ });
+
+ it("shows validation error on submit", async () => {
+ const onSubmit = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.enter();
+ await tui.flush();
+ expect(onSubmit).not.toHaveBeenCalled();
+ expect(tui.screen.contains("Too short")).toBe(true);
+ });
+
+ it("submits when validation passes", async () => {
+ const onSubmit = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await tui.flush();
+ tui.keys.enter();
+ expect(onSubmit).toHaveBeenCalledWith("abc");
+ });
+});
diff --git a/registry/ui/__tests__/text.test.tsx b/registry/ui/__tests__/text.test.tsx
new file mode 100644
index 0000000..b260348
--- /dev/null
+++ b/registry/ui/__tests__/text.test.tsx
@@ -0,0 +1,20 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { Text } from "../text";
+import { renderTui } from "./render-tui";
+
+describe("Text", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders children text", () => {
+ const tui = renderTui(
+
+ hello world
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("hello world")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/theme-provider.test.tsx b/registry/ui/__tests__/theme-provider.test.tsx
new file mode 100644
index 0000000..1b9f2a6
--- /dev/null
+++ b/registry/ui/__tests__/theme-provider.test.tsx
@@ -0,0 +1,91 @@
+import { Text } from "ink";
+import { renderTui } from "ink-testing";
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import {
+ ThemeProvider,
+ useTheme,
+ createTheme,
+ AutoThemeProvider,
+} from "../theme-provider";
+
+describe("ThemeProvider", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders children", () => {
+ const tui = renderTui(
+
+ hello
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("hello")).toBe(true);
+ });
+
+ it("provides theme to descendants", () => {
+ const Inner = () => {
+ const theme = useTheme();
+ return {theme.name};
+ };
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.text().length).toBeGreaterThan(0);
+ });
+
+ it("accepts custom theme", () => {
+ const custom = createTheme({ name: "my-custom" });
+ const Inner = () => {
+ const theme = useTheme();
+ return {theme.name};
+ };
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("my-custom")).toBe(true);
+ });
+
+ it("createTheme merges color overrides", () => {
+ const theme = createTheme({
+ colors: { primary: "#ff0000" },
+ name: "test",
+ });
+ expect(theme.name).toBe("test");
+ expect(theme.colors.primary).toBe("#ff0000");
+ expect(theme.colors.foreground).toBeDefined();
+ });
+
+ it("createTheme merges spacing overrides", () => {
+ const theme = createTheme({
+ name: "test",
+ spacing: { 1: 2 },
+ });
+ expect(theme.spacing[1]).toBe(2);
+ expect(theme.spacing[0]).toBe(0);
+ });
+});
+
+describe("AutoThemeProvider", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders children with auto-detected theme", () => {
+ const dark = createTheme({ name: "dark" });
+ const light = createTheme({ name: "light" });
+ const tui = renderTui(
+
+ auto
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("auto")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/thinking-block.test.tsx b/registry/ui/__tests__/thinking-block.test.tsx
new file mode 100644
index 0000000..7618511
--- /dev/null
+++ b/registry/ui/__tests__/thinking-block.test.tsx
@@ -0,0 +1,72 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { ThinkingBlock } from "../thinking-block";
+import { renderTui } from "./render-tui";
+
+describe("ThinkingBlock", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders label in header", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Reasoning")).toBe(true);
+ });
+
+ it("renders collapsed by default", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("▶")).toBe(true);
+ expect(tui.screen.contains("hidden content")).toBe(false);
+ });
+
+ it("expands on enter to show content", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.enter();
+ await tui.flush();
+ expect(tui.screen.contains("▼")).toBe(true);
+ expect(tui.screen.contains("revealed content")).toBe(true);
+ });
+
+ it("toggles collapse on space", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.space();
+ await tui.flush();
+ expect(tui.screen.contains("toggle me")).toBe(true);
+ tui.keys.space();
+ await tui.flush();
+ expect(tui.screen.contains("toggle me")).toBe(false);
+ });
+
+ it("shows Thinking... when streaming", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Thinking...")).toBe(true);
+ });
+
+ it("renders token count", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("1,500 tokens")).toBe(true);
+ });
+
+ it("renders duration", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("3.2s")).toBe(true);
+ });
+
+ it("starts expanded when defaultCollapsed is false", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("visible")).toBe(true);
+ expect(tui.screen.contains("▼")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/timer.test.tsx b/registry/ui/__tests__/timer.test.tsx
new file mode 100644
index 0000000..5c698dd
--- /dev/null
+++ b/registry/ui/__tests__/timer.test.tsx
@@ -0,0 +1,90 @@
+import React from "react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+import { Timer } from "../timer";
+import { renderTui } from "./render-tui";
+
+describe("Timer", () => {
+ let unmount: () => void;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ unmount?.();
+ vi.useRealTimers();
+ });
+
+ it("renders initial duration in hms format", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("00:01:30")).toBe(true);
+ });
+
+ it("shows Paused status when not autoStarted", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("[Paused]")).toBe(true);
+ });
+
+ it("counts down when autoStart is true", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("[Running]")).toBe(true);
+ await vi.advanceTimersByTimeAsync(3000);
+ expect(tui.screen.contains("00:00:07")).toBe(true);
+ });
+
+ it("calls onComplete when timer finishes", async () => {
+ const onComplete = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await vi.advanceTimersByTimeAsync(2000);
+ expect(onComplete).toHaveBeenCalledTimes(1);
+ expect(tui.screen.contains("[Done!]")).toBe(true);
+ });
+
+ it("toggles pause/resume with space", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("[Paused]")).toBe(true);
+ tui.keys.space();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(tui.screen.contains("[Running]")).toBe(true);
+ tui.keys.space();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(tui.screen.contains("[Paused]")).toBe(true);
+ });
+
+ it("resets with r key", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ await vi.advanceTimersByTimeAsync(5000);
+ expect(tui.screen.contains("00:00:05")).toBe(true);
+ tui.keys.press("r");
+ await vi.advanceTimersByTimeAsync(0);
+ expect(tui.screen.contains("00:00:10")).toBe(true);
+ expect(tui.screen.contains("[Paused]")).toBe(true);
+ });
+
+ it("renders label", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Pomodoro")).toBe(true);
+ });
+
+ it("renders ms format", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("01:30")).toBe(true);
+ });
+
+ it("renders s format", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("45s")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/toast.test.tsx b/registry/ui/__tests__/toast.test.tsx
new file mode 100644
index 0000000..1db9df5
--- /dev/null
+++ b/registry/ui/__tests__/toast.test.tsx
@@ -0,0 +1,79 @@
+import React from "react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+import { Toast } from "../toast";
+import { renderTui } from "./render-tui";
+
+describe("Toast", () => {
+ let unmount: () => void;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ unmount?.();
+ vi.useRealTimers();
+ });
+
+ it("renders message text", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("File saved")).toBe(true);
+ });
+
+ it("renders info variant icon by default", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("ℹ")).toBe(true);
+ });
+
+ it("renders success variant icon", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("✓")).toBe(true);
+ });
+
+ it("renders error variant icon", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("✗")).toBe(true);
+ });
+
+ it("renders warning variant icon", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("⚠")).toBe(true);
+ });
+
+ it("renders custom icon", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("🔥")).toBe(true);
+ });
+
+ it("renders countdown bar", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("█")).toBe(true);
+ expect(tui.screen.contains("3.0s")).toBe(true);
+ });
+
+ it("counts down over time", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(tui.screen.contains("2.0s")).toBe(true);
+ });
+
+ it("calls onDismiss and disappears after duration", async () => {
+ const onDismiss = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ await vi.advanceTimersByTimeAsync(2000);
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ expect(tui.screen.contains("Bye")).toBe(false);
+ });
+});
diff --git a/registry/ui/__tests__/toggle.test.tsx b/registry/ui/__tests__/toggle.test.tsx
new file mode 100644
index 0000000..c1df100
--- /dev/null
+++ b/registry/ui/__tests__/toggle.test.tsx
@@ -0,0 +1,70 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { Toggle } from "../toggle";
+import { renderTui } from "./render-tui";
+
+describe("Toggle", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders OFF state by default", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("○")).toBe(true);
+ expect(tui.screen.contains("OFF")).toBe(true);
+ });
+
+ it("renders ON state when checked", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("●")).toBe(true);
+ expect(tui.screen.contains("ON")).toBe(true);
+ });
+
+ it("renders label", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Dark mode")).toBe(true);
+ });
+
+ it("renders custom on/off labels", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Enabled")).toBe(true);
+ });
+
+ it("toggles on space", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.tab();
+ await tui.flush();
+ tui.keys.space();
+ expect(onChange).toHaveBeenCalledWith(true);
+ });
+
+ it("toggles off on space when checked", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.tab();
+ await tui.flush();
+ tui.keys.space();
+ expect(onChange).toHaveBeenCalledWith(false);
+ });
+
+ it("does not toggle when disabled", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.tab();
+ await tui.flush();
+ tui.keys.space();
+ expect(onChange).not.toHaveBeenCalled();
+ });
+});
diff --git a/registry/ui/__tests__/token-usage.test.tsx b/registry/ui/__tests__/token-usage.test.tsx
new file mode 100644
index 0000000..b5023ec
--- /dev/null
+++ b/registry/ui/__tests__/token-usage.test.tsx
@@ -0,0 +1,43 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { ContextMeter, TokenUsage } from "../token-usage";
+import { renderTui } from "./render-tui";
+
+describe("TokenUsage", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it('renders prompt/completion tokens with "in" and "out"', () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("in")).toBe(true);
+ expect(tui.screen.contains("out")).toBe(true);
+ expect(tui.screen.contains("800")).toBe(true);
+ expect(tui.screen.contains("200")).toBe(true);
+ });
+
+ it("renders model name", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("gpt-4o-mini")).toBe(true);
+ });
+});
+
+describe("ContextMeter", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders ContextMeter with bar", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("ctx")).toBe(true);
+ expect(tui.screen.contains("█")).toBe(true);
+ expect(tui.screen.contains("░")).toBe(true);
+ expect(tui.screen.contains("50%")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/tool-approval.test.tsx b/registry/ui/__tests__/tool-approval.test.tsx
new file mode 100644
index 0000000..5b6c4d3
--- /dev/null
+++ b/registry/ui/__tests__/tool-approval.test.tsx
@@ -0,0 +1,93 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { ToolApproval } from "../tool-approval";
+import { renderTui } from "./render-tui";
+
+describe("ToolApproval", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders tool name", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("file_write")).toBe(true);
+ });
+
+ it("renders title text", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Tool Approval Required")).toBe(true);
+ });
+
+ it("renders description when provided", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Run a shell command")).toBe(true);
+ });
+
+ it("renders risk level label", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("[HIGH RISK]")).toBe(true);
+ });
+
+ it("renders LOW risk by default", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("[LOW RISK]")).toBe(true);
+ });
+
+ it("renders args when provided", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("path")).toBe(true);
+ expect(tui.screen.contains("/tmp/file")).toBe(true);
+ });
+
+ it("calls onApprove when pressing y", () => {
+ const onApprove = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.press("y");
+ expect(onApprove).toHaveBeenCalled();
+ });
+
+ it("calls onDeny when pressing n", () => {
+ const onDeny = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.press("n");
+ expect(onDeny).toHaveBeenCalled();
+ });
+
+ it("calls onAlwaysAllow when pressing a", () => {
+ const onAlwaysAllow = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.press("a");
+ expect(onAlwaysAllow).toHaveBeenCalled();
+ });
+
+ it("shows Always Allow option only when handler provided", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("[y] Approve")).toBe(true);
+ expect(tui.screen.contains("[n] Deny")).toBe(true);
+ expect(tui.screen.contains("[a] Always Allow")).toBe(false);
+ });
+
+ it("shows Always Allow when onAlwaysAllow is provided", () => {
+ const tui = renderTui(
+ {}} />
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("[a] Always Allow")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/tool-call.test.tsx b/registry/ui/__tests__/tool-call.test.tsx
new file mode 100644
index 0000000..82fcfa1
--- /dev/null
+++ b/registry/ui/__tests__/tool-call.test.tsx
@@ -0,0 +1,101 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { ToolCall } from "../tool-call";
+import { renderTui } from "./render-tui";
+
+describe("ToolCall", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders tool name", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("file_read")).toBe(true);
+ });
+
+ it("renders pending status icon", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("○")).toBe(true);
+ });
+
+ it("renders success status icon", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("✓")).toBe(true);
+ });
+
+ it("renders error status icon", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("✗")).toBe(true);
+ });
+
+ it("renders duration when provided", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("150ms")).toBe(true);
+ });
+
+ it("starts collapsed by default", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("▶")).toBe(true);
+ expect(tui.screen.contains("Args:")).toBe(false);
+ });
+
+ it("expands on enter to show args and result", async () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.enter();
+ await tui.flush();
+ expect(tui.screen.contains("▼")).toBe(true);
+ expect(tui.screen.contains("Args:")).toBe(true);
+ expect(tui.screen.contains("file")).toBe(true);
+ expect(tui.screen.contains("Result:")).toBe(true);
+ expect(tui.screen.contains("done")).toBe(true);
+ });
+
+ it("toggles collapse state", async () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.enter();
+ await tui.flush();
+ expect(tui.screen.contains("Args:")).toBe(true);
+ tui.keys.enter();
+ await tui.flush();
+ expect(tui.screen.contains("Args:")).toBe(false);
+ });
+
+ it("renders expanded by default when defaultCollapsed is false", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Args:")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/tooltip.test.tsx b/registry/ui/__tests__/tooltip.test.tsx
new file mode 100644
index 0000000..51e4cba
--- /dev/null
+++ b/registry/ui/__tests__/tooltip.test.tsx
@@ -0,0 +1,43 @@
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { Text } from "../text";
+import { Tooltip } from "../tooltip";
+import { renderTui } from "./render-tui";
+
+describe("Tooltip", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders children always", () => {
+ const tui = renderTui(
+
+ anchor
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("anchor")).toBe(true);
+ });
+
+ it("shows tooltip content when visible", () => {
+ const tui = renderTui(
+
+ item
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("item")).toBe(true);
+ expect(tui.screen.contains("Shown tip")).toBe(true);
+ });
+
+ it("hides tooltip when not visible", () => {
+ const tui = renderTui(
+
+ only this
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("only this")).toBe(true);
+ expect(tui.screen.contains("Secret")).toBe(false);
+ });
+});
diff --git a/registry/ui/__tests__/tree-select.test.tsx b/registry/ui/__tests__/tree-select.test.tsx
new file mode 100644
index 0000000..63487ef
--- /dev/null
+++ b/registry/ui/__tests__/tree-select.test.tsx
@@ -0,0 +1,108 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { TreeSelect } from "../tree-select";
+import { renderTui } from "./render-tui";
+
+describe("TreeSelect", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ const nodes = [
+ {
+ children: [
+ { label: "Apple", value: "apple" },
+ { label: "Banana", value: "banana" },
+ ],
+ label: "Fruits",
+ value: "fruits",
+ },
+ { label: "Vegetables", value: "vegetables" },
+ ];
+
+ it("renders top-level labels", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Fruits")).toBe(true);
+ expect(tui.screen.contains("Vegetables")).toBe(true);
+ });
+
+ it("renders label prop", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Select item")).toBe(true);
+ });
+
+ it("shows collapsed icon for parent nodes", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("▶")).toBe(true);
+ });
+
+ it("expands on right arrow", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.right();
+ await tui.flush();
+ expect(tui.screen.contains("Apple")).toBe(true);
+ expect(tui.screen.contains("Banana")).toBe(true);
+ });
+
+ it("collapses on left arrow", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.right();
+ await tui.flush();
+ expect(tui.screen.contains("Apple")).toBe(true);
+ tui.keys.left();
+ await tui.flush();
+ expect(tui.screen.contains("Apple")).toBe(false);
+ });
+
+ it("navigates with up/down arrows", async () => {
+ const onChange = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.enter();
+ expect(onChange).toHaveBeenCalledWith("vegetables");
+ });
+
+ it("calls onSubmit on enter", async () => {
+ const onSubmit = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.enter();
+ expect(onSubmit).toHaveBeenCalledWith("vegetables");
+ });
+
+ it("does not select disabled nodes", () => {
+ const disabledNodes = [
+ { disabled: true, label: "A", value: "a" },
+ { label: "B", value: "b" },
+ ];
+ const onChange = vi.fn();
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ tui.keys.enter();
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it("renders expanded by default when expandedByDefault is true", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Apple")).toBe(true);
+ expect(tui.screen.contains("Banana")).toBe(true);
+ });
+
+ it("shows leaf icon for leaf nodes", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("·")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/tree.test.tsx b/registry/ui/__tests__/tree.test.tsx
new file mode 100644
index 0000000..47233e1
--- /dev/null
+++ b/registry/ui/__tests__/tree.test.tsx
@@ -0,0 +1,99 @@
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { Tree } from "../tree";
+import { renderTui } from "./render-tui";
+
+describe("Tree", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ const nodes = [
+ {
+ children: [
+ { key: "index", label: "index.ts" },
+ { key: "utils", label: "utils.ts" },
+ ],
+ key: "src",
+ label: "src",
+ },
+ { key: "readme", label: "README.md" },
+ ];
+
+ it("renders top-level node labels", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("src")).toBe(true);
+ expect(tui.screen.contains("README.md")).toBe(true);
+ });
+
+ it("shows collapsed icon for nodes with children", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("▶")).toBe(true);
+ });
+
+ it("shows leaf icon for leaf nodes", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("•")).toBe(true);
+ });
+
+ it("expands node on right arrow", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.right();
+ await tui.flush();
+ expect(tui.screen.contains("▼")).toBe(true);
+ expect(tui.screen.contains("index.ts")).toBe(true);
+ expect(tui.screen.contains("utils.ts")).toBe(true);
+ });
+
+ it("collapses node on left arrow", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("index.ts")).toBe(true);
+ tui.keys.left();
+ await tui.flush();
+ expect(tui.screen.contains("index.ts")).toBe(false);
+ expect(tui.screen.contains("▶")).toBe(true);
+ });
+
+ it("navigates with up/down arrows", async () => {
+ const onSelect = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.enter();
+ expect(onSelect).toHaveBeenCalledWith(
+ expect.objectContaining({ key: "readme" })
+ );
+ });
+
+ it("calls onSelect on enter", () => {
+ const onSelect = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.enter();
+ expect(onSelect).toHaveBeenCalledWith(
+ expect.objectContaining({ key: "src" })
+ );
+ });
+
+ it("renders defaultExpanded nodes", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("index.ts")).toBe(true);
+ expect(tui.screen.contains("utils.ts")).toBe(true);
+ });
+
+ it("supports custom icons", () => {
+ const tui = renderTui(
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("[+]")).toBe(true);
+ expect(tui.screen.contains("~")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/usage-monitor.test.tsx b/registry/ui/__tests__/usage-monitor.test.tsx
new file mode 100644
index 0000000..484943e
--- /dev/null
+++ b/registry/ui/__tests__/usage-monitor.test.tsx
@@ -0,0 +1,133 @@
+import React from "react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+import { UsageMonitor } from "../usage-monitor";
+import { renderTui } from "./render-tui";
+
+describe("UsageMonitor", () => {
+ let unmount: () => void;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ unmount?.();
+ vi.useRealTimers();
+ });
+
+ it("renders header title", () => {
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Dashboard")).toBe(true);
+ });
+
+ it("renders tags", () => {
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Pro")).toBe(true);
+ expect(tui.screen.contains("Active")).toBe(true);
+ });
+
+ it("renders section with title", () => {
+ const tui = renderTui(
+
+
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("CPU")).toBe(true);
+ expect(tui.screen.contains("Usage")).toBe(true);
+ });
+
+ it("renders metric bar with fill chars", () => {
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("█")).toBe(true);
+ expect(tui.screen.contains("░")).toBe(true);
+ expect(tui.screen.contains("75.0%")).toBe(true);
+ });
+
+ it("renders stat row", () => {
+ const tui = renderTui(
+
+
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Uptime")).toBe(true);
+ expect(tui.screen.contains("3d 5h")).toBe(true);
+ });
+
+ it("renders predictions", () => {
+ const tui = renderTui(
+
+
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Predictions")).toBe(true);
+ expect(tui.screen.contains("2 hours")).toBe(true);
+ });
+
+ it("renders distribution metric", () => {
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Traffic")).toBe(true);
+ expect(tui.screen.contains("US")).toBe(true);
+ expect(tui.screen.contains("EU")).toBe(true);
+ });
+
+ it("renders status bar", () => {
+ const tui = renderTui(
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("main")).toBe(true);
+ expect(tui.screen.contains("Ctrl+C")).toBe(true);
+ });
+});
diff --git a/registry/ui/__tests__/virtual-list.test.tsx b/registry/ui/__tests__/virtual-list.test.tsx
new file mode 100644
index 0000000..6de2469
--- /dev/null
+++ b/registry/ui/__tests__/virtual-list.test.tsx
@@ -0,0 +1,118 @@
+import { Text } from "ink";
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { VirtualList } from "../virtual-list";
+import { renderTui } from "./render-tui";
+
+describe("VirtualList", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ const items = Array.from({ length: 20 }, (_, i) => `Item ${i}`);
+
+ it("renders visible items within height", () => {
+ const tui = renderTui(
+ {item}}
+ />
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Item 0")).toBe(true);
+ expect(tui.screen.contains("Item 4")).toBe(true);
+ });
+
+ it("renders active state for first item", () => {
+ const tui = renderTui(
+ (
+ {isActive ? `> ${item}` : item}
+ )}
+ />
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("> Item 0")).toBe(true);
+ });
+
+ it("navigates down with arrow keys", async () => {
+ const tui = renderTui(
+ (
+ {isActive ? `> ${item}` : item}
+ )}
+ />
+ );
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ expect(tui.screen.contains("> Item 1")).toBe(true);
+ });
+
+ it("navigates up with arrow keys", async () => {
+ const tui = renderTui(
+ (
+ {isActive ? `> ${item}` : item}
+ )}
+ />
+ );
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.up();
+ await tui.flush();
+ expect(tui.screen.contains("> Item 1")).toBe(true);
+ });
+
+ it("calls onSelect on enter", async () => {
+ const onSelect = vi.fn();
+ const tui = renderTui(
+ {item}}
+ onSelect={onSelect}
+ />
+ );
+ ({ unmount } = tui);
+ tui.keys.down();
+ await tui.flush();
+ tui.keys.enter();
+ expect(onSelect).toHaveBeenCalledWith("Item 1", 1);
+ });
+
+ it("shows scrollbar when items exceed height", () => {
+ const tui = renderTui(
+ {item}}
+ />
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("█")).toBe(true);
+ });
+
+ it("does not show scrollbar when items fit in height", () => {
+ const shortItems = ["A", "B"];
+ const tui = renderTui(
+ {item}}
+ />
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("█")).toBe(false);
+ });
+});
diff --git a/registry/ui/__tests__/welcome-screen.test.tsx b/registry/ui/__tests__/welcome-screen.test.tsx
new file mode 100644
index 0000000..3d359a4
--- /dev/null
+++ b/registry/ui/__tests__/welcome-screen.test.tsx
@@ -0,0 +1,146 @@
+import { Text } from "ink";
+import React from "react";
+import { describe, it, expect, afterEach } from "vitest";
+
+import { WelcomeScreen } from "../welcome-screen";
+import { renderTui } from "./render-tui";
+
+describe("WelcomeScreen", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ it("renders app name", () => {
+ const tui = renderTui(
+
+
+ left
+
+
+ right
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("MyCLI")).toBe(true);
+ });
+
+ it("renders app name with version", () => {
+ const tui = renderTui(
+
+
+ l
+
+
+ r
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("MyCLI v1.2.3")).toBe(true);
+ });
+
+ it("renders left and right panels", () => {
+ const tui = renderTui(
+
+
+ Left Panel
+
+
+ Right Panel
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Left Panel")).toBe(true);
+ expect(tui.screen.contains("Right Panel")).toBe(true);
+ });
+
+ describe("WelcomeScreen.Greeting", () => {
+ it("renders greeting text", () => {
+ const tui = renderTui(
+
+
+ Hello, User!
+
+
+ r
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Hello, User!")).toBe(true);
+ });
+ });
+
+ describe("WelcomeScreen.Logo", () => {
+ it("renders logo text", () => {
+ const tui = renderTui(
+
+
+ LOGO ART
+
+
+ r
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("LOGO ART")).toBe(true);
+ });
+ });
+
+ describe("WelcomeScreen.Meta", () => {
+ it("renders meta items with separator", () => {
+ const tui = renderTui(
+
+
+
+
+
+ r
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("v1.0")).toBe(true);
+ expect(tui.screen.contains("MIT")).toBe(true);
+ expect(tui.screen.contains("Node 20")).toBe(true);
+ });
+
+ it("renders stacked meta items", () => {
+ const tui = renderTui(
+
+
+
+
+
+ r
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Line 1")).toBe(true);
+ expect(tui.screen.contains("Line 2")).toBe(true);
+ });
+ });
+
+ describe("WelcomeScreen.Section", () => {
+ it("renders section with title and content", () => {
+ const tui = renderTui(
+
+
+ l
+
+
+
+ Run npx my-cli init
+
+
+
+ );
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Getting Started")).toBe(true);
+ expect(tui.screen.contains("Run npx my-cli init")).toBe(true);
+ });
+ });
+});
diff --git a/registry/ui/__tests__/wizard.test.tsx b/registry/ui/__tests__/wizard.test.tsx
new file mode 100644
index 0000000..e5f5089
--- /dev/null
+++ b/registry/ui/__tests__/wizard.test.tsx
@@ -0,0 +1,129 @@
+import { Text } from "ink";
+import React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import { Wizard } from "../wizard";
+import { renderTui } from "./render-tui";
+
+describe("Wizard", () => {
+ let unmount: () => void;
+ afterEach(() => unmount?.());
+
+ const steps = [
+ { content: Step 1 Content, key: "step1", title: "Account" },
+ { content: Step 2 Content, key: "step2", title: "Profile" },
+ { content: Step 3 Content, key: "step3", title: "Confirm" },
+ ];
+
+ it("renders first step content", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Step 1 Content")).toBe(true);
+ expect(tui.screen.contains("Account")).toBe(true);
+ });
+
+ it("renders progress indicators", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Account")).toBe(true);
+ expect(tui.screen.contains("Profile")).toBe(true);
+ expect(tui.screen.contains("Confirm")).toBe(true);
+ });
+
+ it("shows Next button on non-last step", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("Next")).toBe(true);
+ });
+
+ it("advances to next step on tab", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.tab();
+ await tui.flush();
+ expect(tui.screen.contains("Step 2 Content")).toBe(true);
+ });
+
+ it("advances to next step on right arrow", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.right();
+ await tui.flush();
+ expect(tui.screen.contains("Step 2 Content")).toBe(true);
+ });
+
+ it("goes back on left arrow", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.right();
+ await tui.flush();
+ expect(tui.screen.contains("Step 2 Content")).toBe(true);
+ tui.keys.left();
+ await tui.flush();
+ expect(tui.screen.contains("Step 1 Content")).toBe(true);
+ });
+
+ it("shows Back button after first step", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.right();
+ await tui.flush();
+ expect(tui.screen.contains("Back")).toBe(true);
+ });
+
+ it("shows Finish on last step", async () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.right();
+ await tui.flush();
+ tui.keys.right();
+ await tui.flush();
+ expect(tui.screen.contains("Finish")).toBe(true);
+ });
+
+ it("calls onComplete when finishing last step", async () => {
+ const onComplete = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.right();
+ await tui.flush();
+ tui.keys.right();
+ await tui.flush();
+ tui.keys.right();
+ await tui.flush();
+ expect(onComplete).toHaveBeenCalledWith(["step1", "step2", "step3"]);
+ });
+
+ it("calls onCancel on escape", async () => {
+ const onCancel = vi.fn();
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.escape();
+ await tui.flush();
+ expect(onCancel).toHaveBeenCalled();
+ });
+
+ it("shows validation error when step validation fails", async () => {
+ const stepsWithValidation = [
+ {
+ content: content,
+ key: "s1",
+ title: "First",
+ validate: () => "Fill required fields",
+ },
+ { content: done, key: "s2", title: "Second" },
+ ];
+ const tui = renderTui();
+ ({ unmount } = tui);
+ tui.keys.right();
+ await tui.flush();
+ expect(tui.screen.contains("Fill required fields")).toBe(true);
+ });
+
+ it("hides progress when showProgress is false", () => {
+ const tui = renderTui();
+ ({ unmount } = tui);
+ expect(tui.screen.contains("◉")).toBe(false);
+ expect(tui.screen.contains("○")).toBe(false);
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..5ba0b5c
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,16 @@
+import { resolve } from "node:path";
+
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ "@": resolve(import.meta.dirname, "."),
+ },
+ },
+ test: {
+ environment: "node",
+ globals: true,
+ include: ["registry/**/__tests__/**/*.test.{ts,tsx}"],
+ },
+});