mod app;
#[allow(dead_code)]
mod cli;
mod clock;
mod config;
mod draw;
mod drawlog;
mod emote;
mod feed;
mod followlog;
mod held;
mod hsauth;
mod http;
mod input;
mod intr;
mod key;
mod kick;
mod layout;
mod mentions;
mod modcmd;
mod net;
mod paints;
mod palette;
mod picker;
mod sched;
mod scroll;
mod social;
mod special;
mod streamrow;
mod talk;
mod temp;
mod twitch;
use std::io::{self, IsTerminal};
use std::time::{Duration, Instant};
use app::{App, Feed, InputMode};
use config::TabPos;
use crossterm::event::{self, Event, KeyEventKind};
use draw::{emote_mode, ui};
use emote::fb::FbEmotes;
use emote::render::EmoteStore;
use feed::{
drain_emotes, drain_feed, drain_history, drain_hs, drain_pins, drain_twitch, pump_loads,
request_stacks, spawn_emote_fetch, spawn_global_emotes, spawn_history, spawn_pin, tick_advance,
};
use heatsync_core::edit;
use heatsync_core::emote::EmoteSet;
use heatsync_core::room::RoomModes;
use heatsync_core::streamev::StreamEvents;
use heatsync_core::TabKind;
use heatsync_core::{mock, Channel, Platform};
use input::{drain_picker, handle_key, Flow};
use net::Sub;
use ratatui::Terminal;
const USAGE: &str = "\
heatsync-tui — heat-sorted live multichat in the terminal
USAGE:
heatsync-tui [CHANNEL...] open the chat UI on those channels
(name, kick:name, yt:videoid or a youtube url)
heatsync-tui <SUBCOMMAND>
SUBCOMMANDS:
log print recent messages
search search the chat archive
hot, top busiest channels right now
login log in with heatsync (chat + mod, twitch and kick)
login kick same login, with how to link kick
logout log out and forget the login
status who is logged in and what they can do
probe check a channel is reachable
diag dump diagnostics
render-test draw the emote/paint test pattern
OPTIONS:
--mock run against a synthetic feed, no network
-h, --help
-V, --version
";
fn main() -> io::Result<()> {
intr::install();
let args: Vec<String> = std::env::args().skip(1).collect();
match args.first().map(String::as_str) {
Some("log") => return cli::log(&args[1..]),
Some("search") => return cli::search(&args[1..]),
Some("hot") | Some("top") => return cli::hot(&args[1..]),
Some("probe") => return cli::probe(&args[1..]),
Some("diag") => return cli::diag(&args[1..]),
Some("render-test") => return cli::render_test(&args[1..]),
Some("status") => return cli::status(),
Some("login") => {
intr::login_phase();
let opened = cli::login(args.get(1).map(String::as_str) == Some("kick"))?;
intr::plain_phase();
if !opened {
return Ok(());
}
return chat(&[]);
}
Some("logout") => return cli::logout(),
Some("--version") | Some("-V") => {
println!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
return Ok(());
}
Some("--help") | Some("-h") | Some("help") => {
print!("{USAGE}");
return Ok(());
}
_ => {}
}
chat(&args)
}
fn chat(args: &[String]) -> io::Result<()> {
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
eprintln!(
"heatsync-tui: not a terminal — the chat UI needs an interactive tty.\n\
Run it directly, or use a subcommand that writes to stdout:\n\
\n\
{USAGE}"
);
std::process::exit(1);
}
let mock_mode = args.iter().any(|a| a == "--mock");
let chan_args: Vec<&String> = args.iter().filter(|a| !a.starts_with('-')).collect();
let cfg = config::load();
let mut app = if mock_mode {
let (hs_tx, hs_rx) = std::sync::mpsc::channel::<hsauth::Note>();
let (emote_tx, emote_rx) = std::sync::mpsc::channel();
let (hist_tx, hist_rx) = std::sync::mpsc::channel();
let (pick_tx, pick_rx) = std::sync::mpsc::channel();
App {
channels: mock::channels(),
emotes: (0..mock::channels().len())
.map(|_| EmoteSet::new())
.collect(),
globals: EmoteSet::new(),
focus: 0,
paused: false,
feed: Feed::Mock(mock::Driver::new()),
store: None,
fb: None,
mode: InputMode::Normal,
input: String::new(),
line: edit::Line::default(),
status: None,
out: None,
twitch_tx: None,
twitch_notes: None,
kick_tx: None,
hs: None,
hs_tx,
hs_notes: hs_rx,
tab_pos: cfg.tab_pos,
manage_cursor: 0,
completion: None,
me: Vec::new(),
names_rx: std::sync::mpsc::channel().1,
names_tx: std::sync::mpsc::channel().0,
stamps: cfg.timestamps,
emote_tx,
emote_rx,
hist_tx,
hist_rx,
picker: None,
pick_cache: picker::Cache(None),
streams: StreamEvents::new(),
seen: Default::default(),
rooms: RoomModes::new(),
pick_tx,
pick_rx,
pick_inflight: false,
paints: paints::Paints::new(),
pin_tx: std::sync::mpsc::channel().0,
pin_rx: std::sync::mpsc::channel().1,
cursor: 0,
reply: None,
view_h: std::cell::Cell::new(0),
chat_body: Default::default(),
special: special::State::new(false),
}
} else {
let feed_open = cfg.special.contains(&TabKind::Feed);
build_live(
&chan_args,
cfg.tab_pos,
cfg.timestamps,
cfg.channels,
feed_open,
)
};
special::restore(&mut app, &cfg.special);
let had_fb = app.fb.is_some();
drop(ratatui::init());
let mut terminal = Terminal::new(held::HeldBackend::new())?;
intr::tui_phase();
let res = run(&mut terminal, app);
ratatui::restore();
intr::plain_phase();
if had_fb {
use std::io::Write;
print!("\x1b[2J\x1b[H");
let _ = io::stdout().flush();
}
res
}
fn build_live(
chan_args: &[&String],
tab_pos: TabPos,
stamps: bool,
saved: Vec<Vec<Sub>>,
feed_open: bool,
) -> App {
let tabs: Vec<Vec<Sub>> = if !chan_args.is_empty() {
chan_args.iter().map(|a| parse_tab(a)).collect()
} else {
saved
};
let tabs: Vec<Vec<Sub>> = tabs.into_iter().filter(|t| !t.is_empty()).collect();
eprintln!(
"heatsync: fetching emotes + connecting to {} channels…",
tabs.len()
);
let store = EmoteStore::new();
let fb = if store.is_none() {
let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
FbEmotes::open(cols, rows)
} else {
None
};
let tier = match (&store, &fb) {
(Some(s), _) => s.tier_label(),
(None, Some(_)) => "framebuffer",
_ => "text (no terminal graphics)",
};
eprintln!("heatsync: emotes = {tier}");
if store.is_none() && fb.is_none() {
eprintln!(
" → for image emotes, run in a graphics terminal: foot (linux), \
windows terminal (win), wezterm (any) — else names show as text"
);
}
let (emote_tx, emote_rx) = std::sync::mpsc::channel();
let (hist_tx, hist_rx) = std::sync::mpsc::channel();
let (pin_tx, pin_rx) = std::sync::mpsc::channel();
let mut channels = Vec::new();
let mut emotes = Vec::new();
for tab in &tabs {
let (platform, name) = &tab[0];
let mut ch = Channel::new(name, *platform, 200);
ch.extra = tab[1..].to_vec();
channels.push(ch);
emotes.push(EmoteSet::new());
for (p, n) in tab {
if *p != Platform::Youtube {
spawn_emote_fetch(&emote_tx, *p, n.clone());
spawn_history(&hist_tx, *p, n.clone());
spawn_pin(&pin_tx, *p, n.clone());
}
}
}
let hs = hsauth::load_client();
let env_token = std::env::var("HEATSYNC_TOKEN")
.ok()
.filter(|t| !t.is_empty());
let ws_cli = env_token.is_none() && hs.as_ref().and_then(|c| c.socket_token()).is_some();
let token = env_token.or_else(|| hs.as_ref().and_then(|c| c.socket_token()));
let subs: Vec<Sub> = tabs.into_iter().flatten().collect();
let (rx, out) = net::spawn(subs, token, feed_open);
let auth = config::load_auth();
let me: Vec<String> = auth.twitch_user.iter().cloned().collect();
let kick_tx = auth.kick_token.clone().map(kick::spawn);
let (twitch_tx, twitch_notes) = match (auth.twitch_user, auth.twitch_oauth) {
(Some(u), Some(o)) => {
let (tx, notes) = twitch::spawn(u, o);
(Some(tx), Some(notes))
}
_ => (None, None),
};
spawn_global_emotes(&emote_tx);
let (hs_tx, hs_rx) = std::sync::mpsc::channel::<hsauth::Note>();
let (pick_tx, pick_rx) = std::sync::mpsc::channel();
let (names_tx, names_rx) = std::sync::mpsc::channel();
if let Some(c) = hs.clone() {
let tx = names_tx.clone();
std::thread::spawn(move || {
if let Ok(v) = c.me() {
let _ = tx.send(hsauth::my_names(&v));
}
});
}
App {
channels,
emotes,
globals: EmoteSet::new(),
focus: 0,
paused: false,
feed: Feed::Live {
rx,
start: Instant::now(),
connected: false,
},
store,
fb,
mode: InputMode::Normal,
input: String::new(),
line: edit::Line::default(),
status: None,
out: Some(out),
twitch_tx,
twitch_notes,
kick_tx,
hs,
hs_tx,
hs_notes: hs_rx,
tab_pos,
manage_cursor: 0,
completion: None,
me,
names_rx,
names_tx,
stamps,
emote_tx,
emote_rx,
hist_tx,
hist_rx,
picker: None,
pick_cache: picker::Cache(None),
streams: StreamEvents::new(),
seen: Default::default(),
rooms: RoomModes::new(),
pick_tx,
pick_rx,
pick_inflight: false,
paints: paints::Paints::new(),
pin_tx,
pin_rx,
cursor: 0,
reply: None,
view_h: std::cell::Cell::new(0),
chat_body: Default::default(),
special: special::State::new(ws_cli),
}
}
pub(crate) fn save_state(app: &App) {
if !matches!(app.feed, Feed::Live { .. }) {
return;
}
config::save(&persisted(app));
}
pub(crate) fn persisted(app: &App) -> config::Config {
let channels = app
.channels
.iter()
.filter(|c| !c.kind.is_special() && !c.temp)
.map(|c| c.subs().map(|(p, n)| (p, n.to_string())).collect())
.collect();
let special = app
.channels
.iter()
.map(|c| c.kind)
.filter(|k| k.is_special())
.collect();
config::Config {
tab_pos: app.tab_pos,
timestamps: app.stamps,
channels,
special,
}
}
pub(crate) fn parse_tab(tok: &str) -> Vec<Sub> {
let mut subs: Vec<Sub> = Vec::new();
for part in tok
.split(|c: char| c == '+' || c.is_whitespace())
.filter(|s| !s.trim().is_empty())
{
let s = parse_sub(part.trim());
if !s.1.is_empty()
&& !subs
.iter()
.any(|(p, n)| *p == s.0 && n.eq_ignore_ascii_case(&s.1))
{
subs.push(s);
}
}
subs
}
pub(crate) fn parse_sub(tok: &str) -> Sub {
if let Some(rest) = tok.strip_prefix("kick:") {
(Platform::Kick, rest.to_string())
} else if let Some(rest) = tok
.strip_prefix("yt:")
.or_else(|| tok.strip_prefix("youtube:"))
{
(Platform::Youtube, yt_video_id(rest))
} else if tok.contains("youtube.com") || tok.contains("youtu.be") {
(Platform::Youtube, yt_video_id(tok))
} else if let Some(rest) = tok.strip_prefix("twitch:") {
(Platform::Twitch, rest.to_string())
} else {
(Platform::Twitch, tok.to_string())
}
}
pub(crate) fn yt_video_id(tok: &str) -> String {
let tail = tok
.split_once("v=")
.map(|(_, t)| t)
.or_else(|| tok.split_once("youtu.be/").map(|(_, t)| t))
.or_else(|| tok.split_once("/live/").map(|(_, t)| t))
.unwrap_or(tok);
tail.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.collect()
}
fn run(terminal: &mut Terminal<held::HeldBackend>, mut app: App) -> io::Result<()> {
let tick = Duration::from_millis(200);
let mut last = Instant::now();
let sync = std::env::var_os("TMUX").is_none() || app.store.as_ref().is_none_or(|s| s.sync_ok());
let mut draw_cost = Duration::ZERO;
let mut scroller = scroll::Scroller::default();
let mut dlog = drawlog::DrawLog::from_env();
let mut gate = sched::ChatGate::default();
let mut woke = true;
loop {
drain_emotes(&mut app);
drain_history(&mut app);
drain_pins(&mut app);
drain_twitch(&mut app);
drain_hs(&mut app);
drain_picker(&mut app);
if let Some(ch) = app.channels.get_mut(app.focus) {
ch.mark_seen();
}
let now = Instant::now();
let mut dirty = woke;
if !app.paused && gate.due(now) && drain_feed(&mut app) {
gate.mark(now);
dirty = true;
}
pump_loads(&mut app);
if app.mode == InputMode::Insert && !app.channels.is_empty() {
let text = app.line.text();
let focus = app.focus;
let App {
store, fb, emotes, ..
} = &mut app;
request_stacks(store, fb, &emotes[focus], EmoteSet::none(), &text);
}
if dirty || emote_mode(&app).next_flip_in().is_some() {
if let Some(store) = &app.store {
store.begin_frame();
}
let drew_at = Instant::now();
if sync {
crossterm::execute!(io::stdout(), crossterm::terminal::BeginSynchronizedUpdate)?;
}
let drew = terminal.draw(|f| ui(f, &app));
let (plan, area) = match &drew {
Ok(done) => (
scroller.observe(done.buffer, scroll_body(&app, done.area)),
Some(done.area),
),
Err(_) => (None, None),
};
let drew = drew.map(|_| ());
let plan = plan.filter(|(s, _)| {
app.store
.as_ref()
.is_some_and(|st| st.shift_blits(s.top, s.h, s.k))
});
let wrote = match plan {
Some((s, cells)) => terminal.backend_mut().scroll_then_paint(s, &cells),
None => terminal.backend_mut().release(),
};
let blit = match (area, &app.store) {
(Some(area), Some(store)) => store.blit(area, &mut io::stdout()),
_ => Ok(()),
};
if sync {
crossterm::execute!(io::stdout(), crossterm::terminal::EndSynchronizedUpdate)?;
}
drew?;
wrote?;
blit?;
dlog.draw();
draw_cost = (draw_cost * 3 + drew_at.elapsed()) / 4;
if let Some(fb) = &app.fb {
fb.blit();
}
}
let tick_left = tick.saturating_sub(last.elapsed());
let wait = match emote_mode(&app).next_flip_in() {
Some(flip) => flip.max(draw_cost * 2).min(tick_left),
None => tick_left,
};
woke = false;
if intr::requested() {
return Ok(());
}
if event::poll(wait.min(sched::POLL))? {
woke = true;
if let Event::Key(k) = event::read()? {
gate.note_key(Instant::now());
dlog.key();
if matches!(k.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& handle_key(&mut app, k) == Flow::Quit
{
return Ok(());
}
}
}
if last.elapsed() >= tick {
if !app.paused {
tick_advance(&mut app);
}
last = Instant::now();
woke = true;
}
}
}
fn scroll_body(app: &App, area: ratatui::layout::Rect) -> Option<ratatui::layout::Rect> {
let body = app.chat_body.get();
let ok = matches!(emote_mode(app), layout::EmoteMode::Term(s) if s.scroll_ok());
(ok && body.width == area.width && body.height > 0).then_some(body)
}
#[cfg(test)]
mod spec_tests {
use super::*;
use heatsync_core::Platform;
#[test]
fn plus_and_whitespace_both_merge() {
let want = vec![
(Platform::Twitch, "nl_kripp".to_string()),
(Platform::Kick, "nl_kripp".to_string()),
(Platform::Youtube, "4tDC0sKhTnA".to_string()),
];
assert_eq!(parse_tab("nl_kripp+kick:nl_kripp+yt:4tDC0sKhTnA"), want);
assert_eq!(
parse_tab("twitch:nl_kripp kick:nl_kripp yt:4tDC0sKhTnA"),
want
);
assert_eq!(
parse_tab(" nl_kripp + kick:nl_kripp "),
want[..2].to_vec()
);
}
}