Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
dc9d8d2
added: rustls to reqwest
ih8js-git Jul 16, 2026
cb2d0dc
Merge pull request #32 from ih8js-git/fix/api
ih8js-git Jul 16, 2026
a1a3d29
test: added tests for typing and single keybinding shortcuts
thairanaru Jul 17, 2026
85ea5c5
fix: update capital letters
thairanaru Jul 17, 2026
044806a
feat: added GoToTop action with tests
thairanaru Jul 17, 2026
b1ad7b1
Merge pull request #35 from ih8js-git/test/input
ih8js-git Jul 18, 2026
c238c04
fix: changed String error handling to anyhow + thiserror errors
YetAnotherMechanicusEnjoyer Jul 21, 2026
fe609ae
Merge pull request #36 from YetAnotherMechanicusEnjoyer/staging
ih8js-git Jul 23, 2026
14fcdd6
feat(crate): add tokio-tungstenite & futures-util
YetAnotherMechanicusEnjoyer Jul 24, 2026
8ce6b9d
feat: add WebSocket connection to read & send events
YetAnotherMechanicusEnjoyer Jul 24, 2026
bbabb8c
feat(refactor): URLs are no longer hard coded
YetAnotherMechanicusEnjoyer Jul 24, 2026
f05bea8
feat: add authenticate_ws to App
YetAnotherMechanicusEnjoyer Jul 24, 2026
b77d171
feat(refactor): WsClient is now in src/api/ws.rs
YetAnotherMechanicusEnjoyer Jul 26, 2026
76f2ef6
feat: add NotifyHandler with notify_rust crate
YetAnotherMechanicusEnjoyer Jul 29, 2026
92e073f
Merge pull request #37 from YetAnotherMechanicusEnjoyer/ws
ih8js-git Jul 29, 2026
14c5ba4
Merge branch 'staging' into notifications
YetAnotherMechanicusEnjoyer Jul 29, 2026
cb5c4c7
Merge pull request #38 from YetAnotherMechanicusEnjoyer/notifications
ih8js-git Jul 30, 2026
54ba548
fix: blocking WebSocket receiver causing unresponsive input
ih8js-git Aug 3, 2026
fe8d657
refactor: handle WebSocket ready event and store servers in memory
ih8js-git Aug 4, 2026
76cfbbb
feat: render dynamic server list with Vim hybrid line numbers
ih8js-git Aug 4, 2026
a42b754
feat(api): add non-blocking DM list page with recipient resolution
ih8js-git Aug 4, 2026
59286ae
feat(command): create command module for Vim command parsing and exec…
ih8js-git Aug 4, 2026
5e5cf29
feat(ui): added first basic version of a DM page
ih8js-git Aug 23, 2026
1bc007a
feat(ui): added basic message history viewing for dm's
ih8js-git Aug 23, 2026
02bf87c
feat(api): add username caching
YetAnotherMechanicusEnjoyer Aug 23, 2026
d936fde
fix: clippy warning (i can't remove this damn pre-push script)
YetAnotherMechanicusEnjoyer Aug 23, 2026
91c5211
Merge pull request #40 from YetAnotherMechanicusEnjoyer/staging
ih8js-git Aug 24, 2026
ee13dab
refactor(api): rename dms module to dm
ih8js-git Aug 24, 2026
457cc6e
feat(ux): use :q for screen navigation and :qa to quit app
ih8js-git Aug 24, 2026
ea16a8d
perf(ui): pre-resolve dm author names into memory to remove render lo…
ih8js-git Aug 24, 2026
fc24a3b
perf(api): check local cache before fetching dm recipient user profiles
ih8js-git Aug 24, 2026
723fe4a
refactor(app): extract server, dm channel, and message state into App…
ih8js-git Aug 24, 2026
e258235
refactor(cache): switch to in-memory user cache with disk sync on exit
ih8js-git Aug 24, 2026
7a46dd9
feat(ui): add temporary placeholder message box to DM view
ih8js-git Aug 24, 2026
03fcb2c
feat(ui): add text wrapping and auto-scroll to DM view
ih8js-git Aug 24, 2026
7d95dac
feat(ui): update hardware cursor shape based on input mode
ih8js-git Aug 24, 2026
f7bd10d
style(ui): switch to blinking cursor variants for normal and insert m…
ih8js-git Aug 24, 2026
f64288f
fix(ui): preserve message draft when toggling insert mode
ih8js-git Aug 24, 2026
e47f2fa
feat(ui): implement cursor positioning and vim navigation in message …
ih8js-git Aug 24, 2026
526169a
feat(ui): add mode-specific border color themes
ih8js-git Aug 24, 2026
11abd28
fix(ui): prevent cursor from wrapping across line boundaries
ih8js-git Aug 24, 2026
dcb501b
refactor(app): modularize state action handlers into separate files
ih8js-git Aug 24, 2026
4ea75ce
feat(input): add in-memory yank buffer and normal mode `dd` motion
ih8js-git Aug 24, 2026
8c1e4a6
feat(input): add `A` and `I` insert mode motions
ih8js-git Aug 24, 2026
022f9bb
fix(ui): resolve usernames for message authors in DMs
ih8js-git Aug 24, 2026
4ee7d95
feat(ws): listen for real-time messages and resolve author details
ih8js-git Aug 24, 2026
35959ae
feat(dm): add unread indicators and message previews for DM list
ih8js-git Aug 24, 2026
9770db4
feat(dm): hook up message sending to API
ih8js-git Aug 24, 2026
424836c
feat(ws): handle MessageUpdate and MessageDelete events
ih8js-git Aug 24, 2026
eaac987
refactor(input): separate InputMode::UI and InputMode::Normal contexts
ih8js-git Aug 24, 2026
568b774
chore: remove startup test notification and silence unused notificati…
ih8js-git Aug 24, 2026
b17560e
docs: document new features, keybindings, and commands in mdBook
ih8js-git Aug 24, 2026
9b3e434
fix(ws): return error on WebSocket authentication failure instead of …
ih8js-git Aug 24, 2026
c81300d
fix(ui): match "invalid token" case-insensitively in error title dete…
ih8js-git Aug 24, 2026
6527752
fix(ui): clamp command box cursor X position to inner widget bounds
ih8js-git Aug 24, 2026
e14a30c
fix(ui): clamp token input cursor X position to widget bounds
ih8js-git Aug 24, 2026
afde45d
perf(app): drain app and websocket event channels each tick
ih8js-git Aug 24, 2026
bf0f531
docs(notification): rephrase ICON_FILE comment for clarity and tone
ih8js-git Aug 24, 2026
6476211
fix(ws): correct typos in WebSocket log messages
ih8js-git Aug 24, 2026
66d654a
fix(auth): authenticate WebSocket and fetch user only after successfu…
ih8js-git Aug 24, 2026
b80d07c
fix(ui): truncate DM preview by char count instead of byte length to …
ih8js-git Aug 24, 2026
8fe7e69
fix: fix temporary borrows across awaits and event use-after-move
ih8js-git Aug 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ target
Cargo.lock

# Logs
*.log
*.log
docs/vimstoat/book*/
12 changes: 11 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,22 @@ pickledb = { version = "0.5.1", features = ["bincode"] }
# Directories
dirs = "6.0.0"

# Notifications
notify-rust = "4.18.0"
# WebSockets
tokio-tungstenite = { version = "0.30.0", features = ["native-tls"] }
futures-util = "0.3.31"

keyring-lib = { version = "1.0.3", features = ["tokio"] }
ratatui = "0.30.2"
reqwest = { version = "0.13.4", default-features = false, features = ["json"] }
reqwest = { version = "0.13.4", default-features = false, features = [
"json",
"rustls",
] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1.52.3", features = ["full"] }
serde_json = "1.0.150"
ulid = "3.0.0"

[dev-dependencies]
cargo-husky = { version = "1.5.0", default-features = false, features = [
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A lightweight TUI [Stoat](https://stoat.chat) client that feels like Vim.

> **Status:** Early development — see [ROADMAP.md](ROADMAP.md) for the full plan.
> **Status:** Early development — see our [Documentation](docs/vimstoat/src/SUMMARY.md) for features and keybinds.

## What is this?

Expand Down
557 changes: 0 additions & 557 deletions ROADMAP.md

This file was deleted.

1 change: 1 addition & 0 deletions docs/vimstoat/src/SUMMARY.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# Summary

- [Features](./features.md)
- [Keybinds](./keybinds.md)
20 changes: 20 additions & 0 deletions docs/vimstoat/src/features.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Features

VimStoat is currently in early development. Here are the features implemented so far:

## Real-time Messaging
- **WebSocket Integration**: Listens for real-time `Message`, `MessageUpdate`, and `MessageDelete` events from the server.
- **Dynamic Author Resolution**: Transparently fetches and caches unknown user profiles to display real usernames instead of raw IDs in the UI.
- **In-Memory Cache**: Uses an in-memory cache synchronized to disk upon application exit for maximum performance.

## Direct Messages
- **DM List View**: Displays all active Direct Message channels.
- **Unread Indicators**: Channels with new messages are automatically marked with a red `[*]` unread indicator.
- **Message Previews**: The DM list shows a truncated preview of the latest message for each channel.
- **Chat Interface**: View message history and send new messages seamlessly via the REST API.

## Vim-Native Interface
- **Mode-Specific UI Themes**: Border colors dynamically shift based on the current mode (e.g., Yellow for Insert mode, Blue for UI/Normal mode).
- **Cursor Shaping**: Hardware cursor shape changes depending on mode (e.g., blinking bar in Insert mode, block in Normal mode).
- **Input Motions**: Features robust vim-like text composition motions like `i`, `I`, `a`, `A`, `o`, `O`, `dd` (delete line), and an in-memory yank buffer.
- **Screen Navigation**: Use `q` or `:q` to navigate back through screens, and `:qa` to quit globally.
10 changes: 10 additions & 0 deletions docs/vimstoat/src/keybinds.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,21 @@
| `A` | Enter Insert mode, at the end of the line |
| `o` | Enter Insert mode, and create a new line below the current line. |
| `O` | Enter Insert mode, and create a new line above the current line. |
| `dd` | Delete the current line |
| `gg` | Go to the top of the UI list |
| `q` | Go back or Quit |
| `:` | Enter Command mode |
| `/` | Search (enters Command mode with `/` prefix) |
| `v` | Enter Visual mode (future) |
| `Esc` | Enter Normal mode |

## Commands (Command Mode)

| Command | Action |
| -------------- | ---------------------------------------------------------------- |
| `:q`, `:quit` | Go back to the previous screen, or quit if at the top level |
| `:qa`, `:qall` | Quit the application immediately |

## Fuzzy Finder

| Key | Action |
Expand Down
14 changes: 12 additions & 2 deletions src/action.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
/// Action based on user input
#[derive(Clone, Copy)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Action {
#[allow(unused)]
GoToTopUI,
#[allow(unused)]
AppendCharacter(char),
RemoveCharacter,
EnterCommandMode,
EnterInsertMode,
EnterInsertModeAfter,
EnterInsertModeLineStart,
EnterInsertModeLineEnd,
OpenNewLineBelow,
OpenNewLineAbove,
DeleteLine,
Enter,
CursorLeft,
CursorRight,
CursorUp,
CursorDown,
Escape,
#[allow(unused)]
Quit,
}
33 changes: 20 additions & 13 deletions src/api/auth.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
use crate::api::client::{ApiClient, Endpoint};
use crate::{
Result,
api::client::{ApiClient, Endpoint},
error::AuthError,
};
use keyring::KeyringEntry;
use serde_json::Value;

Expand All @@ -7,23 +11,21 @@ pub struct Auth {
}

impl Auth {
pub fn new() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
pub fn new() -> Result<Self> {
let crate_id = "vimstoat";
let token_entry = KeyringEntry::try_new(crate_id)?;
Ok(Self { token_entry })
}

pub async fn store_token(&self, token: &str) -> Result<(), String> {
self.token_entry.set_secret(token).await.map_err(|e| {
format!(
"{}\n\nUnderlying Details:\n{:?}\n\n💡 Hint: If you are on a minimal Linux install, you likely need to install a Secret Service provider (e.g., `sudo pacman -S gnome-keyring`).",
e, e
)
})
pub async fn store_token(&self, token: &str) -> Result<()> {
self.token_entry
.set_secret(token)
.await
.map_err(|e| e.into())
}

pub async fn validate_token(&self, token: &str) -> Result<ApiClient, String> {
let client = ApiClient::new(token.to_string());
pub async fn validate_token(&self, token: &str, base_url: Option<String>) -> Result<ApiClient> {
let client = ApiClient::new(token.to_string(), base_url);

client
.get::<Value>(Endpoint::CurrentUser)
Expand All @@ -32,9 +34,14 @@ impl Auth {
.map_err(|e| {
let err_msg = e.to_string();
if err_msg.contains("401") {
"Invalid token. Please check your session token and try again.".to_string()
AuthError::InvalidToken(
"Please check your session token and try again.".to_string(),
)
.into()
} else if err_msg.contains("API GET request") {
AuthError::RequestError(err_msg).into()
} else {
err_msg
AuthError::ServerConnectionError.into()
}
})
}
Expand Down
51 changes: 51 additions & 0 deletions src/api/channel.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
use crate::{
Result,
api::client::{ApiClient, Endpoint},
};
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct MessageHistoryQuery {
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub before: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub after: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sort: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nearby: Option<String>,
}

pub async fn fetch_message_history(
api_client: &ApiClient,
channel_id: &str,
query: Option<&MessageHistoryQuery>,
) -> Result<Vec<serde_json::Value>> {
let mut path = format!("/channels/{}/messages", channel_id);
if let Some(q) = query {
let mut params = Vec::new();
if let Some(limit) = q.limit {
params.push(format!("limit={}", limit));
}
if let Some(before) = &q.before {
params.push(format!("before={}", before));
}
if let Some(after) = &q.after {
params.push(format!("after={}", after));
}
if let Some(sort) = &q.sort {
params.push(format!("sort={}", sort));
}
if let Some(nearby) = &q.nearby {
params.push(format!("nearby={}", nearby));
}
if !params.is_empty() {
path.push('?');
path.push_str(&params.join("&"));
}
}

api_client.get(Endpoint::Custom(path)).await
}
48 changes: 44 additions & 4 deletions src/api/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ use anyhow::{Result, anyhow};
use reqwest::Client;
use serde::de::DeserializeOwned;

const BASE_URL: &str = "https://api.stoat.chat";
use crate::api::API_BASE_URL;

#[derive(Debug)]
#[allow(unused)]
pub enum Endpoint {
Config,
CurrentUser,
Expand All @@ -14,6 +15,7 @@ pub enum Endpoint {
Channel(String),
MessageHistory(String),
SendMessage(String),
Custom(String),
}

impl Endpoint {
Expand All @@ -27,27 +29,31 @@ impl Endpoint {
Self::Channel(id) => format!("/channels/{}", id),
Self::MessageHistory(id) => format!("/channels/{}/messages", id),
Self::SendMessage(id) => format!("/channels/{}/messages", id),
Self::Custom(path) => path.clone(),
}
}
}

#[derive(Debug, Clone)]
pub struct ApiClient {
client: Client,
token: String,
base_url: String,
}

impl ApiClient {
pub fn new(token: String) -> Self {
pub fn new(token: String, base_url: Option<String>) -> Self {
Self {
client: Client::new(),
token,
base_url: base_url.unwrap_or(API_BASE_URL.to_string()),
}
}

/// Makes a GET request to the specified endpoint and deserializes the JSON response into `T`.
/// The `endpoint` should start with a slash, e.g., `/users/@me`.
pub async fn get<T: DeserializeOwned>(&self, endpoint: Endpoint) -> Result<T> {
let url = format!("{}{}", BASE_URL, endpoint.path());
let url = format!("{}{}", self.base_url, endpoint.path());

let response = self
.client
Expand All @@ -70,6 +76,40 @@ impl ApiClient {
))
}
}

pub async fn post<T: DeserializeOwned, B: serde::Serialize>(
&self,
endpoint: Endpoint,
body: &B,
) -> Result<T> {
let url = format!("{}{}", self.base_url, endpoint.path());

let response = self
.client
.post(&url)
.header("X-Session-Token", &self.token)
.json(body)
.send()
.await?;

if response.status().is_success() {
let data = response.json::<T>().await?;
Ok(data)
} else {
let status = response.status();
let text = response.text().await.unwrap_or_default();
Err(anyhow!(
"API POST request to {:?} failed: {} - {}",
endpoint,
status,
text
))
}
}

pub fn clone_token(&self) -> String {
self.token.clone()
}
}

#[cfg(test)]
Expand All @@ -80,7 +120,7 @@ mod tests {
#[tokio::test]
async fn test_get_config() {
// Token doesn't matter for the root config endpoint, but we provide a dummy one
let client = ApiClient::new("dummy_token".to_string());
let client = ApiClient::new("dummy_token".to_string(), None);

let result = client.get::<Value>(Endpoint::Config).await;
assert!(result.is_ok(), "Failed to get config: {:?}", result.err());
Expand Down
Loading
Loading