#include "input.h" #include "chat_room.h" #include "client.h" #include "commands.h" #include "config_defaults.h" #include "common.h" #include "exec.h" #include "history_view.h" #include "i18n.h" #include "message.h" #include "ratelimit.h" #include "system_message.h" #include "tui.h" #include "utf8.h" #include #include #include #include /* strncasecmp */ #include #include #include #include static int g_idle_timeout = TNT_DEFAULT_IDLE_TIMEOUT; static ui_lang_t g_default_ui_lang = UI_LANG_EN; void input_init(void) { g_idle_timeout = tnt_config_env_int(&TNT_CONFIG_IDLE_TIMEOUT); g_default_ui_lang = i18n_default_ui_lang(); } static int read_username(client_t *client) { char username[MAX_USERNAME_LEN] = {0}; int pos = 0; char buf[4]; const char *prompt = i18n_text(client->ui_lang, I18N_USERNAME_PROMPT); tui_render_welcome(client); client_printf(client, "%s", prompt); while (1) { int n = ssh_channel_read_timeout(client->channel, buf, 1, 0, 60000); /* 60 sec timeout */ if (n == SSH_AGAIN) { /* Timeout */ if (!ssh_channel_is_open(client->channel)) { return -1; } continue; } if (n <= 0) return -1; unsigned char b = buf[0]; if (b == '\r' || b == '\n') { break; } else if (b == 3 || b == 4) { /* Ctrl+C / Ctrl+D */ return -1; } else if (b == 21) { /* Ctrl+U: clear line */ username[0] = '\0'; pos = 0; client_printf(client, "\r\033[K%s", prompt); } else if (b == 23) { /* Ctrl+W: delete word */ if (username[0] != '\0') { utf8_remove_last_word(username); pos = (int)strlen(username); client_printf(client, "\r\033[K%s%s", prompt, username); } } else if (b == 127 || b == 8) { /* Backspace */ if (pos > 0) { /* Compute width of the last character before removing it */ int old_pos = pos; int ci = pos - 1; while (ci > 0 && (username[ci] & 0xC0) == 0x80) ci--; int bytes_read; uint32_t cp = utf8_decode(username + ci, &bytes_read); int w = utf8_char_width(cp); utf8_remove_last_char(username); pos = strlen(username); (void)old_pos; for (int j = 0; j < w; j++) client_printf(client, "\b \b"); } } else if (b < 32) { /* Ignore control characters */ } else if (b < 128) { /* ASCII */ if (pos < MAX_USERNAME_LEN - 1) { username[pos++] = b; username[pos] = '\0'; client_send(client, (char *)&b, 1); } } else { /* UTF-8 multi-byte */ int len = utf8_byte_length(b); if (len <= 0 || len > 4) { /* Invalid UTF-8 start byte */ continue; } buf[0] = b; if (len > 1) { int read_bytes = ssh_channel_read_timeout(client->channel, &buf[1], len - 1, 0, 5000); if (read_bytes != len - 1) { /* Incomplete or timed-out UTF-8 continuation */ continue; } } /* Validate the complete UTF-8 sequence */ if (!utf8_is_valid_sequence(buf, len)) { continue; } if (pos + len < MAX_USERNAME_LEN - 1) { memcpy(username + pos, buf, len); pos += len; username[pos] = '\0'; client_send(client, buf, len); } } } client_printf(client, "\r\n"); if (username[0] == '\0') { strncpy(client->username, "anonymous", MAX_USERNAME_LEN - 1); client->username[MAX_USERNAME_LEN - 1] = '\0'; } else { strncpy(client->username, username, MAX_USERNAME_LEN - 1); client->username[MAX_USERNAME_LEN - 1] = '\0'; /* Validate username for security */ if (!is_valid_username(client->username)) { client_printf(client, "%s", i18n_text(client->ui_lang, I18N_INVALID_USERNAME)); strcpy(client->username, "anonymous"); } else { /* Truncate to 20 characters */ if (utf8_strlen(client->username) > 20) { utf8_truncate(client->username, 20); } } } return 0; } void notify_mentions(const char *content, const client_t *sender) { pthread_rwlock_rdlock(&g_room->lock); int count = g_room->client_count; client_t **targets = NULL; int target_count = 0; if (count > 0) { targets = calloc((size_t)count, sizeof(*targets)); if (!targets) { pthread_rwlock_unlock(&g_room->lock); return; } } for (int i = 0; i < count; i++) { client_t *c = g_room->clients[i]; if (c == sender) continue; char mention[MAX_USERNAME_LEN + 2]; snprintf(mention, sizeof(mention), "@%s", c->username); if (strstr(content, mention) != NULL) { client_addref(c); targets[target_count++] = c; } } pthread_rwlock_unlock(&g_room->lock); for (int i = 0; i < target_count; i++) { targets[i]->unread_mentions++; client_queue_bell(targets[i]); client_release(targets[i]); } free(targets); } static int read_channel_exact(client_t *client, char *buf, size_t len, int timeout_ms) { size_t got = 0; while (got < len) { int n = ssh_channel_read_timeout(client->channel, buf + got, len - got, 0, timeout_ms); if (n == SSH_AGAIN || n <= 0) { break; } got += (size_t)n; } return (int)got; } static bool append_paste_byte(char *input, unsigned char b) { if (b == '\r' || b == '\n' || b == '\t') { b = ' '; } if (b < 32) { return true; } size_t cur = strlen(input); if (cur < MAX_MESSAGE_LEN - 1) { input[cur] = (char)b; input[cur + 1] = '\0'; return true; } return false; } static void normal_scroll_to_latest(client_t *client) { if (!client) return; history_view_scroll_to_latest(&client->scroll_pos, &client->follow_tail, room_get_message_count(g_room), history_view_height(client->height)); } static void normal_scroll_by(client_t *client, int delta) { if (!client) return; history_view_scroll_by(&client->scroll_pos, &client->follow_tail, room_get_message_count(g_room), history_view_height(client->height), delta); } static void dismiss_command_output(client_t *client) { bool was_motd; if (!client) return; was_motd = client->show_motd; client->command_output[0] = '\0'; client->command_output_scroll = 0; client->command_output_kind = TNT_COMMAND_OUTPUT_NONE; client->show_motd = false; if (was_motd) { client->mode = MODE_INSERT; client->follow_tail = true; client->unread_mentions = 0; normal_scroll_to_latest(client); } else { client->mode = MODE_NORMAL; } tui_render_screen(client); } typedef enum { PAGER_ACTION_NONE, PAGER_ACTION_SCROLL, PAGER_ACTION_CLOSE, PAGER_ACTION_REFRESH } pager_action_t; static int pager_page_height(client_t *client) { int page = client->height - 2; if (page < 1) page = 1; return page; } static void pager_scroll_by(int *scroll_pos, int delta) { *scroll_pos += delta; if (*scroll_pos < 0) { *scroll_pos = 0; } } static pager_action_t pager_apply_key(client_t *client, unsigned char key, int *scroll_pos, bool allow_refresh) { int page = pager_page_height(client); int half = page / 2; if (half < 1) half = 1; if (key == 'q') { return PAGER_ACTION_CLOSE; } else if (key == 'j') { pager_scroll_by(scroll_pos, 1); return PAGER_ACTION_SCROLL; } else if (key == 'k') { pager_scroll_by(scroll_pos, -1); return PAGER_ACTION_SCROLL; } else if (key == 4) { /* Ctrl+D: half page down */ pager_scroll_by(scroll_pos, half); return PAGER_ACTION_SCROLL; } else if (key == 21) { /* Ctrl+U: half page up */ pager_scroll_by(scroll_pos, -half); return PAGER_ACTION_SCROLL; } else if (key == 6 || key == ' ') { /* Ctrl+F / Space: page down */ pager_scroll_by(scroll_pos, page); return PAGER_ACTION_SCROLL; } else if (key == 2 || key == 'b') { /* Ctrl+B / b: page up */ pager_scroll_by(scroll_pos, -page); return PAGER_ACTION_SCROLL; } else if (key == 'g') { *scroll_pos = 0; return PAGER_ACTION_SCROLL; } else if (key == 'G') { *scroll_pos = 999; return PAGER_ACTION_SCROLL; } else if ((key == 'r' || key == 'R') && allow_refresh) { return PAGER_ACTION_REFRESH; } else if (key == 27) { char seq[3]; int n = ssh_channel_read_timeout(client->channel, seq, 1, 0, 50); if (n != 1) { return PAGER_ACTION_CLOSE; } if (seq[0] != '[') { return PAGER_ACTION_NONE; } n = ssh_channel_read_timeout(client->channel, &seq[1], 1, 0, 50); if (n != 1) { return PAGER_ACTION_NONE; } if (seq[1] == 'A') { /* Up arrow */ pager_scroll_by(scroll_pos, -1); return PAGER_ACTION_SCROLL; } else if (seq[1] == 'B') { /* Down arrow */ pager_scroll_by(scroll_pos, 1); return PAGER_ACTION_SCROLL; } else if (seq[1] == 'H') { /* Home */ *scroll_pos = 0; return PAGER_ACTION_SCROLL; } else if (seq[1] == 'F') { /* End */ *scroll_pos = 999; return PAGER_ACTION_SCROLL; } else if (seq[1] >= '1' && seq[1] <= '6') { n = ssh_channel_read_timeout(client->channel, &seq[2], 1, 0, 50); if (n == 1 && seq[2] == '~') { if (seq[1] == '5') { /* PageUp */ pager_scroll_by(scroll_pos, -page); return PAGER_ACTION_SCROLL; } else if (seq[1] == '6') { /* PageDown */ pager_scroll_by(scroll_pos, page); return PAGER_ACTION_SCROLL; } else if (seq[1] == '1') { /* Home */ *scroll_pos = 0; return PAGER_ACTION_SCROLL; } else if (seq[1] == '4') { /* End */ *scroll_pos = 999; return PAGER_ACTION_SCROLL; } } } } return PAGER_ACTION_NONE; } /* Handle a single key press. Returns true if the key was fully consumed * (no further character buffering needed). */ static bool handle_key(client_t *client, unsigned char key, char *input) { /* Handle Ctrl+C (Exit or switch to NORMAL) */ if (key == 3) { client_mode_t previous_mode = client->mode; if (client->show_help) { client->show_help = false; tui_render_screen(client); return true; } if (client->command_output[0] != '\0') { dismiss_command_output(client); return true; } if (previous_mode != MODE_NORMAL) { client->mode = MODE_NORMAL; client->command_input[0] = '\0'; client->show_help = false; if (previous_mode == MODE_INSERT) { normal_scroll_to_latest(client); } tui_render_screen(client); } else { /* In NORMAL mode, Ctrl+C exits */ client->connected = false; } return true; } /* Handle help screen */ if (client->show_help) { pager_action_t action; if (key == 'l' || key == 'L') { client->ui_lang = i18n_next_ui_lang(client->ui_lang); client->help_scroll_pos = 0; tui_render_help(client); return true; } action = pager_apply_key(client, key, &client->help_scroll_pos, false); if (action == PAGER_ACTION_CLOSE) { client->show_help = false; tui_render_screen(client); } else if (action == PAGER_ACTION_SCROLL) { tui_render_help(client); } return true; /* Key consumed */ } /* Handle command output / MOTD display. MOTD remains a simple notice; * command output behaves like a small pager so long results can be read. */ if (client->command_output[0] != '\0') { pager_action_t action; if (client->show_motd) { dismiss_command_output(client); return true; } action = pager_apply_key(client, key, &client->command_output_scroll, true); if (action == PAGER_ACTION_CLOSE) { dismiss_command_output(client); } else if (action == PAGER_ACTION_SCROLL) { tui_render_command_output(client); } else if (action == PAGER_ACTION_REFRESH) { if (commands_refresh_active_output(client)) { tui_render_command_output(client); } } return true; /* Key consumed */ } /* Mode-specific handling */ switch (client->mode) { case MODE_INSERT: if (key == 27) { /* ESC — may also be the start of an arrow seq */ char seq[2]; int n = ssh_channel_read_timeout(client->channel, seq, 1, 0, 50); if (n == 1 && seq[0] == '[') { n = ssh_channel_read_timeout(client->channel, &seq[1], 1, 0, 50); if (n == 1) { if (seq[1] == 'A') { /* Up — walk back through sent history */ if (client->insert_history_count > 0 && client->insert_history_pos > 0) { client->insert_history_pos--; strncpy(input, client->insert_history[client->insert_history_pos], MAX_MESSAGE_LEN - 1); input[MAX_MESSAGE_LEN - 1] = '\0'; tui_render_input(client, input); } return true; } else if (seq[1] == 'B') { /* Down — walk forward */ if (client->insert_history_pos < client->insert_history_count - 1) { client->insert_history_pos++; strncpy(input, client->insert_history[client->insert_history_pos], MAX_MESSAGE_LEN - 1); input[MAX_MESSAGE_LEN - 1] = '\0'; } else { client->insert_history_pos = client->insert_history_count; input[0] = '\0'; } tui_render_input(client, input); return true; } else if (seq[1] == '2') { /* Could be bracketed-paste start "ESC[200~". * Read the next 3 bytes and confirm. */ char rest[3]; int m = read_channel_exact(client, rest, sizeof(rest), 500); if (m == 3 && rest[0] == '0' && rest[1] == '0' && rest[2] == '~') { /* Drain bytes into `input` until we see * the end marker ESC[201~. Newlines become * spaces so a multi-line paste stays a * single message instead of N sends. */ bool overflow = false; while (1) { char b; int k = ssh_channel_read_timeout( client->channel, &b, 1, 0, 5000); if (k != 1) break; if (b == '\033') { char tail[5]; int t = read_channel_exact( client, tail, sizeof(tail), 500); if (t == 5 && tail[0] == '[' && tail[1] == '2' && tail[2] == '0' && tail[3] == '1' && tail[4] == '~') { break; /* end of paste */ } /* Stray ESC inside paste: drop the ESC * but keep printable bytes that * followed it. */ for (int i = 0; i < t; i++) { if (!append_paste_byte( input, (unsigned char)tail[i])) { overflow = true; } } continue; } if (!append_paste_byte(input, (unsigned char)b)) { overflow = true; } } tui_render_input(client, input); if (overflow) { client_send(client, "\a", 1); } } return true; } } } /* Plain ESC — fall through to NORMAL mode */ client->mode = MODE_NORMAL; normal_scroll_to_latest(client); tui_render_screen(client); return true; } else if (key == '\r' || key == '\n') { /* Enter */ if (input[0] != '\0') { /* Record into the per-client INSERT history ring */ int max_hist = (int)(sizeof(client->insert_history) / sizeof(client->insert_history[0])); if (client->insert_history_count >= max_hist) { memmove(&client->insert_history[0], &client->insert_history[1], (max_hist - 1) * sizeof(client->insert_history[0])); client->insert_history_count = max_hist - 1; } snprintf(client->insert_history[client->insert_history_count], sizeof(client->insert_history[0]), "%s", input); client->insert_history_count++; client->insert_history_pos = client->insert_history_count; message_t msg = { .timestamp = time(NULL), }; if (strncmp(input, "/me ", 4) == 0 && input[4] != '\0') { msg.username[0] = '*'; msg.username[1] = '\0'; int n = snprintf(msg.content, sizeof(msg.content), "%s %s", client->username, input + 4); if (n >= (int)sizeof(msg.content)) { msg.content[sizeof(msg.content) - 1] = '\0'; } } else { snprintf(msg.username, sizeof(msg.username), "%s", client->username); snprintf(msg.content, sizeof(msg.content), "%s", input); } room_broadcast(g_room, &msg); notify_mentions(msg.content, client); message_save(&msg); input[0] = '\0'; } tui_render_screen(client); return true; /* Key consumed */ } else if (key == 127 || key == 8) { /* Backspace */ if (input[0] != '\0') { utf8_remove_last_char(input); tui_render_input(client, input); } return true; /* Key consumed */ } else if (key == 23) { /* Ctrl+W (Delete Word) */ if (input[0] != '\0') { utf8_remove_last_word(input); tui_render_input(client, input); } return true; } else if (key == 21) { /* Ctrl+U (Delete Line) */ if (input[0] != '\0') { input[0] = '\0'; tui_render_input(client, input); } return true; } else if (key == 9) { /* Tab: complete @mention */ /* Walk back from end to find the start of the trailing * "@…" token (an '@' not preceded by an alphanumeric). * If found, scan g_room for the first case-insensitive * username prefix-match (cycling past self) and replace * the token. */ size_t in_len = strlen(input); ssize_t at_idx = -1; for (ssize_t i = (ssize_t)in_len - 1; i >= 0; i--) { unsigned char c = (unsigned char)input[i]; if (c == '@') { if (i == 0 || input[i - 1] == ' ') at_idx = i; break; } if (c == ' ') break; } if (at_idx >= 0) { const char *prefix = input + at_idx + 1; size_t plen = strlen(prefix); char match[MAX_USERNAME_LEN] = ""; pthread_rwlock_rdlock(&g_room->lock); for (int i = 0; i < g_room->client_count; i++) { const char *uname = g_room->clients[i]->username; if (plen == 0 ? strcmp(uname, client->username) != 0 : strncasecmp(uname, prefix, plen) == 0) { snprintf(match, sizeof(match), "%s", uname); break; } } pthread_rwlock_unlock(&g_room->lock); if (match[0] != '\0') { /* Replace "@" with "@ " (trailing * space so the next word starts cleanly). */ size_t avail = MAX_MESSAGE_LEN - 1 - (size_t)at_idx - 1; size_t mlen = strlen(match); if (mlen + 1 <= avail) { input[at_idx + 1] = '\0'; strncat(input, match, avail); strncat(input, " ", 1); tui_render_input(client, input); } } } return true; } break; case MODE_NORMAL: { int nm_msg_height = history_view_height(client->height); if (key == 'i') { client->mode = MODE_INSERT; client->follow_tail = true; client->unread_mentions = 0; tui_render_screen(client); return true; } else if (key == ':') { client->mode = MODE_COMMAND; client->command_input[0] = '\0'; tui_render_screen(client); return true; } else if (key == '/') { client->mode = MODE_COMMAND; snprintf(client->command_input, sizeof(client->command_input), "search "); tui_render_screen(client); return true; } else if (key == 'j') { normal_scroll_by(client, 1); tui_render_screen(client); return true; } else if (key == 'k' && client->scroll_pos > 0) { normal_scroll_by(client, -1); tui_render_screen(client); return true; } else if (key == 4) { /* Ctrl+D: half page down */ int half = nm_msg_height / 2; if (half < 1) half = 1; normal_scroll_by(client, half); tui_render_screen(client); return true; } else if (key == 21) { /* Ctrl+U: half page up */ int half = nm_msg_height / 2; if (half < 1) half = 1; normal_scroll_by(client, -half); tui_render_screen(client); return true; } else if (key == 6) { /* Ctrl+F: full page down */ normal_scroll_by(client, nm_msg_height); tui_render_screen(client); return true; } else if (key == 2) { /* Ctrl+B: full page up */ normal_scroll_by(client, -nm_msg_height); tui_render_screen(client); return true; } else if (key == 'g') { history_view_scroll_to_oldest(&client->scroll_pos, &client->follow_tail); tui_render_screen(client); return true; } else if (key == 'G') { normal_scroll_to_latest(client); client->unread_mentions = 0; tui_render_screen(client); return true; } else if (key == 27) { char seq[4]; int n = ssh_channel_read_timeout(client->channel, seq, 1, 0, 50); if (n == 1 && seq[0] == '[') { n = ssh_channel_read_timeout(client->channel, &seq[1], 1, 0, 50); if (n == 1) { if (seq[1] == 'A') { /* Up arrow */ normal_scroll_by(client, -1); } else if (seq[1] == 'B') { /* Down arrow */ normal_scroll_by(client, 1); } else if (seq[1] == 'H') { /* Home */ history_view_scroll_to_oldest(&client->scroll_pos, &client->follow_tail); } else if (seq[1] == 'F') { /* End */ normal_scroll_to_latest(client); } else if (seq[1] >= '1' && seq[1] <= '6') { n = ssh_channel_read_timeout(client->channel, &seq[2], 1, 0, 50); if (n == 1 && seq[2] == '~') { if (seq[1] == '5') { /* PageUp */ normal_scroll_by(client, -nm_msg_height); } else if (seq[1] == '6') { /* PageDown */ normal_scroll_by(client, nm_msg_height); } else if (seq[1] == '1') { /* Home */ history_view_scroll_to_oldest( &client->scroll_pos, &client->follow_tail); } else if (seq[1] == '4') { /* End */ normal_scroll_to_latest(client); } } } tui_render_screen(client); } } return true; } else if (key == '?') { client->show_help = true; client->help_scroll_pos = 0; tui_render_help(client); return true; } break; } case MODE_COMMAND: if (key == 27) { /* ESC - check for arrow key sequences */ char seq[2]; int n = ssh_channel_read_timeout(client->channel, seq, 1, 0, 50); if (n == 1 && seq[0] == '[') { n = ssh_channel_read_timeout(client->channel, &seq[1], 1, 0, 50); if (n == 1) { if (seq[1] == 'A') { /* Up arrow */ if (client->command_history_count > 0 && client->command_history_pos > 0) { client->command_history_pos--; strncpy(client->command_input, client->command_history[client->command_history_pos], sizeof(client->command_input) - 1); client->command_input[sizeof(client->command_input) - 1] = '\0'; tui_render_screen(client); } return true; } else if (seq[1] == 'B') { /* Down arrow */ if (client->command_history_pos < client->command_history_count - 1) { client->command_history_pos++; strncpy(client->command_input, client->command_history[client->command_history_pos], sizeof(client->command_input) - 1); client->command_input[sizeof(client->command_input) - 1] = '\0'; } else { client->command_history_pos = client->command_history_count; client->command_input[0] = '\0'; } tui_render_screen(client); return true; } } } client->mode = MODE_NORMAL; client->command_input[0] = '\0'; tui_render_screen(client); return true; } else if (key == '\r' || key == '\n') { commands_dispatch(client); return true; /* Key consumed */ } else if (key == 127 || key == 8) { /* Backspace */ if (client->command_input[0] != '\0') { utf8_remove_last_char(client->command_input); tui_render_screen(client); } return true; /* Key consumed */ } else if (key == 23) { /* Ctrl+W (Delete Word) */ if (client->command_input[0] != '\0') { utf8_remove_last_word(client->command_input); tui_render_screen(client); } return true; } else if (key == 21) { /* Ctrl+U (Delete Line) */ if (client->command_input[0] != '\0') { client->command_input[0] = '\0'; tui_render_screen(client); } return true; } break; default: break; } return false; /* Key not consumed */ } void input_run_session(client_t *client) { char input[MAX_MESSAGE_LEN] = {0}; char buf[4]; bool joined_room = false; bool bracketed_paste_enabled = false; uint64_t seen_update_seq; time_t last_keepalive = time(NULL); /* Terminal size already set from PTY request */ client->mode = MODE_INSERT; client->follow_tail = true; client->ui_lang = g_default_ui_lang; client->connected = true; client->command_history_count = 0; client->command_history_pos = 0; client->command_output_scroll = 0; client->command_output_kind = TNT_COMMAND_OUTPUT_NONE; client->connect_time = time(NULL); client->last_active = time(NULL); /* Check for exec command */ if (client->exec_command[0] != '\0' || client->exec_command_too_long) { int exit_status = exec_dispatch(client); ssh_channel_request_send_exit_status(client->channel, exit_status); ssh_channel_send_eof(client->channel); ssh_blocking_flush(client->session, 1000); ssh_channel_close(client->channel); goto cleanup; } /* Read username */ if (read_username(client) < 0) { goto cleanup; } /* Add to room */ if (room_add_client(g_room, client) < 0) { client_printf(client, "%s", i18n_text(client->ui_lang, I18N_ROOM_FULL)); goto cleanup; } joined_room = true; /* Enable xterm bracketed-paste mode only for interactive chat, so * multi-line pastes arrive framed by ESC[200~...ESC[201~ instead of * as a stream of Enters. Terminals that don't recognise it ignore it. */ client_send(client, "\033[?2004h", 8); bracketed_paste_enabled = true; /* Broadcast join message */ message_t join_msg; system_message_make_join(&join_msg, client->username, client->ui_lang); room_broadcast(g_room, &join_msg); message_save(&join_msg); /* Show MOTD if motd.txt exists in state directory */ { char motd_path[PATH_MAX]; if (tnt_state_path(motd_path, sizeof(motd_path), "motd.txt") == 0) { FILE *motd_fp = fopen(motd_path, "r"); if (motd_fp) { char motd_buf[sizeof(client->command_output) - 64]; size_t motd_len = fread(motd_buf, 1, sizeof(motd_buf) - 1, motd_fp); fclose(motd_fp); if (motd_len > 0) { motd_buf[motd_len] = '\0'; snprintf(client->command_output, sizeof(client->command_output), "%s", motd_buf); client->command_output_scroll = 0; client->command_output_kind = TNT_COMMAND_OUTPUT_NONE; client->show_motd = true; tui_render_motd(client); seen_update_seq = room_get_update_seq(g_room); goto main_loop; } } } } /* Render initial screen */ tui_render_screen(client); seen_update_seq = room_get_update_seq(g_room); main_loop: /* Main input loop */ while (client->connected && ssh_channel_is_open(client->channel)) { if (client_flush_output(client) != 0) { break; } int ready = ssh_channel_poll_timeout(client->channel, 1000, 0); if (ready == SSH_ERROR) { break; } if (ready == 0) { bool room_updated = false; uint64_t current_update_seq = room_get_update_seq(g_room); if (!ssh_channel_is_open(client->channel)) { break; } if (client_flush_output(client) != 0) { break; } if (client_flush_pending_bells(client) != 0) { break; } if (current_update_seq != seen_update_seq) { seen_update_seq = current_update_seq; room_updated = true; } if (client->command_output_kind == TNT_COMMAND_OUTPUT_INBOX && client->command_output[0] != '\0' && client->unread_whispers > 0) { commands_refresh_active_output(client); client->redraw_pending = true; } if (client->redraw_pending || (room_updated && !client->show_help && client->command_output[0] == '\0')) { client->redraw_pending = false; if (client->show_help) { tui_render_help(client); } else if (client->show_motd) { tui_render_motd(client); } else if (client->command_output[0] != '\0') { tui_render_command_output(client); } else { if (room_updated && client->mode == MODE_NORMAL && client->follow_tail) { normal_scroll_to_latest(client); } tui_render_screen(client); if (client->mode == MODE_INSERT && input[0] != '\0') { tui_render_input(client, input); } } } else if (time(NULL) - last_keepalive >= 15) { if (ssh_send_keepalive(client->session) != SSH_OK) { break; } last_keepalive = time(NULL); } if (g_idle_timeout > 0 && joined_room && time(NULL) - client->last_active >= g_idle_timeout) { client_printf(client, i18n_text(client->ui_lang, I18N_IDLE_TIMEOUT_FORMAT), g_idle_timeout / 60); break; } continue; } int n = ssh_channel_read(client->channel, buf, 1, 0); if (n <= 0) { /* EOF or error */ break; } last_keepalive = time(NULL); client->last_active = last_keepalive; unsigned char b = buf[0]; /* Handle special keys - returns true if key was consumed */ bool key_consumed = handle_key(client, b, input); /* Only add character to input if not consumed by handle_key */ if (!key_consumed) { /* Add character to input (INSERT mode only) */ if (client->mode == MODE_INSERT && !client->show_help && client->command_output[0] == '\0') { if (b >= 32 && b < 127) { /* ASCII printable */ int len = strlen(input); if (len < MAX_MESSAGE_LEN - 1) { input[len] = b; input[len + 1] = '\0'; tui_render_input(client, input); } else { client_send(client, "\a", 1); } } else if (b >= 128) { /* UTF-8 multi-byte */ int char_len = utf8_byte_length(b); if (char_len <= 0 || char_len > 4) { /* Invalid UTF-8 start byte */ continue; } buf[0] = b; if (char_len > 1) { int read_bytes = ssh_channel_read_timeout(client->channel, &buf[1], char_len - 1, 0, 5000); if (read_bytes != char_len - 1) { /* Incomplete or timed-out UTF-8 continuation */ continue; } } /* Validate the complete UTF-8 sequence */ if (!utf8_is_valid_sequence(buf, char_len)) { /* Invalid UTF-8 sequence */ continue; } int len = strlen(input); if (len + char_len <= MAX_MESSAGE_LEN - 1) { memcpy(input + len, buf, char_len); input[len + char_len] = '\0'; tui_render_input(client, input); } else { client_send(client, "\a", 1); } } } else if (client->mode == MODE_COMMAND && !client->show_help && client->command_output[0] == '\0') { if (b >= 32 && b < 127) { /* ASCII printable */ size_t len = strlen(client->command_input); if (len < sizeof(client->command_input) - 1) { client->command_input[len] = b; client->command_input[len + 1] = '\0'; tui_render_screen(client); } else { client_send(client, "\a", 1); } } else if (b >= 128) { /* UTF-8 multi-byte */ int char_len = utf8_byte_length(b); if (char_len <= 0 || char_len > 4) continue; buf[0] = b; if (char_len > 1) { int read_bytes = ssh_channel_read_timeout( client->channel, &buf[1], char_len - 1, 0, 5000); if (read_bytes != char_len - 1) continue; } if (!utf8_is_valid_sequence(buf, char_len)) continue; size_t len = strlen(client->command_input); if (len + (size_t)char_len <= sizeof(client->command_input) - 1) { memcpy(client->command_input + len, buf, char_len); client->command_input[len + char_len] = '\0'; tui_render_screen(client); } else { client_send(client, "\a", 1); } } } } } cleanup: if (bracketed_paste_enabled && client->channel && ssh_channel_is_open(client->channel)) { client_send(client, "\033[?2004l", 8); } /* Broadcast leave message */ if (joined_room) { message_t leave_msg; system_message_make_leave(&leave_msg, client->username, client->ui_lang); client->connected = false; room_remove_client(g_room, client); room_broadcast(g_room, &leave_msg); message_save(&leave_msg); } ratelimit_release_ip(client->client_ip); client_release_session(client); /* Decrement connection count */ ratelimit_decrement_total(); }