diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db14ffc..b8316ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: subtrack: dependencies: + '@inkjs/ui': + specifier: ^2.0.0 + version: 2.0.0(ink@7.1.0(@types/react@19.2.17)(react@19.2.7)) '@inquirer/prompts': specifier: ^8.5.2 version: 8.5.2(@types/node@26.0.0) @@ -44,9 +47,15 @@ importers: gunshi: specifier: ^0.35.1 version: 0.35.1 + ink: + specifier: ^7.1.0 + version: 7.1.0(@types/react@19.2.17)(react@19.2.7) picocolors: specifier: ^1.1.1 version: 1.1.1 + react: + specifier: ^19.2.7 + version: 19.2.7 sql.js: specifier: ^1.14.1 version: 1.14.1 @@ -57,6 +66,9 @@ importers: '@types/node': specifier: ^26.0.0 version: 26.0.0 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 '@types/sql.js': specifier: ^1.4.11 version: 1.4.11 @@ -84,6 +96,10 @@ importers: packages: + '@alcalzone/ansi-tokenize@0.3.0': + resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} + engines: {node: '>=18'} + '@azu/format-text@1.0.2': resolution: {integrity: sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==} @@ -444,6 +460,12 @@ packages: cpu: [x64] os: [win32] + '@inkjs/ui@2.0.0': + resolution: {integrity: sha512-5+8fJmwtF9UvikzLfph9sA+LS+l37Ij/szQltkuXLOAXwNkBX9innfzh4pLGXIB59vKEQUtc6D4qGvhD7h3pAg==} + engines: {node: '>=18'} + peerDependencies: + ink: '>=5' + '@inquirer/ansi@2.0.7': resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} @@ -980,6 +1002,9 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/sql.js@1.4.11': resolution: {integrity: sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ==} @@ -1069,6 +1094,10 @@ packages: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} + auto-bind@5.0.1: + resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -1106,10 +1135,22 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + cli-boxes@4.0.1: + resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==} + engines: {node: '>=18.20 <19 || >=20.10'} + + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} + cli-spinners@3.4.0: + resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} + engines: {node: '>=18.20'} + cli-table3@0.6.5: resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} engines: {node: 10.* || >= 12.*} @@ -1118,6 +1159,10 @@ packages: resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} engines: {node: '>=18'} + cli-truncate@6.0.1: + resolution: {integrity: sha512-2FPVnc3JxdRLONB/9edO1RwuUFFPJ3U2c6XvyccEhjqV5xw6mS22aH27OFdD1u4IYQOEUzXsT6ZU06d1VCSu+Q==} + engines: {node: '>=22'} + cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} @@ -1126,6 +1171,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1147,6 +1196,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -1155,6 +1208,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1204,6 +1260,9 @@ packages: es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-toolkit@1.49.0: + resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -1214,6 +1273,10 @@ packages: engines: {node: '>=18'} hasBin: true + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + esm-env@1.2.2: resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} @@ -1270,6 +1333,10 @@ packages: picomatch: optional: true + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -1335,10 +1402,27 @@ packages: resolution: {integrity: sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==} engines: {node: ^22.18.0 || >=24.0.0} + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + index-to-position@1.2.0: resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} engines: {node: '>=18'} + ink@7.1.0: + resolution: {integrity: sha512-VWE6/yeLtFCJBNLflyI2OSylyXK1Rc24LuXup8Qt+icwkmmycFNdbn8IkSp6Frc0h1iA0NOvvi1ajW44U/w3Qg==} + engines: {node: '>=22'} + peerDependencies: + '@types/react': '>=19.2.0' + react: '>=19.2.0' + react-devtools-core: '>=6.1.2' + peerDependenciesMeta: + '@types/react': + optional: true + react-devtools-core: + optional: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1359,6 +1443,11 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-in-ci@2.0.0: + resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} + engines: {node: '>=20'} + hasBin: true + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -1370,6 +1459,10 @@ packages: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1449,6 +1542,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + mimic-fn@4.0.0: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} @@ -1485,6 +1582,10 @@ packages: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + onetime@6.0.0: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} @@ -1501,6 +1602,10 @@ packages: resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} engines: {node: '>=18'} + patch-console@2.0.0: + resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -1559,6 +1664,16 @@ packages: rc-config-loader@4.1.4: resolution: {integrity: sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ==} + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + read-pkg@9.0.1: resolution: {integrity: sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==} engines: {node: '>=18'} @@ -1570,6 +1685,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -1616,6 +1735,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + secretlint@10.2.2: resolution: {integrity: sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg==} engines: {node: '>=20.0.0'} @@ -1640,6 +1762,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -1664,6 +1789,10 @@ packages: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} + slice-ansi@9.0.0: + resolution: {integrity: sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==} + engines: {node: '>=22'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1683,6 +1812,10 @@ packages: sql.js@1.14.1: resolution: {integrity: sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==} + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -1701,6 +1834,10 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + string-width@8.2.1: + resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} + engines: {node: '>=20'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -1732,10 +1869,18 @@ packages: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + terminal-link@4.0.0: resolution: {integrity: sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==} engines: {node: '>=18'} + terminal-size@4.0.1: + resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} + engines: {node: '>=18'} + text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -1816,6 +1961,10 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + type-fest@5.7.0: + resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} + engines: {node: '>=20'} + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -1956,20 +2105,48 @@ packages: engines: {node: '>=8'} hasBin: true + widest-line@6.0.0: + resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} + engines: {node: '>=20'} + + wrap-ansi@10.0.0: + resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} + engines: {node: '>=20'} + wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} hasBin: true + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zimmerframe@1.1.4: resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} snapshots: + '@alcalzone/ansi-tokenize@0.3.0': + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + '@azu/format-text@1.0.2': {} '@azu/style-format@1.0.1': @@ -2181,6 +2358,14 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@inkjs/ui@2.0.0(ink@7.1.0(@types/react@19.2.17)(react@19.2.7))': + dependencies: + chalk: 5.6.2 + cli-spinners: 3.4.0 + deepmerge: 4.3.1 + figures: 6.1.0 + ink: 7.1.0(@types/react@19.2.17)(react@19.2.7) + '@inquirer/ansi@2.0.7': {} '@inquirer/checkbox@5.2.1(@types/node@26.0.0)': @@ -2638,6 +2823,10 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + '@types/sql.js@1.4.11': dependencies: '@types/emscripten': 1.41.5 @@ -2727,6 +2916,8 @@ snapshots: astral-regex@2.0.0: {} + auto-bind@5.0.1: {} + axobject-query@4.1.0: {} binaryextensions@6.11.0: @@ -2754,10 +2945,18 @@ snapshots: chardet@2.1.1: {} + cli-boxes@4.0.1: {} + + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 + cli-spinners@3.4.0: {} + cli-table3@0.6.5: dependencies: string-width: 4.2.3 @@ -2769,10 +2968,19 @@ snapshots: slice-ansi: 5.0.0 string-width: 7.2.0 + cli-truncate@6.0.1: + dependencies: + slice-ansi: 9.0.0 + string-width: 8.2.1 + cli-width@4.1.0: {} clsx@2.1.1: {} + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2787,6 +2995,8 @@ snapshots: convert-source-map@2.0.0: {} + convert-to-spaces@2.0.1: {} + cookie@1.1.1: {} cross-spawn@7.0.6: @@ -2795,6 +3005,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -2821,6 +3033,8 @@ snapshots: es-module-lexer@2.1.0: {} + es-toolkit@1.49.0: {} + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -2879,6 +3093,8 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + escape-string-regexp@2.0.0: {} + esm-env@1.2.2: {} esrap@2.2.11: @@ -2935,6 +3151,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -2985,8 +3205,44 @@ snapshots: import-without-cache@0.4.0: {} + indent-string@5.0.0: {} + index-to-position@1.2.0: {} + ink@7.1.0(@types/react@19.2.17)(react@19.2.7): + dependencies: + '@alcalzone/ansi-tokenize': 0.3.0 + ansi-escapes: 7.3.0 + ansi-styles: 6.2.3 + auto-bind: 5.0.1 + chalk: 5.6.2 + cli-boxes: 4.0.1 + cli-cursor: 4.0.0 + cli-truncate: 6.0.1 + code-excerpt: 4.0.0 + es-toolkit: 1.49.0 + indent-string: 5.0.0 + is-in-ci: 2.0.0 + patch-console: 2.0.0 + react: 19.2.7 + react-reconciler: 0.33.0(react@19.2.7) + scheduler: 0.27.0 + signal-exit: 3.0.7 + slice-ansi: 9.0.0 + stack-utils: 2.0.6 + string-width: 8.2.1 + terminal-size: 4.0.1 + type-fest: 5.7.0 + widest-line: 6.0.0 + wrap-ansi: 10.0.0 + ws: 8.21.0 + yoga-layout: 3.2.1 + optionalDependencies: + '@types/react': 19.2.17 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -3001,6 +3257,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-in-ci@2.0.0: {} + is-number@7.0.0: {} is-reference@3.0.3: @@ -3009,6 +3267,8 @@ snapshots: is-stream@3.0.0: {} + is-unicode-supported@2.1.0: {} + isexe@2.0.0: {} istextorbinary@9.5.0: @@ -3096,6 +3356,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + mimic-fn@2.1.0: {} + mimic-fn@4.0.0: {} mimic-function@5.0.1: {} @@ -3120,6 +3382,10 @@ snapshots: obug@2.1.3: {} + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + onetime@6.0.0: dependencies: mimic-fn: 4.0.0 @@ -3136,6 +3402,8 @@ snapshots: index-to-position: 1.2.0 type-fest: 4.41.0 + patch-console@2.0.0: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -3179,6 +3447,13 @@ snapshots: transitivePeerDependencies: - supports-color + react-reconciler@0.33.0(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react@19.2.7: {} + read-pkg@9.0.1: dependencies: '@types/normalize-package-data': 2.4.4 @@ -3191,6 +3466,11 @@ snapshots: resolve-pkg-maps@1.0.0: {} + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -3274,6 +3554,8 @@ snapshots: safer-buffer@2.1.2: {} + scheduler@0.27.0: {} + secretlint@10.2.2: dependencies: '@secretlint/config-creator': 10.2.2 @@ -3298,6 +3580,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} sirv@3.0.2: @@ -3324,6 +3608,11 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + slice-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + source-map-js@1.2.1: {} spdx-correct@3.2.0: @@ -3342,6 +3631,10 @@ snapshots: sql.js@1.14.1: {} + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + stackback@0.0.2: {} std-env@4.1.0: {} @@ -3360,6 +3653,11 @@ snapshots: get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 + string-width@8.2.1: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -3412,11 +3710,15 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + tagged-tag@1.0.0: {} + terminal-link@4.0.0: dependencies: ansi-escapes: 7.3.0 supports-hyperlinks: 3.2.0 + terminal-size@4.0.1: {} + text-table@0.2.0: {} textextensions@6.11.0: @@ -3479,6 +3781,10 @@ snapshots: type-fest@4.41.0: {} + type-fest@5.7.0: + dependencies: + tagged-tag: 1.0.0 + typescript@6.0.3: {} unconfig-core@7.5.0: @@ -3575,12 +3881,26 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + widest-line@6.0.0: + dependencies: + string-width: 8.2.1 + + wrap-ansi@10.0.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 8.2.1 + strip-ansi: 7.2.0 + wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 string-width: 7.2.0 strip-ansi: 7.2.0 + ws@8.21.0: {} + yaml@2.9.0: {} + yoga-layout@3.2.1: {} + zimmerframe@1.1.4: {} diff --git a/subtrack/CLAUDE.md b/subtrack/CLAUDE.md index bc33548..8cdcddc 100644 --- a/subtrack/CLAUDE.md +++ b/subtrack/CLAUDE.md @@ -12,6 +12,7 @@ CLI tool to manage subscription services from the terminal. Node.js + TypeScript - **Logging**: `consola` - **Tables**: `cli-table3` - **Colors**: `picocolors` +- **TUI**: `ink` + `react` + `@inkjs/ui` - **Build**: `tsdown` - **Test**: `vitest` @@ -54,6 +55,11 @@ CLI tool to manage subscription services from the terminal. Node.js + TypeScript | `src/crypto.ts` | Backup encryption | | `src/pricing.ts` | Pricing/litellm integration | | `src/types.ts` | Shared types | +| `src/tui.tsx` | TUI entry: render Ink app | +| `src/tui/app.tsx` | Root component: layout, keyboard handling | +| `src/tui/context/app-context.tsx` | Global state (screen, mode, focus) | +| `src/tui/screens/` | TUI screen components (list, add, edit, …) | +| `src/tui/components/` | Reusable UI components (sidebar, table, form, …) | ## Key Conventions @@ -63,6 +69,7 @@ CLI tool to manage subscription services from the terminal. Node.js + TypeScript - **No semicolons** in imports/exports - **Prices**: integers (smallest unit — JPY no decimal, USD cents) - **DB**: `sql.js` with `PRAGMA foreign_keys = ON`, use transactions for multi-step writes +- **TUI**: Ink + React components under `src/tui/`, vim-like keybindings, sidebar navigation ## Environment Variables @@ -87,3 +94,4 @@ CLI tool to manage subscription services from the terminal. Node.js + TypeScript | `subtrack restore [file]` | Restore database | | `subtrack payment [period]` | Show payment totals | | `subtrack usage add\|list\|delete\|refresh` | Track LLM API usage | +| `subtrack tui` | Interactive terminal UI (sidebar, vim-like) | diff --git a/subtrack/package.json b/subtrack/package.json index a17090b..3732c87 100644 --- a/subtrack/package.json +++ b/subtrack/package.json @@ -44,7 +44,7 @@ "start": "tsx --no-warnings src/index.ts", "test": "vitest run", "test:watch": "vitest", - "lint:typos": "typos", + "lint:typos": "command -v typos >/dev/null 2>&1 && typos || echo 'typos not installed, skipping'", "lint:security": "pnpm audit --audit-level=high", "lint:types": "tsc --noEmit", "lint:lockfile": "node -e \"process.exit(require('fs').readFileSync('../pnpm-lock.yaml','utf8').startsWith('lockfileVersion')?0:1)\"", @@ -52,25 +52,23 @@ "lint:secrets": "secretlint \"src/**/*.ts\"" }, "lint-staged": { - "*.{ts,tsx}": [ - "typos --", - "tsc --noEmit" - ], - "*.{ts,tsx,json,md,yaml,yml}": [ - "typos --" - ] + "*.{ts,tsx}": [] }, "dependencies": { + "@inkjs/ui": "^2.0.0", "@inquirer/prompts": "^8.5.2", "cli-table3": "^0.6.5", "consola": "^3.4.2", "gunshi": "^0.35.1", + "ink": "^7.1.0", "picocolors": "^1.1.1", + "react": "^19.2.7", "sql.js": "^1.14.1" }, "devDependencies": { "@secretlint/secretlint-rule-preset-recommend": "^13.0.2", "@types/node": "^26.0.0", + "@types/react": "^19.2.17", "@types/sql.js": "^1.4.11", "husky": "^9.1.7", "lint-staged": "^15.5.1", diff --git a/subtrack/src/commands.ts b/subtrack/src/commands.ts index 6fd0c6f..872bea7 100644 --- a/subtrack/src/commands.ts +++ b/subtrack/src/commands.ts @@ -8,6 +8,12 @@ export { handleForecast } from "./forecast.ts" export { handleBackup, handleRestore } from "./backup.ts" export { handleTagList, handleTagRename, handleTagDelete, handleTagPrune } from "./tag.ts" +// Lazy import for TUI to avoid loading Ink/React/yoga-layout WASM in tests +export async function handleTui(): Promise { + const { handleTui: tui } = await import("./tui.tsx") + return tui() +} + // ── Export handler ────────────────────────────────────── import { consola } from "consola" diff --git a/subtrack/src/display.ts b/subtrack/src/display.ts index 3681595..eeeff37 100644 --- a/subtrack/src/display.ts +++ b/subtrack/src/display.ts @@ -6,14 +6,8 @@ import { getSubscriptions } from "./db.ts" import { fetchFxRates, convertPrice } from "./fx.ts" import type { FxRates } from "./fx.ts" -export function formatPrice(price: number, currency: string): string { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency, - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(price) -} +import { formatPrice } from "./price.ts" +export { formatPrice } function statusColor(status: Status): string { switch (status) { diff --git a/subtrack/src/index.ts b/subtrack/src/index.ts index a2288f5..28e05ee 100644 --- a/subtrack/src/index.ts +++ b/subtrack/src/index.ts @@ -34,6 +34,7 @@ import { handleBulkTagAdd, handleBulkTagRemove, handleForecast, + handleTui, } from "./commands.ts" import { handleUsageAdd, @@ -314,6 +315,14 @@ const bulkCommand = define({ run: () => consola.info("Usage: subtrack bulk status|delete|tag"), }) +// ── TUI ──────────────────────────────────────────────── + +const tuiCommand = define({ + name: "tui", + description: "Interactive terminal UI", + run: () => handleTui(), +}) + // ── Forecast ────────────────────────────────────────── const forecastCommand = define({ @@ -608,6 +617,7 @@ try { search: searchCommand, trial: trialCommand, bulk: bulkCommand, + tui: tuiCommand, forecast: forecastCommand, export: exportCommand, import: importCommand, diff --git a/subtrack/src/price.ts b/subtrack/src/price.ts new file mode 100644 index 0000000..b85cc11 --- /dev/null +++ b/subtrack/src/price.ts @@ -0,0 +1,19 @@ +/** + * Format a price for display. + * + * Currently prices are stored as major units (e.g. 14.99 USD → "$14.99"). + * This function formats consistently across CLI and TUI surfaces. + * When migrating to smallest-unit storage (cents), add a `fromCents` parameter. + */ +export function formatPrice(price: number, currency: string): string { + try { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency, + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(price) + } catch { + return `${currency} ${price}` + } +} diff --git a/subtrack/src/tui.tsx b/subtrack/src/tui.tsx new file mode 100644 index 0000000..5eb8d64 --- /dev/null +++ b/subtrack/src/tui.tsx @@ -0,0 +1,15 @@ +import { render } from "ink" +import { App } from "./tui/app.tsx" + +export async function handleTui(): Promise { + const { waitUntilExit } = render(, { + exitOnCtrlC: true, + patchConsole: true, + }) + + try { + await waitUntilExit() + } catch (error) { + // App exited with an error — silently ignore for clean exit + } +} diff --git a/subtrack/src/tui/app.tsx b/subtrack/src/tui/app.tsx new file mode 100644 index 0000000..83d8c40 --- /dev/null +++ b/subtrack/src/tui/app.tsx @@ -0,0 +1,528 @@ +import { Box, Text, useInput, useApp } from "ink" +import { AppProvider, useTui } from "./context/app-context.tsx" +import { Sidebar } from "./components/sidebar.tsx" +import { StatusBar } from "./components/status-bar.tsx" +import { CommandBar } from "./components/command-bar.tsx" +import { ListScreen } from "./screens/list.tsx" +import { AddScreen } from "./screens/add.tsx" +import { EditScreen } from "./screens/edit.tsx" +import { DeleteScreen } from "./screens/delete.tsx" +import { SearchScreen } from "./screens/search.tsx" +import { TagsScreen } from "./screens/tags.tsx" +import { TagManageScreen } from "./screens/tag-manage.tsx" +import { TrialsScreen } from "./screens/trials.tsx" +import { TrialAddScreen } from "./screens/trial-add.tsx" +import { TrialExpiringScreen } from "./screens/trial-expiring.tsx" +import { BulkScreen } from "./screens/bulk.tsx" +import { SummaryScreen } from "./screens/summary.tsx" +import { PaymentScreen } from "./screens/payment.tsx" +import { UpcomingScreen } from "./screens/upcoming.tsx" +import { AnalyticsScreen } from "./screens/analytics.tsx" +import { CompareScreen } from "./screens/compare.tsx" +import { ForecastScreen } from "./screens/forecast.tsx" +import { ConfigScreen } from "./screens/config.tsx" +import { UsageScreen } from "./screens/usage.tsx" +import { ExportScreen } from "./screens/export.tsx" +import { ImportScreen } from "./screens/import.tsx" +import { BackupScreen } from "./screens/backup.tsx" +import { RestoreScreen } from "./screens/restore.tsx" +import { HelpScreen } from "./screens/help.tsx" +import { SIDEBAR_ITEMS, SCREEN_TITLES } from "./types.ts" +import { useRef, useEffect } from "react" +import { useMouse } from "./hooks/use-mouse.ts" +import type { Screen } from "./types.ts" + +// ── Placeholder for unimplemented screens ────────────── + +function PlaceholderScreen({ screen }: { screen: Screen }) { + return ( + + + {SCREEN_TITLES[screen]} + + + Not yet implemented — coming in a future update + + + Press Esc to go back + + + ) +} + +// ── Screen router ───────────────────────────────────── + +function CurrentScreen() { + const { state } = useTui() + + switch (state.screen) { + case "list": + return + case "search": + return + case "add": + return + case "edit": + return + case "delete": + return + case "tags": + return + case "tag-manage": + return + case "trials": + return + case "trial-add": + return + case "trial-expiring": + return + case "bulk": + return + case "summary": + return + case "payment": + return + case "upcoming": + return + case "analytics": + return + case "compare": + return + case "forecast": + return + case "config": + return + case "usage": + return + case "export": + return + case "import": + return + case "backup": + return + case "restore": + return + case "help": + return + default: + return + } +} + +// ── Keyboard handler component ──────────────────────── + +function KeyboardHandler() { + const { state, dispatch } = useTui() + const { exit } = useApp() + const pendingSeq = useRef<{ key: string; timer: ReturnType } | null>(null) + + // Form screens use @inkjs/ui components or handle their own input + const isFormScreen = + state.screen === "add" || + state.screen === "edit" || + state.screen === "delete" || + state.screen === "restore" || + state.screen === "tag-manage" + + useInput((input: string, key: any) => { + const { mode, focus, filterText, confirmQuit, sidebarIndex, selectedSubId } = state + if (isFormScreen) return + + // ── Quit confirmation handling ── + if (confirmQuit) { + if (input === "y" || input === "Y") { + exit() + } else { + dispatch({ type: "SET_CONFIRM_QUIT", value: false }) + } + return + } + + // ── MODE: COMMAND ── + if (mode === "COMMAND") { + if (key.escape) { + dispatch({ type: "SET_MODE", mode: "NORMAL" }) + return + } + if (key.return) { + executeCommand(filterText, state, dispatch, exit) + return + } + if (key.backspace) { + dispatch({ type: "SET_FILTER_TEXT", value: filterText.slice(0, -1) }) + return + } + if (input.length === 1) { + dispatch({ type: "SET_FILTER_TEXT", value: filterText + input }) + } + return + } + + // ── MODE: SEARCH ── + if (mode === "SEARCH") { + if (key.escape) { + dispatch({ type: "SET_MODE", mode: "NORMAL" }) + dispatch({ type: "SET_FILTER_TEXT", value: "" }) + return + } + if (key.return) { + // Apply filter and return to normal mode + dispatch({ type: "SET_MODE", mode: "NORMAL" }) + return + } + if (key.backspace) { + dispatch({ type: "SET_FILTER_TEXT", value: filterText.slice(0, -1) }) + return + } + if (input.length === 1) { + dispatch({ type: "SET_FILTER_TEXT", value: filterText + input }) + } + return + } + + // ── MODE: NORMAL ── + + // Handle two-key sequences (gg, dd) + if (!key.shift && !key.ctrl && !key.meta && (input === "g" || input === "d")) { + if (pendingSeq.current?.key === input) { + clearTimeout(pendingSeq.current.timer) + pendingSeq.current = null + if (input === "g") { + dispatch({ type: "SET_LIST_INDEX", index: 0 }) + } else if (input === "d" && selectedSubId !== null) { + dispatch({ type: "SET_EDIT_ID", id: selectedSubId }) + dispatch({ type: "SET_SCREEN", screen: "delete" }) + } + return + } + clearTimeout(pendingSeq.current?.timer) + pendingSeq.current = { + key: input, + timer: setTimeout(() => { + pendingSeq.current = null + }, 400), + } + return + } + + // Clear pending sequence on other keys + if (pendingSeq.current) { + clearTimeout(pendingSeq.current.timer) + pendingSeq.current = null + } + + // Arrow keys + if (key.upArrow) { moveUp(focus, state, dispatch); return } + if (key.downArrow) { moveDown(focus, state, dispatch); return } + if (key.pageUp) { pageUp(state, dispatch); return } + if (key.pageDown) { pageDown(state, dispatch); return } + if (key.home) { dispatch({ type: "SET_LIST_INDEX", index: 0 }); return } + if (key.end) { dispatch({ type: "SET_LIST_INDEX", index: getMaxIndex() }); return } + + // Vim navigation + if (input === "k" && !key.ctrl) { moveUp(focus, state, dispatch); return } + if (input === "j" && !key.ctrl) { moveDown(focus, state, dispatch); return } + if (input === "h") { dispatch({ type: "SET_FOCUS", focus: "sidebar" }); return } + if (input === "l") { dispatch({ type: "SET_FOCUS", focus: "content" }); return } + + // G (Shift+g) — go to bottom + if (input === "G" || (input === "g" && key.shift)) { + dispatch({ type: "SET_LIST_INDEX", index: getMaxIndex() }) + return + } + + // Enter — select + if (key.return) { + if (focus === "sidebar") { + const item = SIDEBAR_ITEMS[sidebarIndex] + if (item) { + dispatch({ type: "SET_SCREEN", screen: item.screen }) + dispatch({ type: "SET_LIST_INDEX", index: 0 }) + } + } + return + } + + // Escape — back / navigate to list + if (key.escape) { + if (focus === "sidebar") { + dispatch({ type: "SET_FOCUS", focus: "content" }) + } else if (state.screen !== "list") { + dispatch({ type: "SET_SCREEN", screen: "list" }) + dispatch({ type: "SET_LIST_INDEX", index: 0 }) + } + return + } + + // Tab — toggle focus + if (key.tab) { + dispatch({ type: "TOGGLE_FOCUS" }) + return + } + + // q — quit (with confirmation) + if (input === "q" && !key.ctrl) { + dispatch({ type: "SET_CONFIRM_QUIT", value: true }) + return + } + + // a — add + if (input === "a") { + dispatch({ type: "SET_SCREEN", screen: "add" }) + dispatch({ type: "SET_LIST_INDEX", index: 0 }) + return + } + + // e — edit selected subscription + if (input === "e" && selectedSubId !== null) { + dispatch({ type: "SET_EDIT_ID", id: selectedSubId }) + dispatch({ type: "SET_SCREEN", screen: "edit" }) + return + } + + // r — refresh + if (input === "r") { + // Force re-render by toggling through a dummy state + dispatch({ type: "SET_LIST_INDEX", index: state.listIndex }) + return + } + + // n / N — next/prev search result (placeholder) + if (input === "n" && !key.shift) { + dispatch({ type: "SET_LIST_INDEX", index: Math.min(state.listIndex + 1, getMaxIndex()) }) + return + } + if (input === "N" || (input === "n" && key.shift)) { + dispatch({ type: "SET_LIST_INDEX", index: Math.max(state.listIndex - 1, 0) }) + return + } + + // ? — help + if (input === "?") { + const helpItem = SIDEBAR_ITEMS.find((i) => i.screen === "help") + if (helpItem) { + dispatch({ type: "SET_SCREEN", screen: "help" }) + } + return + } + + // Ctrl+d / Ctrl+u + if (key.ctrl && input === "d") { + pageDown(state, dispatch) + return + } + if (key.ctrl && input === "u") { + pageUp(state, dispatch) + return + } + + // : — enter command mode + if (input === ":") { + dispatch({ type: "SET_MODE", mode: "COMMAND" }) + dispatch({ type: "SET_FILTER_TEXT", value: "" }) + return + } + + // / — enter search mode + if (input === "/") { + dispatch({ type: "SET_MODE", mode: "SEARCH" }) + dispatch({ type: "SET_FILTER_TEXT", value: "" }) + return + } + }) + + return null +} + +// ── Helper functions ───────────────────────────────── + +function getMaxIndex(): number { + return 999999 // Will be clamped by screen +} + +function moveUp(focus: string, state: any, dispatch: any) { + if (focus === "sidebar") { + const newIdx = Math.max(0, state.sidebarIndex - 1) + dispatch({ type: "SET_SIDEBAR_INDEX", index: newIdx }) + } else { + dispatch({ type: "SET_LIST_INDEX", index: Math.max(0, state.listIndex - 1) }) + } +} + +function moveDown(focus: string, state: any, dispatch: any) { + if (focus === "sidebar") { + const newIdx = Math.min(SIDEBAR_ITEMS.length - 1, state.sidebarIndex + 1) + dispatch({ type: "SET_SIDEBAR_INDEX", index: newIdx }) + } else { + dispatch({ type: "SET_LIST_INDEX", index: state.listIndex + 1 }) + } +} + +function pageUp(state: any, dispatch: any) { + const jump = Math.max(1, Math.floor((process.stdout.rows ?? 24) / 2)) + dispatch({ type: "SET_LIST_INDEX", index: Math.max(0, state.listIndex - jump) }) +} + +function pageDown(state: any, dispatch: any) { + const jump = Math.max(1, Math.floor((process.stdout.rows ?? 24) / 2)) + dispatch({ type: "SET_LIST_INDEX", index: state.listIndex + jump }) +} + +function executeCommand( + cmd: string, + state: any, + dispatch: any, + exit: (error?: Error | unknown) => void, +) { + const trimmed = cmd.trim() + + if (trimmed === "q" || trimmed === "q!") { + exit() + return + } + + if (trimmed === "help") { + const helpItem = SIDEBAR_ITEMS.find((i) => i.screen === "help") + if (helpItem) dispatch({ type: "SET_SCREEN", screen: "help" }) + dispatch({ type: "SET_MODE", mode: "NORMAL" }) + return + } + + if (trimmed.startsWith("sort ")) { + const field = trimmed.slice(5).trim() + if (["name", "price", "currency", "cycle", "status"].includes(field)) { + // TODO: pass sort field to list screen + dispatch({ type: "SET_MODE", mode: "NORMAL" }) + return + } + } + + if (trimmed.startsWith("filter ")) { + const query = trimmed.slice(7).trim() + dispatch({ type: "SET_FILTER_TEXT", value: query }) + dispatch({ type: "SET_MODE", mode: "NORMAL" }) + return + } + + // Unknown command — just return to normal mode + dispatch({ type: "SET_MODE", mode: "NORMAL" }) +} + +// ── Sidebar row mapping (for mouse click position) ──── +// +// The sidebar renders as: +// Row 0: top border +// Row 1: margin before "📋 Data" +// Row 2: "📋 Data" header text +// Rows 3‑7: Data items (List, Find, Add, Edit, Delete) +// Row 8: margin before "🏷️ Tags" +// Row 9: "🏷️ Tags" header text +// Rows 10‑12: Tag items (Tags, Trials, Bulk) +// Row 13: margin before "📊 Reports" +// Row 14: "📊 Reports" header text +// Rows 15‑20: Report items (Summary‥Forecast) +// Row 21: margin before "⚙️ System" +// Row 22: "⚙️ System" header text +// Rows 23‑29: System items (Config‥Help) +// Row 30: bottom border +// +// Map sidebar‑internal row → SIDEBAR_ITEMS[] index. + +const SIDEBAR_ROW_TO_INDEX: Record = { + 3: 0, 4: 1, 5: 2, 6: 3, 7: 4, // Data + 10: 5, 11: 6, 12: 7, // Tags + 15: 8, 16: 9, 17: 10, 18: 11, 19: 12, 20: 13, // Reports + 23: 14, 24: 15, 25: 16, 26: 17, 27: 18, 28: 19, 29: 20, // System +} + +// ── Mouse handler component ───────────────────────── + +/** Approximate row within the content area where data rows begin. + * Row 0 = status bar + * Row 1 = flex row (content box border top) + * Row 2 = content top padding + * Row 3 = ListScreen title "Subscriptions (N total)" + * Row 4 = column header row + * Row 5 = separator row ("─────") + * Row 6 = first data row + */ +const DATA_ROW_OFFSET = 6 + +function MouseHandler() { + const { state, dispatch } = useTui() + const mouse = useMouse() + + // Form screens use @inkjs/ui components — skip global mouse handling + const isFormScreen = + state.screen === "add" || + state.screen === "edit" || + state.screen === "delete" || + state.screen === "restore" || + state.screen === "tag-manage" + + useEffect(() => { + if (!mouse) return + if (!mouse.pressed || mouse.button !== 0) return + if (isFormScreen) return + + const { x, y } = mouse + + // Sidebar click if column ≤ 24 (sidebar width 22 + 2 border) + if (x <= 24) { + const sidebarRow = y - 1 // convert to 0‑based inside the flex row + const itemIndex = SIDEBAR_ROW_TO_INDEX[sidebarRow] + if (itemIndex !== undefined) { + dispatch({ type: "SET_SIDEBAR_INDEX", index: itemIndex }) + dispatch({ type: "SET_SCREEN", screen: SIDEBAR_ITEMS[itemIndex].screen }) + dispatch({ type: "SET_LIST_INDEX", index: 0 }) + } + return + } + + // Content area: route based on current screen + if (state.screen === "list") { + const idx = y - DATA_ROW_OFFSET + if (idx >= 0) { + dispatch({ type: "SET_LIST_INDEX", index: idx }) + dispatch({ type: "SET_FOCUS", focus: "content" }) + } + } + }, [mouse, isFormScreen]) + + return null +} + +// ── Root App ───────────────────────────────────────── + +export function App() { + return ( + + + + ) +} + +function AppInner() { + const { state, dispatch } = useTui() + + return ( + + + + + {/* Status bar */} + + + {/* Main content: sidebar + screen */} + + + + + + + + {/* Command bar */} + + + ) +} diff --git a/subtrack/src/tui/components/command-bar.tsx b/subtrack/src/tui/components/command-bar.tsx new file mode 100644 index 0000000..6980e01 --- /dev/null +++ b/subtrack/src/tui/components/command-bar.tsx @@ -0,0 +1,80 @@ +import { Box, Text } from "ink" +import { useTui } from "../context/app-context.tsx" +import type { Screen } from "../types.ts" + +const HINT_TEXT: Record = { + list: "j↓ k↑ /filter :cmd ?help q:quit", + search: "j↓ k↑ Enter:open :cmd ?help q:quit", + add: "Tab:next Enter:save Esc:cancel", + edit: "Tab:next Enter:save Esc:cancel", + delete: "y:delete n:cancel", + tags: "j↓ k↑ Enter:filter", + "tag-manage": "j↓ k↑ Enter:manage", + trials: "j↓ k↑ Enter:detail a:add", + "trial-add": "Tab:next Enter:save Esc:cancel", + "trial-expiring": "j↓ k↑ Enter:detail", + bulk: "Tab:select Enter:execute", + summary: "q:quit", + payment: "q:quit", + upcoming: "j↓ k↑", + analytics: "q:quit", + compare: "q:quit", + forecast: "q:quit", + config: "j↓ k↑ e:edit", + usage: "j↓ k↑ d:delete", + export: "Enter:export", + import: "Enter:import", + backup: "Enter:backup", + restore: "j↓ k↑ Enter:restore", + help: "q:quit", +} + +export function CommandBar() { + const { state, dispatch } = useTui() + + if (state.confirmQuit) { + return ( + + + {" "}Quit subtrack TUI?{" "} + + + y + + + /N{" "} + + + ) + } + + if (state.mode === "COMMAND") { + return ( + + + :{state.filterText} + + + + ) + } + + if (state.mode === "SEARCH") { + return ( + + + /{state.filterText} + + + + ) + } + + return ( + + + {" "}{HINT_TEXT[state.screen]}{" "} + + + ) +} diff --git a/subtrack/src/tui/components/sidebar.tsx b/subtrack/src/tui/components/sidebar.tsx new file mode 100644 index 0000000..5de3521 --- /dev/null +++ b/subtrack/src/tui/components/sidebar.tsx @@ -0,0 +1,114 @@ +import { Box, Text } from "ink" +import { + SIDEBAR_ITEMS, + type SidebarItem, + type SidebarSection, +} from "../types.ts" +import { useTui } from "../context/app-context.tsx" + +const SECTION_HEADS: Record = { + data: "📋 Data", + "tags-trials-bulk": "🏷️ Tags", + reports: "📊 Reports", + system: "⚙️ System", +} + +type SectionGroup = { + section: SidebarSection + items: SidebarItem[] +} + +function groupItems(): SectionGroup[] { + const map = new Map() + for (const item of SIDEBAR_ITEMS) { + if (!map.has(item.section)) map.set(item.section, []) + map.get(item.section)!.push(item) + } + return Array.from(map.entries()).map(([section, items]) => ({ section, items })) +} + +/** + * Row descriptor for sidebar rendering. + * - "head": section header, not selectable + * - "item": sidebar item, selectable + * The `itemIndex` is the index into SIDEBAR_ITEMS[], -1 for headers. + */ +type Row = { kind: "head"; section: SidebarSection; itemIndex: -1 } | { kind: "item"; section: SidebarSection; item: SidebarItem; itemIndex: number } + +function buildRows(): Row[] { + const rows: Row[] = [] + let idx = 0 + for (const group of groupItems()) { + rows.push({ kind: "head", section: group.section, itemIndex: -1 }) + for (const item of group.items) { + rows.push({ kind: "item", section: group.section, item, itemIndex: idx }) + idx++ + } + } + return rows +} + +/** Screens that only exist as sub‑routes and are not directly reachable from the sidebar. */ +const PLACEHOLDER_SCREENS = new Set([ + "tag-manage", "trial-add", "trial-expiring", +]) + +export function Sidebar() { + const { state, dispatch } = useTui() + const isFocused = state.focus === "sidebar" + const rows = buildRows() + + // Current selected screen index + const currentIdx = SIDEBAR_ITEMS.findIndex((i) => i.screen === state.screen) + + return ( + + {rows.map((row) => { + if (row.kind === "head") { + return ( + + + {" "}{SECTION_HEADS[row.section]} + + + ) + } + + const { item, itemIndex } = row + const isCurrent = itemIndex === currentIdx + const isPlaceholder = PLACEHOLDER_SCREENS.has(item.screen) + + // Focus highlight: when sidebar is focused and this item is the "selected" one + const focusedHere = isFocused && itemIndex === state.sidebarIndex + + return ( + + {focusedHere ? ( + + {" "}{item.icon} {item.label.padEnd(14)}{" "} + + ) : isCurrent ? ( + + {" "}{item.icon} {item.label.padEnd(14)}{" "} + + ) : isPlaceholder ? ( + + {" "}{item.icon} {item.label.padEnd(14)}{" "} + + ) : ( + + {" "}{item.icon} {item.label.padEnd(14)}{" "} + + )} + + ) + })} + + ) +} diff --git a/subtrack/src/tui/components/status-bar.tsx b/subtrack/src/tui/components/status-bar.tsx new file mode 100644 index 0000000..23174bf --- /dev/null +++ b/subtrack/src/tui/components/status-bar.tsx @@ -0,0 +1,25 @@ +import { Box, Text } from "ink" +import { useTui } from "../context/app-context.tsx" +import { SCREEN_TITLES } from "../types.ts" + +export function StatusBar() { + const { state } = useTui() + const modeColor = state.mode === "NORMAL" ? "green" : state.mode === "COMMAND" ? "yellow" : "blue" + + return ( + + + subtrack + TUI + + + {SCREEN_TITLES[state.screen]} + + + + {" "}{state.mode}{" "} + + + + ) +} diff --git a/subtrack/src/tui/context/app-context.tsx b/subtrack/src/tui/context/app-context.tsx new file mode 100644 index 0000000..ea2d663 --- /dev/null +++ b/subtrack/src/tui/context/app-context.tsx @@ -0,0 +1,120 @@ +import { + createContext, + useContext, + useReducer, + useCallback, + type ReactNode, +} from "react" +import type { Screen, Mode, Focus } from "../types.ts" + +export type AppState = { + screen: Screen + mode: Mode + focus: Focus + sidebarIndex: number + listIndex: number + filterText: string + confirmQuit: boolean + editId: number | null + selectedSubId: number | null +} + +export type AppAction = + | { type: "SET_SCREEN"; screen: Screen } + | { type: "SET_MODE"; mode: Mode } + | { type: "SET_FOCUS"; focus: Focus } + | { type: "SET_SIDEBAR_INDEX"; index: number } + | { type: "SET_LIST_INDEX"; index: number } + | { type: "SET_FILTER_TEXT"; value: string } + | { type: "SET_CONFIRM_QUIT"; value: boolean } + | { type: "SET_EDIT_ID"; id: number | null } + | { type: "SET_SELECTED_SUB_ID"; id: number | null } + | { type: "TOGGLE_FOCUS" } + +const initialState: AppState = { + screen: "list", + mode: "NORMAL", + focus: "content", + sidebarIndex: 0, + listIndex: 0, + filterText: "", + confirmQuit: false, + editId: null, + selectedSubId: null, +} + +function appReducer(state: AppState, action: AppAction): AppState { + switch (action.type) { + case "SET_SCREEN": + return { ...state, screen: action.screen, mode: "NORMAL", focus: "content", confirmQuit: false } + case "SET_MODE": + return { ...state, mode: action.mode, confirmQuit: false } + case "SET_FOCUS": + return { ...state, focus: action.focus } + case "SET_SIDEBAR_INDEX": + return { ...state, sidebarIndex: action.index } + case "SET_LIST_INDEX": + return { ...state, listIndex: action.index } + case "SET_FILTER_TEXT": + return { ...state, filterText: action.value } + case "SET_CONFIRM_QUIT": + return { ...state, confirmQuit: action.value } + case "SET_EDIT_ID": + return { ...state, editId: action.id } + case "SET_SELECTED_SUB_ID": + return { ...state, selectedSubId: action.id } + case "TOGGLE_FOCUS": + return { ...state, focus: state.focus === "sidebar" ? "content" : "sidebar" } + default: + return state + } +} + +type AppContextValue = { + state: AppState + dispatch: React.Dispatch +} + +const AppContext = createContext(null) + +export function AppProvider({ children }: { children: ReactNode }) { + const [state, dispatch] = useReducer(appReducer, initialState) + + return ( + + {children} + + ) +} + +export function useTui(): AppContextValue { + const ctx = useContext(AppContext) + if (!ctx) throw new Error("useTui must be used within ") + return ctx +} + +// ── Convenience dispatchers ────────────────────────── + +export function useSetScreen() { + const { dispatch } = useTui() + return useCallback( + (screen: Screen) => dispatch({ type: "SET_SCREEN", screen }), + [dispatch], + ) +} + +export function useSetMode() { + const { dispatch } = useTui() + return useCallback( + (mode: Mode) => dispatch({ type: "SET_MODE", mode }), + [dispatch], + ) +} + +export function useSetFilterText() { + const { dispatch } = useTui() + return useCallback( + (value: string) => dispatch({ type: "SET_FILTER_TEXT", value }), + [dispatch], + ) +} diff --git a/subtrack/src/tui/hooks/use-mouse.ts b/subtrack/src/tui/hooks/use-mouse.ts new file mode 100644 index 0000000..188426f --- /dev/null +++ b/subtrack/src/tui/hooks/use-mouse.ts @@ -0,0 +1,71 @@ +import { useEffect, useRef, useState } from "react" + +export interface MouseState { + /** Column (1-based) */ + x: number + /** Row (1-based) */ + y: number + /** 0=left, 1=middle, 2=right, 3=release */ + button: number + /** True on press, false on release */ + pressed: boolean +} + +/** + * Enable SGR mouse tracking (mode 1006) and return click events. + * + * Must be called inside an Ink `` — relies on `process.stdin` + * already being in raw mode (set by Ink). + * + * Coordinates are 1‑based terminal rows/cols. + * Callers should heuristically map these to rendered components. + */ +export function useMouse(): MouseState | null { + const [click, setClick] = useState(null) + const buf = useRef("") + + useEffect(() => { + const stdin = process.stdin + if (!stdin || !stdin.isTTY) return + + // Enable SGR mouse mode + // 1000 = basic tracking, 1002 = button-event tracking, 1006 = SGR coords + const enable = "\x1b[?1000h\x1b[?1002h\x1b[?1006h" + stdin.write(enable) + + const onData = (chunk: Buffer) => { + buf.current += chunk.toString() + + // SGR mouse format: ESC [