use std::time::Duration;
use ntui::{
BorderStyle, Color, Dimension, Element, FlexDirection, KeyCode, KeyModifiers, Overflow, Weight,
component, element, render,
};
const ACCENT: Color = Color::Rgb(0xD9, 0x77, 0x57);
const SPINNER: [&str; 6] = ["·", "✢", "✳", "∗", "✻", "✽"];
const VERBS: [&str; 12] = [
"Cogitating",
"Herding",
"Noodling",
"Percolating",
"Ruminating",
"Conjuring",
"Finagling",
"Schlepping",
"Vibing",
"Marinating",
"Puzzling",
"Wrangling",
];
#[derive(Clone, PartialEq)]
enum Block {
User(String),
Assistant(String),
Tool {
name: String,
arg: String,
result: String,
},
}
fn block_view(key: usize, blk: Block) -> Element {
match blk {
Block::User(text) => element! {
View(key: key.to_string(), flex_direction: FlexDirection::Row, gap: 1, margin: 0) {
Text(content: ">", color: ACCENT, weight: Weight::Bold)
Text(content: text)
}
},
Block::Assistant(text) => element! {
View(key: key.to_string(), flex_direction: FlexDirection::Row, gap: 1) {
Text(content: "●", color: ACCENT)
Text(content: text)
}
},
Block::Tool { name, arg, result } => element! {
View(key: key.to_string(), flex_direction: FlexDirection::Column) {
View(flex_direction: FlexDirection::Row, gap: 1) {
Text(content: "●", color: Color::Green)
Text(content: format!("{name}({arg})"), weight: Weight::Bold)
}
Text(
content: format!(" ⎿ {}", if result.is_empty() { "…" } else { &result }),
color: Color::DarkGrey,
)
}
},
}
}
#[component]
fn App(hooks: &mut ntui::Hooks) -> Element {
let messages = hooks.use_state(Vec::<Block>::new);
let draft = hooks.use_state(String::new);
let working = hooks.use_state(|| false);
let frame = hooks.use_state(|| 0usize);
let start = hooks.use_state(|| 0usize); let verb = hooks.use_state(|| VERBS[0]);
let generation = hooks.use_state(|| 0u64); let confirm_exit = hooks.use_state(|| false); let scroll = hooks.use_scroll(); let app = hooks.use_app();
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 (m, d, w, g, vb, sf) = (
messages.clone(),
draft.clone(),
working.clone(),
generation.clone(),
verb.clone(),
start.clone(),
);
let cur_frame = frame.clone();
let sc = scroll.clone();
let ce = confirm_exit.clone();
hooks.use_input(move |ev, _| {
if ev.code == KeyCode::Char('c') && ev.modifiers.contains(KeyModifiers::CONTROL) {
if ce.get() {
app.exit();
} else {
ce.set(true);
m.update(|ms| ms.push(Block::Assistant("(press ctrl-c again to exit)".into())));
}
return;
}
ce.set(false);
match ev.code {
KeyCode::Char(c) => d.update(|s| s.push(c)),
KeyCode::Backspace => d.update(|s| {
s.pop();
}),
KeyCode::PageUp => sc.scroll_by(-5),
KeyCode::PageDown => sc.scroll_by(5),
KeyCode::Esc => {
if w.get() {
g.update(|n| *n += 1); w.set(false);
m.update(|ms| ms.push(Block::Assistant("[interrupted]".into())));
} else {
app.exit();
}
}
KeyCode::Enter => {
let text = d.get();
if text.trim().is_empty() {
return;
}
d.set(String::new());
m.update(|ms| ms.push(Block::User(text.clone())));
g.update(|n| *n += 1);
let my_gen = g.get();
w.set(true);
sf.set(cur_frame.get());
vb.set(VERBS[text.len() % VERBS.len()]);
let (m2, w2, g2) = (m.clone(), w.clone(), g.clone());
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(650)).await;
if g2.get() != my_gen {
return;
}
m2.update(|ms| {
ms.push(Block::Tool {
name: "Read".into(),
arg: "src/main.rs".into(),
result: String::new(),
})
});
tokio::time::sleep(Duration::from_millis(500)).await;
if g2.get() != my_gen {
return;
}
m2.update(|ms| {
if let Some(Block::Tool { result, .. }) = ms.last_mut() {
*result = "Read 42 lines".into();
}
});
m2.update(|ms| ms.push(Block::Assistant(String::new())));
let reply = format!(
"Looking at \"{}\" — I'd start in main.rs, trace the call path, \
and add a focused test before touching the logic.",
text.trim()
);
for ch in reply.chars() {
tokio::time::sleep(Duration::from_millis(16)).await;
if g2.get() != my_gen {
return;
}
m2.update(|ms| {
if let Some(Block::Assistant(s)) = ms.last_mut() {
s.push(ch);
}
});
}
w2.set(false);
});
}
_ => {}
}
});
let transcript = messages.get();
let status = if working.get() {
let secs = frame.get().saturating_sub(start.get()) * 120 / 1000;
element! {
View(flex_direction: FlexDirection::Row, gap: 1) {
Text(content: SPINNER[frame.get() % SPINNER.len()], color: ACCENT)
Text(
content: format!("{}… ({secs}s · esc to interrupt)", verb.get()),
color: Color::DarkGrey,
)
}
}
} else {
element!(Text(content: ""))
};
let cursor = if frame.get() % 8 < 4 { "▌" } else { " " };
element! {
View(flex_direction: FlexDirection::Column, width: Dimension::Percent(100.0), padding: 1) {
View(
flex_direction: FlexDirection::Column,
border_style: BorderStyle::Round,
border_color: ACCENT,
padding: 1,
) {
View(flex_direction: FlexDirection::Row, gap: 1) {
Text(content: "✻", color: ACCENT)
Text(content: "Welcome to Claude Code", weight: Weight::Bold)
}
Text(content: "/help for help · /status for setup", color: Color::DarkGrey)
}
View(
flex_direction: FlexDirection::Column,
flex_grow: 1.0_f32,
gap: 1,
margin: 1,
overflow: Overflow::Scroll,
scroll: scroll.clone(),
) {
#(transcript.into_iter().enumerate().map(|(i, b)| block_view(i, b)))
}
#(std::iter::once(status))
View(border_style: BorderStyle::Round, border_color: Color::DarkGrey) {
Text(content: format!("> {}{cursor}", draft.get()))
}
Text(
content: "⏵⏵ auto-accept · pgup/pgdn scroll · esc interrupt/quit",
color: Color::DarkGrey,
)
}
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), ntui::Error> {
render(element!(App)).await
}
#[cfg(test)]
mod tests {
use super::*;
use ntui::testing::TestTerminal;
#[tokio::test]
async fn mounts_and_accepts_input() {
let mut term = TestTerminal::new(72, 24, element!(App)).unwrap();
assert!(term.frame_text().contains("Welcome to Claude Code"));
for c in "hi there".chars() {
term.send_key(KeyCode::Char(c)).unwrap();
}
assert!(term.frame_text().contains("> hi there"));
term.send_key(KeyCode::Enter).unwrap();
let frame = term.frame_text();
assert!(frame.contains("hi there")); for row in frame.split('\n') {
assert!(row.chars().all(|c| !c.is_control()));
}
}
}