on main help sign in
feat: added tui
commit0bd9aead3f5db9fa735ea7eb2ee2acda45214d6a authorrobcholz <84130577+robcholz@users.noreply.github.com> dateThu 5 Feb 2026, 22:14 (8 months ago) parente5be12d
6 files changed+1866−8
Cargo.lock+921−6 Cargo.toml+6 docs/tasks.md+21−2 docs/tui.md+28 src/bin/vibebox-tui.rs+215 src/tui.rs+675
Cargo.lock+921−6
@@ -2,6 +2,42 @@
22# It is not intended for manual editing.
33version = 4
44
5+[[package]]
6+name = "addr2line"
7+version = "0.25.1"
8+source = "registry+https://github.com/rust-lang/crates.io-index"
9+checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
10+dependencies = [
11+ "gimli",
12+]
13+
14+[[package]]
15+name = "adler2"
16+version = "2.0.1"
17+source = "registry+https://github.com/rust-lang/crates.io-index"
18+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
19+
20+[[package]]
21+name = "allocator-api2"
22+version = "0.2.21"
23+source = "registry+https://github.com/rust-lang/crates.io-index"
24+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
25+
26+[[package]]
27+name = "backtrace"
28+version = "0.3.76"
29+source = "registry+https://github.com/rust-lang/crates.io-index"
30+checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
31+dependencies = [
32+ "addr2line",
33+ "cfg-if",
34+ "libc",
35+ "miniz_oxide",
36+ "object",
37+ "rustc-demangle",
38+ "windows-link",
39+]
40+
541[[package]]
642name = "bitflags"
743version = "2.10.0"
@@ -23,12 +59,134 @@ version = "3.19.1"
2359source = "registry+https://github.com/rust-lang/crates.io-index"
2460checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
2561
62+[[package]]
63+name = "bytes"
64+version = "1.11.1"
65+source = "registry+https://github.com/rust-lang/crates.io-index"
66+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
67+
68+[[package]]
69+name = "cassowary"
70+version = "0.3.0"
71+source = "registry+https://github.com/rust-lang/crates.io-index"
72+checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
73+
74+[[package]]
75+name = "castaway"
76+version = "0.2.4"
77+source = "registry+https://github.com/rust-lang/crates.io-index"
78+checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
79+dependencies = [
80+ "rustversion",
81+]
82+
2683[[package]]
2784name = "cfg-if"
2885version = "1.0.4"
2986source = "registry+https://github.com/rust-lang/crates.io-index"
3087checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
3188
89+[[package]]
90+name = "color-eyre"
91+version = "0.6.5"
92+source = "registry+https://github.com/rust-lang/crates.io-index"
93+checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d"
94+dependencies = [
95+ "backtrace",
96+ "color-spantrace",
97+ "eyre",
98+ "indenter",
99+ "once_cell",
100+ "owo-colors",
101+ "tracing-error",
102+]
103+
104+[[package]]
105+name = "color-spantrace"
106+version = "0.3.0"
107+source = "registry+https://github.com/rust-lang/crates.io-index"
108+checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427"
109+dependencies = [
110+ "once_cell",
111+ "owo-colors",
112+ "tracing-core",
113+ "tracing-error",
114+]
115+
116+[[package]]
117+name = "compact_str"
118+version = "0.8.1"
119+source = "registry+https://github.com/rust-lang/crates.io-index"
120+checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
121+dependencies = [
122+ "castaway",
123+ "cfg-if",
124+ "itoa",
125+ "rustversion",
126+ "ryu",
127+ "static_assertions",
128+]
129+
130+[[package]]
131+name = "crossterm"
132+version = "0.28.1"
133+source = "registry+https://github.com/rust-lang/crates.io-index"
134+checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
135+dependencies = [
136+ "bitflags",
137+ "crossterm_winapi",
138+ "futures-core",
139+ "mio",
140+ "parking_lot",
141+ "rustix 0.38.44",
142+ "signal-hook",
143+ "signal-hook-mio",
144+ "winapi",
145+]
146+
147+[[package]]
148+name = "crossterm_winapi"
149+version = "0.9.1"
150+source = "registry+https://github.com/rust-lang/crates.io-index"
151+checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
152+dependencies = [
153+ "winapi",
154+]
155+
156+[[package]]
157+name = "darling"
158+version = "0.23.0"
159+source = "registry+https://github.com/rust-lang/crates.io-index"
160+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
161+dependencies = [
162+ "darling_core",
163+ "darling_macro",
164+]
165+
166+[[package]]
167+name = "darling_core"
168+version = "0.23.0"
169+source = "registry+https://github.com/rust-lang/crates.io-index"
170+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
171+dependencies = [
172+ "ident_case",
173+ "proc-macro2",
174+ "quote",
175+ "strsim",
176+ "syn",
177+]
178+
179+[[package]]
180+name = "darling_macro"
181+version = "0.23.0"
182+source = "registry+https://github.com/rust-lang/crates.io-index"
183+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
184+dependencies = [
185+ "darling_core",
186+ "quote",
187+ "syn",
188+]
189+
32190[[package]]
33191name = "deranged"
34192version = "0.5.5"
@@ -51,6 +209,12 @@ dependencies = [
51209 "objc2",
52210]
53211
212+[[package]]
213+name = "either"
214+version = "1.15.0"
215+source = "registry+https://github.com/rust-lang/crates.io-index"
216+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
217+
54218[[package]]
55219name = "equivalent"
56220version = "1.0.2"
@@ -64,7 +228,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
64228checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
65229dependencies = [
66230 "libc",
67− "windows-sys",
231+ "windows-sys 0.61.2",
232+]
233+
234+[[package]]
235+name = "eyre"
236+version = "0.6.12"
237+source = "registry+https://github.com/rust-lang/crates.io-index"
238+checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec"
239+dependencies = [
240+ "indenter",
241+ "once_cell",
68242]
69243
70244[[package]]
@@ -73,6 +247,101 @@ version = "2.3.0"
73247source = "registry+https://github.com/rust-lang/crates.io-index"
74248checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
75249
250+[[package]]
251+name = "foldhash"
252+version = "0.1.5"
253+source = "registry+https://github.com/rust-lang/crates.io-index"
254+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
255+
256+[[package]]
257+name = "futures"
258+version = "0.3.31"
259+source = "registry+https://github.com/rust-lang/crates.io-index"
260+checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
261+dependencies = [
262+ "futures-channel",
263+ "futures-core",
264+ "futures-executor",
265+ "futures-io",
266+ "futures-sink",
267+ "futures-task",
268+ "futures-util",
269+]
270+
271+[[package]]
272+name = "futures-channel"
273+version = "0.3.31"
274+source = "registry+https://github.com/rust-lang/crates.io-index"
275+checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
276+dependencies = [
277+ "futures-core",
278+ "futures-sink",
279+]
280+
281+[[package]]
282+name = "futures-core"
283+version = "0.3.31"
284+source = "registry+https://github.com/rust-lang/crates.io-index"
285+checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
286+
287+[[package]]
288+name = "futures-executor"
289+version = "0.3.31"
290+source = "registry+https://github.com/rust-lang/crates.io-index"
291+checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
292+dependencies = [
293+ "futures-core",
294+ "futures-task",
295+ "futures-util",
296+]
297+
298+[[package]]
299+name = "futures-io"
300+version = "0.3.31"
301+source = "registry+https://github.com/rust-lang/crates.io-index"
302+checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
303+
304+[[package]]
305+name = "futures-macro"
306+version = "0.3.31"
307+source = "registry+https://github.com/rust-lang/crates.io-index"
308+checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
309+dependencies = [
310+ "proc-macro2",
311+ "quote",
312+ "syn",
313+]
314+
315+[[package]]
316+name = "futures-sink"
317+version = "0.3.31"
318+source = "registry+https://github.com/rust-lang/crates.io-index"
319+checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
320+
321+[[package]]
322+name = "futures-task"
323+version = "0.3.31"
324+source = "registry+https://github.com/rust-lang/crates.io-index"
325+checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
326+
327+[[package]]
328+name = "futures-util"
329+version = "0.3.31"
330+source = "registry+https://github.com/rust-lang/crates.io-index"
331+checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
332+dependencies = [
333+ "futures-channel",
334+ "futures-core",
335+ "futures-io",
336+ "futures-macro",
337+ "futures-sink",
338+ "futures-task",
339+ "memchr",
340+ "pin-project-lite",
341+ "pin-utils",
342+ "slab",
343+]
344+
76345[[package]]
77346name = "getrandom"
78347version = "0.3.4"
@@ -85,12 +354,47 @@ dependencies = [
85354 "wasip2",
86355]
87356
357+[[package]]
358+name = "gimli"
359+version = "0.32.3"
360+source = "registry+https://github.com/rust-lang/crates.io-index"
361+checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
362+
363+[[package]]
364+name = "hashbrown"
365+version = "0.15.5"
366+source = "registry+https://github.com/rust-lang/crates.io-index"
367+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
368+dependencies = [
369+ "allocator-api2",
370+ "equivalent",
371+ "foldhash",
372+]
373+
88374[[package]]
89375name = "hashbrown"
90376version = "0.16.1"
91377source = "registry+https://github.com/rust-lang/crates.io-index"
92378checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
93379
380+[[package]]
381+name = "heck"
382+version = "0.5.0"
383+source = "registry+https://github.com/rust-lang/crates.io-index"
384+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
385+
386+[[package]]
387+name = "ident_case"
388+version = "1.0.1"
389+source = "registry+https://github.com/rust-lang/crates.io-index"
390+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
391+
392+[[package]]
393+name = "indenter"
394+version = "0.3.4"
395+source = "registry+https://github.com/rust-lang/crates.io-index"
396+checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5"
397+
94398[[package]]
95399name = "indexmap"
96400version = "2.13.0"
@@ -98,7 +402,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
98402checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
99403dependencies = [
100404 "equivalent",
101− "hashbrown",
405+ "hashbrown 0.16.1",
406+]
407+
408+[[package]]
409+name = "indoc"
410+version = "2.0.7"
411+source = "registry+https://github.com/rust-lang/crates.io-index"
412+checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
413+dependencies = [
414+ "rustversion",
415+]
416+
417+[[package]]
418+name = "instability"
419+version = "0.3.11"
420+source = "registry+https://github.com/rust-lang/crates.io-index"
421+checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d"
422+dependencies = [
423+ "darling",
424+ "indoc",
425+ "proc-macro2",
426+ "quote",
427+ "syn",
428+]
429+
430+[[package]]
431+name = "itertools"
432+version = "0.13.0"
433+source = "registry+https://github.com/rust-lang/crates.io-index"
434+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
435+dependencies = [
436+ "either",
102437]
103438
104439[[package]]
@@ -117,6 +452,12 @@ dependencies = [
117452 "wasm-bindgen",
118453]
119454
455+[[package]]
456+name = "lazy_static"
457+version = "1.5.0"
458+source = "registry+https://github.com/rust-lang/crates.io-index"
459+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
460+
120461[[package]]
121462name = "lexopt"
122463version = "0.3.1"
@@ -129,12 +470,69 @@ version = "0.2.180"
129470source = "registry+https://github.com/rust-lang/crates.io-index"
130471checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
131472
473+[[package]]
474+name = "linux-raw-sys"
475+version = "0.4.15"
476+source = "registry+https://github.com/rust-lang/crates.io-index"
477+checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
478+
132479[[package]]
133480name = "linux-raw-sys"
134481version = "0.11.0"
135482source = "registry+https://github.com/rust-lang/crates.io-index"
136483checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
137484
485+[[package]]
486+name = "lock_api"
487+version = "0.4.14"
488+source = "registry+https://github.com/rust-lang/crates.io-index"
489+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
490+dependencies = [
491+ "scopeguard",
492+]
493+
494+[[package]]
495+name = "log"
496+version = "0.4.29"
497+source = "registry+https://github.com/rust-lang/crates.io-index"
498+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
499+
500+[[package]]
501+name = "lru"
502+version = "0.12.5"
503+source = "registry+https://github.com/rust-lang/crates.io-index"
504+checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
505+dependencies = [
506+ "hashbrown 0.15.5",
507+]
508+
509+[[package]]
510+name = "memchr"
511+version = "2.7.6"
512+source = "registry+https://github.com/rust-lang/crates.io-index"
513+checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
514+
515+[[package]]
516+name = "miniz_oxide"
517+version = "0.8.9"
518+source = "registry+https://github.com/rust-lang/crates.io-index"
519+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
520+dependencies = [
521+ "adler2",
522+]
523+
524+[[package]]
525+name = "mio"
526+version = "1.1.1"
527+source = "registry+https://github.com/rust-lang/crates.io-index"
528+checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
529+dependencies = [
530+ "libc",
531+ "log",
532+ "wasi",
533+ "windows-sys 0.61.2",
534+]
535+
138536[[package]]
139537name = "num-conv"
140538version = "0.2.0"
@@ -206,12 +604,68 @@ dependencies = [
206604 "objc2-foundation",
207605]
208606
607+[[package]]
608+name = "object"
609+version = "0.37.3"
610+source = "registry+https://github.com/rust-lang/crates.io-index"
611+checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
612+dependencies = [
613+ "memchr",
614+]
615+
209616[[package]]
210617name = "once_cell"
211618version = "1.21.3"
212619source = "registry+https://github.com/rust-lang/crates.io-index"
213620checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
214621
622+[[package]]
623+name = "owo-colors"
624+version = "4.2.3"
625+source = "registry+https://github.com/rust-lang/crates.io-index"
626+checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52"
627+
628+[[package]]
629+name = "parking_lot"
630+version = "0.12.5"
631+source = "registry+https://github.com/rust-lang/crates.io-index"
632+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
633+dependencies = [
634+ "lock_api",
635+ "parking_lot_core",
636+]
637+
638+[[package]]
639+name = "parking_lot_core"
640+version = "0.9.12"
641+source = "registry+https://github.com/rust-lang/crates.io-index"
642+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
643+dependencies = [
644+ "cfg-if",
645+ "libc",
646+ "redox_syscall",
647+ "smallvec",
648+ "windows-link",
649+]
650+
651+[[package]]
652+name = "paste"
653+version = "1.0.15"
654+source = "registry+https://github.com/rust-lang/crates.io-index"
655+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
656+
657+[[package]]
658+name = "pin-project-lite"
659+version = "0.2.16"
660+source = "registry+https://github.com/rust-lang/crates.io-index"
661+checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
662+
663+[[package]]
664+name = "pin-utils"
665+version = "0.1.0"
666+source = "registry+https://github.com/rust-lang/crates.io-index"
667+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
668+
215669[[package]]
216670name = "powerfmt"
217671version = "0.2.0"
@@ -242,6 +696,55 @@ version = "5.3.0"
242696source = "registry+https://github.com/rust-lang/crates.io-index"
243697checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
244698
699+[[package]]
700+name = "ratatui"
701+version = "0.29.0"
702+source = "registry+https://github.com/rust-lang/crates.io-index"
703+checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
704+dependencies = [
705+ "bitflags",
706+ "cassowary",
707+ "compact_str",
708+ "crossterm",
709+ "indoc",
710+ "instability",
711+ "itertools",
712+ "lru",
713+ "paste",
714+ "strum",
715+ "unicode-segmentation",
716+ "unicode-truncate",
717+ "unicode-width 0.2.0",
718+]
719+
720+[[package]]
721+name = "redox_syscall"
722+version = "0.5.18"
723+source = "registry+https://github.com/rust-lang/crates.io-index"
724+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
725+dependencies = [
726+ "bitflags",
727+]
728+
729+[[package]]
730+name = "rustc-demangle"
731+version = "0.1.27"
732+source = "registry+https://github.com/rust-lang/crates.io-index"
733+checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
734+
735+[[package]]
736+name = "rustix"
737+version = "0.38.44"
738+source = "registry+https://github.com/rust-lang/crates.io-index"
739+checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
740+dependencies = [
741+ "bitflags",
742+ "errno",
743+ "libc",
744+ "linux-raw-sys 0.4.15",
745+ "windows-sys 0.59.0",
746+]
747+
245748[[package]]
246749name = "rustix"
247750version = "1.1.3"
@@ -251,8 +754,8 @@ dependencies = [
251754 "bitflags",
252755 "errno",
253756 "libc",
254− "linux-raw-sys",
255− "windows-sys",
757+ "linux-raw-sys 0.11.0",
758+ "windows-sys 0.61.2",
256759]
257760
258761[[package]]
@@ -261,6 +764,18 @@ version = "1.0.22"
261764source = "registry+https://github.com/rust-lang/crates.io-index"
262765checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
263766
767+[[package]]
768+name = "ryu"
769+version = "1.0.22"
770+source = "registry+https://github.com/rust-lang/crates.io-index"
771+checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984"
772+
773+[[package]]
774+name = "scopeguard"
775+version = "1.2.0"
776+source = "registry+https://github.com/rust-lang/crates.io-index"
777+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
778+
264779[[package]]
265780name = "serde"
266781version = "1.0.228"
@@ -300,6 +815,102 @@ dependencies = [
300815 "serde_core",
301816]
302817
818+[[package]]
819+name = "sharded-slab"
820+version = "0.1.7"
821+source = "registry+https://github.com/rust-lang/crates.io-index"
822+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
823+dependencies = [
824+ "lazy_static",
825+]
826+
827+[[package]]
828+name = "signal-hook"
829+version = "0.3.18"
830+source = "registry+https://github.com/rust-lang/crates.io-index"
831+checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
832+dependencies = [
833+ "libc",
834+ "signal-hook-registry",
835+]
836+
837+[[package]]
838+name = "signal-hook-mio"
839+version = "0.2.5"
840+source = "registry+https://github.com/rust-lang/crates.io-index"
841+checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
842+dependencies = [
843+ "libc",
844+ "mio",
845+ "signal-hook",
846+]
847+
848+[[package]]
849+name = "signal-hook-registry"
850+version = "1.4.8"
851+source = "registry+https://github.com/rust-lang/crates.io-index"
852+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
853+dependencies = [
854+ "errno",
855+ "libc",
856+]
857+
858+[[package]]
859+name = "slab"
860+version = "0.4.12"
861+source = "registry+https://github.com/rust-lang/crates.io-index"
862+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
863+
864+[[package]]
865+name = "smallvec"
866+version = "1.15.1"
867+source = "registry+https://github.com/rust-lang/crates.io-index"
868+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
869+
870+[[package]]
871+name = "socket2"
872+version = "0.6.2"
873+source = "registry+https://github.com/rust-lang/crates.io-index"
874+checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
875+dependencies = [
876+ "libc",
877+ "windows-sys 0.60.2",
878+]
879+
880+[[package]]
881+name = "static_assertions"
882+version = "1.1.0"
883+source = "registry+https://github.com/rust-lang/crates.io-index"
884+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
885+
886+[[package]]
887+name = "strsim"
888+version = "0.11.1"
889+source = "registry+https://github.com/rust-lang/crates.io-index"
890+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
891+
892+[[package]]
893+name = "strum"
894+version = "0.26.3"
895+source = "registry+https://github.com/rust-lang/crates.io-index"
896+checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
897+dependencies = [
898+ "strum_macros",
899+]
900+
901+[[package]]
902+name = "strum_macros"
903+version = "0.26.4"
904+source = "registry+https://github.com/rust-lang/crates.io-index"
905+checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
906+dependencies = [
907+ "heck",
908+ "proc-macro2",
909+ "quote",
910+ "rustversion",
911+ "syn",
912+]
913+
303914[[package]]
304915name = "syn"
305916version = "2.0.114"
@@ -320,8 +931,8 @@ dependencies = [
320931 "fastrand",
321932 "getrandom",
322933 "once_cell",
323− "rustix",
324− "windows-sys",
934+ "rustix 1.1.3",
935+ "windows-sys 0.61.2",
325936]
326937
327938[[package]]
@@ -344,6 +955,15 @@ dependencies = [
344955 "syn",
345956]
346957
958+[[package]]
959+name = "thread_local"
960+version = "1.1.9"
961+source = "registry+https://github.com/rust-lang/crates.io-index"
962+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
963+dependencies = [
964+ "cfg-if",
965+]
966+
347967[[package]]
348968name = "time"
349969version = "0.3.47"
@@ -375,6 +995,34 @@ dependencies = [
375995 "time-core",
376996]
377997
998+[[package]]
999+name = "tokio"
1000+version = "1.49.0"
1001+source = "registry+https://github.com/rust-lang/crates.io-index"
1002+checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
1003+dependencies = [
1004+ "bytes",
1005+ "libc",
1006+ "mio",
1007+ "parking_lot",
1008+ "pin-project-lite",
1009+ "signal-hook-registry",
1010+ "socket2",
1011+ "tokio-macros",
1012+ "windows-sys 0.61.2",
1013+]
1014+
1015+[[package]]
1016+name = "tokio-macros"
1017+version = "2.6.0"
1018+source = "registry+https://github.com/rust-lang/crates.io-index"
1019+checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
1020+dependencies = [
1021+ "proc-macro2",
1022+ "quote",
1023+ "syn",
1024+]
1025+
3781026[[package]]
3791027name = "toml"
3801028version = "0.9.11+spec-1.1.0"
@@ -414,12 +1062,92 @@ version = "1.0.6+spec-1.1.0"
4141062source = "registry+https://github.com/rust-lang/crates.io-index"
4151063checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
4161064
1065+[[package]]
1066+name = "tracing"
1067+version = "0.1.44"
1068+source = "registry+https://github.com/rust-lang/crates.io-index"
1069+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
1070+dependencies = [
1071+ "pin-project-lite",
1072+ "tracing-core",
1073+]
1074+
1075+[[package]]
1076+name = "tracing-core"
1077+version = "0.1.36"
1078+source = "registry+https://github.com/rust-lang/crates.io-index"
1079+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
1080+dependencies = [
1081+ "once_cell",
1082+ "valuable",
1083+]
1084+
1085+[[package]]
1086+name = "tracing-error"
1087+version = "0.2.1"
1088+source = "registry+https://github.com/rust-lang/crates.io-index"
1089+checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db"
1090+dependencies = [
1091+ "tracing",
1092+ "tracing-subscriber",
1093+]
1094+
1095+[[package]]
1096+name = "tracing-subscriber"
1097+version = "0.3.22"
1098+source = "registry+https://github.com/rust-lang/crates.io-index"
1099+checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
1100+dependencies = [
1101+ "sharded-slab",
1102+ "thread_local",
1103+ "tracing-core",
1104+]
1105+
1106+[[package]]
1107+name = "tui-textarea"
1108+version = "0.4.0"
1109+source = "registry+https://github.com/rust-lang/crates.io-index"
1110+checksum = "a3e38ced1f941a9cfc923fbf2fe6858443c42cc5220bfd35bdd3648371e7bd8e"
1111+dependencies = [
1112+ "ratatui",
1113+ "unicode-width 0.1.14",
1114+]
1115+
4171116[[package]]
4181117name = "unicode-ident"
4191118version = "1.0.22"
4201119source = "registry+https://github.com/rust-lang/crates.io-index"
4211120checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
4221121
1122+[[package]]
1123+name = "unicode-segmentation"
1124+version = "1.12.0"
1125+source = "registry+https://github.com/rust-lang/crates.io-index"
1126+checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
1127+
1128+[[package]]
1129+name = "unicode-truncate"
1130+version = "1.1.0"
1131+source = "registry+https://github.com/rust-lang/crates.io-index"
1132+checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
1133+dependencies = [
1134+ "itertools",
1135+ "unicode-segmentation",
1136+ "unicode-width 0.1.14",
1137+]
1138+
1139+[[package]]
1140+name = "unicode-width"
1141+version = "0.1.14"
1142+source = "registry+https://github.com/rust-lang/crates.io-index"
1143+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
1144+
1145+[[package]]
1146+name = "unicode-width"
1147+version = "0.2.0"
1148+source = "registry+https://github.com/rust-lang/crates.io-index"
1149+checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
1150+
4231151[[package]]
4241152name = "uuid"
4251153version = "1.20.0"
@@ -432,25 +1160,43 @@ dependencies = [
4321160 "wasm-bindgen",
4331161]
4341162
1163+[[package]]
1164+name = "valuable"
1165+version = "0.1.1"
1166+source = "registry+https://github.com/rust-lang/crates.io-index"
1167+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
1168+
4351169[[package]]
4361170name = "vibebox"
4371171version = "0.1.0"
4381172dependencies = [
4391173 "block2",
1174+ "color-eyre",
1175+ "crossterm",
4401176 "dispatch2",
1177+ "futures",
4411178 "lexopt",
4421179 "libc",
4431180 "objc2",
4441181 "objc2-foundation",
4451182 "objc2-virtualization",
1183+ "ratatui",
4461184 "serde",
4471185 "tempfile",
4481186 "thiserror",
4491187 "time",
1188+ "tokio",
4501189 "toml",
1190+ "tui-textarea",
4511191 "uuid",
4521192]
4531193
1194+[[package]]
1195+name = "wasi"
1196+version = "0.11.1+wasi-snapshot-preview1"
1197+source = "registry+https://github.com/rust-lang/crates.io-index"
1198+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
1199+
4541200[[package]]
4551201name = "wasip2"
4561202version = "1.0.2+wasi-0.2.9"
@@ -505,12 +1251,52 @@ dependencies = [
5051251 "unicode-ident",
5061252]
5071253
1254+[[package]]
1255+name = "winapi"
1256+version = "0.3.9"
1257+source = "registry+https://github.com/rust-lang/crates.io-index"
1258+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
1259+dependencies = [
1260+ "winapi-i686-pc-windows-gnu",
1261+ "winapi-x86_64-pc-windows-gnu",
1262+]
1263+
1264+[[package]]
1265+name = "winapi-i686-pc-windows-gnu"
1266+version = "0.4.0"
1267+source = "registry+https://github.com/rust-lang/crates.io-index"
1268+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
1269+
1270+[[package]]
1271+name = "winapi-x86_64-pc-windows-gnu"
1272+version = "0.4.0"
1273+source = "registry+https://github.com/rust-lang/crates.io-index"
1274+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
1275+
5081276[[package]]
5091277name = "windows-link"
5101278version = "0.2.1"
5111279source = "registry+https://github.com/rust-lang/crates.io-index"
5121280checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
5131281
1282+[[package]]
1283+name = "windows-sys"
1284+version = "0.59.0"
1285+source = "registry+https://github.com/rust-lang/crates.io-index"
1286+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
1287+dependencies = [
1288+ "windows-targets 0.52.6",
1289+]
1290+
1291+[[package]]
1292+name = "windows-sys"
1293+version = "0.60.2"
1294+source = "registry+https://github.com/rust-lang/crates.io-index"
1295+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
1296+dependencies = [
1297+ "windows-targets 0.53.5",
1298+]
1299+
5141300[[package]]
5151301name = "windows-sys"
5161302version = "0.61.2"
@@ -520,6 +1306,135 @@ dependencies = [
5201306 "windows-link",
5211307]
5221308
1309+[[package]]
1310+name = "windows-targets"
1311+version = "0.52.6"
1312+source = "registry+https://github.com/rust-lang/crates.io-index"
1313+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
1314+dependencies = [
1315+ "windows_aarch64_gnullvm 0.52.6",
1316+ "windows_aarch64_msvc 0.52.6",
1317+ "windows_i686_gnu 0.52.6",
1318+ "windows_i686_gnullvm 0.52.6",
1319+ "windows_i686_msvc 0.52.6",
1320+ "windows_x86_64_gnu 0.52.6",
1321+ "windows_x86_64_gnullvm 0.52.6",
1322+ "windows_x86_64_msvc 0.52.6",
1323+]
1324+
1325+[[package]]
1326+name = "windows-targets"
1327+version = "0.53.5"
1328+source = "registry+https://github.com/rust-lang/crates.io-index"
1329+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
1330+dependencies = [
1331+ "windows-link",
1332+ "windows_aarch64_gnullvm 0.53.1",
1333+ "windows_aarch64_msvc 0.53.1",
1334+ "windows_i686_gnu 0.53.1",
1335+ "windows_i686_gnullvm 0.53.1",
1336+ "windows_i686_msvc 0.53.1",
1337+ "windows_x86_64_gnu 0.53.1",
1338+ "windows_x86_64_gnullvm 0.53.1",
1339+ "windows_x86_64_msvc 0.53.1",
1340+]
1341+
1342+[[package]]
1343+name = "windows_aarch64_gnullvm"
1344+version = "0.52.6"
1345+source = "registry+https://github.com/rust-lang/crates.io-index"
1346+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
1347+
1348+[[package]]
1349+name = "windows_aarch64_gnullvm"
1350+version = "0.53.1"
1351+source = "registry+https://github.com/rust-lang/crates.io-index"
1352+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
1353+
1354+[[package]]
1355+name = "windows_aarch64_msvc"
1356+version = "0.52.6"
1357+source = "registry+https://github.com/rust-lang/crates.io-index"
1358+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
1359+
1360+[[package]]
1361+name = "windows_aarch64_msvc"
1362+version = "0.53.1"
1363+source = "registry+https://github.com/rust-lang/crates.io-index"
1364+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
1365+
1366+[[package]]
1367+name = "windows_i686_gnu"
1368+version = "0.52.6"
1369+source = "registry+https://github.com/rust-lang/crates.io-index"
1370+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
1371+
1372+[[package]]
1373+name = "windows_i686_gnu"
1374+version = "0.53.1"
1375+source = "registry+https://github.com/rust-lang/crates.io-index"
1376+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
1377+
1378+[[package]]
1379+name = "windows_i686_gnullvm"
1380+version = "0.52.6"
1381+source = "registry+https://github.com/rust-lang/crates.io-index"
1382+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
1383+
1384+[[package]]
1385+name = "windows_i686_gnullvm"
1386+version = "0.53.1"
1387+source = "registry+https://github.com/rust-lang/crates.io-index"
1388+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
1389+
1390+[[package]]
1391+name = "windows_i686_msvc"
1392+version = "0.52.6"
1393+source = "registry+https://github.com/rust-lang/crates.io-index"
1394+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
1395+
1396+[[package]]
1397+name = "windows_i686_msvc"
1398+version = "0.53.1"
1399+source = "registry+https://github.com/rust-lang/crates.io-index"
1400+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
1401+
1402+[[package]]
1403+name = "windows_x86_64_gnu"
1404+version = "0.52.6"
1405+source = "registry+https://github.com/rust-lang/crates.io-index"
1406+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
1407+
1408+[[package]]
1409+name = "windows_x86_64_gnu"
1410+version = "0.53.1"
1411+source = "registry+https://github.com/rust-lang/crates.io-index"
1412+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
1413+
1414+[[package]]
1415+name = "windows_x86_64_gnullvm"
1416+version = "0.52.6"
1417+source = "registry+https://github.com/rust-lang/crates.io-index"
1418+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
1419+
1420+[[package]]
1421+name = "windows_x86_64_gnullvm"
1422+version = "0.53.1"
1423+source = "registry+https://github.com/rust-lang/crates.io-index"
1424+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
1425+
1426+[[package]]
1427+name = "windows_x86_64_msvc"
1428+version = "0.52.6"
1429+source = "registry+https://github.com/rust-lang/crates.io-index"
1430+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
1431+
1432+[[package]]
1433+name = "windows_x86_64_msvc"
1434+version = "0.53.1"
1435+source = "registry+https://github.com/rust-lang/crates.io-index"
1436+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
1437+
5231438[[package]]
5241439name = "winnow"
5251440version = "0.7.14"
Cargo.toml+6
@@ -27,3 +27,9 @@ thiserror = "2.0.18"
2727time = { version = "0.3", features = ["serde", "formatting", "parsing"] }
2828toml = "0.9.8"
2929uuid = { version = "1", features = ["v7", "serde"] }
30+color-eyre = "0.6.3"
31+crossterm = { version = "0.28.1", features = ["event-stream"] }
32+futures = "0.3.31"
33+ratatui = "0.29.0"
34+tokio = { version = "1.40.0", features = ["full"] }
35+tui-textarea = { version = "0.4", default-features = false, features = ["ratatui"] }
docs/tasks.md+21−2
@@ -1,5 +1,7 @@
11# Tasks
22
3+## SessionManager
4+
351. [x] Confirm requirements and scope from `implementations.md`.
462. [x] Define `SessionManager` responsibilities and public API (create, load, list, update, delete, bump last_active, refcount handling, cleanup orphaned index entries).
573. [x] Choose 3rd-party crates for UUIDv7, TOML persistence, and error handling (e.g., `uuid` with v7, `serde` + `toml`, `thiserror`).
@@ -8,5 +10,22 @@
8106. [x] Add tests for edge cases (missing index, invalid TOML, duplicate sessions, refcount transitions, cleanup on missing instance dir).
9117. [ ] Run tests and coverage; target >=80% line/branch coverage using a Rust coverage tool (e.g., `cargo llvm-cov`).
10128. [x] Refactor for clarity and reliability while keeping tests green.
11−9. [ ] Add TUI interface.
12−10. [ ] Integrate VM and SessionManager together.
13+
14+## TUI
15+
16+1. [x] Review `docs/tui.md` requirements and translate into concrete UI sections and state model.
17+2. [x] Add required dependencies for ratatui/crossterm/tokio/color-eyre/futures and pick a text input widget crate.
18+3. [x] Write unit tests for layout calculations (header/terminal/input/status/completions), completion state transitions, and CLI argument parsing.
19+4. [x] Implement TUI state model (header info, terminal history, input area, completion list, status bar visibility).
20+5. [x] Implement rendering functions for header, terminal area, input area, completions, and status bar.
21+6. [x] Implement async event loop (keyboard, resize, tick) with crossterm EventStream + tokio.
22+7. [x] Add a standalone TUI CLI binary (no main.rs wiring) with placeholder VM info and TODOs for VM integration.
23+8. [ ] Run tests and validate coverage for the new module.
24+
25+## TUI
26+
27+
28+
29+## Integration
30+
31+1. [ ] Integrate VM and SessionManager together.
docs/tui.mdnew file+28
@@ -0,0 +1,28 @@
1+# TUI
2+
3+The end-user ui for the vibebox
4+
5+## TUI header
6+
7+- This is a welcome header.
8+- Shows text: "Welcome to Vibebox vX.X.XX"
9+- Shows the ASCII banner
10+- An outlined box
11+ - Shows the current directory
12+ - Shows current vm version and max memory, cpu cores
13+- The position is flex, so this will move with the VM terminal history.
14+
15+## Terminal Area
16+
17+- Shows all the VM terminal history
18+
19+## Vibebox input area
20+
21+- A text input area, user can input text in it, it can vertically expand depending on the text length, by default it
22+ is a line high.
23+- it should be able to switch to auto-completion mode, which will display a list of available commands. When in this mode, the bottom
24+ status bar will disappear. The auto completions are displayed right below the text input area.
25+
26+## Bottom status bar
27+
28+- Display texts in gray, a line high. on the left it shows `:help` for help.
src/bin/vibebox-tui.rsnew file+215
@@ -0,0 +1,215 @@
1+use std::{env, ffi::OsString, path::PathBuf};
2+
3+use color_eyre::Result;
4+use lexopt::prelude::*;
5+
6+#[path = "../tui.rs"]
7+mod tui;
8+
9+use tui::{AppState, VmInfo};
10+
11+#[derive(Debug, Clone, PartialEq, Eq)]
12+struct TuiConfig {
13+ cwd: PathBuf,
14+ vm_version: String,
15+ max_memory_mb: u64,
16+ cpu_cores: usize,
17+}
18+
19+#[derive(Debug, Clone, PartialEq, Eq)]
20+enum TuiCommand {
21+ Run(TuiConfig),
22+ Help,
23+ Version,
24+}
25+
26+#[derive(Debug, thiserror::Error)]
27+enum CliError {
28+ #[error("{0}")]
29+ Message(String),
30+ #[error(transparent)]
31+ Lexopt(#[from] lexopt::Error),
32+ #[error(transparent)]
33+ Io(#[from] std::io::Error),
34+}
35+
36+#[tokio::main]
37+async fn main() -> Result<()> {
38+ color_eyre::install()?;
39+
40+ let command = parse_args(env::args_os())?;
41+ match command {
42+ TuiCommand::Help => {
43+ print_help();
44+ }
45+ TuiCommand::Version => {
46+ println!("vibebox-tui {}", env!("CARGO_PKG_VERSION"));
47+ }
48+ TuiCommand::Run(config) => {
49+ let vm_info = VmInfo {
50+ version: config.vm_version,
51+ max_memory_mb: config.max_memory_mb,
52+ cpu_cores: config.cpu_cores,
53+ };
54+ let mut app = AppState::new(config.cwd, vm_info);
55+ app.push_history("VM output will appear here.");
56+ app.push_history("TODO: wire VM IO into the TUI event loop.");
57+ tui::run_tui(app).await?;
58+ }
59+ }
60+
61+ Ok(())
62+}
63+
64+fn print_help() {
65+ println!(
66+ "vibebox-tui\n\nUsage:\n vibebox-tui [options]\n\nOptions:\n --help, -h Show this help\n --version Show version\n --cwd <path> Working directory for the session header\n --vm-version <ver> VM version string for the header\n --max-memory <mb> Max memory in MB (default 2048)\n --cpu-cores <count> CPU core count (default 2)\n"
67+ );
68+}
69+
70+fn parse_args<I>(args: I) -> Result<TuiCommand, CliError>
71+where
72+ I: IntoIterator<Item = OsString>,
73+{
74+ fn os_to_string(value: OsString, flag: &str) -> Result<String, CliError> {
75+ value
76+ .into_string()
77+ .map_err(|_| CliError::Message(format!("{flag} expects valid UTF-8")))
78+ }
79+
80+ let mut parser = lexopt::Parser::from_iter(args);
81+ let mut cwd: Option<PathBuf> = None;
82+ let mut vm_version = env!("CARGO_PKG_VERSION").to_string();
83+ let mut max_memory_mb: u64 = 2048;
84+ let mut cpu_cores: usize = 2;
85+
86+ while let Some(arg) = parser.next()? {
87+ match arg {
88+ Long("help") | Short('h') => return Ok(TuiCommand::Help),
89+ Long("version") => return Ok(TuiCommand::Version),
90+ Long("cwd") => {
91+ let value = os_to_string(parser.value()?, "--cwd")?;
92+ cwd = Some(PathBuf::from(value));
93+ }
94+ Long("vm-version") => {
95+ vm_version = os_to_string(parser.value()?, "--vm-version")?;
96+ }
97+ Long("max-memory") => {
98+ let value: u64 = os_to_string(parser.value()?, "--max-memory")?
99+ .parse()
100+ .map_err(|_| {
101+ CliError::Message("--max-memory expects an integer".to_string())
102+ })?;
103+ if value == 0 {
104+ return Err(CliError::Message("--max-memory must be >= 1".to_string()));
105+ }
106+ max_memory_mb = value;
107+ }
108+ Long("cpu-cores") => {
109+ let value: usize = os_to_string(parser.value()?, "--cpu-cores")?
110+ .parse()
111+ .map_err(|_| CliError::Message("--cpu-cores expects an integer".to_string()))?;
112+ if value == 0 {
113+ return Err(CliError::Message("--cpu-cores must be >= 1".to_string()));
114+ }
115+ cpu_cores = value;
116+ }
117+ Value(value) => {
118+ return Err(CliError::Message(format!(
119+ "unexpected argument: {}",
120+ value.to_string_lossy()
121+ )));
122+ }
123+ _ => return Err(CliError::Message(arg.unexpected().to_string())),
124+ }
125+ }
126+
127+ let cwd = match cwd {
128+ Some(dir) => dir,
129+ None => env::current_dir()?,
130+ };
131+
132+ Ok(TuiCommand::Run(TuiConfig {
133+ cwd,
134+ vm_version,
135+ max_memory_mb,
136+ cpu_cores,
137+ }))
138+}
139+
140+#[cfg(test)]
141+mod tests {
142+ use super::*;
143+
144+ fn parse_from(args: &[&str]) -> Result<TuiCommand, CliError> {
145+ let mut argv = vec![OsString::from("vibebox-tui")];
146+ argv.extend(args.iter().map(OsString::from));
147+ parse_args(argv)
148+ }
149+
150+ #[test]
151+ fn parse_help_short_circuit() {
152+ let command = parse_from(&["--help"]).unwrap();
153+ assert!(matches!(command, TuiCommand::Help));
154+ }
155+
156+ #[test]
157+ fn parse_version_short_circuit() {
158+ let command = parse_from(&["--version"]).unwrap();
159+ assert!(matches!(command, TuiCommand::Version));
160+ }
161+
162+ #[test]
163+ fn parse_defaults() {
164+ let command = parse_from(&[]).unwrap();
165+ let TuiCommand::Run(config) = command else {
166+ panic!("expected run command");
167+ };
168+
169+ assert_eq!(config.vm_version, env!("CARGO_PKG_VERSION"));
170+ assert_eq!(config.max_memory_mb, 2048);
171+ assert_eq!(config.cpu_cores, 2);
172+ }
173+
174+ #[test]
175+ fn parse_overrides() {
176+ let command = parse_from(&[
177+ "--cwd",
178+ "/tmp",
179+ "--vm-version",
180+ "13.1",
181+ "--max-memory",
182+ "4096",
183+ "--cpu-cores",
184+ "4",
185+ ])
186+ .unwrap();
187+
188+ let TuiCommand::Run(config) = command else {
189+ panic!("expected run command");
190+ };
191+
192+ assert_eq!(config.cwd, PathBuf::from("/tmp"));
193+ assert_eq!(config.vm_version, "13.1");
194+ assert_eq!(config.max_memory_mb, 4096);
195+ assert_eq!(config.cpu_cores, 4);
196+ }
197+
198+ #[test]
199+ fn parse_rejects_zero_cpu() {
200+ let err = parse_from(&["--cpu-cores", "0"]).unwrap_err();
201+ assert!(err.to_string().contains("cpu-cores"));
202+ }
203+
204+ #[test]
205+ fn parse_rejects_zero_memory() {
206+ let err = parse_from(&["--max-memory", "0"]).unwrap_err();
207+ assert!(err.to_string().contains("max-memory"));
208+ }
209+
210+ #[test]
211+ fn parse_rejects_unknown_argument() {
212+ let err = parse_from(&["--unknown"]).unwrap_err();
213+ assert!(!err.to_string().is_empty());
214+ }
215+}
src/tui.rsnew file+675
@@ -0,0 +1,675 @@
1+use std::{
2+ io::{self, Stdout},
3+ path::PathBuf,
4+ time::Duration,
5+};
6+
7+use color_eyre::Result;
8+use crossterm::{
9+ event::{
10+ DisableMouseCapture, EnableMouseCapture, Event as CrosstermEvent, EventStream, KeyCode,
11+ KeyEvent, KeyEventKind, KeyModifiers,
12+ },
13+ execute,
14+ terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
15+};
16+use futures::StreamExt;
17+use ratatui::{
18+ Frame, Terminal,
19+ backend::CrosstermBackend,
20+ layout::{Constraint, Direction, Layout, Rect},
21+ style::{Color, Style},
22+ text::{Line, Span, Text},
23+ widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
24+};
25+use tui_textarea::{Input, Key, TextArea};
26+
27+const ASCII_BANNER: [&str; 7] = [
28+ "░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░▒▓███████▓▒░░▒▓████████▓▒░",
29+ "░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░",
30+ " ░▒▓█▓▒▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░",
31+ " ░▒▓█▓▒▒▓█▓▒░░▒▓█▓▒░▒▓███████▓▒░░▒▓██████▓▒░",
32+ " ░▒▓█▓▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░",
33+ " ░▒▓█▓▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░",
34+ " ░▒▓██▓▒░ ░▒▓█▓▒░▒▓███████▓▒░░▒▓████████▓▒░",
35+];
36+
37+const STATUS_BAR_HEIGHT: u16 = 1;
38+const COMPLETIONS_MAX_HEIGHT: u16 = 6;
39+const SPINNER_FRAMES: [&str; 4] = ["|", "/", "-", "\\"];
40+
41+#[derive(Debug, Clone)]
42+pub struct VmInfo {
43+ pub version: String,
44+ pub max_memory_mb: u64,
45+ pub cpu_cores: usize,
46+}
47+
48+#[derive(Debug)]
49+pub struct AppState {
50+ pub cwd: PathBuf,
51+ pub vm_info: VmInfo,
52+ pub history: Vec<String>,
53+ pub input: TextArea<'static>,
54+ pub completions: CompletionState,
55+ pub should_quit: bool,
56+ key_input_mode: KeyInputMode,
57+ tick: u64,
58+ spinner: usize,
59+ terminal_scroll: usize,
60+}
61+
62+impl AppState {
63+ pub fn new(cwd: PathBuf, vm_info: VmInfo) -> Self {
64+ let mut input = TextArea::default();
65+ input.set_cursor_style(Style::default().fg(Color::Yellow));
66+ input.set_block(Block::default().borders(Borders::ALL).title("Input"));
67+
68+ Self {
69+ cwd,
70+ vm_info,
71+ history: Vec::new(),
72+ input,
73+ completions: CompletionState::default(),
74+ should_quit: false,
75+ key_input_mode: KeyInputMode::Unknown,
76+ tick: 0,
77+ spinner: 0,
78+ terminal_scroll: 0,
79+ }
80+ }
81+
82+ pub fn input_line_count(&self) -> u16 {
83+ self.input.lines().len().max(1) as u16
84+ }
85+
86+ pub fn input_height(&self) -> u16 {
87+ let mut height = self.input_line_count();
88+ if self.input.block().is_some() {
89+ height = height.saturating_add(2);
90+ }
91+ height.max(1)
92+ }
93+
94+ pub fn push_history(&mut self, line: impl Into<String>) {
95+ self.history.push(line.into());
96+ if self.history.len() > 2000 {
97+ let excess = self.history.len() - 2000;
98+ self.history.drain(0..excess);
99+ }
100+ }
101+
102+ pub fn activate_completions(&mut self, items: Vec<String>) {
103+ self.completions.set_items(items);
104+ self.completions.activate();
105+ }
106+
107+ pub fn deactivate_completions(&mut self) {
108+ self.completions.deactivate();
109+ }
110+
111+ pub fn toggle_completions(&mut self, items: Vec<String>) {
112+ if self.completions.active {
113+ self.deactivate_completions();
114+ } else {
115+ self.activate_completions(items);
116+ }
117+ }
118+}
119+
120+#[derive(Debug, Default, Clone)]
121+pub struct CompletionState {
122+ items: Vec<String>,
123+ selected: usize,
124+ active: bool,
125+}
126+
127+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128+enum KeyInputMode {
129+ Unknown,
130+ Press,
131+ Release,
132+}
133+
134+impl CompletionState {
135+ pub fn set_items(&mut self, items: Vec<String>) {
136+ self.items = items;
137+ self.selected = 0;
138+ }
139+
140+ pub fn activate(&mut self) {
141+ self.active = !self.items.is_empty();
142+ self.selected = 0;
143+ }
144+
145+ pub fn deactivate(&mut self) {
146+ self.active = false;
147+ }
148+
149+ pub fn next(&mut self) {
150+ if !self.active || self.items.is_empty() {
151+ return;
152+ }
153+ self.selected = (self.selected + 1) % self.items.len();
154+ }
155+
156+ pub fn previous(&mut self) {
157+ if !self.active || self.items.is_empty() {
158+ return;
159+ }
160+ if self.selected == 0 {
161+ self.selected = self.items.len() - 1;
162+ } else {
163+ self.selected -= 1;
164+ }
165+ }
166+
167+ pub fn current(&self) -> Option<&str> {
168+ if self.active {
169+ self.items.get(self.selected).map(|s| s.as_str())
170+ } else {
171+ None
172+ }
173+ }
174+
175+ pub fn items(&self) -> &[String] {
176+ &self.items
177+ }
178+
179+ pub fn is_active(&self) -> bool {
180+ self.active
181+ }
182+
183+ pub fn selected(&self) -> usize {
184+ self.selected
185+ }
186+}
187+
188+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
189+pub struct LayoutAreas {
190+ pub header: Rect,
191+ pub terminal: Rect,
192+ pub input: Rect,
193+ pub completions: Rect,
194+ pub status: Rect,
195+}
196+
197+pub fn compute_layout(
198+ area: Rect,
199+ input_height: u16,
200+ completion_items: usize,
201+ completion_active: bool,
202+) -> LayoutAreas {
203+ let header_height = header_height().min(area.height);
204+ let mut remaining = area.height.saturating_sub(header_height);
205+
206+ let input_height = input_height.max(1).min(remaining);
207+ remaining = remaining.saturating_sub(input_height);
208+
209+ let (completion_height, status_height) = if completion_active {
210+ let desired = (completion_items as u16).min(COMPLETIONS_MAX_HEIGHT);
211+ let height = desired.min(remaining);
212+ (height, 0)
213+ } else {
214+ let height = STATUS_BAR_HEIGHT.min(remaining);
215+ (0, height)
216+ };
217+
218+ remaining = remaining.saturating_sub(completion_height + status_height);
219+
220+ let terminal_height = remaining;
221+
222+ let chunks = Layout::default()
223+ .direction(Direction::Vertical)
224+ .constraints([
225+ Constraint::Length(header_height),
226+ Constraint::Length(terminal_height),
227+ Constraint::Length(input_height),
228+ Constraint::Length(completion_height),
229+ Constraint::Length(status_height),
230+ ])
231+ .split(area);
232+
233+ LayoutAreas {
234+ header: chunks[0],
235+ terminal: chunks[1],
236+ input: chunks[2],
237+ completions: chunks[3],
238+ status: chunks[4],
239+ }
240+}
241+
242+fn header_height() -> u16 {
243+ let banner_height = ASCII_BANNER.len() as u16;
244+ let welcome_height = 1;
245+ let info_height = 4;
246+ welcome_height + banner_height + info_height
247+}
248+
249+pub async fn run_tui(mut app: AppState) -> Result<()> {
250+ let mut terminal = TerminalGuard::init()?;
251+ let mut events = EventStream::new();
252+ let mut tick = tokio::time::interval(Duration::from_millis(250));
253+
254+ loop {
255+ terminal.draw(|frame| render(frame, &mut app))?;
256+
257+ tokio::select! {
258+ _ = tick.tick() => {
259+ app.tick = app.tick.wrapping_add(1);
260+ app.spinner = (app.spinner + 1) % SPINNER_FRAMES.len();
261+ },
262+ event = events.next() => {
263+ if let Some(event) = event {
264+ handle_event(event?, &mut app);
265+ }
266+ }
267+ }
268+
269+ if app.should_quit {
270+ break;
271+ }
272+ }
273+
274+ Ok(())
275+}
276+
277+fn handle_event(event: CrosstermEvent, app: &mut AppState) {
278+ match event {
279+ CrosstermEvent::Key(key) => handle_key_event(key, app),
280+ CrosstermEvent::Resize(_, _) => {}
281+ CrosstermEvent::Mouse(event) => handle_mouse_event(event, app),
282+ CrosstermEvent::FocusGained | CrosstermEvent::FocusLost => {}
283+ CrosstermEvent::Paste(text) => {
284+ app.input.insert_str(&text);
285+ }
286+ }
287+}
288+
289+fn handle_key_event(key: KeyEvent, app: &mut AppState) {
290+ if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
291+ app.should_quit = true;
292+ return;
293+ }
294+
295+ if app.completions.is_active() {
296+ match key.code {
297+ KeyCode::Esc => app.deactivate_completions(),
298+ KeyCode::Up => app.completions.previous(),
299+ KeyCode::Down => app.completions.next(),
300+ KeyCode::Enter => {
301+ if let Some(selection) = app.completions.current() {
302+ app.input.insert_str(selection);
303+ }
304+ app.deactivate_completions();
305+ }
306+ _ => {}
307+ }
308+ return;
309+ }
310+
311+ if !should_handle_key_event(app, &key) {
312+ return;
313+ }
314+
315+ match key.code {
316+ KeyCode::PageUp => {
317+ app.terminal_scroll = app.terminal_scroll.saturating_add(10);
318+ }
319+ KeyCode::PageDown => {
320+ app.terminal_scroll = app.terminal_scroll.saturating_sub(10);
321+ }
322+ KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
323+ app.terminal_scroll = app.terminal_scroll.saturating_add(1);
324+ }
325+ KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
326+ app.terminal_scroll = app.terminal_scroll.saturating_sub(1);
327+ }
328+ KeyCode::Tab => app.toggle_completions(default_completions()),
329+ _ => {
330+ app.input.input(input_from_key_event(key));
331+ }
332+ }
333+}
334+
335+fn handle_mouse_event(event: crossterm::event::MouseEvent, app: &mut AppState) {
336+ use crossterm::event::MouseEventKind;
337+ match event.kind {
338+ MouseEventKind::ScrollUp => {
339+ app.terminal_scroll = app.terminal_scroll.saturating_add(3);
340+ }
341+ MouseEventKind::ScrollDown => {
342+ app.terminal_scroll = app.terminal_scroll.saturating_sub(3);
343+ }
344+ _ => {}
345+ }
346+}
347+
348+fn should_handle_key_event(app: &mut AppState, key: &KeyEvent) -> bool {
349+ match key.kind {
350+ KeyEventKind::Press | KeyEventKind::Repeat => {
351+ app.key_input_mode = KeyInputMode::Press;
352+ return true;
353+ }
354+ KeyEventKind::Release => {}
355+ }
356+
357+ if app.key_input_mode == KeyInputMode::Press {
358+ return false;
359+ }
360+
361+ if key.code == KeyCode::Null {
362+ return false;
363+ }
364+
365+ if app.key_input_mode == KeyInputMode::Unknown {
366+ app.key_input_mode = KeyInputMode::Release;
367+ }
368+
369+ true
370+}
371+
372+fn input_from_key_event(key: KeyEvent) -> Input {
373+ let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
374+ let alt = key.modifiers.contains(KeyModifiers::ALT);
375+ let shift = key.modifiers.contains(KeyModifiers::SHIFT);
376+
377+ let key = match key.code {
378+ KeyCode::Char(c) => Key::Char(c),
379+ KeyCode::Enter => Key::Enter,
380+ KeyCode::Backspace => Key::Backspace,
381+ KeyCode::Left => Key::Left,
382+ KeyCode::Right => Key::Right,
383+ KeyCode::Up => Key::Up,
384+ KeyCode::Down => Key::Down,
385+ KeyCode::Tab | KeyCode::BackTab => Key::Tab,
386+ KeyCode::Delete => Key::Delete,
387+ KeyCode::Home => Key::Home,
388+ KeyCode::End => Key::End,
389+ KeyCode::PageUp => Key::PageUp,
390+ KeyCode::PageDown => Key::PageDown,
391+ KeyCode::Esc => Key::Esc,
392+ KeyCode::F(n) => Key::F(n),
393+ _ => Key::Null,
394+ };
395+
396+ Input {
397+ key,
398+ ctrl,
399+ alt,
400+ shift,
401+ }
402+}
403+
404+fn default_completions() -> Vec<String> {
405+ vec![":help".to_string(), ":new".to_string(), ":exit".to_string()]
406+}
407+
408+fn render(frame: &mut Frame<'_>, app: &mut AppState) {
409+ let area = frame.area();
410+ let layout = compute_layout(
411+ area,
412+ app.input_height(),
413+ app.completions.items().len(),
414+ app.completions.is_active(),
415+ );
416+
417+ render_header(frame, layout.header, app);
418+ render_terminal(frame, layout.terminal, app);
419+ render_input(frame, layout.input, app);
420+
421+ if app.completions.is_active() {
422+ render_completions(frame, layout.completions, app);
423+ } else {
424+ render_status(frame, layout.status, app);
425+ }
426+}
427+
428+fn render_header(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
429+ if area.height == 0 {
430+ return;
431+ }
432+
433+ let header_chunks = Layout::default()
434+ .direction(Direction::Vertical)
435+ .constraints([
436+ Constraint::Length(1),
437+ Constraint::Length(ASCII_BANNER.len() as u16),
438+ Constraint::Length(4),
439+ ])
440+ .split(area);
441+
442+ let welcome = Line::from(vec![
443+ Span::raw("Welcome to Vibebox v"),
444+ Span::styled(&app.vm_info.version, Style::default().fg(Color::Yellow)),
445+ ]);
446+
447+ frame.render_widget(Paragraph::new(welcome), header_chunks[0]);
448+
449+ let banner_lines = ASCII_BANNER.iter().map(|line| Line::from(*line));
450+ frame.render_widget(
451+ Paragraph::new(Text::from_iter(banner_lines)),
452+ header_chunks[1],
453+ );
454+
455+ let info_block = Block::default().borders(Borders::ALL).title("Session");
456+ let info_lines = vec![
457+ Line::from(vec![
458+ Span::raw("Directory: "),
459+ Span::styled(app.cwd.to_string_lossy(), Style::default().fg(Color::Cyan)),
460+ ]),
461+ Line::from(vec![
462+ Span::raw("VM Version: "),
463+ Span::styled(&app.vm_info.version, Style::default().fg(Color::Green)),
464+ ]),
465+ Line::from(vec![
466+ Span::raw("CPU / Memory: "),
467+ Span::styled(
468+ format!(
469+ "{} cores / {} MB",
470+ app.vm_info.cpu_cores, app.vm_info.max_memory_mb
471+ ),
472+ Style::default().fg(Color::Green),
473+ ),
474+ ]),
475+ ];
476+
477+ frame.render_widget(
478+ Paragraph::new(info_lines).block(info_block),
479+ header_chunks[2],
480+ );
481+}
482+
483+fn render_terminal(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
484+ let lines = app.history.iter().map(|line| Line::from(line.as_str()));
485+ let block = Block::default().borders(Borders::ALL).title("Terminal");
486+ let inner = block.inner(area);
487+ let inner_height = inner.height.max(1) as usize;
488+ let total_lines = app.history.len();
489+ let max_top = total_lines.saturating_sub(inner_height);
490+ let terminal_scroll = app.terminal_scroll.min(max_top);
491+ let scroll_top = max_top.saturating_sub(terminal_scroll);
492+ let paragraph = Paragraph::new(Text::from_iter(lines))
493+ .block(block)
494+ .wrap(Wrap { trim: false })
495+ .scroll((scroll_top.min(u16::MAX as usize) as u16, 0));
496+
497+ frame.render_widget(paragraph, area);
498+}
499+
500+fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) {
501+ frame.render_widget(app.input.widget(), area);
502+ if area.height > 0 && area.width > 0 {
503+ let cursor = app.input.cursor();
504+ let inner = match app.input.block() {
505+ Some(block) => block.inner(area),
506+ None => area,
507+ };
508+ let x = inner.x.saturating_add(cursor.1 as u16);
509+ let y = inner.y.saturating_add(cursor.0 as u16);
510+ if x < inner.x.saturating_add(inner.width) && y < inner.y.saturating_add(inner.height) {
511+ frame.set_cursor_position((x, y));
512+ }
513+ }
514+}
515+
516+fn render_completions(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) {
517+ if area.height == 0 {
518+ return;
519+ }
520+
521+ let items: Vec<ListItem<'_>> = app
522+ .completions
523+ .items()
524+ .iter()
525+ .map(|item| ListItem::new(Line::from(item.as_str())))
526+ .collect();
527+
528+ let list = List::new(items)
529+ .block(Block::default().borders(Borders::ALL).title("Completions"))
530+ .highlight_style(Style::default().fg(Color::Yellow));
531+
532+ let mut state = ListState::default();
533+ if app.completions.is_active() && !app.completions.items().is_empty() {
534+ state.select(Some(app.completions.selected()));
535+ }
536+
537+ frame.render_stateful_widget(list, area, &mut state);
538+}
539+
540+fn render_status(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
541+ if area.height == 0 {
542+ return;
543+ }
544+
545+ let spinner = SPINNER_FRAMES[app.spinner % SPINNER_FRAMES.len()];
546+ let status = Paragraph::new(Line::from(vec![
547+ Span::styled(":help", Style::default().fg(Color::DarkGray)),
548+ Span::raw(" "),
549+ Span::styled(
550+ format!("tick {} {}", app.tick, spinner),
551+ Style::default().fg(Color::DarkGray),
552+ ),
553+ ]));
554+
555+ frame.render_widget(status, area);
556+}
557+
558+struct TerminalGuard {
559+ terminal: Terminal<CrosstermBackend<Stdout>>,
560+}
561+
562+impl TerminalGuard {
563+ fn init() -> Result<Self> {
564+ enable_raw_mode()?;
565+ let mut stdout = io::stdout();
566+ execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
567+ let backend = CrosstermBackend::new(stdout);
568+ let terminal = Terminal::new(backend)?;
569+ Ok(Self { terminal })
570+ }
571+
572+ fn draw(&mut self, f: impl FnOnce(&mut Frame<'_>)) -> Result<()> {
573+ self.terminal.draw(f)?;
574+ Ok(())
575+ }
576+}
577+
578+impl Drop for TerminalGuard {
579+ fn drop(&mut self) {
580+ let _ = disable_raw_mode();
581+ let _ = execute!(
582+ self.terminal.backend_mut(),
583+ DisableMouseCapture,
584+ LeaveAlternateScreen
585+ );
586+ }
587+}
588+
589+#[cfg(test)]
590+mod tests {
591+ use super::*;
592+
593+ #[test]
594+ fn layout_without_completions_reserves_status_bar() {
595+ let area = Rect::new(0, 0, 80, 30);
596+ let layout = compute_layout(area, 3, 0, false);
597+
598+ assert_eq!(layout.header.height, header_height());
599+ assert_eq!(layout.status.height, STATUS_BAR_HEIGHT);
600+ assert_eq!(layout.completions.height, 0);
601+ assert!(layout.terminal.height > 0);
602+ }
603+
604+ #[test]
605+ fn layout_with_completions_hides_status_bar() {
606+ let area = Rect::new(0, 0, 80, 30);
607+ let layout = compute_layout(area, 4, 3, true);
608+
609+ assert_eq!(layout.status.height, 0);
610+ assert_eq!(layout.completions.height, 3);
611+ }
612+
613+ #[test]
614+ fn layout_clamps_when_space_is_tight() {
615+ let area = Rect::new(0, 0, 80, header_height() + 1);
616+ let layout = compute_layout(area, 3, 10, true);
617+
618+ assert_eq!(layout.header.height, header_height());
619+ assert_eq!(layout.input.height, 1);
620+ assert_eq!(layout.terminal.height, 0);
621+ }
622+
623+ #[test]
624+ fn completion_state_wraps_navigation() {
625+ let mut completions = CompletionState::default();
626+ completions.set_items(vec!["a".into(), "b".into(), "c".into()]);
627+ completions.activate();
628+
629+ assert_eq!(completions.current(), Some("a"));
630+
631+ completions.next();
632+ assert_eq!(completions.current(), Some("b"));
633+
634+ completions.next();
635+ completions.next();
636+ assert_eq!(completions.current(), Some("a"));
637+
638+ completions.previous();
639+ assert_eq!(completions.current(), Some("c"));
640+ }
641+
642+ #[test]
643+ fn completion_state_is_inactive_when_empty() {
644+ let mut completions = CompletionState::default();
645+ completions.activate();
646+
647+ assert!(!completions.is_active());
648+ assert_eq!(completions.current(), None);
649+ }
650+
651+ #[test]
652+ fn input_from_key_event_maps_char_and_modifiers() {
653+ let key = KeyEvent::new(
654+ KeyCode::Char('x'),
655+ KeyModifiers::CONTROL | KeyModifiers::ALT,
656+ );
657+ let input = input_from_key_event(key);
658+
659+ assert_eq!(input.key, Key::Char('x'));
660+ assert!(input.ctrl);
661+ assert!(input.alt);
662+ assert!(!input.shift);
663+ }
664+
665+ #[test]
666+ fn input_from_key_event_maps_special_keys() {
667+ let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
668+ let input = input_from_key_event(key);
669+
670+ assert_eq!(input.key, Key::Backspace);
671+ assert!(!input.ctrl);
672+ assert!(!input.alt);
673+ assert!(!input.shift);
674+ }
675+}