@cli-use/tui 0.1.4 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,412 @@
1
+ use anyhow::Result;
2
+ use crossterm::{
3
+ event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
4
+ execute,
5
+ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
6
+ };
7
+ use ratatui::{
8
+ backend::CrosstermBackend,
9
+ layout::{Alignment, Constraint, Direction, Layout},
10
+ style::{Color, Modifier, Style},
11
+ text::{Line, Span, Text},
12
+ widgets::{Block, BorderType, Borders, List, ListItem, Paragraph},
13
+ Terminal,
14
+ };
15
+ use std::io::{stdout, Stdout};
16
+ use std::process::Stdio;
17
+ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
18
+ use tokio::process::Command;
19
+ use tokio::sync::mpsc;
20
+ use std::time::Duration;
21
+
22
+ // --- Colors & Constants ---
23
+ const CLAUDE_ORANGE: Color = Color::Rgb(217, 119, 87); // #D97757
24
+ const DARK_BG: Color = Color::Rgb(21, 21, 21); // #151515
25
+ const INPUT_BG: Color = Color::Rgb(30, 30, 30); // Slightly lighter than background for input area
26
+ const FOOTER_TEXT: Color = Color::Rgb(112, 128, 144); // Slate Gray
27
+ const FOOTER_HIGHLIGHT: Color = Color::Rgb(255, 255, 255); // White
28
+
29
+ pub const LOGO_TEXT: [&str; 12] = [
30
+ " ██████╗██╗ ██╗ ",
31
+ "██╔════╝██║ ██║ ",
32
+ "██║ ██║ ██║ ",
33
+ "██║ ██║ ██║ ",
34
+ "╚██████╗███████╗██║ ",
35
+ " ╚═════╝╚══════╝╚═╝ ",
36
+ " ██████╗ ██████╗ ██████╗ ███████╗",
37
+ "██╔════╝██╔═══██╗██╔══██╗██╔════╝",
38
+ "██║ ██║ ██║██║ ██║█████╗ ",
39
+ "██║ ██║ ██║██║ ██║██╔══╝ ",
40
+ "╚██████╗╚██████╔╝██████╔╝███████╗",
41
+ " ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝",
42
+ ];
43
+
44
+ // --- Data Structures ---
45
+
46
+ enum AppState {
47
+ Splash,
48
+ Chat,
49
+ }
50
+
51
+ #[derive(Clone)]
52
+ enum MessageType {
53
+ User,
54
+ Thinking,
55
+ #[allow(dead_code)]
56
+ ToolCall,
57
+ Output,
58
+ System,
59
+ }
60
+
61
+ #[derive(Clone)]
62
+ struct Message {
63
+ content: String,
64
+ msg_type: MessageType,
65
+ }
66
+
67
+ struct App {
68
+ state: AppState,
69
+ input: String,
70
+ messages: Vec<Message>,
71
+ tx_ai: mpsc::Sender<String>, // Channel to send prompts to AI
72
+ rx_ai: mpsc::Receiver<String>, // Channel to receive responses from AI
73
+ waiting_for_response: bool,
74
+ // Add scroll tracking
75
+ scroll_offset: usize,
76
+ }
77
+
78
+ impl App {
79
+ fn new(tx_ai: mpsc::Sender<String>, rx_ai: mpsc::Receiver<String>) -> App {
80
+ App {
81
+ state: AppState::Splash,
82
+ input: String::new(),
83
+ messages: vec![
84
+ Message {
85
+ content: "Welcome to CLI CODE.".to_string(),
86
+ msg_type: MessageType::System,
87
+ },
88
+ Message {
89
+ content: "I am ready to assist. Type anything to chat with Google Gemini.".to_string(),
90
+ msg_type: MessageType::System,
91
+ },
92
+ ],
93
+ tx_ai,
94
+ rx_ai,
95
+ waiting_for_response: false,
96
+ scroll_offset: 0,
97
+ }
98
+ }
99
+
100
+ async fn submit_message(&mut self) {
101
+ if self.input.trim().is_empty() {
102
+ return;
103
+ }
104
+
105
+ let prompt = self.input.trim().to_string();
106
+
107
+ // Add user message to UI
108
+ self.messages.push(Message {
109
+ content: self.input.clone(),
110
+ msg_type: MessageType::User,
111
+ });
112
+
113
+ // Add thinking indicator
114
+ self.messages.push(Message {
115
+ content: "Consulting Gemini...".to_string(),
116
+ msg_type: MessageType::Thinking,
117
+ });
118
+
119
+ // Send to AI worker
120
+ if let Err(e) = self.tx_ai.send(prompt).await {
121
+ self.messages.push(Message {
122
+ content: format!("Error communicating with AI worker: {}", e),
123
+ msg_type: MessageType::System,
124
+ });
125
+ } else {
126
+ self.waiting_for_response = true;
127
+ }
128
+
129
+ self.input.clear();
130
+ }
131
+ }
132
+
133
+ // --- Main Execution ---
134
+
135
+ #[tokio::main]
136
+ async fn main() -> Result<()> {
137
+ enable_raw_mode()?;
138
+ let mut stdout = stdout();
139
+ execute!(stdout, EnterAlternateScreen)?;
140
+ let backend = CrosstermBackend::new(stdout);
141
+ let mut terminal = Terminal::new(backend)?;
142
+
143
+ // Get worker path from arguments
144
+ let args: Vec<String> = std::env::args().collect();
145
+ let worker_path = if args.len() > 1 {
146
+ args[1].clone()
147
+ } else {
148
+ // Fallback for dev mode
149
+ "scripts/ai-worker.ts".to_string()
150
+ };
151
+
152
+ // --- Spawn Node.js AI Worker ---
153
+ let (tx_to_worker, mut rx_from_ui) = mpsc::channel::<String>(100);
154
+ let (tx_to_ui, rx_from_worker) = mpsc::channel::<String>(100);
155
+
156
+ // Determine command based on extension
157
+ let cmd_exec = if worker_path.ends_with(".ts") {
158
+ "tsx"
159
+ } else {
160
+ "node"
161
+ };
162
+
163
+ let mut child = Command::new(cmd_exec)
164
+ .arg(&worker_path)
165
+ .stdin(Stdio::piped())
166
+ .stdout(Stdio::piped())
167
+ .stderr(Stdio::piped())
168
+ .spawn()
169
+ .expect("Failed to spawn Node.js AI worker");
170
+
171
+ let mut stdin = child.stdin.take().expect("Failed to open stdin");
172
+ let stdout = child.stdout.take().expect("Failed to open stdout");
173
+ let stderr = child.stderr.take().expect("Failed to open stderr");
174
+
175
+ // Task: Write to Node process
176
+ tokio::spawn(async move {
177
+ while let Some(prompt) = rx_from_ui.recv().await {
178
+ if let Err(e) = stdin.write_all(format!("{}\n", prompt).as_bytes()).await {
179
+ eprintln!("Failed to write to AI worker: {}", e);
180
+ break;
181
+ }
182
+ if let Err(e) = stdin.flush().await {
183
+ eprintln!("Failed to flush to AI worker: {}", e);
184
+ break;
185
+ }
186
+ }
187
+ });
188
+
189
+ // Task: Read from Node process
190
+ let tx_to_ui_clone = tx_to_ui.clone();
191
+ tokio::spawn(async move {
192
+ let mut reader = BufReader::new(stdout).lines();
193
+ while let Ok(Some(line)) = reader.next_line().await {
194
+ let _ = tx_to_ui_clone.send(line.replace("\\n", "\n")).await;
195
+ }
196
+ });
197
+
198
+ // Task: Log stderr
199
+ tokio::spawn(async move {
200
+ let mut reader = BufReader::new(stderr).lines();
201
+ while let Ok(Some(_line)) = reader.next_line().await {
202
+ // Ignore stderr for now
203
+ }
204
+ });
205
+
206
+ let mut app = App::new(tx_to_worker, rx_from_worker);
207
+ let res = run_app(&mut terminal, &mut app).await;
208
+
209
+ // Cleanup
210
+ let _ = child.kill().await;
211
+
212
+ disable_raw_mode()?;
213
+ execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
214
+ terminal.show_cursor()?;
215
+
216
+ if let Err(err) = res {
217
+ println!("{:?}", err);
218
+ }
219
+
220
+ Ok(())
221
+ }
222
+
223
+ async fn run_app(terminal: &mut Terminal<CrosstermBackend<Stdout>>, app: &mut App) -> Result<()> {
224
+ let mut interval = tokio::time::interval(Duration::from_millis(33));
225
+
226
+ loop {
227
+ terminal.draw(|f| {
228
+ let size = f.size();
229
+ let main_block = Block::default().style(Style::default().bg(DARK_BG));
230
+ f.render_widget(main_block, size);
231
+
232
+ match app.state {
233
+ AppState::Splash => {
234
+ let vertical_chunks = Layout::default()
235
+ .direction(Direction::Vertical)
236
+ .constraints([
237
+ Constraint::Percentage(15),
238
+ Constraint::Length(5), // Header
239
+ Constraint::Length(15), // Logo
240
+ Constraint::Min(1), // Spacer
241
+ Constraint::Length(3), // Input
242
+ ])
243
+ .margin(2)
244
+ .split(size);
245
+
246
+ let header_area = vertical_chunks[1];
247
+ let header_layout = Layout::default()
248
+ .direction(Direction::Horizontal)
249
+ .constraints([
250
+ Constraint::Fill(1),
251
+ Constraint::Length(54),
252
+ Constraint::Fill(1),
253
+ ])
254
+ .split(header_area);
255
+
256
+ let header_text = "Welcome to the Claude Code research preview!";
257
+ let header = Paragraph::new(Line::from(vec![
258
+ Span::styled(header_text, Style::default().fg(CLAUDE_ORANGE).add_modifier(Modifier::BOLD)),
259
+ ]))
260
+ .block(
261
+ Block::default()
262
+ .borders(Borders::ALL)
263
+ .border_type(BorderType::Rounded)
264
+ .border_style(Style::default().fg(CLAUDE_ORANGE))
265
+ )
266
+ .alignment(Alignment::Center);
267
+ f.render_widget(header, header_layout[1]);
268
+
269
+ let logo_lines: Vec<Line> = LOGO_TEXT.iter()
270
+ .map(|line| Line::from(Span::styled(*line, Style::default().fg(CLAUDE_ORANGE))))
271
+ .collect();
272
+ let logo = Paragraph::new(Text::from(logo_lines)).alignment(Alignment::Center);
273
+ f.render_widget(logo, vertical_chunks[2]);
274
+
275
+ // Render Input on Splash Screen
276
+ let input_text = format!("> {}_", app.input);
277
+ let input = Paragraph::new(input_text)
278
+ .style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)) // Bold White font
279
+ .block(Block::default()
280
+ .borders(Borders::ALL)
281
+ .border_style(Style::default().fg(Color::DarkGray))
282
+ .style(Style::default().bg(INPUT_BG))); // Darker Background
283
+ f.render_widget(input, vertical_chunks[4]);
284
+ }
285
+ AppState::Chat => {
286
+ let chunks = Layout::default()
287
+ .direction(Direction::Vertical)
288
+ .constraints([
289
+ Constraint::Min(1),
290
+ Constraint::Length(3),
291
+ ].as_ref())
292
+ .split(size);
293
+
294
+ let messages: Vec<ListItem> = app.messages
295
+ .iter()
296
+ .map(|m| {
297
+ let (symbol, style) = match m.msg_type {
298
+ MessageType::User => (">", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
299
+ MessageType::Thinking => ("⏺", Style::default().fg(Color::Yellow)),
300
+ MessageType::ToolCall => ("⏺ Call", Style::default().fg(Color::Blue)),
301
+ MessageType::Output => ("⎿", Style::default().fg(Color::DarkGray)),
302
+ MessageType::System => ("*", Style::default().fg(Color::Magenta)),
303
+ };
304
+
305
+ let mut lines = vec![];
306
+ let first_line_content = m.content.lines().next().unwrap_or("");
307
+
308
+ lines.push(Line::from(vec![
309
+ Span::styled(symbol, style),
310
+ Span::raw(" "),
311
+ Span::styled(first_line_content, if matches!(m.msg_type, MessageType::Output) { Style::default().fg(Color::DarkGray) } else { Style::default() }),
312
+ ]));
313
+
314
+ for line in m.content.lines().skip(1) {
315
+ lines.push(Line::from(vec![
316
+ Span::raw(" "),
317
+ Span::styled(line, if matches!(m.msg_type, MessageType::Output) { Style::default().fg(Color::DarkGray) } else { Style::default() }),
318
+ ]));
319
+ }
320
+ ListItem::new(lines)
321
+ })
322
+ .collect();
323
+
324
+ let messages_list = List::new(messages)
325
+ .block(Block::default().style(Style::default().bg(DARK_BG)));
326
+
327
+ // Simple auto-scroll
328
+ let mut state = ratatui::widgets::ListState::default();
329
+ if !app.messages.is_empty() {
330
+ state.select(Some(app.messages.len() - 1));
331
+ }
332
+
333
+ f.render_stateful_widget(messages_list, chunks[0], &mut state);
334
+
335
+ let input = Paragraph::new(format!("> {}_", app.input))
336
+ .style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)) // Bold White font
337
+ .block(Block::default()
338
+ .borders(Borders::TOP) // Separator line
339
+ .border_style(Style::default().fg(Color::DarkGray))
340
+ .style(Style::default().bg(INPUT_BG))); // Darker Background
341
+ f.render_widget(input, chunks[1]);
342
+ }
343
+ }
344
+ })?;
345
+
346
+ // 2. Handle Events (Keyboard + AI Channel)
347
+ tokio::select! {
348
+ _ = interval.tick() => {
349
+ // Just for redraw frequency
350
+ }
351
+
352
+ else => {}
353
+ }
354
+
355
+ while let Ok(response) = app.rx_ai.try_recv() {
356
+ // Remove the thinking message if it exists
357
+ if app.waiting_for_response {
358
+ if let Some(last) = app.messages.last() {
359
+ if matches!(last.msg_type, MessageType::Thinking) {
360
+ app.messages.pop();
361
+ }
362
+ }
363
+ app.waiting_for_response = false;
364
+ }
365
+
366
+ app.messages.push(Message {
367
+ content: response,
368
+ msg_type: MessageType::Output,
369
+ });
370
+ }
371
+
372
+ if event::poll(Duration::from_millis(10))? {
373
+ if let Event::Key(key) = event::read()? {
374
+ if key.kind == KeyEventKind::Press {
375
+ match app.state {
376
+ AppState::Splash => {
377
+ match key.code {
378
+ KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => return Ok(()),
379
+ KeyCode::Enter => {
380
+ if !app.input.trim().is_empty() {
381
+ app.state = AppState::Chat;
382
+ app.submit_message().await;
383
+ } else {
384
+ app.state = AppState::Chat;
385
+ }
386
+ }
387
+ KeyCode::Char(c) => app.input.push(c),
388
+ KeyCode::Backspace => { app.input.pop(); },
389
+ KeyCode::Esc => return Ok(()),
390
+ _ => {}
391
+ }
392
+ }
393
+ AppState::Chat => {
394
+ match key.code {
395
+ KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => return Ok(()),
396
+ KeyCode::Enter => {
397
+ if app.input.trim().eq_ignore_ascii_case("quit") || app.input.trim().eq_ignore_ascii_case("exit") {
398
+ return Ok(());
399
+ }
400
+ app.submit_message().await;
401
+ }
402
+ KeyCode::Char(c) => app.input.push(c),
403
+ KeyCode::Backspace => { app.input.pop(); },
404
+ _ => {}
405
+ }
406
+ }
407
+ }
408
+ }
409
+ }
410
+ }
411
+ }
412
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cli-use/tui",
3
- "version": "0.1.4",
3
+ "version": "0.1.10",
4
4
  "description": "Build beautiful terminal user interfaces with styled components - A powerful TUI framework for creating stunning CLI applications",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,7 +22,10 @@
22
22
  "files": [
23
23
  "dist",
24
24
  "README.md",
25
- "LICENSE"
25
+ "LICENSE",
26
+ "scripts",
27
+ "native/src",
28
+ "native/Cargo.toml"
26
29
  ],
27
30
  "scripts": {
28
31
  "build": "npm run build:rust && tsup && npm run build:copy",
@@ -37,7 +40,7 @@
37
40
  "list:models": "tsx scripts/list-models.ts",
38
41
  "build:copy": "tsx scripts/copy-binary.ts",
39
42
  "demo:cli-use": "tsx src/examples/cli-use-demo.tsx",
40
- "postinstall": "tsx scripts/postinstall.ts"
43
+ "postinstall": "node scripts/postinstall.cjs"
41
44
  },
42
45
  "keywords": [
43
46
  "tui",
@@ -0,0 +1,31 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ const isWindows = process.platform === 'win32';
5
+ const extension = isWindows ? '.exe' : '';
6
+ const binaryName = 'cli-use-demo' + extension;
7
+
8
+ const srcPath = path.resolve(process.cwd(), 'native/target/release', binaryName);
9
+ const destDir = path.resolve(process.cwd(), 'dist/bin');
10
+ const destPath = path.resolve(destDir, binaryName);
11
+
12
+ console.log('Copying binary...');
13
+ console.log('Source:', srcPath);
14
+ console.log('Dest:', destPath);
15
+
16
+ if (!fs.existsSync(srcPath)) {
17
+ console.error('Error: Source binary not found. Did you run npm run build:rust?');
18
+ process.exit(1);
19
+ }
20
+
21
+ if (!fs.existsSync(destDir)) {
22
+ fs.mkdirSync(destDir, { recursive: true });
23
+ }
24
+
25
+ fs.copyFileSync(srcPath, destPath);
26
+
27
+ if (!isWindows) {
28
+ fs.chmodSync(destPath, '755');
29
+ }
30
+
31
+ console.log('Binary copied successfully.');
@@ -0,0 +1,33 @@
1
+ import dotenv from 'dotenv';
2
+
3
+ dotenv.config();
4
+
5
+ const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY ||
6
+ process.env.GEMINI_API_KEY ||
7
+ process.env.GOOGLE_API_KEY;
8
+
9
+ if (!apiKey) {
10
+ console.error('No API Key');
11
+ process.exit(1);
12
+ }
13
+
14
+ async function listModels() {
15
+ try {
16
+ const url = `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`;
17
+ console.log('Fetching:', url.replace(apiKey, 'HIDDEN_KEY'));
18
+
19
+ const response = await fetch(url);
20
+ const data = await response.json();
21
+
22
+ if (data.models) {
23
+ console.log('✅ Available Models:');
24
+ data.models.forEach(m => console.log(' - ' + m.name));
25
+ } else {
26
+ console.log('❌ Error:', JSON.stringify(data, null, 2));
27
+ }
28
+ } catch (error) {
29
+ console.error('Error listing models:', error);
30
+ }
31
+ }
32
+
33
+ listModels();
@@ -0,0 +1,59 @@
1
+ const { spawnSync } = require('child_process');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ // Use __dirname to find package root (scripts/../)
6
+ const packageRoot = path.resolve(__dirname, '..');
7
+
8
+ try {
9
+ // Only run if cargo is available
10
+ try {
11
+ const result = spawnSync('cargo', ['--version']);
12
+ if (result.error || result.status !== 0) {
13
+ console.log('Cargo not found, skipping native build.');
14
+ process.exit(0);
15
+ }
16
+ } catch (e) {
17
+ console.log('Cargo check failed, skipping native build.');
18
+ process.exit(0);
19
+ }
20
+
21
+ console.log('Building native binary locally...');
22
+ const nativeDir = path.resolve(packageRoot, 'native');
23
+
24
+ if (!fs.existsSync(nativeDir)) {
25
+ console.warn('Native directory not found at:', nativeDir);
26
+ process.exit(0);
27
+ }
28
+
29
+ const build = spawnSync('cargo', ['build', '--release'], {
30
+ cwd: nativeDir,
31
+ stdio: 'inherit',
32
+ });
33
+
34
+ if (build.status === 0) {
35
+ const isWindows = process.platform === 'win32';
36
+ const ext = isWindows ? '.exe' : '';
37
+ const src = path.join(nativeDir, 'target/release/cli-use-demo' + ext);
38
+
39
+ // Install to dist/bin
40
+ const destDir = path.resolve(packageRoot, 'dist/bin');
41
+ if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
42
+
43
+ const dest = path.join(destDir, 'cli-use-demo' + ext);
44
+
45
+ try {
46
+ fs.copyFileSync(src, dest);
47
+ console.log('Native binary built and installed successfully.');
48
+ } catch (err) {
49
+ console.error('Error copying binary:', err);
50
+ // Don't fail install just because copy failed
51
+ }
52
+ } else {
53
+ console.error('Failed to build native binary. You may need to build manually.');
54
+ }
55
+ } catch (error) {
56
+ console.error('Postinstall script error:', error);
57
+ // Always exit successfully so npm install doesn't crash
58
+ process.exit(0);
59
+ }
@@ -0,0 +1,22 @@
1
+ import { spawn } from 'child_process';
2
+ import path from 'path';
3
+
4
+ const rustBinaryPath = path.resolve(process.cwd(), 'native/target/release/cli-use-demo');
5
+ const workerPath = path.resolve(process.cwd(), 'src/ai-worker.ts');
6
+
7
+ console.log('Starting cli-use Demo...');
8
+ console.log(`Binary path: ${rustBinaryPath}`);
9
+ console.log(`Worker path: ${workerPath}`);
10
+
11
+ const child = spawn(rustBinaryPath, [workerPath], {
12
+ stdio: 'inherit',
13
+ });
14
+
15
+ child.on('error', (err) => {
16
+ console.error('Failed to start demo:', err);
17
+ console.log('\nMake sure you have built the Rust binary first by running: npm run build:rust');
18
+ });
19
+
20
+ child.on('close', (code) => {
21
+ console.log(`Demo exited with code ${code}`);
22
+ });
@@ -0,0 +1,33 @@
1
+ import { createGoogleGenerativeAI } from '@ai-sdk/google';
2
+ import { generateText } from 'ai';
3
+ import dotenv from 'dotenv';
4
+
5
+ dotenv.config();
6
+
7
+ async function testWorker() {
8
+ const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY ||
9
+ process.env.GEMINI_API_KEY ||
10
+ process.env.GOOGLE_API_KEY;
11
+
12
+ if (!apiKey) {
13
+ console.error('❌ Error: No API Key found.');
14
+ process.exit(1);
15
+ }
16
+
17
+ console.log('✅ API Key found.');
18
+
19
+ const google = createGoogleGenerativeAI({ apiKey });
20
+
21
+ try {
22
+ console.log('⏳ Sending test prompt to Gemini 2.0 Flash...');
23
+ const { text } = await generateText({
24
+ model: google('models/gemini-2.0-flash'),
25
+ prompt: 'Say hello',
26
+ });
27
+ console.log('✅ Success! Response:', text);
28
+ } catch (error) {
29
+ console.error('❌ API Request Failed:', error);
30
+ }
31
+ }
32
+
33
+ testWorker();