@@ -259,48 +259,108 @@ unsafe fn hide_window() {
259259 msg1 ( w, sel ( b"orderOut:\0 " ) , std:: ptr:: null_mut ( ) ) ;
260260}
261261
262- // ── Subprocess-based prompt input ────────────────────────────────────────────
263-
264- /// Show a prompt input dialog via a subprocess (`clx-prompt`).
265- /// The subprocess has no CGEventTap, so keyboard input works normally.
266- pub fn show_prompt_panel ( title : & str , message : & str , prefill : & str ) -> Option < String > {
267- use std:: process:: Command ;
268-
269- // Find the clx-prompt binary next to the main binary.
270- let prompt_bin = {
271- let exe = std:: env:: current_exe ( ) . unwrap_or_default ( ) ;
272- let dir = exe. parent ( ) . unwrap_or ( std:: path:: Path :: new ( "." ) ) ;
273- dir. join ( "clx-prompt" )
274- } ;
262+ // ── Daemon-based prompt input (zero cold-start) ──────────────────────────────
275263
276- // Fall back to looking in the cargo target directory.
277- let prompt_bin = if prompt_bin. exists ( ) {
278- prompt_bin
279- } else {
280- // Try relative to CWD or in PATH.
281- std:: path:: PathBuf :: from ( "clx-prompt" )
282- } ;
264+ use std:: io:: { BufRead , BufReader , Write } ;
265+ use std:: process:: { Child , ChildStdin , ChildStdout , Stdio } ;
266+ use std:: sync:: OnceLock ;
267+
268+ struct PromptDaemon {
269+ _child : Child ,
270+ stdin : ChildStdin ,
271+ stdout : BufReader < ChildStdout > ,
272+ }
273+
274+ impl Drop for PromptDaemon {
275+ fn drop ( & mut self ) {
276+ // Kill the child explicitly so it doesn't outlive us as an orphan.
277+ // Without this, repeated clx restarts (watchdog) leak clx-prompt
278+ // processes (each ~74 MB) until the user reboots.
279+ let _ = self . _child . kill ( ) ;
280+ let _ = self . _child . wait ( ) ;
281+ }
282+ }
283283
284- eprintln ! ( "[CLX] launching prompt subprocess: {:?}" , prompt_bin) ;
285-
286- let output = Command :: new ( & prompt_bin)
287- . arg ( title)
288- . arg ( message)
289- . arg ( prefill)
290- . output ( ) ;
291-
292- match output {
293- Ok ( out) => {
294- if out. status . success ( ) {
295- let text = String :: from_utf8_lossy ( & out. stdout ) . into_owned ( ) ;
296- if text. is_empty ( ) { None } else { Some ( text) }
297- } else {
298- eprintln ! ( "[CLX] prompt cancelled (exit code {:?})" , out. status. code( ) ) ;
299- None
284+ static DAEMON : OnceLock < Mutex < PromptDaemon > > = OnceLock :: new ( ) ;
285+
286+ fn prompt_bin_path ( ) -> std:: path:: PathBuf {
287+ let exe = std:: env:: current_exe ( ) . unwrap_or_default ( ) ;
288+ let dir = exe. parent ( ) . unwrap_or ( std:: path:: Path :: new ( "." ) ) ;
289+ // Check same dir as binary, then bin/ subdir (where build.sh deploys it).
290+ for candidate in [ dir. join ( "clx-prompt" ) , dir. join ( "bin" ) . join ( "clx-prompt" ) ] {
291+ if candidate. exists ( ) {
292+ // Verify it's the Tauri build, not an old AppKit binary.
293+ if let Ok ( bytes) = std:: fs:: read ( & candidate) {
294+ if bytes. windows ( 5 ) . any ( |w| w == b"tauri" ) {
295+ return candidate;
296+ }
297+ eprintln ! ( "[CLX] skipping non-Tauri clx-prompt at {:?}" , candidate) ;
300298 }
301299 }
302- Err ( e) => {
303- eprintln ! ( "[CLX] failed to launch clx-prompt: {}" , e) ;
300+ }
301+ std:: path:: PathBuf :: from ( "clx-prompt" )
302+ }
303+
304+ /// Pre-spawn the clx-prompt daemon so the window is warm before first use.
305+ /// Call this at CLX startup. Safe to call multiple times.
306+ pub fn spawn_prompt_daemon ( ) {
307+ DAEMON . get_or_init ( || {
308+ let bin = prompt_bin_path ( ) ;
309+ eprintln ! ( "[CLX] spawning clx-prompt daemon: {:?}" , bin) ;
310+ let mut child = std:: process:: Command :: new ( & bin)
311+ . arg ( "--daemon" )
312+ . stdin ( Stdio :: piped ( ) )
313+ . stdout ( Stdio :: piped ( ) )
314+ . stderr ( Stdio :: inherit ( ) )
315+ . spawn ( )
316+ . expect ( "failed to spawn clx-prompt --daemon" ) ;
317+
318+ let stdin = child. stdin . take ( ) . expect ( "no stdin" ) ;
319+ let stdout = BufReader :: new ( child. stdout . take ( ) . expect ( "no stdout" ) ) ;
320+
321+ let mut daemon = PromptDaemon { _child : child, stdin, stdout } ;
322+
323+ // Wait for {"type":"ready"} line before returning.
324+ let mut line = String :: new ( ) ;
325+ let _ = daemon. stdout . read_line ( & mut line) ;
326+ eprintln ! ( "[CLX] clx-prompt daemon ready: {}" , line. trim( ) ) ;
327+
328+ Mutex :: new ( daemon)
329+ } ) ;
330+ }
331+
332+ /// Show the prompt dialog. Blocks until user submits or cancels.
333+ /// Returns the prompt text (with optional "[KEEP]\n" prefix), or None if cancelled.
334+ pub fn show_prompt_panel ( title : & str , context : & str , last_prompt : & str ) -> Option < String > {
335+ // Ensure daemon is running (lazy fallback if spawn_prompt_daemon wasn't called at startup).
336+ spawn_prompt_daemon ( ) ;
337+
338+ let daemon_lock = DAEMON . get ( ) ?;
339+ let mut d = daemon_lock. lock ( ) . ok ( ) ?;
340+
341+ // Send show command.
342+ let cmd = serde_json:: json!( {
343+ "cmd" : "show" ,
344+ "title" : title,
345+ "context" : context,
346+ "last_prompt" : last_prompt,
347+ } ) ;
348+ writeln ! ( d. stdin, "{cmd}" ) . ok ( ) ?;
349+ d. stdin . flush ( ) . ok ( ) ?;
350+
351+ // Block until we get a result line.
352+ let mut line = String :: new ( ) ;
353+ d. stdout . read_line ( & mut line) . ok ( ) ?;
354+ let msg: serde_json:: Value = serde_json:: from_str ( line. trim ( ) ) . ok ( ) ?;
355+
356+ match msg[ "type" ] . as_str ( ) ? {
357+ "submit" => {
358+ let text = msg[ "text" ] . as_str ( ) ?. to_string ( ) ;
359+ let keep = msg[ "keep" ] . as_bool ( ) . unwrap_or ( false ) ;
360+ Some ( if keep { format ! ( "[KEEP]\n {text}" ) } else { text } )
361+ }
362+ _ => {
363+ eprintln ! ( "[CLX] prompt cancelled" ) ;
304364 None
305365 }
306366 }
0 commit comments