summary refs log tree commit diff
diff options
context:
space:
mode:
authorJune McEnroe <june@causal.agency>2018-08-10 23:31:20 -0400
committerJune McEnroe <june@causal.agency>2018-08-10 23:31:20 -0400
commit07c750d25cf26883507d46bf319e55d2e35d6a94 (patch)
treedcf7dbc50dd717a1190c2f034ff440badf5a525c
parentMove process spawning onto the event loop (diff)
downloadcatgirl-07c750d25cf26883507d46bf319e55d2e35d6a94.tar.gz
catgirl-07c750d25cf26883507d46bf319e55d2e35d6a94.zip
Become multi-channel
There's a lot of UI missing for it, but it technically works.
Diffstat (limited to '')
-rw-r--r--Makefile2
-rw-r--r--README9
-rw-r--r--chat.c43
-rw-r--r--chat.h104
-rw-r--r--edit.c147
-rw-r--r--handle.c163
-rw-r--r--input.c75
-rw-r--r--irc.c13
-rw-r--r--tab.c35
-rw-r--r--tag.c77
-rw-r--r--ui.c225
-rw-r--r--url.c50
12 files changed, 595 insertions, 348 deletions
diff --git a/Makefile b/Makefile
index 57369d6..bf7b011 100644
--- a/Makefile
+++ b/Makefile
@@ -3,7 +3,7 @@ CFLAGS += -Wall -Wextra -Wpedantic
 CFLAGS += -I/usr/local/include -I/usr/local/opt/libressl/include
 LDFLAGS += -L/usr/local/lib -L/usr/local/opt/libressl/lib
 LDLIBS = -lcursesw -ltls
-OBJS = chat.o edit.o handle.o input.o irc.o pls.o tab.o ui.o url.o
+OBJS = chat.o edit.o handle.o input.o irc.o pls.o tab.o tag.o ui.o url.o
 
 all: tags chat
 
diff --git a/README b/README
index 8fa4ad7..9a93b9d 100644
--- a/README
+++ b/README
@@ -3,12 +3,13 @@ Simple IRC client for use over anonymous SSH.
 This software requires LibreSSL and targets FreeBSD and Darwin.
 
 	chat.h      Shared state and function prototypes
-	chat.c      Command line parsing and poll loop
+	chat.c      Command line parsing and event loop
+	tag.c       Tag (channel, query) management
+	handle.c    Incoming command handling
+	input.c     Input command handling
+	irc.c       TLS client connection
 	ui.c        Curses UI and mIRC formatting
 	edit.c      Line editing
-	irc.c       TLS client connection
-	input.c     Input command handling
-	handle.c    Incoming command handling
 	tab.c       Tab-complete
 	url.c       URL detection
 	pls.c       Functions which should not have to be written
diff --git a/chat.c b/chat.c
index 332cfd6..c85a1fe 100644
--- a/chat.c
+++ b/chat.c
@@ -29,6 +29,22 @@
 
 #include "chat.h"
 
+void selfNick(const char *nick) {
+	free(self.nick);
+	self.nick = strdup(nick);
+	if (!self.nick) err(EX_OSERR, "strdup");
+}
+void selfUser(const char *user) {
+	free(self.user);
+	self.user = strdup(user);
+	if (!self.user) err(EX_OSERR, "strdup");
+}
+void selfJoin(const char *join) {
+	free(self.join);
+	self.join = strdup(join);
+	if (!self.join) err(EX_OSERR, "strdup");
+}
+
 static union {
 	struct {
 		struct pollfd ui;
@@ -44,7 +60,7 @@ static union {
 
 void spawn(char *const argv[]) {
 	if (fds.pipe.events) {
-		uiLog(L"spawn: existing pipe");
+		uiLog(TAG_DEFAULT, L"spawn: existing pipe");
 		return;
 	}
 
@@ -77,7 +93,7 @@ static void pipeRead(void) {
 	if (len) {
 		buf[len] = '\0';
 		len = strcspn(buf, "\n");
-		uiFmt("%.*s", (int)len, buf);
+		uiFmt(TAG_DEFAULT, "%.*s", (int)len, buf);
 	} else {
 		close(fds.pipe.fd);
 		fds.pipe.events = 0;
@@ -108,15 +124,15 @@ static void sigchld(int sig) {
 	pid_t pid = wait(&status);
 	if (pid < 0) err(EX_OSERR, "wait");
 	if (WIFEXITED(status) && WEXITSTATUS(status)) {
-		uiFmt("spawn: exit %d", WEXITSTATUS(status));
+		uiFmt(TAG_DEFAULT, "spawn: exit %d", WEXITSTATUS(status));
 	} else if (WIFSIGNALED(status)) {
-		uiFmt("spawn: signal %d", WTERMSIG(status));
+		uiFmt(TAG_DEFAULT, "spawn: signal %d", WTERMSIG(status));
 	}
 }
 
 static void sigint(int sig) {
 	(void)sig;
-	input("/quit");
+	input(TAG_DEFAULT, "/quit");
 	uiExit();
 	exit(EX_OK);
 }
@@ -129,7 +145,7 @@ static char *prompt(const char *prompt) {
 		fflush(stdout);
 
 		ssize_t len = getline(&line, &cap, stdin);
-		if (ferror(stdin)) err(EX_IOERR, "getline");
+		//if (ferror(stdin)) err(EX_IOERR, "getline");
 		if (feof(stdin)) exit(EX_OK);
 		if (len < 2) continue;
 
@@ -149,25 +165,24 @@ int main(int argc, char *argv[]) {
 		switch (opt) {
 			break; case 'W': webirc = optarg;
 			break; case 'h': host = strdup(optarg);
-			break; case 'j': chat.join = strdup(optarg);
-			break; case 'n': chat.nick = strdup(optarg);
+			break; case 'j': selfJoin(optarg);
+			break; case 'n': selfNick(optarg);
 			break; case 'p': port = optarg;
-			break; case 'u': chat.user = strdup(optarg);
-			break; case 'v': chat.verbose = true;
+			break; case 'u': selfUser(optarg);
+			break; case 'v': self.verbose = true;
 			break; case 'w': pass = optarg;
 			break; default:  return EX_USAGE;
 		}
 	}
 
 	if (!host) host = prompt("Host: ");
-	if (!chat.join) chat.join = prompt("Join: ");
-	if (!chat.nick) chat.nick = prompt("Name: ");
-	if (!chat.user) chat.user = strdup(chat.nick);
+	if (!self.nick) self.nick = prompt("Name: ");
+	if (!self.user) selfUser(self.nick);
 
 	inputTab();
 
 	uiInit();
-	uiLog(L"Traveling...");
+	uiLog(TAG_DEFAULT, L"Traveling...");
 	uiDraw();
 
 	fds.irc.fd = ircConnect(host, port, pass, webirc);
diff --git a/chat.h b/chat.h
index 9a1b855..9219334 100644
--- a/chat.h
+++ b/chat.h
@@ -30,18 +30,23 @@ struct {
 	char *nick;
 	char *user;
 	char *join;
-} chat;
+} self;
 
-void spawn(char *const argv[]);
+void selfNick(const char *nick);
+void selfUser(const char *user);
+void selfJoin(const char *join);
 
-int ircConnect(
-	const char *host, const char *port, const char *pass, const char *webPass
-);
-void ircRead(void);
-void ircWrite(const char *ptr, size_t len);
+struct Tag {
+	size_t id;
+	const char *name;
+};
 
-__attribute__((format(printf, 1, 2)))
-void ircFmt(const char *format, ...);
+enum { TAGS_LEN = 256 };
+const struct Tag TAG_ALL;
+const struct Tag TAG_DEFAULT;
+struct Tag tagFor(const char *name);
+struct Tag tagName(const char *name);
+struct Tag tagNum(size_t num);
 
 enum {
 	IRC_BOLD      = 002,
@@ -52,47 +57,72 @@ enum {
 	IRC_UNDERLINE = 037,
 };
 
+void handle(char *line);
+void input(struct Tag tag, char *line);
+void inputTab(void);
+
+int ircConnect(
+	const char *host, const char *port, const char *pass, const char *webPass
+);
+void ircRead(void);
+void ircWrite(const char *ptr, size_t len);
+void ircFmt(const char *format, ...) __attribute__((format(printf, 1, 2)));
+
 void uiInit(void);
 void uiHide(void);
 void uiExit(void);
 void uiDraw(void);
 void uiBeep(void);
 void uiRead(void);
-void uiTopic(const wchar_t *topic);
-void uiTopicStr(const char *topic);
-void uiLog(const wchar_t *line);
-void uiFmt(const wchar_t *format, ...);
-
-// HACK: clang won't check wchar_t *format strings.
-#ifdef NDEBUG
-#define uiFmt(format, ...) uiFmt(L##format, __VA_ARGS__)
-#else
-#define uiFmt(format, ...) do { \
-	snprintf(NULL, 0, format, __VA_ARGS__); \
-	uiFmt(L##format, __VA_ARGS__); \
-} while(0)
-#endif
-
+void uiFocus(struct Tag tag);
+void uiTopic(struct Tag tag, const char *topic);
+void uiLog(struct Tag tag, const wchar_t *line);
+void uiFmt(struct Tag tag, const wchar_t *format, ...);
+
+enum Edit {
+	EDIT_LEFT,
+	EDIT_RIGHT,
+	EDIT_HOME,
+	EDIT_END,
+	EDIT_BACK_WORD,
+	EDIT_FORE_WORD,
+	EDIT_INSERT,
+	EDIT_BACKSPACE,
+	EDIT_DELETE,
+	EDIT_KILL_BACK_WORD,
+	EDIT_KILL_FORE_WORD,
+	EDIT_KILL_LINE,
+	EDIT_COMPLETE,
+	EDIT_ENTER,
+};
+void edit(struct Tag tag, enum Edit op, wchar_t ch);
 const wchar_t *editHead(void);
 const wchar_t *editTail(void);
-bool edit(bool meta, bool ctrl, wchar_t ch);
-
-void handle(char *line);
-
-void inputTab(void);
-void input(char *line);
-
-void urlScan(const char *str);
-void urlList(void);
-void urlOpen(size_t i);
 
-void tabTouch(const char *word);
-void tabRemove(const char *word);
+void tabTouch(struct Tag tag, const char *word);
+void tabRemove(struct Tag tag, const char *word);
+void tabClear(struct Tag tag);
 void tabReplace(const char *prev, const char *next);
-const char *tabNext(const char *prefix);
+const char *tabNext(struct Tag tag, const char *prefix);
 void tabAccept(void);
 void tabReject(void);
 
+void urlScan(struct Tag tag, const char *str);
+void urlList(struct Tag tag);
+void urlOpen(struct Tag tag, size_t fromEnd);
+
+void spawn(char *const argv[]);
+
 wchar_t *ambstowcs(const char *src);
 char *awcstombs(const wchar_t *src);
 int vaswprintf(wchar_t **ret, const wchar_t *format, va_list ap);
+
+// HACK: clang won't check wchar_t *format strings.
+#ifdef NDEBUG
+#define uiFmt(tag, format, ...) uiFmt(tag, L##format, __VA_ARGS__)
+#else
+#define uiFmt(tag, format, ...) do { \
+	snprintf(NULL, 0, format, __VA_ARGS__); \
+	uiFmt(tag, L##format, __VA_ARGS__); \
+} while(0)
+#endif
diff --git a/edit.c b/edit.c
index dde3396..8db92fc 100644
--- a/edit.c
+++ b/edit.c
@@ -34,6 +34,7 @@ static struct {
 	.end = line.buf,
 };
 
+// XXX: editTail must always be called after editHead.
 static wchar_t tail;
 const wchar_t *editHead(void) {
 	tail = *line.ptr;
@@ -41,8 +42,9 @@ const wchar_t *editHead(void) {
 	return line.buf;
 }
 const wchar_t *editTail(void) {
-	*line.ptr = tail;
+	if (tail) *line.ptr = tail;
 	*line.end = L'\0';
+	tail = L'\0';
 	return line.ptr;
 }
 
@@ -52,25 +54,19 @@ static void left(void) {
 static void right(void) {
 	if (line.ptr < line.end) line.ptr++;
 }
-static void home(void) {
-	line.ptr = line.buf;
-}
-static void end(void) {
-	line.ptr = line.end;
-}
 
-static void backspace(void) {
-	if (line.ptr == line.buf) return;
-	if (line.ptr != line.end) {
-		wmemmove(line.ptr - 1, line.ptr, line.end - line.ptr);
-	}
-	line.ptr--;
-	line.end--;
+static void backWord(void) {
+	left();
+	editHead();
+	wchar_t *word = wcsrchr(line.buf, ' ');
+	editTail();
+	line.ptr = (word ? &word[1] : line.buf);
 }
-static void delete(void) {
-	if (line.ptr == line.end) return;
+static void foreWord(void) {
 	right();
-	backspace();
+	editTail();
+	wchar_t *word = wcschr(line.ptr, ' ');
+	line.ptr = (word ? word : line.end);
 }
 
 static void insert(wchar_t ch) {
@@ -81,31 +77,18 @@ static void insert(wchar_t ch) {
 	*line.ptr++ = ch;
 	line.end++;
 }
-
-static void enter(void) {
-	if (line.end == line.buf) return;
-	*line.end = L'\0';
-	char *str = awcstombs(line.buf);
-	if (!str) err(EX_DATAERR, "awcstombs");
-	input(str);
-	free(str);
-	line.ptr = line.buf;
-	line.end = line.buf;
-}
-
-static void backWord(void) {
-	left();
-	editHead();
-	wchar_t *word = wcsrchr(line.buf, ' ');
-	editTail();
-	line.ptr = (word ? &word[1] : line.buf);
+static void backspace(void) {
+	if (line.ptr == line.buf) return;
+	if (line.ptr != line.end) {
+		wmemmove(line.ptr - 1, line.ptr, line.end - line.ptr);
+	}
+	line.ptr--;
+	line.end--;
 }
-static void foreWord(void) {
+static void delete(void) {
+	if (line.ptr == line.end) return;
 	right();
-	editHead();
-	editTail();
-	wchar_t *word = wcschr(line.ptr, ' ');
-	line.ptr = (word ? word : line.end);
+	backspace();
 }
 
 static void killBackWord(void) {
@@ -121,12 +104,9 @@ static void killForeWord(void) {
 	line.end -= line.ptr - from;
 	line.ptr = from;
 }
-static void killLine(void) {
-	line.end = line.ptr;
-}
 
 static char *prefix;
-static void complete(void) {
+static void complete(struct Tag tag) {
 	if (!line.tab) {
 		editHead();
 		line.tab = wcsrchr(line.buf, L' ');
@@ -136,7 +116,7 @@ static void complete(void) {
 		editTail();
 	}
 
-	const char *next = tabNext(prefix);
+	const char *next = tabNext(tag, prefix);
 	if (!next) return;
 
 	wchar_t *wcs = ambstowcs(next);
@@ -179,52 +159,37 @@ static void reject(void) {
 	tabReject();
 }
 
-static bool editMeta(wchar_t ch) {
-	switch (ch) {
-		break; case L'b':  reject(); backWord();
-		break; case L'f':  reject(); foreWord();
-		break; case L'\b': reject(); killBackWord();
-		break; case L'd':  reject(); killForeWord();
+static void enter(struct Tag tag) {
+	if (line.end == line.buf) return;
+	editTail();
+	char *str = awcstombs(line.buf);
+	if (!str) err(EX_DATAERR, "awcstombs");
+	input(tag, str);
+	free(str);
+	line.ptr = line.buf;
+	line.end = line.buf;
+}
 
-		break; default: return false;
-	}
-	return true;
-}
-
-static bool editCtrl(wchar_t ch) {
-	switch (ch) {
-		break; case L'B': reject(); left();
-		break; case L'F': reject(); right();
-		break; case L'A': reject(); home();
-		break; case L'E': reject(); end();
-		break; case L'D': reject(); delete();
-		break; case L'W': reject(); killBackWord();
-		break; case L'K': reject(); killLine();
-
-		break; case L'C': accept(); insert(IRC_COLOR);
-		break; case L'N': accept(); insert(IRC_RESET);
-		break; case L'O': accept(); insert(IRC_BOLD);
-		break; case L'R': accept(); insert(IRC_COLOR);
-		break; case L'T': accept(); insert(IRC_ITALIC);
-		break; case L'V': accept(); insert(IRC_REVERSE);
-
-		break; default: return false;
-	}
-	return true;
-}
-
-bool edit(bool meta, bool ctrl, wchar_t ch) {
-	if (meta) return editMeta(ch);
-	if (ctrl) return editCtrl(ch);
-	switch (ch) {
-		break; case L'\t': complete();
-		break; case L'\b': reject(); backspace();
-		break; case L'\n': accept(); enter();
-		break; default: {
-			if (!iswprint(ch)) return false;
-			accept();
-			insert(ch);
-		}
+void edit(struct Tag tag, enum Edit op, wchar_t ch) {
+	switch (op) {
+		break; case EDIT_LEFT:  reject(); left();
+		break; case EDIT_RIGHT: reject(); right();
+		break; case EDIT_HOME:  reject(); line.ptr = line.buf;
+		break; case EDIT_END:   reject(); line.ptr = line.end;
+
+		break; case EDIT_BACK_WORD: reject(); backWord();
+		break; case EDIT_FORE_WORD: reject(); foreWord();
+
+		break; case EDIT_INSERT:    accept(); insert(ch);
+		break; case EDIT_BACKSPACE: reject(); backspace();
+		break; case EDIT_DELETE:    reject(); delete();
+
+		break; case EDIT_KILL_BACK_WORD: reject(); killBackWord();
+		break; case EDIT_KILL_FORE_WORD: reject(); killForeWord();
+		break; case EDIT_KILL_LINE:      reject(); line.end = line.ptr;
+
+		break; case EDIT_COMPLETE: complete(tag);
+
+		break; case EDIT_ENTER: accept(); enter(tag);
 	}
-	return true;
 }
diff --git a/handle.c b/handle.c
index be41828..faf44aa 100644
--- a/handle.c
+++ b/handle.c
@@ -74,6 +74,16 @@ static void shift(
 	va_end(ap);
 }
 
+static bool isSelf(const char *nick, const char *user) {
+	if (!user) return false;
+	if (!strcmp(user, self.user)) return true;
+	if (!strcmp(nick, self.nick)) {
+		if (strcmp(user, self.user)) selfUser(user);
+		return true;
+	}
+	return false;
+}
+
 typedef void (*Handler)(char *prefix, char *params);
 
 static void handlePing(char *prefix, char *params) {
@@ -84,104 +94,118 @@ static void handlePing(char *prefix, char *params) {
 static void handle432(char *prefix, char *params) {
 	char *mesg;
 	shift(prefix, NULL, NULL, NULL, params, 3, 0, NULL, NULL, &mesg);
-	uiLog(L"You can't use that name here");
-	uiFmt("Sheriff says, \"%s\"", mesg);
-	uiLog(L"Type /nick <name> to choose a new one");
+	uiLog(TAG_DEFAULT, L"You can't use that name here");
+	uiFmt(TAG_DEFAULT, "Sheriff says, \"%s\"", mesg);
+	uiLog(TAG_DEFAULT, L"Type /nick <name> to choose a new one");
 }
 
 static void handle001(char *prefix, char *params) {
 	char *nick;
 	shift(prefix, NULL, NULL, NULL, params, 1, 0, &nick);
-	if (strcmp(nick, chat.nick)) {
-		free(chat.nick);
-		chat.nick = strdup(nick);
-	}
-	ircFmt("JOIN %s\r\n", chat.join);
+	if (strcmp(nick, self.nick)) selfNick(nick);
+	tabTouch(TAG_DEFAULT, self.nick);
+	if (self.join) ircFmt("JOIN %s\r\n", self.join);
+	uiLog(TAG_DEFAULT, L"You have arrived");
+}
+
+static void handle372(char *prefix, char *params) {
+	char *mesg;
+	shift(prefix, NULL, NULL, NULL, params, 2, 0, NULL, &mesg);
+	if (mesg[0] == '-' && mesg[1] == ' ') mesg = &mesg[2];
+	uiFmt(TAG_DEFAULT, "%s", mesg);
 }
 
 static void handleJoin(char *prefix, char *params) {
 	char *nick, *user, *chan;
 	shift(prefix, &nick, &user, NULL, params, 1, 0, &chan);
+	struct Tag tag = tagFor(chan);
+	if (isSelf(nick, user)) {
+		tabTouch(TAG_DEFAULT, chan);
+		uiFocus(tag);
+	} else {
+		tabTouch(tag, nick);
+	}
 	uiFmt(
-		"\3%d%s\3 arrives in \3%d%s\3",
+		tag, "\3%d%s\3 arrives in \3%d%s\3",
 		color(user), nick, color(chan), chan
 	);
-	if (!strcmp(nick, chat.nick) && strcmp(user, chat.user)) {
-		free(chat.user);
-		chat.user = strdup(user);
-	}
-	tabTouch(nick);
 }
 
 static void handlePart(char *prefix, char *params) {
 	char *nick, *user, *chan, *mesg;
 	shift(prefix, &nick, &user, NULL, params, 1, 1, &chan, &mesg);
+	struct Tag tag = tagFor(chan);
+	(void)(isSelf(nick, user) ? tabClear(tag) : tabRemove(tag, nick));
 	if (mesg) {
 		uiFmt(
-			"\3%d%s\3 leaves \3%d%s\3, \"%s\"",
+			tag, "\3%d%s\3 leaves \3%d%s\3, \"%s\"",
 			color(user), nick, color(chan), chan, mesg
 		);
 	} else {
 		uiFmt(
-			"\3%d%s\3 leaves \3%d%s\3",
+			tag, "\3%d%s\3 leaves \3%d%s\3",
 			color(user), nick, color(chan), chan
 		);
 	}
-	tabRemove(nick);
-}
-
-static void handleQuit(char *prefix, char *params) {
-	char *nick, *user, *mesg;
-	shift(prefix, &nick, &user, NULL, params, 0, 1, &mesg);
-	if (mesg) {
-		char *quot = (mesg[0] == '"') ? "" : "\"";
-		uiFmt(
-			"\3%d%s\3 leaves, %s%s%s",
-			color(user), nick, quot, mesg, quot
-		);
-	} else {
-		uiFmt("\3%d%s\3 leaves", color(user), nick);
-	}
-	tabRemove(nick);
 }
 
 static void handleKick(char *prefix, char *params) {
 	char *nick, *user, *chan, *kick, *mesg;
 	shift(prefix, &nick, &user, NULL, params, 2, 1, &chan, &kick, &mesg);
+	struct Tag tag = tagFor(chan);
+	(void)(isSelf(nick, user) ? tabClear(tag) : tabRemove(tag, nick));
 	if (mesg) {
 		uiFmt(
-			"\3%d%s\3 kicks \3%d%s\3 out of \3%d%s\3, \"%s\"",
+			tag, "\3%d%s\3 kicks \3%d%s\3 out of \3%d%s\3, \"%s\"",
 			color(user), nick, color(kick), kick, color(chan), chan, mesg
 		);
 	} else {
 		uiFmt(
-			"\3%d%s\3 kicks \3%d%s\3 out of \3%d%s\3",
+			tag, "\3%d%s\3 kicks \3%d%s\3 out of \3%d%s\3",
 			color(user), nick, color(kick), kick, color(chan), chan
 		);
 	}
-	tabRemove(nick);
+}
+
+static void handleQuit(char *prefix, char *params) {
+	char *nick, *user, *mesg;
+	shift(prefix, &nick, &user, NULL, params, 0, 1, &mesg);
+	// TODO: Send to tags where nick is in tab.
+	tabRemove(TAG_ALL, nick);
+	if (mesg) {
+		char *quot = (mesg[0] == '"') ? "" : "\"";
+		uiFmt(
+			TAG_DEFAULT, "\3%d%s\3 leaves, %s%s%s",
+			color(user), nick, quot, mesg, quot
+		);
+	} else {
+		uiFmt(TAG_DEFAULT, "\3%d%s\3 leaves", color(user), nick);
+	}
 }
 
 static void handle332(char *prefix, char *params) {
 	char *chan, *topic;
 	shift(prefix, NULL, NULL, NULL, params, 3, 0, NULL, &chan, &topic);
+	struct Tag tag = tagFor(chan);
+	urlScan(tag, topic);
+	uiTopic(tag, topic);
 	uiFmt(
-		"The sign in \3%d%s\3 reads, \"%s\"",
+		tag, "The sign in \3%d%s\3 reads, \"%s\"",
 		color(chan), chan, topic
 	);
-	urlScan(topic);
-	uiTopicStr(topic);
 }
 
 static void handleTopic(char *prefix, char *params) {
 	char *nick, *user, *chan, *topic;
 	shift(prefix, &nick, &user, NULL, params, 2, 0, &chan, &topic);
+	struct Tag tag = tagFor(chan);
+	if (!isSelf(nick, user)) tabTouch(tag, nick);
+	urlScan(tag, topic);
+	uiTopic(tag, topic);
 	uiFmt(
-		"\3%d%s\3 places a new sign in \3%d%s\3, \"%s\"",
+		tag, "\3%d%s\3 places a new sign in \3%d%s\3, \"%s\"",
 		color(user), nick, color(chan), chan, topic
 	);
-	urlScan(topic);
-	uiTopicStr(topic);
 }
 
 static void handle366(char *prefix, char *params) {
@@ -190,17 +214,20 @@ static void handle366(char *prefix, char *params) {
 	ircFmt("WHO %s\r\n", chan);
 }
 
+// FIXME: Track tag?
 static struct {
 	char buf[4096];
 	size_t len;
 } who;
 
 static void handle352(char *prefix, char *params) {
-	char *user, *nick;
+	char *chan, *user, *nick;
 	shift(
 		prefix, NULL, NULL, NULL,
-		params, 6, 0, NULL, NULL, &user, NULL, NULL, &nick
+		params, 6, 0, NULL, &chan, &user, NULL, NULL, &nick
 	);
+	struct Tag tag = tagFor(chan);
+	if (!isSelf(nick, user)) tabTouch(tag, nick);
 	size_t cap = sizeof(who.buf) - who.len;
 	int len = snprintf(
 		&who.buf[who.len], cap,
@@ -208,14 +235,14 @@ static void handle352(char *prefix, char *params) {
 		(who.len ? ", " : ""), color(user), nick
 	);
 	if ((size_t)len < cap) who.len += len;
-	tabTouch(nick);
 }
 
 static void handle315(char *prefix, char *params) {
 	char *chan;
 	shift(prefix, NULL, NULL, NULL, params, 2, 0, NULL, &chan);
+	struct Tag tag = tagFor(chan);
 	uiFmt(
-		"In \3%d%s\3 are %s",
+		tag, "In \3%d%s\3 are %s",
 		color(chan), chan, who.buf
 	);
 	who.len = 0;
@@ -224,58 +251,58 @@ static void handle315(char *prefix, char *params) {
 static void handleNick(char *prefix, char *params) {
 	char *prev, *user, *next;
 	shift(prefix, &prev, &user, NULL, params, 1, 0, &next);
+	if (isSelf(prev, user)) selfNick(next);
+	// TODO: Send to tags where prev is in tab.
+	tabReplace(prev, next);
 	uiFmt(
-		"\3%d%s\3 is now known as \3%d%s\3",
+		TAG_DEFAULT, "\3%d%s\3 is now known as \3%d%s\3",
 		color(user), prev, color(user), next
 	);
-	if (!strcmp(user, chat.user)) {
-		free(chat.nick);
-		chat.nick = strdup(next);
-	}
-	tabReplace(prev, next);
 }
 
-static void handleCTCP(char *nick, char *user, char *mesg) {
+static void handleCTCP(struct Tag tag, char *nick, char *user, char *mesg) {
 	mesg = &mesg[1];
 	char *ctcp = strsep(&mesg, " ");
 	char *params = strsep(&mesg, "\1");
 	if (strcmp(ctcp, "ACTION")) return;
+	if (!isSelf(nick, user)) tabTouch(tag, nick);
+	urlScan(tag, params);
 	uiFmt(
-		"\3%d* %s\3 %s",
+		tag, "\3%d* %s\3 %s",
 		color(user), nick, params
 	);
-	if (strcmp(user, chat.user)) tabTouch(nick);
-	urlScan(params);
 }
 
 static void handlePrivmsg(char *prefix, char *params) {
-	char *nick, *user, *mesg;
-	shift(prefix, &nick, &user, NULL, params, 2, 0, NULL, &mesg);
+	char *nick, *user, *chan, *mesg;
+	shift(prefix, &nick, &user, NULL, params, 2, 0, &chan, &mesg);
+	struct Tag tag = (strcmp(chan, self.nick) ? tagFor(chan) : tagFor(nick));
 	if (mesg[0] == '\1') {
-		handleCTCP(nick, user, mesg);
+		handleCTCP(tag, nick, user, mesg);
 		return;
 	}
-	bool self = !strcmp(user, chat.user);
-	bool ping = !strncasecmp(mesg, chat.nick, strlen(chat.nick));
+	if (!isSelf(nick, user)) tabTouch(tag, nick);
+	urlScan(tag, mesg);
+	bool ping = !strncasecmp(mesg, self.nick, strlen(self.nick));
+	bool self = isSelf(nick, user);
 	uiFmt(
-		"%c\3%d%c%s%c\17 %s",
+		tag, "%c\3%d%c%s%c\17 %s",
 		ping["\17\26"], color(user), self["<("], nick, self[">)"], mesg
 	);
-	if (!self) tabTouch(nick);
 	if (ping) uiBeep();
-	urlScan(mesg);
 }
 
 static void handleNotice(char *prefix, char *params) {
 	char *nick, *user, *chan, *mesg;
 	shift(prefix, &nick, &user, NULL, params, 2, 0, &chan, &mesg);
-	if (strcmp(chan, chat.join)) return;
+	struct Tag tag = TAG_DEFAULT;
+	if (user) tag = (strcmp(chan, self.nick) ? tagFor(chan) : tagFor(nick));
+	if (!isSelf(nick, user)) tabTouch(tag, nick);
+	urlScan(tag, mesg);
 	uiFmt(
-		"\3%d-%s-\3 %s",
+		tag, "\3%d-%s-\3 %s",
 		color(user), nick, mesg
 	);
-	tabTouch(nick);
-	urlScan(mesg);
 }
 
 static const struct {
@@ -287,6 +314,8 @@ static const struct {
 	{ "332", handle332 },
 	{ "352", handle352 },
 	{ "366", handle366 },
+	{ "372", handle372 },
+	{ "375", handle372 },
 	{ "432", handle432 },
 	{ "433", handle432 },
 	{ "JOIN", handleJoin },
diff --git a/input.c b/input.c
index 56c38bf..f4e3106 100644
--- a/input.c
+++ b/input.c
@@ -23,12 +23,13 @@
 
 #include "chat.h"
 
-static void privmsg(bool action, const char *mesg) {
+static void privmsg(struct Tag tag, bool action, const char *mesg) {
+	if (tag.id == TAG_DEFAULT.id) return;
 	char *line;
 	int send;
 	asprintf(
 		&line, ":%s!%s %nPRIVMSG %s :%s%s%s",
-		chat.nick, chat.user, &send, chat.join,
+		self.nick, self.user, &send, tag.name,
 		(action ? "\1ACTION " : ""), mesg, (action ? "\1" : "")
 	);
 	if (!line) err(EX_OSERR, "asprintf");
@@ -37,35 +38,47 @@ static void privmsg(bool action, const char *mesg) {
 	free(line);
 }
 
-typedef void (*Handler)(char *params);
+typedef void (*Handler)(struct Tag tag, char *params);
 
-static void inputMe(char *params) {
-	privmsg(true, params ? params : "");
+static void inputMe(struct Tag tag, char *params) {
+	privmsg(tag, true, params ? params : "");
 }
 
-static void inputNick(char *params) {
+static void inputNick(struct Tag tag, char *params) {
+	(void)tag;
 	char *nick = strsep(&params, " ");
 	if (nick) {
 		ircFmt("NICK %s\r\n", nick);
 	} else {
-		uiLog(L"/nick requires a name");
+		uiLog(TAG_DEFAULT, L"/nick requires a name");
 	}
 }
 
-static void inputWho(char *params) {
-	(void)params;
-	ircFmt("WHO %s\r\n", chat.join);
+static void inputJoin(struct Tag tag, char *params) {
+	(void)tag;
+	char *chan = strsep(&params, " ");
+	if (chan) {
+		ircFmt("JOIN %s\r\n", chan);
+	} else {
+		uiLog(TAG_DEFAULT, L"/join requires a channel");
+	}
 }
 
-static void inputTopic(char *params) {
-	if (params) {
-		ircFmt("TOPIC %s :%s\r\n", chat.join, params);
+static void inputWho(struct Tag tag, char *params) {
+	(void)params; // TODO
+	ircFmt("WHO %s\r\n", tag.name);
+}
+
+static void inputTopic(struct Tag tag, char *params) {
+	if (params) { // TODO
+		ircFmt("TOPIC %s :%s\r\n", tag.name, params);
 	} else {
-		ircFmt("TOPIC %s\r\n", chat.join);
+		ircFmt("TOPIC %s\r\n", tag.name);
 	}
 }
 
-static void inputQuit(char *params) {
+static void inputQuit(struct Tag tag, char *params) {
+	(void)tag;
 	if (params) {
 		ircFmt("QUIT :%s\r\n", params);
 	} else {
@@ -73,25 +86,34 @@ static void inputQuit(char *params) {
 	}
 }
 
-static void inputUrl(char *params) {
+static void inputUrl(struct Tag tag, char *params) {
 	(void)params;
-	urlList();
+	urlList(tag);
 }
-static void inputOpen(char *params) {
-	if (!params) { urlOpen(1); return; }
+static void inputOpen(struct Tag tag, char *params) {
+	if (!params) { urlOpen(tag, 1); return; }
 	size_t from = strtoul(strsep(&params, "-,"), NULL, 0);
-	if (!params) { urlOpen(from); return; }
+	if (!params) { urlOpen(tag, from); return; }
 	size_t to = strtoul(strsep(&params, "-,"), NULL, 0);
 	if (to < from) to = from;
 	for (size_t i = from; i <= to; ++i) {
-		urlOpen(i);
+		urlOpen(tag, i);
 	}
 }
 
+static void inputView(struct Tag tag, char *params) {
+	char *view = strsep(&params, " ");
+	if (!view) return;
+	size_t num = strtoul(view, &view, 0);
+	tag = (view[0] ? tagName(view) : tagNum(num));
+	if (tag.name) uiFocus(tag);
+}
+
 static const struct {
 	const char *command;
 	Handler handler;
 } COMMANDS[] = {
+	{ "/join", inputJoin },
 	{ "/me", inputMe },
 	{ "/names", inputWho },
 	{ "/nick", inputNick },
@@ -99,27 +121,28 @@ static const struct {
 	{ "/quit", inputQuit },
 	{ "/topic", inputTopic },
 	{ "/url", inputUrl },
+	{ "/view", inputView },
 	{ "/who", inputWho },
 };
 static const size_t COMMANDS_LEN = sizeof(COMMANDS) / sizeof(COMMANDS[0]);
 
-void input(char *input) {
+void input(struct Tag tag, char *input) {
 	if (input[0] != '/') {
-		privmsg(false, input);
+		privmsg(tag, false, input);
 		return;
 	}
 	char *command = strsep(&input, " ");
 	if (input && !input[0]) input = NULL;
 	for (size_t i = 0; i < COMMANDS_LEN; ++i) {
 		if (strcasecmp(command, COMMANDS[i].command)) continue;
-		COMMANDS[i].handler(input);
+		COMMANDS[i].handler(tag, input);
 		return;
 	}
-	uiFmt("%s isn't a recognized command", command);
+	uiFmt(TAG_DEFAULT, "%s isn't a recognized command", command);
 }
 
 void inputTab(void) {
 	for (size_t i = 0; i < COMMANDS_LEN; ++i) {
-		tabTouch(COMMANDS[i].command);
+		tabTouch(TAG_DEFAULT, COMMANDS[i].command);
 	}
 }
diff --git a/irc.c b/irc.c
index b9bef73..579f23b 100644
--- a/irc.c
+++ b/irc.c
@@ -39,7 +39,7 @@ static void webirc(const char *pass) {
 	if (sp) len = sp - ssh;
 	ircFmt(
 		"WEBIRC %s %s %.*s %.*s\r\n",
-		pass, chat.user, len, ssh, len, ssh
+		pass, self.user, len, ssh, len, ssh
 	);
 }
 
@@ -83,11 +83,8 @@ int ircConnect(
 
 	if (webPass) webirc(webPass);
 	if (pass) ircFmt("PASS :%s\r\n", pass);
-	ircFmt(
-		"NICK %s\r\n"
-		"USER %s 0 * :%s\r\n",
-		chat.nick, chat.user, chat.nick
-	);
+	ircFmt("NICK %s\r\n", self.nick);
+	ircFmt("USER %s 0 * :%s\r\n", self.user, self.nick);
 
 	return sock;
 }
@@ -109,7 +106,7 @@ void ircFmt(const char *format, ...) {
 	int len = vasprintf(&buf, format, ap);
 	va_end(ap);
 	if (!buf) err(EX_OSERR, "vasprintf");
-	if (chat.verbose) uiFmt("<<< %.*s", len - 2, buf);
+	if (self.verbose) uiFmt(tagFor("(irc)"), "\00314<<<\3 %.*s", len - 2, buf);
 	ircWrite(buf, len);
 	free(buf);
 }
@@ -129,7 +126,7 @@ void ircRead(void) {
 	char *crlf, *line = buf;
 	while ((crlf = strnstr(line, "\r\n", &buf[len] - line))) {
 		crlf[0] = '\0';
-		if (chat.verbose) uiFmt(">>> %s", line);
+		if (self.verbose) uiFmt(tagFor("(irc)"), "\00314>>>\3 %s", line);
 		handle(line);
 		line = &crlf[2];
 	}
diff --git a/tab.c b/tab.c
index a9ddfe5..a6bb795 100644
--- a/tab.c
+++ b/tab.c
@@ -22,6 +22,7 @@
 #include "chat.h"
 
 static struct Entry {
+	size_t tag;
 	char *word;
 	struct Entry *prev;
 	struct Entry *next;
@@ -46,8 +47,9 @@ static void touch(struct Entry *entry) {
 	prepend(entry);
 }
 
-void tabTouch(const char *word) {
+void tabTouch(struct Tag tag, const char *word) {
 	for (struct Entry *entry = head; entry; entry = entry->next) {
+		if (entry->tag != tag.id) continue;
 		if (strcmp(entry->word, word)) continue;
 		touch(entry);
 		return;
@@ -55,20 +57,28 @@ void tabTouch(const char *word) {
 
 	struct Entry *entry = malloc(sizeof(*entry));
 	if (!entry) err(EX_OSERR, "malloc");
+
+	entry->tag = tag.id;
 	entry->word = strdup(word);
+	if (!entry->word) err(EX_OSERR, "strdup");
+
 	prepend(entry);
 }
 
 void tabReplace(const char *prev, const char *next) {
-	tabTouch(prev);
-	free(head->word);
-	head->word = strdup(next);
+	for (struct Entry *entry = head; entry; entry = entry->next) {
+		if (strcmp(entry->word, prev)) continue;
+		free(entry->word);
+		entry->word = strdup(next);
+		if (!entry->word) err(EX_OSERR, "strdup");
+	}
 }
 
 static struct Entry *match;
 
-void tabRemove(const char *word) {
+void tabRemove(struct Tag tag, const char *word) {
 	for (struct Entry *entry = head; entry; entry = entry->next) {
+		if (tag.id != TAG_ALL.id && entry->tag != tag.id) continue;
 		if (strcmp(entry->word, word)) continue;
 		unlink(entry);
 		if (match == entry) match = entry->next;
@@ -78,17 +88,28 @@ void tabRemove(const char *word) {
 	}
 }
 
-const char *tabNext(const char *prefix) {
+void tabClear(struct Tag tag) {
+	for (struct Entry *entry = head; entry; entry = entry->next) {
+		if (entry->tag != tag.id) continue;
+		unlink(entry);
+		if (match == entry) match = entry->next;
+		free(entry->word);
+		free(entry);
+	}
+}
+
+const char *tabNext(struct Tag tag, const char *prefix) {
 	size_t len = strlen(prefix);
 	struct Entry *start = (match ? match->next : head);
 	for (struct Entry *entry = start; entry; entry = entry->next) {
+		if (entry->tag != TAG_DEFAULT.id && entry->tag != tag.id) continue;
 		if (strncasecmp(entry->word, prefix, len)) continue;
 		match = entry;
 		return entry->word;
 	}
 	if (!match) return NULL;
 	match = NULL;
-	return tabNext(prefix);
+	return tabNext(tag, prefix);
 }
 
 void tabAccept(void) {
diff --git a/tag.c b/tag.c
new file mode 100644
index 0000000..014e84c
--- /dev/null
+++ b/tag.c
@@ -0,0 +1,77 @@
+/* Copyright (C) 2018  Curtis McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <err.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+
+#include "chat.h"
+
+const struct Tag TAG_ALL = { (size_t)-1, NULL };
+const struct Tag TAG_DEFAULT = { 0, "(status)" };
+
+static struct {
+	char *name[TAGS_LEN];
+	size_t len;
+	size_t gap;
+} tags = {
+	.name = { "(status)" },
+	.len = 1,
+	.gap = 1,
+};
+
+static struct Tag Tag(size_t id) {
+	return (struct Tag) { id, tags.name[id] };
+}
+
+struct Tag tagName(const char *name) {
+	for (size_t id = 0; id < tags.len; ++id) {
+		if (!tags.name[id] || strcmp(tags.name[id], name)) continue;
+		return Tag(id);
+	}
+	return TAG_ALL;
+}
+
+struct Tag tagNum(size_t num) {
+	if (num < tags.gap) return Tag(num);
+	num -= tags.gap;
+	for (size_t id = tags.gap; id < tags.len; ++id) {
+		if (!tags.name[id]) continue;
+		if (!num--) return Tag(id);
+	}
+	return TAG_ALL;
+}
+
+struct Tag tagFor(const char *name) {
+	struct Tag tag = tagName(name);
+	if (tag.name) return tag;
+
+	size_t id = tags.gap;
+	tags.name[id] = strdup(name);
+	if (!tags.name[id]) err(EX_OSERR, "strdup");
+
+	if (tags.gap == tags.len) {
+		tags.gap++;
+		tags.len++;
+	} else {
+		for (tags.gap++; tags.gap < tags.len; ++tags.gap) {
+			if (!tags.name[tags.gap]) break;
+		}
+	}
+
+	return Tag(id);
+}
diff --git a/ui.c b/ui.c
index 9778473..844e777 100644
--- a/ui.c
+++ b/ui.c
@@ -95,13 +95,19 @@ static int logHeight(void) {
 	return LINES - 4;
 }
 
-static struct {
+struct View {
 	WINDOW *topic;
 	WINDOW *log;
-	WINDOW *input;
-	bool hide;
-	bool mark;
 	int scroll;
+	bool mark;
+};
+
+static struct {
+	bool hide;
+	WINDOW *input;
+	struct Tag tag;
+	struct View views[TAGS_LEN];
+	size_t len;
 } ui;
 
 void uiInit(void) {
@@ -113,14 +119,7 @@ void uiInit(void) {
 	focusEnable();
 	colorInit();
 
-	ui.topic = newpad(2, TOPIC_COLS);
-	mvwhline(ui.topic, 1, 0, ACS_HLINE, TOPIC_COLS);
-
-	ui.log = newpad(LOG_LINES, COLS);
-	wsetscrreg(ui.log, 0, LOG_LINES - 1);
-	scrollok(ui.log, true);
-	wmove(ui.log, LOG_LINES - logHeight() - 1, 0);
-	ui.scroll = LOG_LINES;
+	ui.tag = TAG_DEFAULT;
 
 	ui.input = newpad(2, INPUT_COLS);
 	mvwhline(ui.input, 0, 0, ACS_HLINE, INPUT_COLS);
@@ -130,11 +129,6 @@ void uiInit(void) {
 	nodelay(ui.input, true);
 }
 
-static void uiResize(void) {
-	wresize(ui.log, LOG_LINES, COLS);
-	wmove(ui.log, LOG_LINES - 1, COLS - 1);
-}
-
 void uiHide(void) {
 	ui.hide = true;
 	endwin();
@@ -149,17 +143,46 @@ void uiExit(void) {
 	);
 }
 
+static struct View *uiView(struct Tag tag) {
+	struct View *view = &ui.views[tag.id];
+	if (view->log) return view;
+
+	view->topic = newpad(2, TOPIC_COLS);
+	mvwhline(view->topic, 1, 0, ACS_HLINE, TOPIC_COLS);
+
+	view->log = newpad(LOG_LINES, COLS);
+	wsetscrreg(view->log, 0, LOG_LINES - 1);
+	scrollok(view->log, true);
+	wmove(view->log, LOG_LINES - logHeight() - 1, 0);
+
+	view->scroll = LOG_LINES;
+	view->mark = false;
+
+	if (tag.id >= ui.len) ui.len = tag.id + 1;
+	return view;
+}
+
+static void uiResize(void) {
+	for (size_t i = 0; i < ui.len; ++i) {
+		struct View *view = &ui.views[i];
+		if (!view->log) continue;
+		wresize(view->log, LOG_LINES, COLS);
+		wmove(view->log, LOG_LINES - 1, COLS - 1);
+	}
+}
+
 void uiDraw(void) {
 	if (ui.hide) return;
+	struct View *view = uiView(ui.tag);
 	pnoutrefresh(
-		ui.topic,
+		view->topic,
 		0, 0,
 		0, 0,
 		1, lastCol()
 	);
 	pnoutrefresh(
-		ui.log,
-		ui.scroll - logHeight(), 0,
+		view->log,
+		view->scroll - logHeight(), 0,
 		2, 0,
 		lastLine() - 2, lastCol()
 	);
@@ -178,6 +201,16 @@ static void uiRedraw(void) {
 	clearok(curscr, true);
 }
 
+void uiFocus(struct Tag tag) {
+	struct View *view = uiView(ui.tag);
+	view->mark = true;
+	view = uiView(tag);
+	view->mark = false;
+	touchwin(view->topic);
+	touchwin(view->log);
+	ui.tag = tag;
+}
+
 void uiBeep(void) {
 	beep(); // always be beeping
 }
@@ -276,93 +309,133 @@ static void addIRC(WINDOW *win, const wchar_t *str) {
 	}
 }
 
-void uiTopic(const wchar_t *topic) {
-	wmove(ui.topic, 0, 0);
-	addIRC(ui.topic, topic);
-	wclrtoeol(ui.topic);
-}
-
-void uiTopicStr(const char *topic) {
+void uiTopic(struct Tag tag, const char *topic) {
 	wchar_t *wcs = ambstowcs(topic);
 	if (!wcs) err(EX_DATAERR, "ambstowcs");
-	uiTopic(wcs);
+	struct View *view = uiView(tag);
+	wmove(view->topic, 0, 0);
+	addIRC(view->topic, wcs);
+	wclrtoeol(view->topic);
 	free(wcs);
 }
 
-void uiLog(const wchar_t *line) {
-	waddch(ui.log, '\n');
-	if (ui.mark) {
-		waddch(ui.log, '\n');
-		ui.mark = false;
+void uiLog(struct Tag tag, const wchar_t *line) {
+	struct View *view = uiView(tag);
+	waddch(view->log, '\n');
+	if (view->mark) {
+		waddch(view->log, '\n');
+		view->mark = false;
 	}
-	addIRC(ui.log, line);
+	addIRC(view->log, line);
 }
 
-void uiFmt(const wchar_t *format, ...) {
+void uiFmt(struct Tag tag, const wchar_t *format, ...) {
 	wchar_t *buf;
 	va_list ap;
 	va_start(ap, format);
 	vaswprintf(&buf, format, ap);
 	va_end(ap);
 	if (!buf) err(EX_OSERR, "vaswprintf");
-	uiLog(buf);
+	uiLog(tag, buf);
 	free(buf);
 }
 
 static void logUp(void) {
-	if (ui.scroll == logHeight()) return;
-	if (ui.scroll == LOG_LINES) ui.mark = true;
-	ui.scroll = MAX(ui.scroll - logHeight() / 2, logHeight());
+	struct View *view = uiView(ui.tag);
+	if (view->scroll == logHeight()) return;
+	if (view->scroll == LOG_LINES) view->mark = true;
+	view->scroll = MAX(view->scroll - logHeight() / 2, logHeight());
 }
 static void logDown(void) {
-	if (ui.scroll == LOG_LINES) return;
-	ui.scroll = MIN(ui.scroll + logHeight() / 2, LOG_LINES);
-	if (ui.scroll == LOG_LINES) ui.mark = false;
+	struct View *view = uiView(ui.tag);
+	if (view->scroll == LOG_LINES) return;
+	view->scroll = MIN(view->scroll + logHeight() / 2, LOG_LINES);
+	if (view->scroll == LOG_LINES) view->mark = false;
 }
 
-static bool keyChar(wint_t ch) {
+static bool keyChar(wchar_t ch) {
 	static bool esc, csi;
-	bool update = false;
+	if (ch == L'\33') {
+		esc = true;
+		return false;
+	}
+	if (esc && ch == L'[') {
+		esc = false;
+		csi = true;
+		return false;
+	}
+	if (csi) {
+		if (ch == L'O') uiView(ui.tag)->mark = true;
+		if (ch == L'I') uiView(ui.tag)->mark = false;
+		csi = false;
+		return false;
+	}
+	if (ch == L'\177') ch = L'\b';
+
+	bool update = true;
+	if (esc) {
+		switch (ch) {
+			break; case L'b':  edit(ui.tag, EDIT_BACK_WORD, 0);
+			break; case L'f':  edit(ui.tag, EDIT_FORE_WORD, 0);
+			break; case L'\b': edit(ui.tag, EDIT_KILL_BACK_WORD, 0);
+			break; case L'd':  edit(ui.tag, EDIT_KILL_FORE_WORD, 0);
+			break; default: {
+				update = false;
+				if (ch >= L'0' && ch <= L'9') {
+					struct Tag tag = tagNum(ch - L'0');
+					if (tag.name) uiFocus(tag);
+				}
+			}
+		}
+		esc = false;
+		return update;
+	}
+
 	switch (ch) {
-		break; case CTRL('L'): uiRedraw();
-		break; case CTRL('['): esc = true; return false;
-		break; case L'\b':     update = edit(esc, false, L'\b');
-		break; case L'\177':   update = edit(esc, false, L'\b');
-		break; case L'\t':     update = edit(esc, false, L'\t');
-		break; case L'\n':     update = edit(esc, false, L'\n');
+		break; case CTRL(L'L'): uiRedraw(); return false;
+
+		break; case CTRL(L'A'): edit(ui.tag, EDIT_HOME, 0);
+		break; case CTRL(L'B'): edit(ui.tag, EDIT_LEFT, 0);
+		break; case CTRL(L'D'): edit(ui.tag, EDIT_DELETE, 0);
+		break; case CTRL(L'E'): edit(ui.tag, EDIT_END, 0);
+		break; case CTRL(L'F'): edit(ui.tag, EDIT_RIGHT, 0);
+		break; case CTRL(L'K'): edit(ui.tag, EDIT_KILL_LINE, 0);
+		break; case CTRL(L'W'): edit(ui.tag, EDIT_KILL_BACK_WORD, 0);
+
+		break; case CTRL(L'C'): edit(ui.tag, EDIT_INSERT, IRC_COLOR);
+		break; case CTRL(L'N'): edit(ui.tag, EDIT_INSERT, IRC_RESET);
+		break; case CTRL(L'O'): edit(ui.tag, EDIT_INSERT, IRC_BOLD);
+		break; case CTRL(L'R'): edit(ui.tag, EDIT_INSERT, IRC_COLOR);
+		break; case CTRL(L'T'): edit(ui.tag, EDIT_INSERT, IRC_ITALIC);
+		break; case CTRL(L'U'): edit(ui.tag, EDIT_INSERT, IRC_UNDERLINE);
+		break; case CTRL(L'V'): edit(ui.tag, EDIT_INSERT, IRC_REVERSE);
+
+		break; case L'\b': edit(ui.tag, EDIT_BACKSPACE, 0);
+		break; case L'\t': edit(ui.tag, EDIT_COMPLETE, 0);
+		break; case L'\n': edit(ui.tag, EDIT_ENTER, 0);
+
 		break; default: {
-			if (esc && ch == L'[') {
-				csi = true;
-				return false;
-			} else if (csi) {
-				if (ch == L'O') ui.mark = true;
-				if (ch == L'I') ui.mark = false;
-			} else if (iswcntrl(ch)) {
-				update = edit(esc, true, UNCTRL(ch));
-			} else {
-				update = edit(esc, false, ch);
-			}
+			if (!iswprint(ch)) return false;
+			edit(ui.tag, EDIT_INSERT, ch);
 		}
 	}
-	esc = false;
-	csi = false;
-	return update;
+	return true;
 }
 
-static bool keyCode(wint_t ch) {
+static bool keyCode(wchar_t ch) {
 	switch (ch) {
-		break; case KEY_RESIZE:    uiResize();
-		break; case KEY_PPAGE:     logUp();
-		break; case KEY_NPAGE:     logDown();
-		break; case KEY_LEFT:      return edit(false, true, 'B');
-		break; case KEY_RIGHT:     return edit(false, true, 'F');
-		break; case KEY_HOME:      return edit(false, true, 'A');
-		break; case KEY_END:       return edit(false, true, 'E');
-		break; case KEY_DC:        return edit(false, true, 'D');
-		break; case KEY_BACKSPACE: return edit(false, false, '\b');
-		break; case KEY_ENTER:     return edit(false, false, '\n');
+		break; case KEY_RESIZE:    uiResize(); return false;
+		break; case KEY_PPAGE:     logUp(); return false;
+		break; case KEY_NPAGE:     logDown(); return false;
+		break; case KEY_LEFT:      edit(ui.tag, EDIT_LEFT, ch);
+		break; case KEY_RIGHT:     edit(ui.tag, EDIT_RIGHT, ch);
+		break; case KEY_HOME:      edit(ui.tag, EDIT_HOME, ch);
+		break; case KEY_END:       edit(ui.tag, EDIT_END, ch);
+		break; case KEY_DC:        edit(ui.tag, EDIT_DELETE, ch);
+		break; case KEY_BACKSPACE: edit(ui.tag, EDIT_BACKSPACE, ch);
+		break; case KEY_ENTER:     edit(ui.tag, EDIT_ENTER, ch);
 	}
-	return false;
+	return true;
 }
 
 void uiRead(void) {
diff --git a/url.c b/url.c
index 1c57126..b7172ce 100644
--- a/url.c
+++ b/url.c
@@ -30,40 +30,56 @@ static const char *SCHEMES[] = {
 };
 static const size_t SCHEMES_LEN = sizeof(SCHEMES) / sizeof(SCHEMES[0]);
 
-enum { RING_LEN = 16 };
-static char *ring[RING_LEN];
-static size_t last;
+struct Entry {
+	size_t tag;
+	char *url;
+};
+
+enum { RING_LEN = 32 };
 static_assert(!(RING_LEN & (RING_LEN - 1)), "power of two RING_LEN");
 
-static void push(const char *url, size_t len) {
-	free(ring[last]);
-	ring[last++] = strndup(url, len);
-	last &= RING_LEN - 1;
+static struct {
+	struct Entry buf[RING_LEN];
+	size_t end;
+} ring;
+
+static void push(struct Tag tag, const char *url, size_t len) {
+	free(ring.buf[ring.end].url);
+	ring.buf[ring.end].tag = tag.id;
+	ring.buf[ring.end].url = strndup(url, len);
+	if (!ring.buf[ring.end].url) err(EX_OSERR, "strndup");
+	ring.end = (ring.end + 1) & (RING_LEN - 1);
 }
 
-void urlScan(const char *str) {
+void urlScan(struct Tag tag, const char *str) {
 	while (str[0]) {
 		size_t len = 1;
 		for (size_t i = 0; i < SCHEMES_LEN; ++i) {
 			if (strncmp(str, SCHEMES[i], strlen(SCHEMES[i]))) continue;
 			len = strcspn(str, " >\"");
-			push(str, len);
+			push(tag, str, len);
 		}
 		str = &str[len];
 	}
 }
 
-void urlList(void) {
+void urlList(struct Tag tag) {
 	uiHide();
 	for (size_t i = 0; i < RING_LEN; ++i) {
-		char *url = ring[(i + last) & (RING_LEN - 1)];
-		if (url) printf("%s\n", url);
+		struct Entry entry = ring.buf[(ring.end + i) & (RING_LEN - 1)];
+		if (!entry.url || entry.tag != tag.id) continue;
+		printf("%s\n", entry.url);
 	}
 }
 
-void urlOpen(size_t i) {
-	char *url = ring[(last - i) & (RING_LEN - 1)];
-	if (!url) return;
-	char *argv[] = { "open", url, NULL };
-	spawn(argv);
+void urlOpen(struct Tag tag, size_t fromEnd) {
+	size_t count = 0;
+	for (size_t i = 0; i < RING_LEN; ++i) {
+		struct Entry entry = ring.buf[(ring.end - i) & (RING_LEN - 1)];
+		if (!entry.url || entry.tag != tag.id) continue;
+		if (++count != fromEnd) continue;
+		char *argv[] = { "open", entry.url, NULL };
+		spawn(argv);
+		return;
+	}
 }