package miaou-runner
sectionYPositions = computeSectionYPositions($el), 10)"
x-init="setTimeout(() => sectionYPositions = computeSectionYPositions($el), 10)"
>
Miaou runner with backend selection
Install
dune-project
Dependency
Authors
Maintainers
Sources
v0.5.2.tar.gz
md5=60a3b9f181f24572a06a9492532bfdda
sha512=fcc35a275066be2900e6201782faf47503076fa4640f08cf78067835a6f447b74613009e55b2ac799adb7ca46f1bffa261fc5971753f2cc3c6bef327511c7ef6
doc/src/miaou-runner.tui/headless_json_runner.ml.html
Source file headless_json_runner.ml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225(******************************************************************************) (* *) (* SPDX-License-Identifier: MIT *) (* Copyright (c) 2026 Mathias Bourgoin <mathias.bourgoin@atacama.tech> *) (* *) (******************************************************************************) (** Headless JSON-over-stdio runner. Activated when [MIAOU_DRIVER=headless]. Reads newline-delimited JSON commands from stdin and writes JSON responses to stdout. The app binary becomes a subprocess that an AI agent or CI script can drive without a real terminal. *) [@@@warning "-32-34-37-69"] module HD = Lib_miaou_internal.Headless_driver module Tui_page = Miaou_core.Tui_page (* ── ANSI strip ─────────────────────────────────────────────────────────── *) (** Remove ANSI/VT escape sequences from [s], returning plain text. *) let ansi_strip s = let buf = Buffer.create (String.length s) in let n = String.length s in let i = ref 0 in while !i < n do if s.[!i] = '\x1b' then ( incr i ; if !i < n && s.[!i] = '[' then ( (* CSI sequence: ESC [ ... <final byte 0x40-0x7e> *) incr i ; while !i < n && (s.[!i] < '@' || s.[!i] > '~') do incr i done ; if !i < n then incr i (* consume final byte *)) else if !i < n then incr i (* skip single char after ESC (Fe sequence) *)) else ( Buffer.add_char buf s.[!i] ; incr i) done ; Buffer.contents buf (* ── on_frame callback ──────────────────────────────────────────────────── *) (** Optional callback invoked with the raw ANSI frame on every frame emit. Set by [run ~on_frame] before the command loop starts. The callback receives the terminal dimensions (rows, cols) followed by the raw ANSI data so that a viewer can resize its terminal to match. *) let on_frame_fn : (rows:int -> cols:int -> string -> unit) option ref = ref None (* ── Frame helpers ───────────────────────────────────────────────────────── *) let current_frame () = let size = HD.get_size () in let raw = HD.Screen.get () in (match !on_frame_fn with | Some f -> f ~rows:size.LTerm_geom.rows ~cols:size.LTerm_geom.cols raw | None -> ()) ; let text = ansi_strip raw in `Assoc [ ("type", `String "frame"); ("text", `String text); ("rows", `Int size.LTerm_geom.rows); ("cols", `Int size.LTerm_geom.cols); ] let action = `Assoc [("type", `String "nav"); ("action", `String action)] let error_response msg = `Assoc [("type", `String "error"); ("message", `String msg)] let emit json = (print_string [@allow_forbidden "headless runner writes JSON to stdout"]) (Yojson.Safe.to_string json) ; (print_char [@allow_forbidden "headless runner writes JSON to stdout"]) '\n' ; (flush [@allow_forbidden "headless runner flushes stdout"]) stdout (* ── Command dispatch ────────────────────────────────────────────────────── *) (** Process one parsed command. Returns [true] to keep running, [false] to stop the loop. *) let handle_cmd (cmd : (string * Yojson.Safe.t) list) : bool = let get_string key = match List.assoc_opt key cmd with Some (`String s) -> Some s | _ -> None in let get_int key = match List.assoc_opt key cmd with Some (`Int n) -> Some n | _ -> None in match get_string "cmd" with | None -> emit (error_response "Missing 'cmd' field") ; true | Some "render" -> emit (current_frame ()) ; true | Some "key" -> ( match get_string "key" with | None -> emit (error_response "Missing 'key' field") ; true | Some k -> ( let outcome = HD.Stateful.send_key k in (* Run a few idle ticks so timers/refresh settle *) let outcome = match outcome with | `Continue -> HD.Stateful.idle_wait ~iterations:3 () | other -> other in match outcome with | `Quit -> emit (nav_response "quit") ; false | `Back -> emit (nav_response "back") ; false | `SwitchTo name -> emit (nav_response ("switch:" ^ name)) ; true | `Continue -> emit (current_frame ()) ; true)) | Some "click" -> (* Click is not yet implemented in Stateful; just re-render *) ignore (get_int "row") ; ignore (get_int "col") ; emit (current_frame ()) ; true | Some "tick" -> ( let n = Option.value ~default:1 (get_int "n") in let outcome = HD.Stateful.idle_wait ~iterations:n () in (match outcome with | `Quit -> emit (nav_response "quit") | `Back -> emit (nav_response "back") | `SwitchTo name -> emit (nav_response ("switch:" ^ name)) | `Continue -> emit (current_frame ())) ; match outcome with `Quit | `Back -> false | _ -> true) | Some "resize" -> ( match (get_int "rows", get_int "cols") with | Some rows, Some cols -> HD.set_size rows cols ; emit (current_frame ()) ; true | _ -> emit (error_response "Missing 'rows' or 'cols'") ; true) | Some "quit" -> emit (nav_response "quit") ; false | Some other -> emit (error_response (Printf.sprintf "Unknown command: %s" other)) ; true (* ── Main entry point ────────────────────────────────────────────────────── *) let run ?on_frame (page : (module Tui_page.PAGE_SIG)) = on_frame_fn := on_frame ; (* Redirect stderr to /dev/null unless verbose mode requested *) (match Sys.getenv_opt "MIAOU_HEADLESS_VERBOSE" with | None | Some "" -> ( try let null_fd = Unix.openfile "/dev/null" [Unix.O_WRONLY] 0 in Unix.dup2 null_fd Unix.stderr ; Unix.close null_fd with _ -> ()) | _ -> ()) ; (* Unpack and repack to give OCaml a concrete module with an abstract state type, avoiding first-class module type-path issues with Stateful.init. *) let module P = (val page : Tui_page.PAGE_SIG) in HD.Stateful.init (module P) ; let rec loop () = (* Use run_in_systhread so the blocking input_line runs in a system thread. This keeps the eio event loop active, allowing background fibers (e.g. LLM subprocess I/O) to make progress between commands. *) match Eio_unix.run_in_systhread ~label:"headless-stdin" (fun () -> input_line stdin) with | exception End_of_file -> () | line -> ( let line = String.trim line in if line = "" then loop () else match Yojson.Safe.from_string line with | exception Yojson.Json_error msg -> emit (error_response ("JSON parse error: " ^ msg)) ; loop () | `Assoc pairs -> if handle_cmd pairs then loop () | _ -> emit (error_response "Expected a JSON object") ; loop ()) in (* When a viewer is attached, fork a daemon fiber that periodically re-renders the screen and broadcasts changes. Eio's cooperative scheduling ensures the refresh fiber only runs while the main loop is blocked on stdin (run_in_systhread), so there is no concurrent access to the headless driver state. *) (match !on_frame_fn with | Some _ -> Eio.Switch.run @@ fun sw -> Eio.Fiber.fork_daemon ~sw (fun () -> let prev = ref "" in while true do Eio_unix.sleep 0.2 ; (* Read the cached screen content written by the command handler. Do NOT call idle_wait here — that would race with the command handler's own idle_wait (both mutate the shared page-state ref and double-tick clocks/timers whenever the command handler yields). The cache is always fresh: every tick/key/render command updates it before returning a response. *) let size = HD.get_size () in let raw = HD.Screen.get () in if raw <> !prev then ( prev := raw ; match !on_frame_fn with | Some f -> f ~rows:size.LTerm_geom.rows ~cols:size.LTerm_geom.cols raw | None -> ()) done ; `Stop_daemon) ; loop () | None -> loop ()) ; (* Exit cleanly — same pattern as Tui_driver_common which calls exit 0 after its event loop to avoid blocking on pending Eio fibers. *) exit 0
sectionYPositions = computeSectionYPositions($el), 10)"
x-init="setTimeout(() => sectionYPositions = computeSectionYPositions($el), 10)"
>