mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-05-12 13:09:29 +00:00
feat: chat cli demo app (#87)
* chore: remove ffi from double ratchet * chore: format * feat: chat cli demo app via file transport * chore: fix the compile issues * chore: fix long intro copy to clipboard * chore: move chat cli to bin folder * chore: use tmp data folder * chore: update doc * chore: use encrypted db with default db pass * chore: fmt and clippy * chore: fix clippy and refactor * chore: utils for helper funcs * chore: rename sessions to chats
This commit is contained in:
parent
94935c28fe
commit
6c7b3a4252
914
Cargo.lock
generated
914
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -10,6 +10,7 @@ members = [
|
|||||||
"core/storage",
|
"core/storage",
|
||||||
"crates/client",
|
"crates/client",
|
||||||
"crates/client-ffi",
|
"crates/client-ffi",
|
||||||
|
"bin/chat-cli",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
|||||||
18
bin/chat-cli/Cargo.toml
Normal file
18
bin/chat-cli/Cargo.toml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "chat-cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "chat-cli"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
libchat = { path = "../../core/conversations" }
|
||||||
|
ratatui = "0.29"
|
||||||
|
crossterm = "0.29"
|
||||||
|
anyhow = "1.0"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
hex = "0.4"
|
||||||
|
arboard = "3"
|
||||||
117
bin/chat-cli/README.md
Normal file
117
bin/chat-cli/README.md
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
# Chat CLI
|
||||||
|
|
||||||
|
A terminal chat application based on libchat library.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- End-to-end encrypted messaging using libchat
|
||||||
|
- File-based transport for local simulation (no network required)
|
||||||
|
- Persistent storage (SQLite + JSON state)
|
||||||
|
- Multiple chat support with chat switching
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Run two instances with different usernames in separate terminals:
|
||||||
|
|
||||||
|
### Terminal 1 (Alice)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p chat-cli -- alice
|
||||||
|
```
|
||||||
|
|
||||||
|
### Terminal 2 (Bob)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p chat-cli -- bob
|
||||||
|
```
|
||||||
|
|
||||||
|
### Establishing a Connection
|
||||||
|
|
||||||
|
1. In Alice's terminal, type `/intro` to generate an introduction bundle
|
||||||
|
2. Copy the intro string
|
||||||
|
3. In Bob's terminal, type `/connect alice <intro>` (paste Alice's intro bundle)
|
||||||
|
4. Bob can now send messages to Alice
|
||||||
|
5. Alice will see Bob's initial "Hello!" message and can reply
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `/help` | Show available commands |
|
||||||
|
| `/intro` | Generate and display your introduction bundle |
|
||||||
|
| `/connect <user> <intro>` | Connect to a user using their introduction bundle |
|
||||||
|
| `/chats` | List all your established chats |
|
||||||
|
| `/switch <user>` | Switch to a different chat |
|
||||||
|
| `/delete <user>` | Delete a chat (removes session and crypto state) |
|
||||||
|
| `/peers` | List transport-level peers (users with inbox directories) |
|
||||||
|
| `/status` | Show connection status and your address |
|
||||||
|
| `/clear` | Clear current chat's message history |
|
||||||
|
| `/quit` or `Esc` or `Ctrl+C` | Exit the application |
|
||||||
|
|
||||||
|
#### `/peers` vs `/chats`
|
||||||
|
|
||||||
|
- **`/peers`**: Shows users whose CLI has been started (have inbox directories). These are potential contacts you *could* message.
|
||||||
|
- **`/chats`**: Shows users you have an **encrypted session** with (via `/connect`). These are active conversations.
|
||||||
|
|
||||||
|
### Sending Messages
|
||||||
|
|
||||||
|
Simply type your message and press Enter. Messages are automatically encrypted and delivered via file-based transport.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### File-Based Transport
|
||||||
|
|
||||||
|
Messages are passed between users via files in a shared directory:
|
||||||
|
|
||||||
|
1. Each user has an "inbox" directory at `tmp/chat-cli-data/transport/<username>/`
|
||||||
|
2. When Alice sends a message to Bob, it's written as a JSON file in Bob's inbox
|
||||||
|
3. Bob's client watches for new files and processes incoming messages
|
||||||
|
4. Files are deleted after processing
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
Data is stored in the `tmp/chat-cli-data/` directory:
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `<username>.db` | SQLite database for identity keys, inbox keys, chat metadata, and Double Ratchet state |
|
||||||
|
| `<username>_state.json` | CLI state: username↔chat mappings, message history, active chat |
|
||||||
|
| `transport/<username>/` | Inbox directory for receiving messages |
|
||||||
|
|
||||||
|
The sqlite tables can be viewed with app `DB Browser for SQLite`, password is `123456`, config use `SQLCipher 4 defaults`.
|
||||||
|
|
||||||
|
## Example Session
|
||||||
|
|
||||||
|
```
|
||||||
|
# Terminal 1 (Alice)
|
||||||
|
$ cargo run -p chat-cli -- alice
|
||||||
|
|
||||||
|
/intro
|
||||||
|
# Output: logos_chatintro_abc123
|
||||||
|
|
||||||
|
# Terminal 2 (Bob)
|
||||||
|
$ cargo run -p chat-cli -- bob
|
||||||
|
|
||||||
|
/connect alice logos_chatintro_abc123
|
||||||
|
# Connected! Bob sends "Hello!" automatically
|
||||||
|
|
||||||
|
# Now type messages in either terminal to chat!
|
||||||
|
|
||||||
|
# To see your chats:
|
||||||
|
/chats
|
||||||
|
# Output: alice (active)
|
||||||
|
|
||||||
|
# To switch between chats (if you have multiple):
|
||||||
|
/switch alice
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
chat-cli/
|
||||||
|
├── src/
|
||||||
|
│ ├── main.rs # Entry point
|
||||||
|
│ ├── app.rs # Application state and logic
|
||||||
|
│ ├── transport.rs # File-based message transport
|
||||||
|
│ └── ui.rs # Ratatui terminal UI
|
||||||
|
```
|
||||||
460
bin/chat-cli/src/app.rs
Normal file
460
bin/chat-cli/src/app.rs
Normal file
@ -0,0 +1,460 @@
|
|||||||
|
//! Chat application logic.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use arboard::Clipboard;
|
||||||
|
use libchat::{ChatStorage, Context as ChatManager, Introduction, StorageConfig};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{transport::FileTransport, utils::now};
|
||||||
|
|
||||||
|
/// A chat message for display.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DisplayMessage {
|
||||||
|
pub from_self: bool,
|
||||||
|
pub content: String,
|
||||||
|
pub timestamp: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata for a chat session (persisted).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChatSession {
|
||||||
|
pub chat_id: String,
|
||||||
|
pub remote_user: String,
|
||||||
|
pub messages: Vec<DisplayMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App state that gets persisted.
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct AppState {
|
||||||
|
/// Map from remote username to chat session.
|
||||||
|
pub chats: HashMap<String, ChatSession>,
|
||||||
|
/// Currently active chat (remote username).
|
||||||
|
pub active_chat: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The chat application state.
|
||||||
|
pub struct ChatApp {
|
||||||
|
/// The logos-chat manager.
|
||||||
|
pub manager: ChatManager<ChatStorage>,
|
||||||
|
/// File-based transport for message passing.
|
||||||
|
pub transport: FileTransport,
|
||||||
|
/// Our introduction bundle (to share with others).
|
||||||
|
pub intro_bundle: Option<Vec<u8>>,
|
||||||
|
/// Persisted app state.
|
||||||
|
pub state: AppState,
|
||||||
|
/// Global messages (shown when no active chat).
|
||||||
|
pub global_messages: Vec<DisplayMessage>,
|
||||||
|
/// Input buffer.
|
||||||
|
pub input: String,
|
||||||
|
/// Status message.
|
||||||
|
pub status: String,
|
||||||
|
/// Our user name.
|
||||||
|
pub user_name: String,
|
||||||
|
/// Path to state file.
|
||||||
|
state_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChatApp {
|
||||||
|
/// Create a new chat application.
|
||||||
|
pub fn new(user_name: &str, data_dir: &PathBuf) -> Result<Self> {
|
||||||
|
// Create database path
|
||||||
|
let db_path = data_dir.join(format!("{}.db", user_name));
|
||||||
|
std::fs::create_dir_all(data_dir)?;
|
||||||
|
|
||||||
|
// Open or create the chat manager with file-based storage
|
||||||
|
let manager = ChatManager::new_from_store(
|
||||||
|
user_name,
|
||||||
|
ChatStorage::new(StorageConfig::Encrypted {
|
||||||
|
path: db_path.to_string_lossy().to_string(),
|
||||||
|
key: "123456".to_string(),
|
||||||
|
})?,
|
||||||
|
)
|
||||||
|
.context("Failed to open ChatManager")?;
|
||||||
|
|
||||||
|
// Create file-based transport
|
||||||
|
let transport =
|
||||||
|
FileTransport::new(user_name, data_dir).context("Failed to create file transport")?;
|
||||||
|
|
||||||
|
// Load persisted state
|
||||||
|
let state_path = data_dir.join(format!("{}_state.json", user_name));
|
||||||
|
let state = Self::load_state(&state_path);
|
||||||
|
|
||||||
|
// Count existing chats
|
||||||
|
let chat_count = state.chats.len();
|
||||||
|
let status = if chat_count > 0 {
|
||||||
|
format!(
|
||||||
|
"Welcome back, {}! {} chat(s) loaded. Type /help for commands.",
|
||||||
|
user_name, chat_count
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("Welcome, {}! Type /help for commands.", user_name)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
manager,
|
||||||
|
transport,
|
||||||
|
intro_bundle: None,
|
||||||
|
state,
|
||||||
|
global_messages: Vec::new(),
|
||||||
|
input: String::new(),
|
||||||
|
status,
|
||||||
|
user_name: user_name.to_string(),
|
||||||
|
state_path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load state from file.
|
||||||
|
fn load_state(path: &PathBuf) -> AppState {
|
||||||
|
if path.exists()
|
||||||
|
&& let Ok(contents) = fs::read_to_string(path)
|
||||||
|
&& let Ok(state) = serde_json::from_str(&contents)
|
||||||
|
{
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
AppState::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save state to file.
|
||||||
|
fn save_state(&self) -> Result<()> {
|
||||||
|
let json = serde_json::to_string_pretty(&self.state)?;
|
||||||
|
fs::write(&self.state_path, json)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current chat session (if any).
|
||||||
|
pub fn current_session(&self) -> Option<&ChatSession> {
|
||||||
|
self.state
|
||||||
|
.active_chat
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|name| self.state.chats.get(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current messages to display.
|
||||||
|
pub fn messages(&self) -> Vec<&DisplayMessage> {
|
||||||
|
if let Some(session) = self.current_session() {
|
||||||
|
session.messages.iter().collect()
|
||||||
|
} else {
|
||||||
|
// Show global messages when no active chat
|
||||||
|
self.global_messages.iter().collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create and display our introduction bundle.
|
||||||
|
pub fn create_intro(&mut self) -> Result<String> {
|
||||||
|
let intro = self.manager.create_intro_bundle()?;
|
||||||
|
let bundle_string = String::from_utf8_lossy(&intro).to_string();
|
||||||
|
self.intro_bundle = Some(intro);
|
||||||
|
self.status = "Introduction bundle created. Share it with others!".to_string();
|
||||||
|
Ok(bundle_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to another user using their introduction bundle.
|
||||||
|
pub fn connect(&mut self, remote_user: &str, bundle_str: &str) -> Result<()> {
|
||||||
|
// Check if we already have a chat with this user
|
||||||
|
if self.state.chats.contains_key(remote_user) {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Already have a chat with {}. Use /switch {} to switch to it.",
|
||||||
|
remote_user,
|
||||||
|
remote_user
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let intro = Introduction::try_from(bundle_str.as_bytes())
|
||||||
|
.map_err(|e| anyhow::anyhow!("Invalid bundle: {:?}", e))?;
|
||||||
|
|
||||||
|
let (chat_id, envelopes) = self
|
||||||
|
.manager
|
||||||
|
.create_private_convo(&intro, "👋 Hello!".as_bytes())?;
|
||||||
|
|
||||||
|
// Send the envelopes via file transport
|
||||||
|
for envelope in envelopes {
|
||||||
|
self.transport.send(remote_user, envelope.data)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new session
|
||||||
|
let mut session = ChatSession {
|
||||||
|
chat_id: chat_id.clone().to_string(),
|
||||||
|
remote_user: remote_user.to_string(),
|
||||||
|
messages: Vec::new(),
|
||||||
|
};
|
||||||
|
session.messages.push(DisplayMessage {
|
||||||
|
from_self: true,
|
||||||
|
content: "👋 Hello!".to_string(),
|
||||||
|
timestamp: now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
self.state.chats.insert(remote_user.to_string(), session);
|
||||||
|
self.state.active_chat = Some(remote_user.to_string());
|
||||||
|
self.save_state()?;
|
||||||
|
|
||||||
|
self.status = format!("Connected to {}!", remote_user);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switch to a different chat.
|
||||||
|
pub fn switch_chat(&mut self, remote_user: &str) -> Result<()> {
|
||||||
|
if self.state.chats.contains_key(remote_user) {
|
||||||
|
self.state.active_chat = Some(remote_user.to_string());
|
||||||
|
self.save_state()?;
|
||||||
|
self.status = format!("Switched to chat with {}", remote_user);
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"No chat with {}. Use /chats to list available chats.",
|
||||||
|
remote_user
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a chat session.
|
||||||
|
pub fn delete_chat(&mut self, remote_user: &str) -> Result<()> {
|
||||||
|
if let Some(_session) = self.state.chats.remove(remote_user) {
|
||||||
|
// TODO delete not implemented in libchat
|
||||||
|
// Also delete from the library's storage
|
||||||
|
// if let Err(e) = self.manager.delete_chat(&session.chat_id) {
|
||||||
|
// // Log but don't fail - the CLI state is already updated
|
||||||
|
// self.status = format!("Warning: failed to delete crypto state: {}", e);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// If we deleted the active chat, clear it
|
||||||
|
if self.state.active_chat.as_deref() == Some(remote_user) {
|
||||||
|
// Switch to another chat if available, otherwise clear
|
||||||
|
self.state.active_chat = self.state.chats.keys().next().cloned();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.save_state()?;
|
||||||
|
self.status = format!("Deleted chat with {}", remote_user);
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"No chat with {}. Use /chats to list available chats.",
|
||||||
|
remote_user
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a message in the current chat.
|
||||||
|
pub fn send_message(&mut self, content: &str) -> Result<()> {
|
||||||
|
let active = self
|
||||||
|
.state
|
||||||
|
.active_chat
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No active chat. Use /connect or /switch first."))?;
|
||||||
|
|
||||||
|
let session = self
|
||||||
|
.state
|
||||||
|
.chats
|
||||||
|
.get(&active)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Chat session not found"))?;
|
||||||
|
|
||||||
|
let chat_id = session.chat_id.clone();
|
||||||
|
let remote_user = session.remote_user.clone();
|
||||||
|
|
||||||
|
let envelopes = self.manager.send_content(&chat_id, content.as_bytes())?;
|
||||||
|
|
||||||
|
for envelope in envelopes {
|
||||||
|
self.transport.send(&remote_user, envelope.data)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update messages
|
||||||
|
if let Some(session) = self.state.chats.get_mut(&active) {
|
||||||
|
session.messages.push(DisplayMessage {
|
||||||
|
from_self: true,
|
||||||
|
content: content.to_string(),
|
||||||
|
timestamp: now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.save_state()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process incoming messages from transport.
|
||||||
|
pub fn process_incoming(&mut self) -> Result<()> {
|
||||||
|
while let Some(envelope) = self.transport.try_recv() {
|
||||||
|
self.handle_incoming_envelope(&envelope)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle an incoming envelope.
|
||||||
|
fn handle_incoming_envelope(
|
||||||
|
&mut self,
|
||||||
|
envelope: &crate::transport::MessageEnvelope,
|
||||||
|
) -> Result<()> {
|
||||||
|
match self.manager.handle_payload(&envelope.data) {
|
||||||
|
Ok(content) => {
|
||||||
|
let from_user = &envelope.from;
|
||||||
|
let content = content.ok_or(anyhow::anyhow!("Convo not exist"))?;
|
||||||
|
let chat_id = content.conversation_id.clone();
|
||||||
|
|
||||||
|
// Find or create session for this user
|
||||||
|
if !self.state.chats.contains_key(from_user) {
|
||||||
|
// New chat from someone
|
||||||
|
let session = ChatSession {
|
||||||
|
chat_id: chat_id.clone(),
|
||||||
|
remote_user: from_user.clone(),
|
||||||
|
messages: Vec::new(),
|
||||||
|
};
|
||||||
|
self.state.chats.insert(from_user.clone(), session);
|
||||||
|
self.state.active_chat = Some(from_user.clone());
|
||||||
|
self.status = format!("New chat from {}!", from_user);
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = String::from_utf8_lossy(&content.data).to_string();
|
||||||
|
if !message.is_empty()
|
||||||
|
&& let Some(session) = self.state.chats.get_mut(from_user)
|
||||||
|
{
|
||||||
|
session.messages.push(DisplayMessage {
|
||||||
|
from_self: false,
|
||||||
|
content: message,
|
||||||
|
timestamp: envelope.timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
self.save_state()?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.status = format!("Error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a system message to the current chat (for display only).
|
||||||
|
fn add_system_message(&mut self, content: &str) {
|
||||||
|
let msg = DisplayMessage {
|
||||||
|
from_self: true,
|
||||||
|
content: content.to_string(),
|
||||||
|
timestamp: now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(active) = &self.state.active_chat.clone()
|
||||||
|
&& let Some(session) = self.state.chats.get_mut(active)
|
||||||
|
{
|
||||||
|
session.messages.push(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// No active chat - add to global messages
|
||||||
|
self.global_messages.push(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a command (starts with /).
|
||||||
|
pub fn handle_command(&mut self, cmd: &str) -> Result<Option<String>> {
|
||||||
|
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
|
||||||
|
let command = parts[0];
|
||||||
|
let args = parts.get(1).copied().unwrap_or("");
|
||||||
|
|
||||||
|
match command {
|
||||||
|
"/help" => {
|
||||||
|
self.add_system_message("── Commands ──");
|
||||||
|
self.add_system_message("/intro - Show your introduction bundle");
|
||||||
|
self.add_system_message("/connect <user> <bundle> - Connect to a user");
|
||||||
|
self.add_system_message("/chats - List all chats");
|
||||||
|
self.add_system_message("/switch <user> - Switch to chat with user");
|
||||||
|
self.add_system_message("/delete <user> - Delete chat with user");
|
||||||
|
self.add_system_message("/peers - List transport peers");
|
||||||
|
self.add_system_message("/status - Show connection status");
|
||||||
|
self.add_system_message("/clear - Clear current chat messages");
|
||||||
|
self.add_system_message("/quit or Esc or Ctrl+C - Exit");
|
||||||
|
Ok(Some("Help displayed".to_string()))
|
||||||
|
}
|
||||||
|
"/intro" => {
|
||||||
|
let bundle = self.create_intro()?;
|
||||||
|
self.add_system_message("── Your Introduction Bundle ──");
|
||||||
|
self.add_system_message(&bundle);
|
||||||
|
let clipboard_msg = match Clipboard::new().and_then(|mut cb| cb.set_text(&bundle)) {
|
||||||
|
Ok(()) => "Bundle copied to clipboard! Paste with Cmd+V in /connect.",
|
||||||
|
Err(_) => "Share this bundle with others to connect!",
|
||||||
|
};
|
||||||
|
self.add_system_message(clipboard_msg);
|
||||||
|
Ok(Some("Bundle created and copied to clipboard".to_string()))
|
||||||
|
}
|
||||||
|
"/connect" => {
|
||||||
|
let connect_parts: Vec<&str> = args.splitn(2, ' ').collect();
|
||||||
|
if connect_parts.len() < 2 {
|
||||||
|
return Ok(Some("Usage: /connect <username> <bundle>".to_string()));
|
||||||
|
}
|
||||||
|
let remote_user = connect_parts[0];
|
||||||
|
let bundle = connect_parts[1];
|
||||||
|
self.connect(remote_user, bundle)?;
|
||||||
|
Ok(Some(format!("Connected to {}", remote_user)))
|
||||||
|
}
|
||||||
|
"/chats" => {
|
||||||
|
let chat_names: Vec<_> = self.state.chats.keys().cloned().collect();
|
||||||
|
if chat_names.is_empty() {
|
||||||
|
Ok(Some("No chats yet. Use /connect to start one.".to_string()))
|
||||||
|
} else {
|
||||||
|
self.add_system_message(&format!("── Your Chats ({}) ──", chat_names.len()));
|
||||||
|
for name in &chat_names {
|
||||||
|
let marker = if Some(name) == self.state.active_chat.as_ref() {
|
||||||
|
" (active)"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
self.add_system_message(&format!(" • {}{}", name, marker));
|
||||||
|
}
|
||||||
|
Ok(Some(format!("{} chat(s)", chat_names.len())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"/switch" => {
|
||||||
|
if args.is_empty() {
|
||||||
|
return Ok(Some("Usage: /switch <username>".to_string()));
|
||||||
|
}
|
||||||
|
self.switch_chat(args)?;
|
||||||
|
Ok(Some(format!("Switched to {}", args)))
|
||||||
|
}
|
||||||
|
"/delete" => {
|
||||||
|
if args.is_empty() {
|
||||||
|
return Ok(Some("Usage: /delete <username>".to_string()));
|
||||||
|
}
|
||||||
|
self.delete_chat(args)?;
|
||||||
|
Ok(Some(format!("Deleted chat with {}", args)))
|
||||||
|
}
|
||||||
|
"/peers" => {
|
||||||
|
let peers = self.transport.list_peers();
|
||||||
|
if peers.is_empty() {
|
||||||
|
Ok(Some(
|
||||||
|
"No peers found. Start another chat-cli instance.".to_string(),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
self.add_system_message(&format!("── Peers ({}) ──", peers.len()));
|
||||||
|
for peer in &peers {
|
||||||
|
self.add_system_message(&format!(" • {}", peer));
|
||||||
|
}
|
||||||
|
Ok(Some(format!("{} peer(s)", peers.len())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"/status" => {
|
||||||
|
let chats = self.state.chats.len();
|
||||||
|
let active = self.state.active_chat.as_deref().unwrap_or("none");
|
||||||
|
let status = format!(
|
||||||
|
"User: {}\nAddress: {}\nChats: {}\nActive: {}",
|
||||||
|
self.user_name,
|
||||||
|
hex::encode(self.manager.installation_key().as_bytes()),
|
||||||
|
chats,
|
||||||
|
active
|
||||||
|
);
|
||||||
|
Ok(Some(status))
|
||||||
|
}
|
||||||
|
"/clear" => {
|
||||||
|
if let Some(active) = &self.state.active_chat.clone()
|
||||||
|
&& let Some(session) = self.state.chats.get_mut(active)
|
||||||
|
{
|
||||||
|
session.messages.clear();
|
||||||
|
self.save_state()?;
|
||||||
|
}
|
||||||
|
Ok(Some("Messages cleared".to_string()))
|
||||||
|
}
|
||||||
|
"/quit" => Ok(None),
|
||||||
|
_ => Ok(Some(format!(
|
||||||
|
"Unknown command: {}. Type /help for commands.",
|
||||||
|
command
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
bin/chat-cli/src/main.rs
Normal file
93
bin/chat-cli/src/main.rs
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
//! Chat CLI - A terminal chat application using logos-chat.
|
||||||
|
//!
|
||||||
|
//! This application demonstrates how to use the logos-chat library
|
||||||
|
//! with file-based transport for local communication.
|
||||||
|
//!
|
||||||
|
//! # Usage
|
||||||
|
//!
|
||||||
|
//! Run two instances with different usernames:
|
||||||
|
//!
|
||||||
|
//! ```bash
|
||||||
|
//! # Terminal 1
|
||||||
|
//! cargo run -p chat-cli -- alice
|
||||||
|
//!
|
||||||
|
//! # Terminal 2
|
||||||
|
//! cargo run -p chat-cli -- bob
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Then in alice's terminal:
|
||||||
|
//! 1. Type `/intro` to get your introduction bundle
|
||||||
|
//! 2. Copy the bundle string
|
||||||
|
//!
|
||||||
|
//! In bob's terminal:
|
||||||
|
//! 1. Type `/connect alice <bundle>` (paste alice's bundle)
|
||||||
|
//!
|
||||||
|
//! Now bob can send messages to alice, and alice can reply.
|
||||||
|
|
||||||
|
mod app;
|
||||||
|
mod transport;
|
||||||
|
mod ui;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
|
use std::{env, path::PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
/// Get the data directory (in project folder).
|
||||||
|
fn get_data_dir() -> PathBuf {
|
||||||
|
env::current_dir()
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
.join("tmp/chat-cli-data")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
// Parse arguments
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
if args.len() < 2 {
|
||||||
|
eprintln!("Usage: {} <username>", args[0]);
|
||||||
|
eprintln!("\nExample:");
|
||||||
|
eprintln!(" Terminal 1: {} alice", args[0]);
|
||||||
|
eprintln!(" Terminal 2: {} bob", args[0]);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_name = &args[1];
|
||||||
|
|
||||||
|
// Setup data directory in project folder
|
||||||
|
let data_dir = get_data_dir();
|
||||||
|
std::fs::create_dir_all(&data_dir).context("Failed to create data directory")?;
|
||||||
|
|
||||||
|
println!("Starting chat as '{}'...", user_name);
|
||||||
|
println!("Data dir: {:?}", data_dir);
|
||||||
|
|
||||||
|
// Create app
|
||||||
|
let mut app = app::ChatApp::new(user_name, &data_dir).context("Failed to create chat app")?;
|
||||||
|
|
||||||
|
// Initialize terminal UI
|
||||||
|
let mut terminal = ui::init().context("Failed to initialize terminal")?;
|
||||||
|
|
||||||
|
// Main loop
|
||||||
|
let result = run_app(&mut terminal, &mut app);
|
||||||
|
|
||||||
|
// Restore terminal
|
||||||
|
ui::restore().context("Failed to restore terminal")?;
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_app(terminal: &mut ui::Tui, app: &mut app::ChatApp) -> Result<()> {
|
||||||
|
loop {
|
||||||
|
// Process incoming messages
|
||||||
|
app.process_incoming()?;
|
||||||
|
|
||||||
|
// Draw UI
|
||||||
|
terminal.draw(|frame| ui::draw(frame, app))?;
|
||||||
|
|
||||||
|
// Handle input
|
||||||
|
if !ui::handle_events(app)? {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
138
bin/chat-cli/src/transport.rs
Normal file
138
bin/chat-cli/src/transport.rs
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
//! File-based transport for local chat communication.
|
||||||
|
//!
|
||||||
|
//! Messages are passed between users via files in a shared directory.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::utils::now;
|
||||||
|
|
||||||
|
/// A message envelope for transport.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MessageEnvelope {
|
||||||
|
pub from: String,
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
pub timestamp: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File-based transport for local communication.
|
||||||
|
pub struct FileTransport {
|
||||||
|
/// Our user name.
|
||||||
|
user_name: String,
|
||||||
|
/// Base directory for transport files.
|
||||||
|
base_dir: PathBuf,
|
||||||
|
/// Our inbox directory.
|
||||||
|
inbox_dir: PathBuf,
|
||||||
|
/// Set of processed message files (to avoid reprocessing).
|
||||||
|
processed: HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileTransport {
|
||||||
|
/// Create a new file transport.
|
||||||
|
pub fn new(user_name: &str, data_dir: &Path) -> Result<Self> {
|
||||||
|
let base_dir = data_dir.join("transport");
|
||||||
|
let inbox_dir = base_dir.join(user_name);
|
||||||
|
|
||||||
|
// Create our inbox directory
|
||||||
|
fs::create_dir_all(&inbox_dir).context("Failed to create inbox directory")?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
user_name: user_name.to_string(),
|
||||||
|
base_dir,
|
||||||
|
inbox_dir,
|
||||||
|
processed: HashSet::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a message to a specific user.
|
||||||
|
pub fn send(&self, to_user: &str, data: Vec<u8>) -> Result<()> {
|
||||||
|
let target_dir = self.base_dir.join(to_user);
|
||||||
|
|
||||||
|
// Create target inbox if it doesn't exist
|
||||||
|
fs::create_dir_all(&target_dir).context("Failed to create target inbox")?;
|
||||||
|
|
||||||
|
let envelope = MessageEnvelope {
|
||||||
|
from: self.user_name.clone(),
|
||||||
|
data,
|
||||||
|
timestamp: now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write message to a unique file
|
||||||
|
let filename = format!("{}_{}.json", self.user_name, now());
|
||||||
|
let filepath = target_dir.join(&filename);
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(&envelope)?;
|
||||||
|
fs::write(&filepath, json).context("Failed to write message file")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to receive an incoming message (non-blocking).
|
||||||
|
pub fn try_recv(&mut self) -> Option<MessageEnvelope> {
|
||||||
|
// List files in our inbox
|
||||||
|
let entries = match fs::read_dir(&self.inbox_dir) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
// Skip non-json files
|
||||||
|
if path.extension().map(|e| e != "json").unwrap_or(true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let filename = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Skip already processed files
|
||||||
|
if self.processed.contains(&filename) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to read and parse the message
|
||||||
|
if let Ok(contents) = fs::read_to_string(&path)
|
||||||
|
&& let Ok(envelope) = serde_json::from_str::<MessageEnvelope>(&contents)
|
||||||
|
{
|
||||||
|
// Mark as processed and delete
|
||||||
|
self.processed.insert(filename);
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
return Some(envelope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List available peers (users with inbox directories).
|
||||||
|
pub fn list_peers(&self) -> Vec<String> {
|
||||||
|
let mut peers = Vec::new();
|
||||||
|
|
||||||
|
if let Ok(entries) = fs::read_dir(&self.base_dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
if entry.path().is_dir()
|
||||||
|
&& let Some(name) = entry.file_name().to_str()
|
||||||
|
&& name != self.user_name
|
||||||
|
{
|
||||||
|
peers.push(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
peers
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get our user name.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn user_name(&self) -> &str {
|
||||||
|
&self.user_name
|
||||||
|
}
|
||||||
|
}
|
||||||
243
bin/chat-cli/src/ui.rs
Normal file
243
bin/chat-cli/src/ui.rs
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
//! Terminal UI using ratatui.
|
||||||
|
|
||||||
|
use std::io::{self, Stdout};
|
||||||
|
|
||||||
|
use crossterm::{
|
||||||
|
event::{self, Event, KeyCode, KeyEventKind},
|
||||||
|
execute,
|
||||||
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||||
|
};
|
||||||
|
use ratatui::{
|
||||||
|
Frame, Terminal,
|
||||||
|
backend::CrosstermBackend,
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::app::ChatApp;
|
||||||
|
|
||||||
|
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
|
||||||
|
|
||||||
|
/// Initialize the terminal.
|
||||||
|
pub fn init() -> io::Result<Tui> {
|
||||||
|
execute!(io::stdout(), EnterAlternateScreen)?;
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let backend = CrosstermBackend::new(io::stdout());
|
||||||
|
Terminal::new(backend)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore the terminal to its original state.
|
||||||
|
pub fn restore() -> io::Result<()> {
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(io::stdout(), LeaveAlternateScreen)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw the UI.
|
||||||
|
pub fn draw(frame: &mut Frame, app: &ChatApp) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3), // Header
|
||||||
|
Constraint::Min(10), // Messages
|
||||||
|
Constraint::Length(3), // Input
|
||||||
|
Constraint::Length(3), // Status
|
||||||
|
])
|
||||||
|
.split(frame.area());
|
||||||
|
|
||||||
|
draw_header(frame, app, chunks[0]);
|
||||||
|
draw_messages(frame, app, chunks[1]);
|
||||||
|
draw_input(frame, app, chunks[2]);
|
||||||
|
draw_status(frame, app, chunks[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_header(frame: &mut Frame, app: &ChatApp, area: Rect) {
|
||||||
|
let title = match app.current_session() {
|
||||||
|
Some(session) => format!(" 💬 Chat: {} ↔ {} ", app.user_name, session.remote_user),
|
||||||
|
None => format!(
|
||||||
|
" 💬 {} (no active chat - use /connect or /chats) ",
|
||||||
|
app.user_name
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let header = Paragraph::new(title)
|
||||||
|
.style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
.block(Block::default().borders(Borders::ALL));
|
||||||
|
|
||||||
|
frame.render_widget(header, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_messages(frame: &mut Frame, app: &ChatApp, area: Rect) {
|
||||||
|
let remote_name = app
|
||||||
|
.current_session()
|
||||||
|
.map(|s| s.remote_user.as_str())
|
||||||
|
.unwrap_or("Them");
|
||||||
|
|
||||||
|
// Inner width: area minus borders (2) for wrapping long content.
|
||||||
|
let inner_width = area.width.saturating_sub(2) as usize;
|
||||||
|
|
||||||
|
let messages: Vec<ListItem> = app
|
||||||
|
.messages()
|
||||||
|
.iter()
|
||||||
|
.flat_map(|msg| {
|
||||||
|
let (prefix, style) = if msg.from_self {
|
||||||
|
("You", Style::default().fg(Color::Green))
|
||||||
|
} else {
|
||||||
|
(remote_name, Style::default().fg(Color::Yellow))
|
||||||
|
};
|
||||||
|
|
||||||
|
let prefix_str = format!("{}: ", prefix);
|
||||||
|
let prefix_len = prefix_str.len();
|
||||||
|
|
||||||
|
// Split content into lines that fit within inner_width.
|
||||||
|
let content = &msg.content;
|
||||||
|
if content.is_empty() {
|
||||||
|
return vec![ListItem::new(Line::from(vec![Span::styled(
|
||||||
|
prefix_str,
|
||||||
|
style.add_modifier(Modifier::BOLD),
|
||||||
|
)]))];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut items = Vec::new();
|
||||||
|
let first_line_width = inner_width.saturating_sub(prefix_len).max(1);
|
||||||
|
|
||||||
|
// First line includes the prefix.
|
||||||
|
let (first_chunk, rest) = if content.len() <= first_line_width {
|
||||||
|
(content.as_str(), "")
|
||||||
|
} else {
|
||||||
|
content.split_at(first_line_width)
|
||||||
|
};
|
||||||
|
|
||||||
|
items.push(ListItem::new(Line::from(vec![
|
||||||
|
Span::styled(prefix_str, style.add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw(first_chunk),
|
||||||
|
])));
|
||||||
|
|
||||||
|
// Continuation lines are indented to align with content.
|
||||||
|
let indent = " ".repeat(prefix_len);
|
||||||
|
let mut remaining = rest;
|
||||||
|
while !remaining.is_empty() {
|
||||||
|
let chunk_width = inner_width.saturating_sub(prefix_len).max(1);
|
||||||
|
let (chunk, tail) = if remaining.len() <= chunk_width {
|
||||||
|
(remaining, "")
|
||||||
|
} else {
|
||||||
|
remaining.split_at(chunk_width)
|
||||||
|
};
|
||||||
|
items.push(ListItem::new(Line::from(vec![
|
||||||
|
Span::raw(indent.clone()),
|
||||||
|
Span::raw(chunk),
|
||||||
|
])));
|
||||||
|
remaining = tail;
|
||||||
|
}
|
||||||
|
|
||||||
|
items
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let title = match app.current_session() {
|
||||||
|
Some(session) => format!(" Messages with {} ", session.remote_user),
|
||||||
|
None => " Messages ".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let messages_widget =
|
||||||
|
List::new(messages).block(Block::default().title(title).borders(Borders::ALL));
|
||||||
|
|
||||||
|
frame.render_widget(messages_widget, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_input(frame: &mut Frame, app: &ChatApp, area: Rect) {
|
||||||
|
// Inner width: area minus borders (2).
|
||||||
|
let inner_width = area.width.saturating_sub(2) as usize;
|
||||||
|
let input_len = app.input.len();
|
||||||
|
|
||||||
|
// Scroll the view so the cursor (end of input) is always visible.
|
||||||
|
let scroll_offset = if input_len >= inner_width {
|
||||||
|
input_len - inner_width + 1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let visible_input = &app.input[scroll_offset..];
|
||||||
|
|
||||||
|
let input = Paragraph::new(visible_input)
|
||||||
|
.style(Style::default().fg(Color::White))
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.title(" Input (Enter to send) ")
|
||||||
|
.borders(Borders::ALL),
|
||||||
|
);
|
||||||
|
|
||||||
|
frame.render_widget(input, area);
|
||||||
|
|
||||||
|
// Place cursor at the visible end of the input.
|
||||||
|
let cursor_x = area.x + (input_len - scroll_offset) as u16 + 1;
|
||||||
|
frame.set_cursor_position((cursor_x, area.y + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_status(frame: &mut Frame, app: &ChatApp, area: Rect) {
|
||||||
|
let status = Paragraph::new(app.status.as_str())
|
||||||
|
.style(Style::default().fg(Color::Gray))
|
||||||
|
.block(Block::default().title(" Status ").borders(Borders::ALL))
|
||||||
|
.wrap(Wrap { trim: true });
|
||||||
|
|
||||||
|
frame.render_widget(status, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle keyboard events.
|
||||||
|
pub fn handle_events(app: &mut ChatApp) -> io::Result<bool> {
|
||||||
|
// Poll for events with a short timeout to allow checking incoming messages
|
||||||
|
if event::poll(std::time::Duration::from_millis(100))?
|
||||||
|
&& let Event::Key(key) = event::read()?
|
||||||
|
{
|
||||||
|
if key.kind != KeyEventKind::Press {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => return Ok(false),
|
||||||
|
// Handle Ctrl+C
|
||||||
|
KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
KeyCode::Enter if !app.input.is_empty() => {
|
||||||
|
let input = std::mem::take(&mut app.input);
|
||||||
|
|
||||||
|
if input.starts_with('/') {
|
||||||
|
match app.handle_command(&input) {
|
||||||
|
Ok(Some(response)) => {
|
||||||
|
app.status = response;
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
// Quit signal
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.status = format!("Error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if app.current_session().is_some() {
|
||||||
|
if let Err(e) = app.send_message(&input) {
|
||||||
|
app.status = format!("Send error: {}", e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
app.status = "No active chat. Use /connect first.".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
app.input.push(c);
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
app.input.pop();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
6
bin/chat-cli/src/utils.rs
Normal file
6
bin/chat-cli/src/utils.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
pub fn now() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_millis() as u64
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::{cell::RefCell, rc::Rc};
|
use std::{cell::RefCell, rc::Rc};
|
||||||
|
|
||||||
use crypto::Identity;
|
use crypto::{Identity, PublicKey};
|
||||||
use storage::{ChatStore, ConversationKind};
|
use storage::{ChatStore, ConversationKind};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@ -77,6 +77,10 @@ impl<S: ChatStore> Context<S> {
|
|||||||
self._identity.get_name()
|
self._identity.get_name()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn installation_key(&self) -> PublicKey {
|
||||||
|
self._identity.public_key()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn create_private_convo(
|
pub fn create_private_convo(
|
||||||
&mut self,
|
&mut self,
|
||||||
remote_bundle: &Introduction,
|
remote_bundle: &Introduction,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user