about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--Makefile65
-rw-r--r--README.774
-rw-r--r--buffer.c11
-rw-r--r--catgirl.1731
-rw-r--r--chat.c110
-rw-r--r--chat.h168
-rw-r--r--command.c194
-rw-r--r--compat_readpassphrase.c206
-rw-r--r--complete.c173
-rw-r--r--config.c16
-rwxr-xr-xconfigure19
-rw-r--r--edit.c454
-rw-r--r--edit.h74
-rw-r--r--filter.c11
-rw-r--r--handle.c375
-rw-r--r--input.c628
-rw-r--r--irc.c39
-rw-r--r--log.c59
-rw-r--r--sandman.1 (renamed from scripts/sandman.1)2
-rw-r--r--sandman.m (renamed from scripts/sandman.m)15
-rw-r--r--scripts/.gitignore1
-rw-r--r--scripts/build-chroot.sh74
-rw-r--r--scripts/chroot-man.sh2
-rw-r--r--scripts/chroot-prompt.sh7
-rw-r--r--scripts/reconnect.sh10
-rw-r--r--scripts/sshd_config9
-rw-r--r--ui.c1020
-rw-r--r--url.c37
-rw-r--r--window.c658
-rw-r--r--xdg.c5
31 files changed, 3128 insertions, 2121 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 48fc350..e08e8e3 100644
--- a/Makefile
+++ b/Makefile
@@ -3,13 +3,18 @@ BINDIR ?= ${PREFIX}/bin
 MANDIR ?= ${PREFIX}/man
 
 CEXTS = gnu-case-range gnu-conditional-omitted-operand
-CFLAGS += -std=c11 -Wall -Wextra -Wpedantic ${CEXTS:%=-Wno-%}
+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
@@ -19,55 +24,51 @@ OBJS += config.o
 OBJS += edit.o
 OBJS += filter.o
 OBJS += handle.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: *.[ch]
-	ctags -w *.[ch]
-
-clean:
-	rm -f catgirl ${OBJS} tags
-
-install: catgirl catgirl.1
-	install -d ${DESTDIR}${BINDIR} ${DESTDIR}${MANDIR}/man1
-	install catgirl ${DESTDIR}${BINDIR}
-	install -m 644 catgirl.1 ${DESTDIR}${MANDIR}/man1
+edit.o edit.t input.o: edit.h
 
-uninstall:
-	rm -f ${DESTDIR}${BINDIR}/catgirl ${DESTDIR}${MANDIR}/man1/catgirl.1
+sandman: ${OBJS.sandman}
+	${CC} ${LDFLAGS} ${OBJS.$@} ${LDLIBS.$@} -o $@
 
-scripts/sandman: scripts/sandman.o
-	${CC} ${LDFLAGS} scripts/sandman.o -framework Cocoa -o $@
+check: ${TESTS}
 
-install-sandman: scripts/sandman scripts/sandman.1
-	install -d ${DESTDIR}${BINDIR} ${DESTDIR}${MANDIR}/man1
-	install scripts/sandman ${DESTDIR}${BINDIR}
-	install -m 644 scripts/sandman.1 ${DESTDIR}${MANDIR}/man1
+.SUFFIXES: .t
 
-uninstall-sandman:
-	rm -f ${DESTDIR}${BINDIR}/sandman ${DESTDIR}${MANDIR}/man1/sandman.1
+.c.t:
+	${CC} ${CFLAGS} -DTEST ${LDFLAGS} $< ${LDLIBS} -o $@
+	./$@ || rm $@
 
-CHROOT_USER = chat
-CHROOT_GROUP = ${CHROOT_USER}
+tags: *.[ch]
+	ctags -w *.[ch]
 
-chroot.tar: catgirl catgirl.1 scripts/chroot-prompt.sh scripts/chroot-man.sh
-chroot.tar: scripts/build-chroot.sh
-	sh scripts/build-chroot.sh ${CHROOT_USER} ${CHROOT_GROUP}
+clean:
+	rm -f ${BINS} ${OBJS} ${OBJS.sandman} ${TESTS} tags
 
-install-chroot: chroot.tar
-	tar -px -f chroot.tar -C /home/${CHROOT_USER}
+install: ${BINS} ${MANS}
+	install -d ${DESTDIR}${BINDIR} ${DESTDIR}${MANDIR}/man1
+	install ${BINS} ${DESTDIR}${BINDIR}
+	install -m 644 ${MANS} ${DESTDIR}${MANDIR}/man1
 
-clean-chroot:
-	rm -fr chroot.tar root
+uninstall:
+	rm -f ${BINS:%=${DESTDIR}${BINDIR}/%}
+	rm -f ${MANS:%=${DESTDIR}${MANDIR}/man1/%}
diff --git a/README.7 b/README.7
index c717aa9..c4f82e8 100644
--- a/README.7
+++ b/README.7
@@ -1,5 +1,5 @@
-.\" To view this file, run: man ./README.7
-.Dd June 28, 2021
+.\" To view this file: $ man ./README.7
+.Dd May  8, 2025
 .Dt README 7
 .Os "Causal Agency"
 .
@@ -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
@@ -80,7 +88,7 @@ Reconnection:
 when the connection to the server is lost,
 .Nm
 exits.
-It can be run in a loop
+She can be run in a loop
 or connected to a bouncer,
 such as
 .Lk https://git.causal.agency/pounce "pounce" .
@@ -101,6 +109,10 @@ TLS is now ubiquitous
 and certificates are easy to obtain.
 .El
 .
+.Sh TESTIMONIALS
+.Dq catgirl has like the best scrolling i've ever used in a terminal application
+.D1 \(em my friend kylie
+.
 .Sh INSTALLING
 .Nm
 requires ncurses and
@@ -124,15 +136,15 @@ 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 edit.c .
+.Pa input.c .
 .
 .Pp
 If installing
@@ -155,7 +167,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
@@ -165,10 +177,10 @@ wrapper is provided for macOS
 to stop and start
 .Nm
 on system sleep and wake.
-Install it as follows:
+To enable him,
+configure with:
 .Bd -literal -offset indent
-make scripts/sandman
-sudo make install-sandman
+$ ./configure --enable-sandman
 .Ed
 .
 .Sh FILES
@@ -181,10 +193,14 @@ 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
@@ -201,6 +217,8 @@ chat logging
 configuration parsing
 .It Pa xdg.c
 XDG base directories
+.It Pa sandman.m
+sleep/wake wrapper for macOS
 .El
 .
 .Pp
@@ -210,24 +228,13 @@ example
 .Xr tmux 1
 configuration for multiple networks
 and automatic reconnects
-.It Pa scripts/sandman.m
-sleep/wake wrapper for macOS
+.It Pa scripts/reconnect.sh
+example script to restart
+.Xr catgirl 1
+when she gets disconnected
 .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
-.Xr man 1
-implementation for chroot
-.It Pa scripts/sshd_config
-.Xr sshd 8
-configuration for public chroot
 .El
 .
 .Sh CONTRIBUTING
@@ -240,22 +247,19 @@ For sending patches by email, see
 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
index b87816e..6a7b412 100644
--- a/buffer.c
+++ b/buffer.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
@@ -30,7 +30,6 @@
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
-#include <sysexits.h>
 #include <time.h>
 #include <wchar.h>
 #include <wctype.h>
@@ -50,7 +49,7 @@ struct Buffer {
 
 struct Buffer *bufferAlloc(void) {
 	struct Buffer *buffer = calloc(1, sizeof(*buffer));
-	if (!buffer) err(EX_OSERR, "calloc");
+	if (!buffer) err(1, "calloc");
 	return buffer;
 }
 
@@ -107,7 +106,7 @@ static int flow(struct Lines *hard, int cols, const struct Line *soft) {
 	line->heat = soft->heat;
 	line->time = soft->time;
 	line->str = strdup(soft->str);
-	if (!line->str) err(EX_OSERR, "strdup");
+	if (!line->str) err(1, "strdup");
 
 	int width = 0;
 	int align = 0;
@@ -185,7 +184,7 @@ static int flow(struct Lines *hard, int cols, const struct Line *soft) {
 
 		size_t cap = StyleCap + align + strlen(&wrap[n]) + 1;
 		line->str = malloc(cap);
-		if (!line->str) err(EX_OSERR, "malloc");
+		if (!line->str) err(1, "malloc");
 
 		char *end = &line->str[cap];
 		str = seprintf(line->str, end, "%*s", (width = align), "");
@@ -209,7 +208,7 @@ int bufferPush(
 	soft->heat = heat;
 	soft->time = time;
 	soft->str = strdup(str);
-	if (!soft->str) err(EX_OSERR, "strdup");
+	if (!soft->str) err(1, "strdup");
 	if (heat < thresh) return 0;
 	return flow(&buffer->hard, cols, soft);
 }
diff --git a/catgirl.1 b/catgirl.1
index bb425cc..f2a2fbb 100644
--- a/catgirl.1
+++ b/catgirl.1
@@ -1,4 +1,4 @@
-.Dd July 20, 2021
+.Dd May 24, 2024
 .Dt CATGIRL 1
 .Os
 .
@@ -8,7 +8,7 @@
 .
 .Sh SYNOPSIS
 .Nm
-.Op Fl KRelqv
+.Op Fl Relqv
 .Op Fl C Ar copy
 .Op Fl H Ar hash
 .Op Fl I Ar highlight
@@ -46,9 +46,9 @@
 The
 .Nm
 IRC client
-provides a curses interface
-for TLS-only
-Internet Relay Chat.
+provides a curses interface for
+Internet Relay Chat
+over TLS.
 The only required option is
 .Fl h ,
 the host name to connect to.
@@ -62,7 +62,8 @@ in
 to view the list of
 .Sx COMMANDS
 and
-.Sx KEY BINDINGS .
+.Sx KEY BINDINGS
+in this manual.
 .
 .Pp
 Options can be loaded from files
@@ -78,50 +79,65 @@ unless the path starts with
 .Ql \&./
 or
 .Ql \&../ .
-Files and flags listed later
-on the command line
-take precedence over
-those listed earlier.
+For example,
+a configuration file at
+.Pa ~/.config/catgirl/tilde
+can be loaded by running
+.Ql catgirl tilde .
 .
 .Pp
 Each option is placed on a line,
 and lines beginning with
 .Ql #
 are ignored.
+An optional
+.Ql =
+may appear
+between an option and its value.
 The options are listed below
 following their corresponding flags.
+Flags and options in files are processed
+in the order they appear on the command line,
+so later values override earlier values.
 .
 .Bl -tag -width Ds
-.It Fl C Ar util | Cm copy No = Ar util
-Set the utility used by
-.Ic /copy .
+.It Fl C Ar util | Cm copy Ar util
+Set the utility used by the
+.Ic /copy 
+command.
 Subsequent
 .Cm copy
-options append arguments to
-.Ar util .
-The URL to copy is provided to
-.Ar util
-on standard input.
+options add arguments
+to the utility.
+The URL to copy is provided
+to the utility on standard input.
 The default is the first available of
 .Xr pbcopy 1 ,
 .Xr wl-copy 1 ,
-.Xr xclip 1 ,
+.Xr xclip 1
+or
 .Xr xsel 1 .
 .
-.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,
+.It Fl H Ar seed,bound | Cm hash Ar seed,bound
+Set the seed for choosing
+nick and channel colours
+and the maximum IRC colour value
+that will be chosen.
+Changing the seed
+will randomize the chosen colours,
+in case you don't like the ones
+chosen for yourself or your crush.
+.Pp
+The default is 0,75,
+which uses colours
+in the 256-colour terminal set.
+To use only colours
+from the 16-colour terminal set,
 use 0,15.
-To disable nick and channel colors,
+To disable nick and channel colours,
 use 0,0.
 .
-.It Fl I Ar pattern | Cm highlight No = Ar pattern
+.It Fl I Ar pattern | Cm highlight Ar pattern
 Add a case-insensitive message highlight pattern,
 which may contain
 .Ql * ,
@@ -132,60 +148,51 @@ wildcards as in
 .Xr glob 7 .
 The format of the pattern is as follows:
 .Bd -ragged -offset indent
+.\" FIXME: there's really no reason !user@host should be required
 .Ar nick Ns Op Ar !user@host Op Ar command Op Ar channel Op Ar message
 .Ed
 .Pp
-The commands which can be filtered are:
-.Sy INVITE ,
-.Sy JOIN ,
-.Sy NICK ,
-.Sy NOTICE ,
-.Sy PART ,
-.Sy PRIVMSG ,
-.Sy QUIT ,
-.Sy SETNAME .
-.
-.It Fl K | Cm kiosk
-Disable the
-.Ic /copy ,
-.Ic /debug ,
-.Ic /exec ,
-.Ic /join ,
-.Ic /list ,
-.Ic /msg ,
-.Ic /open ,
-.Ic /part ,
-.Ic /query ,
-.Ic /quote
-commands.
-Replace the username
-with a hash of its original value.
-.
-.It Fl N Ar util | Cm notify No = Ar util
+The commands which can be matched are:
+.Sy invite ,
+.Sy join ,
+.Sy nick ,
+.Sy notice ,
+.Sy part ,
+.Sy privmsg ,
+.Sy quit ,
+.Sy setname .
+.Pp
+For example,
+to highlight whenever your crush
+joins your favourite channel:
+.Pp
+.Dl highlight crush!*@* join #channel
+.
+.It Fl N Ar util | Cm notify Ar util
 Send notifications using a utility.
 Subsequent
 .Cm notify
-options append arguments to
-.Ar util .
+options add arguments
+to the utility.
 The window name and message
-are provided to
-.Ar util
+are provided to the utility
 as two additional arguments,
 appropriate for
 .Xr notify-send 1 .
 .
-.It Fl O Ar util | Cm open No = Ar util
-Set the utility used by
-.Ic /open .
+.It Fl O Ar util | Cm open Ar util
+Set the utility used by the
+.Ic /open
+command.
 Subsequent
 .Cm open
-options append arguments to
-.Ar util .
-The URL to open is provided to
-.Ar util
-as an argument.
+options add arguments
+to the utility.
+The URL to open is provided
+to the utility as an additional argument.
 The default is the first available of
-.Xr open 1 ,
+.Xr open 1
+or
 .Xr xdg-open 1 .
 .
 .It Fl R | Cm restrict
@@ -196,57 +203,80 @@ and
 .Ic /open
 commands,
 the
-.Fl N
+.Cm notify
 option,
 and viewing this manual with
 .Ic /help .
 .
-.It Fl S Ar host | Cm bind No = Ar host
+.It Fl S Ar host | Cm bind Ar host
 Bind to source address
 .Ar host
 when connecting to the server.
+To connect from any IPv4 address,
+use 0.0.0.0.
+To connect from any IPv6 address,
+use ::.
 .
-.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 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
+.It Fl T Ns Oo Ar format Oc | Cm timestamp Op Ar format
+Show timestamps by default.
+The optional
+.Ar format
+string is interpreted by
+.Xr strftime 3 .
+The string may contain
+raw IRC formatting codes,
+if you can figure out
+how to enter them.
+.
+.It Fl a Ar user : Ns Ar pass | Cm sasl-plain Ar user : Ns Ar pass
+Authenticate with NickServ
+during connection using SASL PLAIN.
+.Nm
+will disconnect
+if authentication fails.
+Leave
 .Ar pass
-using SASL PLAIN.
-Since this requires the account password
-in plain text,
-it is recommended to use CertFP instead.
+blank to prompt for the password when
+.Nm
+starts.
+.
+.It Fl c Ar path | Cm cert Ar path
+Connect using a TLS client certificate
+loaded from
+.Ar path ,
+which is searched for
+in the same manner as configuration files.
+If the private key
+is in a separate file,
+additionally specify it with the
+.Cm priv
+option.
+.Pp
+To use this certificate
+to authenticate to NickServ
+using CertFP,
+use the
+.Cm sasl-external
+option.
 See
 .Sx Configuring CertFP .
-.
-.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 .
-With
-.Fl e ,
-authenticate using SASL EXTERNAL.
-Certificates can be generated with
-.Fl g .
+.Pp
+Client certificates
+can be generated with the
+.Fl g
+flag.
 .
 .It Fl e | Cm sasl-external
-Authenticate using SASL EXTERNAL,
-also known as CertFP.
-The TLS client certificate is loaded with
-.Fl c .
+Authenticate to NickServ
+during connection using CertFP
+via SASL EXTERNAL.
+.Nm
+will disconnect
+if authentication fails.
+The client certificate
+must be specified with the
+.Cm cert
+option.
 See
 .Sx Configuring CertFP .
 .
@@ -256,11 +286,11 @@ Generate a TLS client certificate using
 and write it to
 .Ar path .
 .
-.It Fl h Ar host | Cm host No = Ar host
-Connect to
+.It Fl h Ar host | Cm host Ar host
+Connect to the IRC server
 .Ar host .
 .
-.It Fl i Ar pattern | Cm ignore No = Ar pattern
+.It Fl i Ar pattern | Cm ignore Ar pattern
 Add a case-insensitive message ignore pattern,
 which may contain
 .Ql * ,
@@ -274,70 +304,99 @@ The format of the pattern is as follows:
 .Ar nick Ns Op Ar !user@host Op Ar command Op Ar channel Op Ar message
 .Ed
 .Pp
-The commands which can be filtered are:
-.Sy INVITE ,
-.Sy JOIN ,
-.Sy NICK ,
-.Sy NOTICE ,
-.Sy PART ,
-.Sy PRIVMSG ,
-.Sy QUIT ,
-.Sy SETNAME .
-.
-.It Fl j Ar channels Oo Ar keys Oc | Cm join No = Ar channels Oo Ar keys Oc
+The commands which can be matched are:
+.Sy invite ,
+.Sy join ,
+.Sy nick ,
+.Sy notice ,
+.Sy part ,
+.Sy privmsg ,
+.Sy quit ,
+.Sy setname .
+.Pp
+Visibility of ignored messages
+can be toggled using
+.Ic M--
+and
+.Ic M-+ .
+.
+.It Fl j Ar channels Oo Ar keys Oc | Cm join Ar channels Oo Ar keys Oc
 Join the comma-separated list of
 .Ar channels
 with the optional comma-separated list of channel
 .Ar keys .
+No spaces may appear in either list.
 .
-.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 k Ar path | Cm priv Ar path
+Load the TLS client private key 
+for a certificate loaded with the
+.Cm cert
+option from
+.Ar path ,
+which is search for
+in the same manner as configuration files.
 .
 .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 m Ar mode | Cm mode No = Ar mode
-Set the user
-.Ar mode .
-.
-.It Fl n Ar nick | Cm nick No = Ar nick
-Set nickname to
-.Ar nick .
-The default nickname is
-the value of the environment variable
+Log messages to files in
+.Pa $XDG_DATA_HOME/catgirl/log
+.Po
+usually
+.Pa ~/.local/share/catgirl/log
+.Pc .
+Directories are created
+for each network and channel,
+and files are created for each date
+in the format
+.Pa YYYY-MM-DD.log .
+.
+.It Fl m Ar modes | Cm mode Ar modes
+Set user modes as soon as possible
+after connecting.
+.
+.It Fl n Ar nick Oo Ar ... Oc | Cm nick Ar nick Oo Ar ... Oc
+Set the nickname with optional fallbacks,
+should one nick be unavailable.
+Each nick is treated as a highlight word.
+The default nickname is the value of
 .Ev USER .
 .
 .It Fl o
-Print the server certificate chain
-to standard output in PEM format
-and exit.
+Connect to the server
+only to obtain its certificate chain
+and write it to standard output
+in PEM format.
 .
-.It Fl p Ar port | Cm port No = Ar port
-Connect to
+.It Fl p Ar port | Cm port Ar port
+Connect to the IRC server on
 .Ar port .
 The default port is 6697.
 .
 .It Fl q | Cm quiet
 Raise the default message visibility threshold
 for new windows,
-hiding general events.
+hiding general events
+(joins, quits, etc.).
+The threshold can be lowered with
+.Ic M-- .
 .
-.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 r Ar real | Cm real Ar real
+Set the
+.Dq realname
+which appears in
+.Ic /whois .
+The default is the same as the nickname.
+This is a good place to add your pronouns.
 .
-.It Fl s Ar name | Cm save No = Ar name
-Save and load the contents of windows from
+.It Fl s Ar name | Cm save Ar name
+Persist windows and their scrollback
+in a file called
 .Ar name
 in
-.Pa $XDG_DATA_DIRS/catgirl ,
+.Pa $XDG_DATA_DIRS/catgirl
+.Po
+usually
+.Pa ~/.local/share/catgirl
+.Pc ,
 or an absolute or relative path if
 .Ar name
 starts with
@@ -346,150 +405,305 @@ starts with
 or
 .Ql \&../ .
 .
-.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.
+.It Fl t Ar path | Cm trust Ar path
+Trust the self-signed certificate in
+.Ar path ,
+which is searched for
+in the same manner as configuration files.
+Server name verification is also disabled.
 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 u Ar user | Cm user Ar user
+Set the username.
+This is almost entirely irrelevant,
+except that it's more likely to remain stable,
+and
+.Nm
+uses it to choose nick colours.
+The default is the same as the nickname.
 .
 .It Fl v | Cm debug
-Log raw IRC messages to the
+Log raw IRC protocol to the
 .Sy <debug>
-window
+window,
 as well as standard error
 if it is not a terminal.
 .
-.It Fl w Ar pass | Cm pass No = Ar pass
-Log in with the server password
-.Ar pass .
+.It Fl w Ar pass | Cm pass Ar pass
+Connect using a server password.
+Leave
+.Ar pass
+blank
+.Po
+using an
+.Ql =
+.Pc
+to prompt for the password when
+.Nm
+starts.
 .El
 .
 .Ss Configuring CertFP
+CertFP allows you to
+authenticate with NickServ during connection
+using a TLS client certificate
+rather than your account password.
 .Bl -enum
 .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:
+Connect to the server using the certificate
+by adding the following configuration:
 .Bd -literal -offset indent
-cert = example.pem
-# or: catgirl -c example.pem
+cert example.pem
 .Ed
 .It
-Identify with services or use
-.Cm sasl-plain ,
+Identify with NickServ,
 then add the certificate fingerprint
 to your account:
 .Bd -literal -offset indent
 /ns CERT ADD
 .Ed
 .It
-Enable SASL EXTERNAL
+Enable SASL EXTERNAL in your configuration
 to require successful authentication
 when connecting
 (not possible on all networks):
 .Bd -literal -offset indent
-cert = example.pem
+cert example.pem
 sasl-external
-# or: catgirl -e -c example.pem
 .Ed
 .El
 .
 .Ss Connecting to Servers with Self-signed Certificates
+If connecting to a server fails
+with a certificate verification error
+due to a self-signed certificate,
+it needs to be trusted manually.
 .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
+$ 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
+trust example.pem
 .Ed
 .El
 .
+.Sh INTERFACE
+The
+.Nm
+interface is split
+into three main areas.
+.
+.Ss Status Line
+The top line of the terminal
+shows window statuses.
+Only the currently active window
+and windows with activity are shown.
+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 +
+or
+.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 current scroll position.
+An
+.Ql @
+indicates that there is unsent input
+waiting 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
+whenever there are messages
+in a window that is not active
+or the terminal is not focused
+(in some terminal emulators).
+.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.
+Commands can be abbreviated
+if no other command
+shares the same prefix.
 For example,
 .Ic /join
 can be typed
-.Ic /j .
+.Ic /j ,
+and
+.Ic /window
+can be typed
+.Ic /wi .
 .
 .Ss Chat Commands
 .Bl -tag -width Ds
 .It Ic /away Op Ar message
 Set or clear your away status.
+This is sent in reply to private messages
+and shown in
+.Ic /whois .
 .It Ic /cs Ar command
-Send a command to ChanServ.
+Send a command to ChanServ,
+the service for managing registered channels.
 .It Ic /invite Ar nick
-Invite a user to the channel.
+Invite someone to the channel.
 .It Ic /join Op Ar channel Op Ar key
 Join the named channel,
-the current channel,
+the current channel (if you've left),
 or the channel you've been invited to.
-.It Ic /list Op Ar channel
-List channels.
+.It Ic /list Op Ar search
+List channels, their user counts and their topics.
+The
+.Ar search
+can usually contain glob-style wildcards.
 .It Ic /me Op Ar action
 Send an action message.
+These are used to write messages in third person.
 .It Ic /msg Ar nick message
-Send a private message.
+Send a private message to someone.
 .It Ic /names
-List users in the channel.
+List the users in the channel.
 .It Ic /nick Ar nick
-Change nicknames.
+Change your nickname.
 .It Ic /notice Ar message
 Send a notice.
+It's best not to do this.
 .It Ic /ns Ar command
-Send a command to NickServ.
+Send a command to NickServ,
+the service for managing your account.
 .It Ic /ops
 List channel operators.
+They can kick or ban someone from the channel.
 .It Ic /part Op Ar message
 Leave the channel.
+Use
+.Ic /close
+if you want to close the window afterwards.
 .It Ic /query Ar nick
-Start a private conversation.
+Start a private conversation with someone.
 .It Ic /quit Op Ar message
-Quit IRC.
+Disconnect from IRC and close
+.Nm .
+You can do this even quicker with
+.Ic C-c .
 .It Ic /quote Ar command
 Send a raw IRC command.
-The
-.Ic /debug
-command is likely needed
-for command output.
+Often
+.Nm
+will not know how to interpret the results.
+You can use
+.Ic M--
+to show unknown server responses
+in the
+.Sy <network>
+or channel windows.
 .It Ic /say Ar message
 Send a regular message.
+This is useful
+if the message you want to send
+begins with a slash.
 .It Ic /setname Ar name
-Update realname
-if supported by the server.
+Update your 
+.Dq realname
+if the server supports it.
+This may be broadcast
+to other users
+with clients that support it.
 .It Ic /topic Op Ar topic
 Show or set the topic of the channel.
 Press
 .Ic Tab
-twice to copy the current topic.
+twice immediately after
+.Ic /topic
+to copy the current topic.
 .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
+.Ss Interface Commands
 .Bl -tag -width Ds
 .It Ic /close Op Ar name | num
 Close the named, numbered or current window.
@@ -524,15 +738,22 @@ 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
-.Fl I .
+To permanently add a pattern,
+use the
+.Cm highlight
+option.
 .It Ic /ignore Op Ar pattern
 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 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
@@ -660,11 +881,13 @@ Scroll down a page.
 .It Ic M-+
 Raise message visibility threshold,
 hiding ignored messages,
-general events,
+general events
+(joins, quits, etc.),
 or non-highlighted messages.
 .It Ic M--
 Lower message visibility threshold,
-showing ignored messages.
+showing ignored messages
+and unknown replies.
 .It Ic M-=
 Toggle mute.
 Muted windows do not appear in the status line
@@ -693,6 +916,8 @@ Insert a blank line in the window.
 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
@@ -717,6 +942,8 @@ Reset formatting.
 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
@@ -760,13 +987,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
@@ -803,9 +1027,7 @@ The
 .Nm
 client exits 0
 if requested by the user,
-.Dv EX_UNAVAILABLE
-(69)
-if the connection is lost,
+69 if the connection is lost,
 and >0 if any other error occurs.
 .
 .Sh EXAMPLES
@@ -813,7 +1035,7 @@ Join
 .Li #ascii.town
 from the command line:
 .Bd -literal -offset indent
-catgirl -h irc.tilde.chat -j '#ascii.town'
+$ catgirl -h irc.tilde.chat -j '#ascii.town'
 .Ed
 .Pp
 Create a configuration file in
@@ -825,7 +1047,7 @@ join = #ascii.town
 .Pp
 Load the configuration file:
 .Bd -literal -offset indent
-catgirl tilde
+$ catgirl tilde
 .Ed
 .
 .Sh STANDARDS
@@ -834,13 +1056,15 @@ catgirl tilde
 .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
@@ -852,16 +1076,16 @@ catgirl tilde
 .%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 Kiyoshi Aman
-.%T IRCv3.1 extended-join Extension
+.%T extended-join Extension
 .%I IRCv3 Working Group
-.%U https://ircv3.net/specs/extensions/extended-join-3.1
+.%U https://ircv3.net/specs/extensions/extended-join
 .Re
 .It
 .Rs
@@ -875,9 +1099,11 @@ catgirl tilde
 .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
@@ -885,19 +1111,22 @@ catgirl tilde
 .%A St\('ephan Kochen
 .%A Alexey Sokolov
 .%A James Wheare
-.%T IRCv3.2 server-time Extension
+.%T server-time Extension
 .%I IRCv3 Working Group
-.%U https://ircv3.net/specs/extensions/server-time-3.2
+.%U https://ircv3.net/specs/extensions/server-time
 .Re
 .It
 .Rs
 .%A Lee Hardy
 .%A Perry Lorier
 .%A Kevin L. Mitchell
+.%A Attila Molnar
+.%A Daniel Oakley
 .%A William Pitcock
-.%T IRCv3.1 Client Capability Negotiation
+.%A James Wheare
+.%T Client Capability Negotiation
 .%I IRCv3 Working Group
-.%U https://ircv3.net/specs/core/capability-negotiation-3.1.html
+.%U https://ircv3.net/specs/core/capability-negotiation
 .Re
 .It
 .Rs
@@ -920,30 +1149,30 @@ catgirl tilde
 .It
 .Rs
 .%A Janne Mareike Koschinski
-.%T IRCv3 setname Extension
+.%T setname Extension
 .%I IRCv3 Working Group
 .%U https://ircv3.net/specs/extensions/setname
 .Re
 .It
 .Rs
 .%A Mantas Mikul\[u0117]nas
-.%T IRCv3.2 userhost-in-names Extension
+.%T userhost-in-names Extension
 .%I IRCv3 Working Group
-.%U https://ircv3.net/specs/extensions/userhost-in-names-3.2
+.%U https://ircv3.net/specs/extensions/userhost-in-names
 .Re
 .It
 .Rs
 .%A Daniel Oaks
-.%T Standard Replies Extension
-.%I IRCv3 Working Group
-.%U https://ircv3.net/specs/extensions/standard-replies
+.%T IRC Formatting
+.%I ircdocs
+.%U https://modern.ircdocs.horse/formatting.html
 .Re
 .It
 .Rs
 .%A Daniel Oaks
-.%T IRC Formatting
-.%I ircdocs
-.%U https://modern.ircdocs.horse/formatting.html
+.%T Standard Replies Extension
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/extensions/standard-replies
 .Re
 .It
 .Rs
@@ -961,28 +1190,28 @@ catgirl tilde
 .%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-3.1
+.%U https://ircv3.net/specs/extensions/multi-prefix
 .Re
 .It
 .Rs
 .%A James Wheare
-.%T Reply Client Tag
+.%T Message IDs
 .%I IRCv3 Working Group
-.%U https://ircv3.net/specs/client-tags/reply
+.%U https://ircv3.net/specs/extensions/message-ids
 .Re
 .It
 .Rs
 .%A James Wheare
-.%T Message IDs
+.%T reply Client Tag
 .%I IRCv3 Working Group
-.%U https://ircv3.net/specs/extensions/message-ids
+.%U https://ircv3.net/specs/client-tags/reply
 .Re
 .It
 .Rs
@@ -1008,7 +1237,7 @@ The consumer position is stored in the
 file.
 .
 .Sh AUTHORS
-.An June Bug Aq Mt june@causal.agency
+.An June McEnroe Aq Mt june@causal.agency
 .
 .Sh BUGS
 Send mail to
diff --git a/chat.c b/chat.c
index ba6c9a1..bc23c3f 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
@@ -41,7 +41,6 @@
 #include <sys/stat.h>
 #include <sys/time.h>
 #include <sys/wait.h>
-#include <sysexits.h>
 #include <time.h>
 #include <tls.h>
 #include <unistd.h>
@@ -50,6 +49,8 @@
 #include <capsicum_helpers.h>
 #endif
 
+char *readpassphrase(const char *prompt, char *buf, size_t bufsiz, int flags);
+
 #include "chat.h"
 
 #ifndef OPENSSL_BIN
@@ -68,7 +69,7 @@ static void genCert(const char *path) {
 		"-nodes", "-subj", subj, "-out", path, "-keyout", path,
 		NULL
 	);
-	err(EX_UNAVAILABLE, "openssl");
+	err(127, "openssl");
 }
 
 char *idNames[IDCap] = {
@@ -91,7 +92,7 @@ static void exitSave(void) {
 	int error = uiSave();
 	if (error) {
 		warn("%s", save);
-		_exit(EX_IOERR);
+		_exit(1);
 	}
 }
 
@@ -102,7 +103,7 @@ int utilPipe[2] = { -1, -1 };
 static void execRead(void) {
 	char buf[1024];
 	ssize_t len = read(execPipe[0], buf, sizeof(buf) - 1);
-	if (len < 0) err(EX_IOERR, "read");
+	if (len < 0) err(1, "read");
 	if (!len) return;
 	buf[len] = '\0';
 	for (char *ptr = buf; ptr;) {
@@ -114,7 +115,7 @@ static void execRead(void) {
 static void utilRead(void) {
 	char buf[1024];
 	ssize_t len = read(utilPipe[0], buf, sizeof(buf) - 1);
-	if (len < 0) err(EX_IOERR, "read");
+	if (len < 0) err(1, "read");
 	if (!len) return;
 	buf[len] = '\0';
 	for (char *ptr = buf; ptr;) {
@@ -131,6 +132,12 @@ static void parseHash(char *str) {
 	if (*str) hashBound = strtoul(&str[1], NULL, 0);
 }
 
+static void parsePlain(char *str) {
+	self.plainUser = strsep(&str, ":");
+	if (!str) errx(1, "SASL PLAIN missing colon");
+	self.plainPass = str;
+}
+
 static volatile sig_atomic_t signals[NSIG];
 static void signalHandler(int signal) {
 	signals[signal] = 1;
@@ -151,27 +158,27 @@ static void sandboxEarly(bool log) {
 	if (log) {
 		char buf[PATH_MAX];
 		int error = unveil(dataPath(buf, sizeof(buf), "log", 0), "wc");
-		if (error) err(EX_OSERR, "unveil");
+		if (error) err(1, "unveil");
 		ptr = seprintf(ptr, end, " wpath cpath");
 	}
 
 	if (!self.restricted) {
 		int error = unveil("/", "x");
-		if (error) err(EX_OSERR, "unveil");
+		if (error) err(1, "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");
+	if (error) err(1, "pledge");
 }
 
 static void sandboxLate(int irc) {
 	(void)irc;
 	*promisesInitial = '\0';
 	int error = pledge(promises, NULL);
-	if (error) err(EX_OSERR, "pledge");
+	if (error) err(1, "pledge");
 }
 
 #elif defined __FreeBSD__
@@ -194,7 +201,7 @@ static void sandboxLate(int irc) {
 		|| caph_rights_limit(
 			irc, cap_rights_init(&rights, CAP_SEND, CAP_RECV, CAP_EVENT)
 		);
-	if (error) err(EX_OSERR, "cap_rights_limit");
+	if (error) err(1, "cap_rights_limit");
 
 	// caph_cache_tzdata(3) doesn't load UTC info, which we need for
 	// certificate verification. gmtime(3) does.
@@ -202,7 +209,7 @@ static void sandboxLate(int irc) {
 	gmtime(&(time_t) { time(NULL) });
 
 	error = cap_enter();
-	if (error) err(EX_OSERR, "cap_enter");
+	if (error) err(1, "cap_enter");
 }
 
 #else
@@ -229,7 +236,6 @@ int main(int argc, char *argv[]) {
 	bool log = false;
 	bool sasl = false;
 	char *pass = NULL;
-	const char *nick = NULL;
 	const char *user = NULL;
 	const char *real = NULL;
 
@@ -238,7 +244,6 @@ int main(int argc, char *argv[]) {
 		{ .val = 'C', .name = "copy", required_argument },
 		{ .val = 'H', .name = "hash", required_argument },
 		{ .val = 'I', .name = "highlight", required_argument },
-		{ .val = 'K', .name = "kiosk", no_argument },
 		{ .val = 'N', .name = "notify", required_argument },
 		{ .val = 'O', .name = "open", required_argument },
 		{ .val = 'R', .name = "restrict", no_argument },
@@ -279,16 +284,15 @@ int main(int argc, char *argv[]) {
 			break; case 'C': utilPush(&urlCopyUtil, optarg);
 			break; case 'H': parseHash(optarg);
 			break; case 'I': filterAdd(Hot, optarg);
-			break; case 'K': self.kiosk = true;
 			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 'T': {
-				uiTime.enable = true;
-				if (optarg) uiTime.format = optarg;
+				windowTime.enable = true;
+				if (optarg) windowTime.format = optarg;
 			}
-			break; case 'a': sasl = true; self.plain = optarg;
+			break; case 'a': sasl = true; parsePlain(optarg);
 			break; case 'c': cert = optarg;
 			break; case 'e': sasl = true;
 			break; case 'g': genCert(optarg);
@@ -298,43 +302,54 @@ int main(int argc, char *argv[]) {
 			break; case 'k': priv = optarg;
 			break; case 'l': log = true; logOpen();
 			break; case 'm': self.mode = optarg;
-			break; case 'n': nick = 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': uiThreshold = Warm;
+			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;
-			break; default:  return EX_USAGE;
+			break; default:  return 1;
 		}
 	}
-	if (!host) errx(EX_USAGE, "host required");
+	if (!host) errx(1, "host required");
 
 	if (printCert) {
 #ifdef __OpenBSD__
 		int error = pledge("stdio inet dns", NULL);
-		if (error) err(EX_OSERR, "pledge");
+		if (error) err(1, "pledge");
 #endif
 		ircConfig(true, NULL, NULL, NULL);
 		ircConnect(bind, host, port);
 		ircPrintCert();
 		ircClose();
-		return EX_OK;
+		return 0;
 	}
 
-	if (!nick) nick = getenv("USER");
-	if (!nick) errx(EX_CONFIG, "USER unset");
-	if (!user) user = nick;
-	if (!real) real = nick;
+	if (!self.nicks[0]) self.nicks[0] = getenv("USER");
+	if (!self.nicks[0]) errx(1, "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(1, "malloc");
+		pass = readpassphrase("Server password: ", buf, 512, 0);
+		if (!pass) errx(1, "unable to read passphrase");
+	}
 
-	if (self.kiosk) {
-		char *hash;
-		int n = asprintf(&hash, "%08" PRIx32, _hash(user));
-		if (n < 0) err(EX_OSERR, "asprintf");
-		user = hash;
+	if (self.plainPass && !self.plainPass[0]) {
+		char *buf = malloc(512);
+		if (!buf) err(1, "malloc");
+		self.plainPass = readpassphrase("Account password: ", buf, 512, 0);
+		if (!self.plainPass) errx(1, "unable to read passphrase");
 	}
 
 	// Modes defined in RFC 1459:
@@ -349,18 +364,17 @@ int main(int argc, char *argv[]) {
 	set(&network.name, host);
 	set(&self.nick, "*");
 
-	editCompleteAdd();
-	commandCompleteAdd();
+	inputCompletion();
 
 	ircConfig(insecure, trust, cert, priv);
 
-	uiInitEarly();
+	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^  "
@@ -383,20 +397,20 @@ int main(int argc, char *argv[]) {
 	}
 	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);
 
-	uiInitLate();
+	// Avoid disabling VINTR until main loop.
+	inputInit();
 	signal(SIGHUP, signalHandler);
 	signal(SIGINT, signalHandler);
 	signal(SIGALRM, signalHandler);
 	signal(SIGTERM, signalHandler);
 	signal(SIGCHLD, signalHandler);
 
-	bool pipes = !self.kiosk && !self.restricted;
-	if (pipes) {
+	if (!self.restricted) {
 		int error = pipe(utilPipe) || pipe(execPipe);
-		if (error) err(EX_OSERR, "pipe");
+		if (error) err(1, "pipe");
 
 		fcntl(utilPipe[0], F_SETFD, FD_CLOEXEC);
 		fcntl(utilPipe[1], F_SETFD, FD_CLOEXEC);
@@ -412,10 +426,10 @@ int main(int argc, char *argv[]) {
 		{ .events = POLLIN, .fd = execPipe[0] },
 	};
 	while (!self.quit) {
-		int nfds = poll(fds, (pipes ? ARRAY_LEN(fds) : 2), -1);
-		if (nfds < 0 && errno != EINTR) err(EX_IOERR, "poll");
+		int nfds = poll(fds, (self.restricted ? 2 : ARRAY_LEN(fds)), -1);
+		if (nfds < 0 && errno != EINTR) err(1, "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();
@@ -431,12 +445,12 @@ int main(int argc, char *argv[]) {
 				.it_interval.tv_sec = 30,
 			};
 			int error = setitimer(ITIMER_REAL, &timer, NULL);
-			if (error) err(EX_OSERR, "setitimer");
+			if (error) err(1, "setitimer");
 		}
 		if (signals[SIGALRM]) {
 			signals[SIGALRM] = 0;
 			if (ping) {
-				errx(EX_UNAVAILABLE, "ping timeout");
+				errx(69, "ping timeout");
 			} else {
 				ircFormat("PING nyaa\r\n");
 				ping = true;
@@ -467,7 +481,7 @@ int main(int argc, char *argv[]) {
 			cursesWinch(SIGWINCH);
 			// 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 19d2b18..369747c 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
@@ -35,7 +35,6 @@
 #include <stdio.h>
 #include <string.h>
 #include <strings.h>
-#include <sysexits.h>
 #include <time.h>
 #include <wchar.h>
 
@@ -131,7 +130,7 @@ static inline uint idFor(const char *name) {
 	if (idNext == IDCap) return Network;
 	idNames[idNext] = strdup(name);
 	idColors[idNext] = Default;
-	if (!idNames[idNext]) err(EX_OSERR, "strdup");
+	if (!idNames[idNext]) err(1, "strdup");
 	return idNext++;
 }
 
@@ -168,6 +167,19 @@ 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) \
@@ -189,13 +201,14 @@ enum Cap {
 
 extern struct Self {
 	bool debug;
-	bool kiosk;
 	bool restricted;
 	size_t pos;
 	enum Cap caps;
-	char *plain;
-	char *mode;
-	char *join;
+	const char *plainUser;
+	char *plainPass;
+	const char *nicks[8];
+	const char *mode;
+	const char *join;
 	char *nick;
 	char *user;
 	char *host;
@@ -207,7 +220,7 @@ extern struct Self {
 static inline void set(char **field, const char *value) {
 	free(*field);
 	*field = strdup(value);
-	if (!*field) err(EX_OSERR, "strdup");
+	if (!*field) err(1, "strdup");
 }
 
 #define ENUM_TAG \
@@ -259,7 +272,7 @@ static inline void utilPush(struct Util *util, const char *arg) {
 	if (1 + util->argc < UtilCap) {
 		util->argv[util->argc++] = arg;
 	} else {
-		errx(EX_CONFIG, "too many utility arguments");
+		errx(1, "too many utility arguments");
 	}
 }
 
@@ -276,7 +289,6 @@ enum Reply {
 	ReplyNamesAuto,
 	ReplyTopic,
 	ReplyTopicAuto,
-	ReplyWho,
 	ReplyWhois,
 	ReplyWhowas,
 	ReplyCap,
@@ -290,29 +302,36 @@ const char *commandIsPrivmsg(uint id, const char *input);
 const char *commandIsNotice(uint id, const char *input);
 const char *commandIsAction(uint id, const char *input);
 size_t commandWillSplit(uint id, const char *input);
-void commandCompleteAdd(void);
+void commandCompletion(void);
 
-enum Heat { Ice, Cold, Warm, Hot };
-enum { TimeCap = 64 };
-extern enum Heat uiThreshold;
-extern struct Time {
-	bool enable;
-	const char *format;
-	int width;
-} uiTime;
+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 uiInitEarly(void);
-void uiInitLate(void);
+void uiInit(void);
+uint uiAttr(struct Style style);
+short uiPair(struct Style style);
 void uiShow(void);
 void uiHide(void);
 void uiDraw(void);
-void uiWindows(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, ...
@@ -320,6 +339,53 @@ void uiFormat(
 void uiLoad(const char *name);
 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 {
@@ -340,42 +406,22 @@ int bufferReflow(
 	struct Buffer *buffer, int cols, enum Heat thresh, size_t tail
 );
 
-enum Edit {
-	EditHead,
-	EditTail,
-	EditPrev,
-	EditNext,
-	EditPrevWord,
-	EditNextWord,
-	EditDeleteHead,
-	EditDeleteTail,
-	EditDeletePrev,
-	EditDeleteNext,
-	EditDeletePrevWord,
-	EditDeleteNextWord,
-	EditPaste,
-	EditTranspose,
-	EditCollapse,
-	EditInsert,
-	EditComplete,
-	EditExpand,
-	EditEnter,
+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);
-const char *completeSubstr(uint id, const char *substr);
-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;
diff --git a/command.c b/command.c
index 335c396..9b2b4eb 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
@@ -144,7 +144,7 @@ static void commandMsg(uint id, char *params) {
 	if (params) {
 		splitMessage("PRIVMSG", msg, params);
 	} else {
-		uiShowID(msg);
+		windowShow(windowFor(msg));
 	}
 }
 
@@ -176,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) {
@@ -213,8 +219,31 @@ static void commandNames(uint id, char *params) {
 
 static void commandOps(uint id, char *params) {
 	(void)params;
-	ircFormat("WHO %s\r\n", idNames[id]);
-	replies[ReplyWho]++;
+	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) {
@@ -376,25 +405,26 @@ static void commandQuery(uint id, char *params) {
 	if (idColors[query] == Default) {
 		idColors[query] = completeColor(id, params);
 	}
-	uiShowID(query);
+	windowShow(windowFor(query));
 }
 
 static void commandWindow(uint id, char *params) {
 	if (!params) {
-		uiWindows();
+		windowList();
 	} else if (isdigit(params[0])) {
-		uiShowNum(strtoul(params, NULL, 10));
+		windowShow(strtoul(params, NULL, 10));
 	} else {
 		id = idFind(params);
 		if (id) {
-			uiShowID(id);
+			windowShow(windowFor(id));
 			return;
 		}
-		for (const char *match; (match = completeSubstr(None, params));) {
-			id = idFind(match);
+		struct Cursor curs = {0};
+		for (const char *str; (str = completeSubstr(&curs, None, params));) {
+			id = idFind(str);
 			if (!id) continue;
-			completeAccept();
-			uiShowID(id);
+			completeAccept(&curs);
+			windowShow(windowFor(id));
 			break;
 		}
 	}
@@ -405,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));
 	}
 }
 
@@ -486,7 +516,7 @@ static void commandExec(uint id, char *params) {
 	execID = id;
 
 	pid_t pid = fork();
-	if (pid < 0) err(EX_OSERR, "fork");
+	if (pid < 0) err(1, "fork");
 	if (pid) return;
 
 	setsid();
@@ -497,7 +527,7 @@ static void commandExec(uint id, char *params) {
 	const char *shell = getenv("SHELL") ?: "/bin/sh";
 	execl(shell, shell, "-c", params, NULL);
 	warn("%s", shell);
-	_exit(EX_UNAVAILABLE);
+	_exit(127);
 }
 
 static void commandHelp(uint id, char *params) {
@@ -515,7 +545,7 @@ static void commandHelp(uint id, char *params) {
 
 	uiHide();
 	pid_t pid = fork();
-	if (pid < 0) err(EX_OSERR, "fork");
+	if (pid < 0) err(1, "fork");
 	if (pid) return;
 
 	char buf[256];
@@ -524,66 +554,66 @@ static void commandHelp(uint id, char *params) {
 	execlp("man", "man", "1", "catgirl", NULL);
 	dup2(utilPipe[1], STDERR_FILENO);
 	warn("man");
-	_exit(EX_UNAVAILABLE);
+	_exit(127);
 }
 
 enum Flag {
 	BIT(Multiline),
 	BIT(Restrict),
-	BIT(Kiosk),
 };
 
 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, Restrict | Kiosk },
-	{ "/cs", commandCS, 0 },
-	{ "/debug", commandDebug, Kiosk },
-	{ "/deop", commandDeop, 0 },
-	{ "/devoice", commandDevoice, 0 },
-	{ "/except", commandExcept, 0 },
-	{ "/exec", commandExec, Multiline | Restrict | Kiosk },
-	{ "/help", commandHelp, 0 }, // Restrict special case.
-	{ "/highlight", commandHighlight, 0 },
-	{ "/ignore", commandIgnore, 0 },
-	{ "/invex", commandInvex, 0 },
-	{ "/invite", commandInvite, 0 },
-	{ "/join", commandJoin, Kiosk },
-	{ "/kick", commandKick, 0 },
-	{ "/list", commandList, Kiosk },
-	{ "/me", commandMe, Multiline },
-	{ "/mode", commandMode, 0 },
-	{ "/move", commandMove, 0 },
-	{ "/msg", commandMsg, Multiline | Kiosk },
-	{ "/names", commandNames, 0 },
-	{ "/nick", commandNick, 0 },
-	{ "/notice", commandNotice, Multiline },
-	{ "/ns", commandNS, 0 },
-	{ "/o", commandOpen, Restrict | Kiosk },
-	{ "/op", commandOp, 0 },
-	{ "/open", commandOpen, Restrict | Kiosk },
-	{ "/ops", commandOps, 0 },
-	{ "/part", commandPart, Kiosk },
-	{ "/query", commandQuery, Kiosk },
-	{ "/quit", commandQuit, 0 },
-	{ "/quote", commandQuote, Multiline | Kiosk },
-	{ "/say", commandPrivmsg, Multiline },
-	{ "/setname", commandSetname, 0 },
-	{ "/topic", commandTopic, 0 },
-	{ "/unban", commandUnban, 0 },
-	{ "/unexcept", commandUnexcept, 0 },
-	{ "/unhighlight", commandUnhighlight, 0 },
-	{ "/unignore", commandUnignore, 0 },
-	{ "/uninvex", commandUninvex, 0 },
-	{ "/voice", commandVoice, 0 },
-	{ "/whois", commandWhois, 0 },
-	{ "/whowas", commandWhowas, 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) {
@@ -639,6 +669,14 @@ size_t commandWillSplit(uint id, const char *input) {
 	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);
@@ -653,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(
@@ -667,10 +705,7 @@ void command(uint id, char *input) {
 		uiFormat(id, Warm, NULL, "No such command %s", cmd);
 		return;
 	}
-	if (
-		(self.restricted && handler->flags & Restrict) ||
-		(self.kiosk && handler->flags & Kiosk)
-	) {
+	if (!commandAvailable(handler)) {
 		uiFormat(id, Warm, NULL, "Command %s is unavailable", cmd);
 		return;
 	}
@@ -687,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 9e59db5..d7108e6 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,10 +26,8 @@
  */
 
 #include <err.h>
-#include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
-#include <sysexits.h>
 
 #include "chat.h"
 
@@ -37,25 +35,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(1, "calloc");
 	node->id = id;
 	node->str = strdup(str);
+	if (!node->str) err(1, "strdup");
 	node->color = color;
-	node->prev = NULL;
-	node->next = NULL;
-	if (!node->str) err(EX_OSERR, "strdup");
+	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,76 +85,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;
-	}
-	return NULL;
-}
-
-const char *completeSubstr(uint id, const char *substr) {
-	for (match = (match ? match->next : head); match; match = match->next) {
-		if (match->id && match->id != id) continue;
-		if (!strstr(match->str, substr)) 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);
+		if (!node->str) err(1, "strdup");
 		prepend(detach(node));
-		if (!node->str) err(EX_OSERR, "strdup");
 	}
 }
 
@@ -164,24 +126,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 d6eaa45..e568e40 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
@@ -97,13 +97,6 @@ int getopt_config(
 			}
 
 			char *equal = &name[len] + strspn(&name[len], WS);
-			if (*equal && *equal != '=') {
-				warnx(
-					"%s:%zu: option `%s' missing equals sign",
-					path, num, option->name
-				);
-				return clean('?');
-			}
 			if (option->has_arg == no_argument && *equal) {
 				warnx(
 					"%s:%zu: option `%s' doesn't allow an argument",
@@ -121,8 +114,11 @@ int getopt_config(
 
 			optarg = NULL;
 			if (*equal) {
-				char *arg = &equal[1] + strspn(&equal[1], WS);
-				optarg = strdup(arg);
+				if (*equal == '=') {
+					optarg = strdup(&equal[1] + strspn(&equal[1], WS));
+				} else {
+					optarg = strdup(equal);
+				}
 				if (!optarg) {
 					warn("getopt_config");
 					return clean('?');
diff --git a/configure b/configure
index 4199980..1fd4ad2 100755
--- a/configure
+++ b/configure
@@ -1,6 +1,8 @@
 #!/bin/sh
 set -eu
 
+: ${PKG_CONFIG:=pkg-config}
+
 cflags() {
 	echo "CFLAGS += $*"
 }
@@ -8,16 +10,16 @@ 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
+	${PKG_CONFIG} --print-errors "$@"
+	cflags $(${PKG_CONFIG} --cflags "$@")
+	for lib; do ldadd $lib $(${PKG_CONFIG} --libs $lib); done
 }
 
 exec >config.mk
@@ -27,6 +29,7 @@ for opt; do
 		(--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
@@ -43,6 +46,7 @@ case "$(uname)" in
 		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
@@ -50,6 +54,13 @@ case "$(uname)" in
 		config libtls ncursesw
 		defvar OPENSSL_BIN openssl exec_prefix /bin/openssl
 		;;
+	(NetBSD)
+		cflags "-D'explicit_bzero(b,l)=explicit_memset((b),0,(l))'"
+		config libtls ncurses
+		echo 'LDADD.ncursesw = ${LDADD.ncurses}'
+		echo 'OBJS += compat_readpassphrase.o'
+		defstr OPENSSL_BIN /usr/bin/openssl
+		;;
 	(*)
 		config libtls ncursesw
 		defvar OPENSSL_BIN openssl exec_prefix /bin/openssl
diff --git a/edit.c b/edit.c
index 71c5cca..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,276 +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;
-	wmemmove(&buf[index + count], &buf[index], len - index);
-	len += count;
-	return true;
-}
-
-static void delete(bool copy, size_t index, size_t count) {
-	if (index + count > len) return;
-	if (copy) {
-		wmemcpy(cut.buf, &buf[index], count);
-		cut.len = count;
+int editReserve(struct Edit *e, size_t index, size_t count) {
+	if (index > e->len) {
+		errno = EINVAL;
+		return -1;
 	}
-	wmemmove(&buf[index], &buf[index + count], 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"\\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 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 && buf[macro] != L'\\') 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;
-		if (wcstombs(NULL, Macros[i].string, 0) == (size_t)-1) 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;
-	bool suffix;
-} 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;
-		tab.suffix = true;
-	}
-
-	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);
-		tab.suffix ^= true;
+int editDelete(struct Edit *e, bool cut, size_t index, size_t count) {
+	if (index + count > e->len) {
+		errno = EINVAL;
+		return -1;
 	}
-	if (!comp) {
-		tab.len = 0;
-		return;
-	}
-
-	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;
-	}
-
-	bool colon = (tab.len >= 2 && buf[tab.pos + tab.len - 2] == L':');
-
-	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.suffix && (!tab.pos || colon)) {
-		tab.len += 2;
-		reserve(tab.pos, tab.len);
-		buf[tab.pos + n + 0] = L':';
-		buf[tab.pos + n + 1] = L' ';
-	} else if (tab.suffix && 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);
-		if (!tab.suffix && tab.pos >= 2 && buf[tab.pos - 2] == L',') {
-			buf[tab.pos - 2] = L':';
-		}
-		buf[tab.pos + n] = L' ';
-	}
-	wmemcpy(&buf[tab.pos], 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)) {
-				wmemcpy(&buf[pos], cut.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: {
-			char mb[MB_LEN_MAX];
-			if (wctomb(mb, ch) < 0) return;
-			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;
+}
+
+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;
+}
 
-	if (pos < init) {
-		tabReject();
-	} else {
-		tabAccept();
+#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
index 63c3369..bbe40c8 100644
--- a/filter.c
+++ b/filter.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
@@ -31,7 +31,6 @@
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
-#include <sysexits.h>
 
 #include "chat.h"
 
@@ -48,14 +47,14 @@ struct Filter filterParse(enum Heat heat, char *pattern) {
 }
 
 struct Filter filterAdd(enum Heat heat, const char *pattern) {
-	if (len == FilterCap) errx(EX_CONFIG, "filter limit exceeded");
+	if (len == FilterCap) errx(1, "filter limit exceeded");
 	char *own;
 	if (!strchr(pattern, '!') && !strchr(pattern, ' ')) {
 		int n = asprintf(&own, "%s!*@*", pattern);
-		if (n < 0) err(EX_OSERR, "asprintf");
+		if (n < 0) err(1, "asprintf");
 	} else {
 		own = strdup(pattern);
-		if (!own) err(EX_OSERR, "strdup");
+		if (!own) err(1, "strdup");
 	}
 	struct Filter filter = filterParse(heat, own);
 	filters[len++] = filter;
@@ -105,7 +104,7 @@ static void icedPush(const char *msgID) {
 	size_t i = iced.len % IcedCap;
 	free(iced.msgIDs[i]);
 	iced.msgIDs[i] = strdup(msgID);
-	if (!iced.msgIDs[i]) err(EX_OSERR, "strdup");
+	if (!iced.msgIDs[i]) err(1, "strdup");
 	iced.len++;
 }
 
diff --git a/handle.c b/handle.c
index 7721745..0cc7c04 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
@@ -32,7 +32,6 @@
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
-#include <sysexits.h>
 #include <wchar.h>
 
 #include "chat.h"
@@ -83,7 +82,7 @@ static void require(struct Message *msg, bool origin, uint len) {
 	}
 	for (uint i = 0; i < len; ++i) {
 		if (msg->params[i]) continue;
-		errx(EX_PROTOCOL, "%s missing parameter %u", msg->cmd, 1 + i);
+		errx(1, "%s missing parameter %u", msg->cmd, 1 + i);
 	}
 }
 
@@ -127,10 +126,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);
 	}
@@ -139,7 +161,7 @@ static void handleErrorNicknameInUse(struct Message *msg) {
 static void handleErrorErroneousNickname(struct Message *msg) {
 	require(msg, false, 3);
 	if (!strcmp(self.nick, "*")) {
-		errx(EX_CONFIG, "%s: %s", msg->params[1], msg->params[2]);
+		errx(1, "%s: %s", msg->params[1], msg->params[2]);
 	} else {
 		handleErrorGeneric(msg);
 	}
@@ -164,11 +186,13 @@ static void handleCap(struct Message *msg) {
 	} 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")) {
-		errx(EX_CONFIG, "server does not support %s", msg->params[2]);
+		errx(1, "server does not support %s", msg->params[2]);
 	}
 }
 
@@ -203,44 +227,45 @@ 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] = {0};
-	size_t len = 1 + strlen(self.plain);
-	if (sizeof(buf) < len) errx(EX_USAGE, "SASL PLAIN is too long");
-	memcpy(&buf[1], self.plain, len - 1);
-	byte *sep = memchr(buf, ':', len);
-	if (!sep) errx(EX_USAGE, "SASL PLAIN missing colon");
-	*sep = 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(1, "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) {
 	require(msg, false, 2);
-	errx(EX_CONFIG, "%s", msg->params[1]);
+	errx(1, "%s", msg->params[1]);
 }
 
 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;
@@ -252,19 +277,26 @@ static void handleReplyWelcome(struct Message *msg) {
 		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);
@@ -282,7 +314,7 @@ static void handleReplyISupport(struct Message *msg) {
 			char *modes = strsep(&msg->params[i], ")");
 			char *prefixes = msg->params[i];
 			if (!modes || !prefixes || strlen(modes) != strlen(prefixes)) {
-				errx(EX_PROTOCOL, "invalid PREFIX value");
+				errx(1, "invalid PREFIX value");
 			}
 			set(&network.prefixModes, modes);
 			set(&network.prefixes, prefixes);
@@ -292,7 +324,7 @@ static void handleReplyISupport(struct Message *msg) {
 			char *setParam = strsep(&msg->params[i], ",");
 			char *channel = strsep(&msg->params[i], ",");
 			if (!list || !param || !setParam || !channel) {
-				errx(EX_PROTOCOL, "invalid CHANMODES value");
+				errx(1, "invalid CHANMODES value");
 			}
 			set(&network.listModes, list);
 			set(&network.paramModes, param);
@@ -339,13 +371,13 @@ static void handleJoin(struct Message *msg) {
 			set(&self.host, msg->host);
 		}
 		idColors[id] = hash(msg->params[0]);
-		completeTouch(None, msg->params[0], idColors[id]);
+		completePull(None, msg->params[0], idColors[id]);
 		if (replies[ReplyJoin]) {
-			uiShowID(id);
+			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;
 	}
@@ -377,7 +409,7 @@ 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 = filterCheck(Cold, id, msg);
@@ -399,7 +431,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),
@@ -416,16 +448,17 @@ 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]);
 		}
@@ -440,12 +473,13 @@ 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);
-	for (uint id; (id = completeID(msg->nick));) {
+	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)",
@@ -457,7 +491,8 @@ static void handleSetname(struct Message *msg) {
 
 static void handleQuit(struct Message *msg) {
 	require(msg, true, 0);
-	for (uint id; (id = completeID(msg->nick));) {
+	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(
@@ -535,7 +570,12 @@ static void handleReplyNames(struct Message *msg) {
 		char *nick = &prefixes[strspn(prefixes, network.prefixes)];
 		char *user = strsep(&name, "@");
 		enum Color color = (user ? hash(user) : Default);
-		completeAdd(id, nick, color);
+		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
@@ -558,41 +598,6 @@ static void handleReplyEndOfNames(struct Message *msg) {
 	}
 }
 
-static struct {
-	char buf[1024];
-	char *ptr;
-	char *end;
-} who = {
-	.ptr = who.buf,
-	.end = &who.buf[sizeof(who.buf)],
-};
-
-static void handleReplyWho(struct Message *msg) {
-	require(msg, false, 7);
-	if (who.ptr == who.buf) {
-		who.ptr = seprintf(
-			who.ptr, who.end, "The council of \3%02d%s\3 are ",
-			hash(msg->params[1]), msg->params[1]
-		);
-	}
-	char *prefixes = &msg->params[6][1];
-	if (prefixes[0] == '*') prefixes++;
-	prefixes[strspn(prefixes, network.prefixes)] = '\0';
-	if (!prefixes[0] || prefixes[0] == '+') return;
-	who.ptr = seprintf(
-		who.ptr, who.end, "%s\3%02d%s%s\3%s",
-		(who.ptr[-1] == ' ' ? "" : ", "),
-		hash(msg->params[2]), prefixes, msg->params[5],
-		(msg->params[6][0] == 'H' ? "" : " (away)")
-	);
-}
-
-static void handleReplyEndOfWho(struct Message *msg) {
-	require(msg, false, 2);
-	uiWrite(idFor(msg->params[1]), Warm, tagTime(msg), who.buf);
-	who.ptr = who.buf;
-}
-
 static void handleReplyNoTopic(struct Message *msg) {
 	require(msg, false, 2);
 	uiFormat(
@@ -604,14 +609,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);
 	}
 }
 
@@ -643,6 +649,28 @@ static void swap(wchar_t *a, wchar_t *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]);
@@ -660,10 +688,8 @@ static void handleTopic(struct Message *msg) {
 		return;
 	}
 
-	char buf[1024];
-	char *ptr = buf, *end = &buf[sizeof(buf)];
-	const char *prev = complete(id, "/topic ");
-	completeReject();
+	struct Cursor curs = {0};
+	const char *prev = completePrefix(&curs, id, "/topic ");
 	if (prev) {
 		prev += 7;
 	} else {
@@ -675,45 +701,46 @@ static void handleTopic(struct Message *msg) {
 	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;
 
-	if (!hashBound) {
-		ptr = seprintf(ptr, end, "%c%ls%c -> %c%ls%c", R, old, O, R, new, O);
-		goto plain;
-	}
-
 	size_t pre;
 	for (pre = 0; old[pre] && new[pre] && old[pre] == new[pre]; ++pre);
-	wchar_t *osuf = &old[wcslen(old)];
-	wchar_t *nsuf = &new[wcslen(new)];
-	while (osuf > &old[pre] && nsuf > &new[pre] && osuf[-1] == nsuf[-1]) {
+	size_t osuf = wcslen(old);
+	size_t nsuf = wcslen(new);
+	while (osuf > pre && nsuf > pre && old[osuf-1] == new[nsuf-1]) {
 		osuf--;
 		nsuf--;
 	}
 
-	wchar_t nul = L'\0';
-	swap(&new[pre], &nul);
-	ptr = seprintf(ptr, end, "%ls", new);
-	swap(&new[pre], &nul);
-	swap(osuf, &nul);
-	ptr = seprintf(ptr, end, "\3%02d,%02d%ls", Default, Brown, &old[pre]);
-	swap(osuf, &nul);
-	swap(nsuf, &nul);
-	ptr = seprintf(ptr, end, "\3%02d,%02d%ls", Default, Green, &new[pre]);
-	swap(nsuf, &nul);
-	ptr = seprintf(ptr, end, "\3%02d,%02d%ls", Default, Default, 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:
-	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],
-		(ptr > buf ? buf : msg->params[1])
+		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] = {
@@ -829,12 +856,18 @@ static void handleMode(struct Message *msg) {
 
 		if (strchr(network.prefixModes, *ch)) {
 			if (i >= ParamCap || !msg->params[i]) {
-				errx(EX_PROTOCOL, "MODE missing %s parameter", mode);
+				errx(1, "MODE missing %s parameter", mode);
 			}
 			char *nick = msg->params[i++];
 			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",
@@ -850,7 +883,7 @@ static void handleMode(struct Message *msg) {
 
 		if (strchr(network.listModes, *ch)) {
 			if (i >= ParamCap || !msg->params[i]) {
-				errx(EX_PROTOCOL, "MODE missing %s parameter", mode);
+				errx(1, "MODE missing %s parameter", mode);
 			}
 			char *mask = msg->params[i++];
 			if (*ch == 'b') {
@@ -883,7 +916,7 @@ static void handleMode(struct Message *msg) {
 
 		if (strchr(network.paramModes, *ch)) {
 			if (i >= ParamCap || !msg->params[i]) {
-				errx(EX_PROTOCOL, "MODE missing %s parameter", mode);
+				errx(1, "MODE missing %s parameter", mode);
 			}
 			char *param = msg->params[i++];
 			uiFormat(
@@ -900,7 +933,7 @@ static void handleMode(struct Message *msg) {
 
 		if (strchr(network.setParamModes, *ch) && set) {
 			if (i >= ParamCap || !msg->params[i]) {
-				errx(EX_PROTOCOL, "MODE missing %s parameter", mode);
+				errx(1, "MODE missing %s parameter", mode);
 			}
 			char *param = msg->params[i++];
 			uiFormat(
@@ -1021,19 +1054,19 @@ static void handleReplyInviteList(struct Message *msg) {
 }
 
 static void handleReplyList(struct Message *msg) {
-	require(msg, false, 4);
+	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 handleReplyWhoisUser(struct Message *msg) {
 	require(msg, false, 6);
-	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\17)",
@@ -1122,7 +1155,7 @@ static void handleReplyEndOfWhois(struct Message *msg) {
 
 static void handleReplyWhowasUser(struct Message *msg) {
 	require(msg, false, 6);
-	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\twas %s!%s@%s (%s)",
@@ -1141,14 +1174,9 @@ static void handleReplyEndOfWhowas(struct Message *msg) {
 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]
 	);
@@ -1179,11 +1207,11 @@ static bool isAction(struct Message *msg) {
 	return true;
 }
 
-static bool isMention(const struct Message *msg) {
-	size_t len = strlen(self.nick);
-	const char *match = msg->params[1];
-	while (NULL != (match = strstr(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;
@@ -1193,48 +1221,53 @@ static bool isMention(const struct Message *msg) {
 	return false;
 }
 
-static void colorMentions(char *buf, size_t cap, uint id, struct Message *msg) {
-	*buf = '\0';
+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;
+}
 
-	char *split = strstr(msg->params[1], ": ");
+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';
 
-	char *ptr = buf, *end = &buf[cap];
-	while (*mention) {
-		size_t skip = strspn(mention, ",<> ");
-		ptr = seprintf(ptr, end, "%.*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) {
-			ptr = seprintf(ptr, end, "\3%02d%s\3", color, mention);
+			ptr = seprintf(ptr, end, "\3%02d%.*s\3", color, (int)len, msg);
 		} else {
-			ptr = seprintf(ptr, end, "%s", mention);
+			ptr = p;
 		}
-		mention[len] = punct;
-		mention += len;
+		msg += len;
 	}
-	seprintf(ptr, end, "%c", delimit);
+
+rest:
+	return seprintf(ptr, end, "%s", msg);
 }
 
 static void handlePrivmsg(struct Message *msg) {
 	require(msg, true, 2);
-	if (network.statusmsg) {
-		msg->params[0] += strspn(msg->params[0], network.statusmsg);
+	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, '.');
@@ -1252,42 +1285,48 @@ static void handlePrivmsg(struct Message *msg) {
 	bool notice = (msg->cmd[0] == 'N');
 	bool action = !notice && isAction(msg);
 	bool highlight = !mine && isMention(msg);
-	enum Heat heat = filterCheck((highlight || query ? Hot : Warm), id, 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) {
-		completeTouch(id, msg->nick, hash(msg->user));
+		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, filterCheck(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]);
-		colorMentions(buf, sizeof(buf), id, msg);
-		uiFormat(
-			id, heat, tagTime(msg),
-			"%s\35\3%d* %s\17\35\t%s%s",
-			(highlight ? "\26" : ""), hash(msg->user), msg->nick,
-			buf, 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]);
-		colorMentions(buf, sizeof(buf), id, msg);
-		uiFormat(
-			id, heat, tagTime(msg),
-			"%s\3%d<%s>\17\t%s%s",
-			(highlight ? "\26" : ""), hash(msg->user), msg->nick,
-			buf, 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) {
@@ -1297,7 +1336,7 @@ static void handlePing(struct Message *msg) {
 
 static void handleError(struct Message *msg) {
 	require(msg, false, 1);
-	errx(EX_UNAVAILABLE, "%s", msg->params[0]);
+	errx(69, "%s", msg->params[0]);
 }
 
 static const struct Handler {
@@ -1317,7 +1356,6 @@ static const struct Handler {
 	{ "312", 0, handleReplyWhoisServer },
 	{ "313", +ReplyWhois, handleReplyWhoisGeneric },
 	{ "314", +ReplyWhowas, handleReplyWhowasUser },
-	{ "315", -ReplyWho, handleReplyEndOfWho },
 	{ "317", +ReplyWhois, handleReplyWhoisIdle },
 	{ "318", -ReplyWhois, handleReplyEndOfWhois },
 	{ "319", +ReplyWhois, handleReplyWhoisChannels },
@@ -1335,7 +1373,6 @@ static const struct Handler {
 	{ "347", -ReplyInvex, NULL },
 	{ "348", +ReplyExcepts, handleReplyExceptList },
 	{ "349", -ReplyExcepts, NULL },
-	{ "352", +ReplyWho, handleReplyWho },
 	{ "353", 0, handleReplyNames },
 	{ "366", 0, handleReplyEndOfNames },
 	{ "367", +ReplyBan, handleReplyBanList },
@@ -1400,5 +1437,7 @@ void handle(struct Message *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/input.c b/input.c
new file mode 100644
index 0000000..7e1f9c1
--- /dev/null
+++ b/input.c
@@ -0,0 +1,628 @@
+/* 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 <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(1, "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(1, "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(1, "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(1, "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(1, "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(1, "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(1, "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(1, "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(1, "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(1, "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(1, "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 20e9f91..28e557b 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
@@ -38,7 +38,6 @@
 #include <string.h>
 #include <sys/socket.h>
 #include <sys/stat.h>
-#include <sysexits.h>
 #include <tls.h>
 #include <unistd.h>
 
@@ -54,7 +53,7 @@ void ircConfig(
 	char buf[PATH_MAX];
 
 	config = tls_config_new();
-	if (!config) errx(EX_SOFTWARE, "tls_config_new");
+	if (!config) errx(1, "tls_config_new");
 
 	if (insecure) {
 		tls_config_insecure_noverifycert(config);
@@ -66,7 +65,7 @@ void ircConfig(
 			error = tls_config_set_ca_file(config, buf);
 			if (!error) break;
 		}
-		if (error) errx(EX_NOINPUT, "%s: %s", trust, tls_config_error(config));
+		if (error) errx(1, "%s: %s", trust, tls_config_error(config));
 	}
 
 	// Explicitly load the default CA cert file on OpenBSD now so it doesn't
@@ -76,7 +75,7 @@ void ircConfig(
 	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));
+		if (error) errx(1, "%s: %s", ca, tls_config_error(config));
 	}
 #endif
 
@@ -89,21 +88,21 @@ void ircConfig(
 			}
 			if (!error) break;
 		}
-		if (error) errx(EX_NOINPUT, "%s: %s", cert, tls_config_error(config));
+		if (error) errx(1, "%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;
 		}
-		if (error) errx(EX_NOINPUT, "%s: %s", priv, tls_config_error(config));
+		if (error) errx(1, "%s: %s", priv, tls_config_error(config));
 	}
 
 	client = tls_client();
-	if (!client) errx(EX_SOFTWARE, "tls_client");
+	if (!client) errx(1, "tls_client");
 
 	error = tls_configure(client, config);
-	if (error) errx(EX_SOFTWARE, "tls_configure: %s", tls_error(client));
+	if (error) errx(1, "tls_configure: %s", tls_error(client));
 }
 
 int ircConnect(const char *bindHost, const char *host, const char *port) {
@@ -120,11 +119,11 @@ int ircConnect(const char *bindHost, const char *host, const char *port) {
 
 	if (bindHost) {
 		error = getaddrinfo(bindHost, NULL, &hints, &head);
-		if (error) errx(EX_NOHOST, "%s: %s", bindHost, gai_strerror(error));
+		if (error) errx(1, "%s: %s", bindHost, gai_strerror(error));
 
 		for (struct addrinfo *ai = head; ai; ai = ai->ai_next) {
 			sock = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
-			if (sock < 0) err(EX_OSERR, "socket");
+			if (sock < 0) err(1, "socket");
 
 			error = bind(sock, ai->ai_addr, ai->ai_addrlen);
 			if (!error) {
@@ -135,17 +134,17 @@ int ircConnect(const char *bindHost, const char *host, const char *port) {
 			close(sock);
 			sock = -1;
 		}
-		if (sock < 0) err(EX_UNAVAILABLE, "%s", bindHost);
+		if (sock < 0) err(1, "%s", bindHost);
 		freeaddrinfo(head);
 	}
 
 	error = getaddrinfo(host, port, &hints, &head);
-	if (error) errx(EX_NOHOST, "%s:%s: %s", host, port, gai_strerror(error));
+	if (error) errx(1, "%s:%s: %s", host, port, gai_strerror(error));
 
 	for (struct addrinfo *ai = head; ai; ai = ai->ai_next) {
 		if (sock < 0) {
 			sock = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
-			if (sock < 0) err(EX_OSERR, "socket");
+			if (sock < 0) err(1, "socket");
 		}
 
 		error = connect(sock, ai->ai_addr, ai->ai_addrlen);
@@ -155,12 +154,12 @@ int ircConnect(const char *bindHost, const char *host, const char *port) {
 		close(sock);
 		sock = -1;
 	}
-	if (sock < 0) err(EX_UNAVAILABLE, "%s:%s", host, port);
+	if (sock < 0) err(69, "%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));
+	if (error) errx(1, "tls_connect: %s", tls_error(client));
 
 	return sock;
 }
@@ -170,7 +169,7 @@ void ircHandshake(void) {
 	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));
+	if (error) errx(1, "tls_handshake: %s", tls_error(client));
 
 	tls_config_clear_keys(config);
 }
@@ -202,7 +201,7 @@ void ircSend(const char *ptr, size_t len) {
 	while (len) {
 		ssize_t ret = tls_write(client, ptr, len);
 		if (ret == TLS_WANT_POLLIN || ret == TLS_WANT_POLLOUT) continue;
-		if (ret < 0) errx(EX_IOERR, "tls_write: %s", tls_error(client));
+		if (ret < 0) errx(1, "tls_write: %s", tls_error(client));
 		ptr += ret;
 		len -= ret;
 	}
@@ -287,8 +286,8 @@ void ircRecv(void) {
 	assert(client);
 	ssize_t ret = tls_read(client, &buf[len], sizeof(buf) - len);
 	if (ret == TLS_WANT_POLLIN || ret == TLS_WANT_POLLOUT) return;
-	if (ret < 0) errx(EX_IOERR, "tls_read: %s", tls_error(client));
-	if (!ret) errx(EX_PROTOCOL, "server closed connection");
+	if (ret < 0) errx(1, "tls_read: %s", tls_error(client));
+	if (!ret) errx(69, "server closed connection");
 	len += ret;
 
 	char *crlf;
diff --git a/log.c b/log.c
index 32d771f..181c009 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
@@ -34,7 +34,6 @@
 #include <stdio.h>
 #include <stdlib.h>
 #include <sys/stat.h>
-#include <sysexits.h>
 #include <time.h>
 #include <unistd.h>
 
@@ -49,13 +48,13 @@ 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);
+	if (error && errno != EEXIST) err(1, "%s", buf);
 
 	error = mkdir(dataPath(buf, sizeof(buf), "log", 0), S_IRWXU);
-	if (error && errno != EEXIST) err(EX_CANTCREAT, "%s", buf);
+	if (error && errno != EEXIST) err(1, "%s", buf);
 
 	logDir = open(buf, O_RDONLY | O_CLOEXEC);
-	if (logDir < 0) err(EX_CANTCREAT, "%s", buf);
+	if (logDir < 0) err(1, "%s", buf);
 
 #ifdef __FreeBSD__
 	cap_rights_t rights;
@@ -64,13 +63,22 @@ void logOpen(void) {
 		/* for fdopen(3) */ CAP_FCNTL, CAP_FSTAT
 	);
 	error = caph_rights_limit(logDir, &rights);
-	if (error) err(EX_OSERR, "cap_rights_limit");
+	if (error) err(1, "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);
+	if (error && errno != EEXIST) err(1, "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 {
@@ -90,37 +98,36 @@ static FILE *logFile(uint id, const struct tm *tm) {
 
 	if (logs[id].file) {
 		int error = fclose(logs[id].file);
-		if (error) err(EX_IOERR, "%s", idNames[id]);
+		if (error) err(1, "%s", idNames[id]);
 	}
 
 	logs[id].year = tm->tm_year;
 	logs[id].month = tm->tm_mon;
 	logs[id].day = tm->tm_mday;
 
-	size_t len = 0;
 	char path[PATH_MAX];
-	for (const char *ch = network.name; *ch; ++ch) {
-		path[len++] = (*ch == '/' ? '_' : *ch);
-	}
-	path[len] = '\0';
+	char *ptr = path, *end = &path[sizeof(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';
+	char *name = ptr;
+	ptr = seprintf(ptr, end, "/%s", idNames[id]);
+	sanitize(&name[1], ptr);
 	logMkdir(path);
 
-	strftime(&path[len], sizeof(path) - len, "/%F.log", tm);
+	size_t len = strftime(ptr, end - ptr, "/%F.log", tm);
+	if (!len) errx(1, "log path too long");
+
 	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);
+	if (fd < 0) err(1, "log/%s", path);
 	logs[id].file = fdopen(fd, "a");
-	if (!logs[id].file) err(EX_OSERR, "fdopen");
+	if (!logs[id].file) err(1, "fdopen");
 
 	setlinebuf(logs[id].file);
 	return logs[id].file;
@@ -131,7 +138,7 @@ void logClose(void) {
 	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]);
+		if (error) err(1, "%s", idNames[id]);
 	}
 	close(logDir);
 }
@@ -141,21 +148,21 @@ void logFormat(uint id, const time_t *src, const char *format, ...) {
 
 	time_t ts = (src ? *src : time(NULL));
 	struct tm *tm = localtime(&ts);
-	if (!tm) err(EX_OSERR, "localtime");
+	if (!tm) err(1, "localtime");
 
 	FILE *file = logFile(id, tm);
 
 	char buf[sizeof("0000-00-00T00:00:00+0000")];
 	strftime(buf, sizeof(buf), "%FT%T%z", tm);
 	int n = fprintf(file, "[%s] ", buf);
-	if (n < 0) err(EX_IOERR, "%s", idNames[id]);
+	if (n < 0) err(1, "%s", idNames[id]);
 
 	va_list ap;
 	va_start(ap, format);
 	n = vfprintf(file, format, ap);
 	va_end(ap);
-	if (n < 0) err(EX_IOERR, "%s", idNames[id]);
+	if (n < 0) err(1, "%s", idNames[id]);
 
 	n = fprintf(file, "\n");
-	if (n < 0) err(EX_IOERR, "%s", idNames[id]);
+	if (n < 0) err(1, "%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..c9d0705 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
@@ -19,7 +19,6 @@
 #import <signal.h>
 #import <stdio.h>
 #import <stdlib.h>
-#import <sysexits.h>
 #import <unistd.h>
 
 typedef unsigned uint;
@@ -27,17 +26,17 @@ typedef unsigned uint;
 static pid_t pid;
 static void spawn(char *argv[]) {
 	pid = fork();
-	if (pid < 0) err(EX_OSERR, "fork");
+	if (pid < 0) err(1, "fork");
 	if (pid) return;
 	execvp(argv[0], argv);
-	err(EX_CONFIG, "%s", argv[0]);
+	err(127, "%s", argv[0]);
 }
 
 static void handler(int signal) {
 	(void)signal;
 	int status;
 	pid_t pid = wait(&status);
-	if (pid < 0) _exit(EX_OSERR);
+	if (pid < 0) _exit(1);
 	_exit(status);
 }
 
@@ -47,12 +46,12 @@ int main(int argc, char *argv[]) {
 	for (int opt; 0 < (opt = getopt(argc, argv, "t:"));) {
 		switch (opt) {
 			break; case 't': delay = strtoul(optarg, NULL, 10);
-			break; default:  return EX_USAGE;
+			break; default:  return 1;
 		}
 	}
 	argc -= optind;
 	argv += optind;
-	if (!argc) errx(EX_USAGE, "command required");
+	if (!argc) errx(1, "command required");
 
 	NSWorkspace *workspace = [NSWorkspace sharedWorkspace];
 	NSNotificationCenter *notifCenter = [workspace notificationCenter];
@@ -64,7 +63,7 @@ int main(int argc, char *argv[]) {
 							 (void)notif;
 							 signal(SIGCHLD, SIG_IGN);
 							 int error = kill(pid, SIGHUP);
-							 if (error) err(EX_UNAVAILABLE, "kill");
+							 if (error) err(1, "kill");
 							 int status;
 							 wait(&status);
 						 }];
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
deleted file mode 100644
index a0fcf32..0000000
--- a/scripts/build-chroot.sh
+++ /dev/null
@@ -1,74 +0,0 @@
-#!/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/chroot-man.sh b/scripts/chroot-man.sh
deleted file mode 100644
index 9d686f9..0000000
--- a/scripts/chroot-man.sh
+++ /dev/null
@@ -1,2 +0,0 @@
-#!/bin/sh
-exec mandoc /usr/share/man/man1/catgirl.1 | LESSSECURE=1 less
diff --git a/scripts/chroot-prompt.sh b/scripts/chroot-prompt.sh
deleted file mode 100644
index 2b34426..0000000
--- a/scripts/chroot-prompt.sh
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/bin/sh
-set -eu
-
-printf 'Name: '
-read -r nick rest
-printf '%s %s\n' "$nick" "$SSH_CLIENT" >>nicks.log
-exec catgirl -K -n "$nick" -s "${nick##*/}" -u "${SSH_CLIENT%% *}" "$@"
diff --git a/scripts/reconnect.sh b/scripts/reconnect.sh
new file mode 100644
index 0000000..92d9668
--- /dev/null
+++ b/scripts/reconnect.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+set -u
+
+while :; do
+	catgirl "$@"
+	status=$?
+	if [ $status -ne 69 ]; then
+		exit $status
+	fi
+done
diff --git a/scripts/sshd_config b/scripts/sshd_config
deleted file mode 100644
index c7e99ec..0000000
--- a/scripts/sshd_config
+++ /dev/null
@@ -1,9 +0,0 @@
-UsePAM no
-
-Match User chat
-	PasswordAuthentication yes
-	PermitEmptyPasswords yes
-	ChrootDirectory /home/chat
-	ForceCommand catgirl-prompt
-	DisableForwarding yes
-	MaxSessions 1
diff --git a/ui.c b/ui.c
index 2789bd7..df675b1 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,26 +28,20 @@
 #define _XOPEN_SOURCE_EXTENDED
 
 #include <assert.h>
-#include <ctype.h>
 #include <curses.h>
 #include <err.h>
 #include <errno.h>
 #include <fcntl.h>
-#include <limits.h>
-#include <signal.h>
+#include <inttypes.h>
 #include <stdarg.h>
 #include <stdbool.h>
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
-#include <sysexits.h>
 #include <sys/file.h>
 #include <term.h>
-#include <termios.h>
 #include <time.h>
 #include <unistd.h>
-#include <wchar.h>
-#include <wctype.h>
 
 #ifdef __FreeBSD__
 #include <capsicum_helpers.h>
@@ -55,105 +49,13 @@
 
 #include "chat.h"
 
-// Annoying stuff from <term.h>:
-#undef lines
-#undef tab
-
-enum {
-	StatusLines = 1,
-	MarkerLines = 1,
-	SplitLines = 5,
-	InputLines = 1,
-	InputCols = 1024,
-};
-
 #define BOTTOM (LINES - 1)
 #define RIGHT (COLS - 1)
 #define MAIN_LINES (LINES - StatusLines - InputLines)
 
-static WINDOW *status;
-static WINDOW *main;
-static WINDOW *input;
-
-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;
-};
-
-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;
-}
-
-enum Heat uiThreshold = Cold;
-
-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->mark = true;
-	window->time = uiTime.enable;
-	if (id == Network || id == Debug) {
-		window->thresh = Cold;
-	} else {
-		window->thresh = uiThreshold;
-	}
-	window->buffer = bufferAlloc();
-	completeAdd(None, idNames[id], idColors[id]);
-	return windowPush(window);
-}
-
-static void windowFree(struct Window *window) {
-	completeRemove(None, idNames[window->id]);
-	bufferFree(window->buffer);
-	free(window);
-}
+WINDOW *uiStatus;
+WINDOW *uiMain;
+WINDOW *uiInput;
 
 static short colorPairs;
 
@@ -188,65 +90,18 @@ static short colorPair(short fg, short bg) {
 	return colorPairs++;
 }
 
-#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(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
-};
-
 // XXX: Assuming terminals will be fine with these even if they're unsupported,
 // since they're "private" modes.
 static const char *FocusMode[2] = { "\33[?1004l", "\33[?1004h" };
 static const char *PasteMode[2] = { "\33[?2004l", "\33[?2004h" };
 
-struct Time uiTime = { .format = "%X" };
-
 static void errExit(void) {
 	putp(FocusMode[false]);
 	putp(PasteMode[false]);
 	reset_shell_mode();
 }
 
-void uiInitEarly(void) {
+void uiInit(void) {
 	initscr();
 	cbreak();
 	noecho();
@@ -265,73 +120,32 @@ void uiInitEarly(void) {
 		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(1, "newwin");
 
-	main = newwin(MAIN_LINES, COLS, StatusLines, 0);
-	if (!main) err(EX_OSERR, "newwin");
+	uiMain = newwin(MAIN_LINES, COLS, StatusLines, 0);
+	if (!uiMain) err(1, "newwin");
 
-	int y;
-	char fmt[TimeCap];
-	char buf[TimeCap];
-	styleStrip(fmt, sizeof(fmt), uiTime.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);
-	waddstr(main, buf);
-	waddch(main, ' ');
-	getyx(main, y, uiTime.width);
-	(void)y;
-
-	input = newpad(InputLines, InputCols);
-	if (!input) err(EX_OSERR, "newpad");
-	keypad(input, true);
-	nodelay(input, true);
+	uiInput = newpad(InputLines, InputCols);
+	if (!uiInput) err(1, "newpad");
 
-	windowFor(Network);
+	windowInit();
 	uiShow();
 }
 
-// Avoid disabling VINTR until main loop.
-void uiInitLate(void) {
-	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();
-}
-
 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);
-	wnoutrefresh(main);
+	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
@@ -340,10 +154,10 @@ 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);
 }
@@ -375,7 +189,7 @@ static const short Colors[ColorCap] = {
 	16, 233, 235, 237, 239, 241, 244, 247, 250, 254, 231,
 };
 
-static attr_t styleAttr(struct Style style) {
+uint uiAttr(struct Style style) {
 	attr_t attr = A_NORMAL;
 	if (style.attr & Bold) attr |= A_BOLD;
 	if (style.attr & Reverse) attr |= A_REVERSE;
@@ -384,91 +198,13 @@ static attr_t styleAttr(struct Style style) {
 	return attr | colorAttr(Colors[style.fg]);
 }
 
-static short stylePair(struct Style style) {
-	return colorPair(Colors[style.fg], Colors[style.bg]);
-}
-
-static int styleAdd(WINDOW *win, struct Style init, const char *str) {
-	struct Style style = init;
-	while (*str) {
-		size_t len = styleParse(&style, &str);
-		wattr_set(win, styleAttr(style), stylePair(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 };
+bool uiSpoilerReveal;
 
-	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], *end = &buf[sizeof(buf)];
-		char *ptr = seprintf(
-			buf, end, "\3%d%s %u%s%s %s ",
-			idColors[window->id], (num == windows.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%s",
-				(window->heat > Warm ? White : idColors[window->id]),
-				window->unreadWarm, idColors[window->id],
-				(window->scroll ? "" : " ")
-			);
-		}
-		if (window->scroll) {
-			ptr = seprintf(ptr, end, "~%d ", window->scroll);
-		}
-		if (styleAdd(status, StyleDefault, buf) < 0) break;
+short uiPair(struct Style style) {
+	if (uiSpoilerReveal && style.fg == style.bg) {
+		return colorPair(Colors[Default], Colors[style.bg]);
 	}
-	wclrtoeol(status);
-
-	const struct Window *window = windows.ptrs[windows.show];
-	char *end = &title[sizeof(title)];
-	char *ptr = seprintf(
-		title, 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 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();
+	return colorPair(Colors[style.fg], Colors[style.bg]);
 }
 
 void uiShow(void) {
@@ -478,88 +214,18 @@ void uiShow(void) {
 	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(FocusMode[false]);
 	putp(PasteMode[false]);
 	endwin();
 }
 
-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 int windowCols(const struct Window *window) {
-	return COLS - (window->time ? uiTime.width : 0);
-}
-
-static void mainAdd(int y, bool time, const struct Line *line) {
-	int ny, nx;
-	wmove(main, y, 0);
-	if (!line || !line->str[0]) {
-		wclrtoeol(main);
-		return;
-	}
-	if (time && line->time) {
-		char buf[TimeCap];
-		strftime(buf, sizeof(buf), uiTime.format, localtime(&line->time));
-		struct Style init = { .fg = Gray, .bg = Default };
-		styleAdd(main, init, buf);
-		waddch(main, ' ');
-	} else if (time) {
-		whline(main, ' ', uiTime.width);
-		wmove(main, y, uiTime.width);
-	}
-	styleAdd(main, StyleDefault, line->str);
-	getyx(main, ny, nx);
-	if (ny != y) return;
-	wclrtoeol(main);
-	(void)nx;
-}
-
-static void mainUpdate(void) {
-	struct Window *window = windows.ptrs[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(main, A_NORMAL, 0, NULL);
-	mvwhline(main, marker, 0, ACS_BULLET, COLS);
-}
-
-static void windowScroll(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.ptrs[windows.show]) mainUpdate();
-}
-
 struct Util uiNotifyUtil;
 static void notify(uint id, const char *str) {
 	if (self.restricted) return;
@@ -573,7 +239,7 @@ static void notify(uint id, const char *str) {
 	utilPush(&util, buf);
 
 	pid_t pid = fork();
-	if (pid < 0) err(EX_OSERR, "fork");
+	if (pid < 0) err(1, "fork");
 	if (pid) return;
 
 	setsid();
@@ -582,40 +248,12 @@ static void notify(uint id, const char *str) {
 	dup2(utilPipe[1], STDERR_FILENO);
 	execvp(util.argv[0], (char *const *)util.argv);
 	warn("%s", util.argv[0]);
-	_exit(EX_CONFIG);
+	_exit(127);
 }
 
 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));
-
-	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) windowScroll(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) windowScroll(window, lines);
-	if (window == windows.ptrs[windows.show]) mainUpdate();
-
-	if (window->mark && heat > Warm) {
+	bool note = windowWrite(id, heat, src, str);
+	if (note) {
 		beep();
 		notify(id, str);
 	}
@@ -633,488 +271,15 @@ void uiFormat(
 	uiWrite(id, heat, time, buf);
 }
 
-static void scrollTo(struct Window *window, int top) {
-	window->scroll = 0;
-	windowScroll(window, top - MAIN_LINES + MarkerLines);
-}
-
-static void windowReflow(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;
-	}
-}
-
-static void resize(void) {
-	wclear(main);
-	wresize(main, MAIN_LINES, COLS);
-	for (uint num = 0; num < windows.len; ++num) {
-		windowReflow(windows.ptrs[num]);
-	}
-	statusUpdate();
-	mainUpdate();
-}
-
-static void windowList(const struct Window *window) {
-	uiHide();
-	waiting = true;
-
-	uint num = 0;
-	const struct Line *line = bufferHard(window->buffer, windowBottom(window));
-	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];
-		strftime(buf, sizeof(buf), uiTime.format, localtime(&line->time));
-		vid_attr(colorAttr(Colors[Gray]), colorPair(Colors[Gray], -1), NULL);
-		printf("%s ", buf);
-
-		bool align = false;
-		struct Style 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(styleAttr(style), stylePair(style), NULL);
-			printf("%.*s", (int)len, str);
-			str += len;
-		}
-		printf("\n");
-	}
-}
-
-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(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') {
-			*style = reset;
-			str++;
-			len--;
-		}
-		size_t nl = strcspn(str, "\n");
-		if (nl < len) len = nl;
-		wattr_set(input, styleAttr(*style), stylePair(*style), NULL);
-		waddnstr(input, 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;
+void uiResize(void) {
+	wclear(uiMain);
+	wresize(uiMain, MAIN_LINES, COLS);
+	windowResize();
 }
 
-static void inputUpdate(void) {
-	size_t pos;
-	char *buf = editBuffer(&pos);
-	struct Window *window = windows.ptrs[windows.show];
-
-	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(window->id, buf);
-	const char *privmsg = commandIsPrivmsg(window->id, buf);
-	const char *notice = commandIsNotice(window->id, buf);
-	const char *action = commandIsAction(window->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 (window->id == Debug && buf[0] != '/') {
-		prompt = "<< ";
-		stylePrompt.fg = Gray;
-	} else {
-		prompt = "";
-	}
-	if (skip > &buf[pos]) {
-		prefix = prompt = suffix = "";
-		skip = buf;
-	}
-
-	wmove(input, 0, 0);
-	if (window->time && window->id != Network) {
-		whline(input, ' ', uiTime.width);
-		wmove(input, 0, uiTime.width);
-	}
-	wattr_set(input, styleAttr(stylePrompt), stylePair(stylePrompt), NULL);
-	waddstr(input, prefix);
-	waddstr(input, prompt);
-	waddstr(input, suffix);
-
-	int y, x;
-	const char *ptr = skip;
-	struct Style style = styleInput;
-	if (split && split < pos) {
-		ptr = inputStop(styleInput, &style, ptr, &buf[split]);
-		style = styleInput;
-		style.bg = Red;
-	}
-	ptr = inputStop(styleInput, &style, ptr, &buf[pos]);
-	getyx(input, y, x);
-	if (split && split >= pos) {
-		ptr = inputStop(styleInput, &style, ptr, &buf[split]);
-		style = styleInput;
-		style.bg = Red;
-	}
-	inputAdd(styleInput, &style, ptr);
-	wclrtoeol(input);
-	wmove(input, y, x);
-}
-
-void uiWindows(void) {
-	for (uint num = 0; num < windows.len; ++num) {
-		const struct Window *window = windows.ptrs[num];
-		uiFormat(
-			Network, Warm, NULL, "\3%02d%u %s",
-			idColors[window->id], num, idNames[window->id]
-		);
-	}
-}
-
-static void windowShow(uint num) {
-	if (num != windows.show) {
-		windows.swap = windows.show;
-		mark(windows.ptrs[windows.swap]);
-	}
-	windows.show = num;
-	windows.user = num;
-	unmark(windows.ptrs[windows.show]);
-	mainUpdate();
-	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--;
-		mainUpdate();
-	}
-	statusUpdate();
-}
-
-void uiCloseID(uint id) {
-	windowClose(windowFor(id));
-}
-
-void uiCloseNum(uint num) {
-	if (num < windows.len) windowClose(num);
-}
-
-static void scrollPage(struct Window *window, int n) {
-	windowScroll(window, n * (MAIN_LINES - SplitLines - MarkerLines - 1));
-}
-
-static void scrollTop(struct Window *window) {
-	for (size_t i = 0; i < BufferCap; ++i) {
-		if (!bufferHard(window->buffer, i)) continue;
-		scrollTo(window, BufferCap - i);
-		break;
-	}
-}
-
-static void scrollHot(struct Window *window, int dir) {
-	for (size_t i = windowTop(window) + dir; i < BufferCap; i += dir) {
-		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;
-	}
-}
-
-static void scrollSearch(struct Window *window, const char *str, int dir) {
-	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 void toggleTime(struct Window *window) {
-	window->time ^= true;
-	windowReflow(window);
-	statusUpdate();
-	mainUpdate();
-	inputUpdate();
-}
-
-static void incThresh(struct Window *window, int n) {
-	if (n > 0 && window->thresh == Hot) return;
-	if (n < 0 && window->thresh == Ice) {
-		window->thresh = Cold;
-	} else {
-		window->thresh += n;
-	}
-	windowReflow(window);
-	statusUpdate();
-	mainUpdate();
-	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: incThresh(window, -1);
-		break; case KeyMetaPlus:  incThresh(window, +1);
-		break; case KeyMetaSlash: windowShow(windows.swap);
-
-		break; case KeyMetaGt: scrollTo(window, 0);
-		break; case KeyMetaLt: scrollTop(window);
-
-		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: windowList(window);
-		break; case KeyMetaM: uiWrite(id, Warm, NULL, "");
-		break; case KeyMetaN: scrollHot(window, +1);
-		break; case KeyMetaP: scrollHot(window, -1);
-		break; case KeyMetaQ: edit(id, EditCollapse, 0);
-		break; case KeyMetaT: toggleTime(window);
-		break; case KeyMetaU: scrollTo(window, window->unreadHard);
-		break; case KeyMetaV: scrollPage(window, +1);
-
-		break; case KeyCtrlLeft: edit(id, EditPrevWord, 0);
-		break; case KeyCtrlRight: edit(id, EditNextWord, 0);
-
-		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: scrollPage(window, -1);
-		break; case KEY_PPAGE: scrollPage(window, +1);
-		break; case KEY_RIGHT: edit(id, EditNext, 0);
-		break; case KEY_SEND: scrollTo(window, 0);
-		break; case KEY_SHOME: scrollTo(window, BufferCap);
-		break; case KEY_UP: windowScroll(window, +1);
-	}
-}
-
-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'R': scrollSearch(window, editBuffer(NULL), -1);
-		break; case L'S': scrollSearch(window, editBuffer(NULL), +1);
-		break; case L'T': edit(id, EditTranspose, 0);
-		break; case L'U': edit(id, EditDeleteHead, 0);
-		break; case L'V': scrollPage(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;
-	if (iswcntrl(ch)) ch = towlower(ch ^ L'@');
-	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': 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);
-	}
-	if (color != Default) {
-		char buf[4];
-		snprintf(buf, sizeof(buf), "%c%02d", C, color);
-		for (char *ch = buf; *ch; ++ch) {
-			edit(id, EditInsert, *ch);
-		}
-	}
-}
-
-void uiRead(void) {
-	if (hidden) {
-		if (waiting) {
-			uiShow();
-			flushinp();
-			waiting = false;
-		} else {
-			return;
-		}
-	}
-
-	wint_t ch;
-	static bool paste, style, literal;
-	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 (ret == KEY_CODE_YES && ch == KeyPasteManual) {
-			paste ^= true;
-		} else if (paste || literal) {
-			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 && ch == (L'V' ^ L'@')) {
-			literal = true;
-			continue;
-		} else if (style) {
-			keyStyle(ch);
-		} else if (iswcntrl(ch)) {
-			keyCtrl(ch);
-		} else {
-			edit(windows.ptrs[windows.show]->id, EditInsert, ch);
-		}
-		style = false;
-		literal = false;
-	}
-	inputUpdate();
-}
+static FILE *saveFile;
 
-static const time_t Signatures[] = {
+static const uint64_t Signatures[] = {
 	0x6C72696774616301, // no heat, unread, unreadWarm
 	0x6C72696774616302, // no self.pos
 	0x6C72696774616303, // no buffer line heat
@@ -1122,125 +287,70 @@ static const time_t Signatures[] = {
 	0x6C72696774616305, // no URLs
 	0x6C72696774616306, // no thresh
 	0x6C72696774616307, // no window time
-	0x6C72696774616308,
+	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;
 	}
-	errx(EX_DATAERR, "unknown file signature %jX", (uintmax_t)signature);
+	errx(1, "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);
 }
 
-static FILE *saveFile;
-
 int uiSave(void) {
-	int error = 0
-		|| ftruncate(fileno(saveFile), 0)
-		|| writeTime(saveFile, Signatures[7])
-		|| writeTime(saveFile, self.pos);
-	if (error) return error;
-	for (uint num = 0; num < windows.len; ++num) {
-		const struct Window *window = windows.ptrs[num];
-		error = 0
-			|| writeString(saveFile, idNames[window->id])
-			|| writeTime(saveFile, window->mute)
-			|| writeTime(saveFile, window->time)
-			|| writeTime(saveFile, window->thresh)
-			|| writeTime(saveFile, window->heat)
-			|| writeTime(saveFile, window->unreadSoft)
-			|| writeTime(saveFile, 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(saveFile, line->time)
-				|| writeTime(saveFile, line->heat)
-				|| writeString(saveFile, line->str);
-			if (error) return error;
-		}
-		error = writeTime(saveFile, 0);
-		if (error) return error;
-	}
 	return 0
-		|| writeString(saveFile, "")
+		|| 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);
-	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;
+static uint64_t readUint64(FILE *file) {
+	uint64_t u;
+	fread(&u, sizeof(u), 1, file);
+	if (ferror(file)) err(1, "fread");
+	if (feof(file)) errx(1, "unexpected end of save file");
+	return u;
 }
 
 void uiLoad(const char *name) {
 	int error;
 	saveFile = dataOpen(name, "a+e");
-	if (!saveFile) exit(EX_CANTCREAT);
+	if (!saveFile) exit(1);
 	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");
+	if (error) err(1, "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);
+		errx(1, "%s: save file in use", name);
 	}
 
 	time_t signature;
 	fread(&signature, sizeof(signature), 1, saveFile);
-	if (ferror(saveFile)) err(EX_IOERR, "fread");
+	if (ferror(saveFile)) err(1, "fread");
 	if (feof(saveFile)) {
 		return;
 	}
 	size_t version = signatureVersion(signature);
 
 	if (version > 1) {
-		self.pos = readTime(saveFile);
-	}
-
-	char *buf = NULL;
-	size_t cap = 0;
-	while (0 < readString(saveFile, &buf, &cap) && buf[0]) {
-		struct Window *window = windows.ptrs[windowFor(idFor(buf))];
-		if (version > 3) window->mute = readTime(saveFile);
-		if (version > 6) window->time = readTime(saveFile);
-		if (version > 5) window->thresh = readTime(saveFile);
-		if (version > 0) {
-			window->heat = readTime(saveFile);
-			window->unreadSoft = readTime(saveFile);
-			window->unreadWarm = readTime(saveFile);
-		}
-		for (;;) {
-			time_t time = readTime(saveFile);
-			if (!time) break;
-			enum Heat heat = (version > 2 ? readTime(saveFile) : Cold);
-			readString(saveFile, &buf, &cap);
-			bufferPush(window->buffer, COLS, window->thresh, heat, time, buf);
-		}
-		windowReflow(window);
+		self.pos = readUint64(saveFile);
 	}
+	windowLoad(saveFile, version);
+	inputLoad(saveFile, version);
 	urlLoad(saveFile, version);
-
-	free(buf);
 }
diff --git a/url.c b/url.c
index 219a83c..349dc00 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
@@ -32,7 +32,6 @@
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
-#include <sysexits.h>
 #include <unistd.h>
 
 #include "chat.h"
@@ -67,7 +66,7 @@ static void compile(void) {
 	if (!error) return;
 	char buf[256];
 	regerror(error, &Regex, buf, sizeof(buf));
-	errx(EX_SOFTWARE, "regcomp: %s: %s", buf, Pattern);
+	errx(1, "regcomp: %s: %s", buf, Pattern);
 }
 
 struct URL {
@@ -92,10 +91,10 @@ static void push(uint id, const char *nick, const char *str, size_t len) {
 	url->nick = NULL;
 	if (nick) {
 		url->nick = strdup(nick);
-		if (!url->nick) err(EX_OSERR, "strdup");
+		if (!url->nick) err(1, "strdup");
 	}
 	url->url = malloc(len + 1);
-	if (!url->url) err(EX_OSERR, "malloc");
+	if (!url->url) err(1, "malloc");
 
 	char buf[1024];
 	snprintf(buf, sizeof(buf), "%.*s", (int)len, str);
@@ -120,7 +119,7 @@ static const struct Util OpenUtils[] = {
 
 static void urlOpen(const char *url) {
 	pid_t pid = fork();
-	if (pid < 0) err(EX_OSERR, "fork");
+	if (pid < 0) err(1, "fork");
 	if (pid) return;
 
 	setsid();
@@ -132,7 +131,7 @@ static void urlOpen(const char *url) {
 		utilPush(&util, url);
 		execvp(util.argv[0], (char *const *)util.argv);
 		warn("%s", util.argv[0]);
-		_exit(EX_CONFIG);
+		_exit(127);
 	}
 	for (size_t i = 0; i < ARRAY_LEN(OpenUtils); ++i) {
 		struct Util util = OpenUtils[i];
@@ -140,11 +139,11 @@ static void urlOpen(const char *url) {
 		execvp(util.argv[0], (char *const *)util.argv);
 		if (errno != ENOENT) {
 			warn("%s", util.argv[0]);
-			_exit(EX_CONFIG);
+			_exit(127);
 		}
 	}
 	warnx("no open utility found");
-	_exit(EX_CONFIG);
+	_exit(127);
 }
 
 struct Util urlCopyUtil;
@@ -158,18 +157,18 @@ static const struct Util CopyUtils[] = {
 static void urlCopy(const char *url) {
 	int rw[2];
 	int error = pipe(rw);
-	if (error) err(EX_OSERR, "pipe");
+	if (error) err(1, "pipe");
 
 	size_t len = strlen(url);
 	if (len > PIPE_BUF) len = PIPE_BUF;
 	ssize_t n = write(rw[1], url, len);
-	if (n < 0) err(EX_IOERR, "write");
+	if (n < 0) err(1, "write");
 
 	error = close(rw[1]);
-	if (error) err(EX_IOERR, "close");
+	if (error) err(1, "close");
 
 	pid_t pid = fork();
-	if (pid < 0) err(EX_OSERR, "fork");
+	if (pid < 0) err(1, "fork");
 	if (pid) {
 		close(rw[0]);
 		return;
@@ -183,17 +182,17 @@ static void urlCopy(const char *url) {
 	if (urlCopyUtil.argc) {
 		execvp(urlCopyUtil.argv[0], (char *const *)urlCopyUtil.argv);
 		warn("%s", urlCopyUtil.argv[0]);
-		_exit(EX_CONFIG);
+		_exit(127);
 	}
 	for (size_t i = 0; i < ARRAY_LEN(CopyUtils); ++i) {
 		execvp(CopyUtils[i].argv[0], (char *const *)CopyUtils[i].argv);
 		if (errno != ENOENT) {
 			warn("%s", CopyUtils[i].argv[0]);
-			_exit(EX_CONFIG);
+			_exit(127);
 		}
 	}
 	warnx("no copy utility found");
-	_exit(EX_CONFIG);
+	_exit(127);
 }
 
 void urlOpenCount(uint id, uint count) {
@@ -239,7 +238,7 @@ static int writeString(FILE *file, const char *str) {
 }
 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");
+	if (len < 0 && !feof(file)) err(1, "getdelim");
 	return len;
 }
 
@@ -269,11 +268,11 @@ void urlLoad(FILE *file, size_t version) {
 		readString(file, &buf, &cap);
 		if (buf[0]) {
 			url->nick = strdup(buf);
-			if (!url->nick) err(EX_OSERR, "strdup");
+			if (!url->nick) err(1, "strdup");
 		}
 		readString(file, &buf, &cap);
 		url->url = strdup(buf);
-		if (!url->url) err(EX_OSERR, "strdup");
+		if (!url->url) err(1, "strdup");
 	}
 	free(buf);
 }
diff --git a/window.c b/window.c
new file mode 100644
index 0000000..2e79a65
--- /dev/null
+++ b/window.c
@@ -0,0 +1,658 @@
+/* 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 <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(1, "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(1, "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(1, "fread");
+	if (feof(file)) errx(1, "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(1, "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 06c242c..6f61cf9 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,7 +32,6 @@
 #include <stdlib.h>
 #include <string.h>
 #include <sys/stat.h>
-#include <sysexits.h>
 
 #include "chat.h"
 
@@ -90,7 +89,7 @@ static char *basePath(
 	} else if (home) {
 		snprintf(buf, cap, "%s/%s/" SUBDIR "/%s", home, base.defHome, path);
 	} else {
-		errx(EX_USAGE, "HOME unset");
+		errx(1, "HOME unset");
 	}
 	return buf;
 }