From fe75236fadf6a7e464a007acabbcf5fef0a16f6b Mon Sep 17 00:00:00 2001 From: Anders Holck Date: Thu, 30 Apr 2026 08:05:35 +0200 Subject: [PATCH] Add /me, /slap, /q, /topic, tab completion, bold events - /me sends CTCP ACTION - /slap slaps with a large trout - /q sets query target, /q clears it - /topic to view/set channel topic - Tab completion for nicks (cycling, : suffix at line start) - Nick list tracking via NAMES/JOIN/PART/QUIT/NICK - Bold formatting for joins, parts, quits, mode changes - Incoming CTCP ACTION displayed as * nick action - Updated README with all commands and features --- README.md | 13 ++- main.c | 241 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 239 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 20dae70..92a244f 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,10 @@ A lightweight terminal IRC client written in C with automatic charset conversion - **Window levels** — isolated windows with independent 500-line scrollback: - Window 1: Status and private messages - Windows 2–9: Channels -- **Status bar** — shows current window, channel, nick prefix (@/+), and channel modes +- **Status bar** — shows current window, channel, nick prefix (@/+), channel modes, and activity indicator - **UTF-8 terminal support** — full multi-byte input editing +- **Tab completion** — nick completion with cycling (`: ` suffix at line start, space mid-line) +- **Query mode** — `/q nick` to set a default PM target - **CTCP VERSION** reply with OS info - **SIGWINCH** handling (terminal resize) - **Ident** — works with system identd on port 113 @@ -37,6 +39,7 @@ Port defaults to 6667. | Key | Action | |-----|--------| | ESC+1–9 | Switch window | +| Tab | Nick completion (cycle with repeated Tab) | | Ctrl-A | Beginning of line | | Ctrl-E | End of line | | Ctrl-U | Kill to beginning (yank buffer) | @@ -54,15 +57,21 @@ Port defaults to 6667. | `/join #channel` | Join channel (assigned to current window) | | `/part [#channel]` | Part channel (defaults to current) | | `/msg ` | Send private message | +| `/q ` | Set query target (type text to send to them) | +| `/q` | Clear query target | +| `/me ` | Send action to channel/query | +| `/slap ` | Slap with a large trout | | `/nick ` | Change nickname | | `/mode ` | Set mode | +| `/topic [#channel]` | View topic | +| `/topic #channel ` | Set topic | | `/names [#channel]` | List users in channel | | `/whois ` | WHOIS query | | `/wii ` | Extended WHOIS (queries remote server) | | `/quit [reason]` | Quit (default: "See you later") | | `/raw ` | Send raw IRC command | -Typing text without a `/` prefix sends to the channel on the current window. +Typing text without a `/` prefix sends to the channel on the current window (or query target on window 1). ## Window Workflow diff --git a/main.c b/main.c index 150afc6..d749d5a 100644 --- a/main.c +++ b/main.c @@ -61,13 +61,20 @@ static struct { int head; /* next write position (circular) */ } win_buf[WL_MAX]; -/* Per-channel window state */ +/* Per-channel nick list */ +#define MAX_NICKS 256 +#define NICK_LEN 32 static struct { char name[128]; /* channel name */ char modes[64]; /* channel modes (e.g. "+nt") */ char my_prefix; /* '@', '+', or '\0' */ + char nicks[MAX_NICKS][NICK_LEN]; + int nick_count; } win_chans[MAX_CHAN_WINS]; +/* Query target (for /q) */ +static char query_target[64] = ""; + /* Get the active channel for current window, or "" */ static const char *current_channel(void) { @@ -306,6 +313,48 @@ static int irc_connect(const char *host, const char *port) } /* Find window index for a channel, or -1 */ +/* Nick list management */ +static void nicklist_add(int idx, const char *n) +{ + if (idx < 0 || idx >= MAX_CHAN_WINS) return; + /* Skip prefix chars */ + if (*n == '@' || *n == '+' || *n == '%') n++; + if (!*n) return; + /* Check if already present */ + for (int i = 0; i < win_chans[idx].nick_count; i++) + if (strcasecmp(win_chans[idx].nicks[i], n) == 0) return; + if (win_chans[idx].nick_count < MAX_NICKS) + snprintf(win_chans[idx].nicks[win_chans[idx].nick_count++], + NICK_LEN, "%s", n); +} + +static void nicklist_remove(int idx, const char *n) +{ + if (idx < 0 || idx >= MAX_CHAN_WINS) return; + for (int i = 0; i < win_chans[idx].nick_count; i++) { + if (strcasecmp(win_chans[idx].nicks[i], n) == 0) { + win_chans[idx].nick_count--; + if (i < win_chans[idx].nick_count) + memcpy(win_chans[idx].nicks[i], + win_chans[idx].nicks[win_chans[idx].nick_count], + NICK_LEN); + return; + } + } +} + +static void nicklist_rename(const char *old, const char *new_nick) +{ + for (int i = 0; i < MAX_CHAN_WINS; i++) { + for (int j = 0; j < win_chans[i].nick_count; j++) { + if (strcasecmp(win_chans[i].nicks[j], old) == 0) { + snprintf(win_chans[i].nicks[j], NICK_LEN, "%s", new_nick); + break; + } + } + } +} + static int chan_win_idx(const char *chan) { int i; @@ -517,9 +566,22 @@ static void handle_line(char *line) " :: This space available for rent\x01", sender, ut.sysname, ut.release, ut.machine); + wprintf(WL_STATUS, "* CTCP VERSION from %s\n", sender); + } else if (strncmp(text, "\x01" "ACTION ", 8) == 0) { + /* /me action */ + char *action = text + 8; + char *end = strchr(action, '\x01'); + if (end) *end = '\0'; + if (strcasecmp(target, nick) == 0) { + wprintf(WL_MSG, "* %s %s\n", sender, action); + } else { + int lvl = chan_to_level(target); + wprintf(lvl, "* %s %s\n", sender, action); + } + } else { + wprintf(WL_STATUS, "* CTCP from %s: %s\n", + sender, text + 1); } - wprintf(WL_STATUS, "* CTCP from %s: %s\n", - sender, text + 1); } else if (strcasecmp(target, nick) == 0) { wprintf(WL_MSG, "<%s> %s\n", sender, text); } else { @@ -536,7 +598,8 @@ static void handle_line(char *line) char *chan = params; if (chan && chan[0] == ':') chan++; int lvl = chan ? chan_to_level(chan) : WL_STATUS; - wprintf(lvl, "* %s has joined %s\n", sender, chan ? chan : ""); + if (chan) nicklist_add(chan_win_idx(chan), sender); + wprintf(lvl, "\033[1m* %s has joined %s\033[0m\n", sender, chan ? chan : ""); draw_statusbar(); } else if (strcmp(cmd, "PART") == 0) { char *chan = params; @@ -550,7 +613,8 @@ static void handle_line(char *line) } } int lvl = chan ? chan_to_level(chan) : WL_STATUS; - wprintf(lvl, "* %s has left %s (%s)\n", sender, + if (chan) nicklist_remove(chan_win_idx(chan), sender); + wprintf(lvl, "\033[1m* %s has left %s (%s)\033[0m\n", sender, chan ? chan : "", reason ? reason : ""); /* If we parted, clear the window */ if (strcasecmp(sender, nick) == 0 && chan) { @@ -559,19 +623,31 @@ static void handle_line(char *line) win_chans[idx].name[0] = '\0'; win_chans[idx].modes[0] = '\0'; win_chans[idx].my_prefix = '\0'; + win_chans[idx].nick_count = 0; draw_statusbar(); } } } else if (strcmp(cmd, "QUIT") == 0) { char *reason = params; if (reason && reason[0] == ':') reason++; - wprintf(WL_STATUS, "* %s has quit (%s)\n", - sender, reason ? reason : ""); + /* Show quit in all active channel windows and remove nick */ + for (int i = 0; i < MAX_CHAN_WINS; i++) { + if (win_chans[i].name[0]) { + nicklist_remove(i, sender); + wprintf(WL_CHAN + i, "\033[1m* %s has quit (%s)\033[0m\n", + sender, reason ? reason : ""); + } + } } else if (strcmp(cmd, "NICK") == 0) { char *newnick = params; if (newnick && newnick[0] == ':') newnick++; - wprintf(WL_STATUS, "* %s is now known as %s\n", sender, - newnick ? newnick : ""); + /* Update nick lists and show in all active channel windows */ + if (newnick) nicklist_rename(sender, newnick); + for (int i = 0; i < MAX_CHAN_WINS; i++) { + if (win_chans[i].name[0]) + wprintf(WL_CHAN + i, "* %s is now known as %s\n", + sender, newnick ? newnick : ""); + } if (strcasecmp(sender, nick) == 0 && newnick) { snprintf(nick, sizeof(nick), "%s", newnick); draw_statusbar(); @@ -588,12 +664,12 @@ static void handle_line(char *line) if (target[0] == '#' || target[0] == '&') { update_my_prefix(target, modestr, modeargs); int lvl = chan_to_level(target); - wprintf(lvl, "* %s sets mode %s %s\n", + wprintf(lvl, "\033[1m* %s sets mode %s %s\033[0m\n", sender[0] ? sender : "*", modestr, modeargs ? modeargs : ""); draw_statusbar(); } else { - wprintf(WL_STATUS, "* %s sets mode %s\n", + wprintf(WL_STATUS, "\033[1m* %s sets mode %s\033[0m\n", sender[0] ? sender : "*", modestr); } } @@ -621,6 +697,33 @@ static void handle_line(char *line) } } } + } else if (strcmp(cmd, "332") == 0 && params) { + /* RPL_TOPIC: : */ + char *p = params; + char *sp = strchr(p, ' '); + if (sp) { + p = sp + 1; /* skip our nick */ + char *chan = p; + char *topic = strchr(p, ':'); + if (topic) { + sp = strchr(chan, ' '); + if (sp) *sp = '\0'; + topic++; + int lvl = chan_to_level(chan); + wprintf(lvl, "* Topic for %s: %s\n", chan, topic); + } + } + } else if (strcmp(cmd, "TOPIC") == 0 && params) { + /* :nick TOPIC #channel :new topic */ + char *chan = params; + char *topic = strchr(params, ':'); + if (topic) { + char *sp = strchr(chan, ' '); + if (sp) *sp = '\0'; + topic++; + int lvl = chan_to_level(chan); + wprintf(lvl, "* %s changed topic to: %s\n", sender, topic); + } } else if (strcmp(cmd, "353") == 0 && params) { /* RPL_NAMREPLY: = :names... */ char *colon = strchr(params, ':'); @@ -642,7 +745,6 @@ static void handle_line(char *line) int idx = chan_win_idx(chan); if (idx >= 0) { - /* Check our prefix */ char namecopy[512]; snprintf(namecopy, sizeof(namecopy), "%s", names); char *tok = strtok(namecopy, " "); @@ -653,10 +755,10 @@ static void handle_line(char *line) pf = *n; n++; } + nicklist_add(idx, n); if (strcasecmp(n, nick) == 0) { win_chans[idx].my_prefix = pf; draw_statusbar(); - break; } tok = strtok(NULL, " "); } @@ -722,10 +824,40 @@ static void handle_input(char *line) strlen(text)); wprintf(WL_MSG, "-> %s: %s\n", target, text); } + } else if (strcasecmp(cmd, "q") == 0) { + if (args && args[0]) { + snprintf(query_target, sizeof(query_target), "%s", args); + wprintf(WL_MSG, "* Now talking to %s\n", query_target); + } else { + if (query_target[0]) + wprintf(WL_MSG, "* No longer talking to %s\n", query_target); + query_target[0] = '\0'; + } } else if (strcasecmp(cmd, "nick") == 0 && args) { snprintf(nick, sizeof(nick), "%s", args); irc_send_raw("NICK %s", args); draw_statusbar(); + } else if (strcasecmp(cmd, "me") == 0 && args) { + const char *chan = current_channel(); + if (chan[0]) { + irc_send_raw("PRIVMSG %s :\x01" "ACTION %s\x01", chan, args); + wprintf(current_level, "* %s %s\n", nick, args); + } else if (query_target[0]) { + irc_send_raw("PRIVMSG %s :\x01" "ACTION %s\x01", query_target, args); + wprintf(WL_MSG, "* %s %s\n", nick, args); + } + } else if (strcasecmp(cmd, "slap") == 0 && args) { + const char *chan = current_channel(); + char slap[256]; + snprintf(slap, sizeof(slap), + "slaps %s around a bit with a large trout", args); + if (chan[0]) { + irc_send_raw("PRIVMSG %s :\x01" "ACTION %s\x01", chan, slap); + wprintf(current_level, "* %s %s\n", nick, slap); + } else if (query_target[0]) { + irc_send_raw("PRIVMSG %s :\x01" "ACTION %s\x01", query_target, slap); + wprintf(WL_MSG, "* %s %s\n", nick, slap); + } } else if (strcasecmp(cmd, "mode") == 0 && args) { irc_send_raw("MODE %s", args); } else if (strcasecmp(cmd, "whois") == 0 && args) { @@ -739,6 +871,19 @@ static void handle_input(char *line) const char *chan = args ? args : current_channel(); if (chan[0]) irc_send_raw("NAMES %s", chan); + } else if (strcasecmp(cmd, "topic") == 0) { + if (args && strchr(args, ' ')) { + /* /topic #channel new topic */ + char *chan = args; + char *text = strchr(args, ' '); + *text++ = '\0'; + irc_send_raw("TOPIC %s :%s", chan, text); + } else { + /* /topic or /topic #channel — query topic */ + const char *chan = args ? args : current_channel(); + if (chan[0]) + irc_send_raw("TOPIC %s", chan); + } } else if (strcasecmp(cmd, "quit") == 0) { irc_send_raw("QUIT :%s", args ? args : "See you later"); close(sock_fd); @@ -750,6 +895,14 @@ static void handle_input(char *line) } } else { const char *chan = current_channel(); + /* If on status/msg window with a query target, send there */ + if (chan[0] == '\0' && query_target[0]) { + char pfx[IRC_MAX]; + snprintf(pfx, sizeof(pfx), "PRIVMSG %s :", query_target); + irc_send_converted(pfx, (unsigned char *)line, len); + wprintf(WL_MSG, "-> %s: %s\n", query_target, line); + return; + } if (chan[0] == '\0') { wprintf(current_level, "* Not in a channel on this window.\n"); @@ -927,6 +1080,9 @@ int main(int argc, char *argv[]) char yank_buf[BUF_SIZE] = ""; size_t yank_len = 0; int esc_pending = 0; + int tab_idx = -1; /* current tab completion index */ + size_t tab_prefix_len = 0; /* length of prefix being completed */ + size_t tab_start = 0; /* byte position where completion word starts */ for (;;) { FD_ZERO(&fds); @@ -1027,6 +1183,9 @@ int main(int argc, char *argv[]) } } + /* Reset tab completion on any non-tab key */ + if (ch != 0x09) tab_idx = -1; + if (ch == 0x1B) { esc_pending = 1; } else if (ch == '\r' || ch == '\n') { @@ -1074,6 +1233,62 @@ int main(int argc, char *argv[]) memcpy(yank_buf, input_line + input_pos, yank_len); input_len = input_pos; redraw_input(input_line, input_len, input_pos); + } else if (ch == 0x09) { + /* Tab: nick completion */ + int cidx = -1; + if (current_level >= WL_CHAN) + cidx = current_level - WL_CHAN; + + if (cidx >= 0 && win_chans[cidx].nick_count > 0) { + /* Find word start */ + if (tab_idx < 0) { + tab_start = input_pos; + while (tab_start > 0 && + input_line[tab_start-1] != ' ') + tab_start--; + tab_prefix_len = input_pos - tab_start; + tab_idx = 0; + } else { + tab_idx++; + } + + /* Find next matching nick */ + int found = 0; + for (int tries = 0; tries < win_chans[cidx].nick_count; tries++) { + int ni = (tab_idx + tries) % win_chans[cidx].nick_count; + if (tab_prefix_len == 0 || + strncasecmp(win_chans[cidx].nicks[ni], + input_line + tab_start, + tab_prefix_len) == 0) { + tab_idx = ni; + /* Replace from tab_start to input_pos */ + const char *compl = win_chans[cidx].nicks[ni]; + size_t clen = strlen(compl); + /* Add ": " if at start of line */ + char suffix[4] = ""; + if (tab_start == 0) + strcpy(suffix, ": "); + else + strcpy(suffix, " "); + size_t slen = strlen(suffix); + size_t tail = input_len - input_pos; + input_len = tab_start + clen + slen + tail; + if (input_len >= sizeof(input_line) - 1) + input_len = sizeof(input_line) - 1; + memmove(input_line + tab_start + clen + slen, + input_line + input_pos, tail); + memcpy(input_line + tab_start, compl, clen); + memcpy(input_line + tab_start + clen, suffix, slen); + input_pos = tab_start + clen + slen; + redraw_input(input_line, input_len, input_pos); + tab_idx++; + found = 1; + break; + } + } + if (!found) tab_idx = -1; + } + continue; /* don't reset tab state */ } else if (ch == 0x10) { /* Ctrl-P: page up in scrollback */ int page = term_rows - 3;