Add coder-lite ratatui TUI with OpenResponses streaming.

372be4a932e8 · AtlantisPleb · · parent 468f1fa3257e

Add coder-lite ratatui TUI with OpenResponses streaming.

- New crates/coder-lite binary using openresponses-rust
- Streaming responses against OpenAgents /api/v1/responses
- Multiline composer, scrollback, loading state, and inline errors
- --dev flag that boots the local openagents.com Phoenix server
- Smoke test for the OpenResponses client

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified Cargo.lock
  • modified Cargo.toml
  • added crates/coder-lite/Cargo.toml
  • added crates/coder-lite/src/interactive.rs
  • added crates/coder-lite/src/lib.rs
  • added crates/coder-lite/src/main.rs
  • added crates/coder-lite/src/runtime.rs
  • added crates/coder-lite/src/tui.rs
  • added crates/coder-lite/tests/smoke.rs

Diff

9 files changed, +1038 -9

Cargo.lock modified +343 -9

@@ -112,6 +112,29 @@ version = "1.5.1"

112 112
source = "registry+https://github.com/rust-lang/crates.io-index"
113 113
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
114 114
115
[[package]]
116
name = "aws-lc-rs"
117
version = "1.18.0"
118
source = "registry+https://github.com/rust-lang/crates.io-index"
119
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
120
dependencies = [
121
 "aws-lc-sys",
122
 "zeroize",
123
]
124
125
[[package]]
126
name = "aws-lc-sys"
127
version = "0.44.0"
128
source = "registry+https://github.com/rust-lang/crates.io-index"
129
checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
130
dependencies = [
131
 "cc",
132
 "cmake",
133
 "dunce",
134
 "fs_extra",
135
 "pkg-config",
136
]
137
115 138
[[package]]
116 139
name = "base64"
117 140
version = "0.22.1"

@@ -274,6 +297,27 @@ version = "1.1.0"

274 297
source = "registry+https://github.com/rust-lang/crates.io-index"
275 298
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
276 299
300
[[package]]
301
name = "cmake"
302
version = "0.1.58"
303
source = "registry+https://github.com/rust-lang/crates.io-index"
304
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
305
dependencies = [
306
 "cc",
307
]
308
309
[[package]]
310
name = "coder-lite"
311
version = "0.1.0"
312
dependencies = [
313
 "crossterm",
314
 "futures",
315
 "openresponses-rust",
316
 "ratatui",
317
 "reqwest 0.12.28",
318
 "tokio",
319
]
320
277 321
[[package]]
278 322
name = "colorchoice"
279 323
version = "1.0.5"

@@ -304,6 +348,16 @@ dependencies = [

304 348
 "static_assertions",
305 349
]
306 350
351
[[package]]
352
name = "core-foundation"
353
version = "0.9.4"
354
source = "registry+https://github.com/rust-lang/crates.io-index"
355
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
356
dependencies = [
357
 "core-foundation-sys",
358
 "libc",
359
]
360
307 361
[[package]]
308 362
name = "core-foundation"
309 363
version = "0.10.1"

@@ -350,7 +404,7 @@ dependencies = [

350 404
 "core-foundation-sys",
351 405
 "coreaudio-rs",
352 406
 "dasp_sample",
353
 "jni",
407
 "jni 0.21.1",
354 408
 "js-sys",
355 409
 "libc",
356 410
 "mach2",

@@ -490,12 +544,27 @@ dependencies = [

490 544
 "syn 2.0.117",
491 545
]
492 546
547
[[package]]
548
name = "dunce"
549
version = "1.0.5"
550
source = "registry+https://github.com/rust-lang/crates.io-index"
551
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
552
493 553
[[package]]
494 554
name = "either"
495 555
version = "1.16.0"
496 556
source = "registry+https://github.com/rust-lang/crates.io-index"
497 557
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
498 558
559
[[package]]
560
name = "encoding_rs"
561
version = "0.8.35"
562
source = "registry+https://github.com/rust-lang/crates.io-index"
563
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
564
dependencies = [
565
 "cfg-if",
566
]
567
499 568
[[package]]
500 569
name = "equivalent"
501 570
version = "1.0.2"

@@ -529,6 +598,12 @@ version = "0.1.9"

529 598
source = "registry+https://github.com/rust-lang/crates.io-index"
530 599
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
531 600
601
[[package]]
602
name = "fnv"
603
version = "1.0.7"
604
source = "registry+https://github.com/rust-lang/crates.io-index"
605
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
606
532 607
[[package]]
533 608
name = "foldhash"
534 609
version = "0.1.5"

@@ -544,6 +619,12 @@ dependencies = [

544 619
 "percent-encoding",
545 620
]
546 621
622
[[package]]
623
name = "fs_extra"
624
version = "1.3.0"
625
source = "registry+https://github.com/rust-lang/crates.io-index"
626
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
627
547 628
[[package]]
548 629
name = "futures"
549 630
version = "0.3.34"

@@ -686,6 +767,25 @@ version = "0.3.3"

686 767
source = "registry+https://github.com/rust-lang/crates.io-index"
687 768
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
688 769
770
[[package]]
771
name = "h2"
772
version = "0.4.19"
773
source = "registry+https://github.com/rust-lang/crates.io-index"
774
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
775
dependencies = [
776
 "atomic-waker",
777
 "bytes",
778
 "fnv",
779
 "futures-core",
780
 "futures-sink",
781
 "http",
782
 "indexmap",
783
 "slab",
784
 "tokio",
785
 "tokio-util",
786
 "tracing",
787
]
788
689 789
[[package]]
690 790
name = "hashbrown"
691 791
version = "0.15.5"

@@ -758,6 +858,7 @@ dependencies = [

758 858
 "bytes",
759 859
 "futures-channel",
760 860
 "futures-core",
861
 "h2",
761 862
 "http",
762 863
 "http-body",
763 864
 "httparse",

@@ -802,9 +903,11 @@ dependencies = [

802 903
 "percent-encoding",
803 904
 "pin-project-lite",
804 905
 "socket2",
906
 "system-configuration",
805 907
 "tokio",
806 908
 "tower-service",
807 909
 "tracing",
910
 "windows-registry",
808 911
]
809 912
810 913
[[package]]

@@ -991,6 +1094,36 @@ dependencies = [

991 1094
 "windows-sys 0.45.0",
992 1095
]
993 1096
1097
[[package]]
1098
name = "jni"
1099
version = "0.22.4"
1100
source = "registry+https://github.com/rust-lang/crates.io-index"
1101
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
1102
dependencies = [
1103
 "cfg-if",
1104
 "combine",
1105
 "jni-macros",
1106
 "jni-sys 0.4.1",
1107
 "log",
1108
 "simd_cesu8",
1109
 "thiserror 2.0.18",
1110
 "walkdir",
1111
 "windows-link 0.2.1",
1112
]
1113
1114
[[package]]
1115
name = "jni-macros"
1116
version = "0.22.4"
1117
source = "registry+https://github.com/rust-lang/crates.io-index"
1118
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
1119
dependencies = [
1120
 "proc-macro2",
1121
 "quote",
1122
 "rustc_version",
1123
 "simd_cesu8",
1124
 "syn 2.0.117",
1125
]
1126
994 1127
[[package]]
995 1128
name = "jni-sys"
996 1129
version = "0.3.1"

@@ -1129,6 +1262,12 @@ version = "2.8.0"

1129 1262
source = "registry+https://github.com/rust-lang/crates.io-index"
1130 1263
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
1131 1264
1265
[[package]]
1266
name = "mime"
1267
version = "0.3.17"
1268
source = "registry+https://github.com/rust-lang/crates.io-index"
1269
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
1270
1132 1271
[[package]]
1133 1272
name = "minimal-lexical"
1134 1273
version = "0.2.1"

@@ -1250,7 +1389,7 @@ dependencies = [

1250 1389
name = "oa-cloud-run-bridge"
1251 1390
version = "0.1.0"
1252 1391
dependencies = [
1253
 "reqwest",
1392
 "reqwest 0.12.28",
1254 1393
 "serde",
1255 1394
 "serde_json",
1256 1395
]

@@ -1262,7 +1401,7 @@ dependencies = [

1262 1401
 "base64",
1263 1402
 "libc",
1264 1403
 "openagents-cloud-contract",
1265
 "reqwest",
1404
 "reqwest 0.12.28",
1266 1405
 "serde",
1267 1406
 "serde_json",
1268 1407
 "sha2",

@@ -1286,7 +1425,7 @@ name = "oa-node"

1286 1425
version = "0.1.0"
1287 1426
dependencies = [
1288 1427
 "openagents-cloud-contract",
1289
 "reqwest",
1428
 "reqwest 0.12.28",
1290 1429
 "serde",
1291 1430
 "serde_json",
1292 1431
 "sha2",

@@ -1329,7 +1468,7 @@ version = "0.6.1"

1329 1468
source = "registry+https://github.com/rust-lang/crates.io-index"
1330 1469
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
1331 1470
dependencies = [
1332
 "jni",
1471
 "jni 0.21.1",
1333 1472
 "ndk",
1334 1473
 "ndk-context",
1335 1474
 "num-derive",

@@ -1378,7 +1517,7 @@ dependencies = [

1378 1517
 "openagents-all-work-contract",
1379 1518
 "openagents-cloud-contract",
1380 1519
 "ratatui",
1381
 "reqwest",
1520
 "reqwest 0.12.28",
1382 1521
 "serde",
1383 1522
 "serde_json",
1384 1523
 "sha2",

@@ -1396,6 +1535,24 @@ dependencies = [

1396 1535
 "serde_json",
1397 1536
]
1398 1537
1538
[[package]]
1539
name = "openresponses-rust"
1540
version = "2026.7.26"
1541
source = "registry+https://github.com/rust-lang/crates.io-index"
1542
checksum = "a7484a3517821742462fdd2f5e4bf5c13a4d2fd57ddf23be70e6576809bd899f"
1543
dependencies = [
1544
 "async-trait",
1545
 "bytes",
1546
 "eventsource-stream",
1547
 "futures",
1548
 "reqwest 0.13.4",
1549
 "serde",
1550
 "serde_json",
1551
 "thiserror 2.0.18",
1552
 "tokio",
1553
 "url",
1554
]
1555
1399 1556
[[package]]
1400 1557
name = "openssl-probe"
1401 1558
version = "0.2.1"

@@ -1511,6 +1668,7 @@ version = "0.11.14"

1511 1668
source = "registry+https://github.com/rust-lang/crates.io-index"
1512 1669
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
1513 1670
dependencies = [
1671
 "aws-lc-rs",
1514 1672
 "bytes",
1515 1673
 "getrandom 0.3.4",
1516 1674
 "lru-slab",

@@ -1686,11 +1844,54 @@ dependencies = [

1686 1844
 "url",
1687 1845
 "wasm-bindgen",
1688 1846
 "wasm-bindgen-futures",
1689
 "wasm-streams",
1847
 "wasm-streams 0.4.2",
1690 1848
 "web-sys",
1691 1849
 "webpki-roots",
1692 1850
]
1693 1851
1852
[[package]]
1853
name = "reqwest"
1854
version = "0.13.4"
1855
source = "registry+https://github.com/rust-lang/crates.io-index"
1856
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
1857
dependencies = [
1858
 "base64",
1859
 "bytes",
1860
 "encoding_rs",
1861
 "futures-core",
1862
 "futures-util",
1863
 "h2",
1864
 "http",
1865
 "http-body",
1866
 "http-body-util",
1867
 "hyper",
1868
 "hyper-rustls",
1869
 "hyper-util",
1870
 "js-sys",
1871
 "log",
1872
 "mime",
1873
 "percent-encoding",
1874
 "pin-project-lite",
1875
 "quinn",
1876
 "rustls",
1877
 "rustls-pki-types",
1878
 "rustls-platform-verifier",
1879
 "serde",
1880
 "serde_json",
1881
 "sync_wrapper",
1882
 "tokio",
1883
 "tokio-rustls",
1884
 "tokio-util",
1885
 "tower",
1886
 "tower-http",
1887
 "tower-service",
1888
 "url",
1889
 "wasm-bindgen",
1890
 "wasm-bindgen-futures",
1891
 "wasm-streams 0.5.0",
1892
 "web-sys",
1893
]
1894
1694 1895
[[package]]
1695 1896
name = "ring"
1696 1897
version = "0.17.14"

@@ -1711,6 +1912,15 @@ version = "2.1.2"

1711 1912
source = "registry+https://github.com/rust-lang/crates.io-index"
1712 1913
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
1713 1914
1915
[[package]]
1916
name = "rustc_version"
1917
version = "0.4.1"
1918
source = "registry+https://github.com/rust-lang/crates.io-index"
1919
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
1920
dependencies = [
1921
 "semver",
1922
]
1923
1714 1924
[[package]]
1715 1925
name = "rustix"
1716 1926
version = "0.38.44"

@@ -1730,6 +1940,7 @@ version = "0.23.40"

1730 1940
source = "registry+https://github.com/rust-lang/crates.io-index"
1731 1941
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
1732 1942
dependencies = [
1943
 "aws-lc-rs",
1733 1944
 "once_cell",
1734 1945
 "ring",
1735 1946
 "rustls-pki-types",

@@ -1760,12 +1971,40 @@ dependencies = [

1760 1971
 "zeroize",
1761 1972
]
1762 1973
1974
[[package]]
1975
name = "rustls-platform-verifier"
1976
version = "0.7.0"
1977
source = "registry+https://github.com/rust-lang/crates.io-index"
1978
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
1979
dependencies = [
1980
 "core-foundation 0.10.1",
1981
 "core-foundation-sys",
1982
 "jni 0.22.4",
1983
 "log",
1984
 "once_cell",
1985
 "rustls",
1986
 "rustls-native-certs",
1987
 "rustls-platform-verifier-android",
1988
 "rustls-webpki",
1989
 "security-framework",
1990
 "security-framework-sys",
1991
 "webpki-root-certs",
1992
 "windows-sys 0.61.2",
1993
]
1994
1995
[[package]]
1996
name = "rustls-platform-verifier-android"
1997
version = "0.1.1"
1998
source = "registry+https://github.com/rust-lang/crates.io-index"
1999
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
2000
1763 2001
[[package]]
1764 2002
name = "rustls-webpki"
1765 2003
version = "0.103.13"
1766 2004
source = "registry+https://github.com/rust-lang/crates.io-index"
1767 2005
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
1768 2006
dependencies = [
2007
 "aws-lc-rs",
1769 2008
 "ring",
1770 2009
 "rustls-pki-types",
1771 2010
 "untrusted",

@@ -1814,7 +2053,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"

1814 2053
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
1815 2054
dependencies = [
1816 2055
 "bitflags 2.11.1",
1817
 "core-foundation",
2056
 "core-foundation 0.10.1",
1818 2057
 "core-foundation-sys",
1819 2058
 "libc",
1820 2059
 "security-framework-sys",

@@ -1830,6 +2069,12 @@ dependencies = [

1830 2069
 "libc",
1831 2070
]
1832 2071
2072
[[package]]
2073
name = "semver"
2074
version = "1.0.28"
2075
source = "registry+https://github.com/rust-lang/crates.io-index"
2076
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
2077
1833 2078
[[package]]
1834 2079
name = "serde"
1835 2080
version = "1.0.228"

@@ -1953,6 +2198,22 @@ dependencies = [

1953 2198
 "libc",
1954 2199
]
1955 2200
2201
[[package]]
2202
name = "simd_cesu8"
2203
version = "1.2.0"
2204
source = "registry+https://github.com/rust-lang/crates.io-index"
2205
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
2206
dependencies = [
2207
 "rustc_version",
2208
 "simdutf8",
2209
]
2210
2211
[[package]]
2212
name = "simdutf8"
2213
version = "0.1.5"
2214
source = "registry+https://github.com/rust-lang/crates.io-index"
2215
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
2216
1956 2217
[[package]]
1957 2218
name = "slab"
1958 2219
version = "0.4.12"

@@ -2077,6 +2338,27 @@ dependencies = [

2077 2338
 "windows 0.61.3",
2078 2339
]
2079 2340
2341
[[package]]
2342
name = "system-configuration"
2343
version = "0.7.0"
2344
source = "registry+https://github.com/rust-lang/crates.io-index"
2345
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
2346
dependencies = [
2347
 "bitflags 2.11.1",
2348
 "core-foundation 0.9.4",
2349
 "system-configuration-sys",
2350
]
2351
2352
[[package]]
2353
name = "system-configuration-sys"
2354
version = "0.6.0"
2355
source = "registry+https://github.com/rust-lang/crates.io-index"
2356
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
2357
dependencies = [
2358
 "core-foundation-sys",
2359
 "libc",
2360
]
2361
2080 2362
[[package]]
2081 2363
name = "thiserror"
2082 2364
version = "1.0.69"

@@ -2198,6 +2480,7 @@ dependencies = [

2198 2480
 "bytes",
2199 2481
 "futures-core",
2200 2482
 "futures-sink",
2483
 "libc",
2201 2484
 "pin-project-lite",
2202 2485
 "tokio",
2203 2486
]

@@ -2555,6 +2838,19 @@ dependencies = [

2555 2838
 "web-sys",
2556 2839
]
2557 2840
2841
[[package]]
2842
name = "wasm-streams"
2843
version = "0.5.0"
2844
source = "registry+https://github.com/rust-lang/crates.io-index"
2845
checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
2846
dependencies = [
2847
 "futures-util",
2848
 "js-sys",
2849
 "wasm-bindgen",
2850
 "wasm-bindgen-futures",
2851
 "web-sys",
2852
]
2853
2558 2854
[[package]]
2559 2855
name = "web-sys"
2560 2856
version = "0.3.99"

@@ -2575,6 +2871,15 @@ dependencies = [

2575 2871
 "wasm-bindgen",
2576 2872
]
2577 2873
2874
[[package]]
2875
name = "webpki-root-certs"
2876
version = "1.0.9"
2877
source = "registry+https://github.com/rust-lang/crates.io-index"
2878
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
2879
dependencies = [
2880
 "rustls-pki-types",
2881
]
2882
2578 2883
[[package]]
2579 2884
name = "webpki-roots"
2580 2885
version = "1.0.7"

@@ -2667,7 +2972,7 @@ dependencies = [

2667 2972
 "windows-interface",
2668 2973
 "windows-link 0.1.3",
2669 2974
 "windows-result 0.3.4",
2670
 "windows-strings",
2975
 "windows-strings 0.4.2",
2671 2976
]
2672 2977
2673 2978
[[package]]

@@ -2725,6 +3030,17 @@ dependencies = [

2725 3030
 "windows-link 0.1.3",
2726 3031
]
2727 3032
3033
[[package]]
3034
name = "windows-registry"
3035
version = "0.6.1"
3036
source = "registry+https://github.com/rust-lang/crates.io-index"
3037
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
3038
dependencies = [
3039
 "windows-link 0.2.1",
3040
 "windows-result 0.4.1",
3041
 "windows-strings 0.5.1",
3042
]
3043
2728 3044
[[package]]
2729 3045
name = "windows-result"
2730 3046
version = "0.1.2"

@@ -2743,6 +3059,15 @@ dependencies = [

2743 3059
 "windows-link 0.1.3",
2744 3060
]
2745 3061
3062
[[package]]
3063
name = "windows-result"
3064
version = "0.4.1"
3065
source = "registry+https://github.com/rust-lang/crates.io-index"
3066
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
3067
dependencies = [
3068
 "windows-link 0.2.1",
3069
]
3070
2746 3071
[[package]]
2747 3072
name = "windows-strings"
2748 3073
version = "0.4.2"

@@ -2752,6 +3077,15 @@ dependencies = [

2752 3077
 "windows-link 0.1.3",
2753 3078
]
2754 3079
3080
[[package]]
3081
name = "windows-strings"
3082
version = "0.5.1"
3083
source = "registry+https://github.com/rust-lang/crates.io-index"
3084
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
3085
dependencies = [
3086
 "windows-link 0.2.1",
3087
]
3088
2755 3089
[[package]]
2756 3090
name = "windows-sys"
2757 3091
version = "0.45.0"
Cargo.toml modified +1

@@ -6,6 +6,7 @@

6 6
7 7
[workspace]
8 8
members = [
9
    "crates/coder-lite",
9 10
    "crates/openagents-cli",
10 11
    "crates/all-work-contract",
11 12
    "crates/oa-desktop-audio",
crates/coder-lite/Cargo.toml added +18

@@ -0,0 +1,18 @@

1
[package]
2
name = "coder-lite"
3
version = "0.1.0"
4
edition.workspace = true
5
license.workspace = true
6
repository.workspace = true
7
8
[[bin]]
9
name = "coder-lite"
10
path = "src/main.rs"
11
12
[dependencies]
13
tokio = { version = "1", features = ["full"] }
14
crossterm = { version = "0.28", features = ["event-stream"] }
15
ratatui = { version = "0.29", default-features = false, features = ["crossterm"] }
16
openresponses-rust = "2026.7.26"
17
reqwest = { workspace = true }
18
futures = "0.3"
crates/coder-lite/src/interactive.rs added +160

@@ -0,0 +1,160 @@

1
//! Interactive coder-lite TUI session
2
//!
3
//! Key handling follows the pattern used in grok-build's ratatui-textarea and
4
//! grok-pager: destructure `crossterm::event::KeyEvent` by `code` and
5
//! `modifiers` so control chords do not fall through to plain character input.
6
7
use crate::runtime::{CoderRuntimeSession, Control};
8
use crate::tui::{CoderUi, Entry, Role};
9
use std::sync::mpsc;
10
use crossterm::{
11
    event::{
12
        self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,
13
        PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
14
    },
15
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
16
    ExecutableCommand,
17
};
18
use ratatui::backend::CrosstermBackend;
19
use ratatui::Terminal;
20
use std::io::{stderr, stdout};
21
use std::time::Duration;
22
23
pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {
24
    if !atty_is_terminal() {
25
        println!("Non-interactive terminal detected. Run coder-lite from a TTY.");
26
        return Ok(());
27
    }
28
29
    enable_raw_mode()?;
30
    let mut stdout = stdout();
31
    stdout.execute(EnterAlternateScreen)?;
32
33
    let mut stderr = stderr();
34
    let flags = event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
35
        | event::KeyboardEnhancementFlags::REPORT_EVENT_TYPES
36
        | event::KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES;
37
    let _ = crossterm::execute!(stderr, PushKeyboardEnhancementFlags(flags));
38
39
    let backend = CrosstermBackend::new(stdout);
40
    let mut terminal = Terminal::new(backend)?;
41
    terminal.show_cursor()?;
42
43
    let (tx, rx) = mpsc::channel::<Control>();
44
    let mut ui = CoderUi::new();
45
46
    loop {
47
        while let Ok(control) = rx.try_recv() {
48
            match control {
49
                Control::Chunk(chunk) => {
50
                    if let Some(last) = ui.entries.last_mut() {
51
                        last.text.push_str(&chunk);
52
                        ui.scroll_override = None;
53
                    }
54
                }
55
                Control::Done => ui.loading = false,
56
            }
57
        }
58
59
        terminal.draw(|f| {
60
            let size = f.area();
61
            ui.render(f, size);
62
        })?;
63
64
        if event::poll(Duration::from_millis(50))? {
65
            if let Event::Key(key) = event::read()? {
66
                if key.kind != KeyEventKind::Press {
67
                    continue;
68
                }
69
                match key {
70
                    KeyEvent {
71
                        code: KeyCode::Esc,
72
                        ..
73
                    }
74
                    | KeyEvent {
75
                        code: KeyCode::Char('q' | 'c' | 'd'),
76
                        modifiers: KeyModifiers::CONTROL,
77
                        ..
78
                    } => break,
79
                    KeyEvent {
80
                        code: KeyCode::Enter,
81
                        modifiers,
82
                        ..
83
                    } => {
84
                        if modifiers.contains(KeyModifiers::SHIFT) {
85
                            ui.composer.push('\n');
86
                        } else if !ui.composer.trim().is_empty() {
87
                            let prompt = ui.composer.clone();
88
                            ui.entries.push(Entry {
89
                                role: Role::You,
90
                                text: prompt.clone(),
91
                            });
92
                            ui.entries.push(Entry {
93
                                role: Role::Assistant,
94
                                text: String::new(),
95
                            });
96
                            ui.composer.clear();
97
                            ui.scroll_override = None;
98
                            ui.loading = true;
99
100
                            let mut session = CoderRuntimeSession::new();
101
                            let tx = tx.clone();
102
                            tokio::spawn(async move {
103
                                let _ = session.execute_turn(&prompt, tx).await;
104
                            });
105
                        }
106
                    }
107
                    KeyEvent {
108
                        code: KeyCode::Backspace,
109
                        ..
110
                    } => {
111
                        ui.composer.pop();
112
                    }
113
                    KeyEvent {
114
                        code: KeyCode::Up,
115
                        ..
116
                    } => ui.scroll_by(-1),
117
                    KeyEvent {
118
                        code: KeyCode::Down,
119
                        ..
120
                    } => ui.scroll_by(1),
121
                    KeyEvent {
122
                        code: KeyCode::PageUp,
123
                        ..
124
                    } => {
125
                        let page = ui.transcript_height.max(1) as i32;
126
                        ui.scroll_by(-page);
127
                    }
128
                    KeyEvent {
129
                        code: KeyCode::PageDown,
130
                        ..
131
                    } => {
132
                        let page = ui.transcript_height.max(1) as i32;
133
                        ui.scroll_by(page);
134
                    }
135
                    KeyEvent {
136
                        code: KeyCode::Char(c),
137
                        modifiers,
138
                        ..
139
                    } if !modifiers.contains(KeyModifiers::CONTROL)
140
                        && !modifiers.contains(KeyModifiers::ALT) =>
141
                    {
142
                        ui.composer.push(c);
143
                    }
144
                    _ => {}
145
                }
146
            }
147
        }
148
    }
149
150
    terminal.hide_cursor()?;
151
    let _ = crossterm::execute!(stderr, PopKeyboardEnhancementFlags);
152
    disable_raw_mode()?;
153
    std::io::stdout().execute(LeaveAlternateScreen)?;
154
    Ok(())
155
}
156
157
fn atty_is_terminal() -> bool {
158
    std::io::IsTerminal::is_terminal(&std::io::stdin())
159
        && std::io::IsTerminal::is_terminal(&std::io::stdout())
160
}
crates/coder-lite/src/lib.rs added +5

@@ -0,0 +1,5 @@

1
//! coder-lite: a minimal ratatui TUI boot crate
2
3
pub mod interactive;
4
pub mod runtime;
5
pub mod tui;
crates/coder-lite/src/main.rs added +80

@@ -0,0 +1,80 @@

1
//! coder-lite binary entry point.
2
3
use std::env;
4
use std::path::PathBuf;
5
use std::time::Duration;
6
use tokio::process::Command;
7
use tokio::time::sleep;
8
9
const DEV_BASE_URL: &str = "http://localhost:4000/api/v1";
10
const DEV_API_KEY: &str = "fake";
11
12
#[tokio::main]
13
async fn main() -> Result<(), Box<dyn std::error::Error>> {
14
    let dev = env::args().any(|a| a == "--dev");
15
16
    if dev {
17
        boot_dev_server().await?;
18
        if env::var("OPENAGENTS_BASE_URL").is_err() {
19
            env::set_var("OPENAGENTS_BASE_URL", DEV_BASE_URL);
20
        }
21
        if env::var("OPENAGENTS_API_KEY").is_err() {
22
            env::set_var("OPENAGENTS_API_KEY", DEV_API_KEY);
23
        }
24
    }
25
26
    coder_lite::interactive::run_tui().await
27
}
28
29
async fn boot_dev_server() -> Result<(), Box<dyn std::error::Error>> {
30
    if is_dev_server_up().await {
31
        eprintln!("dev server already running at {}", DEV_BASE_URL);
32
        return Ok(());
33
    }
34
35
    let repo = web_repo()?;
36
    eprintln!("starting dev server in {}", repo.display());
37
38
    Command::new("sh")
39
        .arg("start_server.sh")
40
        .current_dir(&repo)
41
        .spawn()?;
42
43
    for _ in 0..60 {
44
        if is_dev_server_up().await {
45
            return Ok(());
46
        }
47
        sleep(Duration::from_secs(1)).await;
48
    }
49
50
    Err("dev server did not become ready in 60 seconds".into())
51
}
52
53
async fn is_dev_server_up() -> bool {
54
    let client = reqwest::Client::builder()
55
        .timeout(Duration::from_secs(2))
56
        .build()
57
        .unwrap_or_default();
58
    let url = format!("{}/health", DEV_BASE_URL);
59
    if let Ok(resp) = client.get(&url).send().await {
60
        resp.status().is_success()
61
    } else {
62
        false
63
    }
64
}
65
66
fn web_repo() -> Result<PathBuf, Box<dyn std::error::Error>> {
67
    if let Ok(dir) = env::var("OPENAGENTS_WEB_REPO") {
68
        return Ok(PathBuf::from(dir));
69
    }
70
    let here = env::current_dir()?;
71
    let candidate = here.join("../openagents.com");
72
    let canonical = candidate.canonicalize()?;
73
    if !canonical.is_dir() {
74
        return Err(format!("{} is not a directory", canonical.display()).into());
75
    }
76
    if !canonical.join("start_server.sh").is_file() {
77
        return Err(format!("{} has no start_server.sh", canonical.display()).into());
78
    }
79
    Ok(canonical)
80
}
crates/coder-lite/src/runtime.rs added +97

@@ -0,0 +1,97 @@

1
//! Open Responses streaming client for coder-lite.
2
3
use futures::StreamExt;
4
use openresponses_rust::{CreateResponseBody, Input, Item, StreamingClient, StreamingEvent};
5
use std::env;
6
use std::sync::mpsc::Sender;
7
8
pub enum Control {
9
    Chunk(String),
10
    Done,
11
}
12
13
pub struct CoderRuntimeSession {
14
    pub api_key: String,
15
    pub base_url: String,
16
    pub history: Vec<Item>,
17
}
18
19
impl CoderRuntimeSession {
20
    pub fn new() -> Self {
21
        Self {
22
            api_key: env::var("OPENAGENTS_API_KEY").unwrap_or_default(),
23
            base_url: env::var("OPENAGENTS_BASE_URL")
24
                .unwrap_or_else(|_| "https://openagents.com/api/v1".to_string()),
25
            history: Vec::new(),
26
        }
27
    }
28
29
    pub async fn execute_turn(
30
        &mut self,
31
        prompt: &str,
32
        tx: Sender<Control>,
33
    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
34
        if self.api_key.is_empty() {
35
            let _ = tx.send(Control::Chunk(
36
                "[error: OPENAGENTS_API_KEY is not set]".to_string(),
37
            ));
38
            let _ = tx.send(Control::Done);
39
            return Err("OPENAGENTS_API_KEY is not set".into());
40
        }
41
42
        self.history.push(Item::user_message(prompt));
43
44
        let client = StreamingClient::with_base_url(&self.api_key, &self.base_url);
45
46
        let request = CreateResponseBody {
47
            model: env::var("OPENAGENTS_MODEL").ok(),
48
            input: Some(Input::Items(self.history.clone())),
49
            stream: Some(true),
50
            ..Default::default()
51
        };
52
53
        let mut stream = match client.stream_response(request).await {
54
            Ok(s) => s,
55
            Err(e) => {
56
                let _ = tx.send(Control::Chunk(format!("[error: {}]", e)));
57
                let _ = tx.send(Control::Done);
58
                return Err(e.into());
59
            }
60
        };
61
62
        let mut collected = String::new();
63
64
        while let Some(event) = stream.next().await {
65
            match event {
66
                Ok(StreamingEvent::OutputTextDelta { delta, .. }) => {
67
                    collected.push_str(&delta);
68
                    let _ = tx.send(Control::Chunk(delta));
69
                }
70
                Ok(StreamingEvent::ReasoningDelta { delta, .. }) => {
71
                    collected.push_str(&delta);
72
                    let _ = tx.send(Control::Chunk(delta));
73
                }
74
                Ok(StreamingEvent::RefusalDelta { delta, .. }) => {
75
                    let _ = tx.send(Control::Chunk(delta));
76
                }
77
                Ok(StreamingEvent::Error { error, .. }) => {
78
                    let msg = format!("[error: {:?}]", error);
79
                    let _ = tx.send(Control::Chunk(msg));
80
                }
81
                Ok(_) => {}
82
                Err(e) => {
83
                    let _ = tx.send(Control::Chunk(format!("[error: {}]", e)));
84
                    let _ = tx.send(Control::Done);
85
                    return Err(e.into());
86
                }
87
            }
88
        }
89
90
        if !collected.is_empty() {
91
            self.history.push(Item::assistant_message(collected));
92
        }
93
94
        let _ = tx.send(Control::Done);
95
        Ok(())
96
    }
97
}
crates/coder-lite/src/tui.rs added +290

@@ -0,0 +1,290 @@

1
//! Full-screen coder TUI layout matching packages/openagents-cli/src/coder-ui.ts
2
3
use ratatui::{
4
    layout::{Constraint, Direction, Layout, Position, Rect},
5
    style::{Color, Style},
6
    text::{Line, Span, Text},
7
    widgets::{Block, Borders, Paragraph},
8
    Frame,
9
};
10
11
const TEXT_COLOR: Color = Color::Rgb(255, 176, 0);
12
const BACKGROUND_COLOR: Color = Color::Rgb(8, 6, 0);
13
14
#[derive(Debug, Clone)]
15
pub enum Role {
16
    You,
17
    Assistant,
18
    Tool,
19
    Reasoning,
20
    Notice,
21
}
22
23
#[derive(Debug, Clone)]
24
pub struct Entry {
25
    pub role: Role,
26
    pub text: String,
27
}
28
29
#[derive(Debug)]
30
pub struct CoderUi {
31
    pub composer: String,
32
    pub repo: String,
33
    pub branch: String,
34
    pub model: String,
35
    pub reasoning: Option<String>,
36
    pub running: bool,
37
    pub entries: Vec<Entry>,
38
    /// Manual scroll override; `None` means the viewport follows the bottom.
39
    pub scroll_override: Option<u16>,
40
    pub scroll_max: u16,
41
    pub transcript_height: u16,
42
    pub loading: bool,
43
}
44
45
fn wrap_text(text: &str, width: usize) -> Vec<String> {
46
    if width == 0 {
47
        return vec![text.to_string()];
48
    }
49
    let mut lines = Vec::new();
50
    let mut current = String::new();
51
    let mut current_width = 0;
52
53
    for word in text.split_whitespace() {
54
        let word_width = word.chars().count();
55
        if current.is_empty() {
56
            current.push_str(word);
57
            current_width = word_width;
58
        } else if current_width + 1 + word_width <= width {
59
            current.push(' ');
60
            current.push_str(word);
61
            current_width += 1 + word_width;
62
        } else {
63
            lines.push(current);
64
            current = word.to_string();
65
            current_width = word_width;
66
        }
67
    }
68
69
    if !current.is_empty() {
70
        lines.push(current);
71
    }
72
73
    if lines.is_empty() {
74
        lines.push(text.to_string());
75
    }
76
77
    lines
78
}
79
80
fn wrap_input(text: &str, width: usize) -> Vec<String> {
81
    if width == 0 {
82
        return vec![text.to_string()];
83
    }
84
    let mut lines = Vec::new();
85
86
    for paragraph in text.split('\n') {
87
        if paragraph.is_empty() {
88
            lines.push(String::new());
89
            continue;
90
        }
91
92
        let mut current = String::new();
93
        let mut current_width = 0;
94
        for word in paragraph.split_whitespace() {
95
            let word_width = word.chars().count();
96
            if current.is_empty() {
97
                current.push_str(word);
98
                current_width = word_width;
99
            } else if current_width + 1 + word_width <= width {
100
                current.push(' ');
101
                current.push_str(word);
102
                current_width += 1 + word_width;
103
            } else {
104
                lines.push(current);
105
                current = word.to_string();
106
                current_width = word_width;
107
            }
108
        }
109
110
        if !current.is_empty() {
111
            lines.push(current);
112
        }
113
    }
114
115
    if lines.is_empty() {
116
        lines.push(String::new());
117
    }
118
119
    lines
120
}
121
122
impl CoderUi {
123
    pub fn new() -> Self {
124
        Self {
125
            composer: String::new(),
126
            repo: "~/work/openagents".to_string(),
127
            branch: "main".to_string(),
128
            model: "sol-high".to_string(),
129
            reasoning: Some("medium".to_string()),
130
            running: true,
131
            entries: vec![],
132
            scroll_override: None,
133
            scroll_max: 0,
134
            transcript_height: 0,
135
            loading: false,
136
        }
137
    }
138
139
    pub fn render(&mut self, frame: &mut Frame, area: Rect) {
140
        let style = Style::default().fg(TEXT_COLOR).bg(BACKGROUND_COLOR);
141
142
        // Fill the entire terminal with the background color first.
143
        let bg_line = Line::from(vec![Span::styled(
144
            " ".repeat(area.width as usize),
145
            style,
146
        )]);
147
        let bg = Paragraph::new(Text::from(vec![bg_line; area.height as usize]));
148
        frame.render_widget(bg, area);
149
150
        // ---- composer input (grok-style) ----
151
        let input_width = (area.width as usize)
152
            .saturating_sub(2)
153
            .saturating_sub(3)
154
            .max(1);
155
        let input_chunks = wrap_input(&self.composer, input_width);
156
        let total_input_lines = input_chunks.len() as u16;
157
        let max_input_lines: u16 = 8;
158
        let visible_input_lines = total_input_lines.min(max_input_lines);
159
        let input_scroll = total_input_lines.saturating_sub(visible_input_lines);
160
        let input_box_height = visible_input_lines + 2;
161
        let status_height = if self.loading { 1 } else { 0 };
162
        let bottom_height = input_box_height + status_height;
163
164
        let main_bottom = Layout::default()
165
            .direction(Direction::Vertical)
166
            .constraints([Constraint::Min(0), Constraint::Length(bottom_height)])
167
            .split(area);
168
169
        let transcript_area = main_bottom[0];
170
171
        let mut all_lines = Vec::new();
172
        for entry in &self.entries {
173
            all_lines.extend(self.render_entry(entry, transcript_area.width as usize));
174
        }
175
176
        let total = all_lines.len() as u16;
177
        self.transcript_height = transcript_area.height;
178
        self.scroll_max = total.saturating_sub(transcript_area.height);
179
        let start = self.effective_scroll(transcript_area.height, total);
180
181
        let transcript = Paragraph::new(Text::from(all_lines))
182
            .scroll((start, 0))
183
            .style(style);
184
        frame.render_widget(transcript, transcript_area);
185
186
        let bottom = main_bottom[1];
187
        let bottom_chunks = if self.loading {
188
            Layout::default()
189
                .direction(Direction::Vertical)
190
                .constraints([Constraint::Length(1), Constraint::Length(input_box_height)])
191
                .split(bottom)
192
        } else {
193
            Layout::default()
194
                .direction(Direction::Vertical)
195
                .constraints([Constraint::Length(input_box_height)])
196
                .split(bottom)
197
        };
198
199
        if self.loading {
200
            let status = Paragraph::new(Line::from(vec![Span::styled("● working…", style)]))
201
                .style(style);
202
            frame.render_widget(status, bottom_chunks[0]);
203
        }
204
205
        let input_idx = if self.loading { 1 } else { 0 };
206
207
        let mut input_lines = Vec::new();
208
        for (i, chunk) in input_chunks.iter().enumerate().skip(input_scroll as usize) {
209
            let prefix = if i == 0 { " > " } else { "   " };
210
            input_lines.push(Line::from(vec![
211
                Span::styled(prefix, style),
212
                Span::styled(chunk.clone(), style),
213
            ]));
214
        }
215
216
        let input = Paragraph::new(Text::from(input_lines))
217
            .style(style)
218
            .block(
219
                Block::default()
220
                    .borders(Borders::ALL)
221
                    .border_style(style)
222
                    .style(style),
223
            );
224
        frame.render_widget(input, bottom_chunks[input_idx]);
225
226
        let input_area = bottom_chunks[input_idx];
227
        let last_chunk = input_chunks.last().map(|s| s.as_str()).unwrap_or("");
228
        let cursor_x = input_area.x + 1 + 3 + last_chunk.chars().count() as u16;
229
        let cursor_y = input_area.y + 1 + visible_input_lines.saturating_sub(1);
230
        frame.set_cursor_position(Position::new(cursor_x, cursor_y));
231
    }
232
233
    fn render_entry(&self, entry: &Entry, width: usize) -> Vec<Line<'static>> {
234
        let text_style = Style::default().fg(TEXT_COLOR).bg(BACKGROUND_COLOR);
235
236
        let (first_prefix, marker, marker_space, rest_indent, first_body) = match entry.role {
237
            Role::You => ("", ">", " ", "  ", width.saturating_sub(2)),
238
            Role::Assistant => ("", "", "", "", width),
239
            _ => ("  ", "⏺", " ", "     ", width.saturating_sub(4)),
240
        };
241
242
        let chunks = wrap_text(&entry.text, first_body);
243
244
        let mut lines = Vec::new();
245
        for (i, chunk) in chunks.iter().enumerate() {
246
            if i == 0 {
247
                lines.push(Line::from(vec![
248
                    Span::styled(first_prefix, text_style),
249
                    Span::styled(marker, text_style),
250
                    Span::styled(marker_space, text_style),
251
                    Span::styled(chunk.clone(), text_style),
252
                ]));
253
            } else {
254
                lines.push(Line::from(vec![
255
                    Span::styled(rest_indent, text_style),
256
                    Span::styled(chunk.clone(), text_style),
257
                ]));
258
            }
259
        }
260
        lines
261
    }
262
263
    /// Calculate the scroll offset that keeps the viewport at the bottom
264
    /// unless the user has manually overridden it.
265
    ///
266
    /// Mirrors grok-build's `effective_scroll` pattern: a `None` override
267
    /// follows the content, while an explicit offset is clamped to the valid
268
    /// range and left in place.
269
    fn effective_scroll(&self, area_height: u16, total: u16) -> u16 {
270
        let max_scroll = total.saturating_sub(area_height);
271
        match self.scroll_override {
272
            Some(ovr) => ovr.min(max_scroll),
273
            None => max_scroll,
274
        }
275
    }
276
277
    /// Scroll the transcript by `delta` lines. Positive scrolls down, negative
278
    /// up. Reaching the bottom clears the override so the viewport resumes
279
    /// following new messages.
280
    pub fn scroll_by(&mut self, delta: i32) {
281
        let max = self.scroll_max;
282
        let current = self.scroll_override.unwrap_or(max);
283
        let new = if delta < 0 {
284
            current.saturating_sub((-delta) as u16)
285
        } else {
286
            current.saturating_add(delta as u16).min(max)
287
        };
288
        self.scroll_override = if new >= max { None } else { Some(new) };
289
    }
290
}
crates/coder-lite/tests/smoke.rs added +44

@@ -0,0 +1,44 @@

1
use coder_lite::runtime::{CoderRuntimeSession, Control};
2
use std::sync::mpsc;
3
use std::time::Duration;
4
5
#[tokio::test]
6
async fn smoke_openresponses_stream() {
7
    if env_unset("OPENAGENTS_API_KEY") {
8
        std::env::set_var("OPENAGENTS_API_KEY", "fake");
9
    }
10
    if env_unset("OPENAGENTS_BASE_URL") {
11
        std::env::set_var("OPENAGENTS_BASE_URL", "https://openagents.com/api/v1");
12
    }
13
14
    let (tx, rx) = mpsc::channel::<Control>();
15
    let mut session = CoderRuntimeSession::new();
16
17
    tokio::spawn(async move {
18
        let _ = session.execute_turn("hi", tx).await;
19
    });
20
21
    let mut collected = String::new();
22
    let mut done = false;
23
    let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
24
25
    while !done && tokio::time::Instant::now() < deadline {
26
        match rx.try_recv() {
27
            Ok(Control::Chunk(c)) => {
28
                eprint!("{}", c);
29
                collected.push_str(&c);
30
            }
31
            Ok(Control::Done) => done = true,
32
            Err(_) => tokio::time::sleep(Duration::from_millis(100)).await,
33
        }
34
    }
35
36
    eprintln!("\n---COLLECTED---");
37
    eprintln!("{}", collected);
38
    assert!(!collected.is_empty(), "no response chunks received");
39
    assert!(!collected.starts_with("[error:"), "received an error: {}", collected);
40
}
41
42
fn env_unset(name: &str) -> bool {
43
    std::env::var(name).is_err()
44
}

This page updates live while a promote is in flight · changelog