33
44//! Interactive shell mode for qauld-ctl.
55//!
6- //! Reads command lines from stdin, parses each line through the same `clap`
7- //! grammar used by single-shot mode, and dispatches them via the existing
8- //! `run` function. A fresh socket is opened per command — the same code path
9- //! that single-shot uses, so behaviour is identical aside from the prompt
10- //! and the persistent process.
6+ //! Reads command lines through `rustyline` (so arrow up/down recall history
7+ //! and arrow left/right move the cursor for editing), tokenises them with
8+ //! shell-style quoting (`shlex`, so `chat send -m "hello my friend"` works),
9+ //! parses each line through the same `clap` grammar used by single-shot mode,
10+ //! and dispatches it via the existing `run` function. A fresh socket is opened
11+ //! per command — the same code path single-shot uses, so behaviour is
12+ //! identical aside from the prompt and the persistent process.
1113//!
12- //! The shell also opens a long-running event subscription on a separate
13- //! socket and prints incoming events in-line. When an event arrives while
14- //! the user is typing, we print the event on its own line and re-print the
15- //! prompt; characters typed before the event remain in the kernel's line
16- //! buffer (canonical mode) and will be delivered on Enter, but they will
17- //! visually appear on the new prompt line.
18- //!
19- //! Limitations of this initial implementation:
20- //! - No line editing or history (we use canonical-mode line input via
21- //! `tokio::io::stdin`); add `rustyline` or similar later if the
22- //! experience demands it.
23- //! - Argument tokenisation uses `split_whitespace`, so quoted arguments
24- //! (`feed send -m "hello world"`) won't parse correctly. Use single-word
25- //! arguments for now, or add a shell-style splitter (e.g. `shlex`) later.
14+ //! A long-running event subscription runs in the background and prints
15+ //! incoming events via rustyline's `ExternalPrinter`, which redraws the line
16+ //! being edited so async events never corrupt the user's input.
2617
27- use clap:: Parser ;
28- use tokio:: io:: { AsyncBufReadExt , AsyncWriteExt , BufReader } ;
18+ use clap:: { CommandFactory , Parser } ;
19+ use rustyline:: error:: ReadlineError ;
20+ use rustyline:: { DefaultEditor , ExternalPrinter } ;
2921
3022use crate :: cli:: { Cli , Commands } ;
3123
32- const PROMPT : & [ u8 ] = b "qauld> ";
24+ const PROMPT : & str = "qauld> " ;
3325
3426/// Run the interactive shell loop until the user types `quit` / `exit`
3527/// or sends EOF (Ctrl-D).
@@ -39,71 +31,70 @@ const PROMPT: &[u8] = b"qauld> ";
3931/// / `--dir` are ignored so the connection target is predictable; per-line
4032/// `--json` is honoured per command.
4133pub async fn run ( shell_cli : Cli ) -> Result < ( ) , Box < dyn std:: error:: Error > > {
42- print_banner ( ) ;
43-
44- // Open a background event subscription. If the daemon isn't running
45- // yet we surface a warning but keep the shell usable — the user can
46- // still type commands; each command will fail with the same
47- // "connection failed" message until the daemon is up.
48- let mut event_rx = match crate :: subscribe:: spawn_event_listener ( & shell_cli) . await {
49- Ok ( rx) => Some ( rx) ,
34+ // Line editor: history (↑/↓), cursor movement + editing (←/↑ etc.).
35+ let mut rl = DefaultEditor :: new ( ) ?;
36+ let history = history_path ( ) ;
37+ if let Some ( path) = history. as_ref ( ) {
38+ // A missing history file on first run is fine.
39+ let _ = rl. load_history ( path) ;
40+ }
41+
42+ // Background event subscription. Events print via rustyline's external
43+ // printer so they don't corrupt the line being edited. If the daemon
44+ // isn't up yet we warn but keep the shell usable.
45+ match crate :: subscribe:: spawn_event_listener ( & shell_cli) . await {
46+ Ok ( mut event_rx) => match rl. create_external_printer ( ) {
47+ Ok ( mut printer) => {
48+ tokio:: spawn ( async move {
49+ while let Some ( line) = event_rx. recv ( ) . await {
50+ if printer. print ( format ! ( "{line}\n " ) ) . is_err ( ) {
51+ break ;
52+ }
53+ }
54+ } ) ;
55+ }
56+ Err ( e) => {
57+ eprintln ! ( "shell: live events unavailable ({e}); commands still work" ) ;
58+ }
59+ } ,
5060 Err ( e) => {
51- eprintln ! (
52- "shell: event subscription unavailable ({e}); commands still work"
53- ) ;
54- None
61+ eprintln ! ( "shell: event subscription unavailable ({e}); commands still work" ) ;
5562 }
56- } ;
63+ }
5764
58- let mut stdout = tokio :: io :: stdout ( ) ;
59- let stdin = tokio :: io :: stdin ( ) ;
60- let mut reader = BufReader :: new ( stdin ) . lines ( ) ;
65+ // Show the help screen on startup.
66+ print_intro ( ) ;
67+ print_help ( ) ;
6168
6269 loop {
63- if let Err ( e) = write_prompt ( & mut stdout) . await {
64- log:: error!( "shell: failed to write prompt: {e}" ) ;
65- break ;
66- }
67-
68- // Race the user's next line against any pushed events. If an
69- // event wins, we print it and loop back to redraw the prompt.
70- let line = loop {
71- tokio:: select! {
72- biased;
73- ev = recv_event( & mut event_rx) => {
74- if let Some ( line) = ev {
75- // Drop to a fresh line, print the event, redraw prompt.
76- // Any chars the user typed-but-not-Entered are still
77- // in the kernel's line buffer and will be delivered
78- // on Enter — they just visually move to the new line.
79- print_async_event( & mut stdout, & line) . await ;
80- if let Err ( e) = write_prompt( & mut stdout) . await {
81- log:: error!( "shell: failed to redraw prompt: {e}" ) ;
82- return Ok ( ( ) ) ;
83- }
84- }
85- }
86- next = reader. next_line( ) => {
87- match next {
88- Ok ( Some ( l) ) => break l,
89- Ok ( None ) => {
90- // EOF (Ctrl-D)
91- println!( ) ;
92- return Ok ( ( ) ) ;
93- }
94- Err ( e) => {
95- log:: error!( "shell: failed to read line: {e}" ) ;
96- return Ok ( ( ) ) ;
97- }
98- }
99- }
70+ // `rustyline::readline` is blocking, so run it on the blocking pool;
71+ // move the editor in and back out so history persists across lines.
72+ let prompt = PROMPT . to_string ( ) ;
73+ let ( editor, readline) = tokio:: task:: spawn_blocking ( move || {
74+ let res = rl. readline ( & prompt) ;
75+ ( rl, res)
76+ } )
77+ . await ?;
78+ rl = editor;
79+
80+ let line = match readline {
81+ Ok ( l) => l,
82+ // Ctrl-C: abandon the current line and show a fresh prompt.
83+ Err ( ReadlineError :: Interrupted ) => continue ,
84+ // Ctrl-D: exit the shell.
85+ Err ( ReadlineError :: Eof ) => break ,
86+ Err ( e) => {
87+ eprintln ! ( "shell: input error: {e}" ) ;
88+ break ;
10089 }
10190 } ;
10291
10392 let trimmed = line. trim ( ) ;
10493 if trimmed. is_empty ( ) {
10594 continue ;
10695 }
96+ let _ = rl. add_history_entry ( trimmed) ;
97+
10798 if trimmed == "quit" || trimmed == "exit" {
10899 break ;
109100 }
@@ -112,14 +103,25 @@ pub async fn run(shell_cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
112103 continue ;
113104 }
114105
115- // Parse the line through the same clap grammar as single-shot mode.
106+ // Tokenise with shell-style quoting so quoted values with spaces are a
107+ // single argument. `shlex::split` returns None on unbalanced quotes.
108+ let tokens = match shlex:: split ( trimmed) {
109+ Some ( t) if !t. is_empty ( ) => t,
110+ Some ( _) => continue ,
111+ None => {
112+ eprintln ! ( "error: unbalanced quotes in command" ) ;
113+ continue ;
114+ }
115+ } ;
116+
116117 // Prepend the binary name so clap's parser is happy.
117- let argv = std:: iter:: once ( "qauld-ctl" )
118- . chain ( trimmed . split_whitespace ( ) )
118+ let argv = std:: iter:: once ( "qauld-ctl" . to_string ( ) )
119+ . chain ( tokens )
119120 . collect :: < Vec < _ > > ( ) ;
120121 let parsed = match Cli :: try_parse_from ( & argv) {
121122 Ok ( p) => p,
122123 Err ( e) => {
124+ // clap renders help/usage/errors here, including `<cmd> --help`.
123125 eprintln ! ( "{e}" ) ;
124126 continue ;
125127 }
@@ -146,73 +148,43 @@ pub async fn run(shell_cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
146148 command : parsed. command ,
147149 } ;
148150
149- let mut transport = match qauld_rpc:: SocketTransport :: connect ( & crate :: connect_info ( & shell_cli) ) . await {
150- Ok ( t) => t,
151- Err ( e) => {
152- eprintln ! ( "connection failed: {e}" ) ;
153- continue ;
154- }
155- } ;
151+ let mut transport =
152+ match qauld_rpc:: SocketTransport :: connect ( & crate :: connect_info ( & shell_cli) ) . await {
153+ Ok ( t) => t,
154+ Err ( e) => {
155+ eprintln ! ( "connection failed: {e}" ) ;
156+ continue ;
157+ }
158+ } ;
156159 if let Err ( e) = crate :: run ( & mut transport, merged) . await {
157160 eprintln ! ( "error: {e}" ) ;
158161 }
159162 }
160163
164+ if let Some ( path) = history. as_ref ( ) {
165+ let _ = rl. save_history ( path) ;
166+ }
161167 println ! ( "bye" ) ;
162168 Ok ( ( ) )
163169}
164170
165- async fn write_prompt (
166- stdout : & mut tokio:: io:: Stdout ,
167- ) -> Result < ( ) , Box < dyn std:: error:: Error > > {
168- stdout. write_all ( PROMPT ) . await ?;
169- stdout. flush ( ) . await ?;
170- Ok ( ( ) )
171- }
172-
173- /// Receive the next event from the listener. Returns `None` if the
174- /// listener was never started (no daemon at shell launch) or has gone
175- /// away. We never return early on `None` from a live channel; instead
176- /// we drop the receiver so future iterations skip the event branch.
177- async fn recv_event ( rx : & mut Option < tokio:: sync:: mpsc:: UnboundedReceiver < String > > ) -> Option < String > {
178- match rx {
179- Some ( r) => match r. recv ( ) . await {
180- Some ( line) => Some ( line) ,
181- None => {
182- // Channel closed; drop the receiver so we don't poll it again.
183- * rx = None ;
184- None
185- }
186- } ,
187- None => {
188- // Park forever so `tokio::select!` always picks the stdin branch.
189- std:: future:: pending :: < Option < String > > ( ) . await
190- }
191- }
192- }
193-
194- async fn print_async_event ( stdout : & mut tokio:: io:: Stdout , line : & str ) {
195- // Carriage return + clear-rest-of-line (ANSI), then the event, then a
196- // newline. The clear-rest-of-line keeps the prompt from leaking onto
197- // the event line in terminals that support it; in terminals that
198- // don't, the worst case is a stale "qauld> " prefix on the event row.
199- let payload = format ! ( "\r \x1b [K{line}\n " ) ;
200- let _ = stdout. write_all ( payload. as_bytes ( ) ) . await ;
201- let _ = stdout. flush ( ) . await ;
171+ /// Where to persist shell history across sessions. `None` (no `$HOME`) just
172+ /// means in-session-only history — arrow-key recall still works.
173+ fn history_path ( ) -> Option < std:: path:: PathBuf > {
174+ let home = std:: env:: var_os ( "HOME" ) . or_else ( || std:: env:: var_os ( "USERPROFILE" ) ) ?;
175+ Some ( std:: path:: PathBuf :: from ( home) . join ( ".qauld-ctl_history" ) )
202176}
203177
204- fn print_banner ( ) {
205- println ! ( "qauld-ctl interactive shell" ) ;
206- println ! ( "Type a qauld-ctl command (without the leading binary name)." ) ;
207- println ! ( "Examples: node info | users list | help | quit" ) ;
208- println ! ( "Live events from qauld will appear in this window as they happen." ) ;
178+ fn print_intro ( ) {
179+ println ! ( "qauld-ctl interactive shell — type a command (no leading binary name)." ) ;
180+ println ! ( " history: ↑/↓ edit line: ←/→ help: help exit: quit / Ctrl-D" ) ;
181+ println ! ( "Live events from qauld appear inline as they happen." ) ;
209182 println ! ( ) ;
210183}
211184
185+ /// Render the real clap help (command list + options) so `help` and the
186+ /// startup screen show the actual grammar rather than a vague pointer.
212187fn print_help ( ) {
213- println ! ( "Available commands are the same as qauld-ctl in single-shot mode." ) ;
214- println ! ( "Run `qauld-ctl --help` outside the shell for the full grammar, or" ) ;
215- println ! ( "type any subcommand with `--help` here to see its options, e.g." ) ;
216- println ! ( " users --help" ) ;
217- println ! ( "Quoted arguments with spaces are not yet supported in shell mode." ) ;
188+ let mut cmd = Cli :: command ( ) ;
189+ println ! ( "{}" , cmd. render_help( ) ) ;
218190}
0 commit comments