1 import Base 2 import ../fs.bend as Fs 3 import ../clock.bend as Clock 4 import ./fmt.bend as F 5 import ./model.bend as M 6 import ./view.bend as V 7 import ./read.bend as R 8 import ./num.bend as N 9 10 # `cswap tui`: a read-only dashboard. It never fetches usage and never decides 11 # anything; it redraws from the files the daemon and the CLI keep: 12 # /sequence.json, cache/usage.json, settings.json 13 # /daemon-status.json, daemon.log (state dir: $CSWAP_STATE_DIR, 14 # else the backup root) 15 # 16 # Flags: --once (draw one frame and exit), --interval N (seconds between 17 # frames, default 2), --no-color. NO_COLOR and $COLUMNS (default 100) are 18 # honoured; $CSWAP_TUI_NOW (unix seconds) pins the clock for tests. 19 # 20 # Bend 2 has no raw key input, so there are no key bindings: the dashboard runs 21 # until it is killed (q and Ctrl-C do what Ctrl-C does to any program), and the 22 # reference's `s ` switch action is not offered; use `cswap switch`. 23 24 type Opts is Data: 25 Opts{once: Bool, interval: U32, color: Bool, bad: String} 26 27 def usage_text() -> String: 28 "usage: cswap tui [--once] [--interval SECONDS] [--no-color]" 29 30 # ---- arguments ---------------------------------------------------------------------------- 31 32 def set_interval(+o: Opts, +n: U32) -> Opts: 33 match o: 34 case Opts{once, i, c, b}: 35 Opts{once, n, c, b} 36 37 def with_bad(+o: Opts, +msg: String) -> Opts: 38 match o: 39 case Opts{once, i, c, b}: 40 Opts{once, i, c, msg} 41 42 def with_interval(+o: Opts, +v: Maybe<&2, U32>) -> Opts: 43 match v: 44 case None{}: 45 with_bad(o, "--interval needs a whole number of seconds") 46 case Some{n}: 47 set_interval(o, n) 48 49 def with_once(+o: Opts) -> Opts: 50 match o: 51 case Opts{once, i, c, b}: 52 Opts{True{}, i, c, b} 53 54 def with_plain(+o: Opts) -> Opts: 55 match o: 56 case Opts{once, i, c, b}: 57 Opts{once, i, False{}, b} 58 59 def keep(+ok: Bool, +m: Maybe<&2, U32>) -> Maybe<&2, U32>: 60 match ok: 61 case True{}: 62 m 63 case False{}: 64 None{} 65 66 def positive(+m: Maybe<&2, U32>) -> Maybe<&2, U32>: 67 match m: 68 case Some{n}: 69 keep(U32.is_gt(n, 0), m) 70 case None{}: 71 None{} 72 73 def default_opts() -> Opts: 74 Opts{False{}, 2, True{}, ""} 75 76 # ---- reading ------------------------------------------------------------------------------------ 77 78 def env_pick(r: Result<&1, &1, U32 & String, String>, +d: String) -> String: 79 match r: 80 case Fail{e}: 81 d 82 case Done{s}: 83 s 84 85 def env_or(+name: String, +d: String) -> IO(String): 86 IO.bind(Result<&1, &1, U32 & String, String>, String, IO.get_env(name), r => IO.pure(String, env_pick(r, d))) 87 88 def text_pick(r: Fs.Res(String)) -> String: 89 match r: 90 case Fail{e}: 91 "" 92 case Done{t}: 93 t 94 95 def read_text(+path: String) -> IO(String): 96 IO.bind(Fs.Res(String), String, Fs.read_file(path), r => IO.pure(String, text_pick(r))) 97 98 def out_of(v: U32 & (String & String)) -> String: 99 (st, oe) = v 100 (o, e) = oe 101 o 102 103 def out_pick(r: Result<&1, &1, U32 & String, U32 & (String & String)>) -> String: 104 match r: 105 case Fail{e}: 106 "" 107 case Done{v}: 108 out_of(v) 109 110 def code_of(v: U32 & (String & String)) -> U32: 111 (st, oe) = v 112 st 113 114 def code_pick(r: Result<&1, &1, U32 & String, U32 & (String & String)>) -> U32: 115 match r: 116 case Fail{e}: 117 1 118 case Done{v}: 119 code_of(v) 120 121 # The last lines of a file, "" when it is missing. 122 def tail_text(+path: String, +n: String) -> IO(String): 123 IO.bind(Result<&1, &1, U32 & String, U32 & (String & String)>, String, Process.run("tail", ["-n", n, path], "", 262144, 2000), r => IO.pure(String, out_pick(r))) 124 125 def local_off() -> IO(U32): 126 IO.bind(Result<&1, &1, U32 & String, U32 & (String & String)>, U32, Process.run("date", ["+%z"], "", 64, 2000), 127 r => IO.pure(U32, N.read_or(Clock.tz(String.trim(out_pick(r))), 86400))) 128 129 # Is the daemon's pid alive? `kill -0` answers without signalling. 130 def alive(+pid: Maybe<&2, U32>) -> IO(Bool): 131 match pid: 132 case None{}: 133 IO.pure(Bool, False{}) 134 case Some{p}: 135 IO.bind(Result<&1, &1, U32 & String, U32 & (String & String)>, Bool, Process.run("kill", ["-0", U32.show(p)], "", 64, 2000), r => IO.pure(Bool, U32.is_eq(code_pick(r), 0))) 136 137 def now_pick(+pinned: String, +real: U32) -> U32: 138 N.read_or(U32.read(pinned), real) 139 140 def clock_now() -> IO(U32): 141 IO.bind(String, U32, env_or("CSWAP_TUI_NOW", ""), p => IO.bind(U32, U32, Clock.now_s(), t => IO.pure(U32, now_pick(p, t)))) 142 143 # ---- one frame -------------------------------------------------------------------------------------- 144 145 def frame_of(+v: M.View, +version: String, +now: U32, +off: U32, +live: Bool, +seq_t: String, +cache_t: String, +status_t: String, +settings_t: String, +log_t: String) -> String: 146 V.frame(R.snap(version, live, now, off, seq_t, cache_t, status_t, settings_t, log_t), v) 147 148 def pid_of(+status_t: String) -> Maybe<&2, U32>: 149 N.uint_at(R.f_pid(), N.parse_text(status_t)) 150 151 def frame_io(+v: M.View, +version: String, +root: String, +state: String) -> IO(String): 152 do IO: 153 now : U32 <- clock_now() 154 off : U32 <- local_off() 155 seq_t : String <- read_text(root ++ "/sequence.json") 156 cache_t : String <- read_text(root ++ "/cache/usage.json") 157 settings_t : String <- read_text(root ++ "/settings.json") 158 +status_t : String <- read_text(state ++ "/daemon-status.json") 159 log_t : String <- tail_text(state ++ "/daemon.log", "8") 160 live : Bool <- alive(pid_of(status_t)) 161 IO.pure(String, frame_of(v, version, now, off, live, seq_t, cache_t, status_t, settings_t, log_t)) 162 163 # ---- the loop ------------------------------------------------------------------------------------------- 164 165 def clear() -> String: 166 "\u{1B}[H\u{1B}[2J" 167 168 @unsafe def forever(+v: M.View, +version: String, +root: String, +state: String, +secs: U32) -> IO(Unit): 169 do IO: 170 f : String <- frame_io(v, version, root, state) 171 IO.write(clear() ++ f ++ "\n") 172 IO.sleep((secs * 1000 : U32)) 173 forever(v, version, root, state, secs) 174 175 def width_of(+cols: String) -> U32: 176 N.read_or(positive(U32.read(cols)), 100) 177 178 def color_of(+want: Bool, +no_color: String) -> Bool: 179 want && String.is_empty(no_color) 180 181 def opts_color(+o: Opts) -> Bool: 182 match o: 183 case Opts{once, i, c, b}: 184 c 185 186 def opts_once(+o: Opts) -> Bool: 187 match o: 188 case Opts{once, i, c, b}: 189 once 190 191 def opts_secs(+o: Opts) -> U32: 192 match o: 193 case Opts{once, i, c, b}: 194 i 195 196 def once_frame(+v: M.View, +version: String, +root: String, +state: String) -> IO(Unit): 197 IO.bind(String, Unit, frame_io(v, version, root, state), f => IO.print(f)) 198 199 def pick_mode(+once: Bool, +v: M.View, +version: String, +root: String, +state: String, +secs: U32) -> IO(Unit): 200 match once: 201 case True{}: 202 once_frame(v, version, root, state) 203 case False{}: 204 forever(v, version, root, state, secs) 205 206 def run_with(+o: Opts, +version: String, +v: M.View, +root: String, +state: String) -> IO(Unit): 207 pick_mode(opts_once(o), v, version, root, state, opts_secs(o)) 208 209 def run(+o: Opts, +version: String, +cols: String, +no_color: String, +home: String, +state: String) -> IO(Unit): 210 run_with(o, version, M.View{width_of(cols), color_of(opts_color(o), no_color)}, home ++ "/.claude-swap-backup", state) 211 212 def opts_bad(+o: Opts) -> String: 213 match o: 214 case Opts{once, i, c, b}: 215 b 216 217 def start2(+o: Opts, +version: String, +cols: String, +no_color: String, +home: String, +st: String) -> IO(Unit): 218 run(o, version, cols, no_color, home, st) 219 220 def start1(+o: Opts, +version: String, +cols: String, +no_color: String, +home: String) -> IO(Unit): 221 IO.bind(String, Unit, env_or("CSWAP_STATE_DIR", home ++ "/.claude-swap-backup"), st => start2(o, version, cols, no_color, home, st)) 222 223 def start(+o: Opts, +version: String) -> IO(Unit): 224 do IO: 225 home : String <- IO.try(String, IO.get_env("HOME")) 226 cols : String <- env_or("COLUMNS", "") 227 no_color : String <- env_or("NO_COLOR", "") 228 start1(o, version, cols, no_color, home) 229 230 def reject(+msg: String) -> IO(Unit): 231 IO.bind(Unit, Unit, IO.print_err("cswap tui: error: " ++ msg ++ "\n" ++ usage_text()), u => IO.exit(2)) 232 233 def read_opt(+v: Maybe<&2, String>) -> Maybe<&2, U32>: 234 match v: 235 case None{}: 236 None{} 237 case Some{s}: 238 U32.read(s) 239 240 def step_pick(+once: Bool, +plain: Bool, +iv: Bool, +o: Opts, +a: String, +v: Maybe<&2, String>) -> Opts: 241 match once plain iv: 242 case True{} _ _: 243 with_once(o) 244 case False{} True{} _: 245 with_plain(o) 246 case False{} False{} True{}: 247 with_interval(o, positive(read_opt(v))) 248 case False{} False{} False{}: 249 with_bad(o, "unknown argument " ++ a) 250 251 # One argument: its effect on the options. `v` is the word after it. 252 def step(+o: Opts, +a: String, +v: Maybe<&2, String>) -> Opts: 253 step_pick(String.eq(a, "--once"), String.eq(a, "--no-color"), String.eq(a, "--interval"), o, a, v) 254 255 def head_opt(+l: List<&2, String>) -> Maybe<&2, String>: 256 match l: 257 case Nil{}: 258 None{} 259 case Con{h, t}: 260 Some{h} 261 262 def tail_of(+l: List<&2, String>) -> List<&2, String>: 263 match l: 264 case Nil{}: 265 Nil{} 266 case Con{h, t}: 267 t 268 269 def rest_after2(+iv: Bool, +t: List<&2, String>) -> List<&2, String>: 270 match iv: 271 case True{}: 272 tail_of(t) 273 case False{}: 274 t 275 276 # `fuel` is the argument count, at least the number of steps. 277 def parse(+fuel: Nat, +args: List<&2, String>, +o: Opts) -> Opts: 278 match fuel args: 279 case _ Nil{}: 280 o 281 case 0n _: 282 o 283 case 1n+p Con{a, t}: 284 parse(p, rest_after2(String.eq(a, "--interval"), t), step(o, a, head_opt(t))) 285 286 def dispatch_pick(+ok: Bool, +o: Opts, +version: String) -> IO(Unit): 287 match ok: 288 case True{}: 289 start(o, version) 290 case False{}: 291 reject(opts_bad(o)) 292 293 def dispatch(+o: Opts, +version: String) -> IO(Unit): 294 dispatch_pick(String.is_empty(opts_bad(o)), o, version) 295 296 # A reusable copy of an argument list (the elements are Data). 297 def share(args: List) -> List<&2, String>: 298 match args: 299 case Nil{}: 300 Nil{} 301 case Con{h, t}: 302 Con{h, share(t)} 303 304 def main2(+args: List<&2, String>, +version: String) -> IO(Unit): 305 dispatch(parse(List.length(&2, String, args), args, default_opts()), version) 306 307 # The entry the CLI dispatches `tui` to: `args` are the words after the verb. 308 def main(args: List, version: String) -> IO(Unit): 309 main2(share(args), version)