Download crates/ds4-cli/src/repl.rs from staccs/ds4-winhip: direct link, hf CLI and curl.
- Browser
- Download file 19.2 kB
-
https://huggingface.co/staccs/ds4-winhip/resolve/main/crates/ds4-cli/src/repl.rs
- Command line
-
hf download hf://staccs/ds4-winhip/crates/ds4-cli/src/repl.rs
-
curl -L -o repl.rs https://huggingface.co/staccs/ds4-winhip/resolve/main/crates/ds4-cli/src/repl.rs
19.2 kB
| //! Interactive CLI commands matching `ds4_cli.c` `print_repl_help` / `run_repl`. | |
| use std::sync::atomic::{AtomicBool, Ordering}; | |
| static INTERRUPTED: AtomicBool = AtomicBool::new(false); | |
| extern "C" fn sigint_handler(_: libc::c_int) { | |
| INTERRUPTED.store(true, Ordering::SeqCst); | |
| } | |
| pub fn interrupt_requested() -> bool { | |
| INTERRUPTED.load(Ordering::SeqCst) | |
| } | |
| pub fn interrupt_clear() { | |
| INTERRUPTED.store(false, Ordering::SeqCst); | |
| } | |
| pub struct InterruptGuard { | |
| old: Option<libc::sigaction>, | |
| } | |
| impl InterruptGuard { | |
| pub fn install() -> Self { | |
| interrupt_clear(); | |
| // SAFETY: [Category 4 — Uninitialized Memory] `sigaction` is a C POD; | |
| // all-zero is a valid bit pattern. `old` is written by `sigaction` | |
| // before any field is read; `sa` is filled (handler, mask) before install. | |
| let mut old = unsafe { std::mem::zeroed::<libc::sigaction>() }; | |
| let mut sa = unsafe { std::mem::zeroed::<libc::sigaction>() }; | |
| sa.sa_sigaction = sigint_handler as *const () as usize; | |
| // SAFETY: [Category 8 — FFI] `sigemptyset` only writes the caller-owned | |
| // mask; the pointer is not retained. | |
| unsafe { | |
| libc::sigemptyset(&mut sa.sa_mask); | |
| } | |
| // SAFETY: [Category 8 — FFI] installs a process-wide SIGINT handler and | |
| // copies the previous action into `old` so Drop can restore it. `sa` is | |
| // fully initialized; both pointers are stack-owned for the call only. | |
| let rc = unsafe { libc::sigaction(libc::SIGINT, &sa, &mut old) }; | |
| Self { | |
| old: (rc == 0).then_some(old), | |
| } | |
| } | |
| } | |
| impl Drop for InterruptGuard { | |
| fn drop(&mut self) { | |
| if let Some(old) = self.old.take() { | |
| // SAFETY: [Category 8 — FFI] restores the previous SIGINT action | |
| // captured at install. `old` is a complete `sigaction`; NULL oact | |
| // is allowed by POSIX. | |
| unsafe { | |
| libc::sigaction(libc::SIGINT, &old, std::ptr::null_mut()); | |
| } | |
| } | |
| } | |
| } | |
| pub enum InterruptEnd { | |
| Rollback, | |
| KeepEos, | |
| } | |
| pub fn interrupt_end(interrupted: bool, generated: i32) -> InterruptEnd { | |
| if interrupted && generated == 0 { | |
| InterruptEnd::Rollback | |
| } else { | |
| InterruptEnd::KeepEos | |
| } | |
| } | |
| unsafe extern "C" { | |
| fn linenoise(prompt: *const libc::c_char) -> *mut libc::c_char; | |
| fn linenoiseFree(ptr: *mut libc::c_void); | |
| fn linenoiseSetMultiLine(ml: libc::c_int); | |
| fn linenoiseHistorySetMaxLen(len: libc::c_int) -> libc::c_int; | |
| fn linenoiseHistoryLoad(filename: *const libc::c_char) -> libc::c_int; | |
| fn linenoiseHistoryAdd(line: *const libc::c_char) -> libc::c_int; | |
| fn linenoiseHistorySave(filename: *const libc::c_char) -> libc::c_int; | |
| } | |
| pub enum ReplRead { | |
| Line(String), | |
| Eof, | |
| Interrupted, | |
| } | |
| pub fn stdin_is_tty() -> bool { | |
| // SAFETY: [Category 8 — FFI] `STDIN_FILENO` is the process stdin; `isatty` | |
| // only inspects the descriptor and does not retain it. | |
| unsafe { libc::isatty(libc::STDIN_FILENO) == 1 } | |
| } | |
| pub fn init_linenoise_history(path: &std::path::Path) { | |
| let Some(c_path) = path.to_str().and_then(|p| std::ffi::CString::new(p).ok()) else { | |
| return; | |
| }; | |
| // SAFETY: [Category 8 — FFI] linenoise config/load take a NUL-terminated | |
| // path borrowed for the call. The C library copies what it needs; `c_path` | |
| // outlives the three calls. | |
| unsafe { | |
| linenoiseSetMultiLine(1); | |
| linenoiseHistorySetMaxLen(HISTORY_MAX_LEN as libc::c_int); | |
| linenoiseHistoryLoad(c_path.as_ptr()); | |
| } | |
| } | |
| pub fn remember_linenoise_line(path: &std::path::Path, line: &str) { | |
| let Ok(c_line) = std::ffi::CString::new(line) else { | |
| return; | |
| }; | |
| let Some(c_path) = path.to_str().and_then(|p| std::ffi::CString::new(p).ok()) else { | |
| return; | |
| }; | |
| // SAFETY: [Category 8 — FFI] both pointers are live CStrings borrowed for | |
| // the call. linenoise copies the line into its history; it does not retain | |
| // the path pointer after return. | |
| unsafe { | |
| linenoiseHistoryAdd(c_line.as_ptr()); | |
| linenoiseHistorySave(c_path.as_ptr()); | |
| } | |
| } | |
| pub fn read_repl_line(prompt: &str) -> Result<ReplRead, String> { | |
| if stdin_is_tty() { | |
| return read_linenoise_line(prompt); | |
| } | |
| read_plain_line() | |
| } | |
| fn read_linenoise_line(prompt: &str) -> Result<ReplRead, String> { | |
| let c_prompt = std::ffi::CString::new(prompt).map_err(|_| "prompt contains NUL".to_string())?; | |
| // SAFETY: [Category 8 — FFI] prompt is a live CString; linenoise does not | |
| // retain it. A non-NULL return is a heap C string owned until `linenoiseFree`. | |
| let ptr = unsafe { linenoise(c_prompt.as_ptr()) }; | |
| if ptr.is_null() { | |
| if interrupt_requested() | |
| || std::io::Error::last_os_error().raw_os_error() == Some(libc::EAGAIN) | |
| { | |
| return Ok(ReplRead::Interrupted); | |
| } | |
| return Ok(ReplRead::Eof); | |
| } | |
| // SAFETY: [Category 8 — FFI] `ptr` is non-null (checked) and NUL-terminated | |
| // by linenoise; we copy to an owned String before free. | |
| let line = unsafe { std::ffi::CStr::from_ptr(ptr) } | |
| .to_string_lossy() | |
| .into_owned(); | |
| // SAFETY: [Category 12 — Double free] exclusive ownership of the linenoise | |
| // allocation; this is the only free, after the CStr copy. | |
| unsafe { linenoiseFree(ptr.cast()) }; | |
| Ok(ReplRead::Line(line)) | |
| } | |
| fn read_plain_line() -> Result<ReplRead, String> { | |
| use std::io::{self, BufRead, Write}; | |
| let mut stdout = io::stdout(); | |
| write!(stdout, "ds4> ").map_err(|e| e.to_string())?; | |
| stdout.flush().map_err(|e| e.to_string())?; | |
| let mut line = String::new(); | |
| match io::stdin().lock().read_line(&mut line) { | |
| Ok(0) if interrupt_requested() => Ok(ReplRead::Interrupted), | |
| Ok(0) => Ok(ReplRead::Eof), | |
| Ok(_) => Ok(ReplRead::Line(line)), | |
| Err(error) if error.kind() == io::ErrorKind::Interrupted => Ok(ReplRead::Interrupted), | |
| Err(error) => Err(error.to_string()), | |
| } | |
| } | |
| pub const HISTORY_MAX_LEN: usize = 512; | |
| pub fn history_file_path() -> std::path::PathBuf { | |
| let home = std::env::var_os("HOME").filter(|home| !home.is_empty()); | |
| std::path::PathBuf::from(home.unwrap_or_else(|| ".".into())).join(".ds4_history") | |
| } | |
| pub struct History { | |
| path: std::path::PathBuf, | |
| lines: Vec<String>, | |
| } | |
| impl History { | |
| pub fn load(path: std::path::PathBuf) -> Self { | |
| let mut hist = Self { | |
| path, | |
| lines: Vec::new(), | |
| }; | |
| let Ok(bytes) = std::fs::read(&hist.path) else { | |
| return hist; | |
| }; | |
| for raw in bytes.split(|byte| *byte == b'\n') { | |
| if raw.is_empty() { | |
| continue; | |
| } | |
| let mut entry = raw.to_vec(); | |
| for byte in &mut entry { | |
| if *byte == b'\r' { | |
| *byte = b'\n'; | |
| } | |
| } | |
| hist.add(String::from_utf8_lossy(&entry).into_owned()); | |
| } | |
| hist | |
| } | |
| pub fn add(&mut self, line: String) -> bool { | |
| if line.is_empty() { | |
| return false; | |
| } | |
| if self.lines.last().is_some_and(|last| last == &line) { | |
| return false; | |
| } | |
| if self.lines.len() == HISTORY_MAX_LEN { | |
| self.lines.remove(0); | |
| } | |
| self.lines.push(line); | |
| true | |
| } | |
| pub fn save(&self) -> std::io::Result<()> { | |
| use std::io::Write; | |
| use std::os::unix::fs::OpenOptionsExt; | |
| let mut file = std::fs::OpenOptions::new() | |
| .create(true) | |
| .write(true) | |
| .truncate(true) | |
| .mode(0o600) | |
| .open(&self.path)?; | |
| for line in &self.lines { | |
| for &byte in line.as_bytes() { | |
| file.write_all(&[if byte == b'\n' { b'\r' } else { byte }])?; | |
| } | |
| file.write_all(b"\n")?; | |
| } | |
| file.flush() | |
| } | |
| pub fn lines(&self) -> &[String] { | |
| &self.lines | |
| } | |
| } | |
| pub const REPL_HELP: &str = "\ | |
| Commands: | |
| /help Show this help. | |
| /think Use normal thinking mode. | |
| /think-max Use Think Max only when context is at least 393216 tokens. | |
| /nothink Disable thinking mode. | |
| /ctx N Set context size for following prompts. | |
| /power N Set GPU duty cycle percentage, 1..100. | |
| /read FILE Read a prompt from FILE and run it. | |
| /quit, /exit Leave the prompt. | |
| Ctrl+C Stop generation and return to the prompt. | |
| "; | |
| pub enum ReplLine { | |
| Help, | |
| Think, | |
| ThinkMax, | |
| NoThink, | |
| Ctx(i32), | |
| Power(Option<i32>), | |
| Read(String), | |
| Quit, | |
| Prompt(String), | |
| Empty, | |
| } | |
| pub enum ReplError { | |
| UnknownCommand(String), | |
| CtxNeedsPositive, | |
| PowerRange, | |
| ReadNeedsPath, | |
| } | |
| impl ReplError { | |
| pub fn message(&self) -> String { | |
| match self { | |
| ReplError::UnknownCommand(cmd) => { | |
| format!("ds4: unknown command: {cmd}\nds4: type /help for commands\n") | |
| } | |
| ReplError::CtxNeedsPositive => "ds4: /ctx needs a positive integer\n".into(), | |
| ReplError::PowerRange => "ds4: /power must be between 1 and 100\n".into(), | |
| ReplError::ReadNeedsPath => "ds4: /read needs a file path\n".into(), | |
| } | |
| } | |
| } | |
| pub fn trim_inplace(s: &str) -> &str { | |
| s.trim() | |
| } | |
| pub fn parse_repl_line(line: &str) -> Result<ReplLine, ReplError> { | |
| let cmd = trim_inplace(line); | |
| if cmd.is_empty() { | |
| return Ok(ReplLine::Empty); | |
| } | |
| if cmd == "/help" { | |
| return Ok(ReplLine::Help); | |
| } | |
| if cmd == "/think" { | |
| return Ok(ReplLine::Think); | |
| } | |
| if cmd == "/think-max" { | |
| return Ok(ReplLine::ThinkMax); | |
| } | |
| if cmd == "/nothink" { | |
| return Ok(ReplLine::NoThink); | |
| } | |
| if cmd == "/quit" || cmd == "/exit" { | |
| return Ok(ReplLine::Quit); | |
| } | |
| if let Some(rest) = strip_command(cmd, "/power") { | |
| if rest.is_empty() { | |
| return Ok(ReplLine::Power(None)); | |
| } | |
| return parse_power_percent(rest).map(|v| ReplLine::Power(Some(v))); | |
| } | |
| if let Some(rest) = strip_command(cmd, "/ctx") { | |
| if rest.is_empty() { | |
| return Err(ReplError::CtxNeedsPositive); | |
| } | |
| return parse_positive_int(rest) | |
| .ok_or(ReplError::CtxNeedsPositive) | |
| .map(ReplLine::Ctx); | |
| } | |
| if let Some(rest) = strip_command(cmd, "/read") { | |
| if rest.is_empty() { | |
| return Err(ReplError::ReadNeedsPath); | |
| } | |
| return Ok(ReplLine::Read(rest.to_string())); | |
| } | |
| if cmd.starts_with('/') { | |
| return Err(ReplError::UnknownCommand(cmd.to_string())); | |
| } | |
| Ok(ReplLine::Prompt(cmd.to_string())) | |
| } | |
| fn strip_command<'a>(cmd: &'a str, name: &str) -> Option<&'a str> { | |
| let rest = cmd.strip_prefix(name)?; | |
| if rest.is_empty() { | |
| return Some(""); | |
| } | |
| if rest.starts_with(|c: char| c.is_ascii_whitespace()) { | |
| return Some(rest.trim()); | |
| } | |
| None | |
| } | |
| fn parse_positive_int(s: &str) -> Option<i32> { | |
| let v = s.parse::<i32>().ok()?; | |
| (v > 0).then_some(v) | |
| } | |
| fn parse_power_percent(s: &str) -> Result<i32, ReplError> { | |
| let v = s.parse::<i32>().map_err(|_| ReplError::PowerRange)?; | |
| if (1..=100).contains(&v) { | |
| Ok(v) | |
| } else { | |
| Err(ReplError::PowerRange) | |
| } | |
| } | |
| pub fn think_mode_message(mode: ThinkRepl) -> &'static str { | |
| match mode { | |
| ThinkRepl::High => "Thinking mode: high.", | |
| ThinkRepl::Max => "Thinking mode: max.", | |
| ThinkRepl::HighBelowCtx => "Thinking mode: high (ctx below 393216).", | |
| ThinkRepl::None => "Thinking mode: none.", | |
| } | |
| } | |
| pub enum ThinkRepl { | |
| High, | |
| Max, | |
| HighBelowCtx, | |
| None, | |
| } | |
| pub const THINK_MAX_MIN_CTX: i32 = 393216; | |
| pub fn think_max_active(ctx: i32) -> bool { | |
| ctx >= THINK_MAX_MIN_CTX | |
| } | |
| pub enum ReplThinkCmd { | |
| None, | |
| Low, | |
| Max, | |
| } | |
| pub struct ReplChat { | |
| pub(crate) conversation: Option<crate::conversation::Conversation>, | |
| pub transcript: ds4_core::TokenBuffer, | |
| pub prefix_tokens: usize, | |
| pub think: ReplThinkCmd, | |
| pub ctx: i32, | |
| } | |
| impl ReplChat { | |
| pub fn new(nothink: bool, ctx: i32) -> Self { | |
| Self { | |
| conversation: None, | |
| transcript: ds4_core::TokenBuffer::new(), | |
| prefix_tokens: 0, | |
| think: if nothink { | |
| ReplThinkCmd::None | |
| } else { | |
| ReplThinkCmd::Low | |
| }, | |
| ctx, | |
| } | |
| } | |
| pub fn effective_think(&self) -> ds4_core::ChatThinkMode { | |
| match self.think { | |
| ReplThinkCmd::None => ds4_core::ChatThinkMode::None, | |
| ReplThinkCmd::Low => ds4_core::ChatThinkMode::Low, | |
| ReplThinkCmd::Max if think_max_active(self.ctx) => ds4_core::ChatThinkMode::High, | |
| ReplThinkCmd::Max => ds4_core::ChatThinkMode::Low, | |
| } | |
| } | |
| pub fn thinking_enabled(&self) -> bool { | |
| self.effective_think() != ds4_core::ChatThinkMode::None | |
| } | |
| pub fn think_message(&self) -> &'static str { | |
| think_mode_message(match self.think { | |
| ReplThinkCmd::None => ThinkRepl::None, | |
| ReplThinkCmd::Low => ThinkRepl::High, | |
| ReplThinkCmd::Max if think_max_active(self.ctx) => ThinkRepl::Max, | |
| ReplThinkCmd::Max => ThinkRepl::HighBelowCtx, | |
| }) | |
| } | |
| pub fn wants_effort_prefix(&self) -> bool { | |
| matches!(self.effective_think(), ds4_core::ChatThinkMode::High) | |
| } | |
| } | |
| mod tests { | |
| use super::*; | |
| fn help_text_matches_c() { | |
| assert!(REPL_HELP.contains("/help Show this help.")); | |
| assert!(REPL_HELP.contains("/quit, /exit Leave the prompt.")); | |
| assert!(REPL_HELP.contains("Ctrl+C Stop generation and return to the prompt.")); | |
| } | |
| fn parses_commands() { | |
| assert_eq!(parse_repl_line(" /help ").unwrap(), ReplLine::Help); | |
| assert_eq!(parse_repl_line("/think").unwrap(), ReplLine::Think); | |
| assert_eq!(parse_repl_line("/think-max").unwrap(), ReplLine::ThinkMax); | |
| assert_eq!(parse_repl_line("/nothink").unwrap(), ReplLine::NoThink); | |
| assert_eq!(parse_repl_line("/quit").unwrap(), ReplLine::Quit); | |
| assert_eq!(parse_repl_line("/exit").unwrap(), ReplLine::Quit); | |
| assert_eq!(parse_repl_line("/ctx 8192").unwrap(), ReplLine::Ctx(8192)); | |
| assert_eq!(parse_repl_line("/power").unwrap(), ReplLine::Power(None)); | |
| assert_eq!( | |
| parse_repl_line("/power 50").unwrap(), | |
| ReplLine::Power(Some(50)) | |
| ); | |
| assert_eq!( | |
| parse_repl_line("/read notes.txt").unwrap(), | |
| ReplLine::Read("notes.txt".into()) | |
| ); | |
| assert_eq!( | |
| parse_repl_line("hello there").unwrap(), | |
| ReplLine::Prompt("hello there".into()) | |
| ); | |
| assert_eq!(parse_repl_line(" ").unwrap(), ReplLine::Empty); | |
| } | |
| fn unknown_and_invalid_match_c() { | |
| assert_eq!( | |
| parse_repl_line("/nope").unwrap_err().message(), | |
| "ds4: unknown command: /nope\nds4: type /help for commands\n" | |
| ); | |
| assert_eq!( | |
| parse_repl_line("/ctx").unwrap_err().message(), | |
| "ds4: /ctx needs a positive integer\n" | |
| ); | |
| assert_eq!( | |
| parse_repl_line("/power 0").unwrap_err().message(), | |
| "ds4: /power must be between 1 and 100\n" | |
| ); | |
| assert_eq!( | |
| parse_repl_line("/read").unwrap_err().message(), | |
| "ds4: /read needs a file path\n" | |
| ); | |
| } | |
| fn think_messages_match_c() { | |
| assert_eq!(think_mode_message(ThinkRepl::High), "Thinking mode: high."); | |
| assert_eq!(think_mode_message(ThinkRepl::Max), "Thinking mode: max."); | |
| assert_eq!( | |
| think_mode_message(ThinkRepl::HighBelowCtx), | |
| "Thinking mode: high (ctx below 393216)." | |
| ); | |
| assert_eq!(think_mode_message(ThinkRepl::None), "Thinking mode: none."); | |
| assert!(!think_max_active(32768)); | |
| assert!(think_max_active(393216)); | |
| } | |
| fn repl_chat_think_gates_match_c() { | |
| let low = ReplChat::new(false, 32768); | |
| assert_eq!(low.effective_think(), ds4_core::ChatThinkMode::Low); | |
| assert!(low.thinking_enabled()); | |
| assert!(!low.wants_effort_prefix()); | |
| assert_eq!(low.think_message(), "Thinking mode: high."); | |
| let none = ReplChat::new(true, 32768); | |
| assert_eq!(none.effective_think(), ds4_core::ChatThinkMode::None); | |
| assert!(!none.thinking_enabled()); | |
| assert_eq!(none.think_message(), "Thinking mode: none."); | |
| let mut max = ReplChat::new(false, 32768); | |
| max.think = ReplThinkCmd::Max; | |
| assert_eq!(max.effective_think(), ds4_core::ChatThinkMode::Low); | |
| assert!(!max.wants_effort_prefix()); | |
| assert_eq!( | |
| max.think_message(), | |
| "Thinking mode: high (ctx below 393216)." | |
| ); | |
| max.ctx = THINK_MAX_MIN_CTX; | |
| assert_eq!(max.effective_think(), ds4_core::ChatThinkMode::High); | |
| assert!(max.wants_effort_prefix()); | |
| assert_eq!(max.think_message(), "Thinking mode: max."); | |
| } | |
| fn interrupt_end_matches_c() { | |
| assert_eq!(interrupt_end(true, 0), InterruptEnd::Rollback); | |
| assert_eq!(interrupt_end(true, 1), InterruptEnd::KeepEos); | |
| assert_eq!(interrupt_end(false, 0), InterruptEnd::KeepEos); | |
| assert_eq!(interrupt_end(false, 4), InterruptEnd::KeepEos); | |
| } | |
| fn history_path_add_and_cr_roundtrip_match_c() { | |
| let old = std::env::var_os("HOME"); | |
| std::env::set_var("HOME", "/tmp/ds4-home"); | |
| assert_eq!( | |
| history_file_path(), | |
| std::path::PathBuf::from("/tmp/ds4-home/.ds4_history") | |
| ); | |
| std::env::set_var("HOME", ""); | |
| assert_eq!( | |
| history_file_path(), | |
| std::path::PathBuf::from("./.ds4_history") | |
| ); | |
| match old { | |
| Some(home) => std::env::set_var("HOME", home), | |
| None => std::env::remove_var("HOME"), | |
| } | |
| let path = std::env::temp_dir().join(format!( | |
| "ds4-history-{}-{}", | |
| std::process::id(), | |
| std::time::SystemTime::now() | |
| .duration_since(std::time::UNIX_EPOCH) | |
| .map(|d| d.as_nanos()) | |
| .unwrap_or(0) | |
| )); | |
| let mut hist = History::load(path.clone()); | |
| assert!(hist.add("hello".into())); | |
| assert!(!hist.add("hello".into())); | |
| assert!(hist.add("a\nb".into())); | |
| hist.save().unwrap(); | |
| let raw = std::fs::read(&path).unwrap(); | |
| assert_eq!(raw, b"hello\na\rb\n"); | |
| let loaded = History::load(path.clone()); | |
| assert_eq!(loaded.lines(), &["hello".to_string(), "a\nb".to_string()]); | |
| let _ = std::fs::remove_file(path); | |
| } | |
| } | |