Skip to main content

max / goingson

Add got, a terminal cockpit over the local goingson.db Read-first TUI peer to the desktop app, mirroring its two-level pill-nav: Work / Time / Messages groups over Tasks, Projects, Events, Timer, and Contacts. Opens the same SQLite file as go-mcp does and reads through the normal repository layer. First consumer of alloy_tui. Loads its theme through makeover rather than a built-in palette, since alloy_tui ships none on purpose: a missing or malformed theme should be an error the user sees, not colors invented at the call site. Defaults to the app's own goingson skin; GOT_THEME and ~/.config/goingson/themes/ override.
Author: Max Johnson <me@maxj.phd> · 2026-07-19 20:04 UTC
Commit: df74aedf38bb95e32874635663ab946d3e01d98f
Parent: 2fd5c96
4 files changed, +804 insertions, -12 deletions
M Cargo.lock +279 -12
@@ -61,6 +61,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
61 61 checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
62 62
63 63 [[package]]
64 + name = "alloy_tui"
65 + version = "0.0.0"
66 + dependencies = [
67 + "makeover",
68 + "ratatui",
69 + ]
70 +
71 + [[package]]
64 72 name = "ammonia"
65 73 version = "4.1.3"
66 74 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -88,6 +96,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
88 96 checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
89 97
90 98 [[package]]
99 + name = "approx"
100 + version = "0.5.1"
101 + source = "registry+https://github.com/rust-lang/crates.io-index"
102 + checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
103 + dependencies = [
104 + "num-traits",
105 + ]
106 +
107 + [[package]]
91 108 name = "arbitrary"
92 109 version = "1.4.2"
93 110 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -318,6 +335,15 @@ dependencies = [
318 335 ]
319 336
320 337 [[package]]
338 + name = "atomic"
339 + version = "0.6.1"
340 + source = "registry+https://github.com/rust-lang/crates.io-index"
341 + checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340"
342 + dependencies = [
343 + "bytemuck",
344 + ]
345 +
346 + [[package]]
321 347 name = "atomic-waker"
322 348 version = "1.1.2"
323 349 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -401,15 +427,30 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
401 427
402 428 [[package]]
403 429 name = "bit-set"
430 + version = "0.5.3"
431 + source = "registry+https://github.com/rust-lang/crates.io-index"
432 + checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
433 + dependencies = [
434 + "bit-vec 0.6.3",
435 + ]
436 +
437 + [[package]]
438 + name = "bit-set"
404 439 version = "0.8.0"
405 440 source = "registry+https://github.com/rust-lang/crates.io-index"
406 441 checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
407 442 dependencies = [
408 - "bit-vec",
443 + "bit-vec 0.8.0",
409 444 ]
410 445
411 446 [[package]]
412 447 name = "bit-vec"
448 + version = "0.6.3"
449 + source = "registry+https://github.com/rust-lang/crates.io-index"
450 + checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
451 +
452 + [[package]]
453 + name = "bit-vec"
413 454 version = "0.8.0"
414 455 source = "registry+https://github.com/rust-lang/crates.io-index"
415 456 checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
@@ -422,9 +463,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
422 463
423 464 [[package]]
424 465 name = "bitflags"
425 - version = "2.11.0"
466 + version = "2.13.1"
426 467 source = "registry+https://github.com/rust-lang/crates.io-index"
427 - checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
468 + checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
428 469 dependencies = [
429 470 "serde_core",
430 471 ]
@@ -497,6 +538,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
497 538 checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
498 539
499 540 [[package]]
541 + name = "by_address"
542 + version = "1.2.1"
543 + source = "registry+https://github.com/rust-lang/crates.io-index"
544 + checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
545 +
546 + [[package]]
500 547 name = "bytemuck"
501 548 version = "1.25.0"
502 549 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -529,7 +576,7 @@ version = "0.18.5"
529 576 source = "registry+https://github.com/rust-lang/crates.io-index"
530 577 checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
531 578 dependencies = [
532 - "bitflags 2.11.0",
579 + "bitflags 2.13.1",
533 580 "cairo-sys-rs",
534 581 "glib",
535 582 "libc",
@@ -591,6 +638,15 @@ dependencies = [
591 638 ]
592 639
593 640 [[package]]
641 + name = "castaway"
642 + version = "0.2.4"
643 + source = "registry+https://github.com/rust-lang/crates.io-index"
644 + checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
645 + dependencies = [
646 + "rustversion",
647 + ]
648 +
649 + [[package]]
594 650 name = "cc"
595 651 version = "1.2.58"
596 652 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -634,6 +690,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
634 690 checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
635 691
636 692 [[package]]
693 + name = "cfg_aliases"
694 + version = "0.2.2"
695 + source = "registry+https://github.com/rust-lang/crates.io-index"
696 + checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
697 +
698 + [[package]]
637 699 name = "chacha20"
638 700 version = "0.9.1"
639 701 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -713,6 +775,20 @@ dependencies = [
713 775 ]
714 776
715 777 [[package]]
778 + name = "compact_str"
779 + version = "0.9.1"
780 + source = "registry+https://github.com/rust-lang/crates.io-index"
781 + checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
782 + dependencies = [
783 + "castaway",
784 + "cfg-if",
785 + "itoa",
786 + "rustversion",
787 + "ryu",
788 + "static_assertions",
789 + ]
790 +
791 + [[package]]
716 792 name = "compression-codecs"
717 793 version = "0.4.37"
718 794 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -750,6 +826,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
750 826 checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
751 827
752 828 [[package]]
829 + name = "convert_case"
830 + version = "0.10.0"
831 + source = "registry+https://github.com/rust-lang/crates.io-index"
832 + checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
833 + dependencies = [
834 + "unicode-segmentation",
835 + ]
836 +
837 + [[package]]
753 838 name = "cookie"
754 839 version = "0.18.1"
755 840 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -791,7 +876,7 @@ version = "0.25.0"
791 876 source = "registry+https://github.com/rust-lang/crates.io-index"
792 877 checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
793 878 dependencies = [
794 - "bitflags 2.11.0",
879 + "bitflags 2.13.1",
795 880 "core-foundation 0.10.1",
796 881 "core-graphics-types",
797 882 "foreign-types 0.5.0",
@@ -804,7 +889,7 @@ version = "0.2.0"
804 889 source = "registry+https://github.com/rust-lang/crates.io-index"
805 890 checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
806 891 dependencies = [
807 - "bitflags 2.11.0",
892 + "bitflags 2.13.1",
808 893 "core-foundation 0.10.1",
809 894 "libc",
810 895 ]
@@ -843,6 +928,12 @@ dependencies = [
843 928 ]
844 929
845 930 [[package]]
931 + name = "critical-section"
932 + version = "1.2.0"
933 + source = "registry+https://github.com/rust-lang/crates.io-index"
934 + checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
935 +
936 + [[package]]
846 937 name = "crossbeam-channel"
847 938 version = "0.5.15"
848 939 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -867,6 +958,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
867 958 checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
868 959
869 960 [[package]]
961 + name = "crossterm"
962 + version = "0.29.0"
963 + source = "registry+https://github.com/rust-lang/crates.io-index"
964 + checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
965 + dependencies = [
966 + "bitflags 2.13.1",
967 + "crossterm_winapi",
968 + "derive_more 2.1.1",
969 + "document-features",
970 + "mio 1.2.0",
971 + "parking_lot",
972 + "rustix",
973 + "signal-hook",
974 + "signal-hook-mio",
975 + "winapi",
976 + ]
977 +
978 + [[package]]
979 + name = "crossterm_winapi"
980 + version = "0.9.1"
981 + source = "registry+https://github.com/rust-lang/crates.io-index"
982 + checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
983 + dependencies = [
984 + "winapi",
985 + ]
986 +
987 + [[package]]
870 988 name = "crypto-common"
871 989 version = "0.1.7"
872 990 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -878,6 +996,16 @@ dependencies = [
878 996 ]
879 997
880 998 [[package]]
999 + name = "csscolorparser"
1000 + version = "0.6.2"
1001 + source = "registry+https://github.com/rust-lang/crates.io-index"
1002 + checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf"
1003 + dependencies = [
1004 + "lab",
1005 + "phf 0.11.3",
1006 + ]
1007 +
1008 + [[package]]
881 1009 name = "cssparser"
882 1010 version = "0.29.6"
883 1011 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1000,6 +1128,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1000 1128 checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
1001 1129
1002 1130 [[package]]
1131 + name = "deltae"
1132 + version = "0.3.2"
1133 + source = "registry+https://github.com/rust-lang/crates.io-index"
1134 + checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
1135 +
1136 + [[package]]
1003 1137 name = "der"
1004 1138 version = "0.7.10"
1005 1139 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1037,7 +1171,7 @@ version = "0.99.20"
1037 1171 source = "registry+https://github.com/rust-lang/crates.io-index"
1038 1172 checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
1039 1173 dependencies = [
1040 - "convert_case",
1174 + "convert_case 0.4.0",
1041 1175 "proc-macro2",
1042 1176 "quote",
1043 1177 "rustc_version",
@@ -1059,6 +1193,7 @@ version = "2.1.1"
1059 1193 source = "registry+https://github.com/rust-lang/crates.io-index"
1060 1194 checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
1061 1195 dependencies = [
1196 + "convert_case 0.10.0",
1062 1197 "proc-macro2",
1063 1198 "quote",
1064 1199 "rustc_version",
@@ -1104,7 +1239,7 @@ version = "0.3.1"
1104 1239 source = "registry+https://github.com/rust-lang/crates.io-index"
1105 1240 checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
1106 1241 dependencies = [
1107 - "bitflags 2.11.0",
1242 + "bitflags 2.13.1",
1108 1243 "block2",
1109 1244 "libc",
1110 1245 "objc2",
@@ -1154,12 +1289,21 @@ dependencies = [
1154 1289 ]
1155 1290
1156 1291 [[package]]
1292 + name = "document-features"
1293 + version = "0.2.12"
1294 + source = "registry+https://github.com/rust-lang/crates.io-index"
1295 + checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
1296 + dependencies = [
1297 + "litrs",
1298 + ]
1299 +
1300 + [[package]]
1157 1301 name = "dom_query"
1158 1302 version = "0.27.0"
1159 1303 source = "registry+https://github.com/rust-lang/crates.io-index"
1160 1304 checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
1161 1305 dependencies = [
1162 - "bit-set",
1306 + "bit-set 0.8.0",
1163 1307 "cssparser 0.36.0",
1164 1308 "foldhash 0.2.0",
1165 1309 "html5ever 0.38.0",
@@ -1336,6 +1480,15 @@ dependencies = [
1336 1480 ]
1337 1481
1338 1482 [[package]]
1483 + name = "euclid"
1484 + version = "0.22.14"
1485 + source = "registry+https://github.com/rust-lang/crates.io-index"
1486 + checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
1487 + dependencies = [
1488 + "num-traits",
1489 + ]
1490 +
1491 + [[package]]
1339 1492 name = "event-listener"
1340 1493 version = "2.5.3"
1341 1494 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1375,6 +1528,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1375 1528 checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
1376 1529
1377 1530 [[package]]
1531 + name = "fancy-regex"
1532 + version = "0.11.0"
1533 + source = "registry+https://github.com/rust-lang/crates.io-index"
1534 + checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
1535 + dependencies = [
1536 + "bit-set 0.5.3",
1537 + "regex",
1538 + ]
1539 +
1540 + [[package]]
1541 + name = "fast-srgb8"
1542 + version = "1.0.0"
1543 + source = "registry+https://github.com/rust-lang/crates.io-index"
1544 + checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1"
1545 +
1546 + [[package]]
1378 1547 name = "fastrand"
1379 1548 version = "2.3.0"
1380 1549 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1400,6 +1569,17 @@ dependencies = [
1400 1569 ]
1401 1570
1402 1571 [[package]]
1572 + name = "filedescriptor"
1573 + version = "0.8.3"
1574 + source = "registry+https://github.com/rust-lang/crates.io-index"
1575 + checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
1576 + dependencies = [
1577 + "libc",
1578 + "thiserror 1.0.69",
1579 + "winapi",
1580 + ]
1581 +
1582 + [[package]]
1403 1583 name = "filetime"
1404 1584 version = "0.2.27"
1405 1585 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1417,6 +1597,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1417 1597 checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
1418 1598
1419 1599 [[package]]
1600 + name = "finl_unicode"
1601 + version = "1.4.0"
1602 + source = "registry+https://github.com/rust-lang/crates.io-index"
1603 + checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
1604 +
1605 + [[package]]
1606 + name = "fixedbitset"
1607 + version = "0.4.2"
1608 + source = "registry+https://github.com/rust-lang/crates.io-index"
1609 + checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
1610 +
1611 + [[package]]
1420 1612 name = "flate2"
1421 1613 version = "1.1.9"
1422 1614 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1849,7 +2041,7 @@ version = "0.18.5"
1849 2041 source = "registry+https://github.com/rust-lang/crates.io-index"
1850 2042 checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
1851 2043 dependencies = [
1852 - "bitflags 2.11.0",
2044 + "bitflags 2.13.1",
1853 2045 "futures-channel",
1854 2046 "futures-core",
1855 2047 "futures-executor",
@@ -1936,8 +2128,8 @@ dependencies = [
1936 2128 "serde_json",
1937 2129 "sha2",
1938 2130 "sqlx",
1939 - "strum",
1940 - "strum_macros",
2131 + "strum 0.26.3",
2132 + "strum_macros 0.26.4",
1941 2133 "tagtree",
1942 2134 "thiserror 2.0.18",
1943 2135 "uuid",
@@ -2014,6 +2206,22 @@ dependencies = [
2014 2206 ]
2015 2207
2016 2208 [[package]]
2209 + name = "got"
2210 + version = "0.5.0"
2211 + dependencies = [
2212 + "alloy_tui",
2213 + "anyhow",
2214 + "chrono",
2215 + "goingson-core",
2216 + "goingson-db-sqlite",
2217 + "makeover",
2218 + "ratatui",
2219 + "sqlx",
2220 + "tokio",
2221 + "uuid",
2222 + ]
2223 +
2224 + [[package]]
2017 2225 name = "gtk"
2018 2226 version = "0.18.2"
2019 2227 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2115,6 +2323,22 @@ name = "hashbrown"
2115 2323 version = "0.16.1"
2116 2324 source = "registry+https://github.com/rust-lang/crates.io-index"
2117 2325 checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
2326 + dependencies = [
2327 + "allocator-api2",
2328 + "equivalent",
2329 + "foldhash 0.2.0",
2330 + ]
2331 +
2332 + [[package]]
2333 + name = "hashbrown"
2334 + version = "0.17.1"
2335 + source = "registry+https://github.com/rust-lang/crates.io-index"
2336 + checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
2337 + dependencies = [
2338 + "allocator-api2",
2339 + "equivalent",
2340 + "foldhash 0.2.0",
2341 + ]
2118 2342
2119 2343 [[package]]
2120 2344 name = "hashlink"
@@ -2535,6 +2759,25 @@ dependencies = [
2535 2759 ]
2536 2760
2537 2761 [[package]]
2762 + name = "include_dir"
2763 + version = "0.7.4"
2764 + source = "registry+https://github.com/rust-lang/crates.io-index"
2765 + checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
2766 + dependencies = [
2767 + "include_dir_macros",
2768 + ]
2769 +
2770 + [[package]]
2771 + name = "include_dir_macros"
2772 + version = "0.7.4"
2773 + source = "registry+https://github.com/rust-lang/crates.io-index"
2774 + checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
2775 + dependencies = [
2776 + "proc-macro2",
2777 + "quote",
2778 + ]
2779 +
2780 + [[package]]
2538 2781 name = "indexmap"
2539 2782 version = "1.9.3"
2540 2783 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2558,6 +2801,15 @@ dependencies = [
2558 2801 ]
2559 2802
2560 2803 [[package]]
2804 + name = "indoc"
2805 + version = "2.0.7"
2806 + source = "registry+https://github.com/rust-lang/crates.io-index"
2807 + checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
2808 + dependencies = [
2809 + "rustversion",
2810 + ]
2811 +
2812 + [[package]]
2561 2813 name = "infer"
2562 2814 version = "0.19.0"
2563 2815 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2596,6 +2848,19 @@ dependencies = [
2596 2848 ]
2597 2849
2598 2850 [[package]]
2851 + name = "instability"
2852 + version = "0.3.12"
2853 + source = "registry+https://github.com/rust-lang/crates.io-index"
2854 + checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
2855 + dependencies = [
2856 + "darling",
2857 + "indoc",
2858 + "proc-macro2",
2859 + "quote",
2860 + "syn 2.0.117",
2861 + ]
2862 +
2863 + [[package]]
2599 2864 name = "ipnet"
2600 2865 version = "2.12.0"
2601 2866 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2640,6 +2905,15 @@ dependencies = [
2640 2905 ]
2641 2906
2642 2907 [[package]]
2908 + name = "itertools"
2909 + version = "0.14.0"
Lines truncated
M Cargo.toml +1
@@ -3,6 +3,7 @@ members = [
3 3 "crates/core",
4 4 "crates/db-sqlite",
5 5 "crates/go-mcp",
6 + "crates/got",
6 7 "src-tauri",
7 8 ]
8 9 default-members = ["src-tauri"]
@@ -0,0 +1,24 @@
1 + [package]
2 + name = "got"
3 + version.workspace = true
4 + edition.workspace = true
5 +
6 + [[bin]]
7 + name = "got"
8 + path = "src/main.rs"
9 +
10 + [dependencies]
11 + goingson-core = { workspace = true, features = ["sqlx-sqlite"] }
12 + goingson-db-sqlite = { workspace = true }
13 +
14 + # Alloy's themed-ratatui layer. Path dep while alloy_tui lives next door under
15 + # ~/Code; becomes a git dep on the sr.ht alloy repo once alloy_tui is published.
16 + alloy_tui = { path = "../../../../alloy/crates/alloy_tui" }
17 +
18 + tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
19 + sqlx = { workspace = true, features = ["sqlite"] }
20 + uuid = { workspace = true }
21 + chrono = { workspace = true }
22 + ratatui = "0.30"
23 + anyhow = "1"
24 + makeover = "0.9.0"
@@ -0,0 +1,1060 @@
1 + //! got — a read-first terminal cockpit over GoingsOn's local `goingson.db`.
2 + //!
3 + //! First consumer of `alloy_tui` (Alloy's themed-ratatui layer). Opens the same
4 + //! SQLite file the desktop app uses as a peer reader, exactly like `go-mcp`
5 + //! (see `crates/go-mcp`): links the domain crates directly, resolves the same
6 + //! default DB path, and reads through the normal repository layer.
7 + //!
8 + //! The shell mirrors the desktop app's two-level pill-nav: three top-level
9 + //! groups (Work / Time / Messages) each holding a set of panes. It is a slimmed
10 + //! down GoingsOn: Tasks and Projects (the pathways `go-mcp` also covers) plus
11 + //! Events, Timer, and Contacts. Email and the review views are deliberately
12 + //! absent — the desktop and web clients are the better surface for those.
13 + //! `Tab`/`Shift+Tab` cycle groups, number keys and the arrows pick a pane
14 + //! within the active group, `j/k` scroll, `r` refreshes.
15 + //!
16 + //! Usage: `got [--db <path>]`
17 +
18 + use std::collections::HashMap;
19 + use std::io::{self, Stdout};
20 + use std::path::PathBuf;
21 +
22 + use anyhow::{Context, Result};
23 + use chrono::{Datelike, Duration, Local, NaiveDate, TimeZone, Utc};
24 + use goingson_core::{
25 + Contact, ContactRepository, Event, EventRepository, Priority, Project, ProjectRepository,
26 + ProjectStatus, ProjectType, Task, TaskCrud, TaskStatus, TaskTimeTracking, TimeSession,
27 + TimeSummaryProject, TimeTrackingSummary, UserId, roll_up_time_summary,
28 + };
29 + use goingson_db_sqlite::{
30 + SqliteContactRepository, SqliteEventRepository, SqliteProjectRepository, SqliteTaskRepository,
31 + init_pool,
32 + };
33 + use alloy_tui::{AlloyStatusBar, Theme, hint};
34 + use ratatui::Terminal;
35 + use ratatui::crossterm::event::{self, Event as CtEvent, KeyCode, KeyEventKind};
36 + use ratatui::crossterm::{execute, terminal};
37 + use ratatui::layout::{Alignment, Constraint, Direction, Layout};
38 + use ratatui::prelude::CrosstermBackend;
39 + use ratatui::style::{Modifier, Style};
40 + use ratatui::text::{Line, Span};
41 + use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
42 + use sqlx::SqlitePool;
43 + use uuid::Uuid;
44 +
45 + /// Fixed single-user desktop id. MUST match `DESKTOP_USER_ID` in `go-mcp`
46 + /// (`crates/go-mcp/src/context.rs`) and the Tauri app (`src-tauri/src/state.rs`);
47 + /// tasks are keyed on it, so a mismatch would read an empty set.
48 + const DESKTOP_USER_ID: UserId = UserId::from_uuid(Uuid::from_u128(1));
49 +
50 + /// The theme `got` renders in.
51 + ///
52 + /// `alloy_tui` ships no built-in palette on purpose: a missing or malformed
53 + /// theme is an error the user sees, not something papered over by rendering in
54 + /// colors that exist nowhere in the theme files. So load one. A theme dropped
55 + /// into `~/.config/goingson/themes/` wins; otherwise use the set `makeover`
56 + /// bundles, which includes the app's own `goingson` skin.
57 + fn load_theme() -> Result<Theme> {
58 + let mut dirs = Vec::new();
59 + if let Some(home) = std::env::var_os("HOME") {
60 + let custom = PathBuf::from(home)
61 + .join(".config")
62 + .join("goingson")
63 + .join("themes");
64 + if custom.is_dir() {
65 + dirs.push((custom, true));
66 + }
67 + }
68 + dirs.push((
69 + makeover::bundled_themes_dir().context("makeover ships no themes directory")?,
70 + false,
71 + ));
72 +
73 + let id = std::env::var("GOT_THEME").unwrap_or_else(|_| DEFAULT_THEME.to_string());
74 + let colors = makeover::load_theme(&dirs, &id)
75 + .map_err(anyhow::Error::msg)
76 + .with_context(|| format!("loading theme `{id}`"))?;
77 + Theme::from_theme(&colors)
78 + .map_err(|e| anyhow::anyhow!("{e:?}"))
79 + .with_context(|| format!("theme `{id}` is incomplete"))
80 + }
81 +
82 + /// The app's own skin, shared with the desktop build.
83 + const DEFAULT_THEME: &str = "goingson";
84 +
85 + fn main() -> Result<()> {
86 + let db_arg = parse_db_arg()?;
87 +
88 + // We own the runtime rather than using `#[tokio::main]` so the synchronous
89 + // event loop can `block_on` a reload whenever the user asks for one.
90 + let rt = tokio::runtime::Runtime::new()?;
91 +
92 + let db_path = db_arg
93 + .or_else(default_db_path)
94 + .context("could not resolve a default goingson.db path; pass --db <path>")?;
95 + if !db_path.exists() {
96 + anyhow::bail!(
97 + "database not found at {}. Run GoingsOn once to create it, or pass --db.",
98 + db_path.display()
99 + );
100 + }
101 +
102 + let pool = rt.block_on(init_pool(Some(&db_path.to_string_lossy())))?;
103 +
104 + let mut app = App::new(db_path, load_theme()?);
105 + app.reload(&rt, &pool)?;
106 +
107 + let mut terminal = setup_terminal()?;
108 + let result = run(&mut terminal, &mut app, &rt, &pool);
109 + restore_terminal(&mut terminal)?;
110 + result
111 + }
112 +
113 + /// The top-level pill-nav groups, mirroring the desktop app. Email lives under
114 + /// the app's "Messages" group but is intentionally not surfaced here.
115 + #[derive(Clone, Copy, PartialEq)]
116 + enum Group {
117 + Work,
118 + Time,
119 + Messages,
120 + }
121 +
122 + impl Group {
123 + const ALL: [Group; 3] = [Group::Work, Group::Time, Group::Messages];
124 +
125 + fn label(self) -> &'static str {
126 + match self {
127 + Group::Work => "Work",
128 + Group::Time => "Time",
129 + Group::Messages => "Messages",
130 + }
131 + }
132 +
133 + /// The panes shown within this group, in tab order.
134 + fn panes(self) -> &'static [Pane] {
135 + match self {
136 + Group::Work => &[Pane::Tasks, Pane::Projects],
137 + Group::Time => &[Pane::Events, Pane::Timer],
138 + Group::Messages => &[Pane::Contacts],
139 + }
140 + }
141 +
142 + fn index(self) -> usize {
143 + Self::ALL.iter().position(|&g| g == self).unwrap_or(0)
144 + }
145 +
146 + fn step(self, delta: isize) -> Group {
147 + let n = Self::ALL.len() as isize;
148 + Self::ALL[(self.index() as isize + delta).rem_euclid(n) as usize]
149 + }
150 + }
151 +
152 + /// A single pane within a group, each backed by a live read of the local DB.
153 + #[derive(Clone, Copy, PartialEq)]
154 + enum Pane {
155 + Tasks,
156 + Projects,
157 + Events,
158 + Timer,
159 + Contacts,
160 + }
161 +
162 + impl Pane {
163 + fn label(self) -> &'static str {
164 + match self {
165 + Pane::Tasks => "Tasks",
166 + Pane::Projects => "Projects",
167 + Pane::Events => "Events",
168 + Pane::Timer => "Timer",
169 + Pane::Contacts => "Contacts",
170 + }
171 + }
172 + }
173 +
174 + /// A flattened board row: either a project header or one of its open tasks.
175 + enum Row {
176 + Header { name: String, open: usize },
177 + Task(Box<Task>),
178 + }
179 +
180 + /// One line on the Projects pane: a project plus its open-task count.
181 + struct ProjectRow {
182 + name: String,
183 + status: ProjectStatus,
184 + project_type: ProjectType,
185 + open: usize,
186 + }
187 +
188 + /// How far ahead the Events pane looks.
189 + const EVENT_HORIZON_DAYS: i64 = 30;
190 +
191 + /// A flattened Events-pane row: a day header or one event under it.
192 + enum EventRow {
193 + DayHeader { label: String, count: usize },
194 + Event(Box<Event>),
195 + }
196 +
197 + /// How many recent sessions the Timer pane lists.
198 + const RECENT_SESSION_LIMIT: usize = 10;
199 +
200 + /// The Timer pane's view: the running timer (if any), this week's tracked
201 + /// breakdown (matching the desktop Day-view panel), and recent sessions.
202 + #[derive(Default)]
203 + struct TimerView {
204 + active: Option<ActiveTimer>,
205 + today_minutes: i32,
206 + week: Vec<TimeSummaryProject>,
207 + recent: Vec<RecentSession>,
208 + }
209 +
210 + /// The currently running timer, resolved to its task and elapsed minutes.
211 + struct ActiveTimer {
212 + task: String,
213 + elapsed_min: i32,
214 + }
215 +
216 + /// One finished session in the Timer pane's recent list.
217 + struct RecentSession {
218 + day: String,
219 + task: String,
220 + project: Option<String>,
221 + minutes: i32,
222 + }
223 +
224 + struct App {
225 + db_path: PathBuf,
226 + group: Group,
227 + /// Index into `group.panes()` of the active pane.
228 + pane_idx: usize,
229 + rows: Vec<Row>,
230 + state: ListState,
231 + projects: Vec<ProjectRow>,
232 + proj_state: ListState,
233 + events: Vec<EventRow>,
234 + event_state: ListState,
235 + timer: TimerView,
236 + contacts: Vec<Contact>,
237 + contact_state: ListState,
238 + status: String,
239 + theme: Theme,
240 + }
241 +
242 + impl App {
243 + fn new(db_path: PathBuf, theme: Theme) -> Self {
244 + Self {
245 + db_path,
246 + theme,
247 + group: Group::Work,
248 + pane_idx: 0,
249 + rows: Vec::new(),
250 + state: ListState::default(),
251 + projects: Vec::new(),
252 + proj_state: ListState::default(),
253 + events: Vec::new(),
254 + event_state: ListState::default(),
255 + timer: TimerView::default(),
256 + contacts: Vec::new(),
257 + contact_state: ListState::default(),
258 + status: String::new(),
259 + }
260 + }
261 +
262 + fn current_pane(&self) -> Pane {
263 + self.group.panes()[self.pane_idx]
264 + }
265 +
266 + /// Switch groups, landing on the new group's first pane.
267 + fn cycle_group(&mut self, delta: isize) {
268 + self.group = self.group.step(delta);
269 + self.pane_idx = 0;
270 + }
271 +
272 + /// Move to another pane within the active group, wrapping.
273 + fn move_pane(&mut self, delta: isize) {
274 + let n = self.group.panes().len() as isize;
275 + self.pane_idx = (self.pane_idx as isize + delta).rem_euclid(n) as usize;
276 + }
277 +
278 + /// Jump straight to the pane at `idx` (0-based) if the group has one there.
279 + fn select_pane(&mut self, idx: usize) {
280 + if idx < self.group.panes().len() {
281 + self.pane_idx = idx;
282 + }
283 + }
284 +
285 + /// Fetch projects + open tasks and rebuild every pane's view.
286 + fn reload(&mut self, rt: &tokio::runtime::Runtime, pool: &SqlitePool) -> Result<()> {
287 + let Loaded {
288 + projects,
289 + tasks,
290 + events,
291 + active_timer,
292 + time_summary,
293 + sessions,
294 + contacts,
295 + } = rt.block_on(load(pool))?;
296 + self.projects = build_projects(&projects, &tasks);
297 + self.events = build_events(events);
298 + self.timer = build_timer(active_timer, time_summary, sessions, &tasks);
299 + self.contacts = build_contacts(contacts);
300 + self.rows = build_board(projects, tasks);
301 + self.status = format!(
302 + "{} open task(s) from {}",
303 + self.rows.iter().filter(|r| matches!(r, Row::Task(_))).count(),
304 + self.db_path.display()
305 + );
306 + clamp_selection(&mut self.state, self.rows.len());
307 + clamp_selection(&mut self.proj_state, self.projects.len());
308 + clamp_selection(&mut self.event_state, self.events.len());
309 + clamp_selection(&mut self.contact_state, self.contacts.len());
310 + Ok(())
311 + }
312 +
313 + /// Scroll the active pane's list, wrapping. No-op on panes without a list.
314 + fn move_selection(&mut self, delta: isize) {
315 + let (state, len) = match self.current_pane() {
316 + Pane::Tasks => (&mut self.state, self.rows.len()),
317 + Pane::Projects => (&mut self.proj_state, self.projects.len()),
318 + Pane::Events => (&mut self.event_state, self.events.len()),
319 + Pane::Contacts => (&mut self.contact_state, self.contacts.len()),
320 + _ => return,
321 + };
322 + if len == 0 {
323 + return;
324 + }
325 + let cur = state.selected().unwrap_or(0) as isize;
326 + let next = (cur + delta).rem_euclid(len as isize);
327 + state.select(Some(next as usize));
328 + }
329 + }
330 +
331 + /// Keep a list's selection in range after its contents change.
332 + fn clamp_selection(state: &mut ListState, len: usize) {
333 + if len == 0 {
334 + state.select(None);
335 + } else {
336 + state.select(Some(state.selected().unwrap_or(0).min(len - 1)));
337 + }
338 + }
339 +
340 + /// Everything the board reads in one refresh.
341 + struct Loaded {
342 + projects: Vec<Project>,
343 + tasks: Vec<Task>,
344 + events: Vec<Event>,
345 + active_timer: Option<(TimeSession, String)>,
346 + time_summary: Vec<TimeTrackingSummary>,
347 + sessions: Vec<TimeSession>,
348 + contacts: Vec<Contact>,
349 + }
350 +
351 + /// Read every pane's data for the desktop user in a single async pass.
352 + async fn load(pool: &SqlitePool) -> Result<Loaded> {
353 + let projects = SqliteProjectRepository::new(pool.clone())
354 + .list_all(DESKTOP_USER_ID)
355 + .await?;
356 + let tasks = SqliteTaskRepository::new(pool.clone())
357 + .list_all(DESKTOP_USER_ID)
358 + .await?;
359 + let events = SqliteEventRepository::new(pool.clone())
360 + .get_upcoming(DESKTOP_USER_ID, EVENT_HORIZON_DAYS)
361 + .await?;
362 +
363 + // Time tracking. The week window matches the desktop Day-view panel: Monday
364 + // 00:00 local through the next Monday, so our totals agree with the app's.
365 + let task_repo = SqliteTaskRepository::new(pool.clone());
366 + let active_timer = task_repo.get_active_timer(DESKTOP_USER_ID).await?;
367 + let now = Local::now();
368 + let week_start = now.date_naive()
369 + - Duration::days(i64::from(now.weekday().num_days_from_monday()));
370 + let (start, end) = (
371 + local_midnight_utc(week_start),
372 + local_midnight_utc(week_start + Duration::days(7)),
373 + );
374 + let time_summary = task_repo
375 + .get_time_summary(DESKTOP_USER_ID, start, end)
376 + .await?;
377 + let sessions = task_repo.list_all_time_sessions(DESKTOP_USER_ID).await?;
378 +
379 + let contacts = SqliteContactRepository::new(pool.clone())
380 + .list_all(DESKTOP_USER_ID)
381 + .await?;
382 +
383 + Ok(Loaded {
384 + projects,
385 + tasks,
386 + events,
387 + active_timer,
388 + time_summary,
389 + sessions,
390 + contacts,
391 + })
392 + }
393 +
394 + /// Local midnight on `date`, expressed in UTC. Mirrors the desktop app's helper
395 + /// so the Timer pane buckets time exactly as the Day view does.
396 + fn local_midnight_utc(date: NaiveDate) -> chrono::DateTime<Utc> {
397 + let naive = date.and_hms_opt(0, 0, 0).expect("midnight is always valid");
398 + Local
399 + .from_local_datetime(&naive)
400 + .earliest()
401 + .map(|dt| dt.with_timezone(&Utc))
402 + .unwrap_or_else(|| Utc.from_utc_datetime(&naive))
403 + }
404 +
405 + fn is_open(t: &Task) -> bool {
406 + matches!(t.status, TaskStatus::Pending | TaskStatus::Started)
407 + }
408 +
409 + /// Group open tasks under their project, projects with work first, and flatten.
410 + fn build_board(projects: Vec<Project>, tasks: Vec<Task>) -> Vec<Row> {
411 + let mut open: Vec<Task> = tasks.into_iter().filter(is_open).collect();
412 + open.sort_by(|a, b| b.urgency.partial_cmp(&a.urgency).unwrap_or(std::cmp::Ordering::Equal));
413 +
414 + // Preserve one bucket per project name plus a trailing "(no project)".
415 + let mut order: Vec<String> = projects.into_iter().map(|p| p.name).collect();
416 + order.sort();
417 + const NO_PROJECT: &str = "(no project)";
418 + order.push(NO_PROJECT.to_string());
419 +
420 + let mut rows = Vec::new();
421 + for name in order {
422 + let bucket: Vec<Task> = open
423 + .iter()
424 + .filter(|t| {
425 + let pn = t.project_name.as_deref().unwrap_or(NO_PROJECT);
426 + pn == name
427 + })
428 + .cloned()
429 + .collect();
430 + if bucket.is_empty() {
431 + continue;
432 + }
433 + rows.push(Row::Header { name, open: bucket.len() });
434 + rows.extend(bucket.into_iter().map(|t| Row::Task(Box::new(t))));
435 + }
436 + rows
437 + }
438 +
439 + /// Build the Projects pane: every project with its open-task count, sorted so
440 + /// active work floats to the top and ties break alphabetically.
441 + fn build_projects(projects: &[Project], tasks: &[Task]) -> Vec<ProjectRow> {
442 + let mut open_counts: HashMap<&str, usize> = HashMap::new();
443 + for t in tasks.iter().filter(|t| is_open(t)) {
444 + if let Some(name) = t.project_name.as_deref() {
445 + *open_counts.entry(name).or_default() += 1;
446 + }
447 + }
448 +
449 + let mut rows: Vec<ProjectRow> = projects
450 + .iter()
451 + .map(|p| ProjectRow {
452 + name: p.name.clone(),
453 + status: p.status.clone(),
454 + project_type: p.project_type.clone(),
455 + open: open_counts.get(p.name.as_str()).copied().unwrap_or(0),
456 + })
457 + .collect();
458 + rows.sort_by(|a, b| {
459 + status_rank(&a.status)
460 + .cmp(&status_rank(&b.status))
461 + .then_with(|| a.name.cmp(&b.name))
462 + });
463 + rows
464 + }
465 +
466 + /// Sort key for project status: live work first, dormant last.
467 + fn status_rank(status: &ProjectStatus) -> u8 {
468 + match status {
469 + ProjectStatus::Active => 0,
470 + ProjectStatus::OnHold => 1,
471 + ProjectStatus::Completed => 2,
472 + ProjectStatus::Archived => 3,
473 + }
474 + }
475 +
476 + /// Group upcoming events (already sorted ascending) by local calendar day,
477 + /// emitting a day header before each day's events.
478 + fn build_events(events: Vec<Event>) -> Vec<EventRow> {
479 + let today = Local::now().date_naive();
480 +
481 + let mut groups: Vec<(NaiveDate, Vec<Event>)> = Vec::new();
482 + for ev in events {
483 + let day = ev.start_time.with_timezone(&Local).date_naive();
484 + match groups.last_mut() {
485 + Some((d, bucket)) if *d == day => bucket.push(ev),
486 + _ => groups.push((day, vec![ev])),
487 + }
488 + }
489 +
490 + let mut rows = Vec::new();
491 + for (day, bucket) in groups {
492 + rows.push(EventRow::DayHeader {
493 + label: day_label(day, today),
494 + count: bucket.len(),
495 + });
496 + rows.extend(bucket.into_iter().map(|e| EventRow::Event(Box::new(e))));
497 + }
498 + rows
499 + }
500 +
Lines truncated