Skip to content

Commit 1665b89

Browse files
committed
feat(elizacp): add chat TUI and deterministic mode
- Add ratatui-based chat TUI: `cargo run -p elizacp -- chat` - Add `--deterministic` flag for reproducible responses (seed 22) - Default to random seed for production use - Require subcommand: `acp` for ACP agent, `chat` for TUI - Add `elizacp()` helper to sacp-test for consistent test setup - Update all test callers to use deterministic mode
1 parent c4f06d9 commit 1665b89

24 files changed

Lines changed: 256 additions & 70 deletions

src/elizacp/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ tracing.workspace = true
3232
tracing-subscriber.workspace = true
3333
uuid.workspace = true
3434
sacp-tokio = { version = "10.0.0", path = "../sacp-tokio" }
35+
ratatui = "0.30.0"
36+
crossterm = "0.29.0"
3537

3638
[dev-dependencies]
3739
expect-test.workspace = true

src/elizacp/src/chat.rs

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
//! Simple TUI chat interface for Eliza.
2+
//!
3+
//! Run with: `cargo run -p elizacp -- chat`
4+
5+
use crate::eliza::Eliza;
6+
use crossterm::{
7+
ExecutableCommand,
8+
event::{self, Event, KeyCode, KeyEventKind},
9+
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
10+
};
11+
use ratatui::{
12+
prelude::*,
13+
widgets::{Block, Borders, Paragraph, Wrap},
14+
};
15+
use std::io::{self, stdout};
16+
17+
/// A single message in the chat history
18+
struct Message {
19+
text: String,
20+
is_user: bool,
21+
}
22+
23+
/// Run the chat TUI
24+
pub fn run(deterministic: bool) -> anyhow::Result<()> {
25+
// Setup terminal
26+
stdout().execute(EnterAlternateScreen)?;
27+
enable_raw_mode()?;
28+
29+
let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
30+
terminal.clear()?;
31+
32+
let result = run_app(&mut terminal, deterministic);
33+
34+
// Restore terminal
35+
disable_raw_mode()?;
36+
stdout().execute(LeaveAlternateScreen)?;
37+
38+
result
39+
}
40+
41+
fn run_app(
42+
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
43+
deterministic: bool,
44+
) -> anyhow::Result<()> {
45+
let mut eliza = if deterministic {
46+
Eliza::new_deterministic()
47+
} else {
48+
Eliza::new()
49+
};
50+
let mut messages: Vec<Message> = vec![Message {
51+
text: "Hello, I am Eliza. How can I help you today?".to_string(),
52+
is_user: false,
53+
}];
54+
let mut input = String::new();
55+
56+
loop {
57+
// Draw UI
58+
terminal.draw(|frame| {
59+
let area = frame.area();
60+
61+
// Split into messages area and input area
62+
let chunks = Layout::default()
63+
.direction(Direction::Vertical)
64+
.constraints([Constraint::Min(3), Constraint::Length(3)])
65+
.split(area);
66+
67+
// Messages area
68+
let messages_text: String = messages
69+
.iter()
70+
.map(|m| {
71+
if m.is_user {
72+
format!("You: {}", m.text)
73+
} else {
74+
format!("Eliza: {}", m.text)
75+
}
76+
})
77+
.collect::<Vec<_>>()
78+
.join("\n\n");
79+
80+
// Calculate scroll to show bottom of messages
81+
let visible_height = chunks[0].height.saturating_sub(2) as usize; // -2 for borders
82+
let line_count = messages_text.lines().count();
83+
let scroll = line_count.saturating_sub(visible_height) as u16;
84+
85+
let messages_paragraph = Paragraph::new(messages_text)
86+
.block(Block::default().borders(Borders::ALL).title("Chat"))
87+
.wrap(Wrap { trim: false })
88+
.scroll((scroll, 0));
89+
90+
frame.render_widget(messages_paragraph, chunks[0]);
91+
92+
// Input area
93+
let input_paragraph = Paragraph::new(input.as_str()).block(
94+
Block::default()
95+
.borders(Borders::ALL)
96+
.title("Type here (Enter to send, Esc to quit)"),
97+
);
98+
99+
frame.render_widget(input_paragraph, chunks[1]);
100+
101+
// Position cursor at end of input
102+
frame.set_cursor_position((chunks[1].x + input.len() as u16 + 1, chunks[1].y + 1));
103+
})?;
104+
105+
// Handle input
106+
if event::poll(std::time::Duration::from_millis(100))? {
107+
if let Event::Key(key) = event::read()? {
108+
if key.kind == KeyEventKind::Press {
109+
match key.code {
110+
KeyCode::Esc => break,
111+
KeyCode::Enter => {
112+
if !input.is_empty() {
113+
// Add user message
114+
messages.push(Message {
115+
text: input.clone(),
116+
is_user: true,
117+
});
118+
119+
// Get Eliza's response
120+
let response = eliza.respond(&input);
121+
messages.push(Message {
122+
text: response,
123+
is_user: false,
124+
});
125+
126+
input.clear();
127+
}
128+
}
129+
KeyCode::Backspace => {
130+
input.pop();
131+
}
132+
KeyCode::Char(c) => {
133+
input.push(c);
134+
}
135+
_ => {}
136+
}
137+
}
138+
}
139+
}
140+
}
141+
142+
Ok(())
143+
}

src/elizacp/src/eliza.rs

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -603,10 +603,14 @@ impl ElizaResponses {
603603
}
604604

605605
impl Eliza {
606-
/// Create a new Eliza instance with classic patterns.
607-
/// Uses a fixed seed for deterministic testing.
606+
/// Create a new Eliza instance with a random seed.
608607
pub fn new() -> Self {
609-
Self::with_seed(42)
608+
Self::with_seed(rand::random())
609+
}
610+
611+
/// Create a new Eliza instance with a fixed seed for deterministic behavior.
612+
pub fn new_deterministic() -> Self {
613+
Self::with_seed(22)
610614
}
611615

612616
/// Create a new Eliza instance with a specific seed.
@@ -1294,77 +1298,77 @@ mod tests {
12941298

12951299
#[test]
12961300
fn test_greeting() {
1297-
let mut eliza = Eliza::new();
1301+
let mut eliza = Eliza::new_deterministic();
12981302
let response = eliza.respond("Hello");
12991303
expect!["How do you do?"].assert_eq(&response);
13001304
}
13011305

13021306
#[test]
13031307
fn test_how_are_you() {
1304-
let mut eliza = Eliza::new();
1308+
let mut eliza = Eliza::new_deterministic();
13051309
let response = eliza.respond("How are you?");
13061310
expect!["I'm ok. Describe yourself."].assert_eq(&response);
13071311
}
13081312

13091313
#[test]
13101314
fn test_goodbye() {
1311-
let mut eliza = Eliza::new();
1315+
let mut eliza = Eliza::new_deterministic();
13121316
let response = eliza.respond("Goodbye");
13131317
expect!["My secretary will send you a bill."].assert_eq(&response);
13141318
}
13151319

13161320
#[test]
13171321
fn test_family() {
1318-
let mut eliza = Eliza::new();
1322+
let mut eliza = Eliza::new_deterministic();
13191323
let response = eliza.respond("I want to talk about my mother");
13201324
expect!["Tell me something about my family."].assert_eq(&response);
13211325
}
13221326

13231327
#[test]
13241328
fn test_mood() {
1325-
let mut eliza = Eliza::new();
1329+
let mut eliza = Eliza::new_deterministic();
13261330
let response = eliza.respond("I am feeling depressed");
13271331
expect!["Are you depressed often?"].assert_eq(&response);
13281332
}
13291333

13301334
#[test]
13311335
fn test_fear() {
1332-
let mut eliza = Eliza::new();
1336+
let mut eliza = Eliza::new_deterministic();
13331337
let response = eliza.respond("I am afraid of spiders");
13341338
expect!["Why do you say you are afraid of spiders?"].assert_eq(&response);
13351339
}
13361340

13371341
#[test]
13381342
fn test_computer() {
1339-
let mut eliza = Eliza::new();
1343+
let mut eliza = Eliza::new_deterministic();
13401344
let response = eliza.respond("I spend too much time on the computer");
13411345
expect!["You have your mind on computer, it seems."].assert_eq(&response);
13421346
}
13431347

13441348
#[test]
13451349
fn test_question_deflection() {
1346-
let mut eliza = Eliza::new();
1350+
let mut eliza = Eliza::new_deterministic();
13471351
let response = eliza.respond("Why do you ask so many questions?");
13481352
expect!["What do you think?"].assert_eq(&response);
13491353
}
13501354

13511355
#[test]
13521356
fn test_can_you() {
1353-
let mut eliza = Eliza::new();
1357+
let mut eliza = Eliza::new_deterministic();
13541358
let response = eliza.respond("Can you help me?");
13551359
expect!["Of course I can."].assert_eq(&response);
13561360
}
13571361

13581362
#[test]
13591363
fn test_reflection() {
1360-
let eliza = Eliza::new();
1364+
let eliza = Eliza::new_deterministic();
13611365
let reflected = eliza.reflect("I am happy");
13621366
expect!["you are happy"].assert_eq(&reflected);
13631367
}
13641368

13651369
#[test]
13661370
fn test_rotation() {
1367-
let mut eliza = Eliza::new();
1371+
let mut eliza = Eliza::new_deterministic();
13681372

13691373
// First greeting
13701374
let r1 = eliza.respond("hi");
@@ -1401,21 +1405,21 @@ mod tests {
14011405

14021406
#[test]
14031407
fn test_abuse() {
1404-
let mut eliza = Eliza::new();
1408+
let mut eliza = Eliza::new_deterministic();
14051409
let response = eliza.respond("You idiot");
14061410
expect!["Please try to be less abusive."].assert_eq(&response);
14071411
}
14081412

14091413
#[test]
14101414
fn test_short_input() {
1411-
let mut eliza = Eliza::new();
1415+
let mut eliza = Eliza::new_deterministic();
14121416
let response = eliza.respond("ok");
14131417
expect!["Can you elaborate on that?"].assert_eq(&response);
14141418
}
14151419

14161420
#[test]
14171421
fn test_desire_with_object() {
1418-
let mut eliza = Eliza::new();
1422+
let mut eliza = Eliza::new_deterministic();
14191423
// "want" is the keyword, "a new car" is the object
14201424
// Response template is "Why do you {} {}?" -> "Why do you want a new car?"
14211425
let response = eliza.respond("I want a new car");
@@ -1424,7 +1428,7 @@ mod tests {
14241428

14251429
#[test]
14261430
fn test_rust_borrow_checker() {
1427-
let mut eliza = Eliza::new();
1431+
let mut eliza = Eliza::new_deterministic();
14281432
let r1 = eliza.respond("I'm struggling with the borrow checker");
14291433
expect!["The borrow checker sees what you cannot."].assert_eq(&r1);
14301434

@@ -1436,35 +1440,35 @@ mod tests {
14361440

14371441
#[test]
14381442
fn test_rust_cargo() {
1439-
let mut eliza = Eliza::new();
1443+
let mut eliza = Eliza::new_deterministic();
14401444
let response = eliza.respond("cargo build is failing");
14411445
expect!["Cargo carries the weight so you don't have to."].assert_eq(&response);
14421446
}
14431447

14441448
#[test]
14451449
fn test_rust_clippy() {
1446-
let mut eliza = Eliza::new();
1450+
let mut eliza = Eliza::new_deterministic();
14471451
let response = eliza.respond("clippy is complaining again");
14481452
expect!["Clippy only wants what's best for you."].assert_eq(&response);
14491453
}
14501454

14511455
#[test]
14521456
fn test_rust_ferris() {
1453-
let mut eliza = Eliza::new();
1457+
let mut eliza = Eliza::new_deterministic();
14541458
let response = eliza.respond("I love ferris");
14551459
expect!["Isn't Ferris just the cutest? I love them."].assert_eq(&response);
14561460
}
14571461

14581462
#[test]
14591463
fn test_rust_async() {
1460-
let mut eliza = Eliza::new();
1464+
let mut eliza = Eliza::new_deterministic();
14611465
let response = eliza.respond("async is confusing me");
14621466
expect!["Have you tried `.await`ing your problems?"].assert_eq(&response);
14631467
}
14641468

14651469
#[test]
14661470
fn test_rust_general() {
1467-
let mut eliza = Eliza::new();
1471+
let mut eliza = Eliza::new_deterministic();
14681472
let response = eliza.respond("I'm learning rust");
14691473
expect!["Rust is not just a language, it's a journey."].assert_eq(&response);
14701474
}

src/elizacp/src/lib.rs

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub mod chat;
12
pub mod eliza;
23

34
use anyhow::Result;
@@ -24,25 +25,26 @@ struct SessionData {
2425
#[derive(Clone)]
2526
pub struct ElizaAgent {
2627
sessions: Arc<Mutex<HashMap<SessionId, SessionData>>>,
28+
deterministic: bool,
2729
}
2830

2931
impl ElizaAgent {
30-
pub fn new() -> Self {
32+
pub fn new(deterministic: bool) -> Self {
3133
Self {
3234
sessions: Arc::new(Mutex::new(HashMap::new())),
35+
deterministic,
3336
}
3437
}
3538

3639
fn create_session(&self, session_id: &SessionId, mcp_servers: Vec<McpServer>) {
3740
let mcp_server_count = mcp_servers.len();
41+
let eliza = if self.deterministic {
42+
Eliza::new_deterministic()
43+
} else {
44+
Eliza::new()
45+
};
3846
let mut sessions = self.sessions.lock().unwrap();
39-
sessions.insert(
40-
session_id.clone(),
41-
SessionData {
42-
eliza: Eliza::new(),
43-
mcp_servers,
44-
},
45-
);
47+
sessions.insert(session_id.clone(), SessionData { eliza, mcp_servers });
4648
tracing::info!(
4749
"Created session: {} with {} MCP servers",
4850
session_id,

0 commit comments

Comments
 (0)