about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--Makefile98
-rw-r--r--README.7108
-rw-r--r--buffer.c233
-rw-r--r--catgirl.1747
-rw-r--r--chat.c258
-rw-r--r--chat.h326
-rw-r--r--command.c407
-rw-r--r--compat_readpassphrase.c206
-rw-r--r--complete.c163
-rw-r--r--config.c22
-rwxr-xr-xconfigure29
-rw-r--r--edit.c442
-rw-r--r--edit.h74
-rw-r--r--filter.c132
-rw-r--r--handle.c740
-rw-r--r--ignore.c89
-rw-r--r--input.c629
-rw-r--r--irc.c121
-rw-r--r--log.c104
-rw-r--r--sandman.1 (renamed from scripts/sandman.1)2
-rw-r--r--sandman.m (renamed from scripts/sandman.m)2
-rw-r--r--scripts/.gitignore1
-rw-r--r--scripts/build-chroot.sh74
-rw-r--r--scripts/chat.tmux.conf64
-rw-r--r--scripts/chroot-prompt.sh3
-rw-r--r--ui.c1049
-rw-r--r--url.c58
-rw-r--r--window.c659
-rw-r--r--xdg.c172
30 files changed, 4719 insertions, 2295 deletions
diff --git a/.gitignore b/.gitignore
index e96e0c1..519791d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,8 @@
 *.o
+*.t
 catgirl
 chroot.tar
 config.mk
 root/
+sandman
 tags
diff --git a/Makefile b/Makefile
index 08e90ab..66fb408 100644
--- a/Makefile
+++ b/Makefile
@@ -1,93 +1,87 @@
 PREFIX ?= /usr/local
-MANDIR ?= ${PREFIX}/share/man
+BINDIR ?= ${PREFIX}/bin
+MANDIR ?= ${PREFIX}/man
 
 CEXTS = gnu-case-range gnu-conditional-omitted-operand
-CFLAGS += -std=c11 -Wall -Wextra -Wpedantic ${CEXTS:%=-Wno-%}
-LDLIBS = -lncursesw -ltls
+CFLAGS += -std=c11 -Wall -Wextra -Wpedantic -Wmissing-prototypes
+CFLAGS += ${CEXTS:%=-Wno-%}
+LDADD.libtls = -ltls
+LDADD.ncursesw = -lncursesw
+
+BINS = catgirl
+MANS = ${BINS:=.1}
 
 -include config.mk
 
+LDLIBS = ${LDADD.libtls} ${LDADD.ncursesw}
+LDLIBS.sandman = -framework Cocoa
+
+OBJS += buffer.o
 OBJS += chat.o
 OBJS += command.o
 OBJS += complete.o
 OBJS += config.o
 OBJS += edit.o
+OBJS += filter.o
 OBJS += handle.o
-OBJS += ignore.o
+OBJS += input.o
 OBJS += irc.o
 OBJS += log.o
 OBJS += ui.o
 OBJS += url.o
+OBJS += window.o
 OBJS += xdg.o
 
-dev: tags all
+OBJS.sandman = sandman.o
+
+TESTS += edit.t
 
-all: catgirl
+dev: tags all check
+
+all: ${BINS}
 
 catgirl: ${OBJS}
 	${CC} ${LDFLAGS} ${OBJS} ${LDLIBS} -o $@
 
 ${OBJS}: chat.h
 
-tags: *.h *.c
-	ctags -w *.h *.c
+edit.o edit.t input.o: edit.h
 
-clean:
-	rm -f tags catgirl ${OBJS}
+sandman: ${OBJS.sandman}
+	${CC} ${LDFLAGS} ${OBJS.$@} ${LDLIBS.$@} -o $@
 
-install: catgirl catgirl.1
-	install -d ${DESTDIR}${PREFIX}/bin ${DESTDIR}${MANDIR}/man1
-	install catgirl ${DESTDIR}${PREFIX}/bin
-	install -m 644 catgirl.1 ${DESTDIR}${MANDIR}/man1
+check: ${TESTS}
 
-uninstall:
-	rm -f ${DESTDIR}${PREFIX}/bin/catgirl ${DESTDIR}${MANDIR}/man1/catgirl.1
+.SUFFIXES: .t
+
+.c.t:
+	${CC} ${CFLAGS} -DTEST ${LDFLAGS} $< ${LDLIBS} -o $@
+	./$@ || rm $@
 
-scripts/sandman: scripts/sandman.o
-	${CC} ${LDFLAGS} scripts/sandman.o -framework Cocoa -o $@
+tags: *.[ch]
+	ctags -w *.[ch]
 
-install-sandman: scripts/sandman scripts/sandman.1
-	install -d ${DESTDIR}${PREFIX}/bin ${DESTDIR}${MANDIR}/man1
-	install scripts/sandman ${DESTDIR}${PREFIX}/bin
-	install -m 644 scripts/sandman.1 ${DESTDIR}${MANDIR}/man1
+clean:
+	rm -f ${BINS} ${OBJS} ${OBJS.sandman} ${TESTS} tags
 
-uninstall-sandman:
-	rm -f ${DESTDIR}${PREFIX}/bin/sandman ${DESTDIR}${MANDIR}/man1/sandman.1
+install: ${BINS} ${MANS}
+	install -d ${DESTDIR}${BINDIR} ${DESTDIR}${MANDIR}/man1
+	install ${BINS} ${DESTDIR}${BINDIR}
+	install -m 644 ${MANS} ${DESTDIR}${MANDIR}/man1
+
+uninstall:
+	rm -f ${BINS:%=${DESTDIR}${BINDIR}/%}
+	rm -f ${MANS:%=${DESTDIR}${MANDIR}/man1/%}
 
 CHROOT_USER = chat
 CHROOT_GROUP = ${CHROOT_USER}
 
 chroot.tar: catgirl catgirl.1 scripts/chroot-prompt.sh scripts/chroot-man.sh
-	install -d -o root -g wheel \
-		root \
-		root/bin \
-		root/etc \
-		root/home \
-		root/lib \
-		root/libexec \
-		root/usr/bin \
-		root/usr/local/etc/ssl \
-		root/usr/share/man \
-		root/usr/share/misc
-	install -d -o ${CHROOT_USER} -g ${CHROOT_GROUP} \
-		root/home/${CHROOT_USER} \
-		root/home/${CHROOT_USER}/.local/share
-	cp -fp /libexec/ld-elf.so.1 root/libexec
-	ldd -f '%p\n' catgirl /usr/bin/mandoc /usr/bin/less \
-		| sort -u | xargs -t -J % cp -fp % root/lib
-	chflags noschg root/libexec/* root/lib/*
-	cp -fp /etc/hosts /etc/resolv.conf root/etc
-	cp -fp /usr/local/etc/ssl/cert.pem root/usr/local/etc/ssl
-	cp -af /usr/share/locale root/usr/share
-	cp -fp /usr/share/misc/termcap.db root/usr/share/misc
-	cp -fp /rescue/sh /usr/bin/mandoc /usr/bin/less root/bin
-	${MAKE} install DESTDIR=root PREFIX=/usr
-	install scripts/chroot-prompt.sh root/usr/bin/catgirl-prompt
-	install scripts/chroot-man.sh root/usr/bin/man
-	tar -c -f chroot.tar -C root bin etc home lib libexec usr
+chroot.tar: scripts/build-chroot.sh
+	sh scripts/build-chroot.sh ${CHROOT_USER} ${CHROOT_GROUP}
 
 install-chroot: chroot.tar
-	tar -x -f chroot.tar -C /home/${CHROOT_USER}
+	tar -px -f chroot.tar -C /home/${CHROOT_USER}
 
 clean-chroot:
 	rm -fr chroot.tar root
diff --git a/README.7 b/README.7
index 6842fd3..a26d270 100644
--- a/README.7
+++ b/README.7
@@ -1,7 +1,7 @@
-.Dd August  4, 2020
+.\" To view this file: $ man ./README.7
+.Dd July  9, 2023
 .Dt README 7
 .Os "Causal Agency"
-.\" To view this file, run: man ./README.7
 .
 .Sh NAME
 .Nm catgirl
@@ -9,7 +9,15 @@
 .
 .Sh DESCRIPTION
 .Xr catgirl 1
-is a TLS-only terminal IRC client.
+is a terminal IRC client.
+.
+.Pp
+Screenshot:
+imagine,
+if you will,
+text on a screen,
+next to names
+in a selection of colours.
 .
 .Ss Notable Features
 .Bl -bullet
@@ -17,7 +25,7 @@ is a TLS-only terminal IRC client.
 Tab complete:
 most recently seen or mentioned nicks
 are completed first.
-Commas are inserted between multple nicks.
+Commas are inserted between multiple nicks.
 .It
 Prompt:
 the prompt clearly shows whether input
@@ -37,9 +45,25 @@ color generation based on usernames
 remains stable across nick changes.
 Mentions of users in messages are colored.
 .It
+Topic diffing:
+the modified portion
+of a channel topic change
+is highlighted.
+.It
 Ignore:
 visibility of filtered messages
 can be toggled.
+.It
+Security:
+on
+.Fx
+and
+.Ox ,
+the
+.Cm restrict
+option enables tight sandboxing.
+Sandboxing is always used on
+.Ox .
 .El
 .
 .Ss Non-features
@@ -75,6 +99,11 @@ apart from
 this protocol is useless at best
 and enables abuse at worst.
 .It
+Protocol coverage:
+IRCv3 extensions are implemented only
+where they contribute to
+the intended user experience.
+.It
 Cleartext IRC:
 TLS is now ubiquitous
 and certificates are easy to obtain.
@@ -93,17 +122,27 @@ It targets
 .Ox ,
 macOS
 and Linux.
-On
-.Ox ,
-configure with
-.Fl \-mandir=/usr/local/man .
+.Nm
+and
+.Sy libtls
+may be packaged for your system.
+Check the Repology pages for
+.Lk https://repology.org/project/catgirl/versions catgirl
+and
+.Lk https://repology.org/project/libretls/versions libretls .
+.
 .Bd -literal -offset indent
-\&./configure
-make all
-sudo make install
+$ ./configure
+$ make all
+# make install
 .Ed
 .
 .Pp
+Packagers are encouraged
+to patch in their own text macros in
+.Pa input.c .
+.
+.Pp
 If installing
 .Sy libtls
 manually to
@@ -124,7 +163,7 @@ for
 .Nm ./configure
 to find it.
 .Bd -literal -offset indent
-PKG_CONFIG_PATH=/usr/local/lib/pkgconfig ./configure
+$ PKG_CONFIG_PATH=/usr/local/lib/pkgconfig ./configure
 .Ed
 .
 .Pp
@@ -134,14 +173,14 @@ wrapper is provided for macOS
 to stop and start
 .Nm
 on system sleep and wake.
-Install it as follows:
+To enable it,
+configure with:
 .Bd -literal -offset indent
-make scripts/sandman
-sudo make install-sandman
+$ ./configure --enable-sandman
 .Ed
 .
 .Sh FILES
-.Bl -tag -width "complete.c" -compact
+.Bl -tag -width "command.c" -compact
 .It Pa chat.h
 global state and declarations
 .It Pa chat.c
@@ -150,17 +189,23 @@ startup and event loop
 IRC connection and parsing
 .It Pa ui.c
 curses interface
+.It Pa window.c
+window management
+.It Pa input.c
+input handling
 .It Pa handle.c
 IRC message handling
 .It Pa command.c
-input command handling
+command handling
+.It Pa buffer.c
+line wrapping
 .It Pa edit.c
 line editing
 .It Pa complete.c
 tab complete
 .It Pa url.c
 URL detection
-.It Pa ignore.c
+.It Pa filter.c
 message filtering
 .It Pa log.c
 chat logging
@@ -168,15 +213,25 @@ chat logging
 configuration parsing
 .It Pa xdg.c
 XDG base directories
+.It Pa sandman.m
+sleep/wake wrapper for macOS
 .El
 .
 .Pp
 .Bl -tag -width "scripts/notify-send.scpt" -compact
-.It Pa scripts/sandman.m
-sleep/wake wrapper for macOS
+.It Pa scripts/chat.tmux.conf
+example
+.Xr tmux 1
+configuration for multiple networks
+and automatic reconnects
 .It Pa scripts/notify-send.scpt
 .Xr notify-send 1
 in AppleScript
+.It Pa scripts/build-chroot.sh
+chroot builder for
+.Ox
+and
+.Fx
 .It Pa scripts/chroot-prompt.sh
 name prompt wrapper for chroot
 .It Pa scripts/chroot-man.sh
@@ -194,19 +249,26 @@ Contributions in any form can be sent to
 .Aq Mt list+catgirl@causal.agency .
 For sending patches by email, see
 .Aq Lk https://git-send-email.io .
+Mailing list archives are available at
+.Aq Lk https://causal.agency/list/catgirl.html .
+.
+.Pp
+Monetary contributions can be
+.Lk https://liberapay.com/june/donate "donated via Liberapay" .
 .
 .Sh SEE ALSO
-.Xr catgirl 1
+.Xr catgirl 1 ,
+.Xr sandman 1
 .
 .Pp
 IRC bouncer:
 .Lk https://git.causal.agency/pounce "pounce"
 .
 .Rs
-.%A June Bug
+.%A June McEnroe
 .%T IRC Suite
 .%U https://text.causal.agency/010-irc-suite.txt
 .%D June 19, 2020
 .Re
 .
-.\" To view this file, run: man ./README.7
+.\" To view this file: $ man ./README.7
diff --git a/buffer.c b/buffer.c
new file mode 100644
index 0000000..f82e553
--- /dev/null
+++ b/buffer.c
@@ -0,0 +1,233 @@
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
+ */
+
+#include <err.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+#include <time.h>
+#include <wchar.h>
+#include <wctype.h>
+
+#include "chat.h"
+
+struct Lines {
+	size_t len;
+	struct Line lines[BufferCap];
+};
+_Static_assert(!(BufferCap & (BufferCap - 1)), "BufferCap is power of two");
+
+struct Buffer {
+	struct Lines soft;
+	struct Lines hard;
+};
+
+struct Buffer *bufferAlloc(void) {
+	struct Buffer *buffer = calloc(1, sizeof(*buffer));
+	if (!buffer) err(EX_OSERR, "calloc");
+	return buffer;
+}
+
+void bufferFree(struct Buffer *buffer) {
+	for (size_t i = 0; i < BufferCap; ++i) {
+		free(buffer->soft.lines[i].str);
+		free(buffer->hard.lines[i].str);
+	}
+	free(buffer);
+}
+
+static const struct Line *linesLine(const struct Lines *lines, size_t i) {
+	const struct Line *line = &lines->lines[(lines->len + i) % BufferCap];
+	return (line->str ? line : NULL);
+}
+
+static struct Line *linesNext(struct Lines *lines) {
+	struct Line *line = &lines->lines[lines->len++ % BufferCap];
+	free(line->str);
+	return line;
+}
+
+const struct Line *bufferSoft(const struct Buffer *buffer, size_t i) {
+	return linesLine(&buffer->soft, i);
+}
+
+const struct Line *bufferHard(const struct Buffer *buffer, size_t i) {
+	return linesLine(&buffer->hard, i);
+}
+
+enum { StyleCap = 10 };
+static char *styleCopy(char *ptr, char *end, struct Style style) {
+	ptr = seprintf(
+		ptr, end, "%s%s%s%s",
+		(style.attr & Bold ? (const char []) { B, '\0' } : ""),
+		(style.attr & Reverse ? (const char []) { R, '\0' } : ""),
+		(style.attr & Italic ? (const char []) { I, '\0' } : ""),
+		(style.attr & Underline ? (const char []) { U, '\0' } : "")
+	);
+	if (style.fg != Default || style.bg != Default) {
+		ptr = seprintf(ptr, end, "\3%02d,%02d", style.fg, style.bg);
+	}
+	return ptr;
+}
+
+static const wchar_t ZWS = L'\u200B';
+static const wchar_t ZWNJ = L'\u200C';
+
+static int flow(struct Lines *hard, int cols, const struct Line *soft) {
+	int flowed = 1;
+
+	struct Line *line = linesNext(hard);
+	line->num = soft->num;
+	line->heat = soft->heat;
+	line->time = soft->time;
+	line->str = strdup(soft->str);
+	if (!line->str) err(EX_OSERR, "strdup");
+
+	int width = 0;
+	int align = 0;
+	char *wrap = NULL;
+	struct Style style = StyleDefault;
+	struct Style wrapStyle = StyleDefault;
+	for (char *str = line->str; *str;) {
+		size_t len = styleParse(&style, (const char **)&str);
+		if (!len) continue;
+
+		bool tab = (*str == '\t' && !align);
+		if (tab) *str = ' ';
+
+		wchar_t wc = L'\0';
+		int n = mbtowc(&wc, str, len);
+		if (n < 0) {
+			n = 1;
+			// ncurses will render these as "~A".
+			width += (*str & '\200' ? 2 : 1);
+		} else if (wc == ZWS || wc == ZWNJ) {
+			// ncurses likes to render these as spaces when they should be
+			// zero-width, so just remove them entirely.
+			memmove(str, &str[n], strlen(&str[n]) + 1);
+			continue;
+		} else if (wc == L'\t') {
+			// Assuming TABSIZE = 8.
+			width += 8 - (width % 8);
+		} else if (wc < L' ' || wc == L'\177') {
+			// ncurses will render these as "^A".
+			width += 2;
+		} else if (wcwidth(wc) > 0) {
+			width += wcwidth(wc);
+		}
+
+		if (tab && width < cols) {
+			align = width;
+			wrap = NULL;
+		}
+		if (iswspace(wc) && !tab) {
+			wrap = str;
+			wrapStyle = style;
+		}
+		if (wc == L'-' && width <= cols) {
+			wrap = &str[n];
+			wrapStyle = style;
+		}
+
+		if (width <= cols) {
+			str += n;
+			continue;
+		} else if (!wrap) {
+			wrap = str;
+			wrapStyle = style;
+		}
+
+		n = 0;
+		len = strlen(wrap);
+		for (int m; wrap[n] && (m = mbtowc(&wc, &wrap[n], len - n)); n += m) {
+			if (m < 0) {
+				m = 1;
+			} else if (!iswspace(wc)) {
+				break;
+			}
+		}
+		if (!wrap[n]) {
+			*wrap = '\0';
+			break;
+		}
+
+		flowed++;
+		line = linesNext(hard);
+		line->num = soft->num;
+		line->heat = soft->heat;
+		line->time = 0;
+
+		size_t cap = StyleCap + align + strlen(&wrap[n]) + 1;
+		line->str = malloc(cap);
+		if (!line->str) err(EX_OSERR, "malloc");
+
+		char *end = &line->str[cap];
+		str = seprintf(line->str, end, "%*s", (width = align), "");
+		str = styleCopy(str, end, wrapStyle);
+		style = wrapStyle;
+		seprintf(str, end, "%s", &wrap[n]);
+
+		*wrap = '\0';
+		wrap = NULL;
+	}
+
+	return flowed;
+}
+
+int bufferPush(
+	struct Buffer *buffer, int cols, enum Heat thresh,
+	enum Heat heat, time_t time, const char *str
+) {
+	struct Line *soft = linesNext(&buffer->soft);
+	soft->num = buffer->soft.len;
+	soft->heat = heat;
+	soft->time = time;
+	soft->str = strdup(str);
+	if (!soft->str) err(EX_OSERR, "strdup");
+	if (heat < thresh) return 0;
+	return flow(&buffer->hard, cols, soft);
+}
+
+int
+bufferReflow(struct Buffer *buffer, int cols, enum Heat thresh, size_t tail) {
+	buffer->hard.len = 0;
+	for (size_t i = 0; i < BufferCap; ++i) {
+		free(buffer->hard.lines[i].str);
+		buffer->hard.lines[i].str = NULL;
+	}
+	int flowed = 0;
+	for (size_t i = 0; i < BufferCap; ++i) {
+		const struct Line *soft = bufferSoft(buffer, i);
+		if (!soft) continue;
+		if (soft->heat < thresh) continue;
+		int n = flow(&buffer->hard, cols, soft);
+		if (i >= BufferCap - tail) flowed += n;
+	}
+	return flowed;
+}
diff --git a/catgirl.1 b/catgirl.1
index 363c393..815eade 100644
--- a/catgirl.1
+++ b/catgirl.1
@@ -1,4 +1,4 @@
-.Dd July 13, 2020
+.Dd October 11, 2023
 .Dt CATGIRL 1
 .Os
 .
@@ -8,44 +8,82 @@
 .
 .Sh SYNOPSIS
 .Nm
-.Op Fl Relv
+.Op Fl Relqv
 .Op Fl C Ar copy
 .Op Fl H Ar hash
-.Op Fl N Ar send
+.Op Fl I Ar highlight
+.Op Fl N Ar notify
 .Op Fl O Ar open
 .Op Fl S Ar bind
-.Op Fl a Ar auth
+.Op Fl T Ns Op Ar timestamp
+.Op Fl a Ar plain
 .Op Fl c Ar cert
 .Op Fl h Ar host
-.Op Fl i Ar patt
+.Op Fl i Ar ignore
 .Op Fl j Ar join
 .Op Fl k Ar priv
+.Op Fl m Ar mode
 .Op Fl n Ar nick
 .Op Fl p Ar port
 .Op Fl r Ar real
 .Op Fl s Ar save
+.Op Fl t Ar trust
 .Op Fl u Ar user
 .Op Fl w Ar pass
 .Op Ar config ...
 .
 .Nm
+.Fl o
+.Op Fl S Ar bind
+.Op Fl h Ar host
+.Op Fl p Ar port
+.Op Ar config ...
+.
+.Nm
 .Fl g Ar cert
 .
 .Sh DESCRIPTION
 The
 .Nm
-program is a TLS-only
-curses IRC client.
+IRC client
+provides a curses interface
+for TLS-only
+Internet Relay Chat.
+The only required option is
+.Fl h ,
+the host name to connect to.
+See
+.Sx EXAMPLES
+for managing further configuration.
+Type
+.Ic /help
+in
+.Nm
+to view the list of
+.Sx COMMANDS
+and
+.Sx KEY BINDINGS .
 .
 .Pp
 Options can be loaded from files
 listed on the command line.
 Files are searched for in
 .Pa $XDG_CONFIG_DIRS/catgirl
+.Po
+usually
+.Pa ~/.config/catgirl
+.Pc
 unless the path starts with
-.Ql /
+.Ql / ,
+.Ql \&./
 or
-.Ql \&. .
+.Ql \&../ .
+Files and flags listed later
+on the command line
+take precedence over
+those listed earlier.
+.
+.Pp
 Each option is placed on a line,
 and lines beginning with
 .Ql #
@@ -53,89 +91,153 @@ are ignored.
 The options are listed below
 following their corresponding flags.
 .
-.Pp
-The arguments are as follows:
 .Bl -tag -width Ds
-.It Fl C Ar util , Cm copy = Ar util
+.It Fl C Ar util | Cm copy No = Ar util
 Set the utility used by
 .Ic /copy .
-Use more than once to add arguments to
+Subsequent
+.Cm copy
+options append arguments to
 .Ar util .
+The URL to copy is provided to
+.Ar util
+on standard input.
 The default is the first available of
 .Xr pbcopy 1 ,
 .Xr wl-copy 1 ,
 .Xr xclip 1 ,
 .Xr xsel 1 .
 .
-.It Fl H Ar hash , Cm hash = Ar hash
-Set the initial value of
-the nick color hash function.
+.It Fl H Ar seed,bound | Cm hash No = Ar seed,bound
+Set the initial seed
+of the nick and channel
+color hash function
+and the maximum IRC color value
+produced by the function.
+The default is 0,75.
+To use only colors from
+the 16-color terminal set,
+use 0,15.
+To disable nick and channel colors,
+use 0,0.
 .
-.It Fl N Ar util , Cm notify = Ar util
+.It Fl I Ar pattern | Cm highlight No = Ar pattern
+Add a case-insensitive message highlight pattern,
+which may contain
+.Ql * ,
+.Ql \&?
+and
+.Ql []
+wildcards as in
+.Xr glob 7 .
+The format of the pattern is as follows:
+.Bd -ragged -offset indent
+.Ar nick Ns Op Ar !user@host Op Ar command Op Ar channel Op Ar message
+.Ed
+.Pp
+The commands which can be matched are:
+.Sy INVITE ,
+.Sy JOIN ,
+.Sy NICK ,
+.Sy NOTICE ,
+.Sy PART ,
+.Sy PRIVMSG ,
+.Sy QUIT ,
+.Sy SETNAME .
+.
+.It Fl N Ar util | Cm notify No = Ar util
 Send notifications using a utility.
-Use more than once to add arguments to
+Subsequent
+.Cm notify
+options append arguments to
 .Ar util .
-Two additional arguments are passed to
-.Ar util :
-a title and a description,
+The window name and message
+are provided to
+.Ar util
+as two additional arguments,
 appropriate for
 .Xr notify-send 1 .
 .
-.It Fl O Ar util , Cm open = Ar util
+.It Fl O Ar util | Cm open No = Ar util
 Set the utility used by
 .Ic /open .
-Use more than once to add arguments to
+Subsequent
+.Cm open
+options append arguments to
 .Ar util .
+The URL to open is provided to
+.Ar util
+as an argument.
 The default is the first available of
 .Xr open 1 ,
 .Xr xdg-open 1 .
 .
-.It Fl R , Cm restrict
+.It Fl R | Cm restrict
 Disable the
 .Ic /copy ,
-.Ic /debug ,
-.Ic /exec ,
-.Ic /join ,
-.Ic /msg ,
-.Ic /open ,
-.Ic /query ,
-.Ic /quote
-commands.
-.
-.It Fl S Ar host , Cm bind = Ar host
+.Ic /exec
+and
+.Ic /open
+commands,
+the
+.Cm notify
+option,
+and viewing this manual with
+.Ic /help .
+.
+.It Fl S Ar host | Cm bind No = Ar host
 Bind to source address
 .Ar host
 when connecting to the server.
+To connect from any address
+over IPv4 only,
+use 0.0.0.0.
+To connect from any address
+over IPv6 only,
+use ::.
 .
-.It Fl a Ar user : Ns Ar pass , Cm sasl-plain = Ar user : Ns Ar pass
+.It Fl T Ns Oo Ar format Oc | Cm timestamp Op = Ar format
+Show timestamps by default,
+in the specified
+.Xr strftime 3
+.Ar format .
+The format string may contain
+raw IRC formatting codes.
+The default format is
+.Qq \&%X .
+.
+.It Fl a Ar user : Ns Ar pass | Cm sasl-plain No = Ar user : Ns Ar pass
 Authenticate as
 .Ar user
 with
 .Ar pass
 using SASL PLAIN.
-Since this requires the account password
-in plain text,
-it is recommended to use SASL EXTERNAL instead with
-.Fl e .
+Leave
+.Ar pass
+blank to prompt for the password.
 .
-.It Fl c Ar path , Cm cert = Ar path
+.It Fl c Ar path | Cm cert No = Ar path
 Load the TLS client certificate from
 .Ar path .
+The
+.Ar path
+is searched for in the same manner
+as configuration files.
 If the private key is in a separate file,
 it is loaded with
-.Fl k .
+.Cm priv .
 With
-.Fl e ,
+.Cm sasl-external ,
 authenticate using SASL EXTERNAL.
 Certificates can be generated with
 .Fl g .
 .
-.It Fl e , Cm sasl-external
+.It Fl e | Cm sasl-external
 Authenticate using SASL EXTERNAL,
 also known as CertFP.
 The TLS client certificate is loaded with
-.Fl c .
-For more information, see
+.Cm cert .
+See
 .Sx Configuring CertFP .
 .
 .It Fl g Ar path
@@ -144,81 +246,132 @@ Generate a TLS client certificate using
 and write it to
 .Ar path .
 .
-.It Fl h Ar host , Cm host = Ar host
+.It Fl h Ar host | Cm host No = Ar host
 Connect to
 .Ar host .
 .
-.It Fl i Ar pattern, Cm ignore = Ar pattern
-Add a case-insensitive message filtering pattern,
+.It Fl i Ar pattern | Cm ignore No = Ar pattern
+Add a case-insensitive message ignore pattern,
 which may contain
 .Ql * ,
 .Ql \&?
 and
 .Ql []
 wildcards as in
-.Xr sh 1 .
+.Xr glob 7 .
 The format of the pattern is as follows:
 .Bd -ragged -offset indent
-.Ar nick Ns Op Ar !user@host
-.Op Ar command
-.Op Ar channel
-.Op Ar message
+.Ar nick Ns Op Ar !user@host Op Ar command Op Ar channel Op Ar message
 .Ed
+.Pp
+The commands which can be matched are:
+.Sy INVITE ,
+.Sy JOIN ,
+.Sy NICK ,
+.Sy NOTICE ,
+.Sy PART ,
+.Sy PRIVMSG ,
+.Sy QUIT ,
+.Sy SETNAME .
 .
-.It Fl j Ar join , Cm join = Ar join
-Join the comma-separated list of channels
-.Ar join .
+.It Fl j Ar channels Oo Ar keys Oc | Cm join No = Ar channels Oo Ar keys Oc
+Join the comma-separated list of
+.Ar channels
+with the optional comma-separated list of channel
+.Ar keys .
 .
-.It Fl k Ar path , Cm priv = Ar priv
+.It Fl k Ar path | Cm priv No = Ar priv
 Load the TLS client private key from
 .Ar path .
+The
+.Ar path
+is searched for in the same manner
+as configuration files.
 .
-.It Fl l , Cm log
+.It Fl l | Cm log
 Log chat events to files in paths
 .Pa $XDG_DATA_HOME/catgirl/log/network/channel/YYYY-MM-DD.log .
 .
-.It Fl n Ar nick , Cm nick = Ar nick
+.It Fl m Ar mode | Cm mode No = Ar mode
+Set the user
+.Ar mode .
+.
+.It Fl n Ar nick Oo Ar ... Oc | Cm nick No = Ar nick Oo Ar ... Oc
 Set nickname to
 .Ar nick .
-The default nickname is the user's name.
+The default nickname is
+the value of the environment variable
+.Ev USER .
+Additional space-separated nicks
+will be tried in order
+if the first is not available,
+and all nicks
+are treated as highlight words.
+.
+.It Fl o
+Print the server certificate chain
+to standard output in PEM format
+and exit.
 .
-.It Fl p Ar port , Cm port = Ar port
+.It Fl p Ar port | Cm port No = Ar port
 Connect to
 .Ar port .
 The default port is 6697.
 .
-.It Fl r Ar real , Cm real = Ar real
+.It Fl q | Cm quiet
+Raise the default message visibility threshold
+for new windows,
+hiding general events
+(joins, quits, etc.).
+.
+.It Fl r Ar real | Cm real No = Ar real
 Set realname to
 .Ar real .
 The default realname is the same as the nickname.
 .
-.It Fl s Ar name , Cm save = Ar name
-Load and save the contents of windows from
+.It Fl s Ar name | Cm save No = Ar name
+Save and load the contents of windows from
 .Ar name
 in
 .Pa $XDG_DATA_DIRS/catgirl ,
 or an absolute or relative path if
 .Ar name
 starts with
-.Ql /
+.Ql / ,
+.Ql \&./ ,
 or
-.Ql \&. .
+.Ql \&../ .
 .
-.It Fl u Ar user , Cm user = Ar user
+.It Fl t Ar path | Cm trust No = Ar path
+Trust the self-signed certificate
+loaded from
+.Ar path
+and disable server name verification.
+The
+.Ar path
+is searched for in the same manner
+as configuration files.
+See
+.Sx Connecting to Servers with Self-signed Certificates .
+.
+.It Fl u Ar user | Cm user No = Ar user
 Set username to
 .Ar user .
 The default username is the same as the nickname.
 .
-.It Fl v , Cm debug
+.It Fl v | Cm debug
 Log raw IRC messages to the
 .Sy <debug>
 window
 as well as standard error
 if it is not a terminal.
 .
-.It Fl w Ar pass , Cm pass = Ar pass
+.It Fl w Ar pass | Cm pass No = Ar pass
 Log in with the server password
 .Ar pass .
+Leave
+.Ar pass
+blank to prompt for the password.
 .El
 .
 .Ss Configuring CertFP
@@ -226,13 +379,13 @@ Log in with the server password
 .It
 Generate a new TLS client certificate:
 .Bd -literal -offset indent
-catgirl -g ~/.config/catgirl/example.pem
+$ catgirl -g ~/.config/catgirl/example.pem
 .Ed
 .It
 Connect to the server using the certificate:
 .Bd -literal -offset indent
 cert = example.pem
-# or: catgirl -c example.pem
+# or: $ catgirl -c example.pem
 .Ed
 .It
 Identify with services or use
@@ -240,19 +393,136 @@ Identify with services or use
 then add the certificate fingerprint
 to your account:
 .Bd -literal -offset indent
-/msg NickServ CERT ADD
+/ns CERT ADD
 .Ed
 .It
 Enable SASL EXTERNAL
 to require successful authentication
-when connecting:
+when connecting
+(not possible on all networks):
 .Bd -literal -offset indent
 cert = example.pem
 sasl-external
-# or: catgirl -e -c example.pem
+# or: $ catgirl -e -c example.pem
 .Ed
 .El
 .
+.Ss Connecting to Servers with Self-signed Certificates
+.Bl -enum
+.It
+Connect to the server
+and write its certificate to a file:
+.Bd -literal -offset indent
+$ catgirl -o -h irc.example.org > ~/.config/catgirl/example.pem
+.Ed
+.It
+Configure
+.Nm
+to trust the certificate:
+.Bd -literal -offset indent
+trust = example.pem
+# or: $ catgirl -t example.pem
+.Ed
+.El
+.
+.Sh INTERFACE
+The
+.Nm
+interface is split
+into three areas.
+.
+.Ss Status Line
+The top line of the terminal
+shows window statuses.
+Only the currently active window
+and windows with activity are listed.
+The status line for a window
+might look like this:
+.Bd -literal -offset indent
+1+ #ascii.town +3 ~7 @
+.Ed
+.Pp
+The number on the left
+is the window number.
+Following it may be one of
+.Ql - ,
+.Ql + ,
+.Ql ++ ,
+as well as
+.Ql = .
+These indicate
+the message visibility threshold
+and mute status
+of the window.
+.Pp
+On the right side,
+the number following
+.Ql +
+indicates the number of unread messages.
+The number following
+.Ql ~
+indicates how many lines
+are below the scroll position.
+An
+.Ql @
+indicates that there is unsent input
+in the window's
+.Sx Input Line .
+.Pp
+.Nm
+will also set the terminal title,
+if possible,
+to the name of the network
+and active window,
+followed by the unread count
+for that window,
+and the unread count
+for all other windows
+in parentheses.
+.
+.Ss Chat Area
+The chat area shows
+messages and events.
+Regular messages are shown
+with the nick between
+.Ql <>
+angle brackets.
+Actions are shown
+with the nick preceded by
+.Ql * .
+Notices are shown
+with the nick between
+.Ql -
+hyphens.
+.Pp
+Blank lines are inserted into the chat
+as unread markers.
+.Pp
+While scrolling,
+the most recent 5 lines of chat
+are kept visible below a marker line.
+.
+.Ss Input Line
+The bottom line of the terminal
+is where messages and commands are entered.
+When entering a message, action or notice,
+your nick appears on the left,
+as it would in the
+.Sx Chat Area .
+When entering a command,
+no nick is shown.
+.Pp
+Formatting codes are shown
+in the input line
+as reverse-video uppercase letters.
+These will not appear in the sent message.
+.Pp
+Input that is too long
+to send as a single message
+will have a red background
+starting at the point where it will be split
+into a second message.
+.
 .Sh COMMANDS
 Any unique prefix can be used to abbreviate a command.
 For example,
@@ -268,8 +538,10 @@ Set or clear your away status.
 Send a command to ChanServ.
 .It Ic /invite Ar nick
 Invite a user to the channel.
-.It Ic /join Ar channel
-Join a channel.
+.It Ic /join Op Ar channel Op Ar key
+Join the named channel,
+the current channel,
+or the channel you've been invited to.
 .It Ic /list Op Ar channel
 List channels.
 .It Ic /me Op Ar action
@@ -284,6 +556,8 @@ Change nicknames.
 Send a notice.
 .It Ic /ns Ar command
 Send a command to NickServ.
+.It Ic /ops
+List channel operators.
 .It Ic /part Op Ar message
 Leave the channel.
 .It Ic /query Ar nick
@@ -292,19 +566,23 @@ Start a private conversation.
 Quit IRC.
 .It Ic /quote Ar command
 Send a raw IRC command.
-The
-.Ic /debug
-command is likely needed
-for command output.
+Use
+.Ic M--
+to show unknown replies.
 .It Ic /say Ar message
 Send a regular message.
+.It Ic /setname Ar name
+Update realname
+if supported by the server.
 .It Ic /topic Op Ar topic
 Show or set the topic of the channel.
 Press
 .Ic Tab
 twice to copy the current topic.
-.It Ic /whois Ar nick
-Query information about a user.
+.It Ic /whois Op Ar nick
+Query information about a user or yourself.
+.It Ic /whowas Ar nick
+Query past information about a user.
 .El
 .
 .Ss UI Commands
@@ -328,19 +606,36 @@ with
 and interpret its output
 as input to the current window,
 including as commands.
-.It Ic /help Op Ar search
+.It Ic /help
 View this manual.
 Type
 .Ic q
 to return to
 .Nm .
+.It Ic /help Ar topic
+List the server help for a topic.
+Try
+.Ic /help index
+for a list of topics.
+.It Ic /highlight Op Ar pattern
+List message highlight patterns
+or temporarily add a pattern.
+To permanently add a pattern,
+use the
+.Cm highlight
+option.
 .It Ic /ignore Op Ar pattern
-List message filtering patterns
+List message ignore patterns
 or temporarily add a pattern.
-To permanently add a pattern, use
-.Fl i .
+To permanently add a pattern,
+use the
+.Cm ignore
+option.
 .It Ic /move Oo Ar name Oc Ar num
-Move named window to number.
+Move the named or current window to number.
+.It Ic /o ...
+Alias of
+.Ic /open .
 .It Ic /open Op Ar count
 Open each of
 .Ar count
@@ -350,11 +645,16 @@ Open the most recent URL from
 .Ar nick
 or matching
 .Ar substring .
+.It Ic /unhighlight Ar pattern
+Temporarily remove a message highlight pattern.
 .It Ic /unignore Ar pattern
-Temporarily remove a message filtering pattern.
-.It Ic /window Ar name
-Switch to window by name.
-.It Ic /window Ar num , Ic / Ns Ar num
+Temporarily remove a message ignore pattern.
+.It Ic /window
+List all windows.
+.It Ic /window Ar name | substring
+Switch to window by name
+or matching substring.
+.It Ic /window Ar num | Ic / Ns Ar num
 Switch to window by number.
 .El
 .
@@ -442,6 +742,9 @@ Collapse all whitespace.
 .It Ic Tab
 Complete nick, channel, command or macro.
 .El
+.Pp
+Arrow and navigation keys
+also work as expected.
 .
 .Ss Window Keys
 .Bl -tag -width Ds -compact
@@ -451,10 +754,22 @@ Redraw the UI.
 Switch to next window.
 .It Ic C-p
 Switch to previous window.
+.It Ic C-r
+Scroll to previous line matching input.
+.It Ic C-s
+Scroll to next line matching input.
 .It Ic C-v
 Scroll down a page.
+.It Ic M-+
+Raise message visibility threshold,
+hiding ignored messages,
+general events
+(joins, quits, etc.),
+or non-highlighted messages.
 .It Ic M--
-Toggle visibility of filtered messages.
+Lower message visibility threshold,
+showing ignored messages
+and unknown replies.
 .It Ic M-=
 Toggle mute.
 Muted windows do not appear in the status line
@@ -465,19 +780,28 @@ Switch to previously selected window.
 Scroll to top.
 .It Ic M->
 Scroll to bottom.
+.It Ic M- Ns Ar n
+Switch to window by number 0\(en9.
 .It Ic M-a
 Cycle through unread windows.
 .It Ic M-l
 List the contents of the window
-without word-wrapping.
+without word-wrapping
+and with timestamps.
 Press
 .Ic Enter
 to return to
 .Nm .
 .It Ic M-m
 Insert a blank line in the window.
-.It Ic M- Ns Ar n
-Switch to window by number 0\(en9.
+.It Ic M-n
+Scroll to next highlight.
+.It Ic M-p
+Scroll to previous highlight.
+.It Ic M-s
+Reveal spoiler text.
+.It Ic M-t
+Toggle timestamps.
 .It Ic M-u
 Scroll to first unread line.
 .It Ic M-v
@@ -485,7 +809,9 @@ Scroll up a page.
 .El
 .
 .Ss IRC Formatting
-.Bl -tag -width Ds -compact
+.Bl -tag -width "C-z C-v" -compact
+.It Ic C-z C-v
+Insert the next input character literally.
 .It Ic C-z b
 Toggle bold.
 .It Ic C-z c
@@ -494,14 +820,30 @@ Set or reset color.
 Toggle italics.
 .It Ic C-z o
 Reset formatting.
+.It Ic C-z p
+Manually toggle paste mode.
 .It Ic C-z r
 Toggle reverse color.
+.It Ic C-z s
+Set spoiler text (black on black).
 .It Ic C-z u
 Toggle underline.
 .El
 .
 .Pp
-To set colors, follow
+Some color codes can be inserted
+with the following:
+.Bl -column "C-z A" "magenta" "C-z N" "orange (dark yellow)"
+.It Ic C-z A Ta gray Ta Ic C-z N Ta brown (dark red)
+.It Ic C-z B Ta blue Ta Ic C-z O Ta orange (dark yellow)
+.It Ic C-z C Ta cyan Ta Ic C-z P Ta pink (light magenta)
+.It Ic C-z G Ta green Ta Ic C-z R Ta red
+.It Ic C-z K Ta black Ta Ic C-z W Ta white
+.It Ic C-z M Ta magenta Ta Ic C-z Y Ta yellow
+.El
+.
+.Pp
+To set other colors, follow
 .Ic C-z c
 by one or two digits for the foreground color,
 optionally followed by a comma
@@ -527,13 +869,10 @@ The color numbers are as follows:
 .Sh ENVIRONMENT
 .Bl -tag -width Ds
 .It Ev SHELL
-The path executed by
-.Ic /exec
-with
-.Fl c Ar command .
-If unset,
-.Pa /bin/sh
-is used.
+The shell used by
+.Ic /exec .
+The default is
+.Pa /bin/sh .
 .It Ev USER
 The default nickname.
 .El
@@ -573,23 +912,26 @@ if requested by the user,
 .Dv EX_UNAVAILABLE
 (69)
 if the connection is lost,
-and >0 if an error occurs.
+and >0 if any other error occurs.
 .
 .Sh EXAMPLES
-Command line:
+Join
+.Li #ascii.town
+from the command line:
 .Bd -literal -offset indent
-catgirl -h chat.freenode.net -j '#ascii.town'
+$ catgirl -h irc.tilde.chat -j '#ascii.town'
 .Ed
 .Pp
-Configuration file:
+Create a configuration file in
+.Pa ~/.config/catgirl/tilde :
 .Bd -literal -offset indent
-host = chat.freenode.net
+host = irc.tilde.chat
 join = #ascii.town
 .Ed
 .Pp
-Filtering joins, parts and quits from a channel:
+Load the configuration file:
 .Bd -literal -offset indent
-ignore = * [JPQ][OAU][IR][NT] #example
+$ catgirl tilde
 .Ed
 .
 .Sh STANDARDS
@@ -598,121 +940,133 @@ ignore = * [JPQ][OAU][IR][NT] #example
 .Rs
 .%A Adam
 .%A Attila Molnar
-.%T IRCv3.2 invite-notify Extension
+.%T invite-notify Extension
 .%I IRCv3 Working Group
-.%U https://ircv3.net/specs/extensions/invite-notify-3.2
+.%U https://ircv3.net/specs/extensions/invite-notify
+.Re
+.It
+.Rs
+.%A Jack Allnutt
+.%A Val Lorentz
+.%A Daniel Oaks
+.%T Modern IRC Client Protocol
+.%I ircdocs
+.%U https://modern.ircdocs.horse/index.html
 .Re
 .It
 .Rs
-.%A Alexey Sokolov
-.%A St\('ephan Kochen
-.%A Kyle Fuller
 .%A Kiyoshi Aman
+.%A Kyle Fuller
+.%A St\('ephan Kochen
+.%A Alexey Sokolov
 .%A James Wheare
-.%T IRCv3 Message Tags
+.%T Message Tags
 .%I IRCv3 Working Group
 .%U https://ircv3.net/specs/extensions/message-tags
 .Re
 .It
 .Rs
-.%A C. Kalt
-.%T Internet Relay Chat: Client Protocol
-.%I IETF
-.%N RFC 2812
-.%D April 2000
-.%U https://tools.ietf.org/html/rfc2812
+.%A Kiyoshi Aman
+.%T extended-join Extension
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/extensions/extended-join
+.Re
+.It
+.Rs
+.%A Waldo Bastian
+.%A Ryan Lortie
+.%A Lennart Poettering
+.%T XDG Base Directory Specification
+.%U https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
+.%D November 24, 2010
 .Re
 .It
 .Rs
 .%A Christine Dodrill
-.%T IRCv3.2 chghost Extension
+.%A Ryan
+.%A James Wheare
+.%T chghost Extension
 .%I IRCv3 Working Group
-.%U https://ircv3.net/specs/extensions/chghost-3.2
+.%U https://ircv3.net/specs/extensions/chghost
 .Re
 .It
 .Rs
-.%A Daniel Oaks
-.%T IRC Formatting
-.%I ircdocs
-.%U https://modern.ircdocs.horse/formatting.html
+.%A Kyle Fuller
+.%A St\('ephan Kochen
+.%A Alexey Sokolov
+.%A James Wheare
+.%T server-time Extension
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/extensions/server-time
 .Re
 .It
 .Rs
-.%A J. Oikarinen
-.%A D. Reed
-.%T Internet Relay Chat Protocol
-.%I IETF
-.%N RFC 1459
-.%D May 1993
-.%U https://tools.ietf.org/html/rfc1459
+.%A Lee Hardy
+.%A Perry Lorier
+.%A Kevin L. Mitchell
+.%A Attila Molnar
+.%A Daniel Oakley
+.%A William Pitcock
+.%A James Wheare
+.%T Client Capability Negotiation
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/core/capability-negotiation
 .Re
 .It
 .Rs
-.%A Jack Allnutt
-.%T Modern IRC Client Protocol
-.%I ircdocs
-.%U https://modern.ircdocs.horse/index.html
+.%A S. Josefsson
+.%T The Base16, Base32, and Base64 Data Encodings
+.%I IETF
+.%R RFC 4648
+.%U https://tools.ietf.org/html/rfc4648
+.%D October 2006
 .Re
 .It
 .Rs
-.%A K. Zeilenga, Ed. 
-.%T The PLAIN Simple Authentication and Security Layer (SASL) Mechanism
+.%A C. Kalt
+.%T Internet Relay Chat: Client Protocol
 .%I IETF
-.%N RFC 4616
-.%D August 2006
-.%U https://tools.ietf.org/html/rfc4616
+.%R RFC 2812
+.%U https://tools.ietf.org/html/rfc2812
+.%D April 2000
 .Re
 .It
 .Rs
-.%A Kiyoshi Aman
-.%T IRCv3.1 extended-join Extension
+.%A Janne Mareike Koschinski
+.%T setname Extension
 .%I IRCv3 Working Group
-.%U https://ircv3.net/specs/extensions/extended-join-3.1
+.%U https://ircv3.net/specs/extensions/setname
 .Re
 .It
 .Rs
-.%A Kyle Fuller
-.%A St\('ephan Kochen
-.%A Alexey Sokolov
-.%A James Wheare
-.%T IRCv3.2 server-time Extension
+.%A Mantas Mikul\[u0117]nas
+.%T userhost-in-names Extension
 .%I IRCv3 Working Group
-.%U https://ircv3.net/specs/extensions/server-time-3.2
+.%U https://ircv3.net/specs/extensions/userhost-in-names
 .Re
 .It
 .Rs
-.%A Lee Hardy
-.%A Perry Lorier
-.%A Kevin L. Mitchell
-.%A William Pitcock
-.%T IRCv3.1 Client Capability Negotiation
-.%I IRCv3 Working Group
-.%U https://ircv3.net/specs/core/capability-negotiation-3.1.html
+.%A Daniel Oaks
+.%T IRC Formatting
+.%I ircdocs
+.%U https://modern.ircdocs.horse/formatting.html
 .Re
 .It
 .Rs
-.%A Mantas Mikul\[u0117]nas
-.%T IRCv3.2 userhost-in-names Extension
+.%A Daniel Oaks
+.%T Standard Replies Extension
 .%I IRCv3 Working Group
-.%U https://ircv3.net/specs/extensions/userhost-in-names-3.2
+.%U https://ircv3.net/specs/extensions/standard-replies
 .Re
 .It
 .Rs
-.%A S. Josefsson
-.%T The Base16, Base32, and Base64 Data Encodings
+.%A J. Oikarinen
+.%A D. Reed
+.%T Internet Relay Chat Protocol
 .%I IETF
-.%N RFC 4648
-.%D October 2006
-.%U https://tools.ietf.org/html/rfc4648
-.Re
-.It
-.Rs
-.%A Waldo Bastian
-.%A Ryan Lortie
-.%A Lennart Poettering
-.%T XDG Base Directory Specification
-.%D November 24, 2010
-.%U https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
+.%R RFC 1459
+.%U https://tools.ietf.org/html/rfc1459
+.%D May 1993
 .Re
 .It
 .Rs
@@ -720,31 +1074,54 @@ ignore = * [JPQ][OAU][IR][NT] #example
 .%A Jilles Tjoelker
 .%T IRCv3.1 SASL Authentication
 .%I IRCv3 Working Group
-.%U https://ircv3.net/specs/extensions/sasl-3.1.html
+.%U https://ircv3.net/specs/extensions/sasl-3.1
 .Re
 .It
 .Rs
 .%A William Pitcock
-.%T IRCv3.1 multi-prefix Extension
+.%T multi-prefix Extension
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/extensions/multi-prefix
+.Re
+.It
+.Rs
+.%A James Wheare
+.%T Message IDs
 .%I IRCv3 Working Group
-.%U https://ircv3.net/specs/extensions/multi-prefix-3.1
+.%U https://ircv3.net/specs/extensions/message-ids
+.Re
+.It
+.Rs
+.%A James Wheare
+.%T reply Client Tag
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/client-tags/reply
+.Re
+.It
+.Rs
+.%A K. Zeilenga, Ed.
+.%T The PLAIN Simple Authentication and Security Layer (SASL) Mechanism
+.%I IETF
+.%R RFC 4616
+.%U https://tools.ietf.org/html/rfc4616
+.%D August 2006
 .Re
 .El
 .
 .Ss Extensions
 The
 .Nm
-client can take advantage of the
+client implements the
 .Sy causal.agency/consumer
 vendor-specific IRCv3 capability
-implemented by
+offered by
 .Xr pounce 1 .
 The consumer position is stored in the
 .Cm save
 file.
 .
 .Sh AUTHORS
-.An June Bug Aq Mt june@causal.agency
+.An June McEnroe Aq Mt june@causal.agency
 .
 .Sh BUGS
 Send mail to
@@ -752,4 +1129,4 @@ Send mail to
 or join
 .Li #ascii.town
 on
-.Li chat.freenode.net .
+.Li irc.tilde.chat .
diff --git a/chat.c b/chat.c
index 2a16b06..6728240 100644
--- a/chat.c
+++ b/chat.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2020  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -28,6 +28,7 @@
 #include <err.h>
 #include <errno.h>
 #include <fcntl.h>
+#include <inttypes.h>
 #include <limits.h>
 #include <locale.h>
 #include <poll.h>
@@ -38,10 +39,19 @@
 #include <stdlib.h>
 #include <string.h>
 #include <sys/stat.h>
+#include <sys/time.h>
 #include <sys/wait.h>
 #include <sysexits.h>
+#include <time.h>
+#include <tls.h>
 #include <unistd.h>
 
+#ifdef __FreeBSD__
+#include <capsicum_helpers.h>
+#endif
+
+char *readpassphrase(const char *prompt, char *buf, size_t bufsiz, int flags);
+
 #include "chat.h"
 
 #ifndef OPENSSL_BIN
@@ -80,15 +90,13 @@ struct Self self = { .color = Default };
 
 static const char *save;
 static void exitSave(void) {
-	int error = uiSave(save);
+	int error = uiSave();
 	if (error) {
 		warn("%s", save);
 		_exit(EX_IOERR);
 	}
 }
 
-uint32_t hashInit;
-
 uint execID;
 int execPipe[2] = { -1, -1 };
 int utilPipe[2] = { -1, -1 };
@@ -100,7 +108,7 @@ static void execRead(void) {
 	if (!len) return;
 	buf[len] = '\0';
 	for (char *ptr = buf; ptr;) {
-		char *line = strsep(&ptr, "\n");
+		char *line = strsep(&ptr, "\r\n");
 		if (line[0]) command(execID, line);
 	}
 }
@@ -112,29 +120,123 @@ static void utilRead(void) {
 	if (!len) return;
 	buf[len] = '\0';
 	for (char *ptr = buf; ptr;) {
-		char *line = strsep(&ptr, "\n");
+		char *line = strsep(&ptr, "\r\n");
 		if (line[0]) uiFormat(Network, Warm, NULL, "%s", line);
 	}
 }
 
+uint32_t hashInit;
+uint32_t hashBound = 75;
+
+static void parseHash(char *str) {
+	hashInit = strtoul(str, &str, 0);
+	if (*str) hashBound = strtoul(&str[1], NULL, 0);
+}
+
+static void parsePlain(char *str) {
+	self.plainUser = strsep(&str, ":");
+	if (!str) errx(EX_USAGE, "SASL PLAIN missing colon");
+	self.plainPass = str;
+}
+
 static volatile sig_atomic_t signals[NSIG];
 static void signalHandler(int signal) {
 	signals[signal] = 1;
 }
 
+static void sandboxEarly(bool log);
+static void sandboxLate(int irc);
+
+#if defined __OpenBSD__
+
+static char *promisesInitial;
+static char promises[64] = "stdio tty";
+
+static void sandboxEarly(bool log) {
+	char *ptr = &promises[strlen(promises)];
+	char *end = &promises[sizeof(promises)];
+
+	if (log) {
+		char buf[PATH_MAX];
+		int error = unveil(dataPath(buf, sizeof(buf), "log", 0), "wc");
+		if (error) err(EX_OSERR, "unveil");
+		ptr = seprintf(ptr, end, " wpath cpath");
+	}
+
+	if (!self.restricted) {
+		int error = unveil("/", "x");
+		if (error) err(EX_OSERR, "unveil");
+		ptr = seprintf(ptr, end, " proc exec");
+	}
+
+	promisesInitial = ptr;
+	ptr = seprintf(ptr, end, " inet dns");
+	int error = pledge(promises, NULL);
+	if (error) err(EX_OSERR, "pledge");
+}
+
+static void sandboxLate(int irc) {
+	(void)irc;
+	*promisesInitial = '\0';
+	int error = pledge(promises, NULL);
+	if (error) err(EX_OSERR, "pledge");
+}
+
+#elif defined __FreeBSD__
+
+static void sandboxEarly(bool log) {
+	(void)log;
+}
+
+static void sandboxLate(int irc) {
+	if (!self.restricted) return;
+
+	// Rights are also limited in uiLoad() and logOpen().
+	cap_rights_t rights;
+	int error = 0
+		|| caph_limit_stdin()
+		|| caph_rights_limit(
+			STDOUT_FILENO, cap_rights_init(&rights, CAP_WRITE, CAP_IOCTL)
+		)
+		|| caph_limit_stderr()
+		|| caph_rights_limit(
+			irc, cap_rights_init(&rights, CAP_SEND, CAP_RECV, CAP_EVENT)
+		);
+	if (error) err(EX_OSERR, "cap_rights_limit");
+
+	// caph_cache_tzdata(3) doesn't load UTC info, which we need for
+	// certificate verification. gmtime(3) does.
+	caph_cache_tzdata();
+	gmtime(&(time_t) { time(NULL) });
+
+	error = cap_enter();
+	if (error) err(EX_OSERR, "cap_enter");
+}
+
+#else
+static void sandboxEarly(bool log) {
+	(void)log;
+}
+static void sandboxLate(int irc) {
+	(void)irc;
+}
+#endif
+
 int main(int argc, char *argv[]) {
 	setlocale(LC_CTYPE, "");
 
 	bool insecure = false;
+	bool printCert = false;
 	const char *bind = NULL;
 	const char *host = NULL;
 	const char *port = "6697";
+	const char *trust = NULL;
 	const char *cert = NULL;
 	const char *priv = NULL;
 
+	bool log = false;
 	bool sasl = false;
-	const char *pass = NULL;
-	const char *nick = NULL;
+	char *pass = NULL;
 	const char *user = NULL;
 	const char *real = NULL;
 
@@ -142,10 +244,12 @@ int main(int argc, char *argv[]) {
 		{ .val = '!', .name = "insecure", no_argument },
 		{ .val = 'C', .name = "copy", required_argument },
 		{ .val = 'H', .name = "hash", required_argument },
+		{ .val = 'I', .name = "highlight", required_argument },
 		{ .val = 'N', .name = "notify", required_argument },
 		{ .val = 'O', .name = "open", required_argument },
 		{ .val = 'R', .name = "restrict", no_argument },
 		{ .val = 'S', .name = "bind", required_argument },
+		{ .val = 'T', .name = "timestamp", optional_argument },
 		{ .val = 'a', .name = "sasl-plain", required_argument },
 		{ .val = 'c', .name = "cert", required_argument },
 		{ .val = 'e', .name = "sasl-external", no_argument },
@@ -155,43 +259,61 @@ int main(int argc, char *argv[]) {
 		{ .val = 'j', .name = "join", required_argument },
 		{ .val = 'k', .name = "priv", required_argument },
 		{ .val = 'l', .name = "log", no_argument },
+		{ .val = 'm', .name = "mode", required_argument },
 		{ .val = 'n', .name = "nick", required_argument },
+		{ .val = 'o', .name = "print-chain", no_argument },
 		{ .val = 'p', .name = "port", required_argument },
+		{ .val = 'q', .name = "quiet", no_argument },
 		{ .val = 'r', .name = "real", required_argument },
 		{ .val = 's', .name = "save", required_argument },
+		{ .val = 't', .name = "trust", required_argument },
 		{ .val = 'u', .name = "user", required_argument },
 		{ .val = 'v', .name = "debug", no_argument },
 		{ .val = 'w', .name = "pass", required_argument },
 		{0},
 	};
-	char opts[2 * ARRAY_LEN(options)];
+	char opts[3 * ARRAY_LEN(options)];
 	for (size_t i = 0, j = 0; i < ARRAY_LEN(options); ++i) {
 		opts[j++] = options[i].val;
-		if (options[i].has_arg) opts[j++] = ':';
+		if (options[i].has_arg != no_argument) opts[j++] = ':';
+		if (options[i].has_arg == optional_argument) opts[j++] = ':';
 	}
 
 	for (int opt; 0 < (opt = getopt_config(argc, argv, opts, options, NULL));) {
 		switch (opt) {
 			break; case '!': insecure = true;
 			break; case 'C': utilPush(&urlCopyUtil, optarg);
-			break; case 'H': hashInit = strtoul(optarg, NULL, 0);
+			break; case 'H': parseHash(optarg);
+			break; case 'I': filterAdd(Hot, optarg);
 			break; case 'N': utilPush(&uiNotifyUtil, optarg);
 			break; case 'O': utilPush(&urlOpenUtil, optarg);
 			break; case 'R': self.restricted = true;
 			break; case 'S': bind = optarg;
-			break; case 'a': sasl = true; self.plain = optarg;
+			break; case 'T': {
+				windowTime.enable = true;
+				if (optarg) windowTime.format = optarg;
+			}
+			break; case 'a': sasl = true; parsePlain(optarg);
 			break; case 'c': cert = optarg;
 			break; case 'e': sasl = true;
 			break; case 'g': genCert(optarg);
 			break; case 'h': host = optarg;
-			break; case 'i': ignoreAdd(optarg);
+			break; case 'i': filterAdd(Ice, optarg);
 			break; case 'j': self.join = optarg;
 			break; case 'k': priv = optarg;
-			break; case 'l': logEnable = true;
-			break; case 'n': nick = optarg;
+			break; case 'l': log = true; logOpen();
+			break; case 'm': self.mode = optarg;
+			break; case 'n': {
+				for (uint i = 0; i < ARRAY_LEN(self.nicks); ++i) {
+					self.nicks[i] = strsep(&optarg, " ");
+				}
+			}
+			break; case 'o': printCert = true;
 			break; case 'p': port = optarg;
+			break; case 'q': windowThreshold = Warm;
 			break; case 'r': real = optarg;
 			break; case 's': save = optarg;
+			break; case 't': trust = optarg;
 			break; case 'u': user = optarg;
 			break; case 'v': self.debug = true;
 			break; case 'w': pass = optarg;
@@ -200,10 +322,36 @@ int main(int argc, char *argv[]) {
 	}
 	if (!host) errx(EX_USAGE, "host required");
 
-	if (!nick) nick = getenv("USER");
-	if (!nick) errx(EX_CONFIG, "USER unset");
-	if (!user) user = nick;
-	if (!real) real = nick;
+	if (printCert) {
+#ifdef __OpenBSD__
+		int error = pledge("stdio inet dns", NULL);
+		if (error) err(EX_OSERR, "pledge");
+#endif
+		ircConfig(true, NULL, NULL, NULL);
+		ircConnect(bind, host, port);
+		ircPrintCert();
+		ircClose();
+		return EX_OK;
+	}
+
+	if (!self.nicks[0]) self.nicks[0] = getenv("USER");
+	if (!self.nicks[0]) errx(EX_CONFIG, "USER unset");
+	if (!user) user = self.nicks[0];
+	if (!real) real = self.nicks[0];
+
+	if (pass && !pass[0]) {
+		char *buf = malloc(512);
+		if (!buf) err(EX_OSERR, "malloc");
+		pass = readpassphrase("Server password: ", buf, 512, 0);
+		if (!pass) errx(EX_IOERR, "unable to read passphrase");
+	}
+
+	if (self.plainPass && !self.plainPass[0]) {
+		char *buf = malloc(512);
+		if (!buf) err(EX_OSERR, "malloc");
+		self.plainPass = readpassphrase("Account password: ", buf, 512, 0);
+		if (!self.plainPass) errx(EX_IOERR, "unable to read passphrase");
+	}
 
 	// Modes defined in RFC 1459:
 	set(&network.chanTypes, "#&");
@@ -217,29 +365,17 @@ int main(int argc, char *argv[]) {
 	set(&network.name, host);
 	set(&self.nick, "*");
 
-	editCompleteAdd();
-	commandCompleteAdd();
+	inputCompletion();
 
-	FILE *certFile = NULL;
-	FILE *privFile = NULL;
-	if (cert) {
-		certFile = configOpen(cert, "r");
-		if (!certFile) return EX_NOINPUT;
-	}
-	if (priv) {
-		privFile = configOpen(priv, "r");
-		if (!privFile) return EX_NOINPUT;
-	}
-	ircConfig(insecure, certFile, privFile);
-	if (certFile) fclose(certFile);
-	if (privFile) fclose(privFile);
+	ircConfig(insecure, trust, cert, priv);
 
 	uiInit();
+	sig_t cursesWinch = signal(SIGWINCH, signalHandler);
 	if (save) {
 		uiLoad(save);
 		atexit(exitSave);
 	}
-	uiShowID(Network);
+	windowShow(windowFor(Network));
 	uiFormat(
 		Network, Cold, NULL,
 		"\3%dcatgirl\3\tis GPLv3 fwee softwawe ^w^  "
@@ -248,26 +384,33 @@ int main(int argc, char *argv[]) {
 	);
 	uiFormat(Network, Cold, NULL, "Traveling...");
 	uiDraw();
-	
+
+	sandboxEarly(log);
 	int irc = ircConnect(bind, host, port);
-	if (pass) ircFormat("PASS :%s\r\n", pass);
+	sandboxLate(irc);
+
+	ircHandshake();
+	if (pass) {
+		ircFormat("PASS :");
+		ircSend(pass, strlen(pass));
+		ircFormat("\r\n");
+		explicit_bzero(pass, strlen(pass));
+	}
 	if (sasl) ircFormat("CAP REQ :sasl\r\n");
 	ircFormat("CAP LS\r\n");
-	ircFormat("NICK :%s\r\n", nick);
+	ircFormat("NICK %s\r\n", self.nicks[0]);
 	ircFormat("USER %s 0 * :%s\r\n", user, real);
 
+	// Avoid disabling VINTR until main loop.
+	inputInit();
 	signal(SIGHUP, signalHandler);
 	signal(SIGINT, signalHandler);
+	signal(SIGALRM, signalHandler);
 	signal(SIGTERM, signalHandler);
 	signal(SIGCHLD, signalHandler);
-	sig_t cursesWinch = signal(SIGWINCH, signalHandler);
 
-	fcntl(irc, F_SETFD, FD_CLOEXEC);
 	if (!self.restricted) {
-		int error = pipe(utilPipe);
-		if (error) err(EX_OSERR, "pipe");
-
-		error = pipe(execPipe);
+		int error = pipe(utilPipe) || pipe(execPipe);
 		if (error) err(EX_OSERR, "pipe");
 
 		fcntl(utilPipe[0], F_SETFD, FD_CLOEXEC);
@@ -276,6 +419,7 @@ int main(int argc, char *argv[]) {
 		fcntl(execPipe[1], F_SETFD, FD_CLOEXEC);
 	}
 
+	bool ping = false;
 	struct pollfd fds[] = {
 		{ .events = POLLIN, .fd = STDIN_FILENO },
 		{ .events = POLLIN, .fd = irc },
@@ -286,7 +430,7 @@ int main(int argc, char *argv[]) {
 		int nfds = poll(fds, (self.restricted ? 2 : ARRAY_LEN(fds)), -1);
 		if (nfds < 0 && errno != EINTR) err(EX_IOERR, "poll");
 		if (nfds > 0) {
-			if (fds[0].revents) uiRead();
+			if (fds[0].revents) inputRead();
 			if (fds[1].revents) ircRecv();
 			if (fds[2].revents) utilRead();
 			if (fds[3].revents) execRead();
@@ -295,6 +439,25 @@ int main(int argc, char *argv[]) {
 		if (signals[SIGHUP]) self.quit = "zzz";
 		if (signals[SIGINT] || signals[SIGTERM]) break;
 
+		if (nfds > 0 && fds[1].revents) {
+			ping = false;
+			struct itimerval timer = {
+				.it_value.tv_sec = 2 * 60,
+				.it_interval.tv_sec = 30,
+			};
+			int error = setitimer(ITIMER_REAL, &timer, NULL);
+			if (error) err(EX_OSERR, "setitimer");
+		}
+		if (signals[SIGALRM]) {
+			signals[SIGALRM] = 0;
+			if (ping) {
+				errx(EX_UNAVAILABLE, "ping timeout");
+			} else {
+				ircFormat("PING nyaa\r\n");
+				ping = true;
+			}
+		}
+
 		if (signals[SIGCHLD]) {
 			signals[SIGCHLD] = 0;
 			for (int status; 0 < waitpid(-1, &status, WNOHANG);) {
@@ -317,10 +480,9 @@ int main(int argc, char *argv[]) {
 		if (signals[SIGWINCH]) {
 			signals[SIGWINCH] = 0;
 			cursesWinch(SIGWINCH);
-			// XXX: For some reason, calling uiDraw() here is the only way to
-			// get uiRead() to properly receive KEY_RESIZE.
+			// doupdate(3) needs to be called for KEY_RESIZE to be picked up.
 			uiDraw();
-			uiRead();
+			inputRead();
 		}
 
 		uiDraw();
diff --git a/chat.h b/chat.h
index 6e3d20f..2a41cf6 100644
--- a/chat.h
+++ b/chat.h
@@ -1,4 +1,4 @@
-/* Copyright (C) 2020  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -26,6 +26,7 @@
  */
 
 #include <assert.h>
+#include <ctype.h>
 #include <err.h>
 #include <getopt.h>
 #include <stdarg.h>
@@ -33,6 +34,7 @@
 #include <stdint.h>
 #include <stdio.h>
 #include <string.h>
+#include <strings.h>
 #include <sysexits.h>
 #include <time.h>
 #include <wchar.h>
@@ -43,28 +45,73 @@
 typedef unsigned uint;
 typedef unsigned char byte;
 
-struct Cat {
-	char *buf;
-	size_t cap;
-	size_t len;
-};
-static inline void __attribute__((format(printf, 2, 3)))
-catf(struct Cat *cat, const char *format, ...) {
+static inline char *seprintf(char *ptr, char *end, const char *fmt, ...)
+	__attribute__((format(printf, 3, 4)));
+static inline char *seprintf(char *ptr, char *end, const char *fmt, ...) {
 	va_list ap;
-	va_start(ap, format);
-	int len = vsnprintf(&cat->buf[cat->len], cat->cap - cat->len, format, ap);
-	assert(len >= 0);
+	va_start(ap, fmt);
+	int n = vsnprintf(ptr, end - ptr, fmt, ap);
 	va_end(ap);
-	cat->len += len;
-	if (cat->len >= cat->cap) cat->len = cat->cap - 1;
+	if (n < 0) return NULL;
+	if (n > end - ptr) return end;
+	return ptr + n;
 }
 
+enum Attr {
+	BIT(Bold),
+	BIT(Reverse),
+	BIT(Italic),
+	BIT(Underline),
+};
 enum Color {
 	White, Black, Blue, Green, Red, Brown, Magenta, Orange,
 	Yellow, LightGreen, Cyan, LightCyan, LightBlue, Pink, Gray, LightGray,
 	Default = 99,
 	ColorCap,
 };
+struct Style {
+	enum Attr attr;
+	enum Color fg, bg;
+};
+
+static const struct Style StyleDefault = { 0, Default, Default };
+enum { B = '\2', C = '\3', O = '\17', R = '\26', I = '\35', U = '\37' };
+
+static inline size_t styleParse(struct Style *style, const char **str) {
+	switch (**str) {
+		break; case B: (*str)++; style->attr ^= Bold;
+		break; case O: (*str)++; *style = StyleDefault;
+		break; case R: (*str)++; style->attr ^= Reverse;
+		break; case I: (*str)++; style->attr ^= Italic;
+		break; case U: (*str)++; style->attr ^= Underline;
+		break; case C: {
+			(*str)++;
+			if (!isdigit(**str)) {
+				style->fg = Default;
+				style->bg = Default;
+				break;
+			}
+			style->fg = *(*str)++ - '0';
+			if (isdigit(**str)) style->fg = style->fg * 10 + *(*str)++ - '0';
+			if ((*str)[0] != ',' || !isdigit((*str)[1])) break;
+			(*str)++;
+			style->bg = *(*str)++ - '0';
+			if (isdigit(**str)) style->bg = style->bg * 10 + *(*str)++ - '0';
+		}
+	}
+	return strcspn(*str, (const char[]) { B, C, O, R, I, U, '\0' });
+}
+
+static inline void styleStrip(char *buf, size_t cap, const char *str) {
+	*buf = '\0';
+	char *ptr = buf, *end = &buf[cap];
+	struct Style style = StyleDefault;
+	while (*str) {
+		size_t len = styleParse(&style, &str);
+		ptr = seprintf(ptr, end, "%.*s", (int)len, str);
+		str += len;
+	}
+}
 
 enum { None, Debug, Network, IDCap = 256 };
 extern char *idNames[IDCap];
@@ -73,7 +120,7 @@ extern uint idNext;
 
 static inline uint idFind(const char *name) {
 	for (uint id = 0; id < idNext; ++id) {
-		if (!strcmp(idNames[id], name)) return id;
+		if (!strcasecmp(idNames[id], name)) return id;
 	}
 	return None;
 }
@@ -89,7 +136,8 @@ static inline uint idFor(const char *name) {
 }
 
 extern uint32_t hashInit;
-static inline enum Color hash(const char *str) {
+extern uint32_t hashBound;
+static inline uint32_t _hash(const char *str) {
 	if (*str == '~') str++;
 	uint32_t hash = hashInit;
 	for (; *str; ++str) {
@@ -97,7 +145,11 @@ static inline enum Color hash(const char *str) {
 		hash ^= *str;
 		hash *= 0x27220A95;
 	}
-	return Blue + hash % 74;
+	return hash;
+}
+static inline enum Color hash(const char *str) {
+	if (hashBound < Blue) return Default;
+	return Blue + _hash(str) % (hashBound + 1 - Blue);
 }
 
 extern struct Network {
@@ -105,6 +157,7 @@ extern struct Network {
 	uint userLen;
 	uint hostLen;
 	char *chanTypes;
+	char *statusmsg;
 	char *prefixes;
 	char *prefixModes;
 	char *listModes;
@@ -115,15 +168,31 @@ extern struct Network {
 	char invex;
 } network;
 
+static inline uint prefixBit(char p) {
+	char *s = strchr(network.prefixes, p);
+	if (!s) return 0;
+	return 1 << (s - network.prefixes);
+}
+
+static inline char bitPrefix(uint p) {
+	for (uint i = 0; network.prefixes[i]; ++i) {
+		if (p & (1 << i)) return network.prefixes[i];
+	}
+	return '\0';
+}
+
 #define ENUM_CAP \
 	X("causal.agency/consumer", CapConsumer) \
 	X("chghost", CapChghost) \
 	X("extended-join", CapExtendedJoin) \
 	X("invite-notify", CapInviteNotify) \
+	X("message-tags", CapMessageTags) \
 	X("multi-prefix", CapMultiPrefix) \
 	X("sasl", CapSASL) \
 	X("server-time", CapServerTime) \
-	X("userhost-in-names", CapUserhostInNames)
+	X("setname", CapSetname) \
+	X("userhost-in-names", CapUserhostInNames) \
+	X("znc.in/self-message", CapSelfMessage)
 
 enum Cap {
 #define X(name, id) BIT(id),
@@ -136,12 +205,16 @@ extern struct Self {
 	bool restricted;
 	size_t pos;
 	enum Cap caps;
-	char *plain;
-	char *join;
+	const char *plainUser;
+	char *plainPass;
+	const char *nicks[8];
+	const char *mode;
+	const char *join;
 	char *nick;
 	char *user;
 	char *host;
 	enum Color color;
+	char *invited;
 	char *quit;
 } self;
 
@@ -152,7 +225,9 @@ static inline void set(char **field, const char *value) {
 }
 
 #define ENUM_TAG \
+	X("+draft/reply", TagReply) \
 	X("causal.agency/pos", TagPos) \
+	X("msgid", TagMsgID) \
 	X("time", TagTime)
 
 enum Tag {
@@ -172,8 +247,12 @@ struct Message {
 	char *params[ParamCap];
 };
 
-void ircConfig(bool insecure, FILE *cert, FILE *priv);
+void ircConfig(
+	bool insecure, const char *trust, const char *cert, const char *priv
+);
 int ircConnect(const char *bind, const char *host, const char *port);
+void ircHandshake(void);
+void ircPrintCert(void);
 void ircRecv(void);
 void ircSend(const char *ptr, size_t len);
 void ircFormat(const char *format, ...)
@@ -198,80 +277,152 @@ static inline void utilPush(struct Util *util, const char *arg) {
 	}
 }
 
-extern struct Replies {
-	uint away;
-	uint ban;
-	uint excepts;
-	uint invex;
-	uint join;
-	uint list;
-	uint mode;
-	uint names;
-	uint topic;
-	uint whois;
-} replies;
+enum Reply {
+	ReplyAway = 1,
+	ReplyBan,
+	ReplyExcepts,
+	ReplyHelp,
+	ReplyInvex,
+	ReplyJoin,
+	ReplyList,
+	ReplyMode,
+	ReplyNames,
+	ReplyNamesAuto,
+	ReplyTopic,
+	ReplyTopicAuto,
+	ReplyWhois,
+	ReplyWhowas,
+	ReplyCap,
+};
+
+extern uint replies[ReplyCap];
 
 void handle(struct Message *msg);
 void command(uint id, char *input);
 const char *commandIsPrivmsg(uint id, const char *input);
 const char *commandIsNotice(uint id, const char *input);
 const char *commandIsAction(uint id, const char *input);
-void commandCompleteAdd(void);
+size_t commandWillSplit(uint id, const char *input);
+void commandCompletion(void);
 
-enum Heat { Ice, Cold, Warm, Hot };
+enum Heat {
+	Ice,
+	Cold,
+	Warm,
+	Hot,
+};
+
+enum {
+	TitleCap = 256,
+	StatusLines = 1,
+	MarkerLines = 1,
+	SplitLines = 5,
+	InputLines = 1,
+	InputCols = 1024,
+};
+extern char uiTitle[TitleCap];
+extern struct _win_st *uiStatus;
+extern struct _win_st *uiMain;
+extern struct _win_st *uiInput;
+extern bool uiSpoilerReveal;
 extern struct Util uiNotifyUtil;
 void uiInit(void);
+uint uiAttr(struct Style style);
+short uiPair(struct Style style);
 void uiShow(void);
 void uiHide(void);
 void uiDraw(void);
-void uiShowID(uint id);
-void uiShowNum(uint num);
-void uiMoveID(uint id, uint num);
-void uiCloseID(uint id);
-void uiCloseNum(uint id);
-void uiRead(void);
+void uiResize(void);
 void uiWrite(uint id, enum Heat heat, const time_t *time, const char *str);
 void uiFormat(
 	uint id, enum Heat heat, const time_t *time, const char *format, ...
 ) __attribute__((format(printf, 4, 5)));
 void uiLoad(const char *name);
-int uiSave(const char *name);
-
-enum Edit {
-	EditHead,
-	EditTail,
-	EditPrev,
-	EditNext,
-	EditPrevWord,
-	EditNextWord,
-	EditDeleteHead,
-	EditDeleteTail,
-	EditDeletePrev,
-	EditDeleteNext,
-	EditDeletePrevWord,
-	EditDeleteNextWord,
-	EditPaste,
-	EditTranspose,
-	EditCollapse,
-	EditInsert,
-	EditComplete,
-	EditExpand,
-	EditEnter,
+int uiSave(void);
+
+void inputInit(void);
+void inputWait(void);
+void inputUpdate(void);
+bool inputPending(uint id);
+void inputRead(void);
+void inputCompletion(void);
+int inputSave(FILE *file);
+void inputLoad(FILE *file, size_t version);
+
+enum Scroll {
+	ScrollOne,
+	ScrollPage,
+	ScrollAll,
+	ScrollUnread,
+	ScrollHot,
+};
+extern struct Time {
+	bool enable;
+	const char *format;
+	int width;
+} windowTime;
+extern enum Heat windowThreshold;
+void windowInit(void);
+void windowUpdate(void);
+void windowResize(void);
+bool windowWrite(uint id, enum Heat heat, const time_t *time, const char *str);
+void windowBare(void);
+uint windowID(void);
+uint windowNum(void);
+uint windowFor(uint id);
+void windowShow(uint num);
+void windowAuto(void);
+void windowSwap(void);
+void windowMove(uint from, uint to);
+void windowClose(uint num);
+void windowList(void);
+void windowMark(void);
+void windowUnmark(void);
+void windowToggleMute(void);
+void windowToggleTime(void);
+void windowToggleThresh(int n);
+bool windowTimeEnable(void);
+void windowScroll(enum Scroll by, int n);
+void windowSearch(const char *str, int dir);
+int windowSave(FILE *file);
+void windowLoad(FILE *file, size_t version);
+
+enum { BufferCap = 1024 };
+struct Buffer;
+struct Line {
+	uint num;
+	enum Heat heat;
+	time_t time;
+	char *str;
+};
+struct Buffer *bufferAlloc(void);
+void bufferFree(struct Buffer *buffer);
+const struct Line *bufferSoft(const struct Buffer *buffer, size_t i);
+const struct Line *bufferHard(const struct Buffer *buffer, size_t i);
+int bufferPush(
+	struct Buffer *buffer, int cols, enum Heat thresh,
+	enum Heat heat, time_t time, const char *str
+);
+int bufferReflow(
+	struct Buffer *buffer, int cols, enum Heat thresh, size_t tail
+);
+
+struct Cursor {
+	uint gen;
+	struct Node *node;
 };
-void edit(uint id, enum Edit op, wchar_t ch);
-char *editBuffer(size_t *pos);
-void editCompleteAdd(void);
-
-const char *complete(uint id, const char *prefix);
-void completeAccept(void);
-void completeReject(void);
-void completeAdd(uint id, const char *str, enum Color color);
-void completeTouch(uint id, const char *str, enum Color color);
-void completeReplace(uint id, const char *old, const char *new);
+void completePush(uint id, const char *str, enum Color color);
+void completePull(uint id, const char *str, enum Color color);
+void completeReplace(const char *old, const char *new);
 void completeRemove(uint id, const char *str);
-void completeClear(uint id);
-uint completeID(const char *str);
 enum Color completeColor(uint id, const char *str);
+uint *completeBits(uint id, const char *str);
+const char *completePrefix(struct Cursor *curs, uint id, const char *prefix);
+const char *completeSubstr(struct Cursor *curs, uint id, const char *substr);
+const char *completeEach(struct Cursor *curs, uint id);
+uint completeEachID(struct Cursor *curs, const char *str);
+void completeAccept(struct Cursor *curs);
+void completeReject(struct Cursor *curs);
 
 extern struct Util urlOpenUtil;
 extern struct Util urlCopyUtil;
@@ -279,24 +430,31 @@ void urlScan(uint id, const char *nick, const char *mesg);
 void urlOpenCount(uint id, uint count);
 void urlOpenMatch(uint id, const char *str);
 void urlCopyMatch(uint id, const char *str);
+int urlSave(FILE *file);
+void urlLoad(FILE *file, size_t version);
 
-enum { IgnoreCap = 256 };
-extern struct Ignore {
-	size_t len;
-	char *patterns[IgnoreCap];
-} ignore;
-const char *ignoreAdd(const char *pattern);
-bool ignoreRemove(const char *pattern);
-enum Heat ignoreCheck(enum Heat heat, uint id, const struct Message *msg);
+enum { FilterCap = 64 };
+extern struct Filter {
+	enum Heat heat;
+	char *mask;
+	char *cmd;
+	char *chan;
+	char *mesg;
+} filters[FilterCap];
+struct Filter filterParse(enum Heat heat, char *pattern);
+struct Filter filterAdd(enum Heat heat, const char *pattern);
+bool filterRemove(struct Filter filter);
+enum Heat filterCheck(enum Heat heat, uint id, const struct Message *msg);
 
-extern bool logEnable;
+void logOpen(void);
 void logFormat(uint id, const time_t *time, const char *format, ...)
 	__attribute__((format(printf, 3, 4)));
 void logClose(void);
 
+char *configPath(char *buf, size_t cap, const char *path, int i);
+char *dataPath(char *buf, size_t cap, const char *path, int i);
 FILE *configOpen(const char *path, const char *mode);
 FILE *dataOpen(const char *path, const char *mode);
-void dataMkdir(const char *path);
 
 int getopt_config(
 	int argc, char *const *argv,
diff --git a/command.c b/command.c
index 4c51433..502ff17 100644
--- a/command.c
+++ b/command.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2020  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -64,8 +64,7 @@ static void echoMessage(char *cmd, uint id, char *params) {
 	handle(&msg);
 }
 
-static void splitMessage(char *cmd, uint id, char *params) {
-	if (!params) return;
+static int splitChunk(const char *cmd, uint id) {
 	int overhead = snprintf(
 		NULL, 0, ":%s!%*s@%*s %s %s :\r\n",
 		self.nick,
@@ -74,22 +73,32 @@ static void splitMessage(char *cmd, uint id, char *params) {
 		cmd, idNames[id]
 	);
 	assert(overhead > 0 && overhead < 512);
-	int chunk = 512 - overhead;
+	return 512 - overhead;
+}
+
+static int splitLen(int chunk, const char *params) {
+	int len = 0;
+	size_t cap = 1 + strlen(params);
+	for (int n = 0; params[len] != '\n' && len + n <= chunk; len += n) {
+		n = mblen(&params[len], cap - len);
+		if (n < 0) {
+			n = 1;
+			mblen(NULL, 0);
+		}
+		if (!n) break;
+	}
+	return len;
+}
+
+static void splitMessage(char *cmd, uint id, char *params) {
+	if (!params) return;
+	int chunk = splitChunk(cmd, id);
 	if (strlen(params) <= (size_t)chunk && !strchr(params, '\n')) {
 		echoMessage(cmd, id, params);
 		return;
 	}
-
 	while (*params) {
-		int len = 0;
-		for (int n = 0; params[len] != '\n' && len + n <= chunk; len += n) {
-			n = mblen(&params[len], 1 + strlen(&params[len]));
-			if (n < 0) {
-				n = 1;
-				mblen(NULL, 0);
-			}
-			if (!n) break;
-		}
+		int len = splitLen(chunk, params);
 		char ch = params[len];
 		params[len] = '\0';
 		echoMessage(cmd, id, params);
@@ -109,25 +118,47 @@ static void commandNotice(uint id, char *params) {
 
 static void commandMe(uint id, char *params) {
 	char buf[512];
-	snprintf(buf, sizeof(buf), "\1ACTION %s\1", (params ?: ""));
-	echoMessage("PRIVMSG", id, buf);
+	if (!params) params = "";
+	int chunk = splitChunk("PRIVMSG \1ACTION\1", id);
+	if (strlen(params) <= (size_t)chunk && !strchr(params, '\n')) {
+		snprintf(buf, sizeof(buf), "\1ACTION %s\1", params);
+		echoMessage("PRIVMSG", id, buf);
+		return;
+	}
+	while (*params) {
+		int len = splitLen(chunk, params);
+		snprintf(buf, sizeof(buf), "\1ACTION %.*s\1", len, params);
+		echoMessage("PRIVMSG", id, buf);
+		params += len;
+		if (*params == '\n') params++;
+	}
 }
 
 static void commandMsg(uint id, char *params) {
-	id = idFor(strsep(&params, " "));
-	splitMessage("PRIVMSG", id, params);
+	if (!params) return;
+	char *nick = strsep(&params, " ");
+	uint msg = idFor(nick);
+	if (idColors[msg] == Default) {
+		idColors[msg] = completeColor(id, nick);
+	}
+	if (params) {
+		splitMessage("PRIVMSG", msg, params);
+	} else {
+		windowShow(windowFor(msg));
+	}
 }
 
 static void commandJoin(uint id, char *params) {
+	if (!params && id == Network) params = self.invited;
 	if (!params) params = idNames[id];
 	uint count = 1;
 	for (char *ch = params; *ch && *ch != ' '; ++ch) {
 		if (*ch == ',') count++;
 	}
 	ircFormat("JOIN %s\r\n", params);
-	replies.join += count;
-	replies.topic += count;
-	replies.names += count;
+	replies[ReplyJoin] += count;
+	replies[ReplyTopic] += count;
+	replies[ReplyNames] += count;
 }
 
 static void commandPart(uint id, char *params) {
@@ -145,8 +176,14 @@ static void commandQuit(uint id, char *params) {
 
 static void commandNick(uint id, char *params) {
 	(void)id;
-	if (!params) return;
-	ircFormat("NICK :%s\r\n", params);
+	if (params) {
+		ircFormat("NICK :%s\r\n", params);
+	} else {
+		uiFormat(
+			Network, Warm, NULL, "You are \3%02d%s",
+			self.color, self.nick
+		);
+	}
 }
 
 static void commandAway(uint id, char *params) {
@@ -156,7 +193,13 @@ static void commandAway(uint id, char *params) {
 	} else {
 		ircFormat("AWAY\r\n");
 	}
-	replies.away++;
+	replies[ReplyAway]++;
+}
+
+static void commandSetname(uint id, char *params) {
+	(void)id;
+	if (!params) return;
+	ircFormat("SETNAME :%s\r\n", params);
 }
 
 static void commandTopic(uint id, char *params) {
@@ -164,14 +207,43 @@ static void commandTopic(uint id, char *params) {
 		ircFormat("TOPIC %s :%s\r\n", idNames[id], params);
 	} else {
 		ircFormat("TOPIC %s\r\n", idNames[id]);
-		replies.topic++;
+		replies[ReplyTopic]++;
 	}
 }
 
 static void commandNames(uint id, char *params) {
 	(void)params;
 	ircFormat("NAMES %s\r\n", idNames[id]);
-	replies.names++;
+	replies[ReplyNames]++;
+}
+
+static void commandOps(uint id, char *params) {
+	(void)params;
+	char buf[1024];
+	char *ptr = buf, *end = &buf[sizeof(buf)];
+	ptr = seprintf(
+		ptr, end, "The council of \3%02d%s\3 are ",
+		idColors[id], idNames[id]
+	);
+	bool first = true;
+	struct Cursor curs = {0};
+	for (const char *nick; (nick = completeEach(&curs, id));) {
+		char prefix = bitPrefix(*completeBits(id, nick));
+		if (!prefix || prefix == '+') continue;
+		ptr = seprintf(
+			ptr, end, "%s\3%02d%c%s\3",
+			(first ? "" : ", "), completeColor(id, nick), prefix, nick
+		);
+		first = false;
+	}
+	if (first) {
+		uiFormat(
+			id, Warm, NULL, "\3%02d%s\3 is a lawless wasteland",
+			idColors[id], idNames[id]
+		);
+	} else {
+		uiWrite(id, Warm, NULL, buf);
+	}
 }
 
 static void commandInvite(uint id, char *params) {
@@ -196,14 +268,20 @@ static void commandMode(uint id, char *params) {
 			ircFormat("MODE %s %s\r\n", self.nick, params);
 		} else {
 			ircFormat("MODE %s\r\n", self.nick);
-			replies.mode++;
+			replies[ReplyMode]++;
 		}
 	} else {
 		if (params) {
+			if (!params[1] || (params[0] == '+' && !params[2])) {
+				char m = (params[0] == '+' ? params[1] : params[0]);
+				if (m == 'b') replies[ReplyBan]++;
+				if (m == network.excepts) replies[ReplyExcepts]++;
+				if (m == network.invex) replies[ReplyInvex]++;
+			}
 			ircFormat("MODE %s %s\r\n", idNames[id], params);
 		} else {
 			ircFormat("MODE %s\r\n", idNames[id]);
-			replies.mode++;
+			replies[ReplyMode]++;
 		}
 	}
 }
@@ -221,7 +299,7 @@ static void commandOp(uint id, char *params) {
 	if (params) {
 		channelListMode(id, '+', 'o', params);
 	} else {
-		ircFormat("PRIVMSG ChanServ :OP %s\r\n", idNames[id]);
+		ircFormat("CS OP %s\r\n", idNames[id]);
 	}
 }
 
@@ -233,7 +311,7 @@ static void commandVoice(uint id, char *params) {
 	if (params) {
 		channelListMode(id, '+', 'v', params);
 	} else {
-		ircFormat("PRIVMSG ChanServ :VOICE %s\r\n", idNames[id]);
+		ircFormat("CS VOICE %s\r\n", idNames[id]);
 	}
 }
 
@@ -246,7 +324,7 @@ static void commandBan(uint id, char *params) {
 		channelListMode(id, '+', 'b', params);
 	} else {
 		ircFormat("MODE %s b\r\n", idNames[id]);
-		replies.ban++;
+		replies[ReplyBan]++;
 	}
 }
 
@@ -260,7 +338,7 @@ static void commandExcept(uint id, char *params) {
 		channelListMode(id, '+', network.excepts, params);
 	} else {
 		ircFormat("MODE %s %c\r\n", idNames[id], network.excepts);
-		replies.excepts++;
+		replies[ReplyExcepts]++;
 	}
 }
 
@@ -274,7 +352,7 @@ static void commandInvex(uint id, char *params) {
 		channelListMode(id, '+', network.invex, params);
 	} else {
 		ircFormat("MODE %s %c\r\n", idNames[id], network.invex);
-		replies.invex++;
+		replies[ReplyInvex]++;
 	}
 }
 
@@ -290,40 +368,65 @@ static void commandList(uint id, char *params) {
 	} else {
 		ircFormat("LIST\r\n");
 	}
-	replies.list++;
+	replies[ReplyList]++;
 }
 
 static void commandWhois(uint id, char *params) {
 	(void)id;
+	if (!params) params = self.nick;
+	uint count = 1;
+	for (char *ch = params; *ch; ++ch) {
+		if (*ch == ',') count++;
+	}
+	ircFormat("WHOIS %s\r\n", params);
+	replies[ReplyWhois] += count;
+}
+
+static void commandWhowas(uint id, char *params) {
+	(void)id;
 	if (!params) return;
-	ircFormat("WHOIS :%s\r\n", params);
-	replies.whois++;
+	ircFormat("WHOWAS %s\r\n", params);
+	replies[ReplyWhowas]++;
 }
 
 static void commandNS(uint id, char *params) {
 	(void)id;
-	if (params) ircFormat("PRIVMSG NickServ :%s\r\n", params);
+	ircFormat("NS %s\r\n", (params ?: "HELP"));
 }
 
 static void commandCS(uint id, char *params) {
 	(void)id;
-	if (params) ircFormat("PRIVMSG ChanServ :%s\r\n", params);
+	ircFormat("CS %s\r\n", (params ?: "HELP"));
 }
 
 static void commandQuery(uint id, char *params) {
 	if (!params) return;
 	uint query = idFor(params);
-	idColors[query] = completeColor(id, params);
-	uiShowID(query);
+	if (idColors[query] == Default) {
+		idColors[query] = completeColor(id, params);
+	}
+	windowShow(windowFor(query));
 }
 
 static void commandWindow(uint id, char *params) {
-	if (!params) return;
-	if (isdigit(params[0])) {
-		uiShowNum(strtoul(params, NULL, 10));
+	if (!params) {
+		windowList();
+	} else if (isdigit(params[0])) {
+		windowShow(strtoul(params, NULL, 10));
 	} else {
 		id = idFind(params);
-		if (id) uiShowID(id);
+		if (id) {
+			windowShow(windowFor(id));
+			return;
+		}
+		struct Cursor curs = {0};
+		for (const char *str; (str = completeSubstr(&curs, None, params));) {
+			id = idFind(str);
+			if (!id) continue;
+			completeAccept(&curs);
+			windowShow(windowFor(id));
+			break;
+		}
 	}
 }
 
@@ -332,20 +435,20 @@ static void commandMove(uint id, char *params) {
 	char *name = strsep(&params, " ");
 	if (params) {
 		id = idFind(name);
-		if (id) uiMoveID(id, strtoul(params, NULL, 10));
+		if (id) windowMove(windowFor(id), strtoul(params, NULL, 10));
 	} else {
-		uiMoveID(id, strtoul(name, NULL, 10));
+		windowMove(windowFor(id), strtoul(name, NULL, 10));
 	}
 }
 
 static void commandClose(uint id, char *params) {
 	if (!params) {
-		uiCloseID(id);
+		windowClose(windowFor(id));
 	} else if (isdigit(params[0])) {
-		uiCloseNum(strtoul(params, NULL, 10));
+		windowClose(strtoul(params, NULL, 10));
 	} else {
 		id = idFind(params);
-		if (id) uiCloseID(id);
+		if (id) windowClose(windowFor(id));
 	}
 }
 
@@ -363,33 +466,50 @@ static void commandCopy(uint id, char *params) {
 	urlCopyMatch(id, params);
 }
 
-static void commandIgnore(uint id, char *params) {
+static void commandFilter(enum Heat heat, uint id, char *params) {
 	if (params) {
-		const char *pattern = ignoreAdd(params);
+		struct Filter filter = filterAdd(heat, params);
 		uiFormat(
-			id, Cold, NULL, "Ignoring \3%02d%s\3",
-			Brown, pattern
+			id, Cold, NULL, "%sing \3%02d%s %s %s %s",
+			(heat == Hot ? "Highlight" : "Ignor"), Brown, filter.mask,
+			(filter.cmd ?: ""), (filter.chan ?: ""), (filter.mesg ?: "")
 		);
 	} else {
-		for (size_t i = 0; i < ignore.len; ++i) {
+		for (size_t i = 0; i < FilterCap && filters[i].mask; ++i) {
+			if (filters[i].heat != heat) continue;
 			uiFormat(
-				Network, Warm, NULL, "Ignoring \3%02d%s\3",
-				Brown, ignore.patterns[i]
+				Network, Warm, NULL, "%sing \3%02d%s %s %s %s",
+				(heat == Hot ? "Highlight" : "Ignor"), Brown, filters[i].mask,
+				(filters[i].cmd ?: ""), (filters[i].chan ?: ""),
+				(filters[i].mesg ?: "")
 			);
 		}
 	}
 }
 
-static void commandUnignore(uint id, char *params) {
+static void commandUnfilter(enum Heat heat, uint id, char *params) {
 	if (!params) return;
-	if (ignoreRemove(params)) {
-		uiFormat(
-			id, Cold, NULL, "No longer ignoring \3%02d%s\3",
-			Brown, params
-		);
-	} else {
-		uiFormat(id, Cold, NULL, "Not ignoring \3%02d%s\3", Brown, params);
-	}
+	struct Filter filter = filterParse(heat, params);
+	bool found = filterRemove(filter);
+	uiFormat(
+		id, Cold, NULL, "%s %sing \3%02d%s %s %s %s",
+		(found ? "No longer" : "Not"), (heat == Hot ? "highlight" : "ignor"),
+		Brown, filter.mask, (filter.cmd ?: ""), (filter.chan ?: ""),
+		(filter.mesg ?: "")
+	);
+}
+
+static void commandHighlight(uint id, char *params) {
+	commandFilter(Hot, id, params);
+}
+static void commandIgnore(uint id, char *params) {
+	commandFilter(Ice, id, params);
+}
+static void commandUnhighlight(uint id, char *params) {
+	commandUnfilter(Hot, id, params);
+}
+static void commandUnignore(uint id, char *params) {
+	commandUnfilter(Ice, id, params);
 }
 
 static void commandExec(uint id, char *params) {
@@ -399,26 +519,37 @@ static void commandExec(uint id, char *params) {
 	if (pid < 0) err(EX_OSERR, "fork");
 	if (pid) return;
 
+	setsid();
 	close(STDIN_FILENO);
 	dup2(execPipe[1], STDOUT_FILENO);
 	dup2(utilPipe[1], STDERR_FILENO);
 
 	const char *shell = getenv("SHELL") ?: "/bin/sh";
-	execlp(shell, shell, "-c", params, NULL);
+	execl(shell, shell, "-c", params, NULL);
 	warn("%s", shell);
 	_exit(EX_UNAVAILABLE);
 }
 
 static void commandHelp(uint id, char *params) {
 	(void)id;
-	uiHide();
 
+	if (params) {
+		ircFormat("HELP :%s\r\n", params);
+		replies[ReplyHelp]++;
+		return;
+	}
+	if (self.restricted) {
+		uiFormat(id, Warm, NULL, "See catgirl(1) or /help index");
+		return;
+	}
+
+	uiHide();
 	pid_t pid = fork();
 	if (pid < 0) err(EX_OSERR, "fork");
 	if (pid) return;
 
 	char buf[256];
-	snprintf(buf, sizeof(buf), "ip%s$", (params ?: "COMMANDS"));
+	snprintf(buf, sizeof(buf), "%sp^COMMANDS$", (getenv("LESS") ?: ""));
 	setenv("LESS", buf, 1);
 	execlp("man", "man", "1", "catgirl", NULL);
 	dup2(utilPipe[1], STDERR_FILENO);
@@ -428,55 +559,61 @@ static void commandHelp(uint id, char *params) {
 
 enum Flag {
 	BIT(Multiline),
-	BIT(Restricted),
+	BIT(Restrict),
 };
 
 static const struct Handler {
 	const char *cmd;
 	Command *fn;
 	enum Flag flags;
+	enum Cap caps;
 } Commands[] = {
-	{ "/away", commandAway, 0 },
-	{ "/ban", commandBan, 0 },
-	{ "/close", commandClose, 0 },
-	{ "/copy", commandCopy, Restricted },
-	{ "/cs", commandCS, 0 },
-	{ "/debug", commandDebug, Restricted },
-	{ "/deop", commandDeop, 0 },
-	{ "/devoice", commandDevoice, 0 },
-	{ "/except", commandExcept, 0 },
-	{ "/exec", commandExec, Multiline | Restricted },
-	{ "/help", commandHelp, 0 },
-	{ "/ignore", commandIgnore, 0 },
-	{ "/invex", commandInvex, 0 },
-	{ "/invite", commandInvite, 0 },
-	{ "/join", commandJoin, Restricted },
-	{ "/kick", commandKick, 0 },
-	{ "/list", commandList, 0 },
-	{ "/me", commandMe, 0 },
-	{ "/mode", commandMode, 0 },
-	{ "/move", commandMove, 0 },
-	{ "/msg", commandMsg, Multiline | Restricted },
-	{ "/names", commandNames, 0 },
-	{ "/nick", commandNick, 0 },
-	{ "/notice", commandNotice, Multiline },
-	{ "/ns", commandNS, 0 },
-	{ "/o", commandOpen, Restricted },
-	{ "/op", commandOp, 0 },
-	{ "/open", commandOpen, Restricted },
-	{ "/part", commandPart, 0 },
-	{ "/query", commandQuery, Restricted },
-	{ "/quit", commandQuit, 0 },
-	{ "/quote", commandQuote, Multiline | Restricted },
-	{ "/say", commandPrivmsg, Multiline },
-	{ "/topic", commandTopic, 0 },
-	{ "/unban", commandUnban, 0 },
-	{ "/unexcept", commandUnexcept, 0 },
-	{ "/unignore", commandUnignore, 0 },
-	{ "/uninvex", commandUninvex, 0 },
-	{ "/voice", commandVoice, 0 },
-	{ "/whois", commandWhois, 0 },
-	{ "/window", commandWindow, 0 },
+	{ "/away", commandAway, 0, 0 },
+	{ "/ban", commandBan, 0, 0 },
+	{ "/close", commandClose, 0, 0 },
+	{ "/copy", commandCopy, Restrict, 0 },
+	{ "/cs", commandCS, 0, 0 },
+	{ "/debug", commandDebug, 0, 0 },
+	{ "/deop", commandDeop, 0, 0 },
+	{ "/devoice", commandDevoice, 0, 0 },
+	{ "/except", commandExcept, 0, 0 },
+	{ "/exec", commandExec, Multiline | Restrict, 0 },
+	{ "/help", commandHelp, 0, 0 }, // Restrict special case.
+	{ "/highlight", commandHighlight, 0, 0 },
+	{ "/ignore", commandIgnore, 0, 0 },
+	{ "/invex", commandInvex, 0, 0 },
+	{ "/invite", commandInvite, 0, 0 },
+	{ "/join", commandJoin, 0, 0 },
+	{ "/kick", commandKick, 0, 0 },
+	{ "/list", commandList, 0, 0 },
+	{ "/me", commandMe, Multiline, 0 },
+	{ "/mode", commandMode, 0, 0 },
+	{ "/move", commandMove, 0, 0 },
+	{ "/msg", commandMsg, Multiline, 0 },
+	{ "/names", commandNames, 0, 0 },
+	{ "/nick", commandNick, 0, 0 },
+	{ "/notice", commandNotice, Multiline, 0 },
+	{ "/ns", commandNS, 0, 0 },
+	{ "/o", commandOpen, Restrict, 0 },
+	{ "/op", commandOp, 0, 0 },
+	{ "/open", commandOpen, Restrict, 0 },
+	{ "/ops", commandOps, 0, 0 },
+	{ "/part", commandPart, 0, 0 },
+	{ "/query", commandQuery, 0, 0 },
+	{ "/quit", commandQuit, 0, 0 },
+	{ "/quote", commandQuote, Multiline, 0 },
+	{ "/say", commandPrivmsg, Multiline, 0 },
+	{ "/setname", commandSetname, 0, CapSetname },
+	{ "/topic", commandTopic, 0, 0 },
+	{ "/unban", commandUnban, 0, 0 },
+	{ "/unexcept", commandUnexcept, 0, 0 },
+	{ "/unhighlight", commandUnhighlight, 0, 0 },
+	{ "/unignore", commandUnignore, 0, 0 },
+	{ "/uninvex", commandUninvex, 0, 0 },
+	{ "/voice", commandVoice, 0, 0 },
+	{ "/whois", commandWhois, 0, 0 },
+	{ "/whowas", commandWhowas, 0, 0 },
+	{ "/window", commandWindow, 0, 0 },
 };
 
 static int compar(const void *cmd, const void *_handler) {
@@ -505,6 +642,41 @@ const char *commandIsAction(uint id, const char *input) {
 	return &input[4];
 }
 
+size_t commandWillSplit(uint id, const char *input) {
+	int chunk;
+	const char *params;
+	if (NULL != (params = commandIsPrivmsg(id, input))) {
+		chunk = splitChunk("PRIVMSG", id);
+	} else if (NULL != (params = commandIsNotice(id, input))) {
+		chunk = splitChunk("NOTICE", id);
+	} else if (NULL != (params = commandIsAction(id, input))) {
+		chunk = splitChunk("PRIVMSG \1ACTION\1", id);
+	} else if (id != Network && id != Debug && !strncmp(input, "/say ", 5)) {
+		params = &input[5];
+		chunk = splitChunk("PRIVMSG", id);
+	} else {
+		return 0;
+	}
+	if (strlen(params) <= (size_t)chunk) return 0;
+	for (
+		int split;
+		params[(split = splitLen(chunk, params))];
+		params = &params[split + 1]
+	) {
+		if (params[split] == '\n') continue;
+		return (params - input) + split;
+	}
+	return 0;
+}
+
+static bool commandAvailable(const struct Handler *handler) {
+	if (handler->flags & Restrict && self.restricted) return false;
+	if (handler->caps && (handler->caps & self.caps) != handler->caps) {
+		return false;
+	}
+	return true;
+}
+
 void command(uint id, char *input) {
 	if (id == Debug && input[0] != '/' && !self.restricted) {
 		commandQuote(id, input);
@@ -519,11 +691,11 @@ void command(uint id, char *input) {
 		return;
 	}
 
+	struct Cursor curs = {0};
 	const char *cmd = strsep(&input, " ");
-	const char *unique = complete(None, cmd);
-	if (unique && !complete(None, cmd)) {
+	const char *unique = completePrefix(&curs, None, cmd);
+	if (unique && !completePrefix(&curs, None, cmd)) {
 		cmd = unique;
-		completeReject();
 	}
 
 	const struct Handler *handler = bsearch(
@@ -533,8 +705,8 @@ void command(uint id, char *input) {
 		uiFormat(id, Warm, NULL, "No such command %s", cmd);
 		return;
 	}
-	if (self.restricted && handler->flags & Restricted) {
-		uiFormat(id, Warm, NULL, "Command %s is restricted", cmd);
+	if (!commandAvailable(handler)) {
+		uiFormat(id, Warm, NULL, "Command %s is unavailable", cmd);
 		return;
 	}
 
@@ -550,8 +722,9 @@ void command(uint id, char *input) {
 	handler->fn(id, input);
 }
 
-void commandCompleteAdd(void) {
+void commandCompletion(void) {
 	for (size_t i = 0; i < ARRAY_LEN(Commands); ++i) {
-		completeAdd(None, Commands[i].cmd, Default);
+		if (!commandAvailable(&Commands[i])) continue;
+		completePush(None, Commands[i].cmd, Default);
 	}
 }
diff --git a/compat_readpassphrase.c b/compat_readpassphrase.c
new file mode 100644
index 0000000..3bb2045
--- /dev/null
+++ b/compat_readpassphrase.c
@@ -0,0 +1,206 @@
+/* 
+ * Original: readpassphrase.c in OpenSSH portable
+ */
+/*
+ * Copyright (c) 2000-2002, 2007, 2010
+ *	Todd C. Miller <millert@openbsd.org>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ *
+ * Sponsored in part by the Defense Advanced Research Projects
+ * Agency (DARPA) and Air Force Research Laboratory, Air Force
+ * Materiel Command, USAF, under agreement number F39502-99-1-0512.
+ */
+
+#include <ctype.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <paths.h>
+#include <pwd.h>
+#include <signal.h>
+#include <string.h>
+#include <termios.h>
+#include <unistd.h>
+
+/*
+ * Macros and function required for readpassphrase(3).
+ */
+#define RPP_ECHO_OFF 0x00
+#define RPP_ECHO_ON 0x01
+#define RPP_REQUIRE_TTY 0x02
+#define RPP_FORCELOWER 0x04
+#define RPP_FORCEUPPER 0x08
+#define RPP_SEVENBIT 0x10
+#define RPP_STDIN 0x20
+char *readpassphrase(const char *, char *, size_t, int);
+
+#if !defined(_NSIG) && defined(NSIG)
+# define _NSIG NSIG
+#endif
+
+static volatile sig_atomic_t readpassphrase_signo[_NSIG];
+
+static void
+readpassphrase_handler(int s)
+{
+
+	readpassphrase_signo[s] = 1;
+}
+
+char *
+readpassphrase(const char *prompt, char *buf, size_t bufsiz, int flags)
+{
+	ssize_t nr;
+	int input, output, save_errno, i, need_restart;
+	char ch, *p, *end;
+	struct termios term, oterm;
+	struct sigaction sa, savealrm, saveint, savehup, savequit, saveterm;
+	struct sigaction savetstp, savettin, savettou, savepipe;
+/* If we don't have TCSASOFT define it so that ORing it it below is a no-op. */
+#ifndef TCSASOFT
+	const int tcasoft = 0;
+#else
+	const int tcasoft = TCSASOFT;
+#endif
+
+	/* I suppose we could alloc on demand in this case (XXX). */
+	if (bufsiz == 0) {
+		errno = EINVAL;
+		return(NULL);
+	}
+
+restart:
+	for (i = 0; i < _NSIG; i++)
+		readpassphrase_signo[i] = 0;
+	nr = -1;
+	save_errno = 0;
+	need_restart = 0;
+	/*
+	 * Read and write to /dev/tty if available.  If not, read from
+	 * stdin and write to stderr unless a tty is required.
+	 */
+	if ((flags & RPP_STDIN) ||
+	    (input = output = open(_PATH_TTY, O_RDWR)) == -1) {
+		if (flags & RPP_REQUIRE_TTY) {
+			errno = ENOTTY;
+			return(NULL);
+		}
+		input = STDIN_FILENO;
+		output = STDERR_FILENO;
+	}
+
+	/*
+	 * Turn off echo if possible.
+	 * If we are using a tty but are not the foreground pgrp this will
+	 * generate SIGTTOU, so do it *before* installing the signal handlers.
+	 */
+	if (input != STDIN_FILENO && tcgetattr(input, &oterm) == 0) {
+		memcpy(&term, &oterm, sizeof(term));
+		if (!(flags & RPP_ECHO_ON))
+			term.c_lflag &= ~(ECHO | ECHONL);
+#ifdef VSTATUS
+		if (term.c_cc[VSTATUS] != _POSIX_VDISABLE)
+			term.c_cc[VSTATUS] = _POSIX_VDISABLE;
+#endif
+		(void)tcsetattr(input, TCSAFLUSH|tcasoft, &term);
+	} else {
+		memset(&term, 0, sizeof(term));
+		term.c_lflag |= ECHO;
+		memset(&oterm, 0, sizeof(oterm));
+		oterm.c_lflag |= ECHO;
+	}
+
+	/*
+	 * Catch signals that would otherwise cause the user to end
+	 * up with echo turned off in the shell.  Don't worry about
+	 * things like SIGXCPU and SIGVTALRM for now.
+	 */
+	sigemptyset(&sa.sa_mask);
+	sa.sa_flags = 0;		/* don't restart system calls */
+	sa.sa_handler = readpassphrase_handler;
+	(void)sigaction(SIGALRM, &sa, &savealrm);
+	(void)sigaction(SIGHUP, &sa, &savehup);
+	(void)sigaction(SIGINT, &sa, &saveint);
+	(void)sigaction(SIGPIPE, &sa, &savepipe);
+	(void)sigaction(SIGQUIT, &sa, &savequit);
+	(void)sigaction(SIGTERM, &sa, &saveterm);
+	(void)sigaction(SIGTSTP, &sa, &savetstp);
+	(void)sigaction(SIGTTIN, &sa, &savettin);
+	(void)sigaction(SIGTTOU, &sa, &savettou);
+
+	if (!(flags & RPP_STDIN))
+		(void)write(output, prompt, strlen(prompt));
+	end = buf + bufsiz - 1;
+	p = buf;
+	while ((nr = read(input, &ch, 1)) == 1 && ch != '\n' && ch != '\r') {
+		if (p < end) {
+			if ((flags & RPP_SEVENBIT))
+				ch &= 0x7f;
+			if (isalpha((unsigned char)ch)) {
+				if ((flags & RPP_FORCELOWER))
+					ch = (char)tolower((unsigned char)ch);
+				if ((flags & RPP_FORCEUPPER))
+					ch = (char)toupper((unsigned char)ch);
+			}
+			*p++ = ch;
+		}
+	}
+	*p = '\0';
+	save_errno = errno;
+	if (!(term.c_lflag & ECHO))
+		(void)write(output, "\n", 1);
+
+	/* Restore old terminal settings and signals. */
+	if (memcmp(&term, &oterm, sizeof(term)) != 0) {
+		const int sigttou = readpassphrase_signo[SIGTTOU];
+
+		/* Ignore SIGTTOU generated when we are not the fg pgrp. */
+		while (tcsetattr(input, TCSAFLUSH|tcasoft, &oterm) == -1 &&
+		    errno == EINTR && !readpassphrase_signo[SIGTTOU])
+			continue;
+		readpassphrase_signo[SIGTTOU] = sigttou;
+	}
+	(void)sigaction(SIGALRM, &savealrm, NULL);
+	(void)sigaction(SIGHUP, &savehup, NULL);
+	(void)sigaction(SIGINT, &saveint, NULL);
+	(void)sigaction(SIGQUIT, &savequit, NULL);
+	(void)sigaction(SIGPIPE, &savepipe, NULL);
+	(void)sigaction(SIGTERM, &saveterm, NULL);
+	(void)sigaction(SIGTSTP, &savetstp, NULL);
+	(void)sigaction(SIGTTIN, &savettin, NULL);
+	(void)sigaction(SIGTTOU, &savettou, NULL);
+	if (input != STDIN_FILENO)
+		(void)close(input);
+
+	/*
+	 * If we were interrupted by a signal, resend it to ourselves
+	 * now that we have restored the signal handlers.
+	 */
+	for (i = 0; i < _NSIG; i++) {
+		if (readpassphrase_signo[i]) {
+			kill(getpid(), i);
+			switch (i) {
+			case SIGTSTP:
+			case SIGTTIN:
+			case SIGTTOU:
+				need_restart = 1;
+			}
+		}
+	}
+	if (need_restart)
+		goto restart;
+
+	if (save_errno)
+		errno = save_errno;
+	return(nr == -1 ? NULL : buf);
+}
diff --git a/complete.c b/complete.c
index 5835926..3552c7c 100644
--- a/complete.c
+++ b/complete.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2020  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2020, 2022  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -26,7 +26,6 @@
  */
 
 #include <err.h>
-#include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
 #include <sysexits.h>
@@ -37,25 +36,26 @@ struct Node {
 	uint id;
 	char *str;
 	enum Color color;
+	uint bits;
 	struct Node *prev;
 	struct Node *next;
 };
 
+static uint gen;
+static struct Node *head;
+static struct Node *tail;
+
 static struct Node *alloc(uint id, const char *str, enum Color color) {
-	struct Node *node = malloc(sizeof(*node));
-	if (!node) err(EX_OSERR, "malloc");
+	struct Node *node = calloc(1, sizeof(*node));
+	if (!node) err(EX_OSERR, "calloc");
 	node->id = id;
 	node->str = strdup(str);
-	node->color = color;
-	node->prev = NULL;
-	node->next = NULL;
 	if (!node->str) err(EX_OSERR, "strdup");
+	node->color = color;
+	node->bits = 0;
 	return node;
 }
 
-static struct Node *head;
-static struct Node *tail;
-
 static struct Node *detach(struct Node *node) {
 	if (node->prev) node->prev->next = node->next;
 	if (node->next) node->next->prev = node->prev;
@@ -86,67 +86,39 @@ static struct Node *append(struct Node *node) {
 
 static struct Node *find(uint id, const char *str) {
 	for (struct Node *node = head; node; node = node->next) {
-		if (node->id != id) continue;
-		if (strcmp(node->str, str)) continue;
-		return node;
+		if (node->id == id && !strcmp(node->str, str)) return node;
 	}
 	return NULL;
 }
 
-void completeAdd(uint id, const char *str, enum Color color) {
-	if (!find(id, str)) append(alloc(id, str, color));
-}
-
-void completeTouch(uint id, const char *str, enum Color color) {
-	struct Node *node = find(id, str);
-	if (node) node->color = color;
-	prepend(node ? detach(node) : alloc(id, str, color));
-}
-
-enum Color completeColor(uint id, const char *str) {
+void completePush(uint id, const char *str, enum Color color) {
 	struct Node *node = find(id, str);
-	return (node ? node->color : Default);
-}
-
-static struct Node *match;
-
-const char *complete(uint id, const char *prefix) {
-	for (match = (match ? match->next : head); match; match = match->next) {
-		if (match->id && match->id != id) continue;
-		if (strncasecmp(match->str, prefix, strlen(prefix))) continue;
-		return match->str;
+	if (node) {
+		if (color != Default) node->color = color;
+	} else {
+		append(alloc(id, str, color));
 	}
-	return NULL;
-}
-
-void completeAccept(void) {
-	if (match) prepend(detach(match));
-	match = NULL;
-}
-
-void completeReject(void) {
-	match = NULL;
 }
 
-static struct Node *iter;
-
-uint completeID(const char *str) {
-	for (iter = (iter ? iter->next : head); iter; iter = iter->next) {
-		if (iter->id && !strcmp(iter->str, str)) return iter->id;
+void completePull(uint id, const char *str, enum Color color) {
+	struct Node *node = find(id, str);
+	if (node) {
+		if (color != Default) node->color = color;
+		prepend(detach(node));
+	} else {
+		prepend(alloc(id, str, color));
 	}
-	return None;
 }
 
-void completeReplace(uint id, const char *old, const char *new) {
+void completeReplace(const char *old, const char *new) {
 	struct Node *next = NULL;
 	for (struct Node *node = head; node; node = next) {
 		next = node->next;
-		if (id && node->id != id) continue;
 		if (strcmp(node->str, old)) continue;
 		free(node->str);
 		node->str = strdup(new);
-		prepend(detach(node));
 		if (!node->str) err(EX_OSERR, "strdup");
+		prepend(detach(node));
 	}
 }
 
@@ -155,24 +127,83 @@ void completeRemove(uint id, const char *str) {
 	for (struct Node *node = head; node; node = next) {
 		next = node->next;
 		if (id && node->id != id) continue;
-		if (strcmp(node->str, str)) continue;
-		if (match == node) match = NULL;
-		if (iter == node) iter = NULL;
+		if (str && strcmp(node->str, str)) continue;
 		detach(node);
 		free(node->str);
 		free(node);
 	}
+	gen++;
 }
 
-void completeClear(uint id) {
-	struct Node *next = NULL;
-	for (struct Node *node = head; node; node = next) {
-		next = node->next;
-		if (node->id != id) continue;
-		if (match == node) match = NULL;
-		if (iter == node) iter = NULL;
-		detach(node);
-		free(node->str);
-		free(node);
+enum Color completeColor(uint id, const char *str) {
+	struct Node *node = find(id, str);
+	return (node ? node->color : Default);
+}
+
+uint *completeBits(uint id, const char *str) {
+	struct Node *node = find(id, str);
+	return (node ? &node->bits : NULL);
+}
+
+const char *completePrefix(struct Cursor *curs, uint id, const char *prefix) {
+	size_t len = strlen(prefix);
+	if (curs->gen != gen) curs->node = NULL;
+	for (
+		curs->gen = gen, curs->node = (curs->node ? curs->node->next : head);
+		curs->node;
+		curs->node = curs->node->next
+	) {
+		if (curs->node->id && curs->node->id != id) continue;
+		if (!strncasecmp(curs->node->str, prefix, len)) return curs->node->str;
+	}
+	return NULL;
+}
+
+const char *completeSubstr(struct Cursor *curs, uint id, const char *substr) {
+	if (curs->gen != gen) curs->node = NULL;
+	for (
+		curs->gen = gen, curs->node = (curs->node ? curs->node->next : head);
+		curs->node;
+		curs->node = curs->node->next
+	) {
+		if (curs->node->id && curs->node->id != id) continue;
+		if (strstr(curs->node->str, substr)) return curs->node->str;
+	}
+	return NULL;
+}
+
+const char *completeEach(struct Cursor *curs, uint id) {
+	if (curs->gen != gen) curs->node = NULL;
+	for (
+		curs->gen = gen, curs->node = (curs->node ? curs->node->next : head);
+		curs->node;
+		curs->node = curs->node->next
+	) {
+		if (curs->node->id == id) return curs->node->str;
+	}
+	return NULL;
+}
+
+uint completeEachID(struct Cursor *curs, const char *str) {
+	if (curs->gen != gen) curs->node = NULL;
+	for (
+		curs->gen = gen, curs->node = (curs->node ? curs->node->next : head);
+		curs->node;
+		curs->node = curs->node->next
+	) {
+		if (!curs->node->id) continue;
+		if (!strcmp(curs->node->str, str)) return curs->node->id;
+	}
+	return None;
+}
+
+void completeAccept(struct Cursor *curs) {
+	if (curs->gen == gen && curs->node) {
+		prepend(detach(curs->node));
 	}
+	curs->node = NULL;
+}
+
+void completeReject(struct Cursor *curs) {
+	curs->node = NULL;
 }
diff --git a/config.c b/config.c
index 121a849..be88f2f 100644
--- a/config.c
+++ b/config.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2019  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2019  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -56,21 +56,17 @@ int getopt_config(
 	const char *optstring, const struct option *longopts, int *longindex
 ) {
 	static int opt;
-	if (opt >= 0) {
-		opt = getopt_long(argc, argv, optstring, longopts, longindex);
-	}
-	if (opt >= 0) return opt;
-
 	for (;;) {
 		if (!file) {
-			if (optind < argc) {
-				num = 0;
-				path = argv[optind++];
-				file = configOpen(path, "r");
-				if (!file) return clean('?');
-			} else {
-				return clean(-1);
+			if (optind == argc) return clean(-1);
+			if (opt >= 0 && argv[optind][0] == '-') {
+				opt = getopt_long(argc, argv, optstring, longopts, longindex);
+				if (opt >= 0 || optind == argc) return clean(opt);
 			}
+			num = 0;
+			path = argv[optind++];
+			file = configOpen(path, "r");
+			if (!file) return clean('?');
 		}
 
 		for (;;) {
diff --git a/configure b/configure
index d59e7ba..07e3245 100755
--- a/configure
+++ b/configure
@@ -1,23 +1,25 @@
 #!/bin/sh
 set -eu
 
+: ${PKG_CONFIG:=pkg-config}
+
 cflags() {
 	echo "CFLAGS += $*"
 }
-ldlibs() {
-	echo "LDLIBS ${o:-}= $*"
-	o=+
-}
-config() {
-	pkg-config --print-errors "$@"
-	cflags $(pkg-config --cflags "$@")
-	ldlibs $(pkg-config --libs "$@")
-}
 defstr() {
 	cflags "-D'$1=\"$2\"'"
 }
 defvar() {
-	defstr "$1" "$(pkg-config --variable=$3 $2)${4:-}"
+	defstr "$1" "$(${PKG_CONFIG} --variable=$3 $2)${4:-}"
+}
+ldadd() {
+	lib=$1; shift
+	echo "LDADD.${lib} = $*"
+}
+config() {
+	${PKG_CONFIG} --print-errors "$@"
+	cflags $(${PKG_CONFIG} --cflags "$@")
+	for lib; do ldadd $lib $(${PKG_CONFIG} --libs $lib); done
 }
 
 exec >config.mk
@@ -25,25 +27,26 @@ exec >config.mk
 for opt; do
 	case "${opt}" in
 		(--prefix=*) echo "PREFIX = ${opt#*=}" ;;
+		(--bindir=*) echo "BINDIR = ${opt#*=}" ;;
 		(--mandir=*) echo "MANDIR = ${opt#*=}" ;;
+		(--enable-sandman) echo 'BINS += sandman' ;;
 		(*) echo "warning: unsupported option ${opt}" >&2 ;;
 	esac
 done
 
 case "$(uname)" in
 	(FreeBSD)
-		ldlibs -lncursesw
 		config libtls
-		defvar OPENSSL_BIN openssl exec_prefix /bin/openssl
+		defstr OPENSSL_BIN /usr/bin/openssl
 		;;
 	(OpenBSD)
-		ldlibs -lncursesw -ltls
 		defstr OPENSSL_BIN /usr/bin/openssl
 		;;
 	(Linux)
 		cflags -Wno-pedantic -D_GNU_SOURCE
 		config libtls ncursesw
 		defvar OPENSSL_BIN openssl exec_prefix /bin/openssl
+		echo 'OBJS += compat_readpassphrase.o'
 		;;
 	(Darwin)
 		cflags -D__STDC_WANT_LIB_EXT1__=1
diff --git a/edit.c b/edit.c
index d63678d..effb623 100644
--- a/edit.c
+++ b/edit.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2020  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2020, 2022  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -25,264 +25,290 @@
  * covered work.
  */
 
-#include <assert.h>
+#include <errno.h>
 #include <limits.h>
 #include <stdbool.h>
-#include <stdio.h>
 #include <stdlib.h>
 #include <wchar.h>
 #include <wctype.h>
 
-#include "chat.h"
+#include "edit.h"
 
-enum { Cap = 1024 };
-static wchar_t buf[Cap];
-static size_t len;
-static size_t pos;
+static bool isword(wchar_t ch) {
+	return !iswspace(ch) && !iswpunct(ch);
+}
 
-char *editBuffer(size_t *mbsPos) {
-	static char mbs[MB_LEN_MAX * Cap];
+char *editString(const struct Edit *e, char **buf, size_t *cap, size_t *pos) {
+	size_t req = e->len * MB_CUR_MAX + 1;
+	if (req > *cap) {
+		char *new = realloc(*buf, req);
+		if (!new) return NULL;
+		*buf = new;
+		*cap = req;
+	}
 
-	const wchar_t *ptr = buf;
-	size_t mbsLen = wcsnrtombs(mbs, &ptr, pos, sizeof(mbs) - 1, NULL);
-	assert(mbsLen != (size_t)-1);
-	if (mbsPos) *mbsPos = mbsLen;
+	const wchar_t *ptr = e->buf;
+	size_t len = wcsnrtombs(*buf, &ptr, e->pos, *cap-1, NULL);
+	if (len == (size_t)-1) return NULL;
+	if (pos) *pos = len;
 
-	ptr = &buf[pos];
+	ptr = &e->buf[e->pos];
 	size_t n = wcsnrtombs(
-		&mbs[mbsLen], &ptr, len - pos, sizeof(mbs) - mbsLen - 1, NULL
+		*buf + len, &ptr, e->len - e->pos, *cap-1 - len, NULL
 	);
-	assert(n != (size_t)-1);
-	mbsLen += n;
+	if (n == (size_t)-1) return NULL;
+	len += n;
 
-	mbs[mbsLen] = '\0';
-	return mbs;
+	(*buf)[len] = '\0';
+	return *buf;
 }
 
-static struct {
-	wchar_t buf[Cap];
-	size_t len;
-} cut;
-
-static bool reserve(size_t index, size_t count) {
-	if (len + count > Cap) return false;
-	memmove(&buf[index + count], &buf[index], sizeof(*buf) * (len - index));
-	len += count;
-	return true;
-}
-
-static void delete(bool copy, size_t index, size_t count) {
-	if (index + count > len) return;
-	if (copy) {
-		memcpy(cut.buf, &buf[index], sizeof(*buf) * count);
-		cut.len = count;
+int editReserve(struct Edit *e, size_t index, size_t count) {
+	if (index > e->len) {
+		errno = EINVAL;
+		return -1;
 	}
-	memmove(
-		&buf[index], &buf[index + count], sizeof(*buf) * (len - index - count)
-	);
-	len -= count;
-}
-
-static const struct {
-	const wchar_t *name;
-	const wchar_t *string;
-} Macros[] = {
-	{ L"\\banhammer", L"▬▬▬▬▬▬▬▋ Ò╭╮Ó" },
-	{ L"\\bear", L"ʕっ•ᴥ•ʔっ" },
-	{ L"\\blush", L"(˶′◡‵˶)" },
-	{ L"\\cool", L"(⌐■_■)" },
-	{ L"\\flip", L"(╯°□°)╯︵ ┻━┻" },
-	{ L"\\gary", L"ᕕ( ᐛ )ᕗ" },
-	{ L"\\hug", L"(っ・∀・)っ" },
-	{ L"\\lenny", L"( ͡° ͜ʖ ͡°)" },
-	{ L"\\look", L"ಠ_ಠ" },
-	{ L"\\shrug", L"¯\\_(ツ)_/¯" },
-	{ L"\\unflip", L"┬─┬ノ(º_ºノ)" },
-	{ L"\\wave", L"ヾ(^∇^)" },
-};
-
-void editCompleteAdd(void) {
-	char mbs[256];
-	for (size_t i = 0; i < ARRAY_LEN(Macros); ++i) {
-		size_t n = wcstombs(mbs, Macros[i].name, sizeof(mbs));
-		assert(n != (size_t)-1);
-		completeAdd(None, mbs, Default);
+	if (e->len + count > e->cap) {
+		size_t cap = (e->cap ?: 256);
+		while (cap < e->len + count) cap *= 2;
+		wchar_t *buf = realloc(e->buf, sizeof(*buf) * cap);
+		if (!buf) return -1;
+		e->buf = buf;
+		e->cap = cap;
 	}
+	wmemmove(&e->buf[index + count], &e->buf[index], e->len - index);
+	e->len += count;
+	return 0;
 }
 
-static void macroExpand(void) {
-	size_t macro = pos;
-	while (macro && !iswspace(buf[macro - 1])) macro--;
-	if (macro == pos) return;
-	for (size_t i = 0; i < ARRAY_LEN(Macros); ++i) {
-		if (wcsncmp(Macros[i].name, &buf[macro], pos - macro)) continue;
-		delete(false, macro, pos - macro);
-		pos = macro;
-		size_t expand = wcslen(Macros[i].string);
-		if (reserve(macro, expand)) {
-			wcsncpy(&buf[macro], Macros[i].string, expand);
-			pos += expand;
-		}
+int editCopy(struct Edit *e, size_t index, size_t count) {
+	if (index + count > e->len) {
+		errno = EINVAL;
+		return -1;
 	}
+	if (!e->cut) return 0;
+	e->cut->len = 0;
+	if (editReserve(e->cut, 0, count) < 0) return -1;
+	wmemcpy(e->cut->buf, &e->buf[index], count);
+	return 0;
 }
 
-static struct {
-	size_t pos;
-	size_t pre;
-	size_t len;
-} tab;
-
-static void tabComplete(uint id) {
-	if (!tab.len) {
-		tab.pos = pos;
-		while (tab.pos && !iswspace(buf[tab.pos - 1])) tab.pos--;
-		if (tab.pos == pos) return;
-		tab.pre = pos - tab.pos;
-		tab.len = tab.pre;
-	}
-
-	char mbs[MB_LEN_MAX * Cap];
-	const wchar_t *ptr = &buf[tab.pos];
-	size_t n = wcsnrtombs(mbs, &ptr, tab.pre, sizeof(mbs) - 1, NULL);
-	assert(n != (size_t)-1);
-	mbs[n] = '\0';
-
-	const char *comp = complete(id, mbs);
-	if (!comp) comp = complete(id, mbs);
-	if (!comp) {
-		tab.len = 0;
-		return;
+int editDelete(struct Edit *e, bool cut, size_t index, size_t count) {
+	if (index + count > e->len) {
+		errno = EINVAL;
+		return -1;
 	}
-
-	wchar_t wcs[Cap];
-	n = mbstowcs(wcs, comp, Cap);
-	assert(n != (size_t)-1);
-	if (tab.pos + n + 2 > Cap) {
-		completeReject();
-		tab.len = 0;
-		return;
-	}
-
-	delete(false, tab.pos, tab.len);
-	tab.len = n;
-	if (wcs[0] == L'\\' || wcschr(wcs, L' ')) {
-		reserve(tab.pos, tab.len);
-	} else if (wcs[0] != L'/' && !tab.pos) {
-		tab.len += 2;
-		reserve(tab.pos, tab.len);
-		buf[tab.pos + n + 0] = L':';
-		buf[tab.pos + n + 1] = L' ';
-	} else if (tab.pos >= 2 && buf[tab.pos - 2] == L':') {
-		tab.len += 2;
-		reserve(tab.pos, tab.len);
-		buf[tab.pos - 2] = L',';
-		buf[tab.pos + n + 0] = L':';
-		buf[tab.pos + n + 1] = L' ';
-	} else {
-		tab.len++;
-		reserve(tab.pos, tab.len);
-		buf[tab.pos + n] = L' ';
-	}
-	memcpy(&buf[tab.pos], wcs, sizeof(*wcs) * n);
-	pos = tab.pos + tab.len;
+	if (cut && editCopy(e, index, count) < 0) return -1;
+	wmemmove(&e->buf[index], &e->buf[index + count], e->len - index - count);
+	e->len -= count;
+	if (e->pos > e->len) e->pos = e->len;
+	return 0;
 }
 
-static void tabAccept(void) {
-	completeAccept();
-	tab.len = 0;
+static size_t prevSpacing(const struct Edit *e, size_t pos) {
+	if (!pos) return 0;
+	do {
+		pos--;
+	} while (pos && !wcwidth(e->buf[pos]));
+	return pos;
 }
 
-static void tabReject(void) {
-	completeReject();
-	tab.len = 0;
+static size_t nextSpacing(const struct Edit *e, size_t pos) {
+	if (pos == e->len) return e->len;
+	do {
+		pos++;
+	} while (pos < e->len && !wcwidth(e->buf[pos]));
+	return pos;
 }
 
-void edit(uint id, enum Edit op, wchar_t ch) {
-	size_t init = pos;
-	switch (op) {
-		break; case EditHead: pos = 0;
-		break; case EditTail: pos = len;
-		break; case EditPrev: if (pos) pos--;
-		break; case EditNext: if (pos < len) pos++;
+int editFn(struct Edit *e, enum EditFn fn) {
+	int ret = 0;
+	switch (fn) {
+		break; case EditHead: e->pos = 0;
+		break; case EditTail: e->pos = e->len;
+		break; case EditPrev: e->pos = prevSpacing(e, e->pos);
+		break; case EditNext: e->pos = nextSpacing(e, e->pos);
 		break; case EditPrevWord: {
-			if (pos) pos--;
-			while (pos && !iswspace(buf[pos - 1])) pos--;
+			while (e->pos && !isword(e->buf[e->pos-1])) e->pos--;
+			while (e->pos && isword(e->buf[e->pos-1])) e->pos--;
 		}
 		break; case EditNextWord: {
-			if (pos < len) pos++;
-			while (pos < len && !iswspace(buf[pos])) pos++;
+			while (e->pos < e->len && isword(e->buf[e->pos])) e->pos++;
+			while (e->pos < e->len && !isword(e->buf[e->pos])) e->pos++;
 		}
 
-		break; case EditDeleteHead: delete(true, 0, pos); pos = 0;
-		break; case EditDeleteTail: delete(true, pos, len - pos);
-		break; case EditDeletePrev: if (pos) delete(false, --pos, 1);
-		break; case EditDeleteNext: delete(false, pos, 1);
+		break; case EditDeleteHead: {
+			ret = editDelete(e, true, 0, e->pos);
+			e->pos = 0;
+		}
+		break; case EditDeleteTail: {
+			ret = editDelete(e, true, e->pos, e->len - e->pos);
+		}
+		break; case EditDeletePrev: {
+			size_t prev = prevSpacing(e, e->pos);
+			editDelete(e, false, prev, e->pos - prev);
+			e->pos = prev;
+		}
+		break; case EditDeleteNext: {
+			editDelete(e, false, e->pos, nextSpacing(e, e->pos) - e->pos);
+		}
 		break; case EditDeletePrevWord: {
-			if (!pos) break;
-			size_t word = pos - 1;
-			while (word && !iswspace(buf[word - 1])) word--;
-			delete(true, word, pos - word);
-			pos = word;
+			if (!e->pos) break;
+			size_t word = e->pos;
+			while (word && !isword(e->buf[word-1])) word--;
+			while (word && isword(e->buf[word-1])) word--;
+			ret = editDelete(e, true, word, e->pos - word);
+			e->pos = word;
 		}
 		break; case EditDeleteNextWord: {
-			if (pos == len) break;
-			size_t word = pos + 1;
-			while (word < len && !iswspace(buf[word])) word++;
-			delete(true, pos, word - pos);
+			if (e->pos == e->len) break;
+			size_t word = e->pos;
+			while (word < e->len && !isword(e->buf[word])) word++;
+			while (word < e->len && isword(e->buf[word])) word++;
+			ret = editDelete(e, true, e->pos, word - e->pos);
 		}
+
 		break; case EditPaste: {
-			if (reserve(pos, cut.len)) {
-				memcpy(&buf[pos], cut.buf, sizeof(*buf) * cut.len);
-				pos += cut.len;
+			if (!e->cut) break;
+			ret = editReserve(e, e->pos, e->cut->len);
+			if (ret == 0) {
+				wmemcpy(&e->buf[e->pos], e->cut->buf, e->cut->len);
+				e->pos += e->cut->len;
 			}
 		}
-
 		break; case EditTranspose: {
-			if (!pos || len < 2) break;
-			if (pos == len) pos--;
-			wchar_t t = buf[pos - 1];
-			buf[pos - 1] = buf[pos];
-			buf[pos++] = t;
+			if (e->len < 2) break;
+			if (!e->pos) e->pos++;
+			if (e->pos == e->len) e->pos--;
+			wchar_t x = e->buf[e->pos-1];
+			e->buf[e->pos-1] = e->buf[e->pos];
+			e->buf[e->pos++] = x;
 		}
 		break; case EditCollapse: {
 			size_t ws;
-			for (pos = 0; pos < len;) {
-				for (; pos < len && !iswspace(buf[pos]); ++pos);
-				for (ws = pos; ws < len && iswspace(buf[ws]); ++ws);
-				if (pos && ws < len) {
-					delete(false, pos, ws - pos - 1);
-					buf[pos++] = L' ';
+			for (e->pos = 0; e->pos < e->len;) {
+				for (; e->pos < e->len && !iswspace(e->buf[e->pos]); ++e->pos);
+				for (ws = e->pos; ws < e->len && iswspace(e->buf[ws]); ++ws);
+				if (e->pos && ws < e->len) {
+					editDelete(e, false, e->pos, ws - e->pos - 1);
+					e->buf[e->pos++] = L' ';
 				} else {
-					delete(false, pos, ws - pos);
+					editDelete(e, false, e->pos, ws - e->pos);
 				}
 			}
 		}
 
-		break; case EditInsert: {
-			if (reserve(pos, 1)) {
-				buf[pos++] = ch;
-			}
-		}
-		break; case EditComplete: {
-			tabComplete(id);
-			return;
-		}
-		break; case EditExpand: {
-			macroExpand();
-			tabAccept();
-			return;
-		}
-		break; case EditEnter: {
-			tabAccept();
-			command(id, editBuffer(NULL));
-			len = pos = 0;
-			return;
-		}
+		break; case EditClear: e->len = e->pos = 0;
 	}
+	return ret;
+}
 
-	if (pos < init) {
-		tabReject();
-	} else {
-		tabAccept();
+int editInsert(struct Edit *e, wchar_t ch) {
+	char mb[MB_LEN_MAX];
+	if (wctomb(mb, ch) < 0) return -1;
+	if (editReserve(e, e->pos, 1) < 0) return -1;
+	e->buf[e->pos++] = ch;
+	return 0;
+}
+
+#ifdef TEST
+#undef NDEBUG
+#include <assert.h>
+#include <string.h>
+
+static void fix(struct Edit *e, const char *str) {
+	assert(0 == editFn(e, EditClear));
+	for (const char *ch = str; *ch; ++ch) {
+		assert(0 == editInsert(e, (wchar_t)*ch));
 	}
 }
+
+static bool eq(struct Edit *e, const char *str1) {
+	size_t pos;
+	static size_t cap;
+	static char *buf;
+	assert(NULL != editString(e, &buf, &cap, &pos));
+	const char *str2 = &str1[strlen(str1) + 1];
+	return pos == strlen(str1)
+		&& !strncmp(buf, str1, pos)
+		&& !strcmp(&buf[pos], str2);
+}
+
+#define editFn(...) assert(0 == editFn(__VA_ARGS__))
+
+int main(void) {
+	struct Edit cut = {0};
+	struct Edit e = { .cut = &cut };
+
+	fix(&e, "foo bar");
+	editFn(&e, EditHead);
+	assert(eq(&e, "\0foo bar"));
+	editFn(&e, EditTail);
+	assert(eq(&e, "foo bar\0"));
+	editFn(&e, EditPrev);
+	assert(eq(&e, "foo ba\0r"));
+	editFn(&e, EditNext);
+	assert(eq(&e, "foo bar\0"));
+
+	fix(&e, "foo, bar");
+	editFn(&e, EditPrevWord);
+	assert(eq(&e, "foo, \0bar"));
+	editFn(&e, EditPrevWord);
+	assert(eq(&e, "\0foo, bar"));
+	editFn(&e, EditNextWord);
+	assert(eq(&e, "foo, \0bar"));
+	editFn(&e, EditNextWord);
+	assert(eq(&e, "foo, bar\0"));
+
+	fix(&e, "foo bar");
+	editFn(&e, EditPrevWord);
+	editFn(&e, EditDeleteHead);
+	assert(eq(&e, "\0bar"));
+
+	fix(&e, "foo bar");
+	editFn(&e, EditPrevWord);
+	editFn(&e, EditDeleteTail);
+	assert(eq(&e, "foo \0"));
+
+	fix(&e, "foo bar");
+	editFn(&e, EditDeletePrev);
+	assert(eq(&e, "foo ba\0"));
+	editFn(&e, EditHead);
+	editFn(&e, EditDeleteNext);
+	assert(eq(&e, "\0oo ba"));
+
+	fix(&e, "foo, bar");
+	editFn(&e, EditDeletePrevWord);
+	assert(eq(&e, "foo, \0"));
+	editFn(&e, EditDeletePrevWord);
+	assert(eq(&e, "\0"));
+
+	fix(&e, "foo, bar");
+	editFn(&e, EditHead);
+	editFn(&e, EditDeleteNextWord);
+	assert(eq(&e, "\0, bar"));
+	editFn(&e, EditDeleteNextWord);
+	assert(eq(&e, "\0"));
+
+	fix(&e, "foo bar");
+	editFn(&e, EditDeletePrevWord);
+	editFn(&e, EditPaste);
+	assert(eq(&e, "foo bar\0"));
+	editFn(&e, EditPaste);
+	assert(eq(&e, "foo barbar\0"));
+
+	fix(&e, "bar");
+	editFn(&e, EditTranspose);
+	assert(eq(&e, "bra\0"));
+	editFn(&e, EditHead);
+	editFn(&e, EditTranspose);
+	assert(eq(&e, "rb\0a"));
+	editFn(&e, EditTranspose);
+	assert(eq(&e, "rab\0"));
+
+	fix(&e, "  foo  bar  ");
+	editFn(&e, EditCollapse);
+	assert(eq(&e, "foo bar\0"));
+}
+
+#endif /* TEST */
diff --git a/edit.h b/edit.h
new file mode 100644
index 0000000..41966b8
--- /dev/null
+++ b/edit.h
@@ -0,0 +1,74 @@
+/* Copyright (C) 2022  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
+ */
+
+#include <stdbool.h>
+#include <stddef.h>
+
+struct Edit {
+	wchar_t *buf;
+	size_t pos;
+	size_t len;
+	size_t cap;
+	struct Edit *cut;
+};
+
+enum EditFn {
+	EditHead,
+	EditTail,
+	EditPrev,
+	EditNext,
+	EditPrevWord,
+	EditNextWord,
+	EditDeleteHead,
+	EditDeleteTail,
+	EditDeletePrev,
+	EditDeleteNext,
+	EditDeletePrevWord,
+	EditDeleteNextWord,
+	EditPaste,
+	EditTranspose,
+	EditCollapse,
+	EditClear,
+};
+
+// Perform an editing function.
+int editFn(struct Edit *e, enum EditFn fn);
+
+// Insert a character at the cursor.
+int editInsert(struct Edit *e, wchar_t ch);
+
+// Convert the buffer to a multi-byte string.
+char *editString(const struct Edit *e, char **buf, size_t *cap, size_t *pos);
+
+// Reserve a range in the buffer.
+int editReserve(struct Edit *e, size_t index, size_t count);
+
+// Copy a range of the buffer into e->cut.
+int editCopy(struct Edit *e, size_t index, size_t count);
+
+// Delete a range from the buffer. If cut is true, copy the deleted portion.
+int editDelete(struct Edit *e, bool cut, size_t index, size_t count);
diff --git a/filter.c b/filter.c
new file mode 100644
index 0000000..a7f9a29
--- /dev/null
+++ b/filter.c
@@ -0,0 +1,132 @@
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
+ */
+
+#include <err.h>
+#include <fnmatch.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+
+#include "chat.h"
+
+struct Filter filters[FilterCap];
+static size_t len;
+
+struct Filter filterParse(enum Heat heat, char *pattern) {
+	struct Filter filter = { .heat = heat };
+	filter.mask = strsep(&pattern, " ");
+	filter.cmd  = strsep(&pattern, " ");
+	filter.chan = strsep(&pattern, " ");
+	filter.mesg = pattern;
+	return filter;
+}
+
+struct Filter filterAdd(enum Heat heat, const char *pattern) {
+	if (len == FilterCap) errx(EX_CONFIG, "filter limit exceeded");
+	char *own;
+	if (!strchr(pattern, '!') && !strchr(pattern, ' ')) {
+		int n = asprintf(&own, "%s!*@*", pattern);
+		if (n < 0) err(EX_OSERR, "asprintf");
+	} else {
+		own = strdup(pattern);
+		if (!own) err(EX_OSERR, "strdup");
+	}
+	struct Filter filter = filterParse(heat, own);
+	filters[len++] = filter;
+	return filter;
+}
+
+bool filterRemove(struct Filter filter) {
+	bool found = false;
+	for (size_t i = len - 1; i < len; --i) {
+		if (filters[i].heat != filter.heat) continue;
+		if (!filters[i].cmd != !filter.cmd) continue;
+		if (!filters[i].chan != !filter.chan) continue;
+		if (!filters[i].mesg != !filter.mesg) continue;
+		if (strcasecmp(filters[i].mask, filter.mask)) continue;
+		if (filter.cmd && strcasecmp(filters[i].cmd, filter.cmd)) continue;
+		if (filter.chan && strcasecmp(filters[i].chan, filter.chan)) continue;
+		if (filter.mesg && strcasecmp(filters[i].mesg, filter.mesg)) continue;
+		free(filters[i].mask);
+		memmove(&filters[i], &filters[i + 1], sizeof(*filters) * --len);
+		filters[len] = (struct Filter) {0};
+		found = true;
+	}
+	return found;
+}
+
+static bool filterTest(
+	struct Filter filter, const char *mask, uint id, const struct Message *msg
+) {
+	if (fnmatch(filter.mask, mask, FNM_CASEFOLD)) return false;
+	if (!filter.cmd) return true;
+	if (fnmatch(filter.cmd, msg->cmd, FNM_CASEFOLD)) return false;
+	if (!filter.chan) return true;
+	if (fnmatch(filter.chan, idNames[id], FNM_CASEFOLD)) return false;
+	if (!filter.mesg) return true;
+	if (!msg->params[1]) return false;
+	return !fnmatch(filter.mesg, msg->params[1], FNM_CASEFOLD);
+}
+
+enum { IcedCap = 8 };
+static struct {
+	size_t len;
+	char *msgIDs[IcedCap];
+} iced;
+
+static void icedPush(const char *msgID) {
+	if (!msgID) return;
+	size_t i = iced.len % IcedCap;
+	free(iced.msgIDs[i]);
+	iced.msgIDs[i] = strdup(msgID);
+	if (!iced.msgIDs[i]) err(EX_OSERR, "strdup");
+	iced.len++;
+}
+
+enum Heat filterCheck(enum Heat heat, uint id, const struct Message *msg) {
+	if (!len) return heat;
+
+	if (msg->tags[TagReply]) {
+		for (size_t i = 0; i < IcedCap; ++i) {
+			if (!iced.msgIDs[i]) continue;
+			if (strcmp(msg->tags[TagReply], iced.msgIDs[i])) continue;
+			icedPush(msg->tags[TagMsgID]);
+			return Ice;
+		}
+	}
+
+	char mask[512];
+	snprintf(mask, sizeof(mask), "%s!%s@%s", msg->nick, msg->user, msg->host);
+	for (size_t i = 0; i < len; ++i) {
+		if (!filterTest(filters[i], mask, id, msg)) continue;
+		if (filters[i].heat == Ice) icedPush(msg->tags[TagMsgID]);
+		return filters[i].heat;
+	}
+	return heat;
+}
diff --git a/handle.c b/handle.c
index fcc0c5d..5a2cf7c 100644
--- a/handle.c
+++ b/handle.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2020  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -33,10 +33,11 @@
 #include <stdlib.h>
 #include <string.h>
 #include <sysexits.h>
+#include <wchar.h>
 
 #include "chat.h"
 
-struct Replies replies;
+uint replies[ReplyCap];
 
 static const char *CapNames[] = {
 #define X(name, id) [id##Bit] = name,
@@ -62,21 +63,21 @@ static enum Cap capParse(const char *list) {
 	return caps;
 }
 
-static const char *capList(enum Cap caps) {
-	static char buf[1024];
-	buf[0] = '\0';
-	struct Cat cat = { buf, sizeof(buf), 0 };
+static void capList(char *buf, size_t cap, enum Cap caps) {
+	*buf = '\0';
+	char *ptr = buf, *end = &buf[cap];
 	for (size_t i = 0; i < ARRAY_LEN(CapNames); ++i) {
 		if (caps & (1 << i)) {
-			catf(&cat, "%s%s", (buf[0] ? " " : ""), CapNames[i]);
+			ptr = seprintf(
+				ptr, end, "%s%s", (ptr > buf ? " " : ""), CapNames[i]
+			);
 		}
 	}
-	return buf;
 }
 
 static void require(struct Message *msg, bool origin, uint len) {
 	if (origin) {
-		if (!msg->nick) errx(EX_PROTOCOL, "%s missing origin", msg->cmd);
+		if (!msg->nick) msg->nick = "*.*";
 		if (!msg->user) msg->user = msg->nick;
 		if (!msg->host) msg->host = msg->user;
 	}
@@ -90,13 +91,25 @@ static const time_t *tagTime(const struct Message *msg) {
 	static time_t time;
 	struct tm tm;
 	if (!msg->tags[TagTime]) return NULL;
-	if (!strptime(msg->tags[TagTime], "%FT%T", &tm)) return NULL;
+	if (!strptime(msg->tags[TagTime], "%Y-%m-%dT%T", &tm)) return NULL;
 	time = timegm(&tm);
 	return &time;
 }
 
 typedef void Handler(struct Message *msg);
 
+static void handleStandardReply(struct Message *msg) {
+	require(msg, false, 3);
+	for (uint i = 2; i < ParamCap - 1; ++i) {
+		if (msg->params[i + 1]) continue;
+		uiFormat(
+			Network, Warm, tagTime(msg),
+			"%s", msg->params[i]
+		);
+		break;
+	}
+}
+
 static void handleErrorGeneric(struct Message *msg) {
 	require(msg, false, 2);
 	if (msg->params[2]) {
@@ -114,10 +127,33 @@ static void handleErrorGeneric(struct Message *msg) {
 	}
 }
 
+static void handleReplyGeneric(struct Message *msg) {
+	uint first = 1;
+	uint id = Network;
+	if (msg->params[1] && strchr(network.chanTypes, msg->params[1][0])) {
+		id = idFor(msg->params[1]);
+		first++;
+	}
+	char buf[1024];
+	char *ptr = buf, *end = &buf[sizeof(buf)];
+	ptr = seprintf(ptr, end, "\3%d(%s)\3\t", Gray, msg->cmd);
+	for (uint i = first; i < ParamCap && msg->params[i]; ++i) {
+		ptr = seprintf(
+			ptr, end, "%s%s", (i > first ? " " : ""), msg->params[i]
+		);
+	}
+	uiWrite(id, Ice, tagTime(msg), buf);
+}
+
 static void handleErrorNicknameInUse(struct Message *msg) {
 	require(msg, false, 2);
 	if (!strcmp(self.nick, "*")) {
-		ircFormat("NICK :%s_\r\n", msg->params[1]);
+		static uint i = 1;
+		if (i < ARRAY_LEN(self.nicks) && self.nicks[i]) {
+			ircFormat("NICK %s\r\n", self.nicks[i++]);
+		} else {
+			ircFormat("NICK %s_\r\n", msg->params[1]);
+		}
 	} else {
 		handleErrorGeneric(msg);
 	}
@@ -142,14 +178,18 @@ static void handleCap(struct Message *msg) {
 			caps &= ~CapConsumer;
 		}
 		if (caps) {
-			ircFormat("CAP REQ :%s\r\n", capList(caps));
+			char buf[512];
+			capList(buf, sizeof(buf), caps);
+			ircFormat("CAP REQ :%s\r\n", buf);
 		} else {
 			if (!(self.caps & CapSASL)) ircFormat("CAP END\r\n");
 		}
 	} else if (!strcmp(msg->params[1], "ACK")) {
 		self.caps |= caps;
 		if (caps & CapSASL) {
-			ircFormat("AUTHENTICATE %s\r\n", (self.plain ? "PLAIN" : "EXTERNAL"));
+			ircFormat(
+				"AUTHENTICATE %s\r\n", (self.plainUser ? "PLAIN" : "EXTERNAL")
+			);
 		}
 		if (!(self.caps & CapSASL)) ircFormat("CAP END\r\n");
 	} else if (!strcmp(msg->params[1], "NAK")) {
@@ -188,33 +228,34 @@ static void base64(char *dst, const byte *src, size_t len) {
 
 static void handleAuthenticate(struct Message *msg) {
 	(void)msg;
-	if (!self.plain) {
+	if (!self.plainUser) {
 		ircFormat("AUTHENTICATE +\r\n");
 		return;
 	}
 
-	byte buf[299];
-	size_t len = 1 + strlen(self.plain);
-	if (sizeof(buf) < len) errx(EX_CONFIG, "SASL PLAIN is too long");
-	buf[0] = 0;
-	for (size_t i = 0; self.plain[i]; ++i) {
-		buf[1 + i] = (self.plain[i] == ':' ? 0 : self.plain[i]);
-	}
+	byte buf[299] = {0};
+	size_t userLen = strlen(self.plainUser);
+	size_t passLen = strlen(self.plainPass);
+	size_t len = 1 + userLen + 1 + passLen;
+	if (sizeof(buf) < len) errx(EX_USAGE, "SASL PLAIN is too long");
+	memcpy(&buf[1], self.plainUser, userLen);
+	memcpy(&buf[1 + userLen + 1], self.plainPass, passLen);
 
 	char b64[BASE64_SIZE(sizeof(buf))];
 	base64(b64, buf, len);
 	ircFormat("AUTHENTICATE ");
-	ircSend(b64, BASE64_SIZE(len));
+	ircSend(b64, BASE64_SIZE(len) - 1);
 	ircFormat("\r\n");
 
 	explicit_bzero(b64, sizeof(b64));
 	explicit_bzero(buf, sizeof(buf));
-	explicit_bzero(self.plain, strlen(self.plain));
+	explicit_bzero(self.plainPass, strlen(self.plainPass));
 }
 
 static void handleReplyLoggedIn(struct Message *msg) {
 	(void)msg;
 	ircFormat("CAP END\r\n");
+	handleReplyGeneric(msg);
 }
 
 static void handleErrorSASLFail(struct Message *msg) {
@@ -225,30 +266,38 @@ static void handleErrorSASLFail(struct Message *msg) {
 static void handleReplyWelcome(struct Message *msg) {
 	require(msg, false, 1);
 	set(&self.nick, msg->params[0]);
-	completeTouch(Network, self.nick, Default);
+	completePull(Network, self.nick, Default);
+	if (self.mode) ircFormat("MODE %s %s\r\n", self.nick, self.mode);
 	if (self.join) {
 		uint count = 1;
 		for (const char *ch = self.join; *ch && *ch != ' '; ++ch) {
 			if (*ch == ',') count++;
 		}
 		ircFormat("JOIN %s\r\n", self.join);
-		replies.join += count;
-		replies.topic += count;
-		replies.names += count;
+		if (count == 1) replies[ReplyJoin]++;
+		replies[ReplyTopicAuto] += count;
+		replies[ReplyNamesAuto] += count;
 	}
+	commandCompletion();
+	handleReplyGeneric(msg);
 }
 
 static void handleReplyISupport(struct Message *msg) {
+	handleReplyGeneric(msg);
 	for (uint i = 1; i < ParamCap; ++i) {
 		if (!msg->params[i]) break;
 		char *key = strsep(&msg->params[i], "=");
 		if (!strcmp(key, "NETWORK")) {
 			if (!msg->params[i]) continue;
 			set(&network.name, msg->params[i]);
-			uiFormat(
-				Network, Cold, tagTime(msg),
-				"You arrive in %s", msg->params[i]
-			);
+			static bool arrived;
+			if (!arrived) {
+				uiFormat(
+					Network, Cold, tagTime(msg),
+					"You arrive in %s", msg->params[i]
+				);
+				arrived = true;
+			}
 		} else if (!strcmp(key, "USERLEN")) {
 			if (!msg->params[i]) continue;
 			network.userLen = strtoul(msg->params[i], NULL, 10);
@@ -258,6 +307,9 @@ static void handleReplyISupport(struct Message *msg) {
 		} else if (!strcmp(key, "CHANTYPES")) {
 			if (!msg->params[i]) continue;
 			set(&network.chanTypes, msg->params[i]);
+		} else if (!strcmp(key, "STATUSMSG")) {
+			if (!msg->params[i]) continue;
+			set(&network.statusmsg, msg->params[i]);
 		} else if (!strcmp(key, "PREFIX")) {
 			strsep(&msg->params[i], "(");
 			char *modes = strsep(&msg->params[i], ")");
@@ -290,7 +342,7 @@ static void handleReplyISupport(struct Message *msg) {
 static void handleReplyMOTD(struct Message *msg) {
 	require(msg, false, 2);
 	char *line = msg->params[1];
-	urlScan(Network, msg->nick, line);
+	urlScan(Network, NULL, line);
 	if (!strncmp(line, "- ", 2)) {
 		uiFormat(Network, Cold, tagTime(msg), "\3%d-\3\t%s", Gray, &line[2]);
 	} else {
@@ -302,6 +354,12 @@ static void handleErrorNoMOTD(struct Message *msg) {
 	(void)msg;
 }
 
+static void handleReplyHelp(struct Message *msg) {
+	require(msg, false, 3);
+	urlScan(Network, NULL, msg->params[2]);
+	uiWrite(Network, Warm, tagTime(msg), msg->params[2]);
+}
+
 static void handleJoin(struct Message *msg) {
 	require(msg, true, 1);
 	uint id = idFor(msg->params[0]);
@@ -314,23 +372,23 @@ static void handleJoin(struct Message *msg) {
 			set(&self.host, msg->host);
 		}
 		idColors[id] = hash(msg->params[0]);
-		completeTouch(None, msg->params[0], idColors[id]);
-		if (replies.join) {
-			uiShowID(id);
-			replies.join--;
+		completePull(None, msg->params[0], idColors[id]);
+		if (replies[ReplyJoin]) {
+			windowShow(windowFor(id));
+			replies[ReplyJoin]--;
 		}
 	}
-	completeTouch(id, msg->nick, hash(msg->user));
+	completePull(id, msg->nick, hash(msg->user));
 	if (msg->params[2] && !strcasecmp(msg->params[2], msg->nick)) {
 		msg->params[2] = NULL;
 	}
 	uiFormat(
-		id, ignoreCheck(Cold, id, msg), tagTime(msg),
+		id, filterCheck(Cold, id, msg), tagTime(msg),
 		"\3%02d%s\3\t%s%s%sarrives in \3%02d%s\3",
 		hash(msg->user), msg->nick,
 		(msg->params[2] ? "(" : ""),
 		(msg->params[2] ?: ""),
-		(msg->params[2] ? ") " : ""),
+		(msg->params[2] ? "\17) " : ""),
 		hash(msg->params[0]), msg->params[0]
 	);
 	logFormat(id, tagTime(msg), "%s arrives in %s", msg->nick, msg->params[0]);
@@ -352,10 +410,10 @@ static void handlePart(struct Message *msg) {
 	require(msg, true, 1);
 	uint id = idFor(msg->params[0]);
 	if (!strcmp(msg->nick, self.nick)) {
-		completeClear(id);
+		completeRemove(id, NULL);
 	}
 	completeRemove(id, msg->nick);
-	enum Heat heat = ignoreCheck(Cold, id, msg);
+	enum Heat heat = filterCheck(Cold, id, msg);
 	if (heat > Ice) urlScan(id, msg->nick, msg->params[1]);
 	uiFormat(
 		id, heat, tagTime(msg),
@@ -374,7 +432,7 @@ static void handleKick(struct Message *msg) {
 	require(msg, true, 2);
 	uint id = idFor(msg->params[0]);
 	bool kicked = !strcmp(msg->params[1], self.nick);
-	completeTouch(id, msg->nick, hash(msg->user));
+	completePull(id, msg->nick, hash(msg->user));
 	urlScan(id, msg->nick, msg->params[2]);
 	uiFormat(
 		id, (kicked ? Hot : Cold), tagTime(msg),
@@ -391,21 +449,22 @@ static void handleKick(struct Message *msg) {
 		(msg->params[2] ? ": " : ""), (msg->params[2] ?: "")
 	);
 	completeRemove(id, msg->params[1]);
-	if (kicked) completeClear(id);
+	if (kicked) completeRemove(id, NULL);
 }
 
 static void handleNick(struct Message *msg) {
 	require(msg, true, 1);
 	if (!strcmp(msg->nick, self.nick)) {
 		set(&self.nick, msg->params[0]);
-		uiRead(); // Update prompt.
+		inputUpdate();
 	}
-	for (uint id; (id = completeID(msg->nick));) {
+	struct Cursor curs = {0};
+	for (uint id; (id = completeEachID(&curs, msg->nick));) {
 		if (!strcmp(idNames[id], msg->nick)) {
 			set(&idNames[id], msg->params[0]);
 		}
 		uiFormat(
-			id, ignoreCheck(Cold, id, msg), tagTime(msg),
+			id, filterCheck(Cold, id, msg), tagTime(msg),
 			"\3%02d%s\3\tis now known as \3%02d%s\3",
 			hash(msg->user), msg->nick, hash(msg->user), msg->params[0]
 		);
@@ -415,13 +474,27 @@ static void handleNick(struct Message *msg) {
 			msg->nick, msg->params[0]
 		);
 	}
-	completeReplace(None, msg->nick, msg->params[0]);
+	completeReplace(msg->nick, msg->params[0]);
+}
+
+static void handleSetname(struct Message *msg) {
+	require(msg, true, 1);
+	struct Cursor curs = {0};
+	for (uint id; (id = completeEachID(&curs, msg->nick));) {
+		uiFormat(
+			id, filterCheck(Cold, id, msg), tagTime(msg),
+			"\3%02d%s\3\tis now known as \3%02d%s\3 (%s\17)",
+			hash(msg->user), msg->nick, hash(msg->user), msg->nick,
+			msg->params[0]
+		);
+	}
 }
 
 static void handleQuit(struct Message *msg) {
 	require(msg, true, 0);
-	for (uint id; (id = completeID(msg->nick));) {
-		enum Heat heat = ignoreCheck(Cold, id, msg);
+	struct Cursor curs = {0};
+	for (uint id; (id = completeEachID(&curs, msg->nick));) {
+		enum Heat heat = filterCheck(Cold, id, msg);
 		if (heat > Ice) urlScan(id, msg->nick, msg->params[0]);
 		uiFormat(
 			id, heat, tagTime(msg),
@@ -442,8 +515,9 @@ static void handleQuit(struct Message *msg) {
 static void handleInvite(struct Message *msg) {
 	require(msg, true, 2);
 	if (!strcmp(msg->params[0], self.nick)) {
+		set(&self.invited, msg->params[1]);
 		uiFormat(
-			Network, ignoreCheck(Hot, Network, msg), tagTime(msg),
+			Network, filterCheck(Hot, Network, msg), tagTime(msg),
 			"\3%02d%s\3\tinvites you to \3%02d%s\3",
 			hash(msg->user), msg->nick, hash(msg->params[1]), msg->params[1]
 		);
@@ -465,7 +539,6 @@ static void handleInvite(struct Message *msg) {
 
 static void handleReplyInviting(struct Message *msg) {
 	require(msg, false, 3);
-	if (self.caps & CapInviteNotify) return;
 	struct Message invite = {
 		.nick = self.nick,
 		.user = self.user,
@@ -477,10 +550,10 @@ static void handleReplyInviting(struct Message *msg) {
 }
 
 static void handleErrorUserOnChannel(struct Message *msg) {
-	require(msg, false, 4);
+	require(msg, false, 3);
 	uint id = idFor(msg->params[2]);
 	uiFormat(
-		id, Cold, tagTime(msg),
+		id, Warm, tagTime(msg),
 		"\3%02d%s\3 is already in \3%02d%s\3",
 		completeColor(id, msg->params[1]), msg->params[1],
 		hash(msg->params[2]), msg->params[2]
@@ -490,21 +563,28 @@ static void handleErrorUserOnChannel(struct Message *msg) {
 static void handleReplyNames(struct Message *msg) {
 	require(msg, false, 4);
 	uint id = idFor(msg->params[2]);
-	char buf[1024] = "";
-	struct Cat cat = { buf, sizeof(buf), 0 };
+	char buf[1024];
+	char *ptr = buf, *end = &buf[sizeof(buf)];
 	while (msg->params[3]) {
 		char *name = strsep(&msg->params[3], " ");
 		char *prefixes = strsep(&name, "!");
 		char *nick = &prefixes[strspn(prefixes, network.prefixes)];
 		char *user = strsep(&name, "@");
 		enum Color color = (user ? hash(user) : Default);
-		completeAdd(id, nick, color);
-		if (!replies.names) continue;
-		catf(&cat, "%s\3%02d%s\3", (buf[0] ? ", " : ""), color, prefixes);
+		uint bits = 0;
+		for (char *p = prefixes; p < nick; ++p) {
+			bits |= prefixBit(*p);
+		}
+		completePush(id, nick, color);
+		*completeBits(id, nick) = bits;
+		if (!replies[ReplyNames] && !replies[ReplyNamesAuto]) continue;
+		ptr = seprintf(
+			ptr, end, "%s\3%02d%s\3", (ptr > buf ? ", " : ""), color, prefixes
+		);
 	}
-	if (!replies.names) return;
+	if (ptr == buf) return;
 	uiFormat(
-		id, Cold, tagTime(msg),
+		id, (replies[ReplyNamesAuto] ? Cold : Warm), tagTime(msg),
 		"In \3%02d%s\3 are %s",
 		hash(msg->params[2]), msg->params[2], buf
 	);
@@ -512,15 +592,17 @@ static void handleReplyNames(struct Message *msg) {
 
 static void handleReplyEndOfNames(struct Message *msg) {
 	(void)msg;
-	if (replies.names) replies.names--;
+	if (replies[ReplyNamesAuto]) {
+		replies[ReplyNamesAuto]--;
+	} else if (replies[ReplyNames]) {
+		replies[ReplyNames]--;
+	}
 }
 
 static void handleReplyNoTopic(struct Message *msg) {
 	require(msg, false, 2);
-	if (!replies.topic) return;
-	replies.topic--;
 	uiFormat(
-		idFor(msg->params[1]), Cold, tagTime(msg),
+		idFor(msg->params[1]), Warm, tagTime(msg),
 		"There is no sign in \3%02d%s\3",
 		hash(msg->params[1]), msg->params[1]
 	);
@@ -528,14 +610,15 @@ static void handleReplyNoTopic(struct Message *msg) {
 
 static void topicComplete(uint id, const char *topic) {
 	char buf[512];
-	const char *prev = complete(id, "/topic ");
+	struct Cursor curs = {0};
+	const char *prev = completePrefix(&curs, id, "/topic ");
 	if (prev) {
 		snprintf(buf, sizeof(buf), "%s", prev);
 		completeRemove(id, buf);
 	}
 	if (topic) {
 		snprintf(buf, sizeof(buf), "/topic %s", topic);
-		completeAdd(id, buf, Default);
+		completePush(id, buf, Default);
 	}
 }
 
@@ -543,11 +626,10 @@ static void handleReplyTopic(struct Message *msg) {
 	require(msg, false, 3);
 	uint id = idFor(msg->params[1]);
 	topicComplete(id, msg->params[2]);
-	if (!replies.topic) return;
-	replies.topic--;
+	if (!replies[ReplyTopic] && !replies[ReplyTopicAuto]) return;
 	urlScan(id, NULL, msg->params[2]);
 	uiFormat(
-		id, Cold, tagTime(msg),
+		id, (replies[ReplyTopicAuto] ? Cold : Warm), tagTime(msg),
 		"The sign in \3%02d%s\3 reads: %s",
 		hash(msg->params[1]), msg->params[1], msg->params[2]
 	);
@@ -555,25 +637,45 @@ static void handleReplyTopic(struct Message *msg) {
 		id, tagTime(msg), "The sign in %s reads: %s",
 		msg->params[1], msg->params[2]
 	);
+	if (replies[ReplyTopicAuto]) {
+		replies[ReplyTopicAuto]--;
+	} else {
+		replies[ReplyTopic]--;
+	}
+}
+
+static void swap(wchar_t *a, wchar_t *b) {
+	wchar_t x = *a;
+	*a = *b;
+	*b = x;
+}
+
+static char *highlightMiddle(
+	char *ptr, char *end, enum Color color,
+	wchar_t *str, size_t pre, size_t suf
+) {
+	wchar_t nul = L'\0';
+	swap(&str[pre], &nul);
+	ptr = seprintf(ptr, end, "%ls", str);
+	swap(&str[pre], &nul);
+	swap(&str[suf], &nul);
+	if (hashBound) {
+		ptr = seprintf(
+			ptr, end, "\3%02d,%02d%ls\3%02d,%02d",
+			Default, color, &str[pre], Default, Default
+		);
+	} else {
+		ptr = seprintf(ptr, end, "\26%ls\26", &str[pre]);
+	}
+	swap(&str[suf], &nul);
+	ptr = seprintf(ptr, end, "%ls", &str[suf]);
+	return ptr;
 }
 
 static void handleTopic(struct Message *msg) {
 	require(msg, true, 2);
 	uint id = idFor(msg->params[0]);
-	if (msg->params[1][0]) {
-		topicComplete(id, msg->params[1]);
-		urlScan(id, msg->nick, msg->params[1]);
-		uiFormat(
-			id, Warm, tagTime(msg),
-			"\3%02d%s\3\tplaces a new sign in \3%02d%s\3: %s",
-			hash(msg->user), msg->nick, hash(msg->params[0]), msg->params[0],
-			msg->params[1]
-		);
-		logFormat(
-			id, tagTime(msg), "%s places a new sign in %s: %s",
-			msg->nick, msg->params[0], msg->params[1]
-		);
-	} else {
+	if (!msg->params[1][0]) {
 		topicComplete(id, NULL);
 		uiFormat(
 			id, Warm, tagTime(msg),
@@ -584,7 +686,62 @@ static void handleTopic(struct Message *msg) {
 			id, tagTime(msg), "%s removes the sign in %s",
 			msg->nick, msg->params[0]
 		);
+		return;
 	}
+
+	struct Cursor curs = {0};
+	const char *prev = completePrefix(&curs, id, "/topic ");
+	if (prev) {
+		prev += 7;
+	} else {
+		goto plain;
+	}
+
+	wchar_t old[512];
+	wchar_t new[512];
+	if (swprintf(old, ARRAY_LEN(old), L"%s", prev) < 0) goto plain;
+	if (swprintf(new, ARRAY_LEN(new), L"%s", msg->params[1]) < 0) goto plain;
+
+	size_t pre;
+	for (pre = 0; old[pre] && new[pre] && old[pre] == new[pre]; ++pre);
+	size_t osuf = wcslen(old);
+	size_t nsuf = wcslen(new);
+	while (osuf > pre && nsuf > pre && old[osuf-1] == new[nsuf-1]) {
+		osuf--;
+		nsuf--;
+	}
+
+	char buf[1024];
+	char *ptr = buf, *end = &buf[sizeof(buf)];
+	ptr = seprintf(
+		ptr, end, "\3%02d%s\3\ttakes down the sign in \3%02d%s\3: ",
+		hash(msg->user), msg->nick, hash(msg->params[0]), msg->params[0]
+	);
+	ptr = highlightMiddle(ptr, end, Brown, old, pre, osuf);
+	if (osuf != pre) uiWrite(id, Cold, tagTime(msg), buf);
+	ptr = buf;
+	ptr = seprintf(
+		ptr, end, "\3%02d%s\3\tplaces a new sign in \3%02d%s\3: ",
+		hash(msg->user), msg->nick, hash(msg->params[0]), msg->params[0]
+	);
+	ptr = highlightMiddle(ptr, end, Green, new, pre, nsuf);
+	uiWrite(id, Warm, tagTime(msg), buf);
+	goto log;
+
+plain:
+	uiFormat(
+		id, Warm, tagTime(msg),
+		"\3%02d%s\3\tplaces a new sign in \3%02d%s\3: %s",
+		hash(msg->user), msg->nick, hash(msg->params[0]), msg->params[0],
+		msg->params[1]
+	);
+log:
+	logFormat(
+		id, tagTime(msg), "%s places a new sign in %s: %s",
+		msg->nick, msg->params[0], msg->params[1]
+	);
+	topicComplete(id, msg->params[1]);
+	urlScan(id, msg->nick, msg->params[1]);
 }
 
 static const char *UserModes[256] = {
@@ -597,20 +754,19 @@ static const char *UserModes[256] = {
 
 static void handleReplyUserModeIs(struct Message *msg) {
 	require(msg, false, 2);
-	if (!replies.mode) return;
-	replies.mode--;
-
-	char buf[1024] = "";
-	struct Cat cat = { buf, sizeof(buf), 0 };
+	char buf[1024];
+	char *ptr = buf, *end = &buf[sizeof(buf)];
 	for (char *ch = msg->params[1]; *ch; ++ch) {
 		if (*ch == '+') continue;
 		const char *name = UserModes[(byte)*ch];
-		catf(&cat, ", +%c%s%s", *ch, (name ? " " : ""), (name ?: ""));
+		ptr = seprintf(
+			ptr, end, ", +%c%s%s", *ch, (name ? " " : ""), (name ?: "")
+		);
 	}
 	uiFormat(
 		Network, Warm, tagTime(msg),
 		"\3%02d%s\3\tis %s",
-		self.color, self.nick, (buf[0] ? &buf[2] : "modeless")
+		self.color, self.nick, (ptr > buf ? &buf[2] : "modeless")
 	);
 }
 
@@ -631,12 +787,9 @@ static const char *ChanModes[256] = {
 
 static void handleReplyChannelModeIs(struct Message *msg) {
 	require(msg, false, 3);
-	if (!replies.mode) return;
-	replies.mode--;
-
 	uint param = 3;
-	char buf[1024] = "";
-	struct Cat cat = { buf, sizeof(buf), 0 };
+	char buf[1024];
+	char *ptr = buf, *end = &buf[sizeof(buf)];
 	for (char *ch = msg->params[2]; *ch; ++ch) {
 		if (*ch == '+') continue;
 		const char *name = ChanModes[(byte)*ch];
@@ -645,23 +798,23 @@ static void handleReplyChannelModeIs(struct Message *msg) {
 			strchr(network.setParamModes, *ch)
 		) {
 			assert(param < ParamCap);
-			catf(
-				&cat, ", +%c%s%s %s",
+			ptr = seprintf(
+				ptr, end, ", +%c%s%s %s",
 				*ch, (name ? " " : ""), (name ?: ""),
 				msg->params[param++]
 			);
 		} else {
-			catf(
-				&cat, ", +%c%s%s",
+			ptr = seprintf(
+				ptr, end, ", +%c%s%s",
 				*ch, (name ? " " : ""), (name ?: "")
 			);
 		}
 	}
 	uiFormat(
-		idFor(msg->params[1]), Cold, tagTime(msg),
+		idFor(msg->params[1]), Warm, tagTime(msg),
 		"\3%02d%s\3\tis %s",
 		hash(msg->params[1]), msg->params[1],
-		(buf[0] ? &buf[2] : "modeless")
+		(ptr > buf ? &buf[2] : "modeless")
 	);
 }
 
@@ -710,6 +863,12 @@ static void handleMode(struct Message *msg) {
 			char prefix = network.prefixes[
 				strchr(network.prefixModes, *ch) - network.prefixModes
 			];
+			completePush(id, nick, Default);
+			if (set) {
+				*completeBits(id, nick) |= prefixBit(prefix);
+			} else {
+				*completeBits(id, nick) &= ~prefixBit(prefix);
+			}
 			uiFormat(
 				id, Cold, tagTime(msg),
 				"\3%02d%s\3\t%s \3%02d%c%s\3 %s%s in \3%02d%s\3",
@@ -819,7 +978,7 @@ static void handleMode(struct Message *msg) {
 static void handleErrorChanopPrivsNeeded(struct Message *msg) {
 	require(msg, false, 3);
 	uiFormat(
-		idFor(msg->params[1]), Cold, tagTime(msg),
+		idFor(msg->params[1]), Warm, tagTime(msg),
 		"%s", msg->params[2]
 	);
 }
@@ -827,7 +986,7 @@ static void handleErrorChanopPrivsNeeded(struct Message *msg) {
 static void handleErrorUserNotInChannel(struct Message *msg) {
 	require(msg, false, 4);
 	uiFormat(
-		idFor(msg->params[2]), Cold, tagTime(msg),
+		idFor(msg->params[2]), Warm, tagTime(msg),
 		"%s\tis not in \3%02d%s\3",
 		msg->params[1], hash(msg->params[2]), msg->params[2]
 	);
@@ -836,21 +995,20 @@ static void handleErrorUserNotInChannel(struct Message *msg) {
 static void handleErrorBanListFull(struct Message *msg) {
 	require(msg, false, 4);
 	uiFormat(
-		idFor(msg->params[1]), Cold, tagTime(msg),
+		idFor(msg->params[1]), Warm, tagTime(msg),
 		"%s", (msg->params[4] ?: msg->params[3])
 	);
 }
 
 static void handleReplyBanList(struct Message *msg) {
 	require(msg, false, 3);
-	if (!replies.ban) return;
 	uint id = idFor(msg->params[1]);
 	if (msg->params[3] && msg->params[4]) {
 		char since[sizeof("0000-00-00 00:00:00")];
 		time_t time = strtol(msg->params[4], NULL, 10);
 		strftime(since, sizeof(since), "%F %T", localtime(&time));
 		uiFormat(
-			id, Cold, tagTime(msg),
+			id, Warm, tagTime(msg),
 			"Banned from \3%02d%s\3 since %s by \3%02d%s\3: %s",
 			hash(msg->params[1]), msg->params[1],
 			since, completeColor(id, msg->params[3]), msg->params[3],
@@ -858,26 +1016,22 @@ static void handleReplyBanList(struct Message *msg) {
 		);
 	} else {
 		uiFormat(
-			id, Cold, tagTime(msg),
+			id, Warm, tagTime(msg),
 			"Banned from \3%02d%s\3: %s",
 			hash(msg->params[1]), msg->params[1], msg->params[2]
 		);
 	}
 }
 
-static void handleReplyEndOfBanList(struct Message *msg) {
-	(void)msg;
-	if (replies.ban) replies.ban--;
-}
-
 static void onList(const char *list, struct Message *msg) {
+	require(msg, false, 3);
 	uint id = idFor(msg->params[1]);
 	if (msg->params[3] && msg->params[4]) {
 		char since[sizeof("0000-00-00 00:00:00")];
 		time_t time = strtol(msg->params[4], NULL, 10);
 		strftime(since, sizeof(since), "%F %T", localtime(&time));
 		uiFormat(
-			id, Cold, tagTime(msg),
+			id, Warm, tagTime(msg),
 			"On the \3%02d%s\3 %s list since %s by \3%02d%s\3: %s",
 			hash(msg->params[1]), msg->params[1], list,
 			since, completeColor(id, msg->params[3]), msg->params[3],
@@ -885,7 +1039,7 @@ static void onList(const char *list, struct Message *msg) {
 		);
 	} else {
 		uiFormat(
-			id, Cold, tagTime(msg),
+			id, Warm, tagTime(msg),
 			"On the \3%02d%s\3 %s list: %s",
 			hash(msg->params[1]), msg->params[1], list, msg->params[2]
 		);
@@ -893,71 +1047,48 @@ static void onList(const char *list, struct Message *msg) {
 }
 
 static void handleReplyExceptList(struct Message *msg) {
-	require(msg, false, 3);
-	if (!replies.excepts) return;
 	onList("except", msg);
 }
 
-static void handleReplyEndOfExceptList(struct Message *msg) {
-	(void)msg;
-	if (replies.excepts) replies.excepts--;
-}
-
 static void handleReplyInviteList(struct Message *msg) {
-	require(msg, false, 3);
-	if (!replies.invex) return;
 	onList("invite", msg);
 }
 
-static void handleReplyEndOfInviteList(struct Message *msg) {
-	(void)msg;
-	if (replies.invex) replies.invex--;
-}
-
 static void handleReplyList(struct Message *msg) {
-	require(msg, false, 4);
-	if (!replies.list) return;
+	require(msg, false, 3);
 	uiFormat(
 		Network, Warm, tagTime(msg),
 		"In \3%02d%s\3 are %ld under the banner: %s",
 		hash(msg->params[1]), msg->params[1],
 		strtol(msg->params[2], NULL, 10),
-		msg->params[3]
+		(msg->params[3] ?: "")
 	);
 }
 
-static void handleReplyListEnd(struct Message *msg) {
-	(void)msg;
-	if (!replies.list) return;
-	replies.list--;
-}
-
 static void handleReplyWhoisUser(struct Message *msg) {
 	require(msg, false, 6);
-	if (!replies.whois) return;
-	completeTouch(Network, msg->params[1], hash(msg->params[2]));
+	completePull(Network, msg->params[1], hash(msg->params[2]));
 	uiFormat(
 		Network, Warm, tagTime(msg),
-		"\3%02d%s\3\tis %s!%s@%s (%s)",
+		"\3%02d%s\3\tis %s!%s@%s (%s\17)",
 		hash(msg->params[2]), msg->params[1],
 		msg->params[1], msg->params[2], msg->params[3], msg->params[5]
 	);
 }
 
 static void handleReplyWhoisServer(struct Message *msg) {
+	if (!replies[ReplyWhois] && !replies[ReplyWhowas]) return;
 	require(msg, false, 4);
-	if (!replies.whois) return;
 	uiFormat(
 		Network, Warm, tagTime(msg),
-		"\3%02d%s\3\tis connected to %s (%s)",
+		"\3%02d%s\3\t%s connected to %s (%s)",
 		completeColor(Network, msg->params[1]), msg->params[1],
-		msg->params[2], msg->params[3]
+		(replies[ReplyWhowas] ? "was" : "is"), msg->params[2], msg->params[3]
 	);
 }
 
 static void handleReplyWhoisIdle(struct Message *msg) {
 	require(msg, false, 3);
-	if (!replies.whois) return;
 	unsigned long idle = strtoul(msg->params[2], NULL, 10);
 	const char *unit = "second";
 	if (idle / 60) {
@@ -983,13 +1114,16 @@ static void handleReplyWhoisIdle(struct Message *msg) {
 
 static void handleReplyWhoisChannels(struct Message *msg) {
 	require(msg, false, 3);
-	if (!replies.whois) return;
-	char buf[1024] = "";
-	struct Cat cat = { buf, sizeof(buf), 0 };
+	char buf[1024];
+	char *ptr = buf, *end = &buf[sizeof(buf)];
 	while (msg->params[2]) {
 		char *channel = strsep(&msg->params[2], " ");
+		if (!channel[0]) break;
 		char *name = &channel[strspn(channel, network.prefixes)];
-		catf(&cat, "%s\3%02d%s\3", (buf[0] ? ", " : ""), hash(name), channel);
+		ptr = seprintf(
+			ptr, end, "%s\3%02d%s\3",
+			(ptr > buf ? ", " : ""), hash(name), channel
+		);
 	}
 	uiFormat(
 		Network, Warm, tagTime(msg),
@@ -1000,7 +1134,6 @@ static void handleReplyWhoisChannels(struct Message *msg) {
 
 static void handleReplyWhoisGeneric(struct Message *msg) {
 	require(msg, false, 3);
-	if (!replies.whois) return;
 	if (msg->params[3]) {
 		msg->params[0] = msg->params[2];
 		msg->params[2] = msg->params[3];
@@ -1016,24 +1149,35 @@ static void handleReplyWhoisGeneric(struct Message *msg) {
 
 static void handleReplyEndOfWhois(struct Message *msg) {
 	require(msg, false, 2);
-	if (!replies.whois) return;
 	if (strcmp(msg->params[1], self.nick)) {
 		completeRemove(Network, msg->params[1]);
 	}
-	replies.whois--;
+}
+
+static void handleReplyWhowasUser(struct Message *msg) {
+	require(msg, false, 6);
+	completePull(Network, msg->params[1], hash(msg->params[2]));
+	uiFormat(
+		Network, Warm, tagTime(msg),
+		"\3%02d%s\3\twas %s!%s@%s (%s)",
+		hash(msg->params[2]), msg->params[1],
+		msg->params[1], msg->params[2], msg->params[3], msg->params[5]
+	);
+}
+
+static void handleReplyEndOfWhowas(struct Message *msg) {
+	require(msg, false, 2);
+	if (strcmp(msg->params[1], self.nick)) {
+		completeRemove(Network, msg->params[1]);
+	}
 }
 
 static void handleReplyAway(struct Message *msg) {
 	require(msg, false, 3);
 	// Might be part of a WHOIS response.
-	uint id;
-	if (completeColor(Network, msg->params[1]) != Default) {
-		id = Network;
-	} else {
-		id = idFor(msg->params[1]);
-	}
+	uint id = (replies[ReplyWhois] ? Network : idFor(msg->params[1]));
 	uiFormat(
-		id, Warm, tagTime(msg),
+		id, (id == Network ? Warm : Cold), tagTime(msg),
 		"\3%02d%s\3\tis away: %s",
 		completeColor(id, msg->params[1]), msg->params[1], msg->params[2]
 	);
@@ -1045,24 +1189,30 @@ static void handleReplyAway(struct Message *msg) {
 
 static void handleReplyNowAway(struct Message *msg) {
 	require(msg, false, 2);
-	if (!replies.away) return;
 	uiFormat(Network, Warm, tagTime(msg), "%s", msg->params[1]);
-	replies.away--;
 }
 
 static bool isAction(struct Message *msg) {
-	if (strncmp(msg->params[1], "\1ACTION ", 8)) return false;
-	msg->params[1] += 8;
+	if (strncmp(msg->params[1], "\1ACTION", 7)) return false;
+	if (msg->params[1][7] == ' ') {
+		msg->params[1] += 8;
+	} else if (msg->params[1][7] == '\1') {
+		msg->params[1] += 7;
+	} else {
+		return false;
+	}
 	size_t len = strlen(msg->params[1]);
-	if (msg->params[1][len - 1] == '\1') msg->params[1][len - 1] = '\0';
+	if (msg->params[1][len - 1] == '\1') {
+		msg->params[1][len - 1] = '\0';
+	}
 	return true;
 }
 
-static bool isMention(const struct Message *msg) {
-	size_t len = strlen(self.nick);
-	const char *match = msg->params[1];
-	while (NULL != (match = strcasestr(match, self.nick))) {
-		char a = (match > msg->params[1] ? match[-1] : ' ');
+static bool matchWord(const char *str, const char *word) {
+	size_t len = strlen(word);
+	const char *match = str;
+	while (NULL != (match = strstr(match, word))) {
+		char a = (match > str ? match[-1] : ' ');
 		char b = (match[len] ?: ' ');
 		if ((isspace(a) || ispunct(a)) && (isspace(b) || ispunct(b))) {
 			return true;
@@ -1072,47 +1222,54 @@ static bool isMention(const struct Message *msg) {
 	return false;
 }
 
-static const char *colorMentions(uint id, struct Message *msg) {
-	char *split = strstr(msg->params[1], ": ");
+static bool isMention(const struct Message *msg) {
+	if (matchWord(msg->params[1], self.nick)) return true;
+	for (uint i = 0; i < ARRAY_LEN(self.nicks) && self.nicks[i]; ++i) {
+		if (matchWord(msg->params[1], self.nicks[i])) return true;
+	}
+	return false;
+}
+
+static char *colorMentions(char *ptr, char *end, uint id, const char *msg) {
+	// Consider words before a colon, or only the first two.
+	const char *split = strstr(msg, ": ");
 	if (!split) {
-		split = strchr(msg->params[1], ' ');
+		split = strchr(msg, ' ');
 		if (split) split = strchr(&split[1], ' ');
 	}
-	if (!split) split = &msg->params[1][strlen(msg->params[1])];
-	for (char *ch = msg->params[1]; ch < split; ++ch) {
-		if (iscntrl(*ch)) return "";
+	if (!split) split = &msg[strlen(msg)];
+	// Bail if there is existing formatting.
+	for (const char *ch = msg; ch < split; ++ch) {
+		if (iscntrl(*ch)) goto rest;
 	}
-	char delimit = *split;
-	char *mention = msg->params[1];
-	msg->params[1] = (delimit ? &split[1] : split);
-	*split = '\0';
-
-	static char buf[1024];
-	buf[0] = '\0';
-	struct Cat cat = { buf, sizeof(buf), 0 };
-	while (*mention) {
-		size_t skip = strspn(mention, ",<> ");
-		catf(&cat, "%.*s", (int)skip, mention);
-		mention += skip;
-
-		size_t len = strcspn(mention, ",<> ");
-		char punct = mention[len];
-		mention[len] = '\0';
-		enum Color color = completeColor(id, mention);
+
+	while (msg < split) {
+		size_t skip = strspn(msg, ",:<> ");
+		ptr = seprintf(ptr, end, "%.*s", (int)skip, msg);
+		msg += skip;
+
+		size_t len = strcspn(msg, ",:<> ");
+		char *p = seprintf(ptr, end, "%.*s", (int)len, msg);
+		enum Color color = completeColor(id, ptr);
 		if (color != Default) {
-			catf(&cat, "\3%02d%s\3", color, mention);
+			ptr = seprintf(ptr, end, "\3%02d%.*s\3", color, (int)len, msg);
 		} else {
-			catf(&cat, "%s", mention);
+			ptr = p;
 		}
-		mention[len] = punct;
-		mention += len;
+		msg += len;
 	}
-	catf(&cat, "%c", delimit);
-	return buf;
+
+rest:
+	return seprintf(ptr, end, "%s", msg);
 }
 
 static void handlePrivmsg(struct Message *msg) {
 	require(msg, true, 2);
+	char statusmsg = '\0';
+	if (network.statusmsg && strchr(network.statusmsg, msg->params[0][0])) {
+		statusmsg = msg->params[0][0];
+		msg->params[0]++;
+	}
 	bool query = !strchr(network.chanTypes, msg->params[0][0]);
 	bool server = strchr(msg->nick, '.');
 	bool mine = !strcmp(msg->nick, self.nick);
@@ -1127,39 +1284,50 @@ static void handlePrivmsg(struct Message *msg) {
 	}
 
 	bool notice = (msg->cmd[0] == 'N');
-	bool action = isAction(msg);
-	bool mention = !mine && isMention(msg);
-	if (!notice && !mine) completeTouch(id, msg->nick, hash(msg->user));
-	enum Heat heat = ignoreCheck((mention || query ? Hot : Warm), id, msg);
+	bool action = !notice && isAction(msg);
+	bool highlight = !mine && isMention(msg);
+	enum Heat heat = (!notice && (highlight || query) ? Hot : Warm);
+	heat = filterCheck(heat, id, msg);
+	if (heat > Warm && !mine && !query) highlight = true;
+	if (!notice && !mine && heat > Ice) {
+		completePull(id, msg->nick, hash(msg->user));
+	}
 	if (heat > Ice) urlScan(id, msg->nick, msg->params[1]);
+
+	char buf[1024];
+	char *ptr = buf, *end = &buf[sizeof(buf)];
+	if (statusmsg) {
+		ptr = seprintf(
+			ptr, end, "\3%d[%c]\3 ", hash(msg->params[0]), statusmsg
+		);
+	}
 	if (notice) {
 		if (id != Network) {
 			logFormat(id, tagTime(msg), "-%s- %s", msg->nick, msg->params[1]);
 		}
-		uiFormat(
-			id, ignoreCheck(Warm, id, msg), tagTime(msg),
-			"\3%d-%s-\3%d\t%s",
-			hash(msg->user), msg->nick, LightGray, msg->params[1]
+		ptr = seprintf(
+			ptr, end, "\3%d-%s-\3%d\t",
+			hash(msg->user), msg->nick, LightGray
 		);
 	} else if (action) {
 		logFormat(id, tagTime(msg), "* %s %s", msg->nick, msg->params[1]);
-		const char *mentions = colorMentions(id, msg);
-		uiFormat(
-			id, heat, tagTime(msg),
-			"%s\35\3%d* %s\17\35\t%s%s",
-			(mention ? "\26" : ""), hash(msg->user), msg->nick,
-			mentions, msg->params[1]
+		ptr = seprintf(
+			ptr, end, "%s\35\3%d* %s\17\35\t",
+			(highlight ? "\26" : ""), hash(msg->user), msg->nick
 		);
 	} else {
 		logFormat(id, tagTime(msg), "<%s> %s", msg->nick, msg->params[1]);
-		const char *mentions = colorMentions(id, msg);
-		uiFormat(
-			id, heat, tagTime(msg),
-			"%s\3%d<%s>\17\t%s%s",
-			(mention ? "\26" : ""), hash(msg->user), msg->nick,
-			mentions, msg->params[1]
+		ptr = seprintf(
+			ptr, end, "%s\3%d<%s>\17\t",
+			(highlight ? "\26" : ""), hash(msg->user), msg->nick
 		);
 	}
+	if (notice) {
+		ptr = seprintf(ptr, end, "%s", msg->params[1]);
+	} else {
+		ptr = colorMentions(ptr, end, id, msg->params[1]);
+	}
+	uiWrite(id, heat, tagTime(msg), buf);
 }
 
 static void handlePing(struct Message *msg) {
@@ -1174,67 +1342,81 @@ static void handleError(struct Message *msg) {
 
 static const struct Handler {
 	const char *cmd;
+	int reply;
 	Handler *fn;
 } Handlers[] = {
-	{ "001", handleReplyWelcome },
-	{ "005", handleReplyISupport },
-	{ "221", handleReplyUserModeIs },
-	{ "276", handleReplyWhoisGeneric },
-	{ "301", handleReplyAway },
-	{ "305", handleReplyNowAway },
-	{ "306", handleReplyNowAway },
-	{ "307", handleReplyWhoisGeneric },
-	{ "311", handleReplyWhoisUser },
-	{ "312", handleReplyWhoisServer },
-	{ "313", handleReplyWhoisGeneric },
-	{ "317", handleReplyWhoisIdle },
-	{ "318", handleReplyEndOfWhois },
-	{ "319", handleReplyWhoisChannels },
-	{ "322", handleReplyList },
-	{ "323", handleReplyListEnd },
-	{ "324", handleReplyChannelModeIs },
-	{ "330", handleReplyWhoisGeneric },
-	{ "331", handleReplyNoTopic },
-	{ "332", handleReplyTopic },
-	{ "341", handleReplyInviting },
-	{ "346", handleReplyInviteList },
-	{ "347", handleReplyEndOfInviteList },
-	{ "348", handleReplyExceptList },
-	{ "349", handleReplyEndOfExceptList },
-	{ "353", handleReplyNames },
-	{ "366", handleReplyEndOfNames },
-	{ "367", handleReplyBanList },
-	{ "368", handleReplyEndOfBanList },
-	{ "372", handleReplyMOTD },
-	{ "378", handleReplyWhoisGeneric },
-	{ "379", handleReplyWhoisGeneric },
-	{ "422", handleErrorNoMOTD },
-	{ "432", handleErrorErroneousNickname },
-	{ "433", handleErrorNicknameInUse },
-	{ "441", handleErrorUserNotInChannel },
-	{ "443", handleErrorUserOnChannel },
-	{ "478", handleErrorBanListFull },
-	{ "482", handleErrorChanopPrivsNeeded },
-	{ "671", handleReplyWhoisGeneric },
-	{ "900", handleReplyLoggedIn },
-	{ "904", handleErrorSASLFail },
-	{ "905", handleErrorSASLFail },
-	{ "906", handleErrorSASLFail },
-	{ "AUTHENTICATE", handleAuthenticate },
-	{ "CAP", handleCap },
-	{ "CHGHOST", handleChghost },
-	{ "ERROR", handleError },
-	{ "INVITE", handleInvite },
-	{ "JOIN", handleJoin },
-	{ "KICK", handleKick },
-	{ "MODE", handleMode },
-	{ "NICK", handleNick },
-	{ "NOTICE", handlePrivmsg },
-	{ "PART", handlePart },
-	{ "PING", handlePing },
-	{ "PRIVMSG", handlePrivmsg },
-	{ "QUIT", handleQuit },
-	{ "TOPIC", handleTopic },
+	{ "001", 0, handleReplyWelcome },
+	{ "005", 0, handleReplyISupport },
+	{ "221", -ReplyMode, handleReplyUserModeIs },
+	{ "276", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "301", 0, handleReplyAway },
+	{ "305", -ReplyAway, handleReplyNowAway },
+	{ "306", -ReplyAway, handleReplyNowAway },
+	{ "307", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "311", +ReplyWhois, handleReplyWhoisUser },
+	{ "312", 0, handleReplyWhoisServer },
+	{ "313", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "314", +ReplyWhowas, handleReplyWhowasUser },
+	{ "317", +ReplyWhois, handleReplyWhoisIdle },
+	{ "318", -ReplyWhois, handleReplyEndOfWhois },
+	{ "319", +ReplyWhois, handleReplyWhoisChannels },
+	{ "320", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "322", +ReplyList, handleReplyList },
+	{ "323", -ReplyList, NULL },
+	{ "324", -ReplyMode, handleReplyChannelModeIs },
+	{ "330", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "331", -ReplyTopic, handleReplyNoTopic },
+	{ "332", 0, handleReplyTopic },
+	{ "335", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "338", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "341", 0, handleReplyInviting },
+	{ "346", +ReplyInvex, handleReplyInviteList },
+	{ "347", -ReplyInvex, NULL },
+	{ "348", +ReplyExcepts, handleReplyExceptList },
+	{ "349", -ReplyExcepts, NULL },
+	{ "353", 0, handleReplyNames },
+	{ "366", 0, handleReplyEndOfNames },
+	{ "367", +ReplyBan, handleReplyBanList },
+	{ "368", -ReplyBan, NULL },
+	{ "369", -ReplyWhowas, handleReplyEndOfWhowas },
+	{ "372", 0, handleReplyMOTD },
+	{ "378", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "379", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "422", 0, handleErrorNoMOTD },
+	{ "432", 0, handleErrorErroneousNickname },
+	{ "433", 0, handleErrorNicknameInUse },
+	{ "437", 0, handleErrorNicknameInUse },
+	{ "441", 0, handleErrorUserNotInChannel },
+	{ "443", 0, handleErrorUserOnChannel },
+	{ "478", 0, handleErrorBanListFull },
+	{ "482", 0, handleErrorChanopPrivsNeeded },
+	{ "671", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "704", +ReplyHelp, handleReplyHelp },
+	{ "705", +ReplyHelp, handleReplyHelp },
+	{ "706", -ReplyHelp, NULL },
+	{ "900", 0, handleReplyLoggedIn },
+	{ "904", 0, handleErrorSASLFail },
+	{ "905", 0, handleErrorSASLFail },
+	{ "906", 0, handleErrorSASLFail },
+	{ "AUTHENTICATE", 0, handleAuthenticate },
+	{ "CAP", 0, handleCap },
+	{ "CHGHOST", 0, handleChghost },
+	{ "ERROR", 0, handleError },
+	{ "FAIL", 0, handleStandardReply },
+	{ "INVITE", 0, handleInvite },
+	{ "JOIN", 0, handleJoin },
+	{ "KICK", 0, handleKick },
+	{ "MODE", 0, handleMode },
+	{ "NICK", 0, handleNick },
+	{ "NOTE", 0, handleStandardReply },
+	{ "NOTICE", 0, handlePrivmsg },
+	{ "PART", 0, handlePart },
+	{ "PING", 0, handlePing },
+	{ "PRIVMSG", 0, handlePrivmsg },
+	{ "QUIT", 0, handleQuit },
+	{ "SETNAME", 0, handleSetname },
+	{ "TOPIC", 0, handleTopic },
+	{ "WARN", 0, handleStandardReply },
 };
 
 static int compar(const void *cmd, const void *_handler) {
@@ -1251,8 +1433,12 @@ void handle(struct Message *msg) {
 		msg->cmd, Handlers, ARRAY_LEN(Handlers), sizeof(*handler), compar
 	);
 	if (handler) {
-		handler->fn(msg);
+		if (handler->reply && !replies[abs(handler->reply)]) return;
+		if (handler->fn) handler->fn(msg);
+		if (handler->reply < 0) replies[abs(handler->reply)]--;
 	} else if (strcmp(msg->cmd, "400") >= 0 && strcmp(msg->cmd, "599") <= 0) {
 		handleErrorGeneric(msg);
+	} else if (isdigit(msg->cmd[0])) {
+		handleReplyGeneric(msg);
 	}
 }
diff --git a/ignore.c b/ignore.c
deleted file mode 100644
index f8e4d59..0000000
--- a/ignore.c
+++ /dev/null
@@ -1,89 +0,0 @@
-/* Copyright (C) 2020  C. McEnroe <june@causal.agency>
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU 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 General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <https://www.gnu.org/licenses/>.
- *
- * Additional permission under GNU GPL version 3 section 7:
- *
- * If you modify this Program, or any covered work, by linking or
- * combining it with OpenSSL (or a modified version of that library),
- * containing parts covered by the terms of the OpenSSL License and the
- * original SSLeay license, the licensors of this Program grant you
- * additional permission to convey the resulting work. Corresponding
- * Source for a non-source form of such a combination shall include the
- * source code for the parts of OpenSSL used as well as that of the
- * covered work.
- */
-
-#include <err.h>
-#include <fnmatch.h>
-#include <stdbool.h>
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include <sysexits.h>
-
-#include "chat.h"
-
-struct Ignore ignore;
-
-const char *ignoreAdd(const char *pattern) {
-	if (ignore.len == IgnoreCap) errx(EX_CONFIG, "ignore limit exceeded");
-	uint ex = 0, sp = 0;
-	for (const char *ch = pattern; *ch; ++ch) {
-		if (*ch == '!') ex++;
-		if (*ch == ' ') sp++;
-	}
-	char **dest = &ignore.patterns[ignore.len++];
-	int n = 0;
-	if (!ex && !sp) {
-		n = asprintf(dest, "%s!*@* * * *", pattern);
-	} else if (sp < 1) {
-		n = asprintf(dest, "%s * * *", pattern);
-	} else if (sp < 2) {
-		n = asprintf(dest, "%s * *", pattern);
-	} else if (sp < 3) {
-		n = asprintf(dest, "%s *", pattern);
-	} else {
-		*dest = strdup(pattern);
-	}
-	if (n < 0) err(EX_OSERR, "asprintf");
-	if (!*dest) err(EX_OSERR, "strdup");
-	return *dest;
-}
-
-bool ignoreRemove(const char *pattern) {
-	bool found = false;
-	for (size_t i = 0; i < ignore.len; ++i) {
-		if (strcasecmp(ignore.patterns[i], pattern)) continue;
-		free(ignore.patterns[i]);
-		ignore.patterns[i] = ignore.patterns[--ignore.len];
-		found = true;
-	}
-	return found;
-}
-
-enum Heat ignoreCheck(enum Heat heat, uint id, const struct Message *msg) {
-	if (!ignore.len) return heat;
-	char match[512];
-	snprintf(
-		match, sizeof(match), "%s!%s@%s %s %s %s",
-		msg->nick, msg->user, msg->host,
-		msg->cmd, idNames[id], (msg->params[1] ?: "")
-	);
-	for (size_t i = 0; i < ignore.len; ++i) {
-		if (fnmatch(ignore.patterns[i], match, FNM_CASEFOLD)) continue;
-		return Ice;
-	}
-	return heat;
-}
diff --git a/input.c b/input.c
new file mode 100644
index 0000000..6b33b93
--- /dev/null
+++ b/input.c
@@ -0,0 +1,629 @@
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
+ */
+
+#define _XOPEN_SOURCE_EXTENDED
+
+#include <assert.h>
+#include <curses.h>
+#include <err.h>
+#include <signal.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+#include <termios.h>
+#include <unistd.h>
+#include <wchar.h>
+#include <wctype.h>
+
+#include "chat.h"
+#include "edit.h"
+
+#define ENUM_KEY \
+	X(KeyCtrlLeft, "\33[1;5D", NULL) \
+	X(KeyCtrlRight, "\33[1;5C", NULL) \
+	X(KeyMeta0, "\0330", "\33)") \
+	X(KeyMeta1, "\0331", "\33!") \
+	X(KeyMeta2, "\0332", "\33@") \
+	X(KeyMeta3, "\0333", "\33#") \
+	X(KeyMeta4, "\0334", "\33$") \
+	X(KeyMeta5, "\0335", "\33%") \
+	X(KeyMeta6, "\0336", "\33^") \
+	X(KeyMeta7, "\0337", "\33&") \
+	X(KeyMeta8, "\0338", "\33*") \
+	X(KeyMeta9, "\0339", "\33(") \
+	X(KeyMetaA, "\33a", NULL) \
+	X(KeyMetaB, "\33b", NULL) \
+	X(KeyMetaD, "\33d", NULL) \
+	X(KeyMetaF, "\33f", NULL) \
+	X(KeyMetaL, "\33l", NULL) \
+	X(KeyMetaM, "\33m", NULL) \
+	X(KeyMetaN, "\33n", NULL) \
+	X(KeyMetaP, "\33p", NULL) \
+	X(KeyMetaQ, "\33q", NULL) \
+	X(KeyMetaS, "\33s", NULL) \
+	X(KeyMetaT, "\33t", NULL) \
+	X(KeyMetaU, "\33u", NULL) \
+	X(KeyMetaV, "\33v", NULL) \
+	X(KeyMetaEnter, "\33\r", "\33\n") \
+	X(KeyMetaGt, "\33>", "\33.") \
+	X(KeyMetaLt, "\33<", "\33,") \
+	X(KeyMetaEqual, "\33=", NULL) \
+	X(KeyMetaMinus, "\33-", "\33_") \
+	X(KeyMetaPlus, "\33+", NULL) \
+	X(KeyMetaSlash, "\33/", "\33?") \
+	X(KeyFocusIn, "\33[I", NULL) \
+	X(KeyFocusOut, "\33[O", NULL) \
+	X(KeyPasteOn, "\33[200~", NULL) \
+	X(KeyPasteOff, "\33[201~", NULL) \
+	X(KeyPasteManual, "\32p", "\32\20")
+
+enum {
+	KeyMax = KEY_MAX,
+#define X(id, seq, alt) id,
+	ENUM_KEY
+#undef X
+};
+
+static struct Edit cut;
+static struct Edit edits[IDCap];
+
+void inputInit(void) {
+	for (size_t i = 0; i < ARRAY_LEN(edits); ++i) {
+		edits[i].cut = &cut;
+	}
+
+	struct termios term;
+	int error = tcgetattr(STDOUT_FILENO, &term);
+	if (error) err(EX_OSERR, "tcgetattr");
+
+	// Gain use of C-q, C-s, C-c, C-z, C-y, C-v, C-o.
+	term.c_iflag &= ~IXON;
+	term.c_cc[VINTR] = _POSIX_VDISABLE;
+	term.c_cc[VSUSP] = _POSIX_VDISABLE;
+#ifdef VDSUSP
+	term.c_cc[VDSUSP] = _POSIX_VDISABLE;
+#endif
+	term.c_cc[VLNEXT] = _POSIX_VDISABLE;
+	term.c_cc[VDISCARD] = _POSIX_VDISABLE;
+
+	error = tcsetattr(STDOUT_FILENO, TCSANOW, &term);
+	if (error) err(EX_OSERR, "tcsetattr");
+
+	def_prog_mode();
+
+#define X(id, seq, alt) define_key(seq, id); if (alt) define_key(alt, id);
+	ENUM_KEY
+#undef X
+
+	keypad(uiInput, true);
+	nodelay(uiInput, true);
+}
+
+static void inputAdd(struct Style reset, struct Style *style, const char *str) {
+	while (*str) {
+		const char *code = str;
+		size_t len = styleParse(style, &str);
+		wattr_set(uiInput, A_BOLD | A_REVERSE, 0, NULL);
+		switch (*code) {
+			break; case B: waddch(uiInput, 'B');
+			break; case C: waddch(uiInput, 'C');
+			break; case O: waddch(uiInput, 'O');
+			break; case R: waddch(uiInput, 'R');
+			break; case I: waddch(uiInput, 'I');
+			break; case U: waddch(uiInput, 'U');
+			break; case '\n': waddch(uiInput, 'N');
+		}
+		if (str - code > 1) waddnstr(uiInput, &code[1], str - &code[1]);
+		if (str[0] == '\n') {
+			*style = reset;
+			str++;
+			len--;
+		}
+		size_t nl = strcspn(str, "\n");
+		if (nl < len) len = nl;
+		wattr_set(uiInput, uiAttr(*style), uiPair(*style), NULL);
+		waddnstr(uiInput, str, len);
+		str += len;
+	}
+}
+
+static char *inputStop(
+	struct Style reset, struct Style *style,
+	const char *str, char *stop
+) {
+	char ch = *stop;
+	*stop = '\0';
+	inputAdd(reset, style, str);
+	*stop = ch;
+	return stop;
+}
+
+static size_t cap;
+static char *buf;
+
+void inputUpdate(void) {
+	uint id = windowID();
+
+	size_t pos = 0;
+	const char *ptr = editString(&edits[id], &buf, &cap, &pos);
+	if (!ptr) err(EX_OSERR, "editString");
+
+	const char *prefix = "";
+	const char *prompt = self.nick;
+	const char *suffix = "";
+	const char *skip = buf;
+	struct Style stylePrompt = { .fg = self.color, .bg = Default };
+	struct Style styleInput = StyleDefault;
+
+	size_t split = commandWillSplit(id, buf);
+	const char *privmsg = commandIsPrivmsg(id, buf);
+	const char *notice = commandIsNotice(id, buf);
+	const char *action = commandIsAction(id, buf);
+	if (privmsg) {
+		prefix = "<"; suffix = "> ";
+		skip = privmsg;
+	} else if (notice) {
+		prefix = "-"; suffix = "- ";
+		styleInput.fg = LightGray;
+		skip = notice;
+	} else if (action) {
+		prefix = "* "; suffix = " ";
+		stylePrompt.attr |= Italic;
+		styleInput.attr |= Italic;
+		skip = action;
+	} else if (id == Debug && buf[0] != '/') {
+		prompt = "<< ";
+		stylePrompt.fg = Gray;
+	} else {
+		prompt = "";
+	}
+	if (skip > &buf[pos]) {
+		prefix = prompt = suffix = "";
+		skip = buf;
+	}
+
+	int y, x;
+	wmove(uiInput, 0, 0);
+	if (windowTimeEnable() && id != Network) {
+		whline(uiInput, ' ', windowTime.width);
+		wmove(uiInput, 0, windowTime.width);
+	}
+	wattr_set(uiInput, uiAttr(stylePrompt), uiPair(stylePrompt), NULL);
+	waddstr(uiInput, prefix);
+	waddstr(uiInput, prompt);
+	waddstr(uiInput, suffix);
+	getyx(uiInput, y, x);
+
+	int posx;
+	struct Style style = styleInput;
+	inputStop(styleInput, &style, skip, &buf[pos]);
+	getyx(uiInput, y, posx);
+	wmove(uiInput, y, x);
+
+	ptr = skip;
+	style = styleInput;
+	if (split) {
+		ptr = inputStop(styleInput, &style, ptr, &buf[split]);
+		style = styleInput;
+		style.bg = Red;
+	}
+	inputAdd(styleInput, &style, ptr);
+	wclrtoeol(uiInput);
+	wmove(uiInput, y, posx);
+}
+
+bool inputPending(uint id) {
+	return edits[id].len;
+}
+
+static const struct {
+	const wchar_t *name;
+	const wchar_t *string;
+} Macros[] = {
+	{ L"\\banhammer", L"▬▬▬▬▬▬▬▋ Ò╭╮Ó" },
+	{ L"\\bear", L"ʕっ•ᴥ•ʔっ" },
+	{ L"\\blush", L"(˶′◡‵˶)" },
+	{ L"\\com", L"\0038,4\2 ☭ " },
+	{ L"\\cool", L"(⌐■_■)" },
+	{ L"\\flip", L"(╯°□°)╯︵ ┻━┻" },
+	{ L"\\gary", L"ᕕ( ᐛ )ᕗ" },
+	{ L"\\hug", L"(っ・∀・)っ" },
+	{ L"\\lenny", L"( ͡° ͜ʖ ͡°)" },
+	{ L"\\look", L"ಠ_ಠ" },
+	{ L"\\shrug", L"¯\\_(ツ)_/¯" },
+	{ L"\\unflip", L"┬─┬ノ(º_ºノ)" },
+	{ L"\\wave", L"ヾ(^∇^)" },
+};
+
+void inputCompletion(void) {
+	char mbs[256];
+	for (size_t i = 0; i < ARRAY_LEN(Macros); ++i) {
+		size_t n = wcstombs(mbs, Macros[i].name, sizeof(mbs));
+		assert(n != (size_t)-1);
+		completePush(None, mbs, Default);
+	}
+}
+
+static int macroExpand(struct Edit *e) {
+	size_t macro = e->pos;
+	while (macro && e->buf[macro] != L'\\') macro--;
+	if (macro == e->pos) return 0;
+	for (size_t i = 0; i < ARRAY_LEN(Macros); ++i) {
+		if (wcslen(Macros[i].name) != e->pos - macro) continue;
+		if (wcsncmp(Macros[i].name, &e->buf[macro], e->pos - macro)) continue;
+		if (wcstombs(NULL, Macros[i].string, 0) == (size_t)-1) continue;
+		size_t expand = wcslen(Macros[i].string);
+		int error = 0
+			|| editDelete(e, false, macro, e->pos - macro)
+			|| editReserve(e, macro, expand);
+		if (error) return error;
+		wcsncpy(&e->buf[macro], Macros[i].string, expand);
+		e->pos = macro + expand;
+		break;
+	}
+	return 0;
+}
+
+static struct {
+	uint id;
+	char *pre;
+	size_t pos;
+	size_t len;
+	bool suffix;
+	struct Cursor curs;
+} tab;
+
+static void tabAccept(void) {
+	completeAccept(&tab.curs);
+	tab.len = 0;
+}
+
+static void tabReject(void) {
+	completeReject(&tab.curs);
+	tab.len = 0;
+}
+
+static int tabComplete(struct Edit *e, uint id) {
+	if (tab.len && id != tab.id) {
+		tabAccept();
+	}
+
+	if (!tab.len) {
+		tab.id = id;
+		tab.pos = e->pos;
+		while (tab.pos && !iswspace(e->buf[tab.pos-1])) tab.pos--;
+		tab.len = e->pos - tab.pos;
+		if (!tab.len) return 0;
+
+		size_t cap = tab.len * MB_CUR_MAX + 1;
+		char *buf = realloc(tab.pre, cap);
+		if (!buf) return -1;
+		tab.pre = buf;
+
+		const wchar_t *ptr = &e->buf[tab.pos];
+		size_t n = wcsnrtombs(tab.pre, &ptr, tab.len, cap-1, NULL);
+		if (n == (size_t)-1) return -1;
+		tab.pre[n] = '\0';
+		tab.suffix = true;
+	}
+
+	const char *comp = completePrefix(&tab.curs, id, tab.pre);
+	if (!comp) {
+		comp = completePrefix(&tab.curs, id, tab.pre);
+		tab.suffix ^= true;
+	}
+	if (!comp) {
+		tab.len = 0;
+		return 0;
+	}
+
+	size_t cap = strlen(comp) + 1;
+	wchar_t *wcs = malloc(sizeof(*wcs) * cap);
+	if (!wcs) return -1;
+
+	size_t n = mbstowcs(wcs, comp, cap);
+	assert(n != (size_t)-1);
+
+	bool colon = (tab.len >= 2 && e->buf[tab.pos + tab.len - 2] == L':');
+
+	int error = editDelete(e, false, tab.pos, tab.len);
+	if (error) goto fail;
+
+	tab.len = n;
+	if (wcs[0] == L'\\' || wcschr(wcs, L' ')) {
+		error = editReserve(e, tab.pos, tab.len);
+		if (error) goto fail;
+	} else if (wcs[0] != L'/' && tab.suffix && (!tab.pos || colon)) {
+		tab.len += 2;
+		error = editReserve(e, tab.pos, tab.len);
+		if (error) goto fail;
+		e->buf[tab.pos + n + 0] = L':';
+		e->buf[tab.pos + n + 1] = L' ';
+	} else if (tab.suffix && tab.pos >= 2 && e->buf[tab.pos - 2] == L':') {
+		tab.len += 2;
+		error = editReserve(e, tab.pos, tab.len);
+		if (error) goto fail;
+		e->buf[tab.pos - 2] = L',';
+		e->buf[tab.pos + n + 0] = L':';
+		e->buf[tab.pos + n + 1] = L' ';
+	} else {
+		tab.len++;
+		error = editReserve(e, tab.pos, tab.len);
+		if (error) goto fail;
+		if (!tab.suffix && tab.pos >= 2 && e->buf[tab.pos - 2] == L',') {
+			e->buf[tab.pos - 2] = L':';
+		}
+		e->buf[tab.pos + n] = L' ';
+	}
+	wmemcpy(&e->buf[tab.pos], wcs, n);
+	e->pos = tab.pos + tab.len;
+	free(wcs);
+	return 0;
+
+fail:
+	free(wcs);
+	return -1;
+}
+
+static void inputEnter(void) {
+	uint id = windowID();
+	char *cmd = editString(&edits[id], &buf, &cap, NULL);
+	if (!cmd) err(EX_OSERR, "editString");
+
+	tabAccept();
+	editFn(&edits[id], EditClear);
+	command(id, cmd);
+}
+
+static void keyCode(int code) {
+	int error = 0;
+	struct Edit *edit = &edits[windowID()];
+	switch (code) {
+		break; case KEY_RESIZE:  uiResize();
+		break; case KeyFocusIn:  windowUnmark();
+		break; case KeyFocusOut: windowMark();
+
+		break; case KeyMetaEnter: error = editInsert(edit, L'\n');
+		break; case KeyMetaEqual: windowToggleMute();
+		break; case KeyMetaMinus: windowToggleThresh(-1);
+		break; case KeyMetaPlus:  windowToggleThresh(+1);
+		break; case KeyMetaSlash: windowSwap();
+
+		break; case KeyMetaGt: windowScroll(ScrollAll, -1);
+		break; case KeyMetaLt: windowScroll(ScrollAll, +1);
+
+		break; case KeyMeta0 ... KeyMeta9: windowShow(code - KeyMeta0);
+		break; case KeyMetaA: windowAuto();
+		break; case KeyMetaB: error = editFn(edit, EditPrevWord);
+		break; case KeyMetaD: error = editFn(edit, EditDeleteNextWord);
+		break; case KeyMetaF: error = editFn(edit, EditNextWord);
+		break; case KeyMetaL: windowBare();
+		break; case KeyMetaM: uiWrite(windowID(), Warm, NULL, "");
+		break; case KeyMetaN: windowScroll(ScrollHot, +1);
+		break; case KeyMetaP: windowScroll(ScrollHot, -1);
+		break; case KeyMetaQ: error = editFn(edit, EditCollapse);
+		break; case KeyMetaS: uiSpoilerReveal ^= true; windowUpdate();
+		break; case KeyMetaT: windowToggleTime();
+		break; case KeyMetaU: windowScroll(ScrollUnread, 0);
+		break; case KeyMetaV: windowScroll(ScrollPage, +1);
+
+		break; case KeyCtrlLeft: error = editFn(edit, EditPrevWord);
+		break; case KeyCtrlRight: error = editFn(edit, EditNextWord);
+
+		break; case KEY_BACKSPACE: error = editFn(edit, EditDeletePrev);
+		break; case KEY_DC: error = editFn(edit, EditDeleteNext);
+		break; case KEY_DOWN: windowScroll(ScrollOne, -1);
+		break; case KEY_END: error = editFn(edit, EditTail);
+		break; case KEY_ENTER: inputEnter();
+		break; case KEY_HOME: error = editFn(edit, EditHead);
+		break; case KEY_LEFT: error = editFn(edit, EditPrev);
+		break; case KEY_NPAGE: windowScroll(ScrollPage, -1);
+		break; case KEY_PPAGE: windowScroll(ScrollPage, +1);
+		break; case KEY_RIGHT: error = editFn(edit, EditNext);
+		break; case KEY_SEND: windowScroll(ScrollAll, -1);
+		break; case KEY_SHOME: windowScroll(ScrollAll, +1);
+		break; case KEY_UP: windowScroll(ScrollOne, +1);
+	}
+	if (error) err(EX_OSERR, "editFn");
+}
+
+static void keyCtrl(wchar_t ch) {
+	int error = 0;
+	struct Edit *edit = &edits[windowID()];
+	switch (ch ^ L'@') {
+		break; case L'?': error = editFn(edit, EditDeletePrev);
+		break; case L'A': error = editFn(edit, EditHead);
+		break; case L'B': error = editFn(edit, EditPrev);
+		break; case L'C': raise(SIGINT);
+		break; case L'D': error = editFn(edit, EditDeleteNext);
+		break; case L'E': error = editFn(edit, EditTail);
+		break; case L'F': error = editFn(edit, EditNext);
+		break; case L'H': error = editFn(edit, EditDeletePrev);
+		break; case L'I': error = tabComplete(edit, windowID());
+		break; case L'J': inputEnter();
+		break; case L'K': error = editFn(edit, EditDeleteTail);
+		break; case L'L': clearok(curscr, true); wrefresh(curscr);
+		break; case L'N': windowShow(windowNum() + 1);
+		break; case L'P': windowShow(windowNum() - 1);
+		break; case L'R': windowSearch(editString(edit, &buf, &cap, NULL), -1);
+		break; case L'S': windowSearch(editString(edit, &buf, &cap, NULL), +1);
+		break; case L'T': error = editFn(edit, EditTranspose);
+		break; case L'U': error = editFn(edit, EditDeleteHead);
+		break; case L'V': windowScroll(ScrollPage, -1);
+		break; case L'W': error = editFn(edit, EditDeletePrevWord);
+		break; case L'X': error = macroExpand(edit); tabAccept();
+		break; case L'Y': error = editFn(edit, EditPaste);
+	}
+	if (error) err(EX_OSERR, "editFn");
+}
+
+static void keyStyle(wchar_t ch) {
+	if (iswcntrl(ch)) ch = towlower(ch ^ L'@');
+	char buf[8] = {0};
+	enum Color color = Default;
+	switch (ch) {
+		break; case L'A': color = Gray;
+		break; case L'B': color = Blue;
+		break; case L'C': color = Cyan;
+		break; case L'G': color = Green;
+		break; case L'K': color = Black;
+		break; case L'M': color = Magenta;
+		break; case L'N': color = Brown;
+		break; case L'O': color = Orange;
+		break; case L'P': color = Pink;
+		break; case L'R': color = Red;
+		break; case L'W': color = White;
+		break; case L'Y': color = Yellow;
+		break; case L'b': buf[0] = B;
+		break; case L'c': buf[0] = C;
+		break; case L'i': buf[0] = I;
+		break; case L'o': buf[0] = O;
+		break; case L'r': buf[0] = R;
+		break; case L's': {
+			snprintf(buf, sizeof(buf), "%c%02d,%02d", C, Black, Black);
+		}
+		break; case L'u': buf[0] = U;
+	}
+	if (color != Default) {
+		snprintf(buf, sizeof(buf), "%c%02d", C, color);
+	}
+	struct Edit *edit = &edits[windowID()];
+	for (char *ch = buf; *ch; ++ch) {
+		int error = editInsert(edit, *ch);
+		if (error) err(EX_OSERR, "editInsert");
+	}
+}
+
+static bool waiting;
+
+void inputWait(void) {
+	waiting = true;
+}
+
+void inputRead(void) {
+	if (isendwin()) {
+		if (waiting) {
+			uiShow();
+			flushinp();
+			waiting = false;
+		} else {
+			return;
+		}
+	}
+
+	wint_t ch;
+	static bool paste, style, literal;
+	for (int ret; ERR != (ret = wget_wch(uiInput, &ch));) {
+		bool tabbing = false;
+		size_t pos = edits[tab.id].pos;
+		bool spr = uiSpoilerReveal;
+
+		if (ret == KEY_CODE_YES && ch == KeyPasteOn) {
+			paste = true;
+		} else if (ret == KEY_CODE_YES && ch == KeyPasteOff) {
+			paste = false;
+		} else if (ret == KEY_CODE_YES && ch == KeyPasteManual) {
+			paste ^= true;
+		} else if (paste || literal) {
+			int error = editInsert(&edits[windowID()], ch);
+			if (error) err(EX_OSERR, "editInsert");
+		} else if (ret == KEY_CODE_YES) {
+			keyCode(ch);
+		} else if (ch == (L'Z' ^ L'@')) {
+			style = true;
+			continue;
+		} else if (style && ch == (L'V' ^ L'@')) {
+			literal = true;
+			continue;
+		} else if (style) {
+			keyStyle(ch);
+		} else if (iswcntrl(ch)) {
+			tabbing = (ch == (L'I' ^ L'@'));
+			keyCtrl(ch);
+		} else {
+			int error = editInsert(&edits[windowID()], ch);
+			if (error) err(EX_OSERR, "editInsert");
+		}
+		style = false;
+		literal = false;
+
+		if (!tabbing) {
+			if (edits[tab.id].pos > pos) {
+				tabAccept();
+			} else if (edits[tab.id].pos < pos) {
+				tabReject();
+			}
+		}
+
+		if (spr) {
+			uiSpoilerReveal = false;
+			windowUpdate();
+		}
+	}
+	inputUpdate();
+}
+
+static int writeString(FILE *file, const char *str) {
+	return (fwrite(str, strlen(str) + 1, 1, file) ? 0 : -1);
+}
+
+int inputSave(FILE *file) {
+	int error;
+	for (uint id = 0; id < IDCap; ++id) {
+		if (!edits[id].len) continue;
+		char *ptr = editString(&edits[id], &buf, &cap, NULL);
+		if (!ptr) return -1;
+		error = 0
+			|| writeString(file, idNames[id])
+			|| writeString(file, ptr);
+		if (error) return error;
+	}
+	return writeString(file, "");
+}
+
+static ssize_t readString(FILE *file, char **buf, size_t *cap) {
+	ssize_t len = getdelim(buf, cap, '\0', file);
+	if (len < 0 && !feof(file)) err(EX_IOERR, "getdelim");
+	return len;
+}
+
+void inputLoad(FILE *file, size_t version) {
+	if (version < 8) return;
+	while (0 < readString(file, &buf, &cap) && buf[0]) {
+		uint id = idFor(buf);
+		readString(file, &buf, &cap);
+		size_t max = strlen(buf);
+		int error = editReserve(&edits[id], 0, max);
+		if (error) err(EX_OSERR, "editReserve");
+		size_t len = mbstowcs(edits[id].buf, buf, max);
+		assert(len != (size_t)-1);
+		edits[id].len = len;
+		edits[id].pos = len;
+	}
+}
diff --git a/irc.c b/irc.c
index 59b467c..1fc2c3f 100644
--- a/irc.c
+++ b/irc.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2020  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -27,6 +27,9 @@
 
 #include <assert.h>
 #include <err.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
 #include <netdb.h>
 #include <netinet/in.h>
 #include <stdarg.h>
@@ -41,63 +44,59 @@
 
 #include "chat.h"
 
-struct tls *client;
+static struct tls *client;
+static struct tls_config *config;
 
-static byte *readFile(size_t *len, FILE *file) {
-	struct stat stat;
-	int error = fstat(fileno(file), &stat);
-	if (error) err(EX_IOERR, "fstat");
+void ircConfig(
+	bool insecure, const char *trust, const char *cert, const char *priv
+) {
+	int error = 0;
+	char buf[PATH_MAX];
 
-	byte *buf = malloc(stat.st_size);
-	if (!buf) err(EX_OSERR, "malloc");
-
-	rewind(file);
-	*len = fread(buf, 1, stat.st_size, file);
-	if (ferror(file)) err(EX_IOERR, "fread");
-
-	return buf;
-}
-
-void ircConfig(bool insecure, FILE *cert, FILE *priv) {
-	struct tls_config *config = tls_config_new();
+	config = tls_config_new();
 	if (!config) errx(EX_SOFTWARE, "tls_config_new");
 
-	int error = tls_config_set_ciphers(config, "compat");
-	if (error) {
-		errx(
-			EX_SOFTWARE, "tls_config_set_ciphers: %s",
-			tls_config_error(config)
-		);
-	}
-
 	if (insecure) {
 		tls_config_insecure_noverifycert(config);
 		tls_config_insecure_noverifyname(config);
 	}
+	if (trust) {
+		tls_config_insecure_noverifyname(config);
+		for (int i = 0; configPath(buf, sizeof(buf), trust, i); ++i) {
+			error = tls_config_set_ca_file(config, buf);
+			if (!error) break;
+		}
+		if (error) errx(EX_NOINPUT, "%s: %s", trust, tls_config_error(config));
+	}
+
+	// Explicitly load the default CA cert file on OpenBSD now so it doesn't
+	// need to be unveiled. Other systems might use a CA directory, so avoid
+	// changing the default behavior.
+#ifdef __OpenBSD__
+	if (!insecure && !trust) {
+		const char *ca = tls_default_ca_cert_file();
+		error = tls_config_set_ca_file(config, ca);
+		if (error) errx(EX_OSFILE, "%s: %s", ca, tls_config_error(config));
+	}
+#endif
 
 	if (cert) {
-		size_t len;
-		byte *buf = readFile(&len, cert);
-		error = tls_config_set_cert_mem(config, buf, len);
-		if (error) {
-			errx(
-				EX_CONFIG, "tls_config_set_cert_mem: %s",
-				tls_config_error(config)
-			);
-		}
-		if (priv) {
-			free(buf);
-			buf = readFile(&len, priv);
+		for (int i = 0; configPath(buf, sizeof(buf), cert, i); ++i) {
+			if (priv) {
+				error = tls_config_set_cert_file(config, buf);
+			} else {
+				error = tls_config_set_keypair_file(config, buf, buf);
+			}
+			if (!error) break;
 		}
-		error = tls_config_set_key_mem(config, buf, len);
-		if (error) {
-			errx(
-				EX_CONFIG, "tls_config_set_key_mem: %s",
-				tls_config_error(config)
-			);
+		if (error) errx(EX_NOINPUT, "%s: %s", cert, tls_config_error(config));
+	}
+	if (priv) {
+		for (int i = 0; configPath(buf, sizeof(buf), priv, i); ++i) {
+			error = tls_config_set_key_file(config, buf);
+			if (!error) break;
 		}
-		explicit_bzero(buf, len);
-		free(buf);
+		if (error) errx(EX_NOINPUT, "%s: %s", priv, tls_config_error(config));
 	}
 
 	client = tls_client();
@@ -105,7 +104,6 @@ void ircConfig(bool insecure, FILE *cert, FILE *priv) {
 
 	error = tls_configure(client, config);
 	if (error) errx(EX_SOFTWARE, "tls_configure: %s", tls_error(client));
-	tls_config_free(config);
 }
 
 int ircConnect(const char *bindHost, const char *host, const char *port) {
@@ -152,6 +150,7 @@ int ircConnect(const char *bindHost, const char *host, const char *port) {
 
 		error = connect(sock, ai->ai_addr, ai->ai_addrlen);
 		if (!error) break;
+		if (error && errno == EINTR) break; // connect continues asynchronously
 
 		close(sock);
 		sock = -1;
@@ -159,13 +158,29 @@ int ircConnect(const char *bindHost, const char *host, const char *port) {
 	if (sock < 0) err(EX_UNAVAILABLE, "%s:%s", host, port);
 	freeaddrinfo(head);
 
+	fcntl(sock, F_SETFD, FD_CLOEXEC);
 	error = tls_connect_socket(client, sock, host);
 	if (error) errx(EX_PROTOCOL, "tls_connect: %s", tls_error(client));
 
-	error = tls_handshake(client);
+	return sock;
+}
+
+void ircHandshake(void) {
+	int error;
+	do {
+		error = tls_handshake(client);
+	} while (error == TLS_WANT_POLLIN || error == TLS_WANT_POLLOUT);
 	if (error) errx(EX_PROTOCOL, "tls_handshake: %s", tls_error(client));
 
-	return sock;
+	tls_config_clear_keys(config);
+}
+
+void ircPrintCert(void) {
+	size_t len;
+	ircHandshake();
+	const byte *pem = tls_peer_cert_chain_pem(client, &len);
+	printf("subject= %s\n", tls_peer_cert_subject(client));
+	fwrite(pem, len, 1, stdout);
 }
 
 enum { MessageCap = 8191 + 512 };
@@ -235,8 +250,12 @@ static struct Message parse(char *line) {
 			char *key = strsep(&tag, "=");
 			for (uint i = 0; i < TagCap; ++i) {
 				if (strcmp(key, TagNames[i])) continue;
-				unescape(tag);
-				msg.tags[i] = tag;
+				if (tag) {
+					unescape(tag);
+					msg.tags[i] = tag;
+				} else {
+					msg.tags[i] = "";
+				}
 				break;
 			}
 		}
diff --git a/log.c b/log.c
index c114c41..d6b3f2a 100644
--- a/log.c
+++ b/log.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2020  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -27,16 +27,60 @@
 
 #include <assert.h>
 #include <err.h>
+#include <errno.h>
+#include <fcntl.h>
 #include <limits.h>
 #include <stdarg.h>
 #include <stdio.h>
 #include <stdlib.h>
+#include <sys/stat.h>
 #include <sysexits.h>
 #include <time.h>
+#include <unistd.h>
+
+#ifdef __FreeBSD__
+#include <capsicum_helpers.h>
+#endif
 
 #include "chat.h"
 
-bool logEnable;
+static int logDir = -1;
+
+void logOpen(void) {
+	char buf[PATH_MAX];
+	int error = mkdir(dataPath(buf, sizeof(buf), "", 0), S_IRWXU);
+	if (error && errno != EEXIST) err(EX_CANTCREAT, "%s", buf);
+
+	error = mkdir(dataPath(buf, sizeof(buf), "log", 0), S_IRWXU);
+	if (error && errno != EEXIST) err(EX_CANTCREAT, "%s", buf);
+
+	logDir = open(buf, O_RDONLY | O_CLOEXEC);
+	if (logDir < 0) err(EX_CANTCREAT, "%s", buf);
+
+#ifdef __FreeBSD__
+	cap_rights_t rights;
+	cap_rights_init(
+		&rights, CAP_MKDIRAT, CAP_CREATE, CAP_WRITE,
+		/* for fdopen(3) */ CAP_FCNTL, CAP_FSTAT
+	);
+	error = caph_rights_limit(logDir, &rights);
+	if (error) err(EX_OSERR, "cap_rights_limit");
+#endif
+}
+
+static void logMkdir(const char *path) {
+	int error = mkdirat(logDir, path, S_IRWXU);
+	if (error && errno != EEXIST) err(EX_CANTCREAT, "log/%s", path);
+}
+
+static void sanitize(char *ptr, char *end) {
+	for (char *ch = ptr; ch < end && *ch == '.'; ++ch) {
+		*ch = '_';
+	}
+	for (char *ch = ptr; ch < end; ++ch) {
+		if (*ch == '/') *ch = '_';
+	}
+}
 
 static struct {
 	int year;
@@ -62,44 +106,46 @@ static FILE *logFile(uint id, const struct tm *tm) {
 	logs[id].month = tm->tm_mon;
 	logs[id].day = tm->tm_mday;
 
-	char path[PATH_MAX] = "log";
-	size_t len = strlen(path);
-	dataMkdir("");
-	dataMkdir(path);
+	char path[PATH_MAX];
+	char *ptr = path, *end = &path[sizeof(path)];
 
-	path[len++] = '/';
-	for (const char *ch = network.name; *ch; ++ch) {
-		path[len++] = (*ch == '/' ? '_' : *ch);
-	}
-	path[len] = '\0';
-	dataMkdir(path);
+	ptr = seprintf(ptr, end, "%s", network.name);
+	sanitize(path, ptr);
+	logMkdir(path);
 
-	path[len++] = '/';
-	for (const char *ch = idNames[id]; *ch; ++ch) {
-		path[len++] = (*ch == '/' ? '_' : *ch);
-	}
-	path[len] = '\0';
-	dataMkdir(path);
+	char *name = ptr;
+	ptr = seprintf(ptr, end, "/%s", idNames[id]);
+	sanitize(&name[1], ptr);
+	logMkdir(path);
+
+	size_t len = strftime(ptr, end - ptr, "/%F.log", tm);
+	if (!len) errx(EX_CANTCREAT, "log path too long");
 
-	strftime(&path[len], sizeof(path) - len, "/%F.log", tm);
-	logs[id].file = dataOpen(path, "a");
-	if (!logs[id].file) exit(EX_CANTCREAT);
+	int fd = openat(
+		logDir, path,
+		O_WRONLY | O_APPEND | O_CREAT | O_CLOEXEC,
+		S_IRUSR | S_IWUSR
+	);
+	if (fd < 0) err(EX_CANTCREAT, "log/%s", path);
+	logs[id].file = fdopen(fd, "a");
+	if (!logs[id].file) err(EX_OSERR, "fdopen");
 
 	setlinebuf(logs[id].file);
 	return logs[id].file;
 }
 
 void logClose(void) {
-	if (!logEnable) return;
+	if (logDir < 0) return;
 	for (uint id = 0; id < IDCap; ++id) {
 		if (!logs[id].file) continue;
 		int error = fclose(logs[id].file);
 		if (error) err(EX_IOERR, "%s", idNames[id]);
 	}
+	close(logDir);
 }
 
 void logFormat(uint id, const time_t *src, const char *format, ...) {
-	if (!logEnable) return;
+	if (logDir < 0) return;
 
 	time_t ts = (src ? *src : time(NULL));
 	struct tm *tm = localtime(&ts);
@@ -109,15 +155,15 @@ void logFormat(uint id, const time_t *src, const char *format, ...) {
 
 	char buf[sizeof("0000-00-00T00:00:00+0000")];
 	strftime(buf, sizeof(buf), "%FT%T%z", tm);
-	fprintf(file, "[%s] ", buf);
-	if (ferror(file)) err(EX_IOERR, "%s", idNames[id]);
+	int n = fprintf(file, "[%s] ", buf);
+	if (n < 0) err(EX_IOERR, "%s", idNames[id]);
 
 	va_list ap;
 	va_start(ap, format);
-	vfprintf(file, format, ap);
+	n = vfprintf(file, format, ap);
 	va_end(ap);
-	if (ferror(file)) err(EX_IOERR, "%s", idNames[id]);
+	if (n < 0) err(EX_IOERR, "%s", idNames[id]);
 
-	fprintf(file, "\n");
-	if (ferror(file)) err(EX_IOERR, "%s", idNames[id]);
+	n = fprintf(file, "\n");
+	if (n < 0) err(EX_IOERR, "%s", idNames[id]);
 }
diff --git a/scripts/sandman.1 b/sandman.1
index 35765ec..92828c0 100644
--- a/scripts/sandman.1
+++ b/sandman.1
@@ -33,4 +33,4 @@ The default is 8 seconds.
 .El
 .
 .Sh AUTHORS
-.An June Bug Aq Mt june@causal.agency
+.An June McEnroe Aq Mt june@causal.agency
diff --git a/scripts/sandman.m b/sandman.m
index 99899ab..2e5c4db 100644
--- a/scripts/sandman.m
+++ b/sandman.m
@@ -1,4 +1,4 @@
-/* Copyright (C) 2019, 2020  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2019, 2020  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
diff --git a/scripts/.gitignore b/scripts/.gitignore
deleted file mode 100644
index f6dc107..0000000
--- a/scripts/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-sandman
diff --git a/scripts/build-chroot.sh b/scripts/build-chroot.sh
new file mode 100644
index 0000000..a0fcf32
--- /dev/null
+++ b/scripts/build-chroot.sh
@@ -0,0 +1,74 @@
+#!/bin/sh
+set -eux
+
+CHROOT_USER=$1
+CHROOT_GROUP=$2
+
+if [ "$(uname)" = 'OpenBSD' ]; then
+	install -d -o root -g wheel \
+		root \
+		root/bin \
+		root/etc/ssl \
+		root/home \
+		root/usr/bin \
+		root/usr/lib \
+		root/usr/libexec \
+		root/usr/share/man
+	install -d -o ${CHROOT_USER} -g ${CHROOT_GROUP} \
+		root/home/${CHROOT_USER} \
+		root/home/${CHROOT_USER}/.local/share
+
+	cp -fp /bin/sh root/bin
+	cp -fp /usr/libexec/ld.so root/usr/libexec
+	export LD_TRACE_LOADED_OBJECTS_FMT1='%p\n'
+	export LD_TRACE_LOADED_OBJECTS_FMT2=''
+	for bin in ./catgirl /usr/bin/mandoc /usr/bin/less; do
+		LD_TRACE_LOADED_OBJECTS=1 $bin | xargs -t -J % cp -fp % root/usr/lib
+	done
+	cp -fp /usr/bin/printf /usr/bin/mandoc /usr/bin/less root/usr/bin
+	make install DESTDIR=root PREFIX=/usr MANDIR=/usr/share/man
+	install scripts/chroot-prompt.sh root/usr/bin/catgirl-prompt
+	install scripts/chroot-man.sh root/usr/bin/man
+
+	cp -fp /etc/hosts /etc/resolv.conf root/etc
+	cp -fp /etc/ssl/cert.pem root/etc/ssl
+	cp -af /usr/share/locale /usr/share/terminfo root/usr/share
+
+	tar -c -f chroot.tar -C root bin etc home usr
+
+elif [ "$(uname)" = 'FreeBSD' ]; then
+	install -d -o root -g wheel \
+		root \
+		root/bin \
+		root/etc \
+		root/home \
+		root/lib \
+		root/libexec \
+		root/usr/bin \
+		root/usr/local/etc/ssl \
+		root/usr/share/man \
+		root/usr/share/misc
+	install -d -o ${CHROOT_USER} -g ${CHROOT_GROUP} \
+		root/home/${CHROOT_USER} \
+		root/home/${CHROOT_USER}/.local/share
+
+	cp -fp /libexec/ld-elf.so.1 root/libexec
+	ldd -f '%p\n' catgirl /usr/bin/mandoc /usr/bin/less \
+		| sort -u | xargs -t -J % cp -fp % root/lib
+	chflags noschg root/libexec/* root/lib/*
+	cp -fp /rescue/sh /usr/bin/mandoc /usr/bin/less root/bin
+	make install DESTDIR=root PREFIX=/usr MANDIR=/usr/share/man
+	install scripts/chroot-prompt.sh root/usr/bin/catgirl-prompt
+	install scripts/chroot-man.sh root/usr/bin/man
+
+	cp -fp /etc/hosts /etc/resolv.conf root/etc
+	cp -fp /usr/local/etc/ssl/cert.pem root/usr/local/etc/ssl
+	cp -af /usr/share/locale root/usr/share
+	cp -fp /usr/share/misc/termcap.db root/usr/share/misc
+
+	tar -c -f chroot.tar -C root bin etc home lib libexec usr
+
+else
+	echo "Don't know how to build chroot on $(uname)" >&2
+	exit 1
+fi
diff --git a/scripts/chat.tmux.conf b/scripts/chat.tmux.conf
new file mode 100644
index 0000000..3489a19
--- /dev/null
+++ b/scripts/chat.tmux.conf
@@ -0,0 +1,64 @@
+# use `tmux -L chat -f ./chat.tmux.conf attach-session' (without any other
+# options or parameters) to access this session group in its own tmux server,
+# not interfering with existing servers/sessions/configurations
+
+new-session -t chat
+
+# catgirl(1) puts windows at the top
+set-option -g	-- status-position	top
+
+# intuitive navigation
+set-option -g	-- mode-keys	vi
+set-option -g	-- mouse	on
+
+# indicate new messages
+set-option -g	-- monitor-activity	on
+set-option -g	-- monitor-bell	on
+
+# hardcode names during window creation
+set-option -g	-- automatic-rename	off
+set-option -g	-- allow-rename	off
+set-option -g	-- set-titles	off
+set-option -g	-- renumber-windows	on
+# for the F1 binding, make hotkeys match window numbers
+set-option -g	-- base-index	1
+
+
+# clients exit on network errors, restart them automatically
+# (use `kill-pane'/`C-b x' to destroy windows)
+set-option -g	-- remain-on-exit	on
+set-hook -g	-- pane-died	respawn-pane
+
+
+# disarm ^C to avoid accidentially losing logs
+bind-key -n -N 'confirm INTR key'	-- C-c	\
+	confirm-before -p 'Send ^C? (y/N)'	-- 'send-keys	-- C-c'
+
+# one-click version of default `C-b w' (shows preview windows)
+bind-key -n -N 'pick chat network'	-- F1	choose-tree -Z
+
+# catgirl(1) might run in `-R'/`restrict'ed mode, i.e. `/help' is disabled
+bind-key -n -N 'read catgirl help'	-- F2	\
+	new-window -S -n help	-- man -s 1	-- catgirl
+
+# intuitive refresh, just don't spam it ;-)
+bind-key -n -N 'reconnect network'	-- F5	\
+	confirm-before -p 'reconnect network? (y/N)'	-- 'respawn-pane -k'
+
+# immersive mode ;-)
+bind-key -n -N 'toggle fullscreen'	-- F11	set status
+
+
+# this configuration is idempotent, i.e. reloading it only changes settings
+# and never duplicates already existing windows
+bind-key -N 'reload configuration'	-- R	{
+	source-file -F	-- '#{source_files}'
+	display-message	-- 'configuration reloaded'
+}
+
+
+## do not double-quote commands to avoid running through "sh -c"
+
+# IRC
+new-window -d -S -n hackint	-- catgirl	-- defaults hackint
+new-window -d -S -n efnet	-- catgirl	-- defaults efnet
diff --git a/scripts/chroot-prompt.sh b/scripts/chroot-prompt.sh
index 3b43841..2b34426 100644
--- a/scripts/chroot-prompt.sh
+++ b/scripts/chroot-prompt.sh
@@ -3,4 +3,5 @@ set -eu
 
 printf 'Name: '
 read -r nick rest
-exec catgirl -n "$nick" -s "$nick" "$@"
+printf '%s %s\n' "$nick" "$SSH_CLIENT" >>nicks.log
+exec catgirl -K -n "$nick" -s "${nick##*/}" -u "${SSH_CLIENT%% *}" "$@"
diff --git a/ui.c b/ui.c
index 59903c9..079ee19 100644
--- a/ui.c
+++ b/ui.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2020  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -28,159 +28,35 @@
 #define _XOPEN_SOURCE_EXTENDED
 
 #include <assert.h>
-#include <ctype.h>
 #include <curses.h>
 #include <err.h>
 #include <errno.h>
-#include <limits.h>
-#include <signal.h>
+#include <fcntl.h>
+#include <inttypes.h>
 #include <stdarg.h>
 #include <stdbool.h>
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
+#include <sys/file.h>
 #include <sysexits.h>
 #include <term.h>
-#include <termios.h>
 #include <time.h>
 #include <unistd.h>
-#include <wchar.h>
-#include <wctype.h>
 
-#include "chat.h"
-
-// Annoying stuff from <term.h>:
-#undef lines
-#undef tab
-
-#ifndef A_ITALIC
-#define A_ITALIC A_NORMAL
+#ifdef __FreeBSD__
+#include <capsicum_helpers.h>
 #endif
 
-enum {
-	StatusLines = 1,
-	WindowLines = 1024,
-	MarkerLines = 1,
-	SplitLines = 5,
-	InputLines = 1,
-	InputCols = 1024,
-};
+#include "chat.h"
 
 #define BOTTOM (LINES - 1)
 #define RIGHT (COLS - 1)
 #define MAIN_LINES (LINES - StatusLines - InputLines)
 
-static WINDOW *status;
-static WINDOW *marker;
-static WINDOW *input;
-
-struct Line {
-	enum Heat heat;
-	time_t time;
-	char *str;
-};
-
-enum { BufferCap = 1024 };
-struct Buffer {
-	size_t len;
-	struct Line lines[BufferCap];
-};
-_Static_assert(!(BufferCap & (BufferCap - 1)), "BufferCap is power of two");
-
-static void bufferPush(
-	struct Buffer *buffer, enum Heat heat, time_t time, const char *str
-) {
-	struct Line *line = &buffer->lines[buffer->len++ % BufferCap];
-	free(line->str);
-	line->heat = heat;
-	line->time = time;
-	line->str = strdup(str);
-	if (!line->str) err(EX_OSERR, "strdup");
-}
-
-static struct Line bufferLine(const struct Buffer *buffer, size_t i) {
-	return buffer->lines[(buffer->len + i) % BufferCap];
-}
-
-struct Window {
-	uint id;
-	WINDOW *pad;
-	int scroll;
-	bool mark;
-	bool mute;
-	bool ignore;
-	enum Heat heat;
-	uint unreadHard;
-	uint unreadSoft;
-	uint unreadWarm;
-	struct Buffer buffer;
-};
-
-static struct {
-	struct Window *ptrs[IDCap];
-	uint len;
-	uint show;
-	uint swap;
-	uint user;
-} windows;
-
-static uint windowPush(struct Window *window) {
-	assert(windows.len < IDCap);
-	windows.ptrs[windows.len] = window;
-	return windows.len++;
-}
-
-static uint windowInsert(uint num, struct Window *window) {
-	assert(windows.len < IDCap);
-	assert(num <= windows.len);
-	memmove(
-		&windows.ptrs[num + 1],
-		&windows.ptrs[num],
-		sizeof(*windows.ptrs) * (windows.len - num)
-	);
-	windows.ptrs[num] = window;
-	windows.len++;
-	return num;
-}
-
-static struct Window *windowRemove(uint num) {
-	assert(num < windows.len);
-	struct Window *window = windows.ptrs[num];
-	windows.len--;
-	memmove(
-		&windows.ptrs[num],
-		&windows.ptrs[num + 1],
-		sizeof(*windows.ptrs) * (windows.len - num)
-	);
-	return window;
-}
-
-static uint windowFor(uint id) {
-	for (uint num = 0; num < windows.len; ++num) {
-		if (windows.ptrs[num]->id == id) return num;
-	}
-
-	struct Window *window = calloc(1, sizeof(*window));
-	if (!window) err(EX_OSERR, "malloc");
-
-	window->id = id;
-	window->pad = newpad(WindowLines, COLS);
-	if (!window->pad) err(EX_OSERR, "newpad");
-	scrollok(window->pad, true);
-	wmove(window->pad, WindowLines - 1, 0);
-	window->mark = true;
-	window->ignore = true;
-
-	return windowPush(window);
-}
-
-static void windowFree(struct Window *window) {
-	for (size_t i = 0; i < BufferCap; ++i) {
-		free(window->buffer.lines[i].str);
-	}
-	delwin(window->pad);
-	free(window);
-}
+WINDOW *uiStatus;
+WINDOW *uiMain;
+WINDOW *uiInput;
 
 static short colorPairs;
 
@@ -215,71 +91,14 @@ static short colorPair(short fg, short bg) {
 	return colorPairs++;
 }
 
-#define ENUM_KEY \
-	X(KeyMeta0, "\0330", "\33)") \
-	X(KeyMeta1, "\0331", "\33!") \
-	X(KeyMeta2, "\0332", "\33@") \
-	X(KeyMeta3, "\0333", "\33#") \
-	X(KeyMeta4, "\0334", "\33$") \
-	X(KeyMeta5, "\0335", "\33%") \
-	X(KeyMeta6, "\0336", "\33^") \
-	X(KeyMeta7, "\0337", "\33&") \
-	X(KeyMeta8, "\0338", "\33*") \
-	X(KeyMeta9, "\0339", "\33(") \
-	X(KeyMetaA, "\33a", NULL) \
-	X(KeyMetaB, "\33b", NULL) \
-	X(KeyMetaD, "\33d", NULL) \
-	X(KeyMetaF, "\33f", NULL) \
-	X(KeyMetaL, "\33l", NULL) \
-	X(KeyMetaM, "\33m", NULL) \
-	X(KeyMetaQ, "\33q", NULL) \
-	X(KeyMetaU, "\33u", NULL) \
-	X(KeyMetaV, "\33v", NULL) \
-	X(KeyMetaEnter, "\33\r", "\33\n") \
-	X(KeyMetaGt, "\33>", "\33.") \
-	X(KeyMetaLt, "\33<", "\33,") \
-	X(KeyMetaEqual, "\33=", "\33+") \
-	X(KeyMetaMinus, "\33-", "\33_") \
-	X(KeyMetaSlash, "\33/", "\33?") \
-	X(KeyFocusIn, "\33[I", NULL) \
-	X(KeyFocusOut, "\33[O", NULL) \
-	X(KeyPasteOn, "\33[200~", NULL) \
-	X(KeyPasteOff, "\33[201~", NULL)
-
-enum {
-	KeyMax = KEY_MAX,
-#define X(id, seq, alt) id,
-	ENUM_KEY
-#undef X
-};
-
-// Gain use of C-q, C-s, C-c, C-z, C-y, C-v, C-o.
-static void acquireKeys(void) {
-	struct termios term;
-	int error = tcgetattr(STDOUT_FILENO, &term);
-	if (error) err(EX_OSERR, "tcgetattr");
-	term.c_iflag &= ~IXON;
-	term.c_cc[VINTR] = _POSIX_VDISABLE;
-	term.c_cc[VSUSP] = _POSIX_VDISABLE;
-#ifdef VDSUSP
-	term.c_cc[VDSUSP] = _POSIX_VDISABLE;
-#endif
-	term.c_cc[VLNEXT] = _POSIX_VDISABLE;
-	term.c_cc[VDISCARD] = _POSIX_VDISABLE;
-	error = tcsetattr(STDOUT_FILENO, TCSADRAIN, &term);
-	if (error) err(EX_OSERR, "tcsetattr");
-}
-
 // XXX: Assuming terminals will be fine with these even if they're unsupported,
 // since they're "private" modes.
-static const char *EnterFocusMode = "\33[?1004h";
-static const char *ExitFocusMode  = "\33[?1004l";
-static const char *EnterPasteMode = "\33[?2004h";
-static const char *ExitPasteMode  = "\33[?2004l";
+static const char *FocusMode[2] = { "\33[?1004l", "\33[?1004h" };
+static const char *PasteMode[2] = { "\33[?2004l", "\33[?2004h" };
 
 static void errExit(void) {
-	putp(ExitFocusMode);
-	putp(ExitPasteMode);
+	putp(FocusMode[false]);
+	putp(PasteMode[false]);
 	reset_shell_mode();
 }
 
@@ -287,75 +106,47 @@ void uiInit(void) {
 	initscr();
 	cbreak();
 	noecho();
-	acquireKeys();
-	def_prog_mode();
-	atexit(errExit);
 	colorInit();
+	atexit(errExit);
+
+#ifndef A_ITALIC
+#define A_ITALIC A_BLINK
+	// Force ncurses to use individual enter_attr_mode strings:
+	set_attributes = NULL;
+	enter_blink_mode = enter_italics_mode;
+#endif
 
 	if (!to_status_line && !strncmp(termname(), "xterm", 5)) {
 		to_status_line = "\33]2;";
 		from_status_line = "\7";
 	}
 
-#define X(id, seq, alt) define_key(seq, id); if (alt) define_key(alt, id);
-	ENUM_KEY
-#undef X
-
-	status = newwin(StatusLines, COLS, 0, 0);
-	if (!status) err(EX_OSERR, "newwin");
+	uiStatus = newwin(StatusLines, COLS, 0, 0);
+	if (!uiStatus) err(EX_OSERR, "newwin");
 
-	marker = newwin(
-		MarkerLines, COLS,
-		LINES - InputLines - SplitLines - MarkerLines, 0
-	);
-	wbkgd(marker, ACS_BULLET);
+	uiMain = newwin(MAIN_LINES, COLS, StatusLines, 0);
+	if (!uiMain) err(EX_OSERR, "newwin");
 
-	input = newpad(InputLines, InputCols);
-	if (!input) err(EX_OSERR, "newpad");
-	keypad(input, true);
-	nodelay(input, true);
+	uiInput = newpad(InputLines, InputCols);
+	if (!uiInput) err(EX_OSERR, "newpad");
 
-	windowFor(Network);
+	windowInit();
 	uiShow();
 }
 
 static bool hidden = true;
-static bool waiting;
 
-static char title[256];
-static char prevTitle[sizeof(title)];
+char uiTitle[TitleCap];
+static char prevTitle[TitleCap];
 
 void uiDraw(void) {
 	if (hidden) return;
-	wnoutrefresh(status);
-	const struct Window *window = windows.ptrs[windows.show];
-	if (!window->scroll) {
-		pnoutrefresh(
-			window->pad,
-			WindowLines - MAIN_LINES, 0,
-			StatusLines, 0,
-			BOTTOM - InputLines, RIGHT
-		);
-	} else {
-		pnoutrefresh(
-			window->pad,
-			WindowLines - window->scroll - MAIN_LINES + MarkerLines, 0,
-			StatusLines, 0,
-			BOTTOM - InputLines - SplitLines - MarkerLines, RIGHT
-		);
-		touchwin(marker);
-		wnoutrefresh(marker);
-		pnoutrefresh(
-			window->pad,
-			WindowLines - SplitLines, 0,
-			LINES - InputLines - SplitLines, 0,
-			BOTTOM - InputLines, RIGHT
-		);
-	}
+	wnoutrefresh(uiStatus);
+	wnoutrefresh(uiMain);
 	int y, x;
-	getyx(input, y, x);
+	getyx(uiInput, y, x);
 	pnoutrefresh(
-		input,
+		uiInput,
 		0, (x + 1 > RIGHT ? x + 1 - RIGHT : 0),
 		LINES - InputLines, 0,
 		BOTTOM, RIGHT
@@ -364,20 +155,14 @@ void uiDraw(void) {
 	doupdate();
 
 	if (!to_status_line) return;
-	if (!strcmp(title, prevTitle)) return;
-	strcpy(prevTitle, title);
-	putp(to_status_line);
-	putp(title);
+	if (!strcmp(uiTitle, prevTitle)) return;
+	strcpy(prevTitle, uiTitle);
+	putp(tparm(to_status_line, 0));
+	putp(uiTitle);
 	putp(from_status_line);
 	fflush(stdout);
 }
 
-struct Style {
-	attr_t attr;
-	enum Color fg, bg;
-};
-static const struct Style Reset = { A_NORMAL, Default, Default };
-
 static const short Colors[ColorCap] = {
 	[Default]    = -1,
 	[White]      = 8 + COLOR_WHITE,
@@ -405,245 +190,60 @@ static const short Colors[ColorCap] = {
 	16, 233, 235, 237, 239, 241, 244, 247, 250, 254, 231,
 };
 
-enum { B = '\2', C = '\3', O = '\17', R = '\26', I = '\35', U = '\37' };
-
-static size_t styleParse(struct Style *style, const char **str) {
-	switch (**str) {
-		break; case B: (*str)++; style->attr ^= A_BOLD;
-		break; case O: (*str)++; *style = Reset;
-		break; case R: (*str)++; style->attr ^= A_REVERSE;
-		break; case I: (*str)++; style->attr ^= A_ITALIC;
-		break; case U: (*str)++; style->attr ^= A_UNDERLINE;
-		break; case C: {
-			(*str)++;
-			if (!isdigit(**str)) {
-				style->fg = Default;
-				style->bg = Default;
-				break;
-			}
-			style->fg = *(*str)++ - '0';
-			if (isdigit(**str)) style->fg = style->fg * 10 + *(*str)++ - '0';
-			if ((*str)[0] != ',' || !isdigit((*str)[1])) break;
-			(*str)++;
-			style->bg = *(*str)++ - '0';
-			if (isdigit(**str)) style->bg = style->bg * 10 + *(*str)++ - '0';
-		}
-	}
-	return strcspn(*str, (const char[]) { B, C, O, R, I, U, '\0' });
-}
-
-static void statusAdd(const char *str) {
-	struct Style style = Reset;
-	while (*str) {
-		size_t len = styleParse(&style, &str);
-		wattr_set(
-			status,
-			style.attr | colorAttr(Colors[style.fg]),
-			colorPair(Colors[style.fg], Colors[style.bg]),
-			NULL
-		);
-		waddnstr(status, str, len);
-		str += len;
-	}
-}
-
-static void statusUpdate(void) {
-	struct {
-		uint unread;
-		enum Heat heat;
-	} others = { 0, Cold };
-
-	wmove(status, 0, 0);
-	for (uint num = 0; num < windows.len; ++num) {
-		const struct Window *window = windows.ptrs[num];
-		if (num != windows.show && !window->scroll) {
-			if (window->heat < Warm) continue;
-			if (window->mute && window->heat < Hot) continue;
-		}
-		if (num != windows.show) {
-			others.unread += window->unreadWarm;
-			if (window->heat > others.heat) others.heat = window->heat;
-		}
-		char buf[256] = "";
-		struct Cat cat = { buf, sizeof(buf), 0 };
-		catf(
-			&cat, "\3%d%s %u ",
-			idColors[window->id], (num == windows.show ? "\26" : ""), num
-		);
-		if (!window->ignore || window->mute) {
-			catf(&cat, "%s%s ", &"-"[window->ignore], &"="[!window->mute]);
-		}
-		catf(&cat, "%s ", idNames[window->id]);
-		if (window->mark && window->unreadWarm) {
-			catf(
-				&cat, "\3%d+%d\3%d%s",
-				(window->heat > Warm ? White : idColors[window->id]),
-				window->unreadWarm, idColors[window->id],
-				(window->scroll ? "" : " ")
-			);
-		}
-		if (window->scroll) {
-			catf(&cat, "~%d ", window->scroll);
-		}
-		statusAdd(buf);
-	}
-	wclrtoeol(status);
-
-	struct Cat cat = { title, sizeof(title), 0 };
-	const struct Window *window = windows.ptrs[windows.show];
-	catf(&cat, "%s %s", network.name, idNames[window->id]);
-	if (window->mark && window->unreadWarm) {
-		catf(&cat, " +%d%s", window->unreadWarm, &"!"[window->heat < Hot]);
-	}
-	if (others.unread) {
-		catf(&cat, " (+%d%s)", others.unread, &"!"[others.heat < Hot]);
-	}
+uint uiAttr(struct Style style) {
+	attr_t attr = A_NORMAL;
+	if (style.attr & Bold) attr |= A_BOLD;
+	if (style.attr & Reverse) attr |= A_REVERSE;
+	if (style.attr & Italic) attr |= A_ITALIC;
+	if (style.attr & Underline) attr |= A_UNDERLINE;
+	return attr | colorAttr(Colors[style.fg]);
 }
 
-static void mark(struct Window *window) {
-	if (window->scroll) return;
-	window->mark = true;
-	window->unreadHard = 0;
-	window->unreadWarm = 0;
-}
+bool uiSpoilerReveal;
 
-static void unmark(struct Window *window) {
-	if (!window->scroll) {
-		window->mark = false;
-		window->heat = Cold;
+short uiPair(struct Style style) {
+	if (uiSpoilerReveal && style.fg == style.bg) {
+		return colorPair(Colors[Default], Colors[style.bg]);
 	}
-	statusUpdate();
+	return colorPair(Colors[style.fg], Colors[style.bg]);
 }
 
 void uiShow(void) {
 	if (!hidden) return;
 	prevTitle[0] = '\0';
-	putp(EnterFocusMode);
-	putp(EnterPasteMode);
+	putp(FocusMode[true]);
+	putp(PasteMode[true]);
 	fflush(stdout);
 	hidden = false;
-	unmark(windows.ptrs[windows.show]);
+	windowUnmark();
 }
 
 void uiHide(void) {
 	if (hidden) return;
-	mark(windows.ptrs[windows.show]);
+	windowMark();
 	hidden = true;
-	putp(ExitFocusMode);
-	putp(ExitPasteMode);
+	putp(FocusMode[false]);
+	putp(PasteMode[false]);
 	endwin();
 }
 
-static void windowScroll(struct Window *window, int n) {
-	mark(window);
-	window->scroll += n;
-	if (window->scroll > WindowLines - MAIN_LINES) {
-		window->scroll = WindowLines - MAIN_LINES;
-	}
-	if (window->scroll < 0) window->scroll = 0;
-	unmark(window);
-}
-
-static void windowScrollPage(struct Window *window, int n) {
-	windowScroll(window, n * (MAIN_LINES - SplitLines - MarkerLines - 1));
-}
-
-static void windowScrollUnread(struct Window *window) {
-	window->scroll = 0;
-	windowScroll(window, window->unreadSoft - MAIN_LINES);
-}
-
-static int wordWidth(const char *str) {
-	size_t len = strcspn(str, " \t");
-	int width = 0;
-	while (len) {
-		wchar_t wc;
-		int n = mbtowc(&wc, str, len);
-		if (n < 1) return width + len;
-		width += (iswprint(wc) ? wcwidth(wc) : 0);
-		str += n;
-		len -= n;
-	}
-	return width;
-}
-
-// XXX: ncurses likes to render zero-width characters as spaces...
-static int waddnstrnzw(WINDOW *win, const char *str, int len) {
-	wchar_t wc;
-	while (len) {
-		int n = mbtowc(&wc, str, len);
-		if (n < 1) return waddnstr(win, str, len);
-		if (wcwidth(wc)) waddnstr(win, str, n);
-		str += n;
-		len -= n;
-	}
-	return OK;
-}
-
-static int wordWrap(WINDOW *win, const char *str) {
-	int y, x, width;
-	getmaxyx(win, y, width);
-	waddch(win, '\n');
-
-	int lines = 1;
-	int align = 0;
-	struct Style style = Reset;
-	while (*str) {
-		char ch = *str;
-		if (ch == ' ' || ch == '\t') {
-			getyx(win, y, x);
-			const char *word = &str[strspn(str, " \t")];
-			if (width - x - 1 <= wordWidth(word)) {
-				lines += 1 + (align + wordWidth(word)) / width;
-				waddch(win, '\n');
-				getyx(win, y, x);
-				wmove(win, y, align);
-				str = word;
-			} else {
-				waddch(win, (align ? ch : ' '));
-				str++;
-			}
-		}
-		if (ch == '\t' && !align) {
-			getyx(win, y, align);
-		}
-
-		size_t len = styleParse(&style, &str);
-		size_t ws = strcspn(str, " \t");
-		if (ws < len) len = ws;
-
-		wattr_set(
-			win,
-			style.attr | colorAttr(Colors[style.fg]),
-			colorPair(Colors[style.fg], Colors[style.bg]),
-			NULL
-		);
-		waddnstrnzw(win, str, len);
-		str += len;
-	}
-	return lines;
-}
-
 struct Util uiNotifyUtil;
 static void notify(uint id, const char *str) {
+	if (self.restricted) return;
 	if (!uiNotifyUtil.argc) return;
 
+	char buf[1024];
+	styleStrip(buf, sizeof(buf), str);
+
 	struct Util util = uiNotifyUtil;
 	utilPush(&util, idNames[id]);
-	char buf[1024] = "";
-	struct Cat cat = { buf, sizeof(buf), 0 };
-	while (*str) {
-		struct Style style = Reset;
-		size_t len = styleParse(&style, &str);
-		catf(&cat, "%.*s", (int)len, str);
-		str += len;
-	}
 	utilPush(&util, buf);
 
 	pid_t pid = fork();
 	if (pid < 0) err(EX_OSERR, "fork");
 	if (pid) return;
 
+	setsid();
 	close(STDIN_FILENO);
 	dup2(utilPipe[1], STDOUT_FILENO);
 	dup2(utilPipe[1], STDERR_FILENO);
@@ -653,27 +253,8 @@ static void notify(uint id, const char *str) {
 }
 
 void uiWrite(uint id, enum Heat heat, const time_t *src, const char *str) {
-	struct Window *window = windows.ptrs[windowFor(id)];
-	time_t ts = (src ? *src : time(NULL));
-	bufferPush(&window->buffer, heat, ts, str);
-	if (heat < Cold && window->ignore) return;
-
-	int lines = 0;
-	if (!window->unreadHard++) window->unreadSoft = 0;
-	if (window->mark && heat > Cold) {
-		if (!window->unreadWarm++) {
-			lines++;
-			waddch(window->pad, '\n');
-		}
-		if (heat > window->heat) window->heat = heat;
-		statusUpdate();
-	}
-
-	lines += wordWrap(window->pad, str);
-	window->unreadSoft += lines;
-	if (window->scroll) windowScroll(window, lines);
-
-	if (window->mark && heat > Warm) {
+	bool note = windowWrite(id, heat, src, str);
+	if (note) {
 		beep();
 		notify(id, str);
 	}
@@ -691,486 +272,86 @@ void uiFormat(
 	uiWrite(id, heat, time, buf);
 }
 
-static void reflow(struct Window *window) {
-	werase(window->pad);
-	wmove(window->pad, 0, 0);
-
-	int flowed = 0;
-	window->unreadSoft = 0;
-	for (size_t i = 0; i < BufferCap; ++i) {
-		struct Line line = bufferLine(&window->buffer, i);
-		if (!line.str) continue;
-		if (line.heat < Cold && window->ignore) continue;
-		int lines = 0;
-		if (i == (size_t)(BufferCap - window->unreadHard)) {
-			waddch(window->pad, '\n');
-			lines++;
-		}
-		lines += wordWrap(window->pad, line.str);
-		if (i >= (size_t)(BufferCap - window->unreadHard)) {
-			window->unreadSoft += lines;
-		}
-		flowed += lines;
-	}
-
-	if (flowed < WindowLines) {
-		wscrl(window->pad, -(WindowLines - 1 - flowed));
-		wmove(window->pad, WindowLines - 1, RIGHT);
-	}
-}
-
-static void resize(void) {
-	mvwin(marker, LINES - InputLines - SplitLines - MarkerLines, 0);
-	int height, width;
-	getmaxyx(windows.ptrs[0]->pad, height, width);
-	if (width == COLS) return;
-	for (uint num = 0; num < windows.len; ++num) {
-		wresize(windows.ptrs[num]->pad, BufferCap, COLS);
-		reflow(windows.ptrs[num]);
-	}
-	(void)height;
-	statusUpdate();
-}
-
-static void bufferList(const struct Buffer *buffer) {
-	uiHide();
-	waiting = true;
-
-	for (size_t i = 0; i < BufferCap; ++i) {
-		struct Line line = bufferLine(buffer, i);
-		if (!line.str) continue;
-
-		struct tm *tm = localtime(&line.time);
-		if (!tm) err(EX_OSERR, "localtime");
-
-		char buf[sizeof("00:00:00")];
-		strftime(buf, sizeof(buf), "%T", tm);
-		vid_attr(colorAttr(Colors[Gray]), colorPair(Colors[Gray], -1), NULL);
-		printf("[%s] ", buf);
-
-		bool align = false;
-		struct Style style = Reset;
-		while (*line.str) {
-			if (*line.str == '\t') {
-				printf("%c", (align ? '\t' : ' '));
-				align = true;
-				line.str++;
-			}
-
-			size_t len = styleParse(&style, (const char **)&line.str);
-			size_t tab = strcspn(line.str, "\t");
-			if (tab < len) len = tab;
-
-			vid_attr(
-				style.attr | colorAttr(Colors[style.fg]),
-				colorPair(Colors[style.fg], Colors[style.bg]),
-				NULL
-			);
-			printf("%.*s", (int)len, line.str);
-			line.str += len;
-		}
-		printf("\n");
-	}
-}
-
-static void inputAdd(struct Style *style, const char *str) {
-	while (*str) {
-		const char *code = str;
-		size_t len = styleParse(style, &str);
-		wattr_set(input, A_BOLD | A_REVERSE, 0, NULL);
-		switch (*code) {
-			break; case B: waddch(input, 'B');
-			break; case C: waddch(input, 'C');
-			break; case O: waddch(input, 'O');
-			break; case R: waddch(input, 'R');
-			break; case I: waddch(input, 'I');
-			break; case U: waddch(input, 'U');
-			break; case '\n': waddch(input, 'N');
-		}
-		if (str - code > 1) waddnstr(input, &code[1], str - &code[1]);
-		if (str[0] == '\n') {
-			str++;
-			len--;
-		}
-		size_t nl = strcspn(str, "\n");
-		if (nl < len) len = nl;
-		wattr_set(
-			input,
-			style->attr | colorAttr(Colors[style->fg]),
-			colorPair(Colors[style->fg], Colors[style->bg]),
-			NULL
-		);
-		waddnstr(input, str, len);
-		str += len;
-	}
-}
-
-static void inputUpdate(void) {
-	size_t pos;
-	char *buf = editBuffer(&pos);
-	uint id = windows.ptrs[windows.show]->id;
-
-	const char *prefix = "";
-	const char *prompt = self.nick;
-	const char *suffix = "";
-	const char *skip = buf;
-	struct Style stylePrompt = { .fg = self.color, .bg = Default };
-	struct Style styleInput = Reset;
-
-	const char *privmsg = commandIsPrivmsg(id, buf);
-	const char *notice = commandIsNotice(id, buf);
-	const char *action = commandIsAction(id, buf);
-	if (privmsg) {
-		prefix = "<"; suffix = "> ";
-		skip = privmsg;
-	} else if (notice) {
-		prefix = "-"; suffix = "- ";
-		styleInput.fg = LightGray;
-		skip = notice;
-	} else if (action) {
-		prefix = "* "; suffix = " ";
-		stylePrompt.attr |= A_ITALIC;
-		styleInput.attr |= A_ITALIC;
-		skip = action;
-	} else if (id == Debug && buf[0] != '/') {
-		prompt = "<< ";
-		stylePrompt.fg = Gray;
-	} else {
-		prompt = "";
-	}
-	if (skip > &buf[pos]) {
-		prefix = prompt = suffix = "";
-		skip = buf;
-	}
-
-	int y, x;
-	wmove(input, 0, 0);
-	wattr_set(
-		input,
-		stylePrompt.attr | colorAttr(Colors[stylePrompt.fg]),
-		colorPair(Colors[stylePrompt.fg], Colors[stylePrompt.bg]),
-		NULL
-	);
-	waddstr(input, prefix);
-	waddstr(input, prompt);
-	waddstr(input, suffix);
-	struct Style style = styleInput;
-	char p = buf[pos];
-	buf[pos] = '\0';
-	inputAdd(&style, skip);
-	getyx(input, y, x);
-	buf[pos] = p;
-	inputAdd(&style, &buf[pos]);
-	wclrtoeol(input);
-	wmove(input, y, x);
-}
-
-static void windowShow(uint num) {
-	touchwin(windows.ptrs[num]->pad);
-	windows.swap = windows.show;
-	windows.show = num;
-	windows.user = num;
-	mark(windows.ptrs[windows.swap]);
-	unmark(windows.ptrs[windows.show]);
-	inputUpdate();
-}
-
-void uiShowID(uint id) {
-	windowShow(windowFor(id));
-}
-
-void uiShowNum(uint num) {
-	if (num < windows.len) windowShow(num);
-}
-
-void uiMoveID(uint id, uint num) {
-	struct Window *window = windowRemove(windowFor(id));
-	if (num < windows.len) {
-		windowShow(windowInsert(num, window));
-	} else {
-		windowShow(windowPush(window));
-	}
-}
-
-static void windowClose(uint num) {
-	if (windows.ptrs[num]->id == Network) return;
-	struct Window *window = windowRemove(num);
-	completeClear(window->id);
-	windowFree(window);
-	if (windows.swap >= num) windows.swap--;
-	if (windows.show == num) {
-		windowShow(windows.swap);
-		windows.swap = windows.show;
-	} else if (windows.show > num) {
-		windows.show--;
-	}
-	statusUpdate();
-}
-
-void uiCloseID(uint id) {
-	windowClose(windowFor(id));
-}
-
-void uiCloseNum(uint num) {
-	if (num < windows.len) windowClose(num);
-}
-
-static void toggleIgnore(struct Window *window) {
-	window->ignore ^= true;
-	reflow(window);
-	statusUpdate();
-}
-
-static void showAuto(void) {
-	uint minHot = UINT_MAX, numHot = 0;
-	uint minWarm = UINT_MAX, numWarm = 0;
-	for (uint num = 0; num < windows.len; ++num) {
-		struct Window *window = windows.ptrs[num];
-		if (window->heat >= Hot) {
-			if (window->unreadWarm >= minHot) continue;
-			minHot = window->unreadWarm;
-			numHot = num;
-		}
-		if (window->heat >= Warm && !window->mute) {
-			if (window->unreadWarm >= minWarm) continue;
-			minWarm = window->unreadWarm;
-			numWarm = num;
-		}
-	}
-	uint user = windows.user;
-	if (minHot < UINT_MAX) {
-		windowShow(numHot);
-		windows.user = user;
-	} else if (minWarm < UINT_MAX) {
-		windowShow(numWarm);
-		windows.user = user;
-	} else if (user != windows.show) {
-		windowShow(user);
-	}
-}
-
-static void keyCode(int code) {
-	struct Window *window = windows.ptrs[windows.show];
-	uint id = window->id;
-	switch (code) {
-		break; case KEY_RESIZE:  resize();
-		break; case KeyFocusIn:  unmark(window);
-		break; case KeyFocusOut: mark(window);
-
-		break; case KeyMetaEnter: edit(id, EditInsert, L'\n');
-		break; case KeyMetaEqual: window->mute ^= true; statusUpdate();
-		break; case KeyMetaMinus: toggleIgnore(window);
-		break; case KeyMetaSlash: windowShow(windows.swap);
-
-		break; case KeyMetaGt: windowScroll(window, -WindowLines);
-		break; case KeyMetaLt: windowScroll(window, +WindowLines);
-
-		break; case KeyMeta0 ... KeyMeta9: uiShowNum(code - KeyMeta0);
-		break; case KeyMetaA: showAuto();
-		break; case KeyMetaB: edit(id, EditPrevWord, 0);
-		break; case KeyMetaD: edit(id, EditDeleteNextWord, 0);
-		break; case KeyMetaF: edit(id, EditNextWord, 0);
-		break; case KeyMetaL: bufferList(&window->buffer);
-		break; case KeyMetaM: waddch(window->pad, '\n');
-		break; case KeyMetaQ: edit(id, EditCollapse, 0);
-		break; case KeyMetaU: windowScrollUnread(window);
-		break; case KeyMetaV: windowScrollPage(window, +1);
-
-		break; case KEY_BACKSPACE: edit(id, EditDeletePrev, 0);
-		break; case KEY_DC: edit(id, EditDeleteNext, 0);
-		break; case KEY_DOWN: windowScroll(window, -1);
-		break; case KEY_END: edit(id, EditTail, 0);
-		break; case KEY_ENTER: edit(id, EditEnter, 0);
-		break; case KEY_HOME: edit(id, EditHead, 0);
-		break; case KEY_LEFT: edit(id, EditPrev, 0);
-		break; case KEY_NPAGE: windowScrollPage(window, -1);
-		break; case KEY_PPAGE: windowScrollPage(window, +1);
-		break; case KEY_RIGHT: edit(id, EditNext, 0);
-		break; case KEY_SEND: windowScroll(window, -WindowLines);
-		break; case KEY_SHOME: windowScroll(window, +WindowLines);
-		break; case KEY_UP: windowScroll(window, +1);
-	}
+void uiResize(void) {
+	wclear(uiMain);
+	wresize(uiMain, MAIN_LINES, COLS);
+	windowResize();
 }
 
-static void keyCtrl(wchar_t ch) {
-	struct Window *window = windows.ptrs[windows.show];
-	uint id = window->id;
-	switch (ch ^ L'@') {
-		break; case L'?': edit(id, EditDeletePrev, 0);
-		break; case L'A': edit(id, EditHead, 0);
-		break; case L'B': edit(id, EditPrev, 0);
-		break; case L'C': raise(SIGINT);
-		break; case L'D': edit(id, EditDeleteNext, 0);
-		break; case L'E': edit(id, EditTail, 0);
-		break; case L'F': edit(id, EditNext, 0);
-		break; case L'H': edit(id, EditDeletePrev, 0);
-		break; case L'I': edit(id, EditComplete, 0);
-		break; case L'J': edit(id, EditEnter, 0);
-		break; case L'K': edit(id, EditDeleteTail, 0);
-		break; case L'L': clearok(curscr, true);
-		break; case L'N': uiShowNum(windows.show + 1);
-		break; case L'P': uiShowNum(windows.show - 1);
-		break; case L'T': edit(id, EditTranspose, 0);
-		break; case L'U': edit(id, EditDeleteHead, 0);
-		break; case L'V': windowScrollPage(window, -1);
-		break; case L'W': edit(id, EditDeletePrevWord, 0);
-		break; case L'X': edit(id, EditExpand, 0);
-		break; case L'Y': edit(id, EditPaste, 0);
-	}
-}
-
-static void keyStyle(wchar_t ch) {
-	uint id = windows.ptrs[windows.show]->id;
-	switch (iswcntrl(ch) ? ch ^ L'@' : (wchar_t)towupper(ch)) {
-		break; case L'B': edit(id, EditInsert, B);
-		break; case L'C': edit(id, EditInsert, C);
-		break; case L'I': edit(id, EditInsert, I);
-		break; case L'O': edit(id, EditInsert, O);
-		break; case L'R': edit(id, EditInsert, R);
-		break; case L'U': edit(id, EditInsert, U);
-	}
-}
+static FILE *saveFile;
 
-void uiRead(void) {
-	if (hidden) {
-		if (waiting) {
-			uiShow();
-			flushinp();
-			waiting = false;
-		} else {
-			return;
-		}
-	}
-
-	wint_t ch;
-	static bool paste, style;
-	for (int ret; ERR != (ret = wget_wch(input, &ch));) {
-		if (ret == KEY_CODE_YES && ch == KeyPasteOn) {
-			paste = true;
-		} else if (ret == KEY_CODE_YES && ch == KeyPasteOff) {
-			paste = false;
-		} else if (paste) {
-			edit(windows.ptrs[windows.show]->id, EditInsert, ch);
-		} else if (ret == KEY_CODE_YES) {
-			keyCode(ch);
-		} else if (ch == (L'Z' ^ L'@')) {
-			style = true;
-			continue;
-		} else if (style) {
-			keyStyle(ch);
-		} else if (iswcntrl(ch)) {
-			keyCtrl(ch);
-		} else {
-			edit(windows.ptrs[windows.show]->id, EditInsert, ch);
-		}
-		style = false;
-	}
-	inputUpdate();
-}
-
-static const time_t Signatures[] = {
+static const uint64_t Signatures[] = {
 	0x6C72696774616301, // no heat, unread, unreadWarm
 	0x6C72696774616302, // no self.pos
 	0x6C72696774616303, // no buffer line heat
 	0x6C72696774616304, // no mute
-	0x6C72696774616305,
+	0x6C72696774616305, // no URLs
+	0x6C72696774616306, // no thresh
+	0x6C72696774616307, // no window time
+	0x6C72696774616308, // no input
+	0x6C72696774616309,
 };
 
-static size_t signatureVersion(time_t signature) {
+static size_t signatureVersion(uint64_t signature) {
 	for (size_t i = 0; i < ARRAY_LEN(Signatures); ++i) {
 		if (signature == Signatures[i]) return i;
 	}
-	err(EX_DATAERR, "unknown file signature %jX", (uintmax_t)signature);
+	errx(EX_DATAERR, "unknown save file signature %" PRIX64, signature);
 }
 
-static int writeTime(FILE *file, time_t time) {
-	return (fwrite(&time, sizeof(time), 1, file) ? 0 : -1);
-}
-static int writeString(FILE *file, const char *str) {
-	return (fwrite(str, strlen(str) + 1, 1, file) ? 0 : -1);
+static int writeUint64(FILE *file, uint64_t u) {
+	return (fwrite(&u, sizeof(u), 1, file) ? 0 : -1);
 }
 
-int uiSave(const char *name) {
-	FILE *file = dataOpen(name, "w");
-	if (!file) return -1;
-
-	if (writeTime(file, Signatures[4])) return -1;
-	if (writeTime(file, self.pos)) return -1;
-	for (uint num = 0; num < windows.len; ++num) {
-		const struct Window *window = windows.ptrs[num];
-		if (writeString(file, idNames[window->id])) return -1;
-		if (writeTime(file, window->mute)) return -1;
-		if (writeTime(file, window->heat)) return -1;
-		if (writeTime(file, window->unreadHard)) return -1;
-		if (writeTime(file, window->unreadWarm)) return -1;
-		for (size_t i = 0; i < BufferCap; ++i) {
-			struct Line line = bufferLine(&window->buffer, i);
-			if (!line.str) continue;
-			if (writeTime(file, line.time)) return -1;
-			if (writeTime(file, line.heat)) return -1;
-			if (writeString(file, line.str)) return -1;
-		}
-		if (writeTime(file, 0)) return -1;
-	}
-	return fclose(file);
+int uiSave(void) {
+	return 0
+		|| ftruncate(fileno(saveFile), 0)
+		|| writeUint64(saveFile, Signatures[8])
+		|| writeUint64(saveFile, self.pos)
+		|| windowSave(saveFile)
+		|| inputSave(saveFile)
+		|| urlSave(saveFile)
+		|| fclose(saveFile);
 }
 
-static time_t readTime(FILE *file) {
-	time_t time;
-	fread(&time, sizeof(time), 1, file);
+static uint64_t readUint64(FILE *file) {
+	uint64_t u;
+	fread(&u, sizeof(u), 1, file);
 	if (ferror(file)) err(EX_IOERR, "fread");
-	if (feof(file)) errx(EX_DATAERR, "unexpected eof");
-	return time;
-}
-static ssize_t readString(FILE *file, char **buf, size_t *cap) {
-	ssize_t len = getdelim(buf, cap, '\0', file);
-	if (len < 0 && !feof(file)) err(EX_IOERR, "getdelim");
-	return len;
+	if (feof(file)) errx(EX_DATAERR, "unexpected end of save file");
+	return u;
 }
 
 void uiLoad(const char *name) {
-	FILE *file = dataOpen(name, "r");
-	if (!file) {
-		if (errno != ENOENT) exit(EX_NOINPUT);
-		file = dataOpen(name, "w");
-		if (!file) exit(EX_CANTCREAT);
-		fclose(file);
-		return;
+	int error;
+	saveFile = dataOpen(name, "a+e");
+	if (!saveFile) exit(EX_CANTCREAT);
+	rewind(saveFile);
+
+#ifdef __FreeBSD__
+	cap_rights_t rights;
+	cap_rights_init(&rights, CAP_READ, CAP_WRITE, CAP_FLOCK, CAP_FTRUNCATE);
+	error = caph_rights_limit(fileno(saveFile), &rights);
+	if (error) err(EX_OSERR, "cap_rights_limit");
+#endif
+
+	error = flock(fileno(saveFile), LOCK_EX | LOCK_NB);
+	if (error && errno == EWOULDBLOCK) {
+		errx(EX_CANTCREAT, "%s: save file in use", name);
 	}
 
 	time_t signature;
-	fread(&signature, sizeof(signature), 1, file);
-	if (ferror(file)) err(EX_IOERR, "fread");
-	if (feof(file)) {
-		fclose(file);
+	fread(&signature, sizeof(signature), 1, saveFile);
+	if (ferror(saveFile)) err(EX_IOERR, "fread");
+	if (feof(saveFile)) {
 		return;
 	}
 	size_t version = signatureVersion(signature);
 
 	if (version > 1) {
-		self.pos = readTime(file);
+		self.pos = readUint64(saveFile);
 	}
-
-	char *buf = NULL;
-	size_t cap = 0;
-	while (0 < readString(file, &buf, &cap)) {
-		struct Window *window = windows.ptrs[windowFor(idFor(buf))];
-		if (version > 3) window->mute = readTime(file);
-		if (version > 0) {
-			window->heat = readTime(file);
-			window->unreadHard = readTime(file);
-			window->unreadWarm = readTime(file);
-		}
-		for (;;) {
-			time_t time = readTime(file);
-			if (!time) break;
-			enum Heat heat = (version > 2 ? readTime(file) : Cold);
-			readString(file, &buf, &cap);
-			bufferPush(&window->buffer, heat, time, buf);
-		}
-		reflow(window);
-	}
-
-	free(buf);
-	fclose(file);
+	windowLoad(saveFile, version);
+	inputLoad(saveFile, version);
+	urlLoad(saveFile, version);
 }
diff --git a/url.c b/url.c
index 3f7f512..7da0968 100644
--- a/url.c
+++ b/url.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2020  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -41,6 +41,7 @@ static const char *Pattern = {
 	"("
 	"cvs|"
 	"ftp|"
+	"gemini|"
 	"git|"
 	"gopher|"
 	"http|"
@@ -86,14 +87,19 @@ static void push(uint id, const char *nick, const char *str, size_t len) {
 	struct URL *url = &ring.urls[ring.len++ % Cap];
 	free(url->nick);
 	free(url->url);
+
 	url->id = id;
 	url->nick = NULL;
 	if (nick) {
 		url->nick = strdup(nick);
 		if (!url->nick) err(EX_OSERR, "strdup");
 	}
-	url->url = strndup(str, len);
-	if (!url->url) err(EX_OSERR, "strndup");
+	url->url = malloc(len + 1);
+	if (!url->url) err(EX_OSERR, "malloc");
+
+	char buf[1024];
+	snprintf(buf, sizeof(buf), "%.*s", (int)len, str);
+	styleStrip(url->url, len + 1, buf);
 }
 
 void urlScan(uint id, const char *nick, const char *mesg) {
@@ -117,6 +123,7 @@ static void urlOpen(const char *url) {
 	if (pid < 0) err(EX_OSERR, "fork");
 	if (pid) return;
 
+	setsid();
 	close(STDIN_FILENO);
 	dup2(utilPipe[1], STDOUT_FILENO);
 	dup2(utilPipe[1], STDERR_FILENO);
@@ -168,6 +175,7 @@ static void urlCopy(const char *url) {
 		return;
 	}
 
+	setsid();
 	dup2(rw[0], STDIN_FILENO);
 	dup2(utilPipe[1], STDOUT_FILENO);
 	dup2(utilPipe[1], STDERR_FILENO);
@@ -225,3 +233,47 @@ void urlCopyMatch(uint id, const char *str) {
 		}
 	}
 }
+
+static int writeString(FILE *file, const char *str) {
+	return (fwrite(str, strlen(str) + 1, 1, file) ? 0 : -1);
+}
+static ssize_t readString(FILE *file, char **buf, size_t *cap) {
+	ssize_t len = getdelim(buf, cap, '\0', file);
+	if (len < 0 && !feof(file)) err(EX_IOERR, "getdelim");
+	return len;
+}
+
+int urlSave(FILE *file) {
+	for (size_t i = 0; i < Cap; ++i) {
+		const struct URL *url = &ring.urls[(ring.len + i) % Cap];
+		if (!url->url) continue;
+		int error = 0
+			|| writeString(file, idNames[url->id])
+			|| writeString(file, (url->nick ?: ""))
+			|| writeString(file, url->url);
+		if (error) return error;
+	}
+	return writeString(file, "");
+}
+
+void urlLoad(FILE *file, size_t version) {
+	if (version < 5) return;
+	size_t cap = 0;
+	char *buf = NULL;
+	while (0 < readString(file, &buf, &cap) && buf[0]) {
+		struct URL *url = &ring.urls[ring.len++ % Cap];
+		free(url->nick);
+		free(url->url);
+		url->id = idFor(buf);
+		url->nick = NULL;
+		readString(file, &buf, &cap);
+		if (buf[0]) {
+			url->nick = strdup(buf);
+			if (!url->nick) err(EX_OSERR, "strdup");
+		}
+		readString(file, &buf, &cap);
+		url->url = strdup(buf);
+		if (!url->url) err(EX_OSERR, "strdup");
+	}
+	free(buf);
+}
diff --git a/window.c b/window.c
new file mode 100644
index 0000000..f700fd7
--- /dev/null
+++ b/window.c
@@ -0,0 +1,659 @@
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
+ */
+
+#define _XOPEN_SOURCE_EXTENDED
+
+#include <assert.h>
+#include <curses.h>
+#include <err.h>
+#include <limits.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+#include <time.h>
+
+#include "chat.h"
+
+#define MAIN_LINES (LINES - StatusLines - InputLines)
+
+static struct Window {
+	uint id;
+	int scroll;
+	bool mark;
+	bool mute;
+	bool time;
+	enum Heat thresh;
+	enum Heat heat;
+	uint unreadSoft;
+	uint unreadHard;
+	uint unreadWarm;
+	struct Buffer *buffer;
+} *windows[IDCap];
+
+static uint count;
+static uint show;
+static uint swap;
+static uint user;
+
+static uint windowPush(struct Window *window) {
+	assert(count < IDCap);
+	windows[count] = window;
+	return count++;
+}
+
+static uint windowInsert(uint num, struct Window *window) {
+	assert(count < IDCap);
+	assert(num <= count);
+	memmove(
+		&windows[num + 1],
+		&windows[num],
+		sizeof(*windows) * (count - num)
+	);
+	windows[num] = window;
+	count++;
+	return num;
+}
+
+static struct Window *windowRemove(uint num) {
+	assert(num < count);
+	struct Window *window = windows[num];
+	count--;
+	memmove(
+		&windows[num],
+		&windows[num + 1],
+		sizeof(*windows) * (count - num)
+	);
+	return window;
+}
+
+static void windowFree(struct Window *window) {
+	completeRemove(None, idNames[window->id]);
+	bufferFree(window->buffer);
+	free(window);
+}
+
+enum Heat windowThreshold = Cold;
+struct Time windowTime = { .format = "%X" };
+
+uint windowFor(uint id) {
+	for (uint num = 0; num < count; ++num) {
+		if (windows[num]->id == id) return num;
+	}
+
+	struct Window *window = calloc(1, sizeof(*window));
+	if (!window) err(EX_OSERR, "malloc");
+
+	window->id = id;
+	window->mark = true;
+	window->time = windowTime.enable;
+	if (id == Network || id == Debug) {
+		window->thresh = Cold;
+	} else {
+		window->thresh = windowThreshold;
+	}
+	window->buffer = bufferAlloc();
+	completePush(None, idNames[id], idColors[id]);
+
+	return windowPush(window);
+}
+
+enum { TimeCap = 64 };
+
+void windowInit(void) {
+	char fmt[TimeCap];
+	char buf[TimeCap];
+	styleStrip(fmt, sizeof(fmt), windowTime.format);
+
+	struct tm *time = localtime(&(time_t) { -22100400 });
+	size_t len = strftime(buf, sizeof(buf), fmt, time);
+	if (!len) errx(EX_CONFIG, "invalid timestamp format: %s", fmt);
+
+	int y;
+	waddstr(uiMain, buf);
+	waddch(uiMain, ' ');
+	getyx(uiMain, y, windowTime.width);
+	(void)y;
+
+	windowFor(Network);
+}
+
+static int styleAdd(WINDOW *win, struct Style init, const char *str) {
+	struct Style style = init;
+	while (*str) {
+		size_t len = styleParse(&style, &str);
+		if (!len) continue;
+		wattr_set(win, uiAttr(style), uiPair(style), NULL);
+		if (waddnstr(win, str, len) == ERR)
+			return -1;
+		str += len;
+	}
+	return 0;
+}
+
+static void statusUpdate(void) {
+	struct {
+		uint unread;
+		enum Heat heat;
+	} others = { 0, Cold };
+
+	wmove(uiStatus, 0, 0);
+	for (uint num = 0; num < count; ++num) {
+		const struct Window *window = windows[num];
+		if (num != show && !window->scroll && !inputPending(window->id)) {
+			if (window->heat < Warm) continue;
+			if (window->mute && window->heat < Hot) continue;
+		}
+		if (num != show) {
+			others.unread += window->unreadWarm;
+			if (window->heat > others.heat) others.heat = window->heat;
+		}
+		char buf[256], *end = &buf[sizeof(buf)];
+		char *ptr = seprintf(
+			buf, end, "\3%d%s %u%s%s %s ",
+			idColors[window->id], (num == show ? "\26" : ""),
+			num, window->thresh[(const char *[]) { "-", "", "+", "++" }],
+			&"="[!window->mute], idNames[window->id]
+		);
+		if (window->mark && window->unreadWarm) {
+			ptr = seprintf(
+				ptr, end, "\3%d+%d\3%d ",
+				(window->heat > Warm ? White : idColors[window->id]),
+				window->unreadWarm, idColors[window->id]
+			);
+		}
+		if (window->scroll) {
+			ptr = seprintf(ptr, end, "~%d ", window->scroll);
+		}
+		if (num != show && inputPending(window->id)) {
+			ptr = seprintf(ptr, end, "@ ");
+		}
+		if (styleAdd(uiStatus, StyleDefault, buf) < 0) break;
+	}
+	wclrtoeol(uiStatus);
+
+	const struct Window *window = windows[show];
+	char *end = &uiTitle[sizeof(uiTitle)];
+	char *ptr = seprintf(
+		uiTitle, end, "%s %s", network.name, idNames[window->id]
+	);
+	if (window->mark && window->unreadWarm) {
+		ptr = seprintf(
+			ptr, end, " +%d%s", window->unreadWarm, &"!"[window->heat < Hot]
+		);
+	}
+	if (others.unread) {
+		ptr = seprintf(
+			ptr, end, " (+%d%s)", others.unread, &"!"[others.heat < Hot]
+		);
+	}
+}
+
+static size_t windowTop(const struct Window *window) {
+	size_t top = BufferCap - MAIN_LINES - window->scroll;
+	if (window->scroll) top += MarkerLines;
+	return top;
+}
+
+static size_t windowBottom(const struct Window *window) {
+	size_t bottom = BufferCap - (window->scroll ?: 1);
+	if (window->scroll) bottom -= SplitLines + MarkerLines;
+	return bottom;
+}
+
+static void mainAdd(int y, bool time, const struct Line *line) {
+	int ny, nx;
+	wmove(uiMain, y, 0);
+	if (!line || !line->str[0]) {
+		wclrtoeol(uiMain);
+		return;
+	}
+	if (time && line->time) {
+		char buf[TimeCap];
+		strftime(buf, sizeof(buf), windowTime.format, localtime(&line->time));
+		struct Style init = { .fg = Gray, .bg = Default };
+		styleAdd(uiMain, init, buf);
+		waddch(uiMain, ' ');
+	} else if (time) {
+		whline(uiMain, ' ', windowTime.width);
+		wmove(uiMain, y, windowTime.width);
+	}
+	styleAdd(uiMain, StyleDefault, line->str);
+	getyx(uiMain, ny, nx);
+	if (ny != y) return;
+	wclrtoeol(uiMain);
+	(void)nx;
+}
+
+static void mainUpdate(void) {
+	const struct Window *window = windows[show];
+
+	int y = 0;
+	int marker = MAIN_LINES - SplitLines - MarkerLines;
+	for (size_t i = windowTop(window); i < BufferCap; ++i) {
+		mainAdd(y++, window->time, bufferHard(window->buffer, i));
+		if (window->scroll && y == marker) break;
+	}
+	if (!window->scroll) return;
+
+	y = MAIN_LINES - SplitLines;
+	for (size_t i = BufferCap - SplitLines; i < BufferCap; ++i) {
+		mainAdd(y++, window->time, bufferHard(window->buffer, i));
+	}
+	wattr_set(uiMain, A_NORMAL, 0, NULL);
+	mvwhline(uiMain, marker, 0, ACS_BULLET, COLS);
+}
+
+void windowUpdate(void) {
+	statusUpdate();
+	mainUpdate();
+}
+
+void windowBare(void) {
+	uiHide();
+	inputWait();
+
+	const struct Window *window = windows[show];
+	const struct Line *line = bufferHard(window->buffer, windowBottom(window));
+
+	uint num = 0;
+	if (line) num = line->num;
+	for (size_t i = 0; i < BufferCap; ++i) {
+		line = bufferSoft(window->buffer, i);
+		if (!line) continue;
+		if (line->num > num) break;
+		if (!line->str[0]) {
+			printf("\n");
+			continue;
+		}
+
+		char buf[TimeCap];
+		struct Style style = { .fg = Gray, .bg = Default };
+		strftime(buf, sizeof(buf), windowTime.format, localtime(&line->time));
+		vid_attr(uiAttr(style), uiPair(style), NULL);
+		printf("%s ", buf);
+
+		bool align = false;
+		style = StyleDefault;
+		for (const char *str = line->str; *str;) {
+			if (*str == '\t') {
+				printf("%c", (align ? '\t' : ' '));
+				align = true;
+				str++;
+			}
+
+			size_t len = styleParse(&style, &str);
+			size_t tab = strcspn(str, "\t");
+			if (tab < len) len = tab;
+
+			vid_attr(uiAttr(style), uiPair(style), NULL);
+			printf("%.*s", (int)len, str);
+			str += len;
+		}
+		printf("\n");
+	}
+}
+
+static void mark(struct Window *window) {
+	if (window->scroll) return;
+	window->mark = true;
+	window->unreadSoft = 0;
+	window->unreadWarm = 0;
+}
+
+static void unmark(struct Window *window) {
+	if (!window->scroll) {
+		window->mark = false;
+		window->heat = Cold;
+	}
+	statusUpdate();
+}
+
+static void scrollN(struct Window *window, int n) {
+	mark(window);
+	window->scroll += n;
+	if (window->scroll > BufferCap - MAIN_LINES) {
+		window->scroll = BufferCap - MAIN_LINES;
+	}
+	if (window->scroll < 0) window->scroll = 0;
+	unmark(window);
+	if (window == windows[show]) mainUpdate();
+}
+
+static void scrollTo(struct Window *window, int top) {
+	window->scroll = 0;
+	scrollN(window, top - MAIN_LINES + MarkerLines);
+}
+
+static int windowCols(const struct Window *window) {
+	return COLS - (window->time ? windowTime.width : 0);
+}
+
+bool windowWrite(uint id, enum Heat heat, const time_t *src, const char *str) {
+	struct Window *window = windows[windowFor(id)];
+	time_t ts = (src ? *src : time(NULL));
+
+	if (heat >= window->thresh) {
+		if (!window->unreadSoft++) window->unreadHard = 0;
+	}
+	if (window->mark && heat > Cold) {
+		if (!window->unreadWarm++) {
+			int lines = bufferPush(
+				window->buffer, windowCols(window),
+				window->thresh, Warm, ts, ""
+			);
+			if (window->scroll) scrollN(window, lines);
+			if (window->unreadSoft > 1) {
+				window->unreadSoft++;
+				window->unreadHard += lines;
+			}
+		}
+		if (heat > window->heat) window->heat = heat;
+		statusUpdate();
+	}
+	int lines = bufferPush(
+		window->buffer, windowCols(window),
+		window->thresh, heat, ts, str
+	);
+	window->unreadHard += lines;
+	if (window->scroll) scrollN(window, lines);
+	if (window == windows[show]) mainUpdate();
+
+	return window->mark && heat > Warm;
+}
+
+static void reflow(struct Window *window) {
+	uint num = 0;
+	const struct Line *line = bufferHard(window->buffer, windowTop(window));
+	if (line) num = line->num;
+	window->unreadHard = bufferReflow(
+		window->buffer, windowCols(window),
+		window->thresh, window->unreadSoft
+	);
+	if (!window->scroll || !num) return;
+	for (size_t i = 0; i < BufferCap; ++i) {
+		line = bufferHard(window->buffer, i);
+		if (!line || line->num != num) continue;
+		scrollTo(window, BufferCap - i);
+		break;
+	}
+}
+
+void windowResize(void) {
+	for (uint num = 0; num < count; ++num) {
+		reflow(windows[num]);
+	}
+	windowUpdate();
+}
+
+uint windowID(void) {
+	return windows[show]->id;
+}
+
+uint windowNum(void) {
+	return show;
+}
+
+void windowShow(uint num) {
+	if (num >= count) return;
+	if (num != show) {
+		swap = show;
+		mark(windows[swap]);
+	}
+	show = num;
+	user = num;
+	unmark(windows[show]);
+	mainUpdate();
+	inputUpdate();
+}
+
+void windowAuto(void) {
+	uint minHot = UINT_MAX, numHot = 0;
+	uint minWarm = UINT_MAX, numWarm = 0;
+	for (uint num = 0; num < count; ++num) {
+		struct Window *window = windows[num];
+		if (window->heat >= Hot) {
+			if (window->unreadWarm >= minHot) continue;
+			minHot = window->unreadWarm;
+			numHot = num;
+		}
+		if (window->heat >= Warm && !window->mute) {
+			if (window->unreadWarm >= minWarm) continue;
+			minWarm = window->unreadWarm;
+			numWarm = num;
+		}
+	}
+	uint oldUser = user;
+	if (minHot < UINT_MAX) {
+		windowShow(numHot);
+		user = oldUser;
+	} else if (minWarm < UINT_MAX) {
+		windowShow(numWarm);
+		user = oldUser;
+	} else if (user != show) {
+		windowShow(user);
+	}
+}
+
+void windowSwap(void) {
+	windowShow(swap);
+}
+
+void windowMove(uint from, uint to) {
+	if (from >= count) return;
+	struct Window *window = windowRemove(from);
+	if (to < count) {
+		windowShow(windowInsert(to, window));
+	} else {
+		windowShow(windowPush(window));
+	}
+}
+
+void windowClose(uint num) {
+	if (num >= count) return;
+	if (windows[num]->id == Network) return;
+	struct Window *window = windowRemove(num);
+	completeRemove(window->id, NULL);
+	windowFree(window);
+	if (swap >= num) swap--;
+	if (show == num) {
+		windowShow(swap);
+		swap = show;
+	} else if (show > num) {
+		show--;
+		mainUpdate();
+	}
+	statusUpdate();
+}
+
+void windowList(void) {
+	for (uint num = 0; num < count; ++num) {
+		const struct Window *window = windows[num];
+		uiFormat(
+			Network, Warm, NULL, "\3%02d%u %s",
+			idColors[window->id], num, idNames[window->id]
+		);
+	}
+}
+
+void windowMark(void) {
+	mark(windows[show]);
+}
+
+void windowUnmark(void) {
+	unmark(windows[show]);
+}
+
+void windowToggleMute(void) {
+	windows[show]->mute ^= true;
+	statusUpdate();
+}
+
+void windowToggleTime(void) {
+	windows[show]->time ^= true;
+	reflow(windows[show]);
+	windowUpdate();
+	inputUpdate();
+}
+
+void windowToggleThresh(int n) {
+	struct Window *window = windows[show];
+	if (n > 0 && window->thresh == Hot) return;
+	if (n < 0 && window->thresh == Ice) {
+		window->thresh = Cold;
+	} else {
+		window->thresh += n;
+	}
+	reflow(window);
+	windowUpdate();
+}
+
+bool windowTimeEnable(void) {
+	return windows[show]->time;
+}
+
+void windowScroll(enum Scroll by, int n) {
+	struct Window *window = windows[show];
+	switch (by) {
+		break; case ScrollOne: {
+			scrollN(window, n);
+		}
+		break; case ScrollPage: {
+			scrollN(window, n * (MAIN_LINES - SplitLines - MarkerLines - 1));
+		}
+		break; case ScrollAll: {
+			if (n < 0) {
+				scrollTo(window, 0);
+				break;
+			}
+			for (size_t i = 0; i < BufferCap; ++i) {
+				if (!bufferHard(window->buffer, i)) continue;
+				scrollTo(window, BufferCap - i);
+				break;
+			}
+		}
+		break; case ScrollUnread: {
+			scrollTo(window, window->unreadHard);
+		}
+		break; case ScrollHot: {
+			for (size_t i = windowTop(window) + n; i < BufferCap; i += n) {
+				const struct Line *line = bufferHard(window->buffer, i);
+				const struct Line *prev = bufferHard(window->buffer, i - 1);
+				if (!line || line->heat < Hot) continue;
+				if (prev && prev->heat > Warm) continue;
+				scrollTo(window, BufferCap - i);
+				break;
+			}
+		}
+	}
+}
+
+void windowSearch(const char *str, int dir) {
+	struct Window *window = windows[show];
+	for (size_t i = windowTop(window) + dir; i < BufferCap; i += dir) {
+		const struct Line *line = bufferHard(window->buffer, i);
+		if (!line || !strcasestr(line->str, str)) continue;
+		scrollTo(window, BufferCap - i);
+		break;
+	}
+}
+
+static int writeTime(FILE *file, time_t time) {
+	return (fwrite(&time, sizeof(time), 1, file) ? 0 : -1);
+}
+
+static int writeString(FILE *file, const char *str) {
+	return (fwrite(str, strlen(str) + 1, 1, file) ? 0 : -1);
+}
+
+int windowSave(FILE *file) {
+	int error;
+	for (uint num = 0; num < count; ++num) {
+		const struct Window *window = windows[num];
+		error = 0
+			|| writeString(file, idNames[window->id])
+			|| writeTime(file, window->mute)
+			|| writeTime(file, window->time)
+			|| writeTime(file, window->thresh)
+			|| writeTime(file, window->heat)
+			|| writeTime(file, window->unreadSoft)
+			|| writeTime(file, window->unreadWarm);
+		if (error) return error;
+		for (size_t i = 0; i < BufferCap; ++i) {
+			const struct Line *line = bufferSoft(window->buffer, i);
+			if (!line) continue;
+			error = 0
+				|| writeTime(file, line->time)
+				|| writeTime(file, line->heat)
+				|| writeString(file, line->str);
+			if (error) return error;
+		}
+		error = writeTime(file, 0);
+		if (error) return error;
+	}
+	return writeString(file, "");
+}
+
+static time_t readTime(FILE *file) {
+	time_t time;
+	fread(&time, sizeof(time), 1, file);
+	if (ferror(file)) err(EX_IOERR, "fread");
+	if (feof(file)) errx(EX_DATAERR, "unexpected end of save file");
+	return time;
+}
+
+static ssize_t readString(FILE *file, char **buf, size_t *cap) {
+	ssize_t len = getdelim(buf, cap, '\0', file);
+	if (len < 0 && !feof(file)) err(EX_IOERR, "getdelim");
+	return len;
+}
+
+void windowLoad(FILE *file, size_t version) {
+	size_t cap = 0;
+	char *buf = NULL;
+	while (0 < readString(file, &buf, &cap) && buf[0]) {
+		struct Window *window = windows[windowFor(idFor(buf))];
+		if (version > 3) window->mute = readTime(file);
+		if (version > 6) window->time = readTime(file);
+		if (version > 5) window->thresh = readTime(file);
+		if (version > 0) {
+			window->heat = readTime(file);
+			window->unreadSoft = readTime(file);
+			window->unreadWarm = readTime(file);
+		}
+		for (;;) {
+			time_t time = readTime(file);
+			if (!time) break;
+			enum Heat heat = (version > 2 ? readTime(file) : Cold);
+			readString(file, &buf, &cap);
+			bufferPush(window->buffer, COLS, window->thresh, heat, time, buf);
+		}
+		reflow(window);
+	}
+	free(buf);
+}
diff --git a/xdg.c b/xdg.c
index 2500bac..75ee871 100644
--- a/xdg.c
+++ b/xdg.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2019, 2020  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2019, 2020  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -32,126 +32,100 @@
 #include <stdlib.h>
 #include <string.h>
 #include <sys/stat.h>
+#include <sysexits.h>
 
 #include "chat.h"
 
 #define SUBDIR "catgirl"
 
-FILE *configOpen(const char *path, const char *mode) {
-	if (path[0] == '/' || path[0] == '.') goto local;
-
-	const char *home = getenv("HOME");
-	const char *configHome = getenv("XDG_CONFIG_HOME");
-	const char *configDirs = getenv("XDG_CONFIG_DIRS");
-
-	char buf[PATH_MAX];
-	if (configHome) {
-		snprintf(buf, sizeof(buf), "%s/" SUBDIR "/%s", configHome, path);
-	} else {
-		if (!home) goto local;
-		snprintf(buf, sizeof(buf), "%s/.config/" SUBDIR "/%s", home, path);
+struct Base {
+	const char *envHome;
+	const char *envDirs;
+	const char *defHome;
+	const char *defDirs;
+};
+
+static const struct Base Config = {
+	.envHome = "XDG_CONFIG_HOME",
+	.envDirs = "XDG_CONFIG_DIRS",
+	.defHome = ".config",
+	.defDirs = "/etc/xdg",
+};
+
+static const struct Base Data = {
+	.envHome = "XDG_DATA_HOME",
+	.envDirs = "XDG_DATA_DIRS",
+	.defHome = ".local/share",
+	.defDirs = "/usr/local/share:/usr/share",
+};
+
+static char *basePath(
+	struct Base base, char *buf, size_t cap, const char *path, int i
+) {
+	if (path[strspn(path, ".")] == '/') {
+		if (i > 0) return NULL;
+		snprintf(buf, cap, "%s", path);
+		return buf;
 	}
-	FILE *file = fopen(buf, mode);
-	if (file) return file;
-	if (errno != ENOENT) warn("%s", buf);
 
-	if (!configDirs) configDirs = "/etc/xdg";
-	while (*configDirs) {
-		size_t len = strcspn(configDirs, ":");
+	if (i > 0) {
+		const char *dirs = getenv(base.envDirs);
+		if (!dirs) dirs = base.defDirs;
+		for (; i > 1; --i) {
+			dirs += strcspn(dirs, ":");
+			dirs += (*dirs == ':');
+		}
+		if (!*dirs) return NULL;
 		snprintf(
-			buf, sizeof(buf), "%.*s/" SUBDIR "/%s",
-			(int)len, configDirs, path
+			buf, cap, "%.*s/" SUBDIR "/%s",
+			(int)strcspn(dirs, ":"), dirs, path
 		);
-		file = fopen(buf, mode);
-		if (file) return file;
-		if (errno != ENOENT) warn("%s", buf);
-		configDirs += len;
-		if (*configDirs) configDirs++;
+		return buf;
 	}
 
-local:
-	file = fopen(path, mode);
-	if (!file) warn("%s", path);
-	return file;
+	const char *home = getenv("HOME");
+	const char *baseHome = getenv(base.envHome);
+	if (baseHome) {
+		snprintf(buf, cap, "%s/" SUBDIR "/%s", baseHome, path);
+	} else if (home) {
+		snprintf(buf, cap, "%s/%s/" SUBDIR "/%s", home, base.defHome, path);
+	} else {
+		errx(EX_USAGE, "HOME unset");
+	}
+	return buf;
 }
 
-FILE *dataOpen(const char *path, const char *mode) {
-	if (path[0] == '/' || path[0] == '.') goto local;
+char *configPath(char *buf, size_t cap, const char *path, int i) {
+	return basePath(Config, buf, cap, path, i);
+}
 
-	const char *home = getenv("HOME");
-	const char *dataHome = getenv("XDG_DATA_HOME");
-	const char *dataDirs = getenv("XDG_DATA_DIRS");
+char *dataPath(char *buf, size_t cap, const char *path, int i) {
+	return basePath(Data, buf, cap, path, i);
+}
 
-	char homePath[PATH_MAX];
-	if (dataHome) {
-		snprintf(
-			homePath, sizeof(homePath),
-			"%s/" SUBDIR "/%s", dataHome, path
-		);
-	} else {
-		if (!home) goto local;
-		snprintf(
-			homePath, sizeof(homePath),
-			"%s/.local/share/" SUBDIR "/%s", home, path
-		);
+FILE *configOpen(const char *path, const char *mode) {
+	char buf[PATH_MAX];
+	for (int i = 0; configPath(buf, sizeof(buf), path, i); ++i) {
+		FILE *file = fopen(buf, mode);
+		if (file) return file;
+		if (errno != ENOENT) warn("%s", buf);
 	}
-	FILE *file = fopen(homePath, mode);
-	if (file) return file;
-	if (errno != ENOENT) warn("%s", homePath);
+	warn("%s", configPath(buf, sizeof(buf), path, 0));
+	return NULL;
+}
 
+FILE *dataOpen(const char *path, const char *mode) {
 	char buf[PATH_MAX];
-	if (!dataDirs) dataDirs = "/usr/local/share:/usr/share";
-	while (*dataDirs) {
-		size_t len = strcspn(dataDirs, ":");
-		snprintf(
-			buf, sizeof(buf), "%.*s/" SUBDIR "/%s",
-			(int)len, dataDirs, path
-		);
-		file = fopen(buf, mode);
+	for (int i = 0; dataPath(buf, sizeof(buf), path, i); ++i) {
+		FILE *file = fopen(buf, mode);
 		if (file) return file;
 		if (errno != ENOENT) warn("%s", buf);
-		dataDirs += len;
-		if (*dataDirs) dataDirs++;
 	}
-
 	if (mode[0] != 'r') {
-		char *base = strrchr(homePath, '/');
-		*base = '\0';
-		int error = mkdir(homePath, S_IRWXU);
-		if (error && errno != EEXIST) {
-			warn("%s", homePath);
-			return NULL;
-		}
-		*base = '/';
-		file = fopen(homePath, mode);
-		if (!file) warn("%s", homePath);
-		return file;
+		int error = mkdir(dataPath(buf, sizeof(buf), "", 0), S_IRWXU);
+		if (error && errno != EEXIST) warn("%s", buf);
 	}
-
-local:
-	file = fopen(path, mode);
-	if (!file) warn("%s", path);
+	FILE *file = fopen(dataPath(buf, sizeof(buf), path, 0), mode);
+	if (!file) warn("%s", buf);
 	return file;
 }
-
-void dataMkdir(const char *path) {
-	const char *home = getenv("HOME");
-	const char *dataHome = getenv("XDG_DATA_HOME");
-
-	char homePath[PATH_MAX];
-	if (dataHome) {
-		snprintf(
-			homePath, sizeof(homePath),
-			"%s/" SUBDIR "/%s", dataHome, path
-		);
-	} else {
-		if (!home) return;
-		snprintf(
-			homePath, sizeof(homePath),
-			"%s/.local/share/" SUBDIR "/%s", home, path
-		);
-	}
-
-	int error = mkdir(homePath, S_IRWXU);
-	if (error && errno != EEXIST) warn("%s", homePath);
-}