pub mod config;
pub mod data_context;
pub mod input;
pub mod layout;
pub mod logging;
pub mod plugins;
pub mod presets;
pub mod runtime;
pub mod segments;
pub mod theme;
pub use segments::builder::{build_default_segments, build_lines, build_segments};
use crate::segments::LineItem;
use std::io::{self, Read, Write};
pub fn run(reader: impl Read, writer: impl Write) -> io::Result<()> {
run_with_width(reader, writer, detect_terminal_width())
}
pub fn run_with_width(
reader: impl Read,
writer: impl Write,
terminal_width: u16,
) -> io::Result<()> {
let items = build_default_segments();
run_with_segments_and_width(reader, writer, &items, terminal_width)
}
pub fn run_with_segments_and_width(
reader: impl Read,
writer: impl Write,
items: &[LineItem],
terminal_width: u16,
) -> io::Result<()> {
let ctx = RunContext::new(
theme::default_theme(),
theme::Capability::None,
terminal_width,
None,
false,
);
run_with_context(reader, writer, &mut io::stderr().lock(), items, &ctx)
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct RunContext<'a> {
pub theme: &'a theme::Theme,
pub capability: theme::Capability,
pub terminal_width: u16,
pub cwd: Option<std::path::PathBuf>,
pub hyperlinks: bool,
}
impl<'a> RunContext<'a> {
#[must_use]
pub fn new(
theme: &'a theme::Theme,
capability: theme::Capability,
terminal_width: u16,
cwd: Option<std::path::PathBuf>,
hyperlinks: bool,
) -> Self {
Self {
theme,
capability,
terminal_width,
cwd,
hyperlinks,
}
}
}
pub fn run_with_context(
reader: impl Read,
writer: impl Write,
stderr: &mut dyn Write,
items: &[LineItem],
ctx: &RunContext<'_>,
) -> io::Result<()> {
run_lines_with_context(reader, writer, stderr, std::slice::from_ref(&items), ctx)
}
pub fn run_lines_with_context(
mut reader: impl Read,
mut writer: impl Write,
stderr: &mut dyn Write,
lines: &[&[LineItem]],
ctx: &RunContext<'_>,
) -> io::Result<()> {
let mut buf = Vec::new();
reader.read_to_end(&mut buf)?;
let status_ctx = match input::parse(&buf) {
Ok(c) => c,
Err(err) => {
let _ = writeln!(stderr, "linesmith: parse: {err}");
return writeln!(writer, "?");
}
};
let data_ctx = data_context::DataContext::with_cwd(status_ctx, ctx.cwd.clone());
for items in lines {
let mut warn = |msg: &str| {
let _ = writeln!(stderr, "linesmith: {msg}");
};
let mut observers = layout::LayoutObservers::new(&mut warn);
let line = layout::render_with_observers(
items,
&data_ctx,
ctx.terminal_width,
&mut observers,
ctx.theme,
ctx.capability,
ctx.hyperlinks,
);
writeln!(writer, "{line}")?;
}
Ok(())
}
const DEFAULT_TERMINAL_WIDTH: u16 = 200;
#[must_use]
pub fn detect_terminal_width() -> u16 {
let os_width = terminal_size::terminal_size().map(|(terminal_size::Width(w), _)| w);
let columns = std::env::var("COLUMNS").ok();
resolve_terminal_width(os_width, columns.as_deref(), |msg| {
crate::lsm_warn!("{msg}")
})
}
fn resolve_terminal_width(
os_width: Option<u16>,
columns: Option<&str>,
mut warn: impl FnMut(&str),
) -> u16 {
if let Some(w) = os_width {
return w;
}
let Some(raw) = columns else {
return DEFAULT_TERMINAL_WIDTH;
};
match raw.parse::<u16>() {
Ok(parsed) if parsed > 0 => parsed,
Ok(_) => {
warn(&format!(
"COLUMNS='{raw}' is zero; using {DEFAULT_TERMINAL_WIDTH} cells"
));
DEFAULT_TERMINAL_WIDTH
}
Err(err) => {
warn(&format!(
"COLUMNS='{raw}' unparseable ({err}); using {DEFAULT_TERMINAL_WIDTH} cells"
));
DEFAULT_TERMINAL_WIDTH
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn malformed_json_renders_marker_and_succeeds() {
let mut out = Vec::new();
run(Cursor::new(b"{not json"), &mut out).expect("IO should not fail");
assert_eq!(String::from_utf8(out).expect("utf8"), "?\n");
}
#[test]
fn minimal_payload_renders_model_then_workspace() {
let json = br#"{
"model": { "display_name": "Claude Test" },
"workspace": { "project_dir": "/home/dev/linesmith" }
}"#;
let mut out = Vec::new();
run(Cursor::new(json), &mut out).expect("run ok");
assert_eq!(
String::from_utf8(out).expect("utf8"),
"Claude Test linesmith\n"
);
}
fn resolve(os_width: Option<u16>, columns: Option<&str>) -> (u16, Vec<String>) {
let mut warnings = Vec::new();
let w = resolve_terminal_width(os_width, columns, |m| warnings.push(m.to_string()));
(w, warnings)
}
#[test]
fn os_width_wins_over_columns_env() {
let (w, warns) = resolve(Some(120), Some("80"));
assert_eq!(w, 120);
assert!(warns.is_empty());
}
#[test]
fn columns_env_used_when_os_width_missing() {
let (w, warns) = resolve(None, Some("80"));
assert_eq!(w, 80);
assert!(warns.is_empty());
}
#[test]
fn missing_columns_falls_back_silently() {
let (w, warns) = resolve(None, None);
assert_eq!(w, DEFAULT_TERMINAL_WIDTH);
assert!(warns.is_empty());
}
#[test]
fn zero_columns_falls_back_and_warns() {
let (w, warns) = resolve(None, Some("0"));
assert_eq!(w, DEFAULT_TERMINAL_WIDTH);
assert_eq!(warns.len(), 1);
assert!(warns[0].contains("COLUMNS='0'"));
}
#[test]
fn unparseable_columns_falls_back_and_warns() {
let (w, warns) = resolve(None, Some("wide"));
assert_eq!(w, DEFAULT_TERMINAL_WIDTH);
assert_eq!(warns.len(), 1);
assert!(warns[0].contains("unparseable"));
}
#[test]
fn columns_beyond_u16_range_warns() {
let (w, warns) = resolve(None, Some("99999"));
assert_eq!(w, DEFAULT_TERMINAL_WIDTH);
assert_eq!(warns.len(), 1);
}
#[test]
fn full_payload_renders_model_context_workspace() {
let json = br#"{
"model": { "display_name": "Claude Sonnet 4.6" },
"workspace": {
"project_dir": "/home/dev/linesmith"
},
"context_window": {
"used_percentage": 42.5,
"context_window_size": 200000,
"total_input_tokens": 12345,
"total_output_tokens": 6789
}
}"#;
let mut out = Vec::new();
run(Cursor::new(json), &mut out).expect("run ok");
assert_eq!(
String::from_utf8(out).expect("utf8"),
"Claude Sonnet 4.6 42% ยท 200k linesmith\n"
);
}
}