tui/run.bend

cswap-bend-cli@0.1.0.0 · 309 lines · 54 definitions

1import Base
2import cswap-bend-cli@0.1.0.0/fs.bend as Fs
3import cswap-bend-cli@0.1.0.0/clock.bend as Clock
4import cswap-bend-cli@0.1.0.0/tui/fmt.bend as F
5import cswap-bend-cli@0.1.0.0/tui/model.bend as M
6import cswap-bend-cli@0.1.0.0/tui/view.bend as V
7import cswap-bend-cli@0.1.0.0/tui/read.bend as R
8import cswap-bend-cli@0.1.0.0/tui/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#   <backup root>/sequence.json, cache/usage.json, settings.json
13#   <state dir>/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 <slot>` switch action is not offered; use `cswap switch`.
23
24type Opts is Data:
25  Opts{once: Bool, interval: U32, color: Bool, bad: String}
26
27def usage_text() -> String:
28  "usage: cswap tui [--once] [--interval SECONDS] [--no-color]"
29
30# ---- arguments ----------------------------------------------------------------------------
31
32def set_interval(+o: Opts, +n: U32) -> Opts:
33  match o:
34    case Opts{once, i, c, b}:
35      Opts{once, n, c, b}
36
37def with_bad(+o: Opts, +msg: String) -> Opts:
38  match o:
39    case Opts{once, i, c, b}:
40      Opts{once, i, c, msg}
41
42def 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
49def with_once(+o: Opts) -> Opts:
50  match o:
51    case Opts{once, i, c, b}:
52      Opts{True{}, i, c, b}
53
54def with_plain(+o: Opts) -> Opts:
55  match o:
56    case Opts{once, i, c, b}:
57      Opts{once, i, False{}, b}
58
59def keep(+ok: Bool, +m: Maybe<&2, U32>) -> Maybe<&2, U32>:
60  match ok:
61    case True{}:
62      m
63    case False{}:
64      None{}
65
66def 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
73def default_opts() -> Opts:
74  Opts{False{}, 2, True{}, ""}
75
76# ---- reading ------------------------------------------------------------------------------------
77
78def 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
85def 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
88def text_pick(r: Fs.Res(String)) -> String:
89  match r:
90    case Fail{e}:
91      ""
92    case Done{t}:
93      t
94
95def 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
98def out_of(v: U32 & (String & String)) -> String:
99  (st, oe) = v
100  (o, e) = oe
101  o
102
103def 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
110def code_of(v: U32 & (String & String)) -> U32:
111  (st, oe) = v
112  st
113
114def 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.
122def 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
125def 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.
130def 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
137def now_pick(+pinned: String, +real: U32) -> U32:
138  N.read_or(U32.read(pinned), real)
139
140def 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
145def 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
148def pid_of(+status_t: String) -> Maybe<&2, U32>:
149  N.uint_at(R.f_pid(), N.parse_text(status_t))
150
151def frame_io(+v: M.View, +version: String, +root: String, +state: String) -> IO(String):
152  do IO<String>:
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
165def 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<Unit>:
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
175def width_of(+cols: String) -> U32:
176  N.read_or(positive(U32.read(cols)), 100)
177
178def color_of(+want: Bool, +no_color: String) -> Bool:
179  want && String.is_empty(no_color)
180
181def opts_color(+o: Opts) -> Bool:
182  match o:
183    case Opts{once, i, c, b}:
184      c
185
186def opts_once(+o: Opts) -> Bool:
187  match o:
188    case Opts{once, i, c, b}:
189      once
190
191def opts_secs(+o: Opts) -> U32:
192  match o:
193    case Opts{once, i, c, b}:
194      i
195
196def 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
199def 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
206def 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
209def 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
212def opts_bad(+o: Opts) -> String:
213  match o:
214    case Opts{once, i, c, b}:
215      b
216
217def 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
220def 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
223def start(+o: Opts, +version: String) -> IO(Unit):
224  do IO<Unit>:
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
230def 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
233def 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
240def 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.
252def 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
255def 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
262def 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
269def 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.
277def 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
286def 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
293def 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).
297def share(args: List<String>) -> List<&2, String>:
298  match args:
299    case Nil{}:
300      Nil{}
301    case Con{h, t}:
302      Con{h, share(t)}
303
304def 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.
308def main(args: List<String>, version: String) -> IO(Unit):
309  main2(share(args), version)