use std::time::Duration;
use ntui::{
BorderStyle, Color, Element, FlexDirection, KeyCode, Weight, component, element, render_inline,
};
const ACCENT: Color = Color::Rgb(0xD9, 0x77, 0x57);
const SPINNER: [&str; 6] = ["·", "✢", "✳", "∗", "✻", "✽"];
fn user_line(text: &str) -> Element {
element! {
View(flex_direction: FlexDirection::Row, gap: 1) {
Text(content: ">", color: ACCENT, weight: Weight::Bold)
Text(content: text.to_string())
}
}
}
fn reply_line(text: &str) -> Element {
element! {
View(flex_direction: FlexDirection::Row, gap: 1) {
Text(content: "●", color: ACCENT)
Text(content: text.to_string())
}
}
}
#[component]
fn App(hooks: &mut ntui::Hooks) -> Element {
let draft = hooks.use_state(String::new);
let current = hooks.use_state(String::new); let working = hooks.use_state(|| false);
let frame = hooks.use_state(|| 0usize);
let finished = hooks.use_state(|| None::<String>);
let scrollback = hooks.use_scrollback();
let app = hooks.use_app();
let sb_welcome = scrollback.clone();
hooks.use_effect((), move || {
sb_welcome.commit(element! {
View(flex_direction: FlexDirection::Column, border_style: BorderStyle::Round,
border_color: ACCENT, padding: 1) {
Text(content: "✻ Welcome to Claude Code (inline)", weight: Weight::Bold)
Text(content: "finished turns scroll into your terminal history",
color: Color::DarkGrey)
}
});
});
let (sb, cur, fin, wrk) = (
scrollback.clone(),
current.clone(),
finished.clone(),
working.clone(),
);
hooks.use_effect(finished.get(), move || {
if let Some(text) = fin.get() {
sb.commit(reply_line(&text));
fin.set(None);
cur.set(String::new());
wrk.set(false);
}
});
let f = frame.clone();
hooks.use_future(move || async move {
loop {
tokio::time::sleep(Duration::from_millis(120)).await;
f.update(|n| *n = n.wrapping_add(1));
}
});
let (sbi, d, c, w, fi) = (
scrollback.clone(),
draft.clone(),
current.clone(),
working.clone(),
finished.clone(),
);
hooks.use_input(move |ev, _| match ev.code {
KeyCode::Char(ch) => d.update(|s| s.push(ch)),
KeyCode::Backspace => {
d.update(|s| {
s.pop();
});
}
KeyCode::Esc => app.exit(),
KeyCode::Enter => {
let text = d.get();
if text.trim().is_empty() {
return;
}
d.set(String::new());
sbi.commit(user_line(&text)); w.set(true);
c.set(String::new());
let (c2, fi2) = (c.clone(), fi.clone());
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(400)).await;
let reply = format!(
"On \"{}\": I'd trace the call path and add a test first.",
text.trim()
);
let mut acc = String::new();
for ch in reply.chars() {
tokio::time::sleep(Duration::from_millis(16)).await;
acc.push(ch);
c2.set(acc.clone());
}
fi2.set(Some(acc));
});
}
_ => {}
});
let cursor = if frame.get() % 8 < 4 { "▌" } else { " " };
element! {
View(flex_direction: FlexDirection::Column) {
#(if current.get().is_empty() {
None
} else {
Some(reply_line(¤t.get()))
}.into_iter())
#(if working.get() {
Some(element! {
View(flex_direction: FlexDirection::Row, gap: 1) {
Text(content: SPINNER[frame.get() % SPINNER.len()], color: ACCENT)
Text(content: "Working… (esc to quit)", color: Color::DarkGrey)
}
})
} else {
None
}.into_iter())
View(border_style: BorderStyle::Round, border_color: Color::DarkGrey) {
Text(content: format!("> {}{cursor}", draft.get()))
}
}
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), ntui::Error> {
render_inline(element!(App)).await
}
#[cfg(test)]
mod tests {
use super::*;
use ntui::testing::TestTerminal;
#[tokio::test]
async fn mounts_and_accepts_input() {
let mut t = TestTerminal::new(50, 8, element!(App)).unwrap();
for ch in "hi".chars() {
t.send_key(KeyCode::Char(ch)).unwrap();
}
assert!(t.frame_text().contains("> hi"));
}
}