about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--Darwin.mk4
-rw-r--r--LICENSE153
-rw-r--r--Linux.mk6
-rw-r--r--Makefile116
-rw-r--r--NetBSD.mk3
-rw-r--r--README.7307
-rw-r--r--buffer.c233
-rw-r--r--catgirl.11255
-rw-r--r--chat.c515
-rw-r--r--chat.h594
-rw-r--r--color.c51
-rw-r--r--command.c730
-rw-r--r--compat_readpassphrase.c206
-rw-r--r--complete.c209
-rw-r--r--config.c144
-rwxr-xr-xconfigure61
-rw-r--r--edit.c404
-rw-r--r--edit.h74
-rw-r--r--event.c168
-rw-r--r--filter.c132
-rw-r--r--format.c162
-rw-r--r--handle.c1714
-rw-r--r--input.c747
-rw-r--r--irc.c291
-rw-r--r--log.c224
-rw-r--r--pls.c186
-rw-r--r--sandman.138
-rw-r--r--sandman.m111
-rw-r--r--scripts/build-chroot.sh74
-rw-r--r--scripts/chat.tmux.conf64
-rw-r--r--scripts/chroot-man.sh (renamed from man.sh)0
-rw-r--r--scripts/chroot-prompt.sh7
-rw-r--r--scripts/notify-send.scpt9
-rw-r--r--scripts/sshd_config (renamed from sshd_config)8
-rw-r--r--tab.c148
-rw-r--r--tag.c53
-rw-r--r--term.c100
-rw-r--r--ui.c772
-rw-r--r--url.c304
-rw-r--r--window.c659
-rw-r--r--xdg.c131
42 files changed, 8006 insertions, 3163 deletions
diff --git a/.gitignore b/.gitignore
index fb17842..519791d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,6 @@
 catgirl
 chroot.tar
 config.mk
-root
+root/
 sandman
 tags
diff --git a/Darwin.mk b/Darwin.mk
deleted file mode 100644
index d1f26cc..0000000
--- a/Darwin.mk
+++ /dev/null
@@ -1,4 +0,0 @@
-LIBRESSL_PREFIX = /usr/local/opt/libressl
-LDLIBS = -lcurses -ltls -framework Cocoa
-BINS += sandman
-MANS += sandman.1
diff --git a/LICENSE b/LICENSE
index dba13ed..f288702 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,21 +1,23 @@
-                    GNU AFFERO GENERAL PUBLIC LICENSE
-                       Version 3, 19 November 2007
+                    GNU GENERAL PUBLIC LICENSE
+                       Version 3, 29 June 2007
 
- Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
  Everyone is permitted to copy and distribute verbatim copies
  of this license document, but changing it is not allowed.
 
                             Preamble
 
-  The GNU Affero General Public License is a free, copyleft license for
-software and other kinds of works, specifically designed to ensure
-cooperation with the community in the case of network server software.
+  The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
 
   The licenses for most software and other practical works are designed
 to take away your freedom to share and change the works.  By contrast,
-our General Public Licenses are intended to guarantee your freedom to
+the GNU General Public License is intended to guarantee your freedom to
 share and change all versions of a program--to make sure it remains free
-software for all its users.
+software for all its users.  We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors.  You can apply it to
+your programs, too.
 
   When we speak of free software, we are referring to freedom, not
 price.  Our General Public Licenses are designed to make sure that you
@@ -24,34 +26,44 @@ them if you wish), that you receive source code or can get it if you
 want it, that you can change the software or use pieces of it in new
 free programs, and that you know you can do these things.
 
-  Developers that use our General Public Licenses protect your rights
-with two steps: (1) assert copyright on the software, and (2) offer
-you this License which gives you legal permission to copy, distribute
-and/or modify the software.
-
-  A secondary benefit of defending all users' freedom is that
-improvements made in alternate versions of the program, if they
-receive widespread use, become available for other developers to
-incorporate.  Many developers of free software are heartened and
-encouraged by the resulting cooperation.  However, in the case of
-software used on network servers, this result may fail to come about.
-The GNU General Public License permits making a modified version and
-letting the public access it on a server without ever releasing its
-source code to the public.
-
-  The GNU Affero General Public License is designed specifically to
-ensure that, in such cases, the modified source code becomes available
-to the community.  It requires the operator of a network server to
-provide the source code of the modified version running there to the
-users of that server.  Therefore, public use of a modified version, on
-a publicly accessible server, gives the public access to the source
-code of the modified version.
-
-  An older license, called the Affero General Public License and
-published by Affero, was designed to accomplish similar goals.  This is
-a different license, not a version of the Affero GPL, but Affero has
-released a new version of the Affero GPL which permits relicensing under
-this license.
+  To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights.  Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received.  You must make sure that they, too, receive
+or can get the source code.  And you must show them these terms so they
+know their rights.
+
+  Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+  For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software.  For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+  Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so.  This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software.  The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable.  Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products.  If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+  Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary.  To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
 
   The precise terms and conditions for copying, distribution and
 modification follow.
@@ -60,7 +72,7 @@ modification follow.
 
   0. Definitions.
 
-  "This License" refers to version 3 of the GNU Affero General Public License.
+  "This License" refers to version 3 of the GNU General Public License.
 
   "Copyright" also means copyright-like laws that apply to other kinds of
 works, such as semiconductor masks.
@@ -537,45 +549,35 @@ to collect a royalty for further conveying from those to whom you convey
 the Program, the only way you could satisfy both those terms and this
 License would be to refrain entirely from conveying the Program.
 
-  13. Remote Network Interaction; Use with the GNU General Public License.
-
-  Notwithstanding any other provision of this License, if you modify the
-Program, your modified version must prominently offer all users
-interacting with it remotely through a computer network (if your version
-supports such interaction) an opportunity to receive the Corresponding
-Source of your version by providing access to the Corresponding Source
-from a network server at no charge, through some standard or customary
-means of facilitating copying of software.  This Corresponding Source
-shall include the Corresponding Source for any work covered by version 3
-of the GNU General Public License that is incorporated pursuant to the
-following paragraph.
+  13. Use with the GNU Affero General Public License.
 
   Notwithstanding any other provision of this License, you have
 permission to link or combine any covered work with a work licensed
-under version 3 of the GNU General Public License into a single
+under version 3 of the GNU Affero General Public License into a single
 combined work, and to convey the resulting work.  The terms of this
 License will continue to apply to the part which is the covered work,
-but the work with which it is combined will remain governed by version
-3 of the GNU General Public License.
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
 
   14. Revised Versions of this License.
 
   The Free Software Foundation may publish revised and/or new versions of
-the GNU Affero General Public License from time to time.  Such new versions
-will be similar in spirit to the present version, but may differ in detail to
+the GNU General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
 address new problems or concerns.
 
   Each version is given a distinguishing version number.  If the
-Program specifies that a certain numbered version of the GNU Affero General
+Program specifies that a certain numbered version of the GNU General
 Public License "or any later version" applies to it, you have the
 option of following the terms and conditions either of that numbered
 version or of any later version published by the Free Software
 Foundation.  If the Program does not specify a version number of the
-GNU Affero General Public License, you may choose any version ever published
+GNU General Public License, you may choose any version ever published
 by the Free Software Foundation.
 
   If the Program specifies that a proxy can decide which future
-versions of the GNU Affero General Public License can be used, that proxy's
+versions of the GNU General Public License can be used, that proxy's
 public statement of acceptance of a version permanently authorizes you
 to choose that version for the Program.
 
@@ -633,29 +635,40 @@ the "copyright" line and a pointer to where the full notice is found.
     Copyright (C) <year>  <name of author>
 
     This program is free software: you can redistribute it and/or modify
-    it under the terms of the GNU Affero General Public License as published by
+    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 Affero General Public License for more details.
+    GNU General Public License for more details.
 
-    You should have received a copy of the GNU Affero General Public License
-    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <https://www.gnu.org/licenses/>.
 
 Also add information on how to contact you by electronic and paper mail.
 
-  If your software can interact with users remotely through a computer
-network, you should also make sure that it provides a way for users to
-get its source.  For example, if your program is a web application, its
-interface could display a "Source" link that leads users to an archive
-of the code.  There are many ways you could offer source, and different
-solutions will be better for different programs; see section 13 for the
-specific requirements.
+  If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+    <program>  Copyright (C) <year>  <name of author>
+    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
 
   You should also get your employer (if you work as a programmer) or school,
 if any, to sign a "copyright disclaimer" for the program, if necessary.
-For more information on this, and how to apply and follow the GNU AGPL, see
-<http://www.gnu.org/licenses/>.
+For more information on this, and how to apply and follow the GNU GPL, see
+<https://www.gnu.org/licenses/>.
+
+  The GNU General Public License does not permit incorporating your program
+into proprietary programs.  If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.  But first, please read
+<https://www.gnu.org/licenses/why-not-lgpl.html>.
diff --git a/Linux.mk b/Linux.mk
deleted file mode 100644
index f6f5535..0000000
--- a/Linux.mk
+++ /dev/null
@@ -1,6 +0,0 @@
-LIBRESSL_PREFIX = /usr/local
-CFLAGS += -D_GNU_SOURCE
-LDLIBS = -lncursesw -lpthread
-LDLIBS += ${LIBRESSL_PREFIX}/lib/libtls.a
-LDLIBS += ${LIBRESSL_PREFIX}/lib/libssl.a
-LDLIBS += ${LIBRESSL_PREFIX}/lib/libcrypto.a
diff --git a/Makefile b/Makefile
index 8519d59..66fb408 100644
--- a/Makefile
+++ b/Makefile
@@ -1,99 +1,87 @@
-PREFIX = ~/.local
-MANDIR = ${PREFIX}/share/man
-CHROOT_USER = chat
-CHROOT_GROUP = ${CHROOT_USER}
-LIBRESSL_PREFIX = /usr/local
+PREFIX ?= /usr/local
+BINDIR ?= ${PREFIX}/bin
+MANDIR ?= ${PREFIX}/man
 
-CFLAGS += -std=c11 -Wall -Wextra -Wpedantic
-CFLAGS += -I${LIBRESSL_PREFIX}/include
-LDFLAGS += -L${LIBRESSL_PREFIX}/lib
-LDLIBS = -lcursesw -ltls
+CEXTS = gnu-case-range gnu-conditional-omitted-operand
+CFLAGS += -std=c11 -Wall -Wextra -Wpedantic -Wmissing-prototypes
+CFLAGS += ${CEXTS:%=-Wno-%}
+LDADD.libtls = -ltls
+LDADD.ncursesw = -lncursesw
 
 BINS = catgirl
-MANS = catgirl.1
+MANS = ${BINS:=.1}
 
 -include config.mk
 
+LDLIBS = ${LDADD.libtls} ${LDADD.ncursesw}
+LDLIBS.sandman = -framework Cocoa
+
+OBJS += buffer.o
 OBJS += chat.o
-OBJS += color.o
+OBJS += command.o
+OBJS += complete.o
+OBJS += config.o
 OBJS += edit.o
-OBJS += event.o
-OBJS += format.o
+OBJS += filter.o
 OBJS += handle.o
 OBJS += input.o
 OBJS += irc.o
 OBJS += log.o
-OBJS += pls.o
-OBJS += tab.o
-OBJS += tag.o
-OBJS += term.o
 OBJS += ui.o
 OBJS += url.o
+OBJS += window.o
+OBJS += xdg.o
+
+OBJS.sandman = sandman.o
 
-TESTS += format.t
-TESTS += pls.t
-TESTS += term.t
+TESTS += edit.t
 
-all: tags ${BINS} test
+dev: tags all check
+
+all: ${BINS}
 
 catgirl: ${OBJS}
 	${CC} ${LDFLAGS} ${OBJS} ${LDLIBS} -o $@
 
 ${OBJS}: chat.h
 
-test: ${TESTS}
-	set -e; ${TESTS:%=./%;}
+edit.o edit.t input.o: edit.h
+
+sandman: ${OBJS.sandman}
+	${CC} ${LDFLAGS} ${OBJS.$@} ${LDLIBS.$@} -o $@
+
+check: ${TESTS}
 
 .SUFFIXES: .t
 
 .c.t:
 	${CC} ${CFLAGS} -DTEST ${LDFLAGS} $< ${LDLIBS} -o $@
+	./$@ || rm $@
 
-tags: *.c *.h
-	ctags -w *.c *.h
+tags: *.[ch]
+	ctags -w *.[ch]
+
+clean:
+	rm -f ${BINS} ${OBJS} ${OBJS.sandman} ${TESTS} tags
 
 install: ${BINS} ${MANS}
-	install -d ${PREFIX}/bin ${MANDIR}/man1
-	install ${BINS} ${PREFIX}/bin
-	install -m 644 ${MANS} ${MANDIR}/man1
+	install -d ${DESTDIR}${BINDIR} ${DESTDIR}${MANDIR}/man1
+	install ${BINS} ${DESTDIR}${BINDIR}
+	install -m 644 ${MANS} ${DESTDIR}${MANDIR}/man1
 
 uninstall:
-	rm -f ${BINS:%=${PREFIX}/bin/%}
-	rm -f ${MANS:%=${MANDIR}/man1/%}
-
-chroot.tar: catgirl catgirl.1 man.sh
-	install -d -o root -g wheel \
-		root \
-		root/bin \
-		root/etc/ssl \
-		root/home \
-		root/lib \
-		root/libexec \
-		root/usr/bin \
-		root/usr/share/man \
-		root/usr/share/misc
-	install -d -o ${CHROOT_USER} -g ${CHROOT_GROUP} root/home/${CHROOT_USER}
-	cp -fp /libexec/ld-elf.so.1 root/libexec
-	cp -fp \
-		/lib/libc.so.7 \
-		/lib/libncursesw.so.8 \
-		/lib/libthr.so.3 \
-		/lib/libz.so.6 \
-		/usr/local/lib/libcrypto.so.45 \
-		/usr/local/lib/libssl.so.47 \
-		/usr/local/lib/libtls.so.19 \
-		root/lib
-	cp -fp /etc/hosts /etc/resolv.conf root/etc
-	cp -fp /etc/ssl/cert.pem root/etc/ssl
-	cp -af /usr/share/locale root/usr/share
-	cp -fp /usr/share/misc/termcap.db root/usr/share/misc
-	cp -fp /rescue/sh /usr/bin/mandoc /usr/bin/less root/bin
-	${MAKE} install PREFIX=root/usr
-	install man.sh root/usr/bin/man
-	tar -c -f chroot.tar -C root bin etc home lib libexec usr
+	rm -f ${BINS:%=${DESTDIR}${BINDIR}/%}
+	rm -f ${MANS:%=${DESTDIR}${MANDIR}/man1/%}
+
+CHROOT_USER = chat
+CHROOT_GROUP = ${CHROOT_USER}
+
+chroot.tar: catgirl catgirl.1 scripts/chroot-prompt.sh scripts/chroot-man.sh
+chroot.tar: scripts/build-chroot.sh
+	sh scripts/build-chroot.sh ${CHROOT_USER} ${CHROOT_GROUP}
 
 install-chroot: chroot.tar
-	tar -x -f chroot.tar -C /home/${CHROOT_USER}
+	tar -px -f chroot.tar -C /home/${CHROOT_USER}
 
-clean:
-	rm -fr ${BINS} ${OBJS} ${TESTS} tags root chroot.tar
+clean-chroot:
+	rm -fr chroot.tar root
diff --git a/NetBSD.mk b/NetBSD.mk
deleted file mode 100644
index 7c47665..0000000
--- a/NetBSD.mk
+++ /dev/null
@@ -1,3 +0,0 @@
-LIBRESSL_PREFIX = /usr/pkg/libressl
-LDFLAGS += -rpath=${LIBRESSL_PREFIX}/lib
-LDLIBS = -lcurses -ltls
diff --git a/README.7 b/README.7
index cae56bb..a26d270 100644
--- a/README.7
+++ b/README.7
@@ -1,5 +1,6 @@
-.Dd February 25, 2019
-.Dt CATGIRL 7
+.\" To view this file: $ man ./README.7
+.Dd July  9, 2023
+.Dt README 7
 .Os "Causal Agency"
 .
 .Sh NAME
@@ -7,105 +8,267 @@
 .Nd IRC client
 .
 .Sh DESCRIPTION
-.Nm
-is a curses IRC client
-originally intended for
-use over anonymous SSH.
+.Xr catgirl 1
+is a terminal IRC client.
 .
 .Pp
-It requires LibreSSL
-.Pq Fl ltls
-and targets
-.Fx ,
-Darwin,
-.Nx
+Screenshot:
+imagine,
+if you will,
+text on a screen,
+next to names
+in a selection of colours.
+.
+.Ss Notable Features
+.Bl -bullet
+.It
+Tab complete:
+most recently seen or mentioned nicks
+are completed first.
+Commas are inserted between multiple nicks.
+.It
+Prompt:
+the prompt clearly shows whether input
+will be interpreted as a command
+or sent as a message.
+.It
+Split scroll:
+keeps the latest messages in view while scrolling.
+.It
+URL detection:
+recent URLs from a particular user
+or matching a substring
+can be opened or copied.
+.It
+Nick coloring:
+color generation based on usernames
+remains stable across nick changes.
+Mentions of users in messages are colored.
+.It
+Topic diffing:
+the modified portion
+of a channel topic change
+is highlighted.
+.It
+Ignore:
+visibility of filtered messages
+can be toggled.
+.It
+Security:
+on
+.Fx
 and
-GNU/Linux.
+.Ox ,
+the
+.Cm restrict
+option enables tight sandboxing.
+Sandboxing is always used on
+.Ox .
+.El
 .
-.Sh INSTALL
-On platforms other than
+.Ss Non-features
+.Bl -bullet
+.It
+Dynamic configuration:
+all configuration happens
+in a simple text file
+or on the command line.
+.It
+Multi-network:
+a terminal multiplexer such as
+.Xr screen 1
+or
+.Xr tmux 1
+(or just your regular terminal emulator tabs)
+can be used to connect
+.Nm
+to multiple networks.
+.It
+Reconnection:
+when the connection to the server is lost,
+.Nm
+exits.
+It can be run in a loop
+or connected to a bouncer,
+such as
+.Lk https://git.causal.agency/pounce "pounce" .
+.It
+CTCP:
+apart from
+.Sy ACTION ,
+this protocol is useless at best
+and enables abuse at worst.
+.It
+Protocol coverage:
+IRCv3 extensions are implemented only
+where they contribute to
+the intended user experience.
+.It
+Cleartext IRC:
+TLS is now ubiquitous
+and certificates are easy to obtain.
+.El
+.
+.Sh INSTALLING
+.Nm
+requires ncurses and
+.Sy libtls ,
+provided by either
+.Lk https://git.causal.agency/libretls/about LibreTLS
+(for OpenSSL)
+or by LibreSSL.
+It targets
 .Fx ,
-copy the appropriate file to
-.Pa config.mk
-and modify as needed.
-The default install
-.Va PREFIX
-is
-.Pa ~/.local .
+.Ox ,
+macOS
+and Linux.
+.Nm
+and
+.Sy libtls
+may be packaged for your system.
+Check the Repology pages for
+.Lk https://repology.org/project/catgirl/versions catgirl
+and
+.Lk https://repology.org/project/libretls/versions libretls .
+.
+.Bd -literal -offset indent
+$ ./configure
+$ make all
+# make install
+.Ed
+.
+.Pp
+Packagers are encouraged
+to patch in their own text macros in
+.Pa input.c .
 .
 .Pp
+If installing
+.Sy libtls
+manually to
+.Pa /usr/local ,
+for example,
+make sure
+.Pa /usr/local/lib
+appears in
+.Pa /etc/ld.so.conf
+or
+.Pa /etc/ld.so.conf.d/*
+and be sure to run
+.Xr ldconfig 8
+once the library is installed.
+Set
+.Ev PKG_CONFIG_PATH
+for
+.Nm ./configure
+to find it.
 .Bd -literal -offset indent
-cp $(uname).mk config.mk
-make
-make install
+$ PKG_CONFIG_PATH=/usr/local/lib/pkgconfig ./configure
 .Ed
 .
-.Ss Darwin
-LibreSSL is assumed to be installed with
-.Xr brew 1 .
+.Pp
 The
-.Xr sandman 1
-wrapper is also installed.
-.
-.Ss NetBSD
-LibreSSL is assumed to be installed with
-.Xr pkgsrc 7 .
-Due to bugs in
-.Nx Ap s
-.Xr curses 3
-implementation,
-some of the UI is currently broken.
-.
-.Ss GNU/Linux
-LibreSSL is assumed to be manually installed in
-.Pa /usr/local
-and is statically linked.
+.Nm sandman
+wrapper is provided for macOS
+to stop and start
+.Nm
+on system sleep and wake.
+To enable it,
+configure with:
+.Bd -literal -offset indent
+$ ./configure --enable-sandman
+.Ed
 .
 .Sh FILES
-.Bl -tag -width sandman.m -compact
+.Bl -tag -width "command.c" -compact
 .It Pa chat.h
-shared state and function prototypes
+global state and declarations
 .It Pa chat.c
-command line parsing
-.It Pa event.c
-event loop and process spawning
-.It Pa tag.c
-tag (channel, query) ID assignment
-.It Pa handle.c
-incoming command handling
-.It Pa input.c
-input command handling
+startup and event loop
 .It Pa irc.c
-TLS client connection
-.It Pa format.c
-IRC formatting
-.It Pa color.c
-nick and channel coloring
+IRC connection and parsing
 .It Pa ui.c
-cursed UI
-.It Pa term.c
-terminal features unsupported by curses
+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
+command handling
+.It Pa buffer.c
+line wrapping
 .It Pa edit.c
 line editing
-.It Pa tab.c
-tab-complete
+.It Pa complete.c
+tab complete
 .It Pa url.c
 URL detection
-.It Pa pls.c
-functions which should not have to be written
+.It Pa filter.c
+message filtering
+.It Pa log.c
+chat logging
+.It Pa config.c
+configuration parsing
+.It Pa xdg.c
+XDG base directories
 .It Pa sandman.m
-utility for Darwin to signal sleep
+sleep/wake wrapper for macOS
 .El
 .
 .Pp
-.Bl -tag -width sshd_config -compact
-.It Pa sshd_config
-anonymous SSH configuration
-.It Pa man.sh
+.Bl -tag -width "scripts/notify-send.scpt" -compact
+.It Pa scripts/chat.tmux.conf
+example
+.Xr tmux 1
+configuration for multiple networks
+and automatic reconnects
+.It Pa scripts/notify-send.scpt
+.Xr notify-send 1
+in AppleScript
+.It Pa scripts/build-chroot.sh
+chroot builder for
+.Ox
+and
+.Fx
+.It Pa scripts/chroot-prompt.sh
+name prompt wrapper for chroot
+.It Pa scripts/chroot-man.sh
 .Xr man 1
 implementation for chroot
+.It Pa scripts/sshd_config
+.Xr sshd 8
+configuration for public chroot
 .El
 .
+.Sh CONTRIBUTING
+The upstream URL of this project is
+.Aq Lk https://git.causal.agency/catgirl .
+Contributions in any form can be sent to
+.Aq Mt list+catgirl@causal.agency .
+For sending patches by email, see
+.Aq Lk https://git-send-email.io .
+Mailing list archives are available at
+.Aq Lk https://causal.agency/list/catgirl.html .
+.
+.Pp
+Monetary contributions can be
+.Lk https://liberapay.com/june/donate "donated via Liberapay" .
+.
 .Sh SEE ALSO
 .Xr catgirl 1 ,
 .Xr sandman 1
+.
+.Pp
+IRC bouncer:
+.Lk https://git.causal.agency/pounce "pounce"
+.
+.Rs
+.%A June McEnroe
+.%T IRC Suite
+.%U https://text.causal.agency/010-irc-suite.txt
+.%D June 19, 2020
+.Re
+.
+.\" To view this file: $ man ./README.7
diff --git a/buffer.c b/buffer.c
new file mode 100644
index 0000000..f82e553
--- /dev/null
+++ b/buffer.c
@@ -0,0 +1,233 @@
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
+ */
+
+#include <err.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+#include <time.h>
+#include <wchar.h>
+#include <wctype.h>
+
+#include "chat.h"
+
+struct Lines {
+	size_t len;
+	struct Line lines[BufferCap];
+};
+_Static_assert(!(BufferCap & (BufferCap - 1)), "BufferCap is power of two");
+
+struct Buffer {
+	struct Lines soft;
+	struct Lines hard;
+};
+
+struct Buffer *bufferAlloc(void) {
+	struct Buffer *buffer = calloc(1, sizeof(*buffer));
+	if (!buffer) err(EX_OSERR, "calloc");
+	return buffer;
+}
+
+void bufferFree(struct Buffer *buffer) {
+	for (size_t i = 0; i < BufferCap; ++i) {
+		free(buffer->soft.lines[i].str);
+		free(buffer->hard.lines[i].str);
+	}
+	free(buffer);
+}
+
+static const struct Line *linesLine(const struct Lines *lines, size_t i) {
+	const struct Line *line = &lines->lines[(lines->len + i) % BufferCap];
+	return (line->str ? line : NULL);
+}
+
+static struct Line *linesNext(struct Lines *lines) {
+	struct Line *line = &lines->lines[lines->len++ % BufferCap];
+	free(line->str);
+	return line;
+}
+
+const struct Line *bufferSoft(const struct Buffer *buffer, size_t i) {
+	return linesLine(&buffer->soft, i);
+}
+
+const struct Line *bufferHard(const struct Buffer *buffer, size_t i) {
+	return linesLine(&buffer->hard, i);
+}
+
+enum { StyleCap = 10 };
+static char *styleCopy(char *ptr, char *end, struct Style style) {
+	ptr = seprintf(
+		ptr, end, "%s%s%s%s",
+		(style.attr & Bold ? (const char []) { B, '\0' } : ""),
+		(style.attr & Reverse ? (const char []) { R, '\0' } : ""),
+		(style.attr & Italic ? (const char []) { I, '\0' } : ""),
+		(style.attr & Underline ? (const char []) { U, '\0' } : "")
+	);
+	if (style.fg != Default || style.bg != Default) {
+		ptr = seprintf(ptr, end, "\3%02d,%02d", style.fg, style.bg);
+	}
+	return ptr;
+}
+
+static const wchar_t ZWS = L'\u200B';
+static const wchar_t ZWNJ = L'\u200C';
+
+static int flow(struct Lines *hard, int cols, const struct Line *soft) {
+	int flowed = 1;
+
+	struct Line *line = linesNext(hard);
+	line->num = soft->num;
+	line->heat = soft->heat;
+	line->time = soft->time;
+	line->str = strdup(soft->str);
+	if (!line->str) err(EX_OSERR, "strdup");
+
+	int width = 0;
+	int align = 0;
+	char *wrap = NULL;
+	struct Style style = StyleDefault;
+	struct Style wrapStyle = StyleDefault;
+	for (char *str = line->str; *str;) {
+		size_t len = styleParse(&style, (const char **)&str);
+		if (!len) continue;
+
+		bool tab = (*str == '\t' && !align);
+		if (tab) *str = ' ';
+
+		wchar_t wc = L'\0';
+		int n = mbtowc(&wc, str, len);
+		if (n < 0) {
+			n = 1;
+			// ncurses will render these as "~A".
+			width += (*str & '\200' ? 2 : 1);
+		} else if (wc == ZWS || wc == ZWNJ) {
+			// ncurses likes to render these as spaces when they should be
+			// zero-width, so just remove them entirely.
+			memmove(str, &str[n], strlen(&str[n]) + 1);
+			continue;
+		} else if (wc == L'\t') {
+			// Assuming TABSIZE = 8.
+			width += 8 - (width % 8);
+		} else if (wc < L' ' || wc == L'\177') {
+			// ncurses will render these as "^A".
+			width += 2;
+		} else if (wcwidth(wc) > 0) {
+			width += wcwidth(wc);
+		}
+
+		if (tab && width < cols) {
+			align = width;
+			wrap = NULL;
+		}
+		if (iswspace(wc) && !tab) {
+			wrap = str;
+			wrapStyle = style;
+		}
+		if (wc == L'-' && width <= cols) {
+			wrap = &str[n];
+			wrapStyle = style;
+		}
+
+		if (width <= cols) {
+			str += n;
+			continue;
+		} else if (!wrap) {
+			wrap = str;
+			wrapStyle = style;
+		}
+
+		n = 0;
+		len = strlen(wrap);
+		for (int m; wrap[n] && (m = mbtowc(&wc, &wrap[n], len - n)); n += m) {
+			if (m < 0) {
+				m = 1;
+			} else if (!iswspace(wc)) {
+				break;
+			}
+		}
+		if (!wrap[n]) {
+			*wrap = '\0';
+			break;
+		}
+
+		flowed++;
+		line = linesNext(hard);
+		line->num = soft->num;
+		line->heat = soft->heat;
+		line->time = 0;
+
+		size_t cap = StyleCap + align + strlen(&wrap[n]) + 1;
+		line->str = malloc(cap);
+		if (!line->str) err(EX_OSERR, "malloc");
+
+		char *end = &line->str[cap];
+		str = seprintf(line->str, end, "%*s", (width = align), "");
+		str = styleCopy(str, end, wrapStyle);
+		style = wrapStyle;
+		seprintf(str, end, "%s", &wrap[n]);
+
+		*wrap = '\0';
+		wrap = NULL;
+	}
+
+	return flowed;
+}
+
+int bufferPush(
+	struct Buffer *buffer, int cols, enum Heat thresh,
+	enum Heat heat, time_t time, const char *str
+) {
+	struct Line *soft = linesNext(&buffer->soft);
+	soft->num = buffer->soft.len;
+	soft->heat = heat;
+	soft->time = time;
+	soft->str = strdup(str);
+	if (!soft->str) err(EX_OSERR, "strdup");
+	if (heat < thresh) return 0;
+	return flow(&buffer->hard, cols, soft);
+}
+
+int
+bufferReflow(struct Buffer *buffer, int cols, enum Heat thresh, size_t tail) {
+	buffer->hard.len = 0;
+	for (size_t i = 0; i < BufferCap; ++i) {
+		free(buffer->hard.lines[i].str);
+		buffer->hard.lines[i].str = NULL;
+	}
+	int flowed = 0;
+	for (size_t i = 0; i < BufferCap; ++i) {
+		const struct Line *soft = bufferSoft(buffer, i);
+		if (!soft) continue;
+		if (soft->heat < thresh) continue;
+		int n = flow(&buffer->hard, cols, soft);
+		if (i >= BufferCap - tail) flowed += n;
+	}
+	return flowed;
+}
diff --git a/catgirl.1 b/catgirl.1
index 5511ed4..815eade 100644
--- a/catgirl.1
+++ b/catgirl.1
@@ -1,4 +1,4 @@
-.Dd October 3, 2019
+.Dd October 11, 2023
 .Dt CATGIRL 1
 .Os
 .
@@ -8,406 +8,1125 @@
 .
 .Sh SYNOPSIS
 .Nm
-.Op Fl NPRv
-.Op Fl a Ar auth
+.Op Fl Relqv
+.Op Fl C Ar copy
+.Op Fl H Ar hash
+.Op Fl I Ar highlight
+.Op Fl N Ar notify
+.Op Fl O Ar open
+.Op Fl S Ar bind
+.Op Fl T Ns Op Ar timestamp
+.Op Fl a Ar plain
+.Op Fl c Ar cert
 .Op Fl h Ar host
-.Op Fl j Ar chan
-.Op Fl k Ar keys
-.Op Fl l Ar path
+.Op Fl i Ar ignore
+.Op Fl j Ar join
+.Op Fl k Ar priv
+.Op Fl m Ar mode
 .Op Fl n Ar nick
 .Op Fl p Ar port
 .Op Fl r Ar real
+.Op Fl s Ar save
+.Op Fl t Ar trust
 .Op Fl u Ar user
 .Op Fl w Ar pass
+.Op Ar config ...
+.
+.Nm
+.Fl o
+.Op Fl S Ar bind
+.Op Fl h Ar host
+.Op Fl p Ar port
+.Op Ar config ...
+.
+.Nm
+.Fl g Ar cert
 .
 .Sh DESCRIPTION
+The
 .Nm
-is a curses, TLS-only IRC client.
+IRC client
+provides a curses interface
+for TLS-only
+Internet Relay Chat.
+The only required option is
+.Fl h ,
+the host name to connect to.
+See
+.Sx EXAMPLES
+for managing further configuration.
+Type
+.Ic /help
+in
+.Nm
+to view the list of
+.Sx COMMANDS
+and
+.Sx KEY BINDINGS .
 .
 .Pp
-The arguments are as follows:
+Options can be loaded from files
+listed on the command line.
+Files are searched for in
+.Pa $XDG_CONFIG_DIRS/catgirl
+.Po
+usually
+.Pa ~/.config/catgirl
+.Pc
+unless the path starts with
+.Ql / ,
+.Ql \&./
+or
+.Ql \&../ .
+Files and flags listed later
+on the command line
+take precedence over
+those listed earlier.
 .
-.Bl -tag -width "-w pass"
-.It Fl N
-Send notifications with
+.Pp
+Each option is placed on a line,
+and lines beginning with
+.Ql #
+are ignored.
+The options are listed below
+following their corresponding flags.
+.
+.Bl -tag -width Ds
+.It Fl C Ar util | Cm copy No = Ar util
+Set the utility used by
+.Ic /copy .
+Subsequent
+.Cm copy
+options append arguments to
+.Ar util .
+The URL to copy is provided to
+.Ar util
+on standard input.
+The default is the first available of
+.Xr pbcopy 1 ,
+.Xr wl-copy 1 ,
+.Xr xclip 1 ,
+.Xr xsel 1 .
+.
+.It Fl H Ar seed,bound | Cm hash No = Ar seed,bound
+Set the initial seed
+of the nick and channel
+color hash function
+and the maximum IRC color value
+produced by the function.
+The default is 0,75.
+To use only colors from
+the 16-color terminal set,
+use 0,15.
+To disable nick and channel colors,
+use 0,0.
+.
+.It Fl I Ar pattern | Cm highlight No = Ar pattern
+Add a case-insensitive message highlight pattern,
+which may contain
+.Ql * ,
+.Ql \&?
+and
+.Ql []
+wildcards as in
+.Xr glob 7 .
+The format of the pattern is as follows:
+.Bd -ragged -offset indent
+.Ar nick Ns Op Ar !user@host Op Ar command Op Ar channel Op Ar message
+.Ed
+.Pp
+The commands which can be matched are:
+.Sy INVITE ,
+.Sy JOIN ,
+.Sy NICK ,
+.Sy NOTICE ,
+.Sy PART ,
+.Sy PRIVMSG ,
+.Sy QUIT ,
+.Sy SETNAME .
+.
+.It Fl N Ar util | Cm notify No = Ar util
+Send notifications using a utility.
+Subsequent
+.Cm notify
+options append arguments to
+.Ar util .
+The window name and message
+are provided to
+.Ar util
+as two additional arguments,
+appropriate for
 .Xr notify-send 1 .
 .
-.It Fl P
-Prompt for nickname.
+.It Fl O Ar util | Cm open No = Ar util
+Set the utility used by
+.Ic /open .
+Subsequent
+.Cm open
+options append arguments to
+.Ar util .
+The URL to open is provided to
+.Ar util
+as an argument.
+The default is the first available of
+.Xr open 1 ,
+.Xr xdg-open 1 .
+.
+.It Fl R | Cm restrict
+Disable the
+.Ic /copy ,
+.Ic /exec
+and
+.Ic /open
+commands,
+the
+.Cm notify
+option,
+and viewing this manual with
+.Ic /help .
 .
-.It Fl R
-Restrict the use of the
-.Ic /join ,
-.Ic /query ,
-.Ic /quote ,
-.Ic /raw
-commands.
+.It Fl S Ar host | Cm bind No = Ar host
+Bind to source address
+.Ar host
+when connecting to the server.
+To connect from any address
+over IPv4 only,
+use 0.0.0.0.
+To connect from any address
+over IPv6 only,
+use ::.
 .
-.It Fl a Ar auth
-Authenticate with SASL PLAIN.
-.Ar auth
-is a colon-separated
-username and password pair.
+.It Fl T Ns Oo Ar format Oc | Cm timestamp Op = Ar format
+Show timestamps by default,
+in the specified
+.Xr strftime 3
+.Ar format .
+The format string may contain
+raw IRC formatting codes.
+The default format is
+.Qq \&%X .
 .
-.It Fl h Ar host
+.It Fl a Ar user : Ns Ar pass | Cm sasl-plain No = Ar user : Ns Ar pass
+Authenticate as
+.Ar user
+with
+.Ar pass
+using SASL PLAIN.
+Leave
+.Ar pass
+blank to prompt for the password.
+.
+.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
+.Cm priv .
+With
+.Cm sasl-external ,
+authenticate using SASL EXTERNAL.
+Certificates can be generated with
+.Fl g .
+.
+.It Fl e | Cm sasl-external
+Authenticate using SASL EXTERNAL,
+also known as CertFP.
+The TLS client certificate is loaded with
+.Cm cert .
+See
+.Sx Configuring CertFP .
+.
+.It Fl g Ar path
+Generate a TLS client certificate using
+.Xr openssl 1
+and write it to
+.Ar path .
+.
+.It Fl h Ar host | Cm host No = Ar host
 Connect to
 .Ar host .
 .
-.It Fl j Ar chan
-Join
-.Ar chan
-after connecting.
-.Ar chan
-may be a comma-separated list.
-.
-.It Fl k Ar keys
-Set keys for channels in
-.Fl j .
-.Ar keys
-may be a comma-separated list.
-.
-.It Fl l Ar path
-Log messages to
-subdirectories of
+.It Fl i Ar pattern | Cm ignore No = Ar pattern
+Add a case-insensitive message ignore pattern,
+which may contain
+.Ql * ,
+.Ql \&?
+and
+.Ql []
+wildcards as in
+.Xr glob 7 .
+The format of the pattern is as follows:
+.Bd -ragged -offset indent
+.Ar nick Ns Op Ar !user@host Op Ar command Op Ar channel Op Ar message
+.Ed
+.Pp
+The commands which can be matched are:
+.Sy INVITE ,
+.Sy JOIN ,
+.Sy NICK ,
+.Sy NOTICE ,
+.Sy PART ,
+.Sy PRIVMSG ,
+.Sy QUIT ,
+.Sy SETNAME .
+.
+.It Fl j Ar channels Oo Ar keys Oc | Cm join No = Ar channels Oo Ar keys Oc
+Join the comma-separated list of
+.Ar channels
+with the optional comma-separated list of channel
+.Ar keys .
+.
+.It Fl k Ar path | Cm priv No = Ar priv
+Load the TLS client private key from
+.Ar path .
+The
 .Ar path
-named by channel or nick
-in files named by date.
+is searched 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 n Ar nick
+.It Fl m Ar mode | Cm mode No = Ar mode
+Set the user
+.Ar mode .
+.
+.It Fl n Ar nick Oo Ar ... Oc | Cm nick No = Ar nick Oo Ar ... Oc
 Set nickname to
 .Ar nick .
-The default nickname
-is the user's name.
+The default nickname is
+the value of the environment variable
+.Ev USER .
+Additional space-separated nicks
+will be tried in order
+if the first is not available,
+and all nicks
+are treated as highlight words.
+.
+.It Fl o
+Print the server certificate chain
+to standard output in PEM format
+and exit.
 .
-.It Fl p Ar port
+.It Fl p Ar port | Cm port No = Ar port
 Connect to
 .Ar port .
 The default port is 6697.
 .
-.It Fl r Ar real
+.It Fl q | Cm quiet
+Raise the default message visibility threshold
+for new windows,
+hiding general events
+(joins, quits, etc.).
+.
+.It Fl r Ar real | Cm real No = Ar real
 Set realname to
 .Ar real .
-The default realname is
-the same as the nickname.
+The default realname is the same as the nickname.
 .
-.It Fl u Ar user
+.It Fl s Ar name | Cm save No = Ar name
+Save and load the contents of windows from
+.Ar name
+in
+.Pa $XDG_DATA_DIRS/catgirl ,
+or an absolute or relative path if
+.Ar name
+starts with
+.Ql / ,
+.Ql \&./ ,
+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.
+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.
+The default username is the same as the nickname.
 .
-.It Fl v
-Show raw IRC protocol in the
-.Sy <raw>
-window.
-If standard error is not a terminal,
-output raw IRC protocol
-to standard error.
+.It Fl v | Cm debug
+Log raw IRC messages to the
+.Sy <debug>
+window
+as well as standard error
+if it is not a terminal.
 .
-.It Fl w Ar pass
-Log in with
+.It Fl w Ar pass | Cm pass No = Ar pass
+Log in with the server password
 .Ar pass .
+Leave
+.Ar pass
+blank to prompt for the password.
+.El
+.
+.Ss Configuring CertFP
+.Bl -enum
+.It
+Generate a new TLS client certificate:
+.Bd -literal -offset indent
+$ catgirl -g ~/.config/catgirl/example.pem
+.Ed
+.It
+Connect to the server using the certificate:
+.Bd -literal -offset indent
+cert = example.pem
+# or: $ catgirl -c example.pem
+.Ed
+.It
+Identify with services or use
+.Cm sasl-plain ,
+then add the certificate fingerprint
+to your account:
+.Bd -literal -offset indent
+/ns CERT ADD
+.Ed
+.It
+Enable SASL EXTERNAL
+to require successful authentication
+when connecting
+(not possible on all networks):
+.Bd -literal -offset indent
+cert = example.pem
+sasl-external
+# or: $ catgirl -e -c example.pem
+.Ed
+.El
+.
+.Ss Connecting to Servers with Self-signed Certificates
+.Bl -enum
+.It
+Connect to the server
+and write its certificate to a file:
+.Bd -literal -offset indent
+$ catgirl -o -h irc.example.org > ~/.config/catgirl/example.pem
+.Ed
+.It
+Configure
+.Nm
+to trust the certificate:
+.Bd -literal -offset indent
+trust = example.pem
+# or: $ catgirl -t example.pem
+.Ed
 .El
 .
+.Sh INTERFACE
+The
+.Nm
+interface is split
+into three areas.
+.
+.Ss Status Line
+The top line of the terminal
+shows window statuses.
+Only the currently active window
+and windows with activity are listed.
+The status line for a window
+might look like this:
+.Bd -literal -offset indent
+1+ #ascii.town +3 ~7 @
+.Ed
+.Pp
+The number on the left
+is the window number.
+Following it may be one of
+.Ql - ,
+.Ql + ,
+.Ql ++ ,
+as well as
+.Ql = .
+These indicate
+the message visibility threshold
+and mute status
+of the window.
+.Pp
+On the right side,
+the number following
+.Ql +
+indicates the number of unread messages.
+The number following
+.Ql ~
+indicates how many lines
+are below the scroll position.
+An
+.Ql @
+indicates that there is unsent input
+in the window's
+.Sx Input Line .
+.Pp
+.Nm
+will also set the terminal title,
+if possible,
+to the name of the network
+and active window,
+followed by the unread count
+for that window,
+and the unread count
+for all other windows
+in parentheses.
+.
+.Ss Chat Area
+The chat area shows
+messages and events.
+Regular messages are shown
+with the nick between
+.Ql <>
+angle brackets.
+Actions are shown
+with the nick preceded by
+.Ql * .
+Notices are shown
+with the nick between
+.Ql -
+hyphens.
+.Pp
+Blank lines are inserted into the chat
+as unread markers.
+.Pp
+While scrolling,
+the most recent 5 lines of chat
+are kept visible below a marker line.
+.
+.Ss Input Line
+The bottom line of the terminal
+is where messages and commands are entered.
+When entering a message, action or notice,
+your nick appears on the left,
+as it would in the
+.Sx Chat Area .
+When entering a command,
+no nick is shown.
+.Pp
+Formatting codes are shown
+in the input line
+as reverse-video uppercase letters.
+These will not appear in the sent message.
+.Pp
+Input that is too long
+to send as a single message
+will have a red background
+starting at the point where it will be split
+into a second message.
+.
 .Sh COMMANDS
-Any unique prefix
-may be used to abbreviate a command.
+Any unique prefix can be used to abbreviate a command.
+For example,
+.Ic /join
+can be typed
+.Ic /j .
 .
 .Ss Chat Commands
 .Bl -tag -width Ds
-.It Ic /join Ar chan Op Ar key
-Join a channel.
-.
-.It Ic /list Op Ar chan
+.It Ic /away Op Ar message
+Set or clear your away status.
+.It Ic /cs Ar command
+Send a command to ChanServ.
+.It Ic /invite Ar nick
+Invite a user to the channel.
+.It Ic /join Op Ar channel Op Ar key
+Join the named channel,
+the current channel,
+or the channel you've been invited to.
+.It Ic /list Op Ar channel
 List channels.
-.
 .It Ic /me Op Ar action
 Send an action message.
-.
-.It Ic /names , /who
-List users in the current channel.
-.
+.It Ic /msg Ar nick message
+Send a private message.
+.It Ic /names
+List users in the channel.
 .It Ic /nick Ar nick
 Change nicknames.
-.
+.It Ic /notice Ar message
+Send a notice.
+.It Ic /ns Ar command
+Send a command to NickServ.
+.It Ic /ops
+List channel operators.
 .It Ic /part Op Ar message
-Leave the current channel.
-.
+Leave the channel.
 .It Ic /query Ar nick
-Open a private message view.
-.
+Start a private conversation.
 .It Ic /quit Op Ar message
 Quit IRC.
-.
 .It Ic /quote Ar command
 Send a raw IRC command.
-.
+Use
+.Ic M--
+to show unknown replies.
+.It Ic /say Ar message
+Send a regular message.
+.It Ic /setname Ar name
+Update realname
+if supported by the server.
 .It Ic /topic Op Ar topic
-Show or set the topic of the current channel.
-.
-.It Ic /whois Ar nick
-Query information about a user.
-.
-.It Ic /znc Ar command
-Send
-.Xr znc 1
-command.
+Show or set the topic of the channel.
+Press
+.Ic Tab
+twice 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
 .
-.Pp
-Any messages entered in the
-.Sy <raw>
-window will be sent as raw IRC commands.
-.
 .Ss UI Commands
 .Bl -tag -width Ds
-.It Ic /close
-Close the current window.
-.
-.It Ic /help , /man
-View this manual.
-.
-.It Ic /move Ar num
-Move window to number.
-If
-.Ar num
-starts with
-.Cm +
-or
-.Cm - ,
-the number is relative to the current window.
-.
-.It Ic /open Op Ar range
-Open a
-.Ar range
-of recent URLs
-in the current window with
-.Xr open 1 .
-URLs are numbered
-from the most recent
-starting at 1.
-The
-.Ar range
-may be a single number,
-or a hyphen- or comma-separated range.
-.
-.It Ic /open Ar substring
-Open the most recent URL
-in the current window
-matching the
+.It Ic /close Op Ar name | num
+Close the named, numbered or current window.
+.It Ic /copy Op Ar nick | substring
+Copy the most recent URL from
+.Ar nick
+or matching
 .Ar substring .
-.
-.It Ic /raw
-Toggle the
-.Sy <raw>
+.It Ic /debug
+Toggle logging in the
+.Sy <debug>
 window.
-.
-.It Ic /url
-Hide the UI
-and list the most recent URLs
-in the current window.
-Press
-.Ic Enter
-to resume the UI.
-.
-.It Ic /window Ar name
-Switch to window by name.
-.
-.It Ic /window Ar num , Ic / Ns Ar num
+.It Ic /exec Ar command
+Run
+.Ar command
+with
+.Ev SHELL
+and interpret its output
+as input to the current window,
+including as commands.
+.It Ic /help
+View this manual.
+Type
+.Ic q
+to return to
+.Nm .
+.It Ic /help Ar topic
+List the server help for a topic.
+Try
+.Ic /help index
+for a list of topics.
+.It Ic /highlight Op Ar pattern
+List message highlight patterns
+or temporarily add a pattern.
+To permanently add a pattern,
+use the
+.Cm highlight
+option.
+.It Ic /ignore Op Ar pattern
+List message ignore patterns
+or temporarily add a pattern.
+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
+most recent URLs.
+.It Ic /open Ar nick | substring
+Open the most recent URL from
+.Ar nick
+or matching
+.Ar substring .
+.It Ic /unhighlight Ar pattern
+Temporarily remove a message highlight pattern.
+.It Ic /unignore Ar pattern
+Temporarily remove a message ignore pattern.
+.It Ic /window
+List all windows.
+.It Ic /window Ar name | substring
+Switch to window by name
+or matching substring.
+.It Ic /window Ar num | Ic / Ns Ar num
 Switch to window by number.
-If
-.Ar num
-starts with
-.Cm +
-or
-.Cm - ,
-the number is relative to the current window.
+.El
+.
+.Ss Operator Commands
+.Bl -tag -width Ds
+.It Ic /ban Op Ar mask ...
+List or ban masks from the channel.
+.It Ic /deop Op Ar nick ...
+Revoke channel operator status from users or yourself.
+.It Ic /devoice Op Ar nick ...
+Revoke voice from users or yourself in the channel.
+.It Ic /except Op Ar mask ...
+List or add masks to the channel ban exception list.
+.It Ic /invex Op Ar mask ...
+List or add masks to the channel invite list.
+.It Ic /kick Ar nick Op Ar message
+Kick a user from the channel.
+.It Ic /mode Oo Ar modes Oc Op Ar param ...
+Show or set channel modes.
+In the
+.Sy <network>
+window,
+show or set user modes.
+.It Ic /op Op Ar nick ...
+Grant users or yourself channel operator status.
+.It Ic /unban Ar mask ...
+Unban masks from the channel.
+.It Ic /unexcept Ar mask ...
+Remove masks from the channel ban exception list.
+.It Ic /uninvex Ar mask ...
+Remove masks from the channel invite list.
+.It Ic /voice Op Ar nick ...
+Grant users or yourself voice in the channel.
 .El
 .
 .Sh KEY BINDINGS
+The
 .Nm
-provides
+interface provides
 .Xr emacs 1 Ns -like
-line editing keys
-as well as keys for applying IRC formatting.
+line editing
+as well as keys for IRC formatting.
 The prefixes
-.Ic C- , M- , S-
-represent the control, meta (alt) and shift modifiers,
-respectively.
-.Ic M- Ns Ar x
-sequences can also be typed as
-.Ic Esc
-followed by
-.Ar x .
+.Ic C-
+and
+.Ic M-
+represent the control and meta (alt)
+modifiers, respectively.
 .
 .Ss Line Editing
 .Bl -tag -width Ds -compact
 .It Ic C-a
-Move cursor to beginning of line.
+Move to beginning of line.
 .It Ic C-b
-Move cursor left.
+Move left.
 .It Ic C-d
-Delete character under cursor.
+Delete next character.
 .It Ic C-e
-Move cursor to end of line.
+Move to end of line.
 .It Ic C-f
-Move cursor right.
+Move right.
 .It Ic C-k
-Delete line after cursor.
+Delete to end of line.
+.It Ic C-t
+Transpose characters.
 .It Ic C-u
-Delete line.
+Delete to beginning of line.
 .It Ic C-w
-Delete word before cursor.
+Delete previous word.
+.It Ic C-x
+Expand a text macro beginning with
+.Ql \e .
+.It Ic C-y
+Paste previously deleted text.
+.It Ic M-Enter
+Insert a newline without sending a command.
 .It Ic M-b
-Move cursor to beginning of word.
+Move to previous word.
 .It Ic M-d
-Delete word after cursor.
+Delete next word.
 .It Ic M-f
-Move cursor to end of word.
+Move to next word.
+.It Ic M-q
+Collapse all whitespace.
 .It Ic Tab
-Cycle through completions for
-commands, nicks and channels.
+Complete nick, channel, command or macro.
 .El
+.Pp
+Arrow and navigation keys
+also work as expected.
 .
-.Ss IRC Formatting
+.Ss Window Keys
 .Bl -tag -width Ds -compact
-.It Ic C-_
-Toggle underline.
-.It Ic C-o
-Toggle bold.
+.It Ic C-l
+Redraw the UI.
+.It Ic C-n
+Switch to next window.
+.It Ic C-p
+Switch to previous window.
 .It Ic C-r
-Set or reset color.
+Scroll to previous line matching input.
 .It Ic C-s
-Reset formatting.
-.It Ic C-t
-Toggle italics.
+Scroll to next line matching input.
 .It Ic C-v
-Toggle reverse video.
-This must usually be typed as
-.Ic C-v C-v .
+Scroll down a page.
+.It Ic M-+
+Raise message visibility threshold,
+hiding ignored messages,
+general events
+(joins, quits, etc.),
+or non-highlighted messages.
+.It Ic M--
+Lower message visibility threshold,
+showing ignored messages
+and unknown replies.
+.It Ic M-=
+Toggle mute.
+Muted windows do not appear in the status line
+unless you are mentioned.
+.It Ic M-/
+Switch to previously selected window.
+.It Ic M-<
+Scroll to top.
+.It Ic M->
+Scroll to bottom.
+.It Ic M- Ns Ar n
+Switch to window by number 0\(en9.
+.It Ic M-a
+Cycle through unread windows.
+.It Ic M-l
+List the contents of the window
+without word-wrapping
+and with timestamps.
+Press
+.Ic Enter
+to return to
+.Nm .
+.It Ic M-m
+Insert a blank line in the window.
+.It Ic M-n
+Scroll to next highlight.
+.It Ic M-p
+Scroll to previous highlight.
+.It Ic M-s
+Reveal spoiler text.
+.It Ic M-t
+Toggle timestamps.
+.It Ic M-u
+Scroll to first unread line.
+.It Ic M-v
+Scroll up a page.
+.El
+.
+.Ss IRC Formatting
+.Bl -tag -width "C-z C-v" -compact
+.It Ic C-z C-v
+Insert the next input character literally.
+.It Ic C-z b
+Toggle bold.
+.It Ic C-z c
+Set or reset color.
+.It Ic C-z i
+Toggle italics.
+.It Ic C-z o
+Reset formatting.
+.It Ic C-z p
+Manually toggle paste mode.
+.It Ic C-z r
+Toggle reverse color.
+.It Ic C-z s
+Set spoiler text (black on black).
+.It Ic C-z u
+Toggle underline.
+.El
+.
+.Pp
+Some color codes can be inserted
+with the following:
+.Bl -column "C-z A" "magenta" "C-z N" "orange (dark yellow)"
+.It Ic C-z A Ta gray Ta Ic C-z N Ta brown (dark red)
+.It Ic C-z B Ta blue Ta Ic C-z O Ta orange (dark yellow)
+.It Ic C-z C Ta cyan Ta Ic C-z P Ta pink (light magenta)
+.It Ic C-z G Ta green Ta Ic C-z R Ta red
+.It Ic C-z K Ta black Ta Ic C-z W Ta white
+.It Ic C-z M Ta magenta Ta Ic C-z Y Ta yellow
 .El
 .
 .Pp
+To set other colors, follow
+.Ic C-z c
+by one or two digits for the foreground color,
+optionally followed by a comma
+and one or two digits for the background color.
 To reset color, follow
-.Ic C-r
+.Ic C-z c
 by a non-digit.
-To set colors, follow
-.Ic C-r
-by one or two digits
-to set the foreground color,
-optionally followed by a comma
-and one or two digits
-to set the background color.
 .
 .Pp
 The color numbers are as follows:
-.Pp
-.Bl -column "7" "orange (dark yellow)" "15" "pink (light magenta)"
-.It 0 Ta white Ta \ 8 Ta yellow
-.It 1 Ta black Ta \ 9 Ta light green
-.It 2 Ta blue Ta 10 Ta cyan
-.It 3 Ta green Ta 11 Ta light cyan
-.It 4 Ta red Ta 12 Ta light blue
-.It 5 Ta brown (dark red) Ta 13 Ta pink (light magenta)
-.It 6 Ta magenta Ta 14 Ta gray
-.It 7 Ta orange (dark yellow) Ta 15 Ta light gray
-.El
-.
-.Ss Window Keys
-.Bl -tag -width "PageDown" -compact
-.It Ic C-l
-Redraw the UI.
-.It Ic C-n
-Switch to the next window.
-.It Ic C-p
-Switch to the previous window.
-.It Ic M-/
-Switch to the previously active window.
-.It Ic M-a
-Switch to next hot or unread window.
-.It Ic M-l
-Hide the UI and list the log for the current window.
-.It Ic M-m
-Insert a blank line in the window.
-.It Ic M- Ns Ar n
-Switch to window by number 0\(en9.
-.It Ic Down
-Scroll window down by one line.
-.It Ic PageDown
-Scroll window down by one page.
-.It Ic PageUp
-Scroll window up by one page.
-.It Ic Up
-Scroll window up by one line.
+.Bl -column "99" "orange (dark yellow)" "15" "pink (light magenta)"
+.It \ 0 Ta white Ta \ 8 Ta yellow
+.It \ 1 Ta black Ta \ 9 Ta light green
+.It \ 2 Ta blue Ta 10 Ta cyan
+.It \ 3 Ta green Ta 11 Ta light cyan
+.It \ 4 Ta red Ta 12 Ta light blue
+.It \ 5 Ta brown (dark red) Ta 13 Ta pink (light magenta)
+.It \ 6 Ta magenta Ta 14 Ta gray
+.It \ 7 Ta orange (dark yellow) Ta 15 Ta light gray
+.It 99 Ta default Ta Ta
 .El
 .
 .Sh ENVIRONMENT
 .Bl -tag -width Ds
+.It Ev SHELL
+The shell used by
+.Ic /exec .
+The default is
+.Pa /bin/sh .
 .It Ev USER
 The default nickname.
 .El
 .
-.Sh EXAMPLES
-.Dl catgirl -h chat.freenode.net -j '#ascii.town'
+.Sh FILES
+.Bl -tag -width Ds
+.It Pa $XDG_CONFIG_DIRS/catgirl
+Configuration files are searched for first in
+.Ev $XDG_CONFIG_HOME ,
+usually
+.Pa ~/.config ,
+followed by the colon-separated list of paths
+.Ev $XDG_CONFIG_DIRS ,
+usually
+.Pa /etc/xdg .
+.It Pa ~/.config/catgirl
+The most likely location of configuration files.
 .
-.Sh STANDARDS
+.It Pa $XDG_DATA_DIRS/catgirl
+Save files are searched for first in
+.Ev $XDG_DATA_HOME ,
+usually
+.Pa ~/.local/share ,
+followed by the colon-separated list of paths
+.Ev $XDG_DATA_DIRS ,
+usually
+.Pa /usr/local/share:/usr/share .
+.It Pa ~/.local/share/catgirl
+The most likely location of save files.
+.El
+.
+.Sh EXIT STATUS
+The
 .Nm
-is a partial implementation of the following:
+client exits 0
+if requested by the user,
+.Dv EX_UNAVAILABLE
+(69)
+if the connection is lost,
+and >0 if any other error occurs.
+.
+.Sh EXAMPLES
+Join
+.Li #ascii.town
+from the command line:
+.Bd -literal -offset indent
+$ catgirl -h irc.tilde.chat -j '#ascii.town'
+.Ed
+.Pp
+Create a configuration file in
+.Pa ~/.config/catgirl/tilde :
+.Bd -literal -offset indent
+host = irc.tilde.chat
+join = #ascii.town
+.Ed
+.Pp
+Load the configuration file:
+.Bd -literal -offset indent
+$ catgirl tilde
+.Ed
 .
+.Sh STANDARDS
 .Bl -item
 .It
 .Rs
+.%A Adam
+.%A Attila Molnar
+.%T invite-notify Extension
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/extensions/invite-notify
+.Re
+.It
+.Rs
+.%A Jack Allnutt
+.%A Val Lorentz
+.%A Daniel Oaks
+.%T Modern IRC Client Protocol
+.%I ircdocs
+.%U https://modern.ircdocs.horse/index.html
+.Re
+.It
+.Rs
+.%A Kiyoshi Aman
+.%A Kyle Fuller
+.%A St\('ephan Kochen
+.%A Alexey Sokolov
+.%A James Wheare
+.%T Message Tags
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/extensions/message-tags
+.Re
+.It
+.Rs
+.%A Kiyoshi Aman
+.%T extended-join Extension
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/extensions/extended-join
+.Re
+.It
+.Rs
+.%A Waldo Bastian
+.%A Ryan Lortie
+.%A Lennart Poettering
+.%T XDG Base Directory Specification
+.%U https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
+.%D November 24, 2010
+.Re
+.It
+.Rs
+.%A Christine Dodrill
+.%A Ryan
+.%A James Wheare
+.%T chghost Extension
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/extensions/chghost
+.Re
+.It
+.Rs
+.%A Kyle Fuller
+.%A St\('ephan Kochen
+.%A Alexey Sokolov
+.%A James Wheare
+.%T server-time Extension
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/extensions/server-time
+.Re
+.It
+.Rs
+.%A Lee Hardy
+.%A Perry Lorier
+.%A Kevin L. Mitchell
+.%A Attila Molnar
+.%A Daniel Oakley
+.%A William Pitcock
+.%A James Wheare
+.%T Client Capability Negotiation
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/core/capability-negotiation
+.Re
+.It
+.Rs
+.%A S. Josefsson
+.%T The Base16, Base32, and Base64 Data Encodings
+.%I IETF
+.%R RFC 4648
+.%U https://tools.ietf.org/html/rfc4648
+.%D October 2006
+.Re
+.It
+.Rs
 .%A C. Kalt
 .%T Internet Relay Chat: Client Protocol
 .%I IETF
-.%N RFC 2812
-.%D April 2000
+.%R RFC 2812
 .%U https://tools.ietf.org/html/rfc2812
+.%D April 2000
 .Re
-.
 .It
 .Rs
-.%A Kevin L. Mitchell
-.%A Perry Lorier
-.%A Lee Hardy
-.%A William Pitcock
-.%T IRCv3.1 Client Capability Negotiation
+.%A Janne Mareike Koschinski
+.%T setname Extension
 .%I IRCv3 Working Group
-.%U https://ircv3.net/specs/core/capability-negotiation-3.1.html
+.%U https://ircv3.net/specs/extensions/setname
+.Re
+.It
+.Rs
+.%A Mantas Mikul\[u0117]nas
+.%T userhost-in-names Extension
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/extensions/userhost-in-names
+.Re
+.It
+.Rs
+.%A Daniel Oaks
+.%T IRC Formatting
+.%I ircdocs
+.%U https://modern.ircdocs.horse/formatting.html
+.Re
+.It
+.Rs
+.%A Daniel Oaks
+.%T Standard Replies Extension
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/extensions/standard-replies
+.Re
+.It
+.Rs
+.%A J. Oikarinen
+.%A D. Reed
+.%T Internet Relay Chat Protocol
+.%I IETF
+.%R RFC 1459
+.%U https://tools.ietf.org/html/rfc1459
+.%D May 1993
 .Re
-.
 .It
 .Rs
-.%A Jilles Tjoelker
 .%A William Pitcock
+.%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 K. Zeilenga, Ed.
-.%Q OpenLDAP Foundation
-.%T The PLAIN Simple Authentication and Security Layer (SASL) Mechanism
-.%I IETF
-.%N RFC 4616
-.%D August 2006
-.%U https://tools.ietf.org/html/rfc4616
+.%A William Pitcock
+.%T multi-prefix Extension
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/extensions/multi-prefix
 .Re
-.
 .It
 .Rs
-.%A S. Josefsson
-.%Q SJD
-.%T The Base16, Base32, and Base64 Data Encodings
+.%A James Wheare
+.%T Message IDs
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/extensions/message-ids
+.Re
+.It
+.Rs
+.%A James Wheare
+.%T reply Client Tag
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/client-tags/reply
+.Re
+.It
+.Rs
+.%A K. Zeilenga, Ed.
+.%T The PLAIN Simple Authentication and Security Layer (SASL) Mechanism
 .%I IETF
-.%N RFC 4648
-.%D October 2006
-.%U https://tools.ietf.org/html/rfc4648
+.%R RFC 4616
+.%U https://tools.ietf.org/html/rfc4616
+.%D August 2006
 .Re
 .El
 .
-.Sh CAVEATS
+.Ss Extensions
+The
 .Nm
-does not support unencrypted connections.
+client implements the
+.Sy causal.agency/consumer
+vendor-specific IRCv3 capability
+offered by
+.Xr pounce 1 .
+The consumer position is stored in the
+.Cm save
+file.
+.
+.Sh AUTHORS
+.An June McEnroe Aq Mt june@causal.agency
+.
+.Sh BUGS
+Send mail to
+.Aq Mt list+catgirl@causal.agency
+or join
+.Li #ascii.town
+on
+.Li irc.tilde.chat .
diff --git a/chat.c b/chat.c
index b3e4b58..6728240 100644
--- a/chat.c
+++ b/chat.c
@@ -1,90 +1,507 @@
-/* Copyright (C) 2018  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 Affero General Public License as published by
+ * 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 Affero General Public License for more details.
+ * GNU General Public License for more details.
  *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ * 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 _WITH_GETLINE
-
 #include <err.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <inttypes.h>
+#include <limits.h>
 #include <locale.h>
+#include <poll.h>
+#include <signal.h>
+#include <stdbool.h>
+#include <stdint.h>
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
+#include <sys/stat.h>
+#include <sys/time.h>
+#include <sys/wait.h>
 #include <sysexits.h>
+#include <time.h>
+#include <tls.h>
 #include <unistd.h>
 
+#ifdef __FreeBSD__
+#include <capsicum_helpers.h>
+#endif
+
+char *readpassphrase(const char *prompt, char *buf, size_t bufsiz, int flags);
+
 #include "chat.h"
 
-static char *dupe(const char *str) {
-	char *dup = strdup(str);
-	if (!dup) err(EX_OSERR, "strdup");
-	return dup;
+#ifndef OPENSSL_BIN
+#define OPENSSL_BIN "openssl"
+#endif
+
+static void genCert(const char *path) {
+	const char *name = strrchr(path, '/');
+	name = (name ? &name[1] : path);
+	char subj[4 + NAME_MAX];
+	snprintf(subj, sizeof(subj), "/CN=%.*s", (int)strcspn(name, "."), name);
+	umask(0066);
+	execlp(
+		OPENSSL_BIN, "openssl", "req",
+		"-x509", "-new", "-newkey", "rsa:4096", "-sha256", "-days", "3650",
+		"-nodes", "-subj", subj, "-out", path, "-keyout", path,
+		NULL
+	);
+	err(EX_UNAVAILABLE, "openssl");
+}
+
+char *idNames[IDCap] = {
+	[None] = "<none>",
+	[Debug] = "<debug>",
+	[Network] = "<network>",
+};
+enum Color idColors[IDCap] = {
+	[None] = Black,
+	[Debug] = Green,
+	[Network] = Gray,
+};
+uint idNext = Network + 1;
+
+struct Network network = { .userLen = 9, .hostLen = 63 };
+struct Self self = { .color = Default };
+
+static const char *save;
+static void exitSave(void) {
+	int error = uiSave();
+	if (error) {
+		warn("%s", save);
+		_exit(EX_IOERR);
+	}
 }
 
-static char *prompt(const char *prompt) {
-	char *line = NULL;
-	size_t cap;
-	for (;;) {
-		printf("%s", prompt);
-		fflush(stdout);
+uint execID;
+int execPipe[2] = { -1, -1 };
+int utilPipe[2] = { -1, -1 };
 
-		ssize_t len = getline(&line, &cap, stdin);
-		if (ferror(stdin)) err(EX_IOERR, "getline");
-		if (feof(stdin)) exit(EX_OK);
-		if (len < 2) continue;
+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) return;
+	buf[len] = '\0';
+	for (char *ptr = buf; ptr;) {
+		char *line = strsep(&ptr, "\r\n");
+		if (line[0]) command(execID, line);
+	}
+}
 
-		line[len - 1] = '\0';
-		return line;
+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) return;
+	buf[len] = '\0';
+	for (char *ptr = buf; ptr;) {
+		char *line = strsep(&ptr, "\r\n");
+		if (line[0]) uiFormat(Network, Warm, NULL, "%s", line);
 	}
 }
 
+uint32_t hashInit;
+uint32_t hashBound = 75;
+
+static void parseHash(char *str) {
+	hashInit = strtoul(str, &str, 0);
+	if (*str) hashBound = strtoul(&str[1], NULL, 0);
+}
+
+static void parsePlain(char *str) {
+	self.plainUser = strsep(&str, ":");
+	if (!str) errx(EX_USAGE, "SASL PLAIN missing colon");
+	self.plainPass = str;
+}
+
+static volatile sig_atomic_t signals[NSIG];
+static void signalHandler(int signal) {
+	signals[signal] = 1;
+}
+
+static void sandboxEarly(bool log);
+static void sandboxLate(int irc);
+
+#if defined __OpenBSD__
+
+static char *promisesInitial;
+static char promises[64] = "stdio tty";
+
+static void sandboxEarly(bool log) {
+	char *ptr = &promises[strlen(promises)];
+	char *end = &promises[sizeof(promises)];
+
+	if (log) {
+		char buf[PATH_MAX];
+		int error = unveil(dataPath(buf, sizeof(buf), "log", 0), "wc");
+		if (error) err(EX_OSERR, "unveil");
+		ptr = seprintf(ptr, end, " wpath cpath");
+	}
+
+	if (!self.restricted) {
+		int error = unveil("/", "x");
+		if (error) err(EX_OSERR, "unveil");
+		ptr = seprintf(ptr, end, " proc exec");
+	}
+
+	promisesInitial = ptr;
+	ptr = seprintf(ptr, end, " inet dns");
+	int error = pledge(promises, NULL);
+	if (error) err(EX_OSERR, "pledge");
+}
+
+static void sandboxLate(int irc) {
+	(void)irc;
+	*promisesInitial = '\0';
+	int error = pledge(promises, NULL);
+	if (error) err(EX_OSERR, "pledge");
+}
+
+#elif defined __FreeBSD__
+
+static void sandboxEarly(bool log) {
+	(void)log;
+}
+
+static void sandboxLate(int irc) {
+	if (!self.restricted) return;
+
+	// Rights are also limited in uiLoad() and logOpen().
+	cap_rights_t rights;
+	int error = 0
+		|| caph_limit_stdin()
+		|| caph_rights_limit(
+			STDOUT_FILENO, cap_rights_init(&rights, CAP_WRITE, CAP_IOCTL)
+		)
+		|| caph_limit_stderr()
+		|| caph_rights_limit(
+			irc, cap_rights_init(&rights, CAP_SEND, CAP_RECV, CAP_EVENT)
+		);
+	if (error) err(EX_OSERR, "cap_rights_limit");
+
+	// caph_cache_tzdata(3) doesn't load UTC info, which we need for
+	// certificate verification. gmtime(3) does.
+	caph_cache_tzdata();
+	gmtime(&(time_t) { time(NULL) });
+
+	error = cap_enter();
+	if (error) err(EX_OSERR, "cap_enter");
+}
+
+#else
+static void sandboxEarly(bool log) {
+	(void)log;
+}
+static void sandboxLate(int irc) {
+	(void)irc;
+}
+#endif
+
 int main(int argc, char *argv[]) {
 	setlocale(LC_CTYPE, "");
 
-	int opt;
-	while (0 < (opt = getopt(argc, argv, "NPRa:h:j:k:l:n:p:r:u:vw:"))) {
+	bool insecure = false;
+	bool printCert = false;
+	const char *bind = NULL;
+	const char *host = NULL;
+	const char *port = "6697";
+	const char *trust = NULL;
+	const char *cert = NULL;
+	const char *priv = NULL;
+
+	bool log = false;
+	bool sasl = false;
+	char *pass = NULL;
+	const char *user = NULL;
+	const char *real = NULL;
+
+	struct option options[] = {
+		{ .val = '!', .name = "insecure", no_argument },
+		{ .val = 'C', .name = "copy", required_argument },
+		{ .val = 'H', .name = "hash", required_argument },
+		{ .val = 'I', .name = "highlight", required_argument },
+		{ .val = 'N', .name = "notify", required_argument },
+		{ .val = 'O', .name = "open", required_argument },
+		{ .val = 'R', .name = "restrict", no_argument },
+		{ .val = 'S', .name = "bind", required_argument },
+		{ .val = 'T', .name = "timestamp", optional_argument },
+		{ .val = 'a', .name = "sasl-plain", required_argument },
+		{ .val = 'c', .name = "cert", required_argument },
+		{ .val = 'e', .name = "sasl-external", no_argument },
+		{ .val = 'g', .name = "generate", required_argument },
+		{ .val = 'h', .name = "host", required_argument },
+		{ .val = 'i', .name = "ignore", required_argument },
+		{ .val = 'j', .name = "join", required_argument },
+		{ .val = 'k', .name = "priv", required_argument },
+		{ .val = 'l', .name = "log", no_argument },
+		{ .val = 'm', .name = "mode", required_argument },
+		{ .val = 'n', .name = "nick", required_argument },
+		{ .val = 'o', .name = "print-chain", no_argument },
+		{ .val = 'p', .name = "port", required_argument },
+		{ .val = 'q', .name = "quiet", no_argument },
+		{ .val = 'r', .name = "real", required_argument },
+		{ .val = 's', .name = "save", required_argument },
+		{ .val = 't', .name = "trust", required_argument },
+		{ .val = 'u', .name = "user", required_argument },
+		{ .val = 'v', .name = "debug", no_argument },
+		{ .val = 'w', .name = "pass", required_argument },
+		{0},
+	};
+	char opts[3 * ARRAY_LEN(options)];
+	for (size_t i = 0, j = 0; i < ARRAY_LEN(options); ++i) {
+		opts[j++] = options[i].val;
+		if (options[i].has_arg != no_argument) opts[j++] = ':';
+		if (options[i].has_arg == optional_argument) opts[j++] = ':';
+	}
+
+	for (int opt; 0 < (opt = getopt_config(argc, argv, opts, options, NULL));) {
 		switch (opt) {
-			break; case 'N': self.notify = true;
-			break; case 'P': self.nick = prompt("Name: ");
-			break; case 'R': self.limit = true;
-			break; case 'a': self.auth = dupe(optarg);
-			break; case 'h': self.host = dupe(optarg);
-			break; case 'j': self.join = dupe(optarg);
-			break; case 'k': self.keys = dupe(optarg);
-			break; case 'l': logOpen(optarg);
-			break; case 'n': self.nick = dupe(optarg);
-			break; case 'p': self.port = dupe(optarg);
-			break; case 'r': self.real = dupe(optarg);
-			break; case 'u': self.user = dupe(optarg);
-			break; case 'v': self.raw = true;
-			break; case 'w': self.pass = dupe(optarg);
+			break; case '!': insecure = true;
+			break; case 'C': utilPush(&urlCopyUtil, optarg);
+			break; case 'H': parseHash(optarg);
+			break; case 'I': filterAdd(Hot, optarg);
+			break; case 'N': utilPush(&uiNotifyUtil, optarg);
+			break; case 'O': utilPush(&urlOpenUtil, optarg);
+			break; case 'R': self.restricted = true;
+			break; case 'S': bind = optarg;
+			break; case 'T': {
+				windowTime.enable = true;
+				if (optarg) windowTime.format = optarg;
+			}
+			break; case 'a': sasl = true; parsePlain(optarg);
+			break; case 'c': cert = optarg;
+			break; case 'e': sasl = true;
+			break; case 'g': genCert(optarg);
+			break; case 'h': host = optarg;
+			break; case 'i': filterAdd(Ice, optarg);
+			break; case 'j': self.join = optarg;
+			break; case 'k': priv = optarg;
+			break; case 'l': log = true; logOpen();
+			break; case 'm': self.mode = optarg;
+			break; case 'n': {
+				for (uint i = 0; i < ARRAY_LEN(self.nicks); ++i) {
+					self.nicks[i] = strsep(&optarg, " ");
+				}
+			}
+			break; case 'o': printCert = true;
+			break; case 'p': port = optarg;
+			break; case 'q': windowThreshold = Warm;
+			break; case 'r': real = optarg;
+			break; case 's': save = optarg;
+			break; case 't': trust = optarg;
+			break; case 'u': user = optarg;
+			break; case 'v': self.debug = true;
+			break; case 'w': pass = optarg;
 			break; default:  return EX_USAGE;
 		}
 	}
+	if (!host) errx(EX_USAGE, "host required");
+
+	if (printCert) {
+#ifdef __OpenBSD__
+		int error = pledge("stdio inet dns", NULL);
+		if (error) err(EX_OSERR, "pledge");
+#endif
+		ircConfig(true, NULL, NULL, NULL);
+		ircConnect(bind, host, port);
+		ircPrintCert();
+		ircClose();
+		return EX_OK;
+	}
 
-	if (!self.nick) {
-		const char *user = getenv("USER");
-		if (!user) errx(EX_USAGE, "USER unset");
-		self.nick = dupe(user);
+	if (!self.nicks[0]) self.nicks[0] = getenv("USER");
+	if (!self.nicks[0]) errx(EX_CONFIG, "USER unset");
+	if (!user) user = self.nicks[0];
+	if (!real) real = self.nicks[0];
+
+	if (pass && !pass[0]) {
+		char *buf = malloc(512);
+		if (!buf) err(EX_OSERR, "malloc");
+		pass = readpassphrase("Server password: ", buf, 512, 0);
+		if (!pass) errx(EX_IOERR, "unable to read passphrase");
+	}
+
+	if (self.plainPass && !self.plainPass[0]) {
+		char *buf = malloc(512);
+		if (!buf) err(EX_OSERR, "malloc");
+		self.plainPass = readpassphrase("Account password: ", buf, 512, 0);
+		if (!self.plainPass) errx(EX_IOERR, "unable to read passphrase");
 	}
 
-	if (!self.host) self.host = prompt("Host: ");
-	if (!self.port) self.port = dupe("6697");
-	if (!self.user) self.user = dupe(self.nick);
-	if (!self.real) self.real = dupe(self.nick);
+	// Modes defined in RFC 1459:
+	set(&network.chanTypes, "#&");
+	set(&network.prefixes, "@+");
+	set(&network.prefixModes, "ov");
+	set(&network.listModes, "b");
+	set(&network.paramModes, "k");
+	set(&network.setParamModes, "l");
+	set(&network.channelModes, "imnpst");
+
+	set(&network.name, host);
+	set(&self.nick, "*");
+
+	inputCompletion();
+
+	ircConfig(insecure, trust, cert, priv);
 
-	inputTab();
 	uiInit();
-	eventLoop();
+	sig_t cursesWinch = signal(SIGWINCH, signalHandler);
+	if (save) {
+		uiLoad(save);
+		atexit(exitSave);
+	}
+	windowShow(windowFor(Network));
+	uiFormat(
+		Network, Cold, NULL,
+		"\3%dcatgirl\3\tis GPLv3 fwee softwawe ^w^  "
+		"code is avaiwable fwom https://git.causal.agency/catgirl",
+		Pink
+	);
+	uiFormat(Network, Cold, NULL, "Traveling...");
+	uiDraw();
+
+	sandboxEarly(log);
+	int irc = ircConnect(bind, host, port);
+	sandboxLate(irc);
+
+	ircHandshake();
+	if (pass) {
+		ircFormat("PASS :");
+		ircSend(pass, strlen(pass));
+		ircFormat("\r\n");
+		explicit_bzero(pass, strlen(pass));
+	}
+	if (sasl) ircFormat("CAP REQ :sasl\r\n");
+	ircFormat("CAP LS\r\n");
+	ircFormat("NICK %s\r\n", self.nicks[0]);
+	ircFormat("USER %s 0 * :%s\r\n", user, real);
+
+	// Avoid disabling VINTR until main loop.
+	inputInit();
+	signal(SIGHUP, signalHandler);
+	signal(SIGINT, signalHandler);
+	signal(SIGALRM, signalHandler);
+	signal(SIGTERM, signalHandler);
+	signal(SIGCHLD, signalHandler);
+
+	if (!self.restricted) {
+		int error = pipe(utilPipe) || pipe(execPipe);
+		if (error) err(EX_OSERR, "pipe");
+
+		fcntl(utilPipe[0], F_SETFD, FD_CLOEXEC);
+		fcntl(utilPipe[1], F_SETFD, FD_CLOEXEC);
+		fcntl(execPipe[0], F_SETFD, FD_CLOEXEC);
+		fcntl(execPipe[1], F_SETFD, FD_CLOEXEC);
+	}
+
+	bool ping = false;
+	struct pollfd fds[] = {
+		{ .events = POLLIN, .fd = STDIN_FILENO },
+		{ .events = POLLIN, .fd = irc },
+		{ .events = POLLIN, .fd = utilPipe[0] },
+		{ .events = POLLIN, .fd = execPipe[0] },
+	};
+	while (!self.quit) {
+		int nfds = poll(fds, (self.restricted ? 2 : ARRAY_LEN(fds)), -1);
+		if (nfds < 0 && errno != EINTR) err(EX_IOERR, "poll");
+		if (nfds > 0) {
+			if (fds[0].revents) inputRead();
+			if (fds[1].revents) ircRecv();
+			if (fds[2].revents) utilRead();
+			if (fds[3].revents) execRead();
+		}
+
+		if (signals[SIGHUP]) self.quit = "zzz";
+		if (signals[SIGINT] || signals[SIGTERM]) break;
+
+		if (nfds > 0 && fds[1].revents) {
+			ping = false;
+			struct itimerval timer = {
+				.it_value.tv_sec = 2 * 60,
+				.it_interval.tv_sec = 30,
+			};
+			int error = setitimer(ITIMER_REAL, &timer, NULL);
+			if (error) err(EX_OSERR, "setitimer");
+		}
+		if (signals[SIGALRM]) {
+			signals[SIGALRM] = 0;
+			if (ping) {
+				errx(EX_UNAVAILABLE, "ping timeout");
+			} else {
+				ircFormat("PING nyaa\r\n");
+				ping = true;
+			}
+		}
+
+		if (signals[SIGCHLD]) {
+			signals[SIGCHLD] = 0;
+			for (int status; 0 < waitpid(-1, &status, WNOHANG);) {
+				if (WIFEXITED(status) && WEXITSTATUS(status)) {
+					uiFormat(
+						Network, Warm, NULL,
+						"Process exits with status %d", WEXITSTATUS(status)
+					);
+				} else if (WIFSIGNALED(status)) {
+					uiFormat(
+						Network, Warm, NULL,
+						"Process terminates from %s",
+						strsignal(WTERMSIG(status))
+					);
+				}
+			}
+			uiShow();
+		}
+
+		if (signals[SIGWINCH]) {
+			signals[SIGWINCH] = 0;
+			cursesWinch(SIGWINCH);
+			// doupdate(3) needs to be called for KEY_RESIZE to be picked up.
+			uiDraw();
+			inputRead();
+		}
+
+		uiDraw();
+	}
+
+	if (self.quit) {
+		ircFormat("QUIT :%s\r\n", self.quit);
+	} else {
+		ircFormat("QUIT\r\n");
+	}
+	struct Message msg = {
+		.nick = self.nick,
+		.user = self.user,
+		.cmd = "QUIT",
+		.params[0] = self.quit,
+	};
+	handle(&msg);
+
+	ircClose();
+	logClose();
+	uiHide();
 }
diff --git a/chat.h b/chat.h
index ee7a087..2a41cf6 100644
--- a/chat.h
+++ b/chat.h
@@ -1,220 +1,462 @@
-/* Copyright (C) 2018  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 Affero General Public License as published by
+ * 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 Affero General Public License for more details.
+ * GNU General Public License for more details.
  *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ * 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 SOURCE_URL "https://git.causal.agency/catgirl"
-
+#include <assert.h>
+#include <ctype.h>
+#include <err.h>
+#include <getopt.h>
 #include <stdarg.h>
 #include <stdbool.h>
+#include <stdint.h>
 #include <stdio.h>
-#include <stdlib.h>
-#include <stdnoreturn.h>
+#include <string.h>
+#include <strings.h>
+#include <sysexits.h>
 #include <time.h>
 #include <wchar.h>
 
-#define MIN(a, b) ((a) < (b) ? (a) : (b))
-#define MAX(a, b) ((a) > (b) ? (a) : (b))
-
-#define err(...) do { uiHide(); err(__VA_ARGS__); } while (0)
-#define errx(...) do { uiHide(); errx(__VA_ARGS__); } while (0)
+#define ARRAY_LEN(a) (sizeof(a) / sizeof(a[0]))
+#define BIT(x) x##Bit, x = 1 << x##Bit, x##Bit_ = x##Bit
 
 typedef unsigned uint;
 typedef unsigned char byte;
 
-struct {
-	char *host;
-	char *port;
-	char *auth;
-	char *pass;
+static inline char *seprintf(char *ptr, char *end, const char *fmt, ...)
+	__attribute__((format(printf, 3, 4)));
+static inline char *seprintf(char *ptr, char *end, const char *fmt, ...) {
+	va_list ap;
+	va_start(ap, fmt);
+	int n = vsnprintf(ptr, end - ptr, fmt, ap);
+	va_end(ap);
+	if (n < 0) return NULL;
+	if (n > end - ptr) return end;
+	return ptr + n;
+}
+
+enum Attr {
+	BIT(Bold),
+	BIT(Reverse),
+	BIT(Italic),
+	BIT(Underline),
+};
+enum Color {
+	White, Black, Blue, Green, Red, Brown, Magenta, Orange,
+	Yellow, LightGreen, Cyan, LightCyan, LightBlue, Pink, Gray, LightGray,
+	Default = 99,
+	ColorCap,
+};
+struct Style {
+	enum Attr attr;
+	enum Color fg, bg;
+};
+
+static const struct Style StyleDefault = { 0, Default, Default };
+enum { B = '\2', C = '\3', O = '\17', R = '\26', I = '\35', U = '\37' };
+
+static inline size_t styleParse(struct Style *style, const char **str) {
+	switch (**str) {
+		break; case B: (*str)++; style->attr ^= Bold;
+		break; case O: (*str)++; *style = StyleDefault;
+		break; case R: (*str)++; style->attr ^= Reverse;
+		break; case I: (*str)++; style->attr ^= Italic;
+		break; case U: (*str)++; style->attr ^= Underline;
+		break; case C: {
+			(*str)++;
+			if (!isdigit(**str)) {
+				style->fg = Default;
+				style->bg = Default;
+				break;
+			}
+			style->fg = *(*str)++ - '0';
+			if (isdigit(**str)) style->fg = style->fg * 10 + *(*str)++ - '0';
+			if ((*str)[0] != ',' || !isdigit((*str)[1])) break;
+			(*str)++;
+			style->bg = *(*str)++ - '0';
+			if (isdigit(**str)) style->bg = style->bg * 10 + *(*str)++ - '0';
+		}
+	}
+	return strcspn(*str, (const char[]) { B, C, O, R, I, U, '\0' });
+}
+
+static inline void styleStrip(char *buf, size_t cap, const char *str) {
+	*buf = '\0';
+	char *ptr = buf, *end = &buf[cap];
+	struct Style style = StyleDefault;
+	while (*str) {
+		size_t len = styleParse(&style, &str);
+		ptr = seprintf(ptr, end, "%.*s", (int)len, str);
+		str += len;
+	}
+}
+
+enum { None, Debug, Network, IDCap = 256 };
+extern char *idNames[IDCap];
+extern enum Color idColors[IDCap];
+extern uint idNext;
+
+static inline uint idFind(const char *name) {
+	for (uint id = 0; id < idNext; ++id) {
+		if (!strcasecmp(idNames[id], name)) return id;
+	}
+	return None;
+}
+
+static inline uint idFor(const char *name) {
+	uint id = idFind(name);
+	if (id) return id;
+	if (idNext == IDCap) return Network;
+	idNames[idNext] = strdup(name);
+	idColors[idNext] = Default;
+	if (!idNames[idNext]) err(EX_OSERR, "strdup");
+	return idNext++;
+}
+
+extern uint32_t hashInit;
+extern uint32_t hashBound;
+static inline uint32_t _hash(const char *str) {
+	if (*str == '~') str++;
+	uint32_t hash = hashInit;
+	for (; *str; ++str) {
+		hash = (hash << 5) | (hash >> 27);
+		hash ^= *str;
+		hash *= 0x27220A95;
+	}
+	return hash;
+}
+static inline enum Color hash(const char *str) {
+	if (hashBound < Blue) return Default;
+	return Blue + _hash(str) % (hashBound + 1 - Blue);
+}
+
+extern struct Network {
+	char *name;
+	uint userLen;
+	uint hostLen;
+	char *chanTypes;
+	char *statusmsg;
+	char *prefixes;
+	char *prefixModes;
+	char *listModes;
+	char *paramModes;
+	char *setParamModes;
+	char *channelModes;
+	char excepts;
+	char invex;
+} network;
+
+static inline uint prefixBit(char p) {
+	char *s = strchr(network.prefixes, p);
+	if (!s) return 0;
+	return 1 << (s - network.prefixes);
+}
+
+static inline char bitPrefix(uint p) {
+	for (uint i = 0; network.prefixes[i]; ++i) {
+		if (p & (1 << i)) return network.prefixes[i];
+	}
+	return '\0';
+}
+
+#define ENUM_CAP \
+	X("causal.agency/consumer", CapConsumer) \
+	X("chghost", CapChghost) \
+	X("extended-join", CapExtendedJoin) \
+	X("invite-notify", CapInviteNotify) \
+	X("message-tags", CapMessageTags) \
+	X("multi-prefix", CapMultiPrefix) \
+	X("sasl", CapSASL) \
+	X("server-time", CapServerTime) \
+	X("setname", CapSetname) \
+	X("userhost-in-names", CapUserhostInNames) \
+	X("znc.in/self-message", CapSelfMessage)
+
+enum Cap {
+#define X(name, id) BIT(id),
+	ENUM_CAP
+#undef X
+};
+
+extern struct Self {
+	bool debug;
+	bool restricted;
+	size_t pos;
+	enum Cap caps;
+	const char *plainUser;
+	char *plainPass;
+	const char *nicks[8];
+	const char *mode;
+	const char *join;
 	char *nick;
 	char *user;
-	char *real;
-	char *join;
-	char *keys;
-	bool limit;
-	bool raw;
-	bool notify;
-	bool quit;
+	char *host;
+	enum Color color;
+	char *invited;
+	char *quit;
 } self;
 
-void eventWait(const char *argv[static 2]);
-void eventPipe(const char *argv[static 2]);
-noreturn void eventLoop(void);
+static inline void set(char **field, const char *value) {
+	free(*field);
+	*field = strdup(value);
+	if (!*field) err(EX_OSERR, "strdup");
+}
+
+#define ENUM_TAG \
+	X("+draft/reply", TagReply) \
+	X("causal.agency/pos", TagPos) \
+	X("msgid", TagMsgID) \
+	X("time", TagTime)
 
-struct Tag {
-	size_t id;
-	const char *name;
+enum Tag {
+#define X(name, id) id,
+	ENUM_TAG
+#undef X
+	TagCap,
 };
 
-enum { TagsLen = 256 };
-const struct Tag TagNone;
-const struct Tag TagStatus;
-const struct Tag TagRaw;
-struct Tag tagFind(const char *name);
-struct Tag tagFor(const char *name);
-
-enum IRCColor {
-	IRCWhite,
-	IRCBlack,
-	IRCBlue,
-	IRCGreen,
-	IRCRed,
-	IRCBrown,
-	IRCMagenta,
-	IRCOrange,
-	IRCYellow,
-	IRCLightGreen,
-	IRCCyan,
-	IRCLightCyan,
-	IRCLightBlue,
-	IRCPink,
-	IRCGray,
-	IRCLightGray,
-	IRCDefault = 99,
+enum { ParamCap = 254 };
+struct Message {
+	char *tags[TagCap];
+	char *nick;
+	char *user;
+	char *host;
+	char *cmd;
+	char *params[ParamCap];
 };
-enum {
-	IRCBold      = 002,
-	IRCColor     = 003,
-	IRCReverse   = 026,
-	IRCReset     = 017,
-	IRCItalic    = 035,
-	IRCUnderline = 037,
+
+void ircConfig(
+	bool insecure, const char *trust, const char *cert, const char *priv
+);
+int ircConnect(const char *bind, const char *host, const char *port);
+void ircHandshake(void);
+void ircPrintCert(void);
+void ircRecv(void);
+void ircSend(const char *ptr, size_t len);
+void ircFormat(const char *format, ...)
+	__attribute__((format(printf, 1, 2)));
+void ircClose(void);
+
+extern uint execID;
+extern int execPipe[2];
+extern int utilPipe[2];
+
+enum { UtilCap = 16 };
+struct Util {
+	uint argc;
+	const char *argv[UtilCap];
 };
 
-struct Format {
-	const wchar_t *str;
-	size_t len;
-	bool split;
-	bool bold, italic, underline, reverse;
-	enum IRCColor fg, bg;
+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");
+	}
+}
+
+enum Reply {
+	ReplyAway = 1,
+	ReplyBan,
+	ReplyExcepts,
+	ReplyHelp,
+	ReplyInvex,
+	ReplyJoin,
+	ReplyList,
+	ReplyMode,
+	ReplyNames,
+	ReplyNamesAuto,
+	ReplyTopic,
+	ReplyTopicAuto,
+	ReplyWhois,
+	ReplyWhowas,
+	ReplyCap,
 };
-void formatReset(struct Format *format);
-bool formatParse(struct Format *format, const wchar_t *split);
 
-enum IRCColor colorGen(const char *str);
-struct Tag colorTag(struct Tag tag, const char *gen);
-enum IRCColor colorFor(struct Tag tag);
+extern uint replies[ReplyCap];
 
-void handle(char *line);
-void input(struct Tag tag, char *line);
-void inputTab(void);
+void handle(struct Message *msg);
+void command(uint id, char *input);
+const char *commandIsPrivmsg(uint id, const char *input);
+const char *commandIsNotice(uint id, const char *input);
+const char *commandIsAction(uint id, const char *input);
+size_t commandWillSplit(uint id, const char *input);
+void commandCompletion(void);
 
-int ircConnect(void);
-void ircRead(void);
-void ircWrite(const char *ptr, size_t len);
-void ircFmt(const char *format, ...) __attribute__((format(printf, 1, 2)));
-void ircQuit(const char *mesg);
+enum Heat {
+	Ice,
+	Cold,
+	Warm,
+	Hot,
+};
 
+enum {
+	TitleCap = 256,
+	StatusLines = 1,
+	MarkerLines = 1,
+	SplitLines = 5,
+	InputLines = 1,
+	InputCols = 1024,
+};
+extern char uiTitle[TitleCap];
+extern struct _win_st *uiStatus;
+extern struct _win_st *uiMain;
+extern struct _win_st *uiInput;
+extern bool uiSpoilerReveal;
+extern struct Util uiNotifyUtil;
 void uiInit(void);
+uint uiAttr(struct Style style);
+short uiPair(struct Style style);
 void uiShow(void);
 void uiHide(void);
 void uiDraw(void);
-void uiRead(void);
-void uiExit(int status);
-
-void uiPrompt(bool nickChanged);
-void uiShowTag(struct Tag tag);
-void uiShowNum(int num, bool relative);
-void uiMoveTag(struct Tag tag, int num, bool relative);
-void uiCloseTag(struct Tag tag);
-
-enum UIHeat {
-	UICold,
-	UIWarm,
-	UIHot,
-};
-void uiLog(struct Tag tag, enum UIHeat heat, const wchar_t *str);
-void uiFmt(struct Tag tag, enum UIHeat heat, const wchar_t *format, ...);
+void uiResize(void);
+void uiWrite(uint id, enum Heat heat, const time_t *time, const char *str);
+void uiFormat(
+	uint id, enum Heat heat, const time_t *time, const char *format, ...
+) __attribute__((format(printf, 4, 5)));
+void uiLoad(const char *name);
+int uiSave(void);
 
-enum TermMode {
-	TermFocus,
-	TermPaste,
+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,
 };
-enum TermEvent {
-	TermNone,
-	TermFocusIn,
-	TermFocusOut,
-	TermPasteStart,
-	TermPasteEnd,
+extern struct Time {
+	bool enable;
+	const char *format;
+	int width;
+} windowTime;
+extern enum Heat windowThreshold;
+void windowInit(void);
+void windowUpdate(void);
+void windowResize(void);
+bool windowWrite(uint id, enum Heat heat, const time_t *time, const char *str);
+void windowBare(void);
+uint windowID(void);
+uint windowNum(void);
+uint windowFor(uint id);
+void windowShow(uint num);
+void windowAuto(void);
+void windowSwap(void);
+void windowMove(uint from, uint to);
+void windowClose(uint num);
+void windowList(void);
+void windowMark(void);
+void windowUnmark(void);
+void windowToggleMute(void);
+void windowToggleTime(void);
+void windowToggleThresh(int n);
+bool windowTimeEnable(void);
+void windowScroll(enum Scroll by, int n);
+void windowSearch(const char *str, int dir);
+int windowSave(FILE *file);
+void windowLoad(FILE *file, size_t version);
+
+enum { BufferCap = 1024 };
+struct Buffer;
+struct Line {
+	uint num;
+	enum Heat heat;
+	time_t time;
+	char *str;
 };
-void termInit(void);
-void termNoFlow(void);
-void termTitle(const char *title);
-void termMode(enum TermMode mode, bool set);
-enum TermEvent termEvent(char ch);
-
-enum Edit {
-	EditLeft,
-	EditRight,
-	EditHome,
-	EditEnd,
-	EditBackWord,
-	EditForeWord,
-	EditInsert,
-	EditBackspace,
-	EditDelete,
-	EditKill,
-	EditKillBackWord,
-	EditKillForeWord,
-	EditKillEnd,
-	EditComplete,
-	EditEnter,
+struct Buffer *bufferAlloc(void);
+void bufferFree(struct Buffer *buffer);
+const struct Line *bufferSoft(const struct Buffer *buffer, size_t i);
+const struct Line *bufferHard(const struct Buffer *buffer, size_t i);
+int bufferPush(
+	struct Buffer *buffer, int cols, enum Heat thresh,
+	enum Heat heat, time_t time, const char *str
+);
+int bufferReflow(
+	struct Buffer *buffer, int cols, enum Heat thresh, size_t tail
+);
+
+struct Cursor {
+	uint gen;
+	struct Node *node;
 };
-void edit(struct Tag tag, enum Edit op, wchar_t ch);
-const wchar_t *editHead(void);
-const wchar_t *editTail(void);
-
-void tabTouch(struct Tag tag, const char *word);
-void tabAdd(struct Tag tag, const char *word);
-void tabRemove(struct Tag tag, const char *word);
-void tabReplace(struct Tag tag, const char *prev, const char *next);
-void tabClear(struct Tag tag);
-struct Tag tabTag(const char *word);
-const char *tabNext(struct Tag tag, const char *prefix);
-void tabAccept(void);
-void tabReject(void);
-
-void urlScan(struct Tag tag, const char *str);
-void urlList(struct Tag tag);
-void urlOpenMatch(struct Tag tag, const char *substr);
-void urlOpenRange(struct Tag tag, size_t at, size_t to);
-
-void logOpen(const char *path);
-void logFmt(
-	struct Tag tag, const time_t *ts, const char *format, ...
-) __attribute__((format(printf, 3, 4)));
-void logList(struct Tag tag);
-void logReplay(struct Tag tag);
-
-wchar_t *wcsnchr(const wchar_t *wcs, size_t len, wchar_t chr);
-wchar_t *wcsnrchr(const wchar_t *wcs, size_t len, wchar_t chr);
-wchar_t *ambstowcs(const char *src);
-char *awcstombs(const wchar_t *src);
-char *awcsntombs(const wchar_t *src, size_t nwc);
-int vaswprintf(wchar_t **ret, const wchar_t *format, va_list ap);
-int aswprintf(wchar_t **ret, const wchar_t *format, ...);
-
-size_t base64Size(size_t len);
-void base64(char *dst, const byte *src, size_t len);
-
-// HACK: clang won't check wchar_t *format strings.
-#ifdef NDEBUG
-#define uiFmt(tag, heat, format, ...) uiFmt(tag, heat, L##format, __VA_ARGS__)
-#else
-#define uiFmt(tag, heat, format, ...) do { \
-	snprintf(NULL, 0, format, __VA_ARGS__); \
-	uiFmt(tag, heat, L##format, __VA_ARGS__); \
-} while(0)
-#endif
+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);
+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;
+void urlScan(uint id, const char *nick, const char *mesg);
+void urlOpenCount(uint id, uint count);
+void urlOpenMatch(uint id, const char *str);
+void urlCopyMatch(uint id, const char *str);
+int urlSave(FILE *file);
+void urlLoad(FILE *file, size_t version);
+
+enum { FilterCap = 64 };
+extern struct Filter {
+	enum Heat heat;
+	char *mask;
+	char *cmd;
+	char *chan;
+	char *mesg;
+} filters[FilterCap];
+struct Filter filterParse(enum Heat heat, char *pattern);
+struct Filter filterAdd(enum Heat heat, const char *pattern);
+bool filterRemove(struct Filter filter);
+enum Heat filterCheck(enum Heat heat, uint id, const struct Message *msg);
+
+void logOpen(void);
+void logFormat(uint id, const time_t *time, const char *format, ...)
+	__attribute__((format(printf, 3, 4)));
+void logClose(void);
+
+char *configPath(char *buf, size_t cap, const char *path, int i);
+char *dataPath(char *buf, size_t cap, const char *path, int i);
+FILE *configOpen(const char *path, const char *mode);
+FILE *dataOpen(const char *path, const char *mode);
+
+int getopt_config(
+	int argc, char *const *argv,
+	const char *optstring, const struct option *longopts, int *longindex
+);
diff --git a/color.c b/color.c
deleted file mode 100644
index dad5647..0000000
--- a/color.c
+++ /dev/null
@@ -1,51 +0,0 @@
-/* Copyright (C) 2019  C. McEnroe <june@causal.agency>
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-#include <stdint.h>
-
-#include "chat.h"
-
-// Adapted from <https://github.com/cbreeden/fxhash/blob/master/lib.rs>.
-static uint32_t hashChar(uint32_t hash, char ch) {
-	hash = (hash << 5) | (hash >> 27);
-	hash ^= ch;
-	hash *= 0x27220A95;
-	return hash;
-}
-
-enum IRCColor colorGen(const char *str) {
-	if (!str) return IRCDefault;
-	uint32_t hash = 0;
-	if (*str == '~') str++;
-	for (; *str; ++str) {
-		hash = hashChar(hash, *str);
-	}
-	while (IRCBlack == (hash & IRCLightGray)) {
-		hash = hashChar(hash, '\0');
-	}
-	return (hash & IRCLightGray);
-}
-
-static enum IRCColor colors[TagsLen];
-
-struct Tag colorTag(struct Tag tag, const char *gen) {
-	if (!colors[tag.id]) colors[tag.id] = 1 + colorGen(gen);
-	return tag;
-}
-
-enum IRCColor colorFor(struct Tag tag) {
-	return colors[tag.id] ? colors[tag.id] - 1 : IRCDefault;
-}
diff --git a/command.c b/command.c
new file mode 100644
index 0000000..502ff17
--- /dev/null
+++ b/command.c
@@ -0,0 +1,730 @@
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
+ */
+
+#include <assert.h>
+#include <ctype.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "chat.h"
+
+typedef void Command(uint id, char *params);
+
+static void commandDebug(uint id, char *params) {
+	(void)id;
+	(void)params;
+	self.debug ^= true;
+	uiFormat(
+		Debug, Warm, NULL,
+		"\3%dDebug is %s", Gray, (self.debug ? "on" : "off")
+	);
+}
+
+static void commandQuote(uint id, char *params) {
+	(void)id;
+	if (params) ircFormat("%s\r\n", params);
+}
+
+static void echoMessage(char *cmd, uint id, char *params) {
+	if (!params) return;
+	ircFormat("%s %s :%s\r\n", cmd, idNames[id], params);
+	struct Message msg = {
+		.nick = self.nick,
+		.user = self.user,
+		.cmd = cmd,
+		.params[0] = idNames[id],
+		.params[1] = params,
+	};
+	handle(&msg);
+}
+
+static int splitChunk(const char *cmd, uint id) {
+	int overhead = snprintf(
+		NULL, 0, ":%s!%*s@%*s %s %s :\r\n",
+		self.nick,
+		(self.user ? 0 : network.userLen), (self.user ?: "*"),
+		(self.host ? 0 : network.hostLen), (self.host ?: "*"),
+		cmd, idNames[id]
+	);
+	assert(overhead > 0 && overhead < 512);
+	return 512 - overhead;
+}
+
+static int splitLen(int chunk, const char *params) {
+	int len = 0;
+	size_t cap = 1 + strlen(params);
+	for (int n = 0; params[len] != '\n' && len + n <= chunk; len += n) {
+		n = mblen(&params[len], cap - len);
+		if (n < 0) {
+			n = 1;
+			mblen(NULL, 0);
+		}
+		if (!n) break;
+	}
+	return len;
+}
+
+static void splitMessage(char *cmd, uint id, char *params) {
+	if (!params) return;
+	int chunk = splitChunk(cmd, id);
+	if (strlen(params) <= (size_t)chunk && !strchr(params, '\n')) {
+		echoMessage(cmd, id, params);
+		return;
+	}
+	while (*params) {
+		int len = splitLen(chunk, params);
+		char ch = params[len];
+		params[len] = '\0';
+		echoMessage(cmd, id, params);
+		params[len] = ch;
+		params += len;
+		if (ch == '\n') params++;
+	}
+}
+
+static void commandPrivmsg(uint id, char *params) {
+	splitMessage("PRIVMSG", id, params);
+}
+
+static void commandNotice(uint id, char *params) {
+	splitMessage("NOTICE", id, params);
+}
+
+static void commandMe(uint id, char *params) {
+	char buf[512];
+	if (!params) params = "";
+	int chunk = splitChunk("PRIVMSG \1ACTION\1", id);
+	if (strlen(params) <= (size_t)chunk && !strchr(params, '\n')) {
+		snprintf(buf, sizeof(buf), "\1ACTION %s\1", params);
+		echoMessage("PRIVMSG", id, buf);
+		return;
+	}
+	while (*params) {
+		int len = splitLen(chunk, params);
+		snprintf(buf, sizeof(buf), "\1ACTION %.*s\1", len, params);
+		echoMessage("PRIVMSG", id, buf);
+		params += len;
+		if (*params == '\n') params++;
+	}
+}
+
+static void commandMsg(uint id, char *params) {
+	if (!params) return;
+	char *nick = strsep(&params, " ");
+	uint msg = idFor(nick);
+	if (idColors[msg] == Default) {
+		idColors[msg] = completeColor(id, nick);
+	}
+	if (params) {
+		splitMessage("PRIVMSG", msg, params);
+	} else {
+		windowShow(windowFor(msg));
+	}
+}
+
+static void commandJoin(uint id, char *params) {
+	if (!params && id == Network) params = self.invited;
+	if (!params) params = idNames[id];
+	uint count = 1;
+	for (char *ch = params; *ch && *ch != ' '; ++ch) {
+		if (*ch == ',') count++;
+	}
+	ircFormat("JOIN %s\r\n", params);
+	replies[ReplyJoin] += count;
+	replies[ReplyTopic] += count;
+	replies[ReplyNames] += count;
+}
+
+static void commandPart(uint id, char *params) {
+	if (params) {
+		ircFormat("PART %s :%s\r\n", idNames[id], params);
+	} else {
+		ircFormat("PART %s\r\n", idNames[id]);
+	}
+}
+
+static void commandQuit(uint id, char *params) {
+	(void)id;
+	set(&self.quit, (params ?: "nyaa~"));
+}
+
+static void commandNick(uint id, char *params) {
+	(void)id;
+	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) {
+	(void)id;
+	if (params) {
+		ircFormat("AWAY :%s\r\n", params);
+	} else {
+		ircFormat("AWAY\r\n");
+	}
+	replies[ReplyAway]++;
+}
+
+static void commandSetname(uint id, char *params) {
+	(void)id;
+	if (!params) return;
+	ircFormat("SETNAME :%s\r\n", params);
+}
+
+static void commandTopic(uint id, char *params) {
+	if (params) {
+		ircFormat("TOPIC %s :%s\r\n", idNames[id], params);
+	} else {
+		ircFormat("TOPIC %s\r\n", idNames[id]);
+		replies[ReplyTopic]++;
+	}
+}
+
+static void commandNames(uint id, char *params) {
+	(void)params;
+	ircFormat("NAMES %s\r\n", idNames[id]);
+	replies[ReplyNames]++;
+}
+
+static void commandOps(uint id, char *params) {
+	(void)params;
+	char buf[1024];
+	char *ptr = buf, *end = &buf[sizeof(buf)];
+	ptr = seprintf(
+		ptr, end, "The council of \3%02d%s\3 are ",
+		idColors[id], idNames[id]
+	);
+	bool first = true;
+	struct Cursor curs = {0};
+	for (const char *nick; (nick = completeEach(&curs, id));) {
+		char prefix = bitPrefix(*completeBits(id, nick));
+		if (!prefix || prefix == '+') continue;
+		ptr = seprintf(
+			ptr, end, "%s\3%02d%c%s\3",
+			(first ? "" : ", "), completeColor(id, nick), prefix, nick
+		);
+		first = false;
+	}
+	if (first) {
+		uiFormat(
+			id, Warm, NULL, "\3%02d%s\3 is a lawless wasteland",
+			idColors[id], idNames[id]
+		);
+	} else {
+		uiWrite(id, Warm, NULL, buf);
+	}
+}
+
+static void commandInvite(uint id, char *params) {
+	if (!params) return;
+	char *nick = strsep(&params, " ");
+	ircFormat("INVITE %s %s\r\n", nick, idNames[id]);
+}
+
+static void commandKick(uint id, char *params) {
+	if (!params) return;
+	char *nick = strsep(&params, " ");
+	if (params) {
+		ircFormat("KICK %s %s :%s\r\n", idNames[id], nick, params);
+	} else {
+		ircFormat("KICK %s %s\r\n", idNames[id], nick);
+	}
+}
+
+static void commandMode(uint id, char *params) {
+	if (id == Network) {
+		if (params) {
+			ircFormat("MODE %s %s\r\n", self.nick, params);
+		} else {
+			ircFormat("MODE %s\r\n", self.nick);
+			replies[ReplyMode]++;
+		}
+	} else {
+		if (params) {
+			if (!params[1] || (params[0] == '+' && !params[2])) {
+				char m = (params[0] == '+' ? params[1] : params[0]);
+				if (m == 'b') replies[ReplyBan]++;
+				if (m == network.excepts) replies[ReplyExcepts]++;
+				if (m == network.invex) replies[ReplyInvex]++;
+			}
+			ircFormat("MODE %s %s\r\n", idNames[id], params);
+		} else {
+			ircFormat("MODE %s\r\n", idNames[id]);
+			replies[ReplyMode]++;
+		}
+	}
+}
+
+static void channelListMode(uint id, char pm, char l, const char *params) {
+	int count = 1;
+	for (const char *ch = params; *ch; ++ch) {
+		if (*ch == ' ') count++;
+	}
+	char modes[13 + 1] = { l, l, l, l, l, l, l, l, l, l, l, l, l, '\0' };
+	ircFormat("MODE %s %c%.*s %s\r\n", idNames[id], pm, count, modes, params);
+}
+
+static void commandOp(uint id, char *params) {
+	if (params) {
+		channelListMode(id, '+', 'o', params);
+	} else {
+		ircFormat("CS OP %s\r\n", idNames[id]);
+	}
+}
+
+static void commandDeop(uint id, char *params) {
+	channelListMode(id, '-', 'o', (params ?: self.nick));
+}
+
+static void commandVoice(uint id, char *params) {
+	if (params) {
+		channelListMode(id, '+', 'v', params);
+	} else {
+		ircFormat("CS VOICE %s\r\n", idNames[id]);
+	}
+}
+
+static void commandDevoice(uint id, char *params) {
+	channelListMode(id, '-', 'v', (params ?: self.nick));
+}
+
+static void commandBan(uint id, char *params) {
+	if (params) {
+		channelListMode(id, '+', 'b', params);
+	} else {
+		ircFormat("MODE %s b\r\n", idNames[id]);
+		replies[ReplyBan]++;
+	}
+}
+
+static void commandUnban(uint id, char *params) {
+	if (!params) return;
+	channelListMode(id, '-', 'b', params);
+}
+
+static void commandExcept(uint id, char *params) {
+	if (params) {
+		channelListMode(id, '+', network.excepts, params);
+	} else {
+		ircFormat("MODE %s %c\r\n", idNames[id], network.excepts);
+		replies[ReplyExcepts]++;
+	}
+}
+
+static void commandUnexcept(uint id, char *params) {
+	if (!params) return;
+	channelListMode(id, '-', network.excepts, params);
+}
+
+static void commandInvex(uint id, char *params) {
+	if (params) {
+		channelListMode(id, '+', network.invex, params);
+	} else {
+		ircFormat("MODE %s %c\r\n", idNames[id], network.invex);
+		replies[ReplyInvex]++;
+	}
+}
+
+static void commandUninvex(uint id, char *params) {
+	if (!params) return;
+	channelListMode(id, '-', network.invex, params);
+}
+
+static void commandList(uint id, char *params) {
+	(void)id;
+	if (params) {
+		ircFormat("LIST :%s\r\n", params);
+	} else {
+		ircFormat("LIST\r\n");
+	}
+	replies[ReplyList]++;
+}
+
+static void commandWhois(uint id, char *params) {
+	(void)id;
+	if (!params) params = self.nick;
+	uint count = 1;
+	for (char *ch = params; *ch; ++ch) {
+		if (*ch == ',') count++;
+	}
+	ircFormat("WHOIS %s\r\n", params);
+	replies[ReplyWhois] += count;
+}
+
+static void commandWhowas(uint id, char *params) {
+	(void)id;
+	if (!params) return;
+	ircFormat("WHOWAS %s\r\n", params);
+	replies[ReplyWhowas]++;
+}
+
+static void commandNS(uint id, char *params) {
+	(void)id;
+	ircFormat("NS %s\r\n", (params ?: "HELP"));
+}
+
+static void commandCS(uint id, char *params) {
+	(void)id;
+	ircFormat("CS %s\r\n", (params ?: "HELP"));
+}
+
+static void commandQuery(uint id, char *params) {
+	if (!params) return;
+	uint query = idFor(params);
+	if (idColors[query] == Default) {
+		idColors[query] = completeColor(id, params);
+	}
+	windowShow(windowFor(query));
+}
+
+static void commandWindow(uint id, char *params) {
+	if (!params) {
+		windowList();
+	} else if (isdigit(params[0])) {
+		windowShow(strtoul(params, NULL, 10));
+	} else {
+		id = idFind(params);
+		if (id) {
+			windowShow(windowFor(id));
+			return;
+		}
+		struct Cursor curs = {0};
+		for (const char *str; (str = completeSubstr(&curs, None, params));) {
+			id = idFind(str);
+			if (!id) continue;
+			completeAccept(&curs);
+			windowShow(windowFor(id));
+			break;
+		}
+	}
+}
+
+static void commandMove(uint id, char *params) {
+	if (!params) return;
+	char *name = strsep(&params, " ");
+	if (params) {
+		id = idFind(name);
+		if (id) windowMove(windowFor(id), strtoul(params, NULL, 10));
+	} else {
+		windowMove(windowFor(id), strtoul(name, NULL, 10));
+	}
+}
+
+static void commandClose(uint id, char *params) {
+	if (!params) {
+		windowClose(windowFor(id));
+	} else if (isdigit(params[0])) {
+		windowClose(strtoul(params, NULL, 10));
+	} else {
+		id = idFind(params);
+		if (id) windowClose(windowFor(id));
+	}
+}
+
+static void commandOpen(uint id, char *params) {
+	if (!params) {
+		urlOpenCount(id, 1);
+	} else if (isdigit(params[0]) && !params[1]) {
+		urlOpenCount(id, params[0] - '0');
+	} else {
+		urlOpenMatch(id, params);
+	}
+}
+
+static void commandCopy(uint id, char *params) {
+	urlCopyMatch(id, params);
+}
+
+static void commandFilter(enum Heat heat, uint id, char *params) {
+	if (params) {
+		struct Filter filter = filterAdd(heat, params);
+		uiFormat(
+			id, Cold, NULL, "%sing \3%02d%s %s %s %s",
+			(heat == Hot ? "Highlight" : "Ignor"), Brown, filter.mask,
+			(filter.cmd ?: ""), (filter.chan ?: ""), (filter.mesg ?: "")
+		);
+	} else {
+		for (size_t i = 0; i < FilterCap && filters[i].mask; ++i) {
+			if (filters[i].heat != heat) continue;
+			uiFormat(
+				Network, Warm, NULL, "%sing \3%02d%s %s %s %s",
+				(heat == Hot ? "Highlight" : "Ignor"), Brown, filters[i].mask,
+				(filters[i].cmd ?: ""), (filters[i].chan ?: ""),
+				(filters[i].mesg ?: "")
+			);
+		}
+	}
+}
+
+static void commandUnfilter(enum Heat heat, uint id, char *params) {
+	if (!params) return;
+	struct Filter filter = filterParse(heat, params);
+	bool found = filterRemove(filter);
+	uiFormat(
+		id, Cold, NULL, "%s %sing \3%02d%s %s %s %s",
+		(found ? "No longer" : "Not"), (heat == Hot ? "highlight" : "ignor"),
+		Brown, filter.mask, (filter.cmd ?: ""), (filter.chan ?: ""),
+		(filter.mesg ?: "")
+	);
+}
+
+static void commandHighlight(uint id, char *params) {
+	commandFilter(Hot, id, params);
+}
+static void commandIgnore(uint id, char *params) {
+	commandFilter(Ice, id, params);
+}
+static void commandUnhighlight(uint id, char *params) {
+	commandUnfilter(Hot, id, params);
+}
+static void commandUnignore(uint id, char *params) {
+	commandUnfilter(Ice, id, params);
+}
+
+static void commandExec(uint id, char *params) {
+	execID = id;
+
+	pid_t pid = fork();
+	if (pid < 0) err(EX_OSERR, "fork");
+	if (pid) return;
+
+	setsid();
+	close(STDIN_FILENO);
+	dup2(execPipe[1], STDOUT_FILENO);
+	dup2(utilPipe[1], STDERR_FILENO);
+
+	const char *shell = getenv("SHELL") ?: "/bin/sh";
+	execl(shell, shell, "-c", params, NULL);
+	warn("%s", shell);
+	_exit(EX_UNAVAILABLE);
+}
+
+static void commandHelp(uint id, char *params) {
+	(void)id;
+
+	if (params) {
+		ircFormat("HELP :%s\r\n", params);
+		replies[ReplyHelp]++;
+		return;
+	}
+	if (self.restricted) {
+		uiFormat(id, Warm, NULL, "See catgirl(1) or /help index");
+		return;
+	}
+
+	uiHide();
+	pid_t pid = fork();
+	if (pid < 0) err(EX_OSERR, "fork");
+	if (pid) return;
+
+	char buf[256];
+	snprintf(buf, sizeof(buf), "%sp^COMMANDS$", (getenv("LESS") ?: ""));
+	setenv("LESS", buf, 1);
+	execlp("man", "man", "1", "catgirl", NULL);
+	dup2(utilPipe[1], STDERR_FILENO);
+	warn("man");
+	_exit(EX_UNAVAILABLE);
+}
+
+enum Flag {
+	BIT(Multiline),
+	BIT(Restrict),
+};
+
+static const struct Handler {
+	const char *cmd;
+	Command *fn;
+	enum Flag flags;
+	enum Cap caps;
+} Commands[] = {
+	{ "/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) {
+	const struct Handler *handler = _handler;
+	return strcmp(cmd, handler->cmd);
+}
+
+const char *commandIsPrivmsg(uint id, const char *input) {
+	if (id == Network || id == Debug) return NULL;
+	if (input[0] != '/') return input;
+	const char *space = strchr(&input[1], ' ');
+	const char *slash = strchr(&input[1], '/');
+	if (slash && (!space || slash < space)) return input;
+	return NULL;
+}
+
+const char *commandIsNotice(uint id, const char *input) {
+	if (id == Network || id == Debug) return NULL;
+	if (strncmp(input, "/notice ", 8)) return NULL;
+	return &input[8];
+}
+
+const char *commandIsAction(uint id, const char *input) {
+	if (id == Network || id == Debug) return NULL;
+	if (strncmp(input, "/me ", 4)) return NULL;
+	return &input[4];
+}
+
+size_t commandWillSplit(uint id, const char *input) {
+	int chunk;
+	const char *params;
+	if (NULL != (params = commandIsPrivmsg(id, input))) {
+		chunk = splitChunk("PRIVMSG", id);
+	} else if (NULL != (params = commandIsNotice(id, input))) {
+		chunk = splitChunk("NOTICE", id);
+	} else if (NULL != (params = commandIsAction(id, input))) {
+		chunk = splitChunk("PRIVMSG \1ACTION\1", id);
+	} else if (id != Network && id != Debug && !strncmp(input, "/say ", 5)) {
+		params = &input[5];
+		chunk = splitChunk("PRIVMSG", id);
+	} else {
+		return 0;
+	}
+	if (strlen(params) <= (size_t)chunk) return 0;
+	for (
+		int split;
+		params[(split = splitLen(chunk, params))];
+		params = &params[split + 1]
+	) {
+		if (params[split] == '\n') continue;
+		return (params - input) + split;
+	}
+	return 0;
+}
+
+static bool commandAvailable(const struct Handler *handler) {
+	if (handler->flags & Restrict && self.restricted) return false;
+	if (handler->caps && (handler->caps & self.caps) != handler->caps) {
+		return false;
+	}
+	return true;
+}
+
+void command(uint id, char *input) {
+	if (id == Debug && input[0] != '/' && !self.restricted) {
+		commandQuote(id, input);
+		return;
+	} else if (!input[0]) {
+		return;
+	} else if (commandIsPrivmsg(id, input)) {
+		commandPrivmsg(id, input);
+		return;
+	} else if (input[0] == '/' && isdigit(input[1])) {
+		commandWindow(id, &input[1]);
+		return;
+	}
+
+	struct Cursor curs = {0};
+	const char *cmd = strsep(&input, " ");
+	const char *unique = completePrefix(&curs, None, cmd);
+	if (unique && !completePrefix(&curs, None, cmd)) {
+		cmd = unique;
+	}
+
+	const struct Handler *handler = bsearch(
+		cmd, Commands, ARRAY_LEN(Commands), sizeof(*handler), compar
+	);
+	if (!handler) {
+		uiFormat(id, Warm, NULL, "No such command %s", cmd);
+		return;
+	}
+	if (!commandAvailable(handler)) {
+		uiFormat(id, Warm, NULL, "Command %s is unavailable", cmd);
+		return;
+	}
+
+	if (input) {
+		if (!(handler->flags & Multiline)) {
+			input[strcspn(input, "\n")] = '\0';
+		}
+		input += strspn(input, " ");
+		size_t len = strlen(input);
+		while (input[len - 1] == ' ') input[--len] = '\0';
+		if (!input[0]) input = NULL;
+	}
+	handler->fn(id, input);
+}
+
+void commandCompletion(void) {
+	for (size_t i = 0; i < ARRAY_LEN(Commands); ++i) {
+		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
new file mode 100644
index 0000000..3552c7c
--- /dev/null
+++ b/complete.c
@@ -0,0 +1,209 @@
+/* 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
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
+ */
+
+#include <err.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+
+#include "chat.h"
+
+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 = calloc(1, sizeof(*node));
+	if (!node) err(EX_OSERR, "calloc");
+	node->id = id;
+	node->str = strdup(str);
+	if (!node->str) err(EX_OSERR, "strdup");
+	node->color = color;
+	node->bits = 0;
+	return node;
+}
+
+static struct Node *detach(struct Node *node) {
+	if (node->prev) node->prev->next = node->next;
+	if (node->next) node->next->prev = node->prev;
+	if (head == node) head = node->next;
+	if (tail == node) tail = node->prev;
+	node->prev = NULL;
+	node->next = NULL;
+	return node;
+}
+
+static struct Node *prepend(struct Node *node) {
+	node->prev = NULL;
+	node->next = head;
+	if (head) head->prev = node;
+	head = node;
+	tail = (tail ?: node);
+	return node;
+}
+
+static struct Node *append(struct Node *node) {
+	node->next = NULL;
+	node->prev = tail;
+	if (tail) tail->next = node;
+	tail = node;
+	head = (head ?: node);
+	return node;
+}
+
+static struct Node *find(uint id, const char *str) {
+	for (struct Node *node = head; node; node = node->next) {
+		if (node->id == id && !strcmp(node->str, str)) return node;
+	}
+	return NULL;
+}
+
+void completePush(uint id, const char *str, enum Color color) {
+	struct Node *node = find(id, str);
+	if (node) {
+		if (color != Default) node->color = color;
+	} else {
+		append(alloc(id, str, color));
+	}
+}
+
+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));
+	}
+}
+
+void completeReplace(const char *old, const char *new) {
+	struct Node *next = NULL;
+	for (struct Node *node = head; node; node = next) {
+		next = node->next;
+		if (strcmp(node->str, old)) continue;
+		free(node->str);
+		node->str = strdup(new);
+		if (!node->str) err(EX_OSERR, "strdup");
+		prepend(detach(node));
+	}
+}
+
+void completeRemove(uint id, const char *str) {
+	struct Node *next = NULL;
+	for (struct Node *node = head; node; node = next) {
+		next = node->next;
+		if (id && node->id != id) continue;
+		if (str && strcmp(node->str, str)) continue;
+		detach(node);
+		free(node->str);
+		free(node);
+	}
+	gen++;
+}
+
+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
new file mode 100644
index 0000000..be88f2f
--- /dev/null
+++ b/config.c
@@ -0,0 +1,144 @@
+/* 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
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
+ */
+
+#include <err.h>
+#include <errno.h>
+#include <getopt.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "chat.h"
+
+#define WS "\t "
+
+static const char *path;
+static FILE *file;
+static size_t num;
+static char *line;
+static size_t cap;
+
+static int clean(int opt) {
+	if (file) fclose(file);
+	free(line);
+	line = NULL;
+	cap = 0;
+	return opt;
+}
+
+int getopt_config(
+	int argc, char *const *argv,
+	const char *optstring, const struct option *longopts, int *longindex
+) {
+	static int opt;
+	for (;;) {
+		if (!file) {
+			if (optind == argc) return clean(-1);
+			if (opt >= 0 && argv[optind][0] == '-') {
+				opt = getopt_long(argc, argv, optstring, longopts, longindex);
+				if (opt >= 0 || optind == argc) return clean(opt);
+			}
+			num = 0;
+			path = argv[optind++];
+			file = configOpen(path, "r");
+			if (!file) return clean('?');
+		}
+
+		for (;;) {
+			ssize_t llen = getline(&line, &cap, file);
+			if (ferror(file)) {
+				warn("%s", path);
+				return clean('?');
+			}
+			if (llen <= 0) break;
+			if (line[llen - 1] == '\n') line[llen - 1] = '\0';
+			num++;
+
+			char *name = line + strspn(line, WS);
+			size_t len = strcspn(name, WS "=");
+			if (!name[0] || name[0] == '#') continue;
+
+			const struct option *option;
+			for (option = longopts; option->name; ++option) {
+				if (strlen(option->name) != len) continue;
+				if (!strncmp(option->name, name, len)) break;
+			}
+			if (!option->name) {
+				warnx(
+					"%s:%zu: unrecognized option `%.*s'",
+					path, num, (int)len, name
+				);
+				return clean('?');
+			}
+
+			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",
+					path, num, option->name
+				);
+				return clean('?');
+			}
+			if (option->has_arg == required_argument && !*equal) {
+				warnx(
+					"%s:%zu: option `%s' requires an argument",
+					path, num, option->name
+				);
+				return clean(':');
+			}
+
+			optarg = NULL;
+			if (*equal) {
+				char *arg = &equal[1] + strspn(&equal[1], WS);
+				optarg = strdup(arg);
+				if (!optarg) {
+					warn("getopt_config");
+					return clean('?');
+				}
+			}
+
+			if (longindex) *longindex = option - longopts;
+			if (option->flag) {
+				*option->flag = option->val;
+				return 0;
+			} else {
+				return option->val;
+			}
+		}
+
+		fclose(file);
+		file = NULL;
+	}
+}
diff --git a/configure b/configure
new file mode 100755
index 0000000..07e3245
--- /dev/null
+++ b/configure
@@ -0,0 +1,61 @@
+#!/bin/sh
+set -eu
+
+: ${PKG_CONFIG:=pkg-config}
+
+cflags() {
+	echo "CFLAGS += $*"
+}
+defstr() {
+	cflags "-D'$1=\"$2\"'"
+}
+defvar() {
+	defstr "$1" "$(${PKG_CONFIG} --variable=$3 $2)${4:-}"
+}
+ldadd() {
+	lib=$1; shift
+	echo "LDADD.${lib} = $*"
+}
+config() {
+	${PKG_CONFIG} --print-errors "$@"
+	cflags $(${PKG_CONFIG} --cflags "$@")
+	for lib; do ldadd $lib $(${PKG_CONFIG} --libs $lib); done
+}
+
+exec >config.mk
+
+for opt; do
+	case "${opt}" in
+		(--prefix=*) echo "PREFIX = ${opt#*=}" ;;
+		(--bindir=*) echo "BINDIR = ${opt#*=}" ;;
+		(--mandir=*) echo "MANDIR = ${opt#*=}" ;;
+		(--enable-sandman) echo 'BINS += sandman' ;;
+		(*) echo "warning: unsupported option ${opt}" >&2 ;;
+	esac
+done
+
+case "$(uname)" in
+	(FreeBSD)
+		config libtls
+		defstr OPENSSL_BIN /usr/bin/openssl
+		;;
+	(OpenBSD)
+		defstr OPENSSL_BIN /usr/bin/openssl
+		;;
+	(Linux)
+		cflags -Wno-pedantic -D_GNU_SOURCE
+		config libtls ncursesw
+		defvar OPENSSL_BIN openssl exec_prefix /bin/openssl
+		echo 'OBJS += compat_readpassphrase.o'
+		;;
+	(Darwin)
+		cflags -D__STDC_WANT_LIB_EXT1__=1
+		cflags "-D'explicit_bzero(b,l)=memset_s((b),(l),0,(l))'"
+		config libtls ncursesw
+		defvar OPENSSL_BIN openssl exec_prefix /bin/openssl
+		;;
+	(*)
+		config libtls ncursesw
+		defvar OPENSSL_BIN openssl exec_prefix /bin/openssl
+		;;
+esac
diff --git a/edit.c b/edit.c
index c63e4a2..effb623 100644
--- a/edit.c
+++ b/edit.c
@@ -1,186 +1,314 @@
-/* Copyright (C) 2018  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 Affero General Public License as published by
+ * 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 Affero General Public License for more details.
+ * GNU General Public License for more details.
  *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
  */
 
-#include <err.h>
+#include <errno.h>
+#include <limits.h>
 #include <stdbool.h>
 #include <stdlib.h>
-#include <sysexits.h>
 #include <wchar.h>
 #include <wctype.h>
 
-#include "chat.h"
-
-enum { BufLen = 512 };
-static struct {
-	wchar_t buf[BufLen];
-	wchar_t *ptr;
-	wchar_t *end;
-	wchar_t *tab;
-} line = {
-	.ptr = line.buf,
-	.end = line.buf,
-};
-
-const wchar_t *editHead(void) {
-	return line.buf;
-}
-const wchar_t *editTail(void) {
-	return line.ptr;
-}
+#include "edit.h"
 
-static void left(void) {
-	if (line.ptr > line.buf) line.ptr--;
-}
-static void right(void) {
-	if (line.ptr < line.end) line.ptr++;
+static bool isword(wchar_t ch) {
+	return !iswspace(ch) && !iswpunct(ch);
 }
 
-static void backWord(void) {
-	left();
-	wchar_t *word = wcsnrchr(line.buf, line.ptr - line.buf, L' ');
-	line.ptr = (word ? &word[1] : line.buf);
-}
-static void foreWord(void) {
-	right();
-	wchar_t *word = wcsnchr(line.ptr, line.end - line.ptr, L' ');
-	line.ptr = (word ? word : line.end);
+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 = 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 = &e->buf[e->pos];
+	size_t n = wcsnrtombs(
+		*buf + len, &ptr, e->len - e->pos, *cap-1 - len, NULL
+	);
+	if (n == (size_t)-1) return NULL;
+	len += n;
+
+	(*buf)[len] = '\0';
+	return *buf;
 }
 
-static void insert(wchar_t ch) {
-	if (line.end == &line.buf[BufLen - 1]) return;
-	if (line.ptr != line.end) {
-		wmemmove(line.ptr + 1, line.ptr, line.end - line.ptr);
+int editReserve(struct Edit *e, size_t index, size_t count) {
+	if (index > e->len) {
+		errno = EINVAL;
+		return -1;
 	}
-	*line.ptr++ = ch;
-	line.end++;
-}
-static void backspace(void) {
-	if (line.ptr == line.buf) return;
-	if (line.ptr != line.end) {
-		wmemmove(line.ptr - 1, line.ptr, line.end - line.ptr);
+	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;
 	}
-	line.ptr--;
-	line.end--;
+	wmemmove(&e->buf[index + count], &e->buf[index], e->len - index);
+	e->len += count;
+	return 0;
 }
-static void delete(void) {
-	if (line.ptr == line.end) return;
-	right();
-	backspace();
+
+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 void killBackWord(void) {
-	wchar_t *from = line.ptr;
-	backWord();
-	wmemmove(line.ptr, from, line.end - from);
-	line.end -= from - line.ptr;
+int editDelete(struct Edit *e, bool cut, size_t index, size_t count) {
+	if (index + count > e->len) {
+		errno = EINVAL;
+		return -1;
+	}
+	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 killForeWord(void) {
-	wchar_t *from = line.ptr;
-	foreWord();
-	wmemmove(from, line.ptr, line.end - line.ptr);
-	line.end -= line.ptr - from;
-	line.ptr = from;
+
+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 char *prefix;
-static void complete(struct Tag tag) {
-	if (!line.tab) {
-		line.tab = wcsnrchr(line.buf, line.ptr - line.buf, L' ');
-		line.tab = (line.tab ? &line.tab[1] : line.buf);
-		prefix = awcsntombs(line.tab, line.ptr - line.tab);
-		if (!prefix) err(EX_DATAERR, "awcstombs");
-	}
+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;
+}
 
-	const char *next = tabNext(tag, prefix);
-	if (!next) return;
+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: {
+			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: {
+			while (e->pos < e->len && isword(e->buf[e->pos])) e->pos++;
+			while (e->pos < e->len && !isword(e->buf[e->pos])) e->pos++;
+		}
 
-	wchar_t *wcs = ambstowcs(next);
-	if (!wcs) err(EX_DATAERR, "ambstowcs");
+		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 (!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 (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);
+		}
 
-	size_t i = 0;
-	for (; wcs[i] && line.ptr > &line.tab[i]; ++i) {
-		line.tab[i] = wcs[i];
-	}
-	while (line.ptr > &line.tab[i]) {
-		backspace();
-	}
-	for (; wcs[i]; ++i) {
-		insert(wcs[i]);
-	}
-	free(wcs);
-
-	size_t pos = line.tab - line.buf;
-	if (!pos && line.tab[0] != L'/') {
-		insert(L':');
-	} else if (pos >= 2) {
-		if (line.buf[pos - 2] == L':') {
-			line.buf[pos - 2] = L',';
-			insert(L':');
+		break; case EditPaste: {
+			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 (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 (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 {
+					editDelete(e, false, e->pos, ws - e->pos);
+				}
+			}
+		}
+
+		break; case EditClear: e->len = e->pos = 0;
 	}
-	insert(L' ');
+	return ret;
 }
 
-static void accept(void) {
-	if (!line.tab) return;
-	line.tab = NULL;
-	free(prefix);
-	tabAccept();
+int editInsert(struct Edit *e, wchar_t ch) {
+	char mb[MB_LEN_MAX];
+	if (wctomb(mb, ch) < 0) return -1;
+	if (editReserve(e, e->pos, 1) < 0) return -1;
+	e->buf[e->pos++] = ch;
+	return 0;
 }
-static void reject(void) {
-	if (!line.tab) return;
-	line.tab = NULL;
-	free(prefix);
-	tabReject();
+
+#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 void enter(struct Tag tag) {
-	if (line.end == line.buf) return;
-	*line.end = L'\0';
-	char *str = awcstombs(line.buf);
-	if (!str) err(EX_DATAERR, "awcstombs");
-	input(tag, str);
-	free(str);
-	line.ptr = line.buf;
-	line.end = line.buf;
+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);
 }
 
-void edit(struct Tag tag, enum Edit op, wchar_t ch) {
-	switch (op) {
-		break; case EditLeft:  reject(); left();
-		break; case EditRight: reject(); right();
-		break; case EditHome:  reject(); line.ptr = line.buf;
-		break; case EditEnd:   reject(); line.ptr = line.end;
+#define editFn(...) assert(0 == editFn(__VA_ARGS__))
 
-		break; case EditBackWord: reject(); backWord();
-		break; case EditForeWord: reject(); foreWord();
+int main(void) {
+	struct Edit cut = {0};
+	struct Edit e = { .cut = &cut };
 
-		break; case EditInsert:    accept(); insert(ch);
-		break; case EditBackspace: reject(); backspace();
-		break; case EditDelete:    reject(); delete();
+	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"));
 
-		break; case EditKill:         reject(); line.ptr = line.end = line.buf;
-		break; case EditKillBackWord: reject(); killBackWord();
-		break; case EditKillForeWord: reject(); killForeWord();
-		break; case EditKillEnd:      reject(); line.end = line.ptr;
+	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"));
 
-		break; case EditComplete: complete(tag);
+	fix(&e, "foo bar");
+	editFn(&e, EditPrevWord);
+	editFn(&e, EditDeleteHead);
+	assert(eq(&e, "\0bar"));
 
-		break; case EditEnter: accept(); enter(tag);
-	}
+	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"));
 
-	*line.end = L'\0';
+	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/event.c b/event.c
deleted file mode 100644
index c6e0987..0000000
--- a/event.c
+++ /dev/null
@@ -1,168 +0,0 @@
-/* Copyright (C) 2018  C. McEnroe <june@causal.agency>
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-#include <assert.h>
-#include <err.h>
-#include <errno.h>
-#include <poll.h>
-#include <signal.h>
-#include <stdbool.h>
-#include <stdio.h>
-#include <stdlib.h>
-#include <stdnoreturn.h>
-#include <string.h>
-#include <sys/wait.h>
-#include <sysexits.h>
-#include <unistd.h>
-
-#include "chat.h"
-
-static struct {
-	bool wait;
-	int pipe;
-} child = {
-	.pipe = -1,
-};
-
-void eventWait(const char *argv[static 2]) {
-	uiHide();
-	pid_t pid = fork();
-	if (pid < 0) err(EX_OSERR, "fork");
-	if (!pid) {
-		execvp(argv[0], (char *const *)argv);
-		err(EX_CONFIG, "%s", argv[0]);
-	}
-	child.wait = true;
-}
-
-static void childWait(void) {
-	uiShow();
-	int status;
-	pid_t pid = wait(&status);
-	if (pid < 0) err(EX_OSERR, "wait");
-	if (WIFEXITED(status) && WEXITSTATUS(status)) {
-		uiFmt(TagStatus, UIHot, "event: exit %d", WEXITSTATUS(status));
-	} else if (WIFSIGNALED(status)) {
-		uiFmt(
-			TagStatus, UIHot,
-			"event: signal %s", strsignal(WTERMSIG(status))
-		);
-	}
-	child.wait = false;
-}
-
-void eventPipe(const char *argv[static 2]) {
-	if (child.pipe > 0) {
-		uiLog(TagStatus, UIHot, L"event: existing pipe");
-		return;
-	}
-
-	int rw[2];
-	int error = pipe(rw);
-	if (error) err(EX_OSERR, "pipe");
-
-	pid_t pid = fork();
-	if (pid < 0) err(EX_OSERR, "fork");
-	if (!pid) {
-		close(rw[0]);
-		close(STDIN_FILENO);
-		dup2(rw[1], STDOUT_FILENO);
-		dup2(rw[1], STDERR_FILENO);
-		close(rw[1]);
-		execvp(argv[0], (char *const *)argv);
-		perror(argv[0]);
-		exit(EX_CONFIG);
-	}
-
-	close(rw[1]);
-	child.pipe = rw[0];
-}
-
-static void childRead(void) {
-	char buf[256];
-	ssize_t len = read(child.pipe, buf, sizeof(buf) - 1);
-	if (len < 0) err(EX_IOERR, "read");
-	if (len) {
-		buf[len] = '\0';
-		buf[strcspn(buf, "\n")] = '\0';
-		uiFmt(TagStatus, UIHot, "event: %s", buf);
-	} else {
-		close(child.pipe);
-		child.pipe = -1;
-	}
-}
-
-static volatile sig_atomic_t sig[NSIG];
-static void handler(int n) {
-	sig[n] = 1;
-}
-
-noreturn void eventLoop(void) {
-	sigset_t mask;
-	sigemptyset(&mask);
-	struct sigaction action = {
-		.sa_handler = handler,
-		.sa_mask = mask,
-		.sa_flags = SA_RESTART | SA_NOCLDSTOP,
-	};
-	sigaction(SIGCHLD, &action, NULL);
-	sigaction(SIGINT, &action, NULL);
-	sigaction(SIGHUP, &action, NULL);
-
-	struct sigaction curses;
-	sigaction(SIGWINCH, &action, &curses);
-	assert(!(curses.sa_flags & SA_SIGINFO));
-
-	uiShowTag(TagStatus);
-	uiFmt(TagStatus, UICold, "Traveling to %s...", self.host);
-	uiDraw();
-	int irc = ircConnect();
-
-	for (;;) {
-		if (sig[SIGCHLD]) childWait();
-		if (sig[SIGHUP]) ircQuit("zzz");
-		if (sig[SIGINT]) {
-			signal(SIGINT, SIG_DFL);
-			ircQuit("Goodbye");
-		}
-		if (sig[SIGWINCH]) {
-			curses.sa_handler(SIGWINCH);
-			uiRead();
-			uiDraw();
-		}
-		sig[SIGCHLD] = sig[SIGHUP] = sig[SIGINT] = sig[SIGWINCH] = 0;
-
-		struct pollfd fds[3] = {
-			{ .events = POLLIN, .fd = irc },
-			{ .events = POLLIN, .fd = STDIN_FILENO },
-			{ .events = POLLIN, .fd = child.pipe },
-		};
-		if (child.wait) fds[1].events = 0;
-		if (child.pipe < 0) fds[2].events = 0;
-
-		int nfds = poll(fds, 3, -1);
-		if (nfds < 0) {
-			if (errno == EINTR) continue;
-			err(EX_IOERR, "poll");
-		}
-
-		if (fds[0].revents) ircRead();
-		if (fds[1].revents) uiRead();
-		if (fds[2].revents) childRead();
-
-		uiDraw();
-	}
-}
diff --git a/filter.c b/filter.c
new file mode 100644
index 0000000..a7f9a29
--- /dev/null
+++ b/filter.c
@@ -0,0 +1,132 @@
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
+ */
+
+#include <err.h>
+#include <fnmatch.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+
+#include "chat.h"
+
+struct Filter filters[FilterCap];
+static size_t len;
+
+struct Filter filterParse(enum Heat heat, char *pattern) {
+	struct Filter filter = { .heat = heat };
+	filter.mask = strsep(&pattern, " ");
+	filter.cmd  = strsep(&pattern, " ");
+	filter.chan = strsep(&pattern, " ");
+	filter.mesg = pattern;
+	return filter;
+}
+
+struct Filter filterAdd(enum Heat heat, const char *pattern) {
+	if (len == FilterCap) errx(EX_CONFIG, "filter limit exceeded");
+	char *own;
+	if (!strchr(pattern, '!') && !strchr(pattern, ' ')) {
+		int n = asprintf(&own, "%s!*@*", pattern);
+		if (n < 0) err(EX_OSERR, "asprintf");
+	} else {
+		own = strdup(pattern);
+		if (!own) err(EX_OSERR, "strdup");
+	}
+	struct Filter filter = filterParse(heat, own);
+	filters[len++] = filter;
+	return filter;
+}
+
+bool filterRemove(struct Filter filter) {
+	bool found = false;
+	for (size_t i = len - 1; i < len; --i) {
+		if (filters[i].heat != filter.heat) continue;
+		if (!filters[i].cmd != !filter.cmd) continue;
+		if (!filters[i].chan != !filter.chan) continue;
+		if (!filters[i].mesg != !filter.mesg) continue;
+		if (strcasecmp(filters[i].mask, filter.mask)) continue;
+		if (filter.cmd && strcasecmp(filters[i].cmd, filter.cmd)) continue;
+		if (filter.chan && strcasecmp(filters[i].chan, filter.chan)) continue;
+		if (filter.mesg && strcasecmp(filters[i].mesg, filter.mesg)) continue;
+		free(filters[i].mask);
+		memmove(&filters[i], &filters[i + 1], sizeof(*filters) * --len);
+		filters[len] = (struct Filter) {0};
+		found = true;
+	}
+	return found;
+}
+
+static bool filterTest(
+	struct Filter filter, const char *mask, uint id, const struct Message *msg
+) {
+	if (fnmatch(filter.mask, mask, FNM_CASEFOLD)) return false;
+	if (!filter.cmd) return true;
+	if (fnmatch(filter.cmd, msg->cmd, FNM_CASEFOLD)) return false;
+	if (!filter.chan) return true;
+	if (fnmatch(filter.chan, idNames[id], FNM_CASEFOLD)) return false;
+	if (!filter.mesg) return true;
+	if (!msg->params[1]) return false;
+	return !fnmatch(filter.mesg, msg->params[1], FNM_CASEFOLD);
+}
+
+enum { IcedCap = 8 };
+static struct {
+	size_t len;
+	char *msgIDs[IcedCap];
+} iced;
+
+static void icedPush(const char *msgID) {
+	if (!msgID) return;
+	size_t i = iced.len % IcedCap;
+	free(iced.msgIDs[i]);
+	iced.msgIDs[i] = strdup(msgID);
+	if (!iced.msgIDs[i]) err(EX_OSERR, "strdup");
+	iced.len++;
+}
+
+enum Heat filterCheck(enum Heat heat, uint id, const struct Message *msg) {
+	if (!len) return heat;
+
+	if (msg->tags[TagReply]) {
+		for (size_t i = 0; i < IcedCap; ++i) {
+			if (!iced.msgIDs[i]) continue;
+			if (strcmp(msg->tags[TagReply], iced.msgIDs[i])) continue;
+			icedPush(msg->tags[TagMsgID]);
+			return Ice;
+		}
+	}
+
+	char mask[512];
+	snprintf(mask, sizeof(mask), "%s!%s@%s", msg->nick, msg->user, msg->host);
+	for (size_t i = 0; i < len; ++i) {
+		if (!filterTest(filters[i], mask, id, msg)) continue;
+		if (filters[i].heat == Ice) icedPush(msg->tags[TagMsgID]);
+		return filters[i].heat;
+	}
+	return heat;
+}
diff --git a/format.c b/format.c
deleted file mode 100644
index 71d1d93..0000000
--- a/format.c
+++ /dev/null
@@ -1,162 +0,0 @@
-/* Copyright (C) 2018  C. McEnroe <june@causal.agency>
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-#include <stdbool.h>
-#include <stdint.h>
-#include <stdlib.h>
-#include <wchar.h>
-
-#include "chat.h"
-
-void formatReset(struct Format *format) {
-	format->bold = false;
-	format->italic = false;
-	format->underline = false;
-	format->reverse = false;
-	format->fg = IRCDefault;
-	format->bg = IRCDefault;
-}
-
-static void parseColor(struct Format *format) {
-	size_t len = MIN(wcsspn(format->str, L"0123456789"), 2);
-	if (!len) {
-		format->fg = IRCDefault;
-		format->bg = IRCDefault;
-		return;
-	}
-	format->fg = 0;
-	for (size_t i = 0; i < len; ++i) {
-		format->fg *= 10;
-		format->fg += format->str[i] - L'0';
-	}
-	if (format->fg > IRCLightGray) format->fg = IRCDefault;
-	format->str = &format->str[len];
-
-	len = 0;
-	if (format->str[0] == L',') {
-		len = MIN(wcsspn(&format->str[1], L"0123456789"), 2);
-	}
-	if (!len) return;
-	format->bg = 0;
-	for (size_t i = 0; i < len; ++i) {
-		format->bg *= 10;
-		format->bg += format->str[1 + i] - L'0';
-	}
-	if (format->bg > IRCLightGray) format->bg = IRCDefault;
-	format->str = &format->str[1 + len];
-}
-
-static const wchar_t Codes[] = {
-	IRCBold, IRCColor, IRCReverse, IRCReset, IRCItalic, IRCUnderline, L'\0',
-};
-
-bool formatParse(struct Format *format, const wchar_t *split) {
-	format->str += format->len;
-	if (!format->str[0]) {
-		if (split == format->str && !format->split) {
-			format->len = 0;
-			format->split = true;
-			return true;
-		}
-		return false;
-	}
-
-	const wchar_t *init = format->str;
-	for (bool done = false; !done;) {
-		switch (format->str[0]) {
-			break; case IRCBold:      format->str++; format->bold ^= true;
-			break; case IRCItalic:    format->str++; format->italic ^= true;
-			break; case IRCUnderline: format->str++; format->underline ^= true;
-			break; case IRCReverse:   format->str++; format->reverse ^= true;
-			break; case IRCColor:     format->str++; parseColor(format);
-			break; case IRCReset:     format->str++; formatReset(format);
-			break; default:           done = true;
-		}
-	}
-	format->split = (split >= init && split <= format->str);
-
-	format->len = wcscspn(format->str, Codes);
-	if (split > format->str && split < &format->str[format->len]) {
-		format->len = split - format->str;
-	}
-	return true;
-}
-
-#ifdef TEST
-#include <assert.h>
-
-static bool testColor(
-	const wchar_t *str, enum IRCColor fg, enum IRCColor bg, size_t index
-) {
-	struct Format format = { .str = str };
-	formatReset(&format);
-	if (!formatParse(&format, NULL)) return false;
-	if (format.fg != fg) return false;
-	if (format.bg != bg) return false;
-	return (format.str == &str[index]);
-}
-
-static bool testSplit(const wchar_t *str, size_t index) {
-	struct Format format = { .str = str };
-	formatReset(&format);
-	bool split = false;
-	while (formatParse(&format, &str[index])) {
-		if (format.split && split) return false;
-		if (format.split) split = true;
-	}
-	return split;
-}
-
-static bool testSplits(const wchar_t *str) {
-	for (size_t i = 0; i <= wcslen(str); ++i) {
-		if (!testSplit(str, i)) return false;
-	}
-	return true;
-}
-
-int main() {
-	assert(testColor(L"\003a",      IRCDefault,   IRCDefault,   1));
-	assert(testColor(L"\003,a",     IRCDefault,   IRCDefault,   1));
-	assert(testColor(L"\003,1",     IRCDefault,   IRCDefault,   1));
-	assert(testColor(L"\0031a",     IRCBlack,     IRCDefault,   2));
-	assert(testColor(L"\0031,a",    IRCBlack,     IRCDefault,   2));
-	assert(testColor(L"\00312a",    IRCLightBlue, IRCDefault,   3));
-	assert(testColor(L"\00312,a",   IRCLightBlue, IRCDefault,   3));
-	assert(testColor(L"\003123",    IRCLightBlue, IRCDefault,   3));
-	assert(testColor(L"\0031,1a",   IRCBlack,     IRCBlack,     4));
-	assert(testColor(L"\0031,12a",  IRCBlack,     IRCLightBlue, 5));
-	assert(testColor(L"\0031,123",  IRCBlack,     IRCLightBlue, 5));
-	assert(testColor(L"\00312,1a",  IRCLightBlue, IRCBlack,     5));
-	assert(testColor(L"\00312,12a", IRCLightBlue, IRCLightBlue, 6));
-	assert(testColor(L"\00312,123", IRCLightBlue, IRCLightBlue, 6));
-
-	assert(testColor(L"\00316,16a", IRCDefault, IRCDefault, 6));
-	assert(testColor(L"\00399,99a", IRCDefault, IRCDefault, 6));
-
-	assert(testSplits(L""));
-	assert(testSplits(L"ab"));
-	assert(testSplits(L"\002"));
-	assert(testSplits(L"\002ab"));
-	assert(testSplits(L"a\002b"));
-	assert(testSplits(L"\002\003"));
-	assert(testSplits(L"a\002\003b"));
-	assert(testSplits(L"a\0031b"));
-	assert(testSplits(L"a\00312b"));
-	assert(testSplits(L"a\00312,1b"));
-	assert(testSplits(L"a\00312,12b"));
-}
-
-#endif
diff --git a/handle.c b/handle.c
index fe15d9a..5a2cf7c 100644
--- a/handle.c
+++ b/handle.c
@@ -1,568 +1,1444 @@
-/* Copyright (C) 2018, 2019  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 Affero General Public License as published by
+ * 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 Affero General Public License for more details.
+ * GNU General Public License for more details.
  *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ * 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 <assert.h>
 #include <ctype.h>
 #include <err.h>
-#include <stdarg.h>
+#include <stdbool.h>
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
 #include <sysexits.h>
-#include <time.h>
+#include <wchar.h>
 
 #include "chat.h"
 
-static char *paramField(char **params) {
-	char *rest = *params;
-	if (rest[0] == ':') {
-		*params = NULL;
-		return &rest[1];
+uint replies[ReplyCap];
+
+static const char *CapNames[] = {
+#define X(name, id) [id##Bit] = name,
+	ENUM_CAP
+#undef X
+};
+
+static enum Cap capParse(const char *list) {
+	enum Cap caps = 0;
+	while (*list) {
+		enum Cap cap = 0;
+		size_t len = strcspn(list, " ");
+		for (size_t i = 0; i < ARRAY_LEN(CapNames); ++i) {
+			if (len != strlen(CapNames[i])) continue;
+			if (strncmp(list, CapNames[i], len)) continue;
+			cap = 1 << i;
+			break;
+		}
+		caps |= cap;
+		list += len;
+		if (*list) list++;
 	}
-	return strsep(params, " ");
+	return caps;
 }
 
-static void parse(
-	char *prefix, char **nick, char **user, char **host,
-	char *params, size_t req, size_t opt, /* (char **) */ ...
-) {
-	char *field;
-	if (prefix) {
-		field = strsep(&prefix, "!");
-		if (nick) *nick = field;
-		field = strsep(&prefix, "@");
-		if (user) *user = field;
-		if (host) *host = prefix;
-	}
-
-	va_list ap;
-	va_start(ap, opt);
-	for (size_t i = 0; i < req; ++i) {
-		if (!params) errx(EX_PROTOCOL, "%zu params required, found %zu", req, i);
-		field = paramField(&params);
-		char **param = va_arg(ap, char **);
-		if (param) *param = field;
-	}
-	for (size_t i = 0; i < opt; ++i) {
-		char **param = va_arg(ap, char **);
-		if (params) {
-			*param = paramField(&params);
-		} else {
-			*param = NULL;
+static void capList(char *buf, size_t cap, enum Cap caps) {
+	*buf = '\0';
+	char *ptr = buf, *end = &buf[cap];
+	for (size_t i = 0; i < ARRAY_LEN(CapNames); ++i) {
+		if (caps & (1 << i)) {
+			ptr = seprintf(
+				ptr, end, "%s%s", (ptr > buf ? " " : ""), CapNames[i]
+			);
 		}
 	}
-	va_end(ap);
 }
 
-static bool isPing(const char *mesg) {
-	size_t len = strlen(self.nick);
-	const char *match = mesg;
-	while (NULL != (match = strcasestr(match, self.nick))) {
-		char b = (match > mesg ? *(match - 1) : ' ');
-		char a = (match[len] ? match[len] : ' ');
-		match = &match[len];
-		if (!isspace(b) && !ispunct(b)) continue;
-		if (!isspace(a) && !ispunct(a)) continue;
-		return true;
+static void require(struct Message *msg, bool origin, uint len) {
+	if (origin) {
+		if (!msg->nick) msg->nick = "*.*";
+		if (!msg->user) msg->user = msg->nick;
+		if (!msg->host) msg->host = msg->user;
+	}
+	for (uint i = 0; i < len; ++i) {
+		if (msg->params[i]) continue;
+		errx(EX_PROTOCOL, "%s missing parameter %u", msg->cmd, 1 + i);
 	}
-	return false;
 }
 
-static char *dequote(char *mesg) {
-	if (mesg[0] == '"') mesg = &mesg[1];
-	size_t len = strlen(mesg);
-	if (mesg[len - 1] == '"') mesg[len - 1] = '\0';
-	return mesg;
+static const time_t *tagTime(const struct Message *msg) {
+	static time_t time;
+	struct tm tm;
+	if (!msg->tags[TagTime]) return NULL;
+	if (!strptime(msg->tags[TagTime], "%Y-%m-%dT%T", &tm)) return NULL;
+	time = timegm(&tm);
+	return &time;
 }
 
-typedef void Handler(char *prefix, char *params);
+typedef void Handler(struct Message *msg);
 
-static void handlePing(char *prefix, char *params) {
-	(void)prefix;
-	ircFmt("PONG %s\r\n", params);
+static void handleStandardReply(struct Message *msg) {
+	require(msg, false, 3);
+	for (uint i = 2; i < ParamCap - 1; ++i) {
+		if (msg->params[i + 1]) continue;
+		uiFormat(
+			Network, Warm, tagTime(msg),
+			"%s", msg->params[i]
+		);
+		break;
+	}
 }
 
-static void handleError(char *prefix, char *params) {
-	char *mesg;
-	parse(prefix, NULL, NULL, NULL, params, 1, 0, &mesg);
-	if (self.quit) {
-		uiExit(EX_OK);
+static void handleErrorGeneric(struct Message *msg) {
+	require(msg, false, 2);
+	if (msg->params[2]) {
+		size_t len = strlen(msg->params[2]);
+		if (msg->params[2][len - 1] == '.') msg->params[2][len - 1] = '\0';
+		uiFormat(
+			Network, Warm, tagTime(msg),
+			"%s: %s", msg->params[2], msg->params[1]
+		);
 	} else {
-		errx(EX_PROTOCOL, "%s", mesg);
+		uiFormat(
+			Network, Warm, tagTime(msg),
+			"%s", msg->params[1]
+		);
+	}
+}
+
+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 handleCap(char *prefix, char *params) {
-	char *subc, *list;
-	parse(prefix, NULL, NULL, NULL, params, 3, 0, NULL, &subc, &list);
-	if (!strcmp(subc, "ACK") && self.auth) {
-		size_t len = strlen(self.auth);
-		byte plain[1 + len];
-		plain[0] = 0;
-		for (size_t i = 0; i < len; ++i) {
-			plain[1 + i] = (self.auth[i] == ':' ? 0 : self.auth[i]);
+static void handleErrorNicknameInUse(struct Message *msg) {
+	require(msg, false, 2);
+	if (!strcmp(self.nick, "*")) {
+		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]);
 		}
-		char b64[base64Size(sizeof(plain))];
-		base64(b64, plain, sizeof(plain));
-		ircFmt("AUTHENTICATE PLAIN\r\n");
-		ircFmt("AUTHENTICATE %s\r\n", b64);
+	} else {
+		handleErrorGeneric(msg);
 	}
-	ircFmt("CAP END\r\n");
 }
 
-static void handleErrorErroneousNickname(char *prefix, char *params) {
-	char *mesg;
-	parse(prefix, NULL, NULL, NULL, params, 3, 0, NULL, NULL, &mesg);
-	uiFmt(TagStatus, UIHot, "You can't use that name here: \"%s\"", mesg);
-	uiLog(TagStatus, UICold, L"Type /nick <name> to choose a new one");
+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]);
+	} else {
+		handleErrorGeneric(msg);
+	}
 }
 
-static void handleReplyWelcome(char *prefix, char *params) {
-	char *nick;
-	parse(prefix, NULL, NULL, NULL, params, 1, 0, &nick);
+static void handleCap(struct Message *msg) {
+	require(msg, false, 3);
+	enum Cap caps = capParse(msg->params[2]);
+	if (!strcmp(msg->params[1], "LS")) {
+		caps &= ~CapSASL;
+		if (caps & CapConsumer && self.pos) {
+			ircFormat("CAP REQ %s=%zu\r\n", CapNames[CapConsumerBit], self.pos);
+			caps &= ~CapConsumer;
+		}
+		if (caps) {
+			char buf[512];
+			capList(buf, sizeof(buf), caps);
+			ircFormat("CAP REQ :%s\r\n", buf);
+		} else {
+			if (!(self.caps & CapSASL)) ircFormat("CAP END\r\n");
+		}
+	} else if (!strcmp(msg->params[1], "ACK")) {
+		self.caps |= caps;
+		if (caps & CapSASL) {
+			ircFormat(
+				"AUTHENTICATE %s\r\n", (self.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]);
+	}
+}
 
-	if (strcmp(nick, self.nick)) {
-		free(self.nick);
-		self.nick = strdup(nick);
-		if (!self.nick) err(EX_OSERR, "strdup");
-		uiPrompt(true);
+#define BASE64_SIZE(len) (1 + ((len) + 2) / 3 * 4)
+
+static void base64(char *dst, const byte *src, size_t len) {
+	static const char Base64[64] = {
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
+	};
+	size_t i = 0;
+	while (len > 2) {
+		dst[i++] = Base64[0x3F & (src[0] >> 2)];
+		dst[i++] = Base64[0x3F & (src[0] << 4 | src[1] >> 4)];
+		dst[i++] = Base64[0x3F & (src[1] << 2 | src[2] >> 6)];
+		dst[i++] = Base64[0x3F & src[2]];
+		src += 3;
+		len -= 3;
 	}
-	if (self.join && self.keys) {
-		ircFmt("JOIN %s %s\r\n", self.join, self.keys);
-	} else if (self.join) {
-		ircFmt("JOIN %s\r\n", self.join);
+	if (len) {
+		dst[i++] = Base64[0x3F & (src[0] >> 2)];
+		if (len > 1) {
+			dst[i++] = Base64[0x3F & (src[0] << 4 | src[1] >> 4)];
+			dst[i++] = Base64[0x3F & (src[1] << 2)];
+		} else {
+			dst[i++] = Base64[0x3F & (src[0] << 4)];
+			dst[i++] = '=';
+		}
+		dst[i++] = '=';
 	}
-	tabTouch(TagStatus, self.nick);
+	dst[i] = '\0';
+}
 
-	uiLog(TagStatus, UICold, L"You have arrived");
+static void handleAuthenticate(struct Message *msg) {
+	(void)msg;
+	if (!self.plainUser) {
+		ircFormat("AUTHENTICATE +\r\n");
+		return;
+	}
+
+	byte buf[299] = {0};
+	size_t userLen = strlen(self.plainUser);
+	size_t passLen = strlen(self.plainPass);
+	size_t len = 1 + userLen + 1 + passLen;
+	if (sizeof(buf) < len) errx(EX_USAGE, "SASL PLAIN is too long");
+	memcpy(&buf[1], self.plainUser, userLen);
+	memcpy(&buf[1 + userLen + 1], self.plainPass, passLen);
+
+	char b64[BASE64_SIZE(sizeof(buf))];
+	base64(b64, buf, len);
+	ircFormat("AUTHENTICATE ");
+	ircSend(b64, BASE64_SIZE(len) - 1);
+	ircFormat("\r\n");
+
+	explicit_bzero(b64, sizeof(b64));
+	explicit_bzero(buf, sizeof(buf));
+	explicit_bzero(self.plainPass, strlen(self.plainPass));
 }
 
-static void handleReplyMOTD(char *prefix, char *params) {
-	char *mesg;
-	parse(prefix, NULL, NULL, NULL, params, 2, 0, NULL, &mesg);
-	if (mesg[0] == '-' && mesg[1] == ' ') mesg = &mesg[2];
+static void handleReplyLoggedIn(struct Message *msg) {
+	(void)msg;
+	ircFormat("CAP END\r\n");
+	handleReplyGeneric(msg);
+}
 
-	urlScan(TagStatus, mesg);
-	uiFmt(TagStatus, UICold, "%s", mesg);
+static void handleErrorSASLFail(struct Message *msg) {
+	require(msg, false, 2);
+	errx(EX_CONFIG, "%s", msg->params[1]);
 }
 
-static void handleReplyList(char *prefix, char *params) {
-	char *chan, *count, *topic;
-	parse(prefix, NULL, NULL, NULL, params, 4, 0, NULL, &chan, &count, &topic);
-	if (topic[0] == '[') {
-		char *skip = strstr(topic, "] ");
-		if (skip) topic = &skip[2];
+static void handleReplyWelcome(struct Message *msg) {
+	require(msg, false, 1);
+	set(&self.nick, msg->params[0]);
+	completePull(Network, self.nick, Default);
+	if (self.mode) ircFormat("MODE %s %s\r\n", self.nick, self.mode);
+	if (self.join) {
+		uint count = 1;
+		for (const char *ch = self.join; *ch && *ch != ' '; ++ch) {
+			if (*ch == ',') count++;
+		}
+		ircFormat("JOIN %s\r\n", self.join);
+		if (count == 1) replies[ReplyJoin]++;
+		replies[ReplyTopicAuto] += count;
+		replies[ReplyNamesAuto] += count;
 	}
-	const char *people = (strcmp(count, "1") ? "people" : "person");
-	if (topic[0]) {
-		uiFmt(
-			TagStatus, UIWarm,
-			"You see %s %s in \3%d%s\3 under the banner, \"%s\"",
-			count, people, colorGen(chan), chan, topic
-		);
+	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]);
+			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);
+		} else if (!strcmp(key, "HOSTLEN")) {
+			if (!msg->params[i]) continue;
+			network.hostLen = strtoul(msg->params[i], NULL, 10);
+		} else if (!strcmp(key, "CHANTYPES")) {
+			if (!msg->params[i]) continue;
+			set(&network.chanTypes, msg->params[i]);
+		} else if (!strcmp(key, "STATUSMSG")) {
+			if (!msg->params[i]) continue;
+			set(&network.statusmsg, msg->params[i]);
+		} else if (!strcmp(key, "PREFIX")) {
+			strsep(&msg->params[i], "(");
+			char *modes = strsep(&msg->params[i], ")");
+			char *prefixes = msg->params[i];
+			if (!modes || !prefixes || strlen(modes) != strlen(prefixes)) {
+				errx(EX_PROTOCOL, "invalid PREFIX value");
+			}
+			set(&network.prefixModes, modes);
+			set(&network.prefixes, prefixes);
+		} else if (!strcmp(key, "CHANMODES")) {
+			char *list = strsep(&msg->params[i], ",");
+			char *param = strsep(&msg->params[i], ",");
+			char *setParam = strsep(&msg->params[i], ",");
+			char *channel = strsep(&msg->params[i], ",");
+			if (!list || !param || !setParam || !channel) {
+				errx(EX_PROTOCOL, "invalid CHANMODES value");
+			}
+			set(&network.listModes, list);
+			set(&network.paramModes, param);
+			set(&network.setParamModes, setParam);
+			set(&network.channelModes, channel);
+		} else if (!strcmp(key, "EXCEPTS")) {
+			network.excepts = (msg->params[i] ?: "e")[0];
+		} else if (!strcmp(key, "INVEX")) {
+			network.invex = (msg->params[i] ?: "I")[0];
+		}
+	}
+}
+
+static void handleReplyMOTD(struct Message *msg) {
+	require(msg, false, 2);
+	char *line = msg->params[1];
+	urlScan(Network, NULL, line);
+	if (!strncmp(line, "- ", 2)) {
+		uiFormat(Network, Cold, tagTime(msg), "\3%d-\3\t%s", Gray, &line[2]);
 	} else {
-		uiFmt(
-			TagStatus, UIWarm,
-			"You see %s %s in \3%d%s\3",
-			count, people, colorGen(chan), chan
-		);
+		uiFormat(Network, Cold, tagTime(msg), "%s", line);
 	}
 }
 
-static void handleReplyListEnd(char *prefix, char *params) {
-	(void)prefix;
-	(void)params;
-	uiLog(TagStatus, UICold, L"You don't see anyone else");
+static void handleErrorNoMOTD(struct Message *msg) {
+	(void)msg;
 }
 
-static enum IRCColor whoisColor;
-static void handleReplyWhoisUser(char *prefix, char *params) {
-	char *nick, *user, *host, *real;
-	parse(
-		prefix, NULL, NULL, NULL,
-		params, 6, 0, NULL, &nick, &user, &host, NULL, &real
-	);
-	whoisColor = colorGen(user);
-	uiFmt(
-		TagStatus, UIWarm,
-		"\3%d%s\3 is %s@%s, \"%s\"",
-		whoisColor, nick, user, host, real
+static void handleReplyHelp(struct Message *msg) {
+	require(msg, false, 3);
+	urlScan(Network, NULL, msg->params[2]);
+	uiWrite(Network, Warm, tagTime(msg), msg->params[2]);
+}
+
+static void handleJoin(struct Message *msg) {
+	require(msg, true, 1);
+	uint id = idFor(msg->params[0]);
+	if (!strcmp(msg->nick, self.nick)) {
+		if (!self.user || strcmp(self.user, msg->user)) {
+			set(&self.user, msg->user);
+			self.color = hash(msg->user);
+		}
+		if (!self.host || strcmp(self.host, msg->host)) {
+			set(&self.host, msg->host);
+		}
+		idColors[id] = hash(msg->params[0]);
+		completePull(None, msg->params[0], idColors[id]);
+		if (replies[ReplyJoin]) {
+			windowShow(windowFor(id));
+			replies[ReplyJoin]--;
+		}
+	}
+	completePull(id, msg->nick, hash(msg->user));
+	if (msg->params[2] && !strcasecmp(msg->params[2], msg->nick)) {
+		msg->params[2] = NULL;
+	}
+	uiFormat(
+		id, filterCheck(Cold, id, msg), tagTime(msg),
+		"\3%02d%s\3\t%s%s%sarrives in \3%02d%s\3",
+		hash(msg->user), msg->nick,
+		(msg->params[2] ? "(" : ""),
+		(msg->params[2] ?: ""),
+		(msg->params[2] ? "\17) " : ""),
+		hash(msg->params[0]), msg->params[0]
 	);
+	logFormat(id, tagTime(msg), "%s arrives in %s", msg->nick, msg->params[0]);
 }
 
-static void handleReplyWhoisServer(char *prefix, char *params) {
-	char *nick, *serv, *info;
-	parse(prefix, NULL, NULL, NULL, params, 4, 0, NULL, &nick, &serv, &info);
-	uiFmt(
-		TagStatus, UIWarm,
-		"\3%d%s\3 is connected to %s, \"%s\"",
-		whoisColor, nick, serv, info
+static void handleChghost(struct Message *msg) {
+	require(msg, true, 2);
+	if (strcmp(msg->nick, self.nick)) return;
+	if (!self.user || strcmp(self.user, msg->params[0])) {
+		set(&self.user, msg->params[0]);
+		self.color = hash(msg->params[0]);
+	}
+	if (!self.host || strcmp(self.host, msg->params[1])) {
+		set(&self.host, msg->params[1]);
+	}
+}
+
+static void handlePart(struct Message *msg) {
+	require(msg, true, 1);
+	uint id = idFor(msg->params[0]);
+	if (!strcmp(msg->nick, self.nick)) {
+		completeRemove(id, NULL);
+	}
+	completeRemove(id, msg->nick);
+	enum Heat heat = filterCheck(Cold, id, msg);
+	if (heat > Ice) urlScan(id, msg->nick, msg->params[1]);
+	uiFormat(
+		id, heat, tagTime(msg),
+		"\3%02d%s\3\tleaves \3%02d%s\3%s%s",
+		hash(msg->user), msg->nick, hash(msg->params[0]), msg->params[0],
+		(msg->params[1] ? ": " : ""), (msg->params[1] ?: "")
+	);
+	logFormat(
+		id, tagTime(msg), "%s leaves %s%s%s",
+		msg->nick, msg->params[0],
+		(msg->params[1] ? ": " : ""), (msg->params[1] ?: "")
 	);
 }
 
-static void handleReplyWhoisOperator(char *prefix, char *params) {
-	char *nick, *oper;
-	parse(prefix, NULL, NULL, NULL, params, 3, 0, NULL, &nick, &oper);
-	uiFmt(TagStatus, UIWarm, "\3%d%s\3 %s", whoisColor, nick, oper);
-}
-
-static void handleReplyWhoisIdle(char *prefix, char *params) {
-	char *nick, *idle, *sign;
-	parse(prefix, NULL, NULL, NULL, params, 4, 0, NULL, &nick, &idle, &sign);
-	time_t time = strtoul(sign, NULL, 10);
-	const char *at = ctime(&time);
-	unsigned long secs  = strtoul(idle, NULL, 10);
-	unsigned long mins  = secs / 60; secs %= 60;
-	unsigned long hours = mins / 60; mins %= 60;
-	uiFmt(
-		TagStatus, UIWarm,
-		"\3%d%s\3 signed on at %.24s and has been idle for %02lu:%02lu:%02lu",
-		whoisColor, nick, at, hours, mins, secs
+static void handleKick(struct Message *msg) {
+	require(msg, true, 2);
+	uint id = idFor(msg->params[0]);
+	bool kicked = !strcmp(msg->params[1], self.nick);
+	completePull(id, msg->nick, hash(msg->user));
+	urlScan(id, msg->nick, msg->params[2]);
+	uiFormat(
+		id, (kicked ? Hot : Cold), tagTime(msg),
+		"%s\3%02d%s\17\tkicks \3%02d%s\3 out of \3%02d%s\3%s%s",
+		(kicked ? "\26" : ""),
+		hash(msg->user), msg->nick,
+		completeColor(id, msg->params[1]), msg->params[1],
+		hash(msg->params[0]), msg->params[0],
+		(msg->params[2] ? ": " : ""), (msg->params[2] ?: "")
 	);
+	logFormat(
+		id, tagTime(msg), "%s kicks %s out of %s%s%s",
+		msg->nick, msg->params[1], msg->params[0],
+		(msg->params[2] ? ": " : ""), (msg->params[2] ?: "")
+	);
+	completeRemove(id, msg->params[1]);
+	if (kicked) completeRemove(id, NULL);
 }
 
-static void handleReplyWhoisChannels(char *prefix, char *params) {
-	char *nick, *chans;
-	parse(prefix, NULL, NULL, NULL, params, 3, 0, NULL, &nick, &chans);
-	uiFmt(TagStatus, UIWarm, "\3%d%s\3 is in %s", whoisColor, nick, chans);
+static void handleNick(struct Message *msg) {
+	require(msg, true, 1);
+	if (!strcmp(msg->nick, self.nick)) {
+		set(&self.nick, msg->params[0]);
+		inputUpdate();
+	}
+	struct Cursor curs = {0};
+	for (uint id; (id = completeEachID(&curs, msg->nick));) {
+		if (!strcmp(idNames[id], msg->nick)) {
+			set(&idNames[id], msg->params[0]);
+		}
+		uiFormat(
+			id, filterCheck(Cold, id, msg), tagTime(msg),
+			"\3%02d%s\3\tis now known as \3%02d%s\3",
+			hash(msg->user), msg->nick, hash(msg->user), msg->params[0]
+		);
+		if (id == Network) continue;
+		logFormat(
+			id, tagTime(msg), "%s is now known as %s",
+			msg->nick, msg->params[0]
+		);
+	}
+	completeReplace(msg->nick, msg->params[0]);
 }
 
-static void handleErrorNoSuchNick(char *prefix, char *params) {
-	char *nick, *mesg;
-	parse(prefix, NULL, NULL, NULL, params, 3, 0, NULL, &nick, &mesg);
-	uiFmt(TagStatus, UIWarm, "%s, \"%s\"", mesg, nick);
+static void handleSetname(struct Message *msg) {
+	require(msg, true, 1);
+	struct Cursor curs = {0};
+	for (uint id; (id = completeEachID(&curs, msg->nick));) {
+		uiFormat(
+			id, filterCheck(Cold, id, msg), tagTime(msg),
+			"\3%02d%s\3\tis now known as \3%02d%s\3 (%s\17)",
+			hash(msg->user), msg->nick, hash(msg->user), msg->nick,
+			msg->params[0]
+		);
+	}
 }
 
-static void handleJoin(char *prefix, char *params) {
-	char *nick, *user, *chan;
-	parse(prefix, &nick, &user, NULL, params, 1, 0, &chan);
-	struct Tag tag = colorTag(tagFor(chan), chan);
+static void handleQuit(struct Message *msg) {
+	require(msg, true, 0);
+	struct Cursor curs = {0};
+	for (uint id; (id = completeEachID(&curs, msg->nick));) {
+		enum Heat heat = filterCheck(Cold, id, msg);
+		if (heat > Ice) urlScan(id, msg->nick, msg->params[0]);
+		uiFormat(
+			id, heat, tagTime(msg),
+			"\3%02d%s\3\tleaves%s%s",
+			hash(msg->user), msg->nick,
+			(msg->params[0] ? ": " : ""), (msg->params[0] ?: "")
+		);
+		if (id == Network) continue;
+		logFormat(
+			id, tagTime(msg), "%s leaves%s%s",
+			msg->nick,
+			(msg->params[0] ? ": " : ""), (msg->params[0] ?: "")
+		);
+	}
+	completeRemove(None, msg->nick);
+}
 
-	if (!strcmp(nick, self.nick)) {
-		tabTouch(TagNone, chan);
-		uiShowTag(tag);
-		logReplay(tag);
+static void handleInvite(struct Message *msg) {
+	require(msg, true, 2);
+	if (!strcmp(msg->params[0], self.nick)) {
+		set(&self.invited, msg->params[1]);
+		uiFormat(
+			Network, filterCheck(Hot, Network, msg), tagTime(msg),
+			"\3%02d%s\3\tinvites you to \3%02d%s\3",
+			hash(msg->user), msg->nick, hash(msg->params[1]), msg->params[1]
+		);
+	} else {
+		uint id = idFor(msg->params[1]);
+		uiFormat(
+			id, Cold, tagTime(msg),
+			"\3%02d%s\3\tinvites %s to \3%02d%s\3",
+			hash(msg->user), msg->nick,
+			msg->params[0],
+			hash(msg->params[1]), msg->params[1]
+		);
+		logFormat(
+			id, tagTime(msg), "%s invites %s to %s",
+			msg->nick, msg->params[0], msg->params[1]
+		);
 	}
-	tabTouch(tag, nick);
+}
+
+static void handleReplyInviting(struct Message *msg) {
+	require(msg, false, 3);
+	struct Message invite = {
+		.nick = self.nick,
+		.user = self.user,
+		.cmd = "INVITE",
+		.params[0] = msg->params[1],
+		.params[1] = msg->params[2],
+	};
+	handleInvite(&invite);
+}
 
-	uiFmt(
-		tag, UICold,
-		"\3%d%s\3 arrives in \3%d%s\3",
-		colorGen(user), nick, colorGen(chan), chan
+static void handleErrorUserOnChannel(struct Message *msg) {
+	require(msg, false, 3);
+	uint id = idFor(msg->params[2]);
+	uiFormat(
+		id, Warm, tagTime(msg),
+		"\3%02d%s\3 is already in \3%02d%s\3",
+		completeColor(id, msg->params[1]), msg->params[1],
+		hash(msg->params[2]), msg->params[2]
 	);
-	logFmt(tag, NULL, "%s arrives in %s", nick, chan);
 }
 
-static void handlePart(char *prefix, char *params) {
-	char *nick, *user, *chan, *mesg;
-	parse(prefix, &nick, &user, NULL, params, 1, 1, &chan, &mesg);
-	struct Tag tag = colorTag(tagFor(chan), chan);
+static void handleReplyNames(struct Message *msg) {
+	require(msg, false, 4);
+	uint id = idFor(msg->params[2]);
+	char buf[1024];
+	char *ptr = buf, *end = &buf[sizeof(buf)];
+	while (msg->params[3]) {
+		char *name = strsep(&msg->params[3], " ");
+		char *prefixes = strsep(&name, "!");
+		char *nick = &prefixes[strspn(prefixes, network.prefixes)];
+		char *user = strsep(&name, "@");
+		enum Color color = (user ? hash(user) : Default);
+		uint bits = 0;
+		for (char *p = prefixes; p < nick; ++p) {
+			bits |= prefixBit(*p);
+		}
+		completePush(id, nick, color);
+		*completeBits(id, nick) = bits;
+		if (!replies[ReplyNames] && !replies[ReplyNamesAuto]) continue;
+		ptr = seprintf(
+			ptr, end, "%s\3%02d%s\3", (ptr > buf ? ", " : ""), color, prefixes
+		);
+	}
+	if (ptr == buf) return;
+	uiFormat(
+		id, (replies[ReplyNamesAuto] ? Cold : Warm), tagTime(msg),
+		"In \3%02d%s\3 are %s",
+		hash(msg->params[2]), msg->params[2], buf
+	);
+}
 
-	if (!strcmp(nick, self.nick)) {
-		tabClear(tag);
-	} else {
-		tabRemove(tag, nick);
+static void handleReplyEndOfNames(struct Message *msg) {
+	(void)msg;
+	if (replies[ReplyNamesAuto]) {
+		replies[ReplyNamesAuto]--;
+	} else if (replies[ReplyNames]) {
+		replies[ReplyNames]--;
 	}
+}
 
-	if (mesg) {
-		urlScan(tag, mesg);
-		uiFmt(
-			tag, UICold,
-			"\3%d%s\3 leaves \3%d%s\3, \"%s\"",
-			colorGen(user), nick, colorGen(chan), chan, dequote(mesg)
-		);
-		logFmt(tag, NULL, "%s leaves %s, \"%s\"", nick, chan, dequote(mesg));
+static void handleReplyNoTopic(struct Message *msg) {
+	require(msg, false, 2);
+	uiFormat(
+		idFor(msg->params[1]), Warm, tagTime(msg),
+		"There is no sign in \3%02d%s\3",
+		hash(msg->params[1]), msg->params[1]
+	);
+}
+
+static void topicComplete(uint id, const char *topic) {
+	char buf[512];
+	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);
+		completePush(id, buf, Default);
+	}
+}
+
+static void handleReplyTopic(struct Message *msg) {
+	require(msg, false, 3);
+	uint id = idFor(msg->params[1]);
+	topicComplete(id, msg->params[2]);
+	if (!replies[ReplyTopic] && !replies[ReplyTopicAuto]) return;
+	urlScan(id, NULL, msg->params[2]);
+	uiFormat(
+		id, (replies[ReplyTopicAuto] ? Cold : Warm), tagTime(msg),
+		"The sign in \3%02d%s\3 reads: %s",
+		hash(msg->params[1]), msg->params[1], msg->params[2]
+	);
+	logFormat(
+		id, tagTime(msg), "The sign in %s reads: %s",
+		msg->params[1], msg->params[2]
+	);
+	if (replies[ReplyTopicAuto]) {
+		replies[ReplyTopicAuto]--;
 	} else {
-		uiFmt(
-			tag, UICold,
-			"\3%d%s\3 leaves \3%d%s\3",
-			colorGen(user), nick, colorGen(chan), chan
-		);
-		logFmt(tag, NULL, "%s leaves %s", nick, chan);
+		replies[ReplyTopic]--;
 	}
 }
 
-static void handleKick(char *prefix, char *params) {
-	char *nick, *user, *chan, *kick, *mesg;
-	parse(prefix, &nick, &user, NULL, params, 2, 1, &chan, &kick, &mesg);
-	struct Tag tag = colorTag(tagFor(chan), chan);
-	bool kicked = !strcmp(kick, self.nick);
+static void swap(wchar_t *a, wchar_t *b) {
+	wchar_t x = *a;
+	*a = *b;
+	*b = x;
+}
 
-	if (kicked) {
-		tabClear(tag);
+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 {
-		tabRemove(tag, kick);
-	}
-
-	if (mesg) {
-		urlScan(tag, mesg);
-		uiFmt(
-			tag, (kicked ? UIHot : UICold),
-			"\3%d%s\3 kicks \3%d%s\3 out of \3%d%s\3, \"%s\"",
-			colorGen(user), nick,
-			colorGen(kick), kick,
-			colorGen(chan), chan,
-			dequote(mesg)
+		ptr = seprintf(ptr, end, "\26%ls\26", &str[pre]);
+	}
+	swap(&str[suf], &nul);
+	ptr = seprintf(ptr, end, "%ls", &str[suf]);
+	return ptr;
+}
+
+static void handleTopic(struct Message *msg) {
+	require(msg, true, 2);
+	uint id = idFor(msg->params[0]);
+	if (!msg->params[1][0]) {
+		topicComplete(id, NULL);
+		uiFormat(
+			id, Warm, tagTime(msg),
+			"\3%02d%s\3\tremoves the sign in \3%02d%s\3",
+			hash(msg->user), msg->nick, hash(msg->params[0]), msg->params[0]
 		);
-		logFmt(
-			tag, NULL,
-			"%s kicks %s out of %s, \"%s\"", nick, kick, chan, dequote(mesg)
+		logFormat(
+			id, tagTime(msg), "%s removes the sign in %s",
+			msg->nick, msg->params[0]
 		);
+		return;
+	}
+
+	struct Cursor curs = {0};
+	const char *prev = completePrefix(&curs, id, "/topic ");
+	if (prev) {
+		prev += 7;
 	} else {
-		uiFmt(
-			tag, (kicked ? UIHot : UICold),
-			"\3%d%s\3 kicks \3%d%s\3 out of \3%d%s\3",
-			colorGen(user), nick,
-			colorGen(kick), kick,
-			colorGen(chan), chan
-		);
-		logFmt(tag, NULL, "%s kicks %s out of %s", nick, kick, chan);
+		goto plain;
 	}
+
+	wchar_t old[512];
+	wchar_t new[512];
+	if (swprintf(old, ARRAY_LEN(old), L"%s", prev) < 0) goto plain;
+	if (swprintf(new, ARRAY_LEN(new), L"%s", msg->params[1]) < 0) goto plain;
+
+	size_t pre;
+	for (pre = 0; old[pre] && new[pre] && old[pre] == new[pre]; ++pre);
+	size_t osuf = wcslen(old);
+	size_t nsuf = wcslen(new);
+	while (osuf > pre && nsuf > pre && old[osuf-1] == new[nsuf-1]) {
+		osuf--;
+		nsuf--;
+	}
+
+	char buf[1024];
+	char *ptr = buf, *end = &buf[sizeof(buf)];
+	ptr = seprintf(
+		ptr, end, "\3%02d%s\3\ttakes down the sign in \3%02d%s\3: ",
+		hash(msg->user), msg->nick, hash(msg->params[0]), msg->params[0]
+	);
+	ptr = highlightMiddle(ptr, end, Brown, old, pre, osuf);
+	if (osuf != pre) uiWrite(id, Cold, tagTime(msg), buf);
+	ptr = buf;
+	ptr = seprintf(
+		ptr, end, "\3%02d%s\3\tplaces a new sign in \3%02d%s\3: ",
+		hash(msg->user), msg->nick, hash(msg->params[0]), msg->params[0]
+	);
+	ptr = highlightMiddle(ptr, end, Green, new, pre, nsuf);
+	uiWrite(id, Warm, tagTime(msg), buf);
+	goto log;
+
+plain:
+	uiFormat(
+		id, Warm, tagTime(msg),
+		"\3%02d%s\3\tplaces a new sign in \3%02d%s\3: %s",
+		hash(msg->user), msg->nick, hash(msg->params[0]), msg->params[0],
+		msg->params[1]
+	);
+log:
+	logFormat(
+		id, tagTime(msg), "%s places a new sign in %s: %s",
+		msg->nick, msg->params[0], msg->params[1]
+	);
+	topicComplete(id, msg->params[1]);
+	urlScan(id, msg->nick, msg->params[1]);
 }
 
-static void handleQuit(char *prefix, char *params) {
-	char *nick, *user, *mesg;
-	parse(prefix, &nick, &user, NULL, params, 0, 1, &mesg);
+static const char *UserModes[256] = {
+	['O'] = "local oper",
+	['i'] = "invisible",
+	['o'] = "oper",
+	['r'] = "registered",
+	['w'] = "wallops",
+};
+
+static void handleReplyUserModeIs(struct Message *msg) {
+	require(msg, false, 2);
+	char buf[1024];
+	char *ptr = buf, *end = &buf[sizeof(buf)];
+	for (char *ch = msg->params[1]; *ch; ++ch) {
+		if (*ch == '+') continue;
+		const char *name = UserModes[(byte)*ch];
+		ptr = seprintf(
+			ptr, end, ", +%c%s%s", *ch, (name ? " " : ""), (name ?: "")
+		);
+	}
+	uiFormat(
+		Network, Warm, tagTime(msg),
+		"\3%02d%s\3\tis %s",
+		self.color, self.nick, (ptr > buf ? &buf[2] : "modeless")
+	);
+}
 
-	struct Tag tag;
-	while (TagNone.id != (tag = tabTag(nick)).id) {
-		tabRemove(tag, nick);
+static const char *ChanModes[256] = {
+	['a'] = "protected",
+	['h'] = "halfop",
+	['i'] = "invite-only",
+	['k'] = "key",
+	['l'] = "client limit",
+	['m'] = "moderated",
+	['n'] = "no external messages",
+	['o'] = "operator",
+	['q'] = "founder",
+	['s'] = "secret",
+	['t'] = "protected topic",
+	['v'] = "voice",
+};
 
-		if (mesg) {
-			urlScan(tag, mesg);
-			uiFmt(
-				tag, UICold,
-				"\3%d%s\3 leaves, \"%s\"",
-				colorGen(user), nick, dequote(mesg)
+static void handleReplyChannelModeIs(struct Message *msg) {
+	require(msg, false, 3);
+	uint param = 3;
+	char buf[1024];
+	char *ptr = buf, *end = &buf[sizeof(buf)];
+	for (char *ch = msg->params[2]; *ch; ++ch) {
+		if (*ch == '+') continue;
+		const char *name = ChanModes[(byte)*ch];
+		if (
+			strchr(network.paramModes, *ch) ||
+			strchr(network.setParamModes, *ch)
+		) {
+			assert(param < ParamCap);
+			ptr = seprintf(
+				ptr, end, ", +%c%s%s %s",
+				*ch, (name ? " " : ""), (name ?: ""),
+				msg->params[param++]
 			);
-			logFmt(tag, NULL, "%s leaves, \"%s\"", nick, dequote(mesg));
 		} else {
-			uiFmt(tag, UICold, "\3%d%s\3 leaves", colorGen(user), nick);
-			logFmt(tag, NULL, "%s leaves", nick);
+			ptr = seprintf(
+				ptr, end, ", +%c%s%s",
+				*ch, (name ? " " : ""), (name ?: "")
+			);
 		}
 	}
+	uiFormat(
+		idFor(msg->params[1]), Warm, tagTime(msg),
+		"\3%02d%s\3\tis %s",
+		hash(msg->params[1]), msg->params[1],
+		(ptr > buf ? &buf[2] : "modeless")
+	);
 }
 
-static void handleReplyTopic(char *prefix, char *params) {
-	char *chan, *topic;
-	parse(prefix, NULL, NULL, NULL, params, 3, 0, NULL, &chan, &topic);
-	struct Tag tag = colorTag(tagFor(chan), chan);
+static void handleMode(struct Message *msg) {
+	require(msg, true, 2);
+
+	if (!strchr(network.chanTypes, msg->params[0][0])) {
+		bool set = true;
+		for (char *ch = msg->params[1]; *ch; ++ch) {
+			if (*ch == '+') { set = true; continue; }
+			if (*ch == '-') { set = false; continue; }
+			const char *name = UserModes[(byte)*ch];
+			uiFormat(
+				Network, Warm, tagTime(msg),
+				"\3%02d%s\3\t%ssets \3%02d%s\3 %c%c%s%s",
+				hash(msg->user), msg->nick,
+				(set ? "" : "un"),
+				self.color, msg->params[0],
+				set["-+"], *ch, (name ? " " : ""), (name ?: "")
+			);
+		}
+		return;
+	}
 
-	urlScan(tag, topic);
-	uiFmt(
-		tag, UICold,
-		"The sign in \3%d%s\3 reads, \"%s\"",
-		colorGen(chan), chan, topic
-	);
-	logFmt(tag, NULL, "The sign in %s reads, \"%s\"", chan, topic);
+	uint id = idFor(msg->params[0]);
+	bool set = true;
+	uint i = 2;
+	for (char *ch = msg->params[1]; *ch; ++ch) {
+		if (*ch == '+') { set = true; continue; }
+		if (*ch == '-') { set = false; continue; }
+
+		const char *verb = (set ? "sets" : "unsets");
+		const char *name = ChanModes[(byte)*ch];
+		if (*ch == network.excepts) name = "except";
+		if (*ch == network.invex) name = "invite";
+		const char *mode = (const char[]) {
+			set["-+"], *ch, (name ? ' ' : '\0'), '\0'
+		};
+		if (!name) name = "";
+
+		if (strchr(network.prefixModes, *ch)) {
+			if (i >= ParamCap || !msg->params[i]) {
+				errx(EX_PROTOCOL, "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",
+				hash(msg->user), msg->nick, verb,
+				completeColor(id, nick), prefix, nick,
+				mode, name, hash(msg->params[0]), msg->params[0]
+			);
+			logFormat(
+				id, tagTime(msg), "%s %s %c%s %s%s in %s",
+				msg->nick, verb, prefix, nick, mode, name, msg->params[0]
+			);
+		}
+
+		if (strchr(network.listModes, *ch)) {
+			if (i >= ParamCap || !msg->params[i]) {
+				errx(EX_PROTOCOL, "MODE missing %s parameter", mode);
+			}
+			char *mask = msg->params[i++];
+			if (*ch == 'b') {
+				verb = (set ? "bans" : "unbans");
+				uiFormat(
+					id, Cold, tagTime(msg),
+					"\3%02d%s\3\t%s %c%c %s from \3%02d%s\3",
+					hash(msg->user), msg->nick, verb, set["-+"], *ch, mask,
+					hash(msg->params[0]), msg->params[0]
+				);
+				logFormat(
+					id, tagTime(msg), "%s %s %c%c %s from %s",
+					msg->nick, verb, set["-+"], *ch, mask, msg->params[0]
+				);
+			} else {
+				verb = (set ? "adds" : "removes");
+				const char *to = (set ? "to" : "from");
+				uiFormat(
+					id, Cold, tagTime(msg),
+					"\3%02d%s\3\t%s %s %s the \3%02d%s\3 %s%s list",
+					hash(msg->user), msg->nick, verb, mask, to,
+					hash(msg->params[0]), msg->params[0], mode, name
+				);
+				logFormat(
+					id, tagTime(msg), "%s %s %s %s the %s %s%s list",
+					msg->nick, verb, mask, to, msg->params[0], mode, name
+				);
+			}
+		}
+
+		if (strchr(network.paramModes, *ch)) {
+			if (i >= ParamCap || !msg->params[i]) {
+				errx(EX_PROTOCOL, "MODE missing %s parameter", mode);
+			}
+			char *param = msg->params[i++];
+			uiFormat(
+				id, Cold, tagTime(msg),
+				"\3%02d%s\3\t%s \3%02d%s\3 %s%s %s",
+				hash(msg->user), msg->nick, verb,
+				hash(msg->params[0]), msg->params[0], mode, name, param
+			);
+			logFormat(
+				id, tagTime(msg), "%s %s %s %s%s %s",
+				msg->nick, verb, msg->params[0], mode, name, param
+			);
+		}
+
+		if (strchr(network.setParamModes, *ch) && set) {
+			if (i >= ParamCap || !msg->params[i]) {
+				errx(EX_PROTOCOL, "MODE missing %s parameter", mode);
+			}
+			char *param = msg->params[i++];
+			uiFormat(
+				id, Cold, tagTime(msg),
+				"\3%02d%s\3\t%s \3%02d%s\3 %s%s %s",
+				hash(msg->user), msg->nick, verb,
+				hash(msg->params[0]), msg->params[0], mode, name, param
+			);
+			logFormat(
+				id, tagTime(msg), "%s %s %s %s%s %s",
+				msg->nick, verb, msg->params[0], mode, name, param
+			);
+		} else if (strchr(network.setParamModes, *ch)) {
+			uiFormat(
+				id, Cold, tagTime(msg),
+				"\3%02d%s\3\t%s \3%02d%s\3 %s%s",
+				hash(msg->user), msg->nick, verb,
+				hash(msg->params[0]), msg->params[0], mode, name
+			);
+			logFormat(
+				id, tagTime(msg), "%s %s %s %s%s",
+				msg->nick, verb, msg->params[0], mode, name
+			);
+		}
+
+		if (strchr(network.channelModes, *ch)) {
+			uiFormat(
+				id, Cold, tagTime(msg),
+				"\3%02d%s\3\t%s \3%02d%s\3 %s%s",
+				hash(msg->user), msg->nick, verb,
+				hash(msg->params[0]), msg->params[0], mode, name
+			);
+			logFormat(
+				id, tagTime(msg), "%s %s %s %s%s",
+				msg->nick, verb, msg->params[0], mode, name
+			);
+		}
+	}
 }
 
-static void handleTopic(char *prefix, char *params) {
-	char *nick, *user, *chan, *topic;
-	parse(prefix, &nick, &user, NULL, params, 2, 0, &chan, &topic);
-	struct Tag tag = colorTag(tagFor(chan), chan);
+static void handleErrorChanopPrivsNeeded(struct Message *msg) {
+	require(msg, false, 3);
+	uiFormat(
+		idFor(msg->params[1]), Warm, tagTime(msg),
+		"%s", msg->params[2]
+	);
+}
 
-	if (strcmp(nick, self.nick)) tabTouch(tag, nick);
+static void handleErrorUserNotInChannel(struct Message *msg) {
+	require(msg, false, 4);
+	uiFormat(
+		idFor(msg->params[2]), Warm, tagTime(msg),
+		"%s\tis not in \3%02d%s\3",
+		msg->params[1], hash(msg->params[2]), msg->params[2]
+	);
+}
 
-	urlScan(tag, topic);
-	uiFmt(
-		tag, UICold,
-		"\3%d%s\3 places a new sign in \3%d%s\3, \"%s\"",
-		colorGen(user), nick, colorGen(chan), chan, topic
+static void handleErrorBanListFull(struct Message *msg) {
+	require(msg, false, 4);
+	uiFormat(
+		idFor(msg->params[1]), Warm, tagTime(msg),
+		"%s", (msg->params[4] ?: msg->params[3])
 	);
-	logFmt(tag, NULL, "%s places a new sign in %s, \"%s\"", nick, chan, topic);
 }
 
-static void handleReplyEndOfNames(char *prefix, char *params) {
-	char *chan;
-	parse(prefix, NULL, NULL, NULL, params, 2, 0, NULL, &chan);
-	ircFmt("WHO %s\r\n", chan);
+static void handleReplyBanList(struct Message *msg) {
+	require(msg, false, 3);
+	uint id = idFor(msg->params[1]);
+	if (msg->params[3] && msg->params[4]) {
+		char since[sizeof("0000-00-00 00:00:00")];
+		time_t time = strtol(msg->params[4], NULL, 10);
+		strftime(since, sizeof(since), "%F %T", localtime(&time));
+		uiFormat(
+			id, Warm, tagTime(msg),
+			"Banned from \3%02d%s\3 since %s by \3%02d%s\3: %s",
+			hash(msg->params[1]), msg->params[1],
+			since, completeColor(id, msg->params[3]), msg->params[3],
+			msg->params[2]
+		);
+	} else {
+		uiFormat(
+			id, Warm, tagTime(msg),
+			"Banned from \3%02d%s\3: %s",
+			hash(msg->params[1]), msg->params[1], msg->params[2]
+		);
+	}
 }
 
-static struct {
-	char buf[4096];
-	size_t len;
-} who;
+static void onList(const char *list, struct Message *msg) {
+	require(msg, false, 3);
+	uint id = idFor(msg->params[1]);
+	if (msg->params[3] && msg->params[4]) {
+		char since[sizeof("0000-00-00 00:00:00")];
+		time_t time = strtol(msg->params[4], NULL, 10);
+		strftime(since, sizeof(since), "%F %T", localtime(&time));
+		uiFormat(
+			id, Warm, tagTime(msg),
+			"On the \3%02d%s\3 %s list since %s by \3%02d%s\3: %s",
+			hash(msg->params[1]), msg->params[1], list,
+			since, completeColor(id, msg->params[3]), msg->params[3],
+			msg->params[2]
+		);
+	} else {
+		uiFormat(
+			id, Warm, tagTime(msg),
+			"On the \3%02d%s\3 %s list: %s",
+			hash(msg->params[1]), msg->params[1], list, msg->params[2]
+		);
+	}
+}
 
-static void handleReplyWho(char *prefix, char *params) {
-	char *chan, *user, *nick;
-	parse(
-		prefix, NULL, NULL, NULL,
-		params, 6, 0, NULL, &chan, &user, NULL, NULL, &nick
-	);
-	struct Tag tag = colorTag(tagFor(chan), chan);
+static void handleReplyExceptList(struct Message *msg) {
+	onList("except", msg);
+}
 
-	tabAdd(tag, nick);
+static void handleReplyInviteList(struct Message *msg) {
+	onList("invite", msg);
+}
 
-	size_t cap = sizeof(who.buf) - who.len;
-	int len = snprintf(
-		&who.buf[who.len], cap,
-		"%s\3%d%s\3",
-		(who.len ? ", " : ""), colorGen(user), nick
+static void handleReplyList(struct Message *msg) {
+	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] ?: "")
 	);
-	if ((size_t)len < cap) who.len += len;
 }
 
-static void handleReplyEndOfWho(char *prefix, char *params) {
-	char *chan;
-	parse(prefix, NULL, NULL, NULL, params, 2, 0, NULL, &chan);
-	struct Tag tag = colorTag(tagFor(chan), chan);
+static void handleReplyWhoisUser(struct Message *msg) {
+	require(msg, false, 6);
+	completePull(Network, msg->params[1], hash(msg->params[2]));
+	uiFormat(
+		Network, Warm, tagTime(msg),
+		"\3%02d%s\3\tis %s!%s@%s (%s\17)",
+		hash(msg->params[2]), msg->params[1],
+		msg->params[1], msg->params[2], msg->params[3], msg->params[5]
+	);
+}
 
-	uiFmt(
-		tag, UICold,
-		"In \3%d%s\3 are %s",
-		colorGen(chan), chan, who.buf
+static void handleReplyWhoisServer(struct Message *msg) {
+	if (!replies[ReplyWhois] && !replies[ReplyWhowas]) return;
+	require(msg, false, 4);
+	uiFormat(
+		Network, Warm, tagTime(msg),
+		"\3%02d%s\3\t%s connected to %s (%s)",
+		completeColor(Network, msg->params[1]), msg->params[1],
+		(replies[ReplyWhowas] ? "was" : "is"), msg->params[2], msg->params[3]
 	);
-	who.len = 0;
 }
 
-static void handleNick(char *prefix, char *params) {
-	char *prev, *user, *next;
-	parse(prefix, &prev, &user, NULL, params, 1, 0, &next);
+static void handleReplyWhoisIdle(struct Message *msg) {
+	require(msg, false, 3);
+	unsigned long idle = strtoul(msg->params[2], NULL, 10);
+	const char *unit = "second";
+	if (idle / 60) {
+		idle /= 60; unit = "minute";
+		if (idle / 60) {
+			idle /= 60; unit = "hour";
+			if (idle / 24) {
+				idle /= 24; unit = "day";
+			}
+		}
+	}
+	char signon[sizeof("0000-00-00 00:00:00")];
+	time_t time = strtol((msg->params[3] ?: ""), NULL, 10);
+	strftime(signon, sizeof(signon), "%F %T", localtime(&time));
+	uiFormat(
+		Network, Warm, tagTime(msg),
+		"\3%02d%s\3\tis idle for %lu %s%s%s%s",
+		completeColor(Network, msg->params[1]), msg->params[1],
+		idle, unit, (idle != 1 ? "s" : ""),
+		(msg->params[3] ? ", signed on " : ""), (msg->params[3] ? signon : "")
+	);
+}
 
-	if (!strcmp(prev, self.nick)) {
-		free(self.nick);
-		self.nick = strdup(next);
-		if (!self.nick) err(EX_OSERR, "strdup");
-		uiPrompt(true);
+static void handleReplyWhoisChannels(struct Message *msg) {
+	require(msg, false, 3);
+	char buf[1024];
+	char *ptr = buf, *end = &buf[sizeof(buf)];
+	while (msg->params[2]) {
+		char *channel = strsep(&msg->params[2], " ");
+		if (!channel[0]) break;
+		char *name = &channel[strspn(channel, network.prefixes)];
+		ptr = seprintf(
+			ptr, end, "%s\3%02d%s\3",
+			(ptr > buf ? ", " : ""), hash(name), channel
+		);
 	}
+	uiFormat(
+		Network, Warm, tagTime(msg),
+		"\3%02d%s\3\tis in %s",
+		completeColor(Network, msg->params[1]), msg->params[1], buf
+	);
+}
 
-	struct Tag tag;
-	while (TagNone.id != (tag = tabTag(prev)).id) {
-		tabReplace(tag, prev, next);
+static void handleReplyWhoisGeneric(struct Message *msg) {
+	require(msg, false, 3);
+	if (msg->params[3]) {
+		msg->params[0] = msg->params[2];
+		msg->params[2] = msg->params[3];
+		msg->params[3] = msg->params[0];
+	}
+	uiFormat(
+		Network, Warm, tagTime(msg),
+		"\3%02d%s\3\t%s%s%s",
+		completeColor(Network, msg->params[1]), msg->params[1],
+		msg->params[2], (msg->params[3] ? " " : ""), (msg->params[3] ?: "")
+	);
+}
 
-		uiFmt(
-			tag, UICold,
-			"\3%d%s\3 is now known as \3%d%s\3",
-			colorGen(user), prev, colorGen(user), next
-		);
-		logFmt(tag, NULL, "%s is now known as %s", prev, next);
+static void handleReplyEndOfWhois(struct Message *msg) {
+	require(msg, false, 2);
+	if (strcmp(msg->params[1], self.nick)) {
+		completeRemove(Network, msg->params[1]);
 	}
 }
 
-static void handleCTCP(struct Tag tag, char *nick, char *user, char *mesg) {
-	mesg = &mesg[1];
-	char *ctcp = strsep(&mesg, " ");
-	char *params = strsep(&mesg, "\1");
-	if (strcmp(ctcp, "ACTION")) return;
+static void handleReplyWhowasUser(struct Message *msg) {
+	require(msg, false, 6);
+	completePull(Network, msg->params[1], hash(msg->params[2]));
+	uiFormat(
+		Network, Warm, tagTime(msg),
+		"\3%02d%s\3\twas %s!%s@%s (%s)",
+		hash(msg->params[2]), msg->params[1],
+		msg->params[1], msg->params[2], msg->params[3], msg->params[5]
+	);
+}
 
-	if (strcmp(nick, self.nick)) tabTouch(tag, nick);
+static void handleReplyEndOfWhowas(struct Message *msg) {
+	require(msg, false, 2);
+	if (strcmp(msg->params[1], self.nick)) {
+		completeRemove(Network, msg->params[1]);
+	}
+}
 
-	urlScan(tag, params);
-	bool ping = strcmp(nick, self.nick) && isPing(params);
-	uiFmt(
-		tag, (ping ? UIHot : UIWarm),
-		"%c\3%d* %s\17 %s",
-		ping["\17\26"], colorGen(user), nick, params
+static void handleReplyAway(struct Message *msg) {
+	require(msg, false, 3);
+	// Might be part of a WHOIS response.
+	uint id = (replies[ReplyWhois] ? Network : idFor(msg->params[1]));
+	uiFormat(
+		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]
+	);
+	logFormat(
+		id, tagTime(msg), "%s is away: %s",
+		msg->params[1], msg->params[2]
 	);
-	logFmt(tag, NULL, "* %s %s", nick, params);
 }
 
-static void handlePrivmsg(char *prefix, char *params) {
-	char *nick, *user, *chan, *mesg;
-	parse(prefix, &nick, &user, NULL, params, 2, 0, &chan, &mesg);
-	bool direct = !strcmp(chan, self.nick);
-	struct Tag tag = tagFor(direct ? nick : chan);
-	colorTag(tag, direct ? user : chan);
-	if (mesg[0] == '\1') {
-		handleCTCP(tag, nick, user, mesg);
-		return;
+static void handleReplyNowAway(struct Message *msg) {
+	require(msg, false, 2);
+	uiFormat(Network, Warm, tagTime(msg), "%s", msg->params[1]);
+}
+
+static bool isAction(struct Message *msg) {
+	if (strncmp(msg->params[1], "\1ACTION", 7)) return false;
+	if (msg->params[1][7] == ' ') {
+		msg->params[1] += 8;
+	} else if (msg->params[1][7] == '\1') {
+		msg->params[1] += 7;
+	} else {
+		return false;
+	}
+	size_t len = strlen(msg->params[1]);
+	if (msg->params[1][len - 1] == '\1') {
+		msg->params[1][len - 1] = '\0';
 	}
+	return true;
+}
 
-	bool me = !strcmp(nick, self.nick);
-	if (!me) tabTouch(tag, nick);
+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;
+		}
+		match = &match[len];
+	}
+	return false;
+}
 
-	urlScan(tag, mesg);
-	bool hot = !me && (direct || isPing(mesg));
-	bool ping = !me && isPing(mesg);
-	uiFmt(
-		tag, (hot ? UIHot : UIWarm),
-		"%c%c\3%d<%s>%c %s",
-		(me ? IRCUnderline : IRCColor), (ping ? IRCReverse : IRCColor),
-		colorGen(user), nick, IRCReset, mesg
-	);
-	logFmt(tag, NULL, "<%s> %s", nick, mesg);
+static bool isMention(const struct Message *msg) {
+	if (matchWord(msg->params[1], self.nick)) return true;
+	for (uint i = 0; i < ARRAY_LEN(self.nicks) && self.nicks[i]; ++i) {
+		if (matchWord(msg->params[1], self.nicks[i])) return true;
+	}
+	return false;
 }
 
-static void handleNotice(char *prefix, char *params) {
-	char *nick, *user, *chan, *mesg;
-	parse(prefix, &nick, &user, NULL, params, 2, 0, &chan, &mesg);
-	bool direct = !strcmp(chan, self.nick);
-	struct Tag tag = TagStatus;
-	if (user) {
-		tag = tagFor(direct ? nick : chan);
-		colorTag(tag, direct ? user : chan);
+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, ' ');
+		if (split) split = strchr(&split[1], ' ');
+	}
+	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;
 	}
 
-	if (strcmp(nick, self.nick)) tabTouch(tag, nick);
+	while (msg < split) {
+		size_t skip = strspn(msg, ",:<> ");
+		ptr = seprintf(ptr, end, "%.*s", (int)skip, msg);
+		msg += skip;
 
-	urlScan(tag, mesg);
-	bool ping = strcmp(nick, self.nick) && isPing(mesg);
-	uiFmt(
-		tag, (ping ? UIHot : UIWarm),
-		"%c\3%d-%s-\17 %s",
-		ping["\17\26"], colorGen(user), nick, mesg
-	);
-	logFmt(tag, NULL, "-%s- %s", nick, mesg);
+		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, (int)len, msg);
+		} else {
+			ptr = p;
+		}
+		msg += len;
+	}
+
+rest:
+	return seprintf(ptr, end, "%s", msg);
 }
 
-static const struct {
-	const char *command;
-	Handler *handler;
+static void handlePrivmsg(struct Message *msg) {
+	require(msg, true, 2);
+	char statusmsg = '\0';
+	if (network.statusmsg && strchr(network.statusmsg, msg->params[0][0])) {
+		statusmsg = msg->params[0][0];
+		msg->params[0]++;
+	}
+	bool query = !strchr(network.chanTypes, msg->params[0][0]);
+	bool server = strchr(msg->nick, '.');
+	bool mine = !strcmp(msg->nick, self.nick);
+	uint id;
+	if (query && server) {
+		id = Network;
+	} else if (query && !mine) {
+		id = idFor(msg->nick);
+		idColors[id] = hash(msg->user);
+	} else {
+		id = idFor(msg->params[0]);
+	}
+
+	bool notice = (msg->cmd[0] == 'N');
+	bool action = !notice && isAction(msg);
+	bool highlight = !mine && isMention(msg);
+	enum Heat heat = (!notice && (highlight || query) ? Hot : Warm);
+	heat = filterCheck(heat, id, msg);
+	if (heat > Warm && !mine && !query) highlight = true;
+	if (!notice && !mine && heat > Ice) {
+		completePull(id, msg->nick, hash(msg->user));
+	}
+	if (heat > Ice) urlScan(id, msg->nick, msg->params[1]);
+
+	char buf[1024];
+	char *ptr = buf, *end = &buf[sizeof(buf)];
+	if (statusmsg) {
+		ptr = seprintf(
+			ptr, end, "\3%d[%c]\3 ", hash(msg->params[0]), statusmsg
+		);
+	}
+	if (notice) {
+		if (id != Network) {
+			logFormat(id, tagTime(msg), "-%s- %s", msg->nick, msg->params[1]);
+		}
+		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]);
+		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]);
+		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) {
+	require(msg, false, 1);
+	ircFormat("PONG :%s\r\n", msg->params[0]);
+}
+
+static void handleError(struct Message *msg) {
+	require(msg, false, 1);
+	errx(EX_UNAVAILABLE, "%s", msg->params[0]);
+}
+
+static const struct Handler {
+	const char *cmd;
+	int reply;
+	Handler *fn;
 } Handlers[] = {
-	{ "001", handleReplyWelcome },
-	{ "311", handleReplyWhoisUser },
-	{ "312", handleReplyWhoisServer },
-	{ "313", handleReplyWhoisOperator },
-	{ "315", handleReplyEndOfWho },
-	{ "317", handleReplyWhoisIdle },
-	{ "319", handleReplyWhoisChannels },
-	{ "322", handleReplyList },
-	{ "323", handleReplyListEnd },
-	{ "332", handleReplyTopic },
-	{ "352", handleReplyWho },
-	{ "366", handleReplyEndOfNames },
-	{ "372", handleReplyMOTD },
-	{ "375", handleReplyMOTD },
-	{ "401", handleErrorNoSuchNick },
-	{ "432", handleErrorErroneousNickname },
-	{ "433", handleErrorErroneousNickname },
-	{ "CAP", handleCap },
-	{ "ERROR", handleError },
-	{ "JOIN", handleJoin },
-	{ "KICK", handleKick },
-	{ "NICK", handleNick },
-	{ "NOTICE", handleNotice },
-	{ "PART", handlePart },
-	{ "PING", handlePing },
-	{ "PRIVMSG", handlePrivmsg },
-	{ "QUIT", handleQuit },
-	{ "TOPIC", handleTopic },
+	{ "001", 0, handleReplyWelcome },
+	{ "005", 0, handleReplyISupport },
+	{ "221", -ReplyMode, handleReplyUserModeIs },
+	{ "276", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "301", 0, handleReplyAway },
+	{ "305", -ReplyAway, handleReplyNowAway },
+	{ "306", -ReplyAway, handleReplyNowAway },
+	{ "307", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "311", +ReplyWhois, handleReplyWhoisUser },
+	{ "312", 0, handleReplyWhoisServer },
+	{ "313", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "314", +ReplyWhowas, handleReplyWhowasUser },
+	{ "317", +ReplyWhois, handleReplyWhoisIdle },
+	{ "318", -ReplyWhois, handleReplyEndOfWhois },
+	{ "319", +ReplyWhois, handleReplyWhoisChannels },
+	{ "320", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "322", +ReplyList, handleReplyList },
+	{ "323", -ReplyList, NULL },
+	{ "324", -ReplyMode, handleReplyChannelModeIs },
+	{ "330", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "331", -ReplyTopic, handleReplyNoTopic },
+	{ "332", 0, handleReplyTopic },
+	{ "335", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "338", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "341", 0, handleReplyInviting },
+	{ "346", +ReplyInvex, handleReplyInviteList },
+	{ "347", -ReplyInvex, NULL },
+	{ "348", +ReplyExcepts, handleReplyExceptList },
+	{ "349", -ReplyExcepts, NULL },
+	{ "353", 0, handleReplyNames },
+	{ "366", 0, handleReplyEndOfNames },
+	{ "367", +ReplyBan, handleReplyBanList },
+	{ "368", -ReplyBan, NULL },
+	{ "369", -ReplyWhowas, handleReplyEndOfWhowas },
+	{ "372", 0, handleReplyMOTD },
+	{ "378", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "379", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "422", 0, handleErrorNoMOTD },
+	{ "432", 0, handleErrorErroneousNickname },
+	{ "433", 0, handleErrorNicknameInUse },
+	{ "437", 0, handleErrorNicknameInUse },
+	{ "441", 0, handleErrorUserNotInChannel },
+	{ "443", 0, handleErrorUserOnChannel },
+	{ "478", 0, handleErrorBanListFull },
+	{ "482", 0, handleErrorChanopPrivsNeeded },
+	{ "671", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "704", +ReplyHelp, handleReplyHelp },
+	{ "705", +ReplyHelp, handleReplyHelp },
+	{ "706", -ReplyHelp, NULL },
+	{ "900", 0, handleReplyLoggedIn },
+	{ "904", 0, handleErrorSASLFail },
+	{ "905", 0, handleErrorSASLFail },
+	{ "906", 0, handleErrorSASLFail },
+	{ "AUTHENTICATE", 0, handleAuthenticate },
+	{ "CAP", 0, handleCap },
+	{ "CHGHOST", 0, handleChghost },
+	{ "ERROR", 0, handleError },
+	{ "FAIL", 0, handleStandardReply },
+	{ "INVITE", 0, handleInvite },
+	{ "JOIN", 0, handleJoin },
+	{ "KICK", 0, handleKick },
+	{ "MODE", 0, handleMode },
+	{ "NICK", 0, handleNick },
+	{ "NOTE", 0, handleStandardReply },
+	{ "NOTICE", 0, handlePrivmsg },
+	{ "PART", 0, handlePart },
+	{ "PING", 0, handlePing },
+	{ "PRIVMSG", 0, handlePrivmsg },
+	{ "QUIT", 0, handleQuit },
+	{ "SETNAME", 0, handleSetname },
+	{ "TOPIC", 0, handleTopic },
+	{ "WARN", 0, handleStandardReply },
 };
-static const size_t HandlersLen = sizeof(Handlers) / sizeof(Handlers[0]);
-
-void handle(char *line) {
-	char *prefix = NULL;
-	if (line[0] == ':') {
-		prefix = strsep(&line, " ") + 1;
-		if (!line) errx(EX_PROTOCOL, "unexpected eol");
-	}
-	char *command = strsep(&line, " ");
-	for (size_t i = 0; i < HandlersLen; ++i) {
-		if (strcmp(command, Handlers[i].command)) continue;
-		Handlers[i].handler(prefix, line);
-		break;
+
+static int compar(const void *cmd, const void *_handler) {
+	const struct Handler *handler = _handler;
+	return strcmp(cmd, handler->cmd);
+}
+
+void handle(struct Message *msg) {
+	if (!msg->cmd) return;
+	if (msg->tags[TagPos]) {
+		self.pos = strtoull(msg->tags[TagPos], NULL, 10);
+	}
+	const struct Handler *handler = bsearch(
+		msg->cmd, Handlers, ARRAY_LEN(Handlers), sizeof(*handler), compar
+	);
+	if (handler) {
+		if (handler->reply && !replies[abs(handler->reply)]) return;
+		if (handler->fn) handler->fn(msg);
+		if (handler->reply < 0) replies[abs(handler->reply)]--;
+	} else if (strcmp(msg->cmd, "400") >= 0 && strcmp(msg->cmd, "599") <= 0) {
+		handleErrorGeneric(msg);
+	} else if (isdigit(msg->cmd[0])) {
+		handleReplyGeneric(msg);
 	}
 }
diff --git a/input.c b/input.c
index 8be8eaf..6b33b93 100644
--- a/input.c
+++ b/input.c
@@ -1,276 +1,629 @@
-/* Copyright (C) 2018  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 Affero General Public License as published by
+ * 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 Affero General Public License for more details.
+ * GNU General Public License for more details.
  *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ * 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 <ctype.h>
+#define _XOPEN_SOURCE_EXTENDED
+
+#include <assert.h>
+#include <curses.h>
 #include <err.h>
+#include <signal.h>
 #include <stdbool.h>
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
 #include <sysexits.h>
+#include <termios.h>
+#include <unistd.h>
+#include <wchar.h>
+#include <wctype.h>
 
 #include "chat.h"
+#include "edit.h"
 
-static void privmsg(struct Tag tag, bool action, const char *mesg) {
-	char *line;
-	int send;
-	asprintf(
-		&line, ":%s!%s %nPRIVMSG %s :%s%s%s",
-		self.nick, self.user, &send, tag.name,
-		(action ? "\1ACTION " : ""), mesg, (action ? "\1" : "")
-	);
-	if (!line) err(EX_OSERR, "asprintf");
-	ircFmt("%s\r\n", &line[send]);
-	handle(line);
-	free(line);
-}
+#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")
 
-typedef void Handler(struct Tag tag, char *params);
+enum {
+	KeyMax = KEY_MAX,
+#define X(id, seq, alt) id,
+	ENUM_KEY
+#undef X
+};
 
-static void inputJoin(struct Tag tag, char *params) {
-	char *chan = strsep(&params, " ");
-	char *key = strsep(&params, " ");
-	if (key) {
-		ircFmt("JOIN %s %s\r\n", chan, key);
-	} else {
-		ircFmt("JOIN %s\r\n", chan ? chan : tag.name);
-	}
-}
+static struct Edit cut;
+static struct Edit edits[IDCap];
 
-static void inputList(struct Tag tag, char *params) {
-	(void)tag;
-	char *chan = strsep(&params, " ");
-	if (chan) {
-		ircFmt("LIST %s\r\n", chan);
-	} else {
-		ircFmt("LIST\r\n");
+void inputInit(void) {
+	for (size_t i = 0; i < ARRAY_LEN(edits); ++i) {
+		edits[i].cut = &cut;
 	}
-}
 
-static void inputMe(struct Tag tag, char *params) {
-	privmsg(tag, true, params ? params : "");
-}
+	struct termios term;
+	int error = tcgetattr(STDOUT_FILENO, &term);
+	if (error) err(EX_OSERR, "tcgetattr");
 
-static void inputNick(struct Tag tag, char *params) {
-	char *nick = strsep(&params, " ");
-	if (!nick) {
-		uiLog(tag, UIHot, L"/nick requires a name");
-		return;
-	}
-	ircFmt("NICK %s\r\n", nick);
-}
+	// Gain use of C-q, C-s, C-c, C-z, C-y, C-v, C-o.
+	term.c_iflag &= ~IXON;
+	term.c_cc[VINTR] = _POSIX_VDISABLE;
+	term.c_cc[VSUSP] = _POSIX_VDISABLE;
+#ifdef VDSUSP
+	term.c_cc[VDSUSP] = _POSIX_VDISABLE;
+#endif
+	term.c_cc[VLNEXT] = _POSIX_VDISABLE;
+	term.c_cc[VDISCARD] = _POSIX_VDISABLE;
+
+	error = tcsetattr(STDOUT_FILENO, TCSANOW, &term);
+	if (error) err(EX_OSERR, "tcsetattr");
+
+	def_prog_mode();
+
+#define X(id, seq, alt) define_key(seq, id); if (alt) define_key(alt, id);
+	ENUM_KEY
+#undef X
 
-static void inputPart(struct Tag tag, char *params) {
-	ircFmt("PART %s :%s\r\n", tag.name, params ? params : "Goodbye");
+	keypad(uiInput, true);
+	nodelay(uiInput, true);
 }
 
-static void inputQuery(struct Tag tag, char *params) {
-	char *nick = strsep(&params, " ");
-	if (!nick) {
-		uiLog(tag, UIHot, L"/query requires a nick");
-		return;
+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;
 	}
-	tabTouch(TagNone, nick);
-	uiShowTag(tagFor(nick));
-	logReplay(tagFor(nick));
 }
 
-static void inputQuit(struct Tag tag, char *params) {
-	(void)tag;
-	ircQuit(params ? params : "Goodbye");
+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 void inputQuote(struct Tag tag, char *params) {
-	(void)tag;
-	if (params) ircFmt("%s\r\n", params);
-}
+static size_t cap;
+static char *buf;
+
+void inputUpdate(void) {
+	uint id = windowID();
+
+	size_t pos = 0;
+	const char *ptr = editString(&edits[id], &buf, &cap, &pos);
+	if (!ptr) err(EX_OSERR, "editString");
 
-static void inputTopic(struct Tag tag, char *params) {
-	if (params) {
-		ircFmt("TOPIC %s :%s\r\n", tag.name, params);
+	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 {
-		ircFmt("TOPIC %s\r\n", tag.name);
+		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);
 }
 
-static void inputWho(struct Tag tag, char *params) {
-	(void)params;
-	ircFmt("WHO :%s\r\n", tag.name);
+bool inputPending(uint id) {
+	return edits[id].len;
 }
 
-static void inputWhois(struct Tag tag, char *params) {
-	char *nick = strsep(&params, " ");
-	if (!nick) {
-		uiLog(tag, UIHot, L"/whois requires a nick");
-		return;
+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);
 	}
-	ircFmt("WHOIS %s\r\n", nick);
 }
 
-static void inputZNC(struct Tag tag, char *params) {
-	(void)tag;
-	ircFmt("ZNC %s\r\n", params ? params : "");
+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 void inputClose(struct Tag tag, char *params) {
-	(void)params;
-	uiCloseTag(tag);
-	tabRemove(TagNone, tag.name);
+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 inputMan(struct Tag tag, char *params) {
-	(void)tag;
-	(void)params;
-	eventWait((const char *[]) { "man", "1", "catgirl", NULL });
+static void tabReject(void) {
+	completeReject(&tab.curs);
+	tab.len = 0;
 }
 
-static void inputMove(struct Tag tag, char *params) {
-	char *num = strsep(&params, " ");
-	if (!num) {
-		uiLog(tag, UIHot, L"/move requires a number");
-		return;
+static int tabComplete(struct Edit *e, uint id) {
+	if (tab.len && id != tab.id) {
+		tabAccept();
 	}
-	uiMoveTag(tag, strtol(num, NULL, 0), num[0] == '+' || num[0] == '-');
-}
 
-static void inputOpen(struct Tag tag, char *params) {
-	if (params && !isdigit(params[0])) {
-		urlOpenMatch(tag, params);
+	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 {
-		size_t at = (params ? strtoul(strsep(&params, "-,"), NULL, 0) : 1);
-		size_t to = (params ? strtoul(params, NULL, 0) : at);
-		urlOpenRange(tag, at - 1, to);
+		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 inputRaw(struct Tag tag, char *params) {
-	(void)tag;
-	(void)params;
-	self.raw ^= true;
-	uiFmt(
-		TagRaw, UIWarm, "\3%d%s\3 %s raw mode!",
-		colorGen(self.user), self.nick, (self.raw ? "engages" : "disengages")
-	);
+static void inputEnter(void) {
+	uint id = windowID();
+	char *cmd = editString(&edits[id], &buf, &cap, NULL);
+	if (!cmd) err(EX_OSERR, "editString");
+
+	tabAccept();
+	editFn(&edits[id], EditClear);
+	command(id, cmd);
 }
 
-static void inputURL(struct Tag tag, char *params) {
-	(void)params;
-	urlList(tag);
+static void keyCode(int code) {
+	int error = 0;
+	struct Edit *edit = &edits[windowID()];
+	switch (code) {
+		break; case KEY_RESIZE:  uiResize();
+		break; case KeyFocusIn:  windowUnmark();
+		break; case KeyFocusOut: windowMark();
+
+		break; case KeyMetaEnter: error = editInsert(edit, L'\n');
+		break; case KeyMetaEqual: windowToggleMute();
+		break; case KeyMetaMinus: windowToggleThresh(-1);
+		break; case KeyMetaPlus:  windowToggleThresh(+1);
+		break; case KeyMetaSlash: windowSwap();
+
+		break; case KeyMetaGt: windowScroll(ScrollAll, -1);
+		break; case KeyMetaLt: windowScroll(ScrollAll, +1);
+
+		break; case KeyMeta0 ... KeyMeta9: windowShow(code - KeyMeta0);
+		break; case KeyMetaA: windowAuto();
+		break; case KeyMetaB: error = editFn(edit, EditPrevWord);
+		break; case KeyMetaD: error = editFn(edit, EditDeleteNextWord);
+		break; case KeyMetaF: error = editFn(edit, EditNextWord);
+		break; case KeyMetaL: windowBare();
+		break; case KeyMetaM: uiWrite(windowID(), Warm, NULL, "");
+		break; case KeyMetaN: windowScroll(ScrollHot, +1);
+		break; case KeyMetaP: windowScroll(ScrollHot, -1);
+		break; case KeyMetaQ: error = editFn(edit, EditCollapse);
+		break; case KeyMetaS: uiSpoilerReveal ^= true; windowUpdate();
+		break; case KeyMetaT: windowToggleTime();
+		break; case KeyMetaU: windowScroll(ScrollUnread, 0);
+		break; case KeyMetaV: windowScroll(ScrollPage, +1);
+
+		break; case KeyCtrlLeft: error = editFn(edit, EditPrevWord);
+		break; case KeyCtrlRight: error = editFn(edit, EditNextWord);
+
+		break; case KEY_BACKSPACE: error = editFn(edit, EditDeletePrev);
+		break; case KEY_DC: error = editFn(edit, EditDeleteNext);
+		break; case KEY_DOWN: windowScroll(ScrollOne, -1);
+		break; case KEY_END: error = editFn(edit, EditTail);
+		break; case KEY_ENTER: inputEnter();
+		break; case KEY_HOME: error = editFn(edit, EditHead);
+		break; case KEY_LEFT: error = editFn(edit, EditPrev);
+		break; case KEY_NPAGE: windowScroll(ScrollPage, -1);
+		break; case KEY_PPAGE: windowScroll(ScrollPage, +1);
+		break; case KEY_RIGHT: error = editFn(edit, EditNext);
+		break; case KEY_SEND: windowScroll(ScrollAll, -1);
+		break; case KEY_SHOME: windowScroll(ScrollAll, +1);
+		break; case KEY_UP: windowScroll(ScrollOne, +1);
+	}
+	if (error) err(EX_OSERR, "editFn");
 }
 
-static void inputWindow(struct Tag tag, char *params) {
-	char *word = strsep(&params, " ");
-	if (!word) {
-		uiLog(tag, UIHot, L"/window requires a name or number");
-		return;
+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);
 	}
-	bool relative = (word[0] == '+' || word[0] == '-');
-	char *trail;
-	int num = strtol(word, &trail, 0);
-	if (!trail[0]) {
-		uiShowNum(num, relative);
-	} else {
-		struct Tag name = tagFind(word);
-		if (name.id != TagNone.id) {
-			uiShowTag(name);
-		} else {
-			uiFmt(tag, UIHot, "No window for %s", word);
+	if (error) err(EX_OSERR, "editFn");
+}
+
+static void keyStyle(wchar_t ch) {
+	if (iswcntrl(ch)) ch = towlower(ch ^ L'@');
+	char buf[8] = {0};
+	enum Color color = Default;
+	switch (ch) {
+		break; case L'A': color = Gray;
+		break; case L'B': color = Blue;
+		break; case L'C': color = Cyan;
+		break; case L'G': color = Green;
+		break; case L'K': color = Black;
+		break; case L'M': color = Magenta;
+		break; case L'N': color = Brown;
+		break; case L'O': color = Orange;
+		break; case L'P': color = Pink;
+		break; case L'R': color = Red;
+		break; case L'W': color = White;
+		break; case L'Y': color = Yellow;
+		break; case L'b': buf[0] = B;
+		break; case L'c': buf[0] = C;
+		break; case L'i': buf[0] = I;
+		break; case L'o': buf[0] = O;
+		break; case L'r': buf[0] = R;
+		break; case L's': {
+			snprintf(buf, sizeof(buf), "%c%02d,%02d", C, Black, Black);
 		}
+		break; case L'u': buf[0] = U;
+	}
+	if (color != Default) {
+		snprintf(buf, sizeof(buf), "%c%02d", C, color);
+	}
+	struct Edit *edit = &edits[windowID()];
+	for (char *ch = buf; *ch; ++ch) {
+		int error = editInsert(edit, *ch);
+		if (error) err(EX_OSERR, "editInsert");
 	}
 }
 
-static const struct {
-	const char *command;
-	Handler *handler;
-	bool limit;
-} Commands[] = {
-	{ "/close", .handler = inputClose },
-	{ "/help", .handler = inputMan },
-	{ "/join", .handler = inputJoin, .limit = true },
-	{ "/list", .handler = inputList },
-	{ "/man", .handler = inputMan },
-	{ "/me", .handler = inputMe },
-	{ "/move", .handler = inputMove },
-	{ "/names", .handler = inputWho },
-	{ "/nick", .handler = inputNick },
-	{ "/open", .handler = inputOpen },
-	{ "/part", .handler = inputPart },
-	{ "/query", .handler = inputQuery, .limit = true },
-	{ "/quit", .handler = inputQuit },
-	{ "/quote", .handler = inputQuote, .limit = true },
-	{ "/raw", .handler = inputRaw, .limit = true },
-	{ "/topic", .handler = inputTopic },
-	{ "/url", .handler = inputURL },
-	{ "/who", .handler = inputWho },
-	{ "/whois", .handler = inputWhois },
-	{ "/window", .handler = inputWindow },
-	{ "/znc", .handler = inputZNC },
-};
-static const size_t CommandsLen = sizeof(Commands) / sizeof(Commands[0]);
+static bool waiting;
 
-void inputTab(void) {
-	for (size_t i = 0; i < CommandsLen; ++i) {
-		tabTouch(TagNone, Commands[i].command);
-	}
+void inputWait(void) {
+	waiting = true;
 }
 
-void input(struct Tag tag, char *input) {
-	bool slash = (input[0] == '/');
-	if (slash) {
-		char *space = strchr(&input[1], ' ');
-		char *extra = strchr(&input[1], '/');
-		if (extra && (!space || extra < space)) slash = false;
+void inputRead(void) {
+	if (isendwin()) {
+		if (waiting) {
+			uiShow();
+			flushinp();
+			waiting = false;
+		} else {
+			return;
+		}
 	}
 
-	if (!slash) {
-		if (tag.id == TagRaw.id) {
-			ircFmt("%s\r\n", input);
-		} else if (tag.id != TagStatus.id) {
-			privmsg(tag, false, input);
+	wint_t ch;
+	static bool paste, style, literal;
+	for (int ret; ERR != (ret = wget_wch(uiInput, &ch));) {
+		bool tabbing = false;
+		size_t pos = edits[tab.id].pos;
+		bool spr = uiSpoilerReveal;
+
+		if (ret == KEY_CODE_YES && ch == KeyPasteOn) {
+			paste = true;
+		} else if (ret == KEY_CODE_YES && ch == KeyPasteOff) {
+			paste = false;
+		} else if (ret == KEY_CODE_YES && ch == KeyPasteManual) {
+			paste ^= true;
+		} else if (paste || literal) {
+			int error = editInsert(&edits[windowID()], ch);
+			if (error) err(EX_OSERR, "editInsert");
+		} else if (ret == KEY_CODE_YES) {
+			keyCode(ch);
+		} else if (ch == (L'Z' ^ L'@')) {
+			style = true;
+			continue;
+		} else if (style && ch == (L'V' ^ L'@')) {
+			literal = true;
+			continue;
+		} else if (style) {
+			keyStyle(ch);
+		} else if (iswcntrl(ch)) {
+			tabbing = (ch == (L'I' ^ L'@'));
+			keyCtrl(ch);
+		} else {
+			int error = editInsert(&edits[windowID()], ch);
+			if (error) err(EX_OSERR, "editInsert");
 		}
-		return;
-	}
+		style = false;
+		literal = false;
 
-	char *word = strsep(&input, " ");
-	if (input && !input[0]) input = NULL;
+		if (!tabbing) {
+			if (edits[tab.id].pos > pos) {
+				tabAccept();
+			} else if (edits[tab.id].pos < pos) {
+				tabReject();
+			}
+		}
 
-	char *trail;
-	strtol(&word[1], &trail, 0);
-	if (!trail[0]) {
-		inputWindow(tag, &word[1]);
-		return;
+		if (spr) {
+			uiSpoilerReveal = false;
+			windowUpdate();
+		}
 	}
+	inputUpdate();
+}
 
-	const char *command = word;
-	const char *uniq = tabNext(TagNone, command);
-	if (uniq && tabNext(TagNone, command) == uniq) {
-		command = uniq;
-		tabAccept();
-	} else {
-		tabReject();
+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, "");
+}
 
-	for (size_t i = 0; i < CommandsLen; ++i) {
-		if (strcasecmp(command, Commands[i].command)) continue;
-		if (self.limit && Commands[i].limit) {
-			uiFmt(tag, UIHot, "%s isn't available in restricted mode", command);
-			return;
-		}
-		Commands[i].handler(tag, input);
-		return;
+static ssize_t readString(FILE *file, char **buf, size_t *cap) {
+	ssize_t len = getdelim(buf, cap, '\0', file);
+	if (len < 0 && !feof(file)) err(EX_IOERR, "getdelim");
+	return len;
+}
+
+void inputLoad(FILE *file, size_t version) {
+	if (version < 8) return;
+	while (0 < readString(file, &buf, &cap) && buf[0]) {
+		uint id = idFor(buf);
+		readString(file, &buf, &cap);
+		size_t max = strlen(buf);
+		int error = editReserve(&edits[id], 0, max);
+		if (error) err(EX_OSERR, "editReserve");
+		size_t len = mbstowcs(edits[id].buf, buf, max);
+		assert(len != (size_t)-1);
+		edits[id].len = len;
+		edits[id].pos = len;
 	}
-	uiFmt(tag, UIHot, "%s isn't a recognized command", command);
 }
diff --git a/irc.c b/irc.c
index 66ffddb..1fc2c3f 100644
--- a/irc.c
+++ b/irc.c
@@ -1,21 +1,35 @@
-/* Copyright (C) 2018  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 Affero General Public License as published by
+ * 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 Affero General Public License for more details.
+ * GNU General Public License for more details.
  *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ * 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 <assert.h>
 #include <err.h>
+#include <errno.h>
 #include <fcntl.h>
+#include <limits.h>
 #include <netdb.h>
 #include <netinet/in.h>
 #include <stdarg.h>
@@ -23,6 +37,7 @@
 #include <stdlib.h>
 #include <string.h>
 #include <sys/socket.h>
+#include <sys/stat.h>
 #include <sysexits.h>
 #include <tls.h>
 #include <unistd.h>
@@ -30,59 +45,160 @@
 #include "chat.h"
 
 static struct tls *client;
+static struct tls_config *config;
 
-int ircConnect(void) {
-	int error;
+void ircConfig(
+	bool insecure, const char *trust, const char *cert, const char *priv
+) {
+	int error = 0;
+	char buf[PATH_MAX];
+
+	config = tls_config_new();
+	if (!config) errx(EX_SOFTWARE, "tls_config_new");
 
-	struct tls_config *config = tls_config_new();
-	error = tls_config_set_ciphers(config, "compat");
-	if (error) errx(EX_SOFTWARE, "tls_config");
+	if (insecure) {
+		tls_config_insecure_noverifycert(config);
+		tls_config_insecure_noverifyname(config);
+	}
+	if (trust) {
+		tls_config_insecure_noverifyname(config);
+		for (int i = 0; configPath(buf, sizeof(buf), trust, i); ++i) {
+			error = tls_config_set_ca_file(config, buf);
+			if (!error) break;
+		}
+		if (error) errx(EX_NOINPUT, "%s: %s", trust, tls_config_error(config));
+	}
+
+	// Explicitly load the default CA cert file on OpenBSD now so it doesn't
+	// need to be unveiled. Other systems might use a CA directory, so avoid
+	// changing the default behavior.
+#ifdef __OpenBSD__
+	if (!insecure && !trust) {
+		const char *ca = tls_default_ca_cert_file();
+		error = tls_config_set_ca_file(config, ca);
+		if (error) errx(EX_OSFILE, "%s: %s", ca, tls_config_error(config));
+	}
+#endif
+
+	if (cert) {
+		for (int i = 0; configPath(buf, sizeof(buf), cert, i); ++i) {
+			if (priv) {
+				error = tls_config_set_cert_file(config, buf);
+			} else {
+				error = tls_config_set_keypair_file(config, buf, buf);
+			}
+			if (!error) break;
+		}
+		if (error) errx(EX_NOINPUT, "%s: %s", cert, tls_config_error(config));
+	}
+	if (priv) {
+		for (int i = 0; configPath(buf, sizeof(buf), priv, i); ++i) {
+			error = tls_config_set_key_file(config, buf);
+			if (!error) break;
+		}
+		if (error) errx(EX_NOINPUT, "%s: %s", priv, tls_config_error(config));
+	}
 
 	client = tls_client();
 	if (!client) errx(EX_SOFTWARE, "tls_client");
 
 	error = tls_configure(client, config);
 	if (error) errx(EX_SOFTWARE, "tls_configure: %s", tls_error(client));
-	tls_config_free(config);
+}
 
+int ircConnect(const char *bindHost, const char *host, const char *port) {
+	assert(client);
+
+	int error;
+	int sock = -1;
 	struct addrinfo *head;
 	struct addrinfo hints = {
 		.ai_family = AF_UNSPEC,
 		.ai_socktype = SOCK_STREAM,
 		.ai_protocol = IPPROTO_TCP,
 	};
-	error = getaddrinfo(self.host, self.port, &hints, &head);
-	if (error) errx(EX_NOHOST, "getaddrinfo: %s", gai_strerror(error));
 
-	int sock = -1;
+	if (bindHost) {
+		error = getaddrinfo(bindHost, NULL, &hints, &head);
+		if (error) errx(EX_NOHOST, "%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");
+
+			error = bind(sock, ai->ai_addr, ai->ai_addrlen);
+			if (!error) {
+				hints.ai_family = ai->ai_family;
+				break;
+			}
+
+			close(sock);
+			sock = -1;
+		}
+		if (sock < 0) err(EX_UNAVAILABLE, "%s", bindHost);
+		freeaddrinfo(head);
+	}
+
+	error = getaddrinfo(host, port, &hints, &head);
+	if (error) errx(EX_NOHOST, "%s:%s: %s", host, port, 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) {
+			sock = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
+			if (sock < 0) err(EX_OSERR, "socket");
+		}
 
 		error = connect(sock, ai->ai_addr, ai->ai_addrlen);
 		if (!error) break;
+		if (error && errno == EINTR) break; // connect continues asynchronously
 
 		close(sock);
 		sock = -1;
 	}
-	if (sock < 0) err(EX_UNAVAILABLE, "connect");
+	if (sock < 0) err(EX_UNAVAILABLE, "%s:%s", host, port);
 	freeaddrinfo(head);
 
-	error = fcntl(sock, F_SETFD, FD_CLOEXEC);
-	if (error) err(EX_IOERR, "fcntl");
-
-	error = tls_connect_socket(client, sock, self.host);
+	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 (self.auth) ircFmt("CAP REQ :sasl\r\n");
-	if (self.pass) ircFmt("PASS :%s\r\n", self.pass);
-	ircFmt("NICK %s\r\n", self.nick);
-	ircFmt("USER %s 0 * :%s\r\n", self.user, self.real);
-
 	return sock;
 }
 
-void ircWrite(const char *ptr, size_t len) {
+void ircHandshake(void) {
+	int error;
+	do {
+		error = tls_handshake(client);
+	} while (error == TLS_WANT_POLLIN || error == TLS_WANT_POLLOUT);
+	if (error) errx(EX_PROTOCOL, "tls_handshake: %s", tls_error(client));
+
+	tls_config_clear_keys(config);
+}
+
+void ircPrintCert(void) {
+	size_t len;
+	ircHandshake();
+	const byte *pem = tls_peer_cert_chain_pem(client, &len);
+	printf("subject= %s\n", tls_peer_cert_subject(client));
+	fwrite(pem, len, 1, stdout);
+}
+
+enum { MessageCap = 8191 + 512 };
+
+static void debug(const char *pre, const char *line) {
+	if (!self.debug) return;
+	size_t len = strcspn(line, "\r\n");
+	uiFormat(
+		Debug, Cold, NULL, "\3%02d%s\3\t%.*s",
+		Gray, pre, (int)len, line
+	);
+	if (!isatty(STDERR_FILENO)) {
+		fprintf(stderr, "%s %.*s\n", pre, (int)len, line);
+	}
+}
+
+void ircSend(const char *ptr, size_t len) {
+	assert(client);
 	while (len) {
 		ssize_t ret = tls_write(client, ptr, len);
 		if (ret == TLS_WANT_POLLIN || ret == TLS_WANT_POLLOUT) continue;
@@ -92,53 +208,106 @@ void ircWrite(const char *ptr, size_t len) {
 	}
 }
 
-void ircFmt(const char *format, ...) {
-	char *buf;
+void ircFormat(const char *format, ...) {
+	char buf[MessageCap];
 	va_list ap;
 	va_start(ap, format);
-	int len =  vasprintf(&buf, format, ap);
+	int len = vsnprintf(buf, sizeof(buf), format, ap);
 	va_end(ap);
-	if (!buf) err(EX_OSERR, "vasprintf");
-	if (self.raw) {
-		if (!isatty(STDERR_FILENO)) fprintf(stderr, "<<< %.*s\n", len - 2, buf);
-		uiFmt(TagRaw, UICold, "\3%d<<<\3 %.*s", IRCWhite, len - 2, buf);
-	}
-	ircWrite(buf, len);
-	free(buf);
+	assert((size_t)len < sizeof(buf));
+	debug("<<", buf);
+	ircSend(buf, len);
 }
 
-void ircQuit(const char *mesg) {
-	ircFmt("QUIT :%s\r\n", mesg);
-	self.quit = true;
+static const char *TagNames[TagCap] = {
+#define X(name, id) [id] = name,
+	ENUM_TAG
+#undef X
+};
+
+static void unescape(char *tag) {
+	for (;;) {
+		tag = strchr(tag, '\\');
+		if (!tag) break;
+		switch (tag[1]) {
+			break; case ':': tag[1] = ';';
+			break; case 's': tag[1] = ' ';
+			break; case 'r': tag[1] = '\r';
+			break; case 'n': tag[1] = '\n';
+		}
+		memmove(tag, &tag[1], strlen(&tag[1]) + 1);
+		if (tag[0]) tag = &tag[1];
+	}
 }
 
-void ircRead(void) {
-	static char buf[4096];
-	static size_t len;
-
-	ssize_t read;
-retry:
-	read = tls_read(client, &buf[len], sizeof(buf) - len);
-	if (read == TLS_WANT_POLLIN || read == TLS_WANT_POLLOUT) goto retry;
-	if (read < 0) errx(EX_IOERR, "tls_read: %s", tls_error(client));
-	if (!read) {
-		if (!self.quit) errx(EX_PROTOCOL, "unexpected eof");
-		uiExit(EX_OK);
+static struct Message parse(char *line) {
+	struct Message msg = { .cmd = NULL };
+
+	if (line[0] == '@') {
+		char *tags = 1 + strsep(&line, " ");
+		while (tags) {
+			char *tag = strsep(&tags, ";");
+			char *key = strsep(&tag, "=");
+			for (uint i = 0; i < TagCap; ++i) {
+				if (strcmp(key, TagNames[i])) continue;
+				if (tag) {
+					unescape(tag);
+					msg.tags[i] = tag;
+				} else {
+					msg.tags[i] = "";
+				}
+				break;
+			}
+		}
+	}
+
+	if (line[0] == ':') {
+		char *origin = 1 + strsep(&line, " ");
+		msg.nick = strsep(&origin, "!");
+		msg.user = strsep(&origin, "@");
+		msg.host = origin;
+	}
+
+	msg.cmd = strsep(&line, " ");
+	for (uint i = 0; line && i < ParamCap; ++i) {
+		if (line[0] == ':') {
+			msg.params[i] = &line[1];
+			break;
+		}
+		msg.params[i] = strsep(&line, " ");
 	}
-	len += read;
+
+	return msg;
+}
+
+void ircRecv(void) {
+	static char buf[MessageCap];
+	static size_t len = 0;
+
+	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");
+	len += ret;
 
 	char *crlf;
 	char *line = buf;
-	while (NULL != (crlf = memmem(line, &buf[len] - line, "\r\n", 2))) {
-		crlf[0] = '\0';
-		if (self.raw) {
-			if (!isatty(STDERR_FILENO)) fprintf(stderr, ">>> %s\n", line);
-			uiFmt(TagRaw, UICold, "\3%d>>>\3 %s", IRCGray, line);
-		}
-		handle(line);
-		line = &crlf[2];
+	for (;;) {
+		crlf = memmem(line, &buf[len] - line, "\r\n", 2);
+		if (!crlf) break;
+		*crlf = '\0';
+		debug(">>", line);
+		struct Message msg = parse(line);
+		handle(&msg);
+		line = crlf + 2;
 	}
 
 	len -= line - buf;
 	memmove(buf, line, len);
 }
+
+void ircClose(void) {
+	tls_close(client);
+	tls_free(client);
+}
diff --git a/log.c b/log.c
index 2681bac..d6b3f2a 100644
--- a/log.c
+++ b/log.c
@@ -1,157 +1,169 @@
-/* Copyright (C) 2018  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 Affero General Public License as published by
+ * 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 Affero General Public License for more details.
+ * GNU General Public License for more details.
  *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ * 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 <assert.h>
 #include <err.h>
 #include <errno.h>
 #include <fcntl.h>
+#include <limits.h>
+#include <stdarg.h>
 #include <stdio.h>
 #include <stdlib.h>
-#include <string.h>
 #include <sys/stat.h>
 #include <sysexits.h>
 #include <time.h>
+#include <unistd.h>
+
+#ifdef __FreeBSD__
+#include <capsicum_helpers.h>
+#endif
 
 #include "chat.h"
 
-static int logRoot = -1;
+static int logDir = -1;
 
-static struct Log {
-	int dir;
-	int year;
-	int month;
-	int day;
-	FILE *file;
-} logs[TagsLen];
+void logOpen(void) {
+	char buf[PATH_MAX];
+	int error = mkdir(dataPath(buf, sizeof(buf), "", 0), S_IRWXU);
+	if (error && errno != EEXIST) err(EX_CANTCREAT, "%s", buf);
+
+	error = mkdir(dataPath(buf, sizeof(buf), "log", 0), S_IRWXU);
+	if (error && errno != EEXIST) err(EX_CANTCREAT, "%s", buf);
+
+	logDir = open(buf, O_RDONLY | O_CLOEXEC);
+	if (logDir < 0) err(EX_CANTCREAT, "%s", buf);
 
-void logOpen(const char *path) {
-	logRoot = open(path, O_RDONLY | O_CLOEXEC);
-	if (logRoot < 0) err(EX_CANTCREAT, "%s", path);
+#ifdef __FreeBSD__
+	cap_rights_t rights;
+	cap_rights_init(
+		&rights, CAP_MKDIRAT, CAP_CREATE, CAP_WRITE,
+		/* for fdopen(3) */ CAP_FCNTL, CAP_FSTAT
+	);
+	error = caph_rights_limit(logDir, &rights);
+	if (error) err(EX_OSERR, "cap_rights_limit");
+#endif
+}
+
+static void logMkdir(const char *path) {
+	int error = mkdirat(logDir, path, S_IRWXU);
+	if (error && errno != EEXIST) err(EX_CANTCREAT, "log/%s", path);
 }
 
-static void sanitize(char *name) {
-	for (; name[0]; ++name) {
-		if (name[0] == '/') name[0] = '_';
+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 FILE *logFile(struct Tag tag, const struct tm *time) {
-	struct Log *log = &logs[tag.id];
-	if (
-		log->file
-		&& log->year == time->tm_year
-		&& log->month == time->tm_mon
-		&& log->day == time->tm_mday
-	) return log->file;
+static struct {
+	int year;
+	int month;
+	int day;
+	FILE *file;
+} logs[IDCap];
 
-	if (log->file) {
-		fclose(log->file);
+static FILE *logFile(uint id, const struct tm *tm) {
+	if (
+		logs[id].file &&
+		logs[id].year == tm->tm_year &&
+		logs[id].month == tm->tm_mon &&
+		logs[id].day == tm->tm_mday
+	) return logs[id].file;
+
+	if (logs[id].file) {
+		int error = fclose(logs[id].file);
+		if (error) err(EX_IOERR, "%s", idNames[id]);
+	}
 
-	} else {
-		char *name = strdup(tag.name);
-		if (!name) err(EX_OSERR, "strdup");
-		sanitize(name);
+	logs[id].year = tm->tm_year;
+	logs[id].month = tm->tm_mon;
+	logs[id].day = tm->tm_mday;
 
-		int error = mkdirat(logRoot, name, 0700);
-		if (error && errno != EEXIST) err(EX_CANTCREAT, "%s", name);
+	char path[PATH_MAX];
+	char *ptr = path, *end = &path[sizeof(path)];
 
-		log->dir = openat(logRoot, name, O_RDONLY | O_CLOEXEC);
-		if (log->dir < 0) err(EX_CANTCREAT, "%s", name);
+	ptr = seprintf(ptr, end, "%s", network.name);
+	sanitize(path, ptr);
+	logMkdir(path);
 
-		free(name);
-	}
+	char *name = ptr;
+	ptr = seprintf(ptr, end, "/%s", idNames[id]);
+	sanitize(&name[1], ptr);
+	logMkdir(path);
 
-	log->year = time->tm_year;
-	log->month = time->tm_mon;
-	log->day = time->tm_mday;
+	size_t len = strftime(ptr, end - ptr, "/%F.log", tm);
+	if (!len) errx(EX_CANTCREAT, "log path too long");
 
-	char path[sizeof("YYYY-MM-DD.log")];
-	strftime(path, sizeof(path), "%F.log", time);
 	int fd = openat(
-		log->dir, path, O_RDWR | O_APPEND | O_CREAT | O_CLOEXEC, 0600
+		logDir, path,
+		O_WRONLY | O_APPEND | O_CREAT | O_CLOEXEC,
+		S_IRUSR | S_IWUSR
 	);
-	if (fd < 0) err(EX_CANTCREAT, "%s/%s", tag.name, path);
+	if (fd < 0) err(EX_CANTCREAT, "log/%s", path);
+	logs[id].file = fdopen(fd, "a");
+	if (!logs[id].file) err(EX_OSERR, "fdopen");
 
-	log->file = fdopen(fd, "a+");
-	if (!log->file) err(EX_CANTCREAT, "%s/%s", tag.name, path);
-	setlinebuf(log->file);
-
-	return log->file;
+	setlinebuf(logs[id].file);
+	return logs[id].file;
 }
 
-enum { StampLen = sizeof("YYYY-MM-DDThh:mm:ss+hhmm") - 1 };
-
-void logFmt(struct Tag tag, const time_t *ts, const char *format, ...) {
-	if (logRoot < 0) return;
-
-	time_t t;
-	if (!ts) {
-		t = time(NULL);
-		ts = &t;
+void logClose(void) {
+	if (logDir < 0) return;
+	for (uint id = 0; id < IDCap; ++id) {
+		if (!logs[id].file) continue;
+		int error = fclose(logs[id].file);
+		if (error) err(EX_IOERR, "%s", idNames[id]);
 	}
+	close(logDir);
+}
 
-	struct tm *time = localtime(ts);
-	if (!time) err(EX_SOFTWARE, "localtime");
+void logFormat(uint id, const time_t *src, const char *format, ...) {
+	if (logDir < 0) return;
 
-	FILE *file = logFile(tag, time);
+	time_t ts = (src ? *src : time(NULL));
+	struct tm *tm = localtime(&ts);
+	if (!tm) err(EX_OSERR, "localtime");
 
-	char stamp[StampLen + 1];
-	strftime(stamp, sizeof(stamp), "%FT%T%z", time);
-	fprintf(file, "[%s] ", stamp);
-	if (ferror(file)) err(EX_IOERR, "%s", tag.name);
+	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]);
 
 	va_list ap;
 	va_start(ap, format);
-	vfprintf(file, format, ap);
+	n = vfprintf(file, format, ap);
 	va_end(ap);
-	if (ferror(file)) err(EX_IOERR, "%s", tag.name);
-
-	fprintf(file, "\n");
-	if (ferror(file)) err(EX_IOERR, "%s", tag.name);
-}
-
-static void logRead(struct Tag tag, bool replay) {
-	if (logRoot < 0) return;
-
-	time_t t = time(NULL);
-	struct tm *time = localtime(&t);
-	if (!time) err(EX_SOFTWARE, "localtime");
-
-	FILE *file = logFile(tag, time);
-	rewind(file);
-
-	char *line = NULL;
-	size_t cap = 0;
-	ssize_t len;
-	while (0 < (len = getline(&line, &cap, file))) {
-		if (replay) {
-			if (len < 1 + StampLen + 2 + 1) continue;
-			line[len - 1] = '\0';
-			uiFmt(tag, UICold, "\3%d%s", IRCGray, &line[1 + StampLen + 2]);
-		} else {
-			printf("%s", line);
-		}
-	}
-	if (ferror(file)) err(EX_IOERR, "%s", tag.name);
-	free(line);
-}
-
-void logList(struct Tag tag) {
-	logRead(tag, false);
-}
+	if (n < 0) err(EX_IOERR, "%s", idNames[id]);
 
-void logReplay(struct Tag tag) {
-	logRead(tag, true);
+	n = fprintf(file, "\n");
+	if (n < 0) err(EX_IOERR, "%s", idNames[id]);
 }
diff --git a/pls.c b/pls.c
deleted file mode 100644
index b724033..0000000
--- a/pls.c
+++ /dev/null
@@ -1,186 +0,0 @@
-/* Copyright (C) 2018  C. McEnroe <june@causal.agency>
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-#include <errno.h>
-#include <stdarg.h>
-#include <stdio.h>
-#include <stdlib.h>
-#include <wchar.h>
-
-#include "chat.h"
-
-wchar_t *wcsnchr(const wchar_t *wcs, size_t len, wchar_t chr) {
-	len = wcsnlen(wcs, len);
-	for (size_t i = 0; i < len; ++i) {
-		if (wcs[i] == chr) return (wchar_t *)&wcs[i];
-	}
-	return NULL;
-}
-
-wchar_t *wcsnrchr(const wchar_t *wcs, size_t len, wchar_t chr) {
-	len = wcsnlen(wcs, len);
-	for (size_t i = len - 1; i < len; --i) {
-		if (wcs[i] == chr) return (wchar_t *)&wcs[i];
-	}
-	return NULL;
-}
-
-wchar_t *ambstowcs(const char *src) {
-	size_t len = mbsrtowcs(NULL, &src, 0, NULL);
-	if (len == (size_t)-1) return NULL;
-
-	wchar_t *dst = malloc(sizeof(*dst) * (1 + len));
-	if (!dst) return NULL;
-
-	len = mbsrtowcs(dst, &src, len, NULL);
-	if (len == (size_t)-1) {
-		free(dst);
-		return NULL;
-	}
-
-	dst[len] = L'\0';
-	return dst;
-}
-
-char *awcstombs(const wchar_t *src) {
-	size_t len = wcsrtombs(NULL, &src, 0, NULL);
-	if (len == (size_t)-1) return NULL;
-
-	char *dst = malloc(sizeof(*dst) * (1 + len));
-	if (!dst) return NULL;
-
-	len = wcsrtombs(dst, &src, len, NULL);
-	if (len == (size_t)-1) {
-		free(dst);
-		return NULL;
-	}
-
-	dst[len] = '\0';
-	return dst;
-}
-
-char *awcsntombs(const wchar_t *src, size_t nwc) {
-	size_t len = wcsnrtombs(NULL, &src, nwc, 0, NULL);
-	if (len == (size_t)-1) return NULL;
-
-	char *dst = malloc(sizeof(*dst) * (1 + len));
-	if (!dst) return NULL;
-
-	len = wcsnrtombs(dst, &src, nwc, len, NULL);
-	if (len == (size_t)-1) {
-		free(dst);
-		return NULL;
-	}
-
-	dst[len] = '\0';
-	return dst;
-}
-
-// From <https://en.cppreference.com/w/c/io/fwprintf#Notes>:
-//
-// While narrow strings provide snprintf, which makes it possible to determine
-// the required output buffer size, there is no equivalent for wide strings
-// (until C11's snwprintf_s), and in order to determine the buffer size, the
-// program may need to call swprintf, check the result value, and reallocate a
-// larger buffer, trying again until successful.
-//
-// snwprintf_s, unlike swprintf_s, will truncate the result to fit within the
-// array pointed to by buffer, even though truncation is treated as an error by
-// most bounds-checked functions.
-int vaswprintf(wchar_t **ret, const wchar_t *format, va_list ap) {
-	*ret = NULL;
-
-	for (size_t cap = 2 * wcslen(format);; cap *= 2) {
-		wchar_t *buf = realloc(*ret, sizeof(*buf) * (1 + cap));
-		if (!buf) goto fail;
-		*ret = buf;
-
-		va_list _ap;
-		va_copy(_ap, ap);
-		errno = EOVERFLOW; // vswprintf may not set errno.
-		int len = vswprintf(*ret, 1 + cap, format, _ap);
-		va_end(_ap);
-
-		if (len >= 0) return len;
-		if (errno != EOVERFLOW) goto fail;
-	}
-
-fail:
-	free(*ret);
-	*ret = NULL;
-	return -1;
-}
-
-int aswprintf(wchar_t **ret, const wchar_t *format, ...) {
-	va_list ap;
-	va_start(ap, format);
-	int n = vaswprintf(ret, format, ap);
-	va_end(ap);
-	return n;
-}
-
-static const char Base64[64] = {
-	"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
-};
-
-size_t base64Size(size_t len) {
-	return 1 + (len + 2) / 3 * 4;
-}
-
-void base64(char *dst, const byte *src, size_t len) {
-	size_t i = 0;
-	while (len > 2) {
-		dst[i++] = Base64[0x3F & (src[0] >> 2)];
-		dst[i++] = Base64[0x3F & (src[0] << 4 | src[1] >> 4)];
-		dst[i++] = Base64[0x3F & (src[1] << 2 | src[2] >> 6)];
-		dst[i++] = Base64[0x3F & src[2]];
-		src += 3;
-		len -= 3;
-	}
-	if (len) {
-		dst[i++] = Base64[0x3F & (src[0] >> 2)];
-		if (len > 1) {
-			dst[i++] = Base64[0x3F & (src[0] << 4 | src[1] >> 4)];
-			dst[i++] = Base64[0x3F & (src[1] << 2)];
-		} else {
-			dst[i++] = Base64[0x3F & (src[0] << 4)];
-			dst[i++] = '=';
-		}
-		dst[i++] = '=';
-	}
-	dst[i] = '\0';
-}
-
-#ifdef TEST
-#include <assert.h>
-#include <string.h>
-
-int main() {
-	assert(5 == base64Size(1));
-	assert(5 == base64Size(2));
-	assert(5 == base64Size(3));
-	assert(9 == base64Size(4));
-
-	char b64[base64Size(3)];
-	assert((base64(b64, (byte *)"cat", 3), !strcmp("Y2F0", b64)));
-	assert((base64(b64, (byte *)"ca", 2), !strcmp("Y2E=", b64)));
-	assert((base64(b64, (byte *)"c", 1), !strcmp("Yw==", b64)));
-
-	assert((base64(b64, (byte *)"\xFF\x00\xFF", 3), !strcmp("/wD/", b64)));
-	assert((base64(b64, (byte *)"\x00\xFF\x00", 3), !strcmp("AP8A", b64)));
-}
-
-#endif
diff --git a/sandman.1 b/sandman.1
index bd68874..92828c0 100644
--- a/sandman.1
+++ b/sandman.1
@@ -1,30 +1,36 @@
-.Dd July 2, 2019
+.Dd February 12, 2020
 .Dt SANDMAN 1
 .Os
 .
 .Sh NAME
 .Nm sandman
-.Nd signal sleep
+.Nd sleep wrapper
 .
 .Sh SYNOPSIS
 .Nm
+.Op Fl t Ar secs
 .Ar command ...
 .
 .Sh DESCRIPTION
+The
 .Nm
-is a utility for Darwin systems.
-It runs the
-.Ar command
-as a child process.
-When the system goes to sleep,
-the process is sent
-.Dv SIGHUP .
-When the system wakes up,
-the process is restarted.
+utility wraps a
+.Ar command ,
+sending it
+.Dv SIGHUP
+when the system goes to sleep,
+and restarting it
+when the system wakes.
 .
-.Sh EXIT STATUS
-.Nm
-exits with the exit status of the child process.
+.Pp
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl t Ar secs
+Wait for
+.Ar secs
+before restarting the process.
+The default is 8 seconds.
+.El
 .
-.Sh SEE ALSO
-.Xr signal 3
+.Sh AUTHORS
+.An June McEnroe Aq Mt june@causal.agency
diff --git a/sandman.m b/sandman.m
index 94c7d1a..2e5c4db 100644
--- a/sandman.m
+++ b/sandman.m
@@ -1,88 +1,87 @@
-/* Copyright (C) 2019  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 Affero General Public License as published by
+ * 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 Affero General Public License for more details.
+ * GNU General Public License for more details.
  *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
 #import <Cocoa/Cocoa.h>
 #import <err.h>
 #import <signal.h>
+#import <stdio.h>
 #import <stdlib.h>
 #import <sysexits.h>
 #import <unistd.h>
 
-static volatile sig_atomic_t sleeping;
+typedef unsigned uint;
 
-static void sigchld(int sig) {
-	(void)sig;
-	int status;
-	pid_t pid = wait(&status);
-	if (pid < 0) _exit(EX_OSERR);
-	if (WIFSIGNALED(status) && WTERMSIG(status) != SIGHUP) {
-		_exit(128 + WTERMSIG(status));
-	} else if (!sleeping) {
-		_exit(WEXITSTATUS(status));
-	}
-}
-
-static pid_t spawn(char *argv[]) {
-	pid_t pid = fork();
+static pid_t pid;
+static void spawn(char *argv[]) {
+	pid = fork();
 	if (pid < 0) err(EX_OSERR, "fork");
-	if (pid) return pid;
+	if (pid) return;
 	execvp(argv[0], argv);
-	err(EX_NOINPUT, "%s", argv[0]);
+	err(EX_CONFIG, "%s", argv[0]);
 }
 
-static pid_t pid;
+static void handler(int signal) {
+	(void)signal;
+	int status;
+	pid_t pid = wait(&status);
+	if (pid < 0) _exit(EX_OSERR);
+	_exit(status);
+}
 
 int main(int argc, char *argv[]) {
-	if (argc < 2) return EX_USAGE;
+	uint delay = 8;
+
+	for (int opt; 0 < (opt = getopt(argc, argv, "t:"));) {
+		switch (opt) {
+			break; case 't': delay = strtoul(optarg, NULL, 10);
+			break; default:  return EX_USAGE;
+		}
+	}
+	argc -= optind;
+	argv += optind;
+	if (!argc) errx(EX_USAGE, "command required");
 
-	sigset_t mask;
-	sigemptyset(&mask);
-	struct sigaction action = {
-		.sa_handler = sigchld,
-		.sa_mask = mask,
-		.sa_flags = SA_NOCLDSTOP | SA_RESTART,
-	};
-	sigaction(SIGCHLD, &action, NULL);
+	NSWorkspace *workspace = [NSWorkspace sharedWorkspace];
+	NSNotificationCenter *notifCenter = [workspace notificationCenter];
 
-	pid = spawn(&argv[1]);
+	[notifCenter addObserverForName:NSWorkspaceWillSleepNotification
+							 object:nil
+							  queue:nil
+						 usingBlock:^(NSNotification *notif) {
+							 (void)notif;
+							 signal(SIGCHLD, SIG_IGN);
+							 int error = kill(pid, SIGHUP);
+							 if (error) err(EX_UNAVAILABLE, "kill");
+							 int status;
+							 wait(&status);
+						 }];
 
-	[
-		[[NSWorkspace sharedWorkspace] notificationCenter]
-		addObserverForName: NSWorkspaceWillSleepNotification
-		object: nil
-		queue: nil
-		usingBlock: ^(NSNotification *note) {
-			(void)note;
-			sleeping = 1;
-			int error = kill(pid, SIGHUP);
-			if (error) err(EX_UNAVAILABLE, "kill %d", pid);
-		}
-	];
+	[notifCenter addObserverForName:NSWorkspaceDidWakeNotification
+							 object:nil
+							  queue:nil
+						 usingBlock:^(NSNotification *notif) {
+							 (void)notif;
+							 warnx("waiting %u seconds...", delay);
+							 sleep(delay);
+							 signal(SIGCHLD, handler);
+							 spawn(argv);
+						 }];
 
-	[
-		[[NSWorkspace sharedWorkspace] notificationCenter]
-		addObserverForName: NSWorkspaceDidWakeNotification
-		object: nil
-		queue: nil
-		usingBlock: ^(NSNotification *note) {
-			(void)note;
-			sleeping = 0;
-			pid = spawn(&argv[1]);
-		}
-	];
+	signal(SIGCHLD, handler);
+	spawn(argv);
 
 	[[NSApplication sharedApplication] run];
 }
diff --git a/scripts/build-chroot.sh b/scripts/build-chroot.sh
new file mode 100644
index 0000000..a0fcf32
--- /dev/null
+++ b/scripts/build-chroot.sh
@@ -0,0 +1,74 @@
+#!/bin/sh
+set -eux
+
+CHROOT_USER=$1
+CHROOT_GROUP=$2
+
+if [ "$(uname)" = 'OpenBSD' ]; then
+	install -d -o root -g wheel \
+		root \
+		root/bin \
+		root/etc/ssl \
+		root/home \
+		root/usr/bin \
+		root/usr/lib \
+		root/usr/libexec \
+		root/usr/share/man
+	install -d -o ${CHROOT_USER} -g ${CHROOT_GROUP} \
+		root/home/${CHROOT_USER} \
+		root/home/${CHROOT_USER}/.local/share
+
+	cp -fp /bin/sh root/bin
+	cp -fp /usr/libexec/ld.so root/usr/libexec
+	export LD_TRACE_LOADED_OBJECTS_FMT1='%p\n'
+	export LD_TRACE_LOADED_OBJECTS_FMT2=''
+	for bin in ./catgirl /usr/bin/mandoc /usr/bin/less; do
+		LD_TRACE_LOADED_OBJECTS=1 $bin | xargs -t -J % cp -fp % root/usr/lib
+	done
+	cp -fp /usr/bin/printf /usr/bin/mandoc /usr/bin/less root/usr/bin
+	make install DESTDIR=root PREFIX=/usr MANDIR=/usr/share/man
+	install scripts/chroot-prompt.sh root/usr/bin/catgirl-prompt
+	install scripts/chroot-man.sh root/usr/bin/man
+
+	cp -fp /etc/hosts /etc/resolv.conf root/etc
+	cp -fp /etc/ssl/cert.pem root/etc/ssl
+	cp -af /usr/share/locale /usr/share/terminfo root/usr/share
+
+	tar -c -f chroot.tar -C root bin etc home usr
+
+elif [ "$(uname)" = 'FreeBSD' ]; then
+	install -d -o root -g wheel \
+		root \
+		root/bin \
+		root/etc \
+		root/home \
+		root/lib \
+		root/libexec \
+		root/usr/bin \
+		root/usr/local/etc/ssl \
+		root/usr/share/man \
+		root/usr/share/misc
+	install -d -o ${CHROOT_USER} -g ${CHROOT_GROUP} \
+		root/home/${CHROOT_USER} \
+		root/home/${CHROOT_USER}/.local/share
+
+	cp -fp /libexec/ld-elf.so.1 root/libexec
+	ldd -f '%p\n' catgirl /usr/bin/mandoc /usr/bin/less \
+		| sort -u | xargs -t -J % cp -fp % root/lib
+	chflags noschg root/libexec/* root/lib/*
+	cp -fp /rescue/sh /usr/bin/mandoc /usr/bin/less root/bin
+	make install DESTDIR=root PREFIX=/usr MANDIR=/usr/share/man
+	install scripts/chroot-prompt.sh root/usr/bin/catgirl-prompt
+	install scripts/chroot-man.sh root/usr/bin/man
+
+	cp -fp /etc/hosts /etc/resolv.conf root/etc
+	cp -fp /usr/local/etc/ssl/cert.pem root/usr/local/etc/ssl
+	cp -af /usr/share/locale root/usr/share
+	cp -fp /usr/share/misc/termcap.db root/usr/share/misc
+
+	tar -c -f chroot.tar -C root bin etc home lib libexec usr
+
+else
+	echo "Don't know how to build chroot on $(uname)" >&2
+	exit 1
+fi
diff --git a/scripts/chat.tmux.conf b/scripts/chat.tmux.conf
new file mode 100644
index 0000000..3489a19
--- /dev/null
+++ b/scripts/chat.tmux.conf
@@ -0,0 +1,64 @@
+# use `tmux -L chat -f ./chat.tmux.conf attach-session' (without any other
+# options or parameters) to access this session group in its own tmux server,
+# not interfering with existing servers/sessions/configurations
+
+new-session -t chat
+
+# catgirl(1) puts windows at the top
+set-option -g	-- status-position	top
+
+# intuitive navigation
+set-option -g	-- mode-keys	vi
+set-option -g	-- mouse	on
+
+# indicate new messages
+set-option -g	-- monitor-activity	on
+set-option -g	-- monitor-bell	on
+
+# hardcode names during window creation
+set-option -g	-- automatic-rename	off
+set-option -g	-- allow-rename	off
+set-option -g	-- set-titles	off
+set-option -g	-- renumber-windows	on
+# for the F1 binding, make hotkeys match window numbers
+set-option -g	-- base-index	1
+
+
+# clients exit on network errors, restart them automatically
+# (use `kill-pane'/`C-b x' to destroy windows)
+set-option -g	-- remain-on-exit	on
+set-hook -g	-- pane-died	respawn-pane
+
+
+# disarm ^C to avoid accidentially losing logs
+bind-key -n -N 'confirm INTR key'	-- C-c	\
+	confirm-before -p 'Send ^C? (y/N)'	-- 'send-keys	-- C-c'
+
+# one-click version of default `C-b w' (shows preview windows)
+bind-key -n -N 'pick chat network'	-- F1	choose-tree -Z
+
+# catgirl(1) might run in `-R'/`restrict'ed mode, i.e. `/help' is disabled
+bind-key -n -N 'read catgirl help'	-- F2	\
+	new-window -S -n help	-- man -s 1	-- catgirl
+
+# intuitive refresh, just don't spam it ;-)
+bind-key -n -N 'reconnect network'	-- F5	\
+	confirm-before -p 'reconnect network? (y/N)'	-- 'respawn-pane -k'
+
+# immersive mode ;-)
+bind-key -n -N 'toggle fullscreen'	-- F11	set status
+
+
+# this configuration is idempotent, i.e. reloading it only changes settings
+# and never duplicates already existing windows
+bind-key -N 'reload configuration'	-- R	{
+	source-file -F	-- '#{source_files}'
+	display-message	-- 'configuration reloaded'
+}
+
+
+## do not double-quote commands to avoid running through "sh -c"
+
+# IRC
+new-window -d -S -n hackint	-- catgirl	-- defaults hackint
+new-window -d -S -n efnet	-- catgirl	-- defaults efnet
diff --git a/man.sh b/scripts/chroot-man.sh
index 9d686f9..9d686f9 100644
--- a/man.sh
+++ b/scripts/chroot-man.sh
diff --git a/scripts/chroot-prompt.sh b/scripts/chroot-prompt.sh
new file mode 100644
index 0000000..2b34426
--- /dev/null
+++ b/scripts/chroot-prompt.sh
@@ -0,0 +1,7 @@
+#!/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/notify-send.scpt b/scripts/notify-send.scpt
new file mode 100644
index 0000000..5630440
--- /dev/null
+++ b/scripts/notify-send.scpt
@@ -0,0 +1,9 @@
+#!/usr/bin/osascript
+
+on run argv
+	if count of argv is 2 then
+		display notification (item 2 of argv) with title (item 1 of argv)
+	else
+		display notification (item 1 of argv)
+	end if
+end run
diff --git a/sshd_config b/scripts/sshd_config
index 47b5a33..c7e99ec 100644
--- a/sshd_config
+++ b/scripts/sshd_config
@@ -4,10 +4,6 @@ Match User chat
 	PasswordAuthentication yes
 	PermitEmptyPasswords yes
 	ChrootDirectory /home/chat
-	ForceCommand catgirl
-
-	AllowAgentForwarding no
-	AllowTcpForwarding no
-	AllowStreamLocalForwarding no
+	ForceCommand catgirl-prompt
+	DisableForwarding yes
 	MaxSessions 1
-	X11Forwarding no
diff --git a/tab.c b/tab.c
deleted file mode 100644
index a17218d..0000000
--- a/tab.c
+++ /dev/null
@@ -1,148 +0,0 @@
-/* Copyright (C) 2018  C. McEnroe <june@causal.agency>
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-#include <err.h>
-#include <stdlib.h>
-#include <string.h>
-#include <sysexits.h>
-
-#include "chat.h"
-
-static struct Entry {
-	struct Tag tag;
-	char *word;
-	struct Entry *prev;
-	struct Entry *next;
-} *head;
-
-static void prepend(struct Entry *entry) {
-	entry->prev = NULL;
-	entry->next = head;
-	if (head) head->prev = entry;
-	head = entry;
-}
-
-static void unlink(struct Entry *entry) {
-	if (entry->prev) entry->prev->next = entry->next;
-	if (entry->next) entry->next->prev = entry->prev;
-	if (head == entry) head = entry->next;
-}
-
-static void touch(struct Entry *entry) {
-	if (head == entry) return;
-	unlink(entry);
-	prepend(entry);
-}
-
-static struct Entry *find(struct Tag tag, const char *word) {
-	for (struct Entry *entry = head; entry; entry = entry->next) {
-		if (entry->tag.id != tag.id) continue;
-		if (strcmp(entry->word, word)) continue;
-		return entry;
-	}
-	return NULL;
-}
-
-static void add(struct Tag tag, const char *word) {
-	struct Entry *entry = malloc(sizeof(*entry));
-	if (!entry) err(EX_OSERR, "malloc");
-
-	entry->tag = tag;
-	entry->word = strdup(word);
-	if (!entry->word) err(EX_OSERR, "strdup");
-
-	prepend(entry);
-}
-
-void tabTouch(struct Tag tag, const char *word) {
-	struct Entry *entry = find(tag, word);
-	if (entry) {
-		touch(entry);
-	} else {
-		add(tag, word);
-	}
-}
-
-void tabAdd(struct Tag tag, const char *word) {
-	if (!find(tag, word)) add(tag, word);
-}
-
-void tabReplace(struct Tag tag, const char *prev, const char *next) {
-	struct Entry *entry = find(tag, prev);
-	if (!entry) return;
-	touch(entry);
-	free(entry->word);
-	entry->word = strdup(next);
-	if (!entry->word) err(EX_OSERR, "strdup");
-}
-
-static struct Entry *iter;
-
-void tabRemove(struct Tag tag, const char *word) {
-	for (struct Entry *entry = head; entry; entry = entry->next) {
-		if (entry->tag.id != tag.id) continue;
-		if (strcmp(entry->word, word)) continue;
-		if (iter == entry) iter = entry->prev;
-		unlink(entry);
-		free(entry->word);
-		free(entry);
-		return;
-	}
-}
-
-void tabClear(struct Tag tag) {
-	for (struct Entry *entry = head; entry; entry = entry->next) {
-		if (entry->tag.id != tag.id) continue;
-		if (iter == entry) iter = entry->prev;
-		unlink(entry);
-		free(entry->word);
-		free(entry);
-	}
-}
-
-struct Tag tabTag(const char *word) {
-	struct Entry *start = (iter ? iter->next : head);
-	for (struct Entry *entry = start; entry; entry = entry->next) {
-		if (strcmp(entry->word, word)) continue;
-		iter = entry;
-		return entry->tag;
-	}
-	iter = NULL;
-	return TagNone;
-}
-
-const char *tabNext(struct Tag tag, const char *prefix) {
-	size_t len = strlen(prefix);
-	struct Entry *start = (iter ? iter->next : head);
-	for (struct Entry *entry = start; entry; entry = entry->next) {
-		if (entry->tag.id != TagNone.id && entry->tag.id != tag.id) continue;
-		if (strncasecmp(entry->word, prefix, len)) continue;
-		iter = entry;
-		return entry->word;
-	}
-	if (!iter) return NULL;
-	iter = NULL;
-	return tabNext(tag, prefix);
-}
-
-void tabAccept(void) {
-	if (iter) touch(iter);
-	iter = NULL;
-}
-
-void tabReject(void) {
-	iter = NULL;
-}
diff --git a/tag.c b/tag.c
deleted file mode 100644
index 5b4232e..0000000
--- a/tag.c
+++ /dev/null
@@ -1,53 +0,0 @@
-/* Copyright (C) 2018  C. McEnroe <june@causal.agency>
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-#include <err.h>
-#include <stdlib.h>
-#include <string.h>
-#include <sysexits.h>
-
-#include "chat.h"
-
-static struct {
-	char *name[TagsLen];
-	size_t len;
-} tags = {
-	.name = { "<none>", "<status>", "<raw>" },
-	.len = 3,
-};
-
-const struct Tag TagNone   = { 0, "<none>" };
-const struct Tag TagStatus = { 1, "<status>" };
-const struct Tag TagRaw    = { 2, "<raw>" };
-
-struct Tag tagFind(const char *name) {
-	for (size_t id = 0; id < tags.len; ++id) {
-		if (strcmp(tags.name[id], name)) continue;
-		return (struct Tag) { id, tags.name[id] };
-	}
-	return TagNone;
-}
-
-struct Tag tagFor(const char *name) {
-	struct Tag tag = tagFind(name);
-	if (tag.id != TagNone.id) return tag;
-	if (tags.len == TagsLen) return TagStatus;
-
-	size_t id = tags.len++;
-	tags.name[id] = strdup(name);
-	if (!tags.name[id]) err(EX_OSERR, "strdup");
-	return (struct Tag) { id, tags.name[id] };
-}
diff --git a/term.c b/term.c
deleted file mode 100644
index 4b583ae..0000000
--- a/term.c
+++ /dev/null
@@ -1,100 +0,0 @@
-/* Copyright (C) 2018  C. McEnroe <june@causal.agency>
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-#include <stdbool.h>
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include <termios.h>
-#include <unistd.h>
-
-#include "chat.h"
-
-static bool xterm;
-
-void termInit(void) {
-	char *term = getenv("TERM");
-	xterm = term && !strncmp(term, "xterm", 5);
-}
-
-void termNoFlow(void) {
-	struct termios attr;
-	int error = tcgetattr(STDIN_FILENO, &attr);
-	if (error) return;
-	attr.c_iflag &= ~IXON;
-	attr.c_cc[VDISCARD] = _POSIX_VDISABLE;
-	tcsetattr(STDIN_FILENO, TCSANOW, &attr);
-}
-
-void termTitle(const char *title) {
-	if (!xterm) return;
-	printf("\33]0;%s\33\\", title);
-	fflush(stdout);
-}
-
-static void privateMode(const char *mode, bool set) {
-	printf("\33[?%s%c", mode, (set ? 'h' : 'l'));
-	fflush(stdout);
-}
-
-void termMode(enum TermMode mode, bool set) {
-	switch (mode) {
-		break; case TermFocus: privateMode("1004", set);
-		break; case TermPaste: privateMode("2004", set);
-	}
-}
-
-#define T(s, i) ((s) << 8 | (i))
-
-enum { Esc = '\33' };
-
-enum TermEvent termEvent(char ch) {
-	static uint state = 0;
-	switch (T(state, ch)) {
-		case T(0, Esc): state = 1; return 0;
-		case T(1, '['): state = 2; return 0;
-		case T(2, 'I'): state = 0; return TermFocusIn;
-		case T(2, 'O'): state = 0; return TermFocusOut;
-		case T(2, '2'): state = 3; return 0;
-		case T(3, '0'): state = 4; return 0;
-		case T(4, '0'): state = 5; return 0;
-		case T(5, '~'): state = 0; return TermPasteStart;
-		case T(4, '1'): state = 6; return 0;
-		case T(6, '~'): state = 0; return TermPasteEnd;
-		default:        state = 0; return 0;
-	}
-}
-
-#ifdef TEST
-#include <assert.h>
-
-static bool testEvent(const char *str, enum TermEvent event) {
-	enum TermEvent e = TermNone;
-	for (size_t i = 0; i < strlen(str); ++i) {
-		if (e) return false;
-		e = termEvent(str[i]);
-	}
-	return (e == event);
-}
-
-int main() {
-	assert(testEvent("\33[I", TermFocusIn));
-	assert(testEvent("\33[O", TermFocusOut));
-	assert(testEvent("\33[200~", TermPasteStart));
-	assert(testEvent("\33[201~", TermPasteEnd));
-}
-
-#endif
diff --git a/ui.c b/ui.c
index 9cf21d3..079ee19 100644
--- a/ui.c
+++ b/ui.c
@@ -1,615 +1,357 @@
-/* Copyright (C) 2018, 2019  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 Affero General Public License as published by
+ * 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 Affero General Public License for more details.
+ * GNU General Public License for more details.
  *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ * 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 <errno.h>
+#include <fcntl.h>
+#include <inttypes.h>
 #include <stdarg.h>
 #include <stdbool.h>
+#include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
+#include <sys/file.h>
 #include <sysexits.h>
-#include <wchar.h>
-#include <wctype.h>
+#include <term.h>
+#include <time.h>
+#include <unistd.h>
 
-#ifndef A_ITALIC
-#define A_ITALIC A_UNDERLINE
+#ifdef __FreeBSD__
+#include <capsicum_helpers.h>
 #endif
 
 #include "chat.h"
-#undef uiFmt
-
-#define CTRL(ch) ((ch) & 037)
-enum { Esc = L'\33', Del = L'\177' };
-
-static const int LogLines = 512;
-
-static int lastLine(void) {
-	return LINES - 1;
-}
-static int lastCol(void) {
-	return COLS - 1;
-}
-static int logHeight(void) {
-	return LINES - 2;
-}
-
-struct Window {
-	struct Tag tag;
-	WINDOW *log;
-	bool hot;
-	bool mark;
-	int scroll;
-	uint unread;
-	struct Window *prev;
-	struct Window *next;
-};
 
-static struct {
-	struct Window *active;
-	struct Window *other;
-	struct Window *head;
-	struct Window *tail;
-	struct Window *tag[TagsLen];
-} windows;
-
-static void windowAppend(struct Window *win) {
-	if (windows.tail) windows.tail->next = win;
-	win->prev = windows.tail;
-	win->next = NULL;
-	windows.tail = win;
-	if (!windows.head) windows.head = win;
-	windows.tag[win->tag.id] = win;
-}
-
-static void windowInsert(struct Window *win, struct Window *next) {
-	win->prev = next->prev;
-	win->next = next;
-	if (win->prev) win->prev->next = win;
-	win->next->prev = win;
-	if (!win->prev) windows.head = win;
-	windows.tag[win->tag.id] = win;
-}
+#define BOTTOM (LINES - 1)
+#define RIGHT (COLS - 1)
+#define MAIN_LINES (LINES - StatusLines - InputLines)
 
-static void windowRemove(struct Window *win) {
-	windows.tag[win->tag.id] = NULL;
-	if (win->prev) win->prev->next = win->next;
-	if (win->next) win->next->prev = win->prev;
-	if (windows.head == win) windows.head = win->next;
-	if (windows.tail == win) windows.tail = win->prev;
-}
-
-static struct Window *windowFor(struct Tag tag) {
-	struct Window *win = windows.tag[tag.id];
-	if (win) {
-		win->tag = tag;
-		return win;
-	}
+WINDOW *uiStatus;
+WINDOW *uiMain;
+WINDOW *uiInput;
 
-	win = calloc(1, sizeof(*win));
-	if (!win) err(EX_OSERR, "calloc");
+static short colorPairs;
 
-	win->tag = tag;
-	win->mark = true;
-	win->scroll = LogLines;
-
-	win->log = newpad(LogLines, COLS);
-	wsetscrreg(win->log, 0, LogLines - 1);
-	scrollok(win->log, true);
-	wmove(win->log, LogLines - 1, 0);
-
-	windowAppend(win);
-	return win;
-}
-
-static void windowResize(struct Window *win) {
-	wresize(win->log, LogLines, COLS);
-	wmove(win->log, LogLines - 1, lastCol());
-}
-
-static void windowMark(struct Window *win) {
-	win->mark = true;
-}
-static void windowUnmark(struct Window *win) {
-	win->mark = false;
-	win->unread = 0;
-	win->hot = false;
-}
-
-static void windowShow(struct Window *win) {
-	if (windows.active) windowMark(windows.active);
-	if (win) {
-		touchwin(win->log);
-		windowUnmark(win);
+static void colorInit(void) {
+	start_color();
+	use_default_colors();
+	if (!COLORS) return;
+	for (short pair = 0; pair < 16; ++pair) {
+		init_pair(1 + pair, pair % COLORS, -1);
 	}
-	windows.other = windows.active;
-	windows.active = win;
+	colorPairs = 17;
 }
 
-static void windowClose(struct Window *win) {
-	if (windows.active == win) windowShow(win->next ? win->next : win->prev);
-	if (windows.other == win) windows.other = NULL;
-	windowRemove(win);
-	delwin(win->log);
-	free(win);
+static attr_t colorAttr(short fg) {
+	if (!COLORS) return (fg > 0 ? A_BOLD : A_NORMAL);
+	if (fg != COLOR_BLACK && fg % COLORS == COLOR_BLACK) return A_BOLD;
+	if (COLORS > 8) return A_NORMAL;
+	return (fg / COLORS & 1 ? A_BOLD : A_NORMAL);
 }
 
-static void windowScroll(struct Window *win, int lines) {
-	if (lines < 0) {
-		if (win->scroll == logHeight()) return;
-		if (win->scroll == LogLines) windowMark(win);
-		win->scroll = MAX(win->scroll + lines, logHeight());
-	} else {
-		if (win->scroll == LogLines) return;
-		win->scroll = MIN(win->scroll + lines, LogLines);
-		if (win->scroll == LogLines) windowUnmark(win);
+static short colorPair(short fg, short bg) {
+	if (!COLORS) return 0;
+	fg %= COLORS;
+	bg %= COLORS;
+	if (bg == -1 && fg < 16) return 1 + fg;
+	for (short pair = 17; pair < colorPairs; ++pair) {
+		short f, b;
+		pair_content(pair, &f, &b);
+		if (f == fg && b == bg) return pair;
 	}
+	init_pair(colorPairs, fg, bg);
+	return colorPairs++;
 }
 
-static void colorInit(void) {
-	start_color();
-	use_default_colors();
-	if (COLORS < 16) {
-		for (short pair = 0; pair < 0100; ++pair) {
-			if (pair < 010) {
-				init_pair(1 + pair, pair, -1);
-			} else {
-				init_pair(1 + pair, pair & 007, (pair & 070) >> 3);
-			}
-		}
-	} else {
-		for (short pair = 0; pair < 0x100; ++pair) {
-			if (pair < 0x10) {
-				init_pair(1 + pair, pair, -1);
-			} else {
-				init_pair(1 + pair, pair & 0x0F, (pair & 0xF0) >> 4);
-			}
-		}
-	}
-}
+// 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" };
 
-static attr_t colorAttr(short color) {
-	if (color < 0) return A_NORMAL;
-	if (COLORS < 16 && (color & 0x08)) return A_BOLD;
-	return A_NORMAL;
-}
-static short colorPair(short color) {
-	if (color < 0) return 0;
-	if (COLORS < 16) return 1 + ((color & 0x70) >> 1 | (color & 0x07));
-	return 1 + color;
+static void errExit(void) {
+	putp(FocusMode[false]);
+	putp(PasteMode[false]);
+	reset_shell_mode();
 }
 
-static struct {
-	bool hide;
-	WINDOW *status;
-	WINDOW *input;
-} ui;
-
 void uiInit(void) {
 	initscr();
 	cbreak();
 	noecho();
-	termInit();
-	termNoFlow();
-	def_prog_mode();
 	colorInit();
-	ui.status = newwin(1, COLS, 0, 0);
-	ui.input = newpad(1, 512);
-	keypad(ui.input, true);
-	nodelay(ui.input, true);
-	uiShow();
-}
+	atexit(errExit);
 
-static void uiResize(void) {
-	wresize(ui.status, 1, COLS);
-	for (struct Window *win = windows.head; win; win = win->next) {
-		windowResize(win);
+#ifndef A_ITALIC
+#define A_ITALIC A_BLINK
+	// Force ncurses to use individual enter_attr_mode strings:
+	set_attributes = NULL;
+	enter_blink_mode = enter_italics_mode;
+#endif
+
+	if (!to_status_line && !strncmp(termname(), "xterm", 5)) {
+		to_status_line = "\33]2;";
+		from_status_line = "\7";
 	}
-}
 
-void uiShow(void) {
-	ui.hide = false;
-	termMode(TermFocus, true);
-	uiDraw();
-}
-void uiHide(void) {
-	ui.hide = true;
-	termMode(TermFocus, false);
-	endwin();
-}
+	uiStatus = newwin(StatusLines, COLS, 0, 0);
+	if (!uiStatus) err(EX_OSERR, "newwin");
 
-void uiExit(int status) {
-	uiHide();
-	printf(
-		"This program is AGPLv3 Free Software!\n"
-		"Code is available from <" SOURCE_URL ">.\n"
-	);
-	exit(status);
+	uiMain = newwin(MAIN_LINES, COLS, StatusLines, 0);
+	if (!uiMain) err(EX_OSERR, "newwin");
+
+	uiInput = newpad(InputLines, InputCols);
+	if (!uiInput) err(EX_OSERR, "newpad");
+
+	windowInit();
+	uiShow();
 }
 
-static int _;
+static bool hidden = true;
+
+char uiTitle[TitleCap];
+static char prevTitle[TitleCap];
+
 void uiDraw(void) {
-	if (ui.hide) return;
-	wnoutrefresh(ui.status);
-	if (windows.active) {
-		pnoutrefresh(
-			windows.active->log,
-			windows.active->scroll - logHeight(), 0,
-			1, 0,
-			lastLine() - 1, lastCol()
-		);
-	}
-	int x;
-	getyx(ui.input, _, x);
+	if (hidden) return;
+	wnoutrefresh(uiStatus);
+	wnoutrefresh(uiMain);
+	int y, x;
+	getyx(uiInput, y, x);
 	pnoutrefresh(
-		ui.input,
-		0, MAX(0, x - lastCol() + 3),
-		lastLine(), 0,
-		lastLine(), lastCol()
+		uiInput,
+		0, (x + 1 > RIGHT ? x + 1 - RIGHT : 0),
+		LINES - InputLines, 0,
+		BOTTOM, RIGHT
 	);
+	(void)y;
 	doupdate();
-}
 
-static const short Colors[] = {
-	[IRCWhite]      = 8 + COLOR_WHITE,
-	[IRCBlack]      = 0 + COLOR_BLACK,
-	[IRCBlue]       = 0 + COLOR_BLUE,
-	[IRCGreen]      = 0 + COLOR_GREEN,
-	[IRCRed]        = 8 + COLOR_RED,
-	[IRCBrown]      = 0 + COLOR_RED,
-	[IRCMagenta]    = 0 + COLOR_MAGENTA,
-	[IRCOrange]     = 0 + COLOR_YELLOW,
-	[IRCYellow]     = 8 + COLOR_YELLOW,
-	[IRCLightGreen] = 8 + COLOR_GREEN,
-	[IRCCyan]       = 0 + COLOR_CYAN,
-	[IRCLightCyan]  = 8 + COLOR_CYAN,
-	[IRCLightBlue]  = 8 + COLOR_BLUE,
-	[IRCPink]       = 8 + COLOR_MAGENTA,
-	[IRCGray]       = 8 + COLOR_BLACK,
-	[IRCLightGray]  = 0 + COLOR_WHITE,
+	if (!to_status_line) return;
+	if (!strcmp(uiTitle, prevTitle)) return;
+	strcpy(prevTitle, uiTitle);
+	putp(tparm(to_status_line, 0));
+	putp(uiTitle);
+	putp(from_status_line);
+	fflush(stdout);
+}
+
+static const short Colors[ColorCap] = {
+	[Default]    = -1,
+	[White]      = 8 + COLOR_WHITE,
+	[Black]      = 0 + COLOR_BLACK,
+	[Blue]       = 0 + COLOR_BLUE,
+	[Green]      = 0 + COLOR_GREEN,
+	[Red]        = 8 + COLOR_RED,
+	[Brown]      = 0 + COLOR_RED,
+	[Magenta]    = 0 + COLOR_MAGENTA,
+	[Orange]     = 0 + COLOR_YELLOW,
+	[Yellow]     = 8 + COLOR_YELLOW,
+	[LightGreen] = 8 + COLOR_GREEN,
+	[Cyan]       = 0 + COLOR_CYAN,
+	[LightCyan]  = 8 + COLOR_CYAN,
+	[LightBlue]  = 8 + COLOR_BLUE,
+	[Pink]       = 8 + COLOR_MAGENTA,
+	[Gray]       = 8 + COLOR_BLACK,
+	[LightGray]  = 0 + COLOR_WHITE,
+	52, 94, 100, 58, 22, 29, 23, 24, 17, 54, 53, 89,
+	88, 130, 142, 64, 28, 35, 30, 25, 18, 91, 90, 125,
+	124, 166, 184, 106, 34, 49, 37, 33, 19, 129, 127, 161,
+	196, 208, 226, 154, 46, 86, 51, 75, 21, 171, 201, 198,
+	203, 215, 227, 191, 83, 122, 87, 111, 63, 177, 207, 205,
+	217, 223, 229, 193, 157, 158, 159, 153, 147, 183, 219, 212,
+	16, 233, 235, 237, 239, 241, 244, 247, 250, 254, 231,
 };
 
-static void addFormat(WINDOW *win, const struct Format *format) {
+uint uiAttr(struct Style style) {
 	attr_t attr = A_NORMAL;
-	if (format->bold)      attr |= A_BOLD;
-	if (format->italic)    attr |= A_ITALIC;
-	if (format->underline) attr |= A_UNDERLINE;
-	if (format->reverse)   attr |= A_REVERSE;
-
-	short color = -1;
-	if (format->fg != IRCDefault) color = Colors[format->fg];
-	if (format->bg != IRCDefault) color |= Colors[format->bg] << 4;
-
-	wattr_set(win, attr | colorAttr(color), colorPair(color), NULL);
-	waddnwstr(win, format->str, format->len);
-}
-
-static int printWidth(const wchar_t *str, size_t len) {
-	int width = 0;
-	for (size_t i = 0; i < len; ++i) {
-		if (iswprint(str[i])) width += wcwidth(str[i]);
-	}
-	return width;
+	if (style.attr & Bold) attr |= A_BOLD;
+	if (style.attr & Reverse) attr |= A_REVERSE;
+	if (style.attr & Italic) attr |= A_ITALIC;
+	if (style.attr & Underline) attr |= A_UNDERLINE;
+	return attr | colorAttr(Colors[style.fg]);
 }
 
-static int addWrap(WINDOW *win, const wchar_t *str) {
-	int lines = 0;
-	struct Format format = { .str = str };
-	formatReset(&format);
-
-	while (formatParse(&format, NULL)) {
-		size_t word = 1 + wcscspn(&format.str[1], L" ");
-		if (word < format.len) format.len = word;
-
-		int x, xMax;
-		getyx(win, _, x);
-		getmaxyx(win, _, xMax);
-		if (xMax - x - 1 < printWidth(format.str, word)) {
-			if (format.str[0] == L' ') {
-				format.str++;
-				format.len--;
-			}
-			waddch(win, '\n');
-			lines++;
-		}
-		addFormat(win, &format);
-	}
-	return lines;
-}
-
-static void title(const struct Window *win) {
-	int unread;
-	char *str;
-	int len = asprintf(&str, "%s%n (%u)", win->tag.name, &unread, win->unread);
-	if (len < 0) err(EX_OSERR, "asprintf");
-	if (!win->unread) str[unread] = '\0';
-	termTitle(str);
-	free(str);
-}
+bool uiSpoilerReveal;
 
-static void uiStatus(void) {
-	wmove(ui.status, 0, 0);
-	int num = 0;
-	for (const struct Window *win = windows.head; win; win = win->next, ++num) {
-		if (!win->unread && windows.active != win) continue;
-		if (windows.active == win) title(win);
-		int unread;
-		wchar_t *str;
-		int len = aswprintf(
-			&str, L"%c\3%d %d %s %n(\3%02d%u\3%d) ",
-			(windows.active == win ? IRCReverse : IRCReset), colorFor(win->tag),
-			num, win->tag.name,
-			&unread, (win->hot ? IRCWhite : colorFor(win->tag)), win->unread,
-			colorFor(win->tag)
-		);
-		if (len < 0) err(EX_OSERR, "aswprintf");
-		if (!win->unread) str[unread] = L'\0';
-		addWrap(ui.status, str);
-		free(str);
+short uiPair(struct Style style) {
+	if (uiSpoilerReveal && style.fg == style.bg) {
+		return colorPair(Colors[Default], Colors[style.bg]);
 	}
-	wclrtoeol(ui.status);
+	return colorPair(Colors[style.fg], Colors[style.bg]);
 }
 
-static void uiShowWindow(struct Window *win) {
-	windowShow(win);
-	uiStatus();
-	uiPrompt(false);
+void uiShow(void) {
+	if (!hidden) return;
+	prevTitle[0] = '\0';
+	putp(FocusMode[true]);
+	putp(PasteMode[true]);
+	fflush(stdout);
+	hidden = false;
+	windowUnmark();
 }
 
-void uiShowTag(struct Tag tag) {
-	uiShowWindow(windowFor(tag));
+void uiHide(void) {
+	if (hidden) return;
+	windowMark();
+	hidden = true;
+	putp(FocusMode[false]);
+	putp(PasteMode[false]);
+	endwin();
 }
 
-static void uiShowAuto(void) {
-	struct Window *unread = NULL;
-	struct Window *hot;
-	for (hot = windows.head; hot; hot = hot->next) {
-		if (hot->hot) break;
-		if (!unread && hot->unread) unread = hot;
-	}
-	if (!hot && !unread) return;
-	uiShowWindow(hot ? hot : unread);
-}
+struct Util uiNotifyUtil;
+static void notify(uint id, const char *str) {
+	if (self.restricted) return;
+	if (!uiNotifyUtil.argc) return;
 
-void uiShowNum(int num, bool relative) {
-	struct Window *win = (relative ? windows.active : windows.head);
-	if (num < 0) {
-		for (; win; win = win->prev) if (!num++) break;
-	} else {
-		for (; win; win = win->next) if (!num--) break;
-	}
-	if (win) uiShowWindow(win);
-}
+	char buf[1024];
+	styleStrip(buf, sizeof(buf), str);
 
-void uiMoveTag(struct Tag tag, int num, bool relative) {
-	struct Window *win = windowFor(tag);
-	windowRemove(win);
-	struct Window *ins = (relative ? win : windows.head);
-	if (num < 0) {
-		for (; ins; ins = ins->prev) if (!num++) break;
-	} else {
-		if (relative) ins = ins->next;
-		for (; ins; ins = ins->next) if (!num--) break;
-	}
-	ins ? windowInsert(win, ins) : windowAppend(win);
-	uiStatus();
-}
+	struct Util util = uiNotifyUtil;
+	utilPush(&util, idNames[id]);
+	utilPush(&util, buf);
 
-void uiCloseTag(struct Tag tag) {
-	windowClose(windowFor(tag));
-	uiStatus();
-	uiPrompt(false);
-}
+	pid_t pid = fork();
+	if (pid < 0) err(EX_OSERR, "fork");
+	if (pid) return;
 
-static void notify(struct Tag tag, const wchar_t *str) {
-	beep();
-	if (!self.notify) return;
-
-	size_t len = 0;
-	char buf[256];
-	struct Format format = { .str = str };
-	formatReset(&format);
-	while (formatParse(&format, NULL)) {
-		int n = snprintf(
-			&buf[len], sizeof(buf) - len,
-			"%.*ls", (int)format.len, format.str
-		);
-		if (n < 0) err(EX_OSERR, "snprintf");
-		len += n;
-		if (len >= sizeof(buf)) break;
-	}
-	eventPipe((const char *[]) { "notify-send", tag.name, buf, NULL });
+	setsid();
+	close(STDIN_FILENO);
+	dup2(utilPipe[1], STDOUT_FILENO);
+	dup2(utilPipe[1], STDERR_FILENO);
+	execvp(util.argv[0], (char *const *)util.argv);
+	warn("%s", util.argv[0]);
+	_exit(EX_CONFIG);
 }
 
-void uiLog(struct Tag tag, enum UIHeat heat, const wchar_t *str) {
-	struct Window *win = windowFor(tag);
-	int lines = 1;
-	waddch(win->log, '\n');
-	if (win->mark && heat > UICold) {
-		if (!win->unread++) {
-			lines++;
-			waddch(win->log, '\n');
-		}
-		if (heat > UIWarm) {
-			win->hot = true;
-			notify(tag, str);
-		}
-		uiStatus();
+void uiWrite(uint id, enum Heat heat, const time_t *src, const char *str) {
+	bool note = windowWrite(id, heat, src, str);
+	if (note) {
+		beep();
+		notify(id, str);
 	}
-	lines += addWrap(win->log, str);
-	if (win->scroll != LogLines) win->scroll -= lines;
 }
 
-void uiFmt(struct Tag tag, enum UIHeat heat, const wchar_t *format, ...) {
-	wchar_t *str;
+void uiFormat(
+	uint id, enum Heat heat, const time_t *time, const char *format, ...
+) {
+	char buf[1024];
 	va_list ap;
 	va_start(ap, format);
-	vaswprintf(&str, format, ap);
+	int len = vsnprintf(buf, sizeof(buf), format, ap);
 	va_end(ap);
-	if (!str) err(EX_OSERR, "vaswprintf");
-	uiLog(tag, heat, str);
-	free(str);
+	assert((size_t)len < sizeof(buf));
+	uiWrite(id, heat, time, buf);
 }
 
-static void keyCode(wchar_t code) {
-	if (code == KEY_RESIZE) uiResize();
-	struct Window *win = windows.active;
-	if (!win) return;
-	switch (code) {
-		break; case KEY_UP:        windowScroll(win, -1);
-		break; case KEY_DOWN:      windowScroll(win, +1);
-		break; case KEY_PPAGE:     windowScroll(win, -(logHeight() - 1));
-		break; case KEY_NPAGE:     windowScroll(win, +(logHeight() - 1));
-		break; case KEY_LEFT:      edit(win->tag, EditLeft, 0);
-		break; case KEY_RIGHT:     edit(win->tag, EditRight, 0);
-		break; case KEY_HOME:      edit(win->tag, EditHome, 0);
-		break; case KEY_END:       edit(win->tag, EditEnd, 0);
-		break; case KEY_DC:        edit(win->tag, EditDelete, 0);
-		break; case KEY_BACKSPACE: edit(win->tag, EditBackspace, 0);
-		break; case KEY_ENTER:     edit(win->tag, EditEnter, 0);
-		break; default: return;
-	}
-	uiStatus();
+void uiResize(void) {
+	wclear(uiMain);
+	wresize(uiMain, MAIN_LINES, COLS);
+	windowResize();
 }
 
-static void keyMeta(wchar_t ch) {
-	struct Window *win = windows.active;
-	if (ch >= L'0' && ch <= L'9') uiShowNum(ch - L'0', false);
-	if (ch == L'a') uiShowAuto();
-	if (ch == L'/' && windows.other) uiShowWindow(windows.other);
-	if (!win) return;
-	switch (ch) {
-		break; case L'b':  edit(win->tag, EditBackWord, 0);
-		break; case L'f':  edit(win->tag, EditForeWord, 0);
-		break; case L'\b': edit(win->tag, EditKillBackWord, 0);
-		break; case L'd':  edit(win->tag, EditKillForeWord, 0);
-		break; case L'l':  uiHide(); logList(win->tag);
-		break; case L'm':  uiLog(win->tag, UICold, L"");
-	}
-}
+static FILE *saveFile;
+
+static const uint64_t Signatures[] = {
+	0x6C72696774616301, // no heat, unread, unreadWarm
+	0x6C72696774616302, // no self.pos
+	0x6C72696774616303, // no buffer line heat
+	0x6C72696774616304, // no mute
+	0x6C72696774616305, // no URLs
+	0x6C72696774616306, // no thresh
+	0x6C72696774616307, // no window time
+	0x6C72696774616308, // no input
+	0x6C72696774616309,
+};
 
-static void keyChar(wchar_t ch) {
-	struct Window *win = windows.active;
-	if (ch == CTRL(L'L')) clearok(curscr, true);
-	if (!win) return;
-	switch (ch) {
-		break; case CTRL(L'N'): uiShowNum(+1, true);
-		break; case CTRL(L'P'): uiShowNum(-1, true);
-
-		break; case CTRL(L'A'): edit(win->tag, EditHome, 0);
-		break; case CTRL(L'B'): edit(win->tag, EditLeft, 0);
-		break; case CTRL(L'D'): edit(win->tag, EditDelete, 0);
-		break; case CTRL(L'E'): edit(win->tag, EditEnd, 0);
-		break; case CTRL(L'F'): edit(win->tag, EditRight, 0);
-		break; case CTRL(L'K'): edit(win->tag, EditKillEnd, 0);
-		break; case CTRL(L'U'): edit(win->tag, EditKill, 0);
-		break; case CTRL(L'W'): edit(win->tag, EditKillBackWord, 0);
-
-		break; case CTRL(L'C'): edit(win->tag, EditInsert, IRCColor);
-		break; case CTRL(L'O'): edit(win->tag, EditInsert, IRCBold);
-		break; case CTRL(L'R'): edit(win->tag, EditInsert, IRCColor);
-		break; case CTRL(L'S'): edit(win->tag, EditInsert, IRCReset);
-		break; case CTRL(L'T'): edit(win->tag, EditInsert, IRCItalic);
-		break; case CTRL(L'V'): edit(win->tag, EditInsert, IRCReverse);
-		break; case CTRL(L'_'): edit(win->tag, EditInsert, IRCUnderline);
-
-		break; case L'\b': edit(win->tag, EditBackspace, 0);
-		break; case L'\t': edit(win->tag, EditComplete, 0);
-		break; case L'\n': edit(win->tag, EditEnter, 0);
-
-		break; default: if (iswprint(ch)) edit(win->tag, EditInsert, ch);
+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 save file signature %" PRIX64, signature);
 }
 
-void uiRead(void) {
-	if (ui.hide) uiShow();
-	static bool meta;
-	int ret;
-	wint_t ch;
-	enum TermEvent event;
-	while (ERR != (ret = wget_wch(ui.input, &ch))) {
-		if (ret == KEY_CODE_YES) {
-			keyCode(ch);
-		} else if (ch < 0200 && (event = termEvent((char)ch))) {
-			struct Window *win = windows.active;
-			switch (event) {
-				break; case TermFocusIn:  if (win) windowUnmark(win);
-				break; case TermFocusOut: if (win) windowMark(win);
-				break; default: {}
-			}
-			uiStatus();
-		} else if (ch == Esc) {
-			meta = true;
-			continue;
-		} else if (meta) {
-			keyMeta(ch == Del ? '\b' : ch);
-		} else {
-			keyChar(ch == Del ? '\b' : ch);
-		}
-		meta = false;
-	}
-	uiPrompt(false);
+static int writeUint64(FILE *file, uint64_t u) {
+	return (fwrite(&u, sizeof(u), 1, file) ? 0 : -1);
 }
 
-static bool isAction(struct Tag tag, const wchar_t *input) {
-	if (tag.id == TagStatus.id || tag.id == TagRaw.id) return false;
-	return !wcsncasecmp(input, L"/me ", 4);
+int uiSave(void) {
+	return 0
+		|| ftruncate(fileno(saveFile), 0)
+		|| writeUint64(saveFile, Signatures[8])
+		|| writeUint64(saveFile, self.pos)
+		|| windowSave(saveFile)
+		|| inputSave(saveFile)
+		|| urlSave(saveFile)
+		|| fclose(saveFile);
 }
 
-static bool isCommand(struct Tag tag, const wchar_t *input) {
-	if (tag.id == TagStatus.id || tag.id == TagRaw.id) return true;
-	if (input[0] != L'/') return false;
-	const wchar_t *space = wcschr(&input[1], L' ');
-	const wchar_t *extra = wcschr(&input[1], L'/');
-	return !extra || (space && extra > space);
+static uint64_t readUint64(FILE *file) {
+	uint64_t u;
+	fread(&u, sizeof(u), 1, file);
+	if (ferror(file)) err(EX_IOERR, "fread");
+	if (feof(file)) errx(EX_DATAERR, "unexpected end of save file");
+	return u;
 }
 
-void uiPrompt(bool nickChanged) {
-	static wchar_t *promptMesg;
-	static wchar_t *promptAction;
-	if (nickChanged || !promptMesg || !promptAction) {
-		free(promptMesg);
-		free(promptAction);
-		enum IRCColor color = colorGen(self.user);
-		int len = aswprintf(&promptMesg, L"\3%d<%s>\3 ", color, self.nick);
-		if (len < 0) err(EX_OSERR, "aswprintf");
-		len = aswprintf(&promptAction, L"\3%d* %s\3 ", color, self.nick);
-		if (len < 0) err(EX_OSERR, "aswprintf");
-	}
+void uiLoad(const char *name) {
+	int error;
+	saveFile = dataOpen(name, "a+e");
+	if (!saveFile) exit(EX_CANTCREAT);
+	rewind(saveFile);
 
-	const wchar_t *input = editHead();
+#ifdef __FreeBSD__
+	cap_rights_t rights;
+	cap_rights_init(&rights, CAP_READ, CAP_WRITE, CAP_FLOCK, CAP_FTRUNCATE);
+	error = caph_rights_limit(fileno(saveFile), &rights);
+	if (error) err(EX_OSERR, "cap_rights_limit");
+#endif
+
+	error = flock(fileno(saveFile), LOCK_EX | LOCK_NB);
+	if (error && errno == EWOULDBLOCK) {
+		errx(EX_CANTCREAT, "%s: save file in use", name);
+	}
 
-	wmove(ui.input, 0, 0);
-	if (windows.active) {
-		if (isAction(windows.active->tag, input) && editTail() >= &input[4]) {
-			input = &input[4];
-			addWrap(ui.input, promptAction);
-		} else if (!isCommand(windows.active->tag, input)) {
-			addWrap(ui.input, promptMesg);
-		}
+	time_t signature;
+	fread(&signature, sizeof(signature), 1, saveFile);
+	if (ferror(saveFile)) err(EX_IOERR, "fread");
+	if (feof(saveFile)) {
+		return;
 	}
+	size_t version = signatureVersion(signature);
 
-	int x = 0;
-	struct Format format = { .str = input };
-	formatReset(&format);
-	while (formatParse(&format, editTail())) {
-		if (format.split) getyx(ui.input, _, x);
-		addFormat(ui.input, &format);
+	if (version > 1) {
+		self.pos = readUint64(saveFile);
 	}
-	wclrtoeol(ui.input);
-	wmove(ui.input, 0, x);
+	windowLoad(saveFile, version);
+	inputLoad(saveFile, version);
+	urlLoad(saveFile, version);
 }
diff --git a/url.c b/url.c
index 21d93e6..7da0968 100644
--- a/url.c
+++ b/url.c
@@ -1,21 +1,35 @@
-/* Copyright (C) 2018  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 Affero General Public License as published by
+ * 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 Affero General Public License for more details.
+ * GNU General Public License for more details.
  *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ * 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 <assert.h>
 #include <err.h>
+#include <errno.h>
+#include <limits.h>
+#include <regex.h>
+#include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
 #include <sysexits.h>
@@ -23,89 +37,243 @@
 
 #include "chat.h"
 
-static const char *Schemes[] = {
-	"cvs:",
-	"ftp:",
-	"git:",
-	"http:",
-	"https:",
-	"irc:",
-	"ircs:",
-	"magnet:",
-	"sftp:",
-	"ssh:",
-	"svn:",
-	"telnet:",
-	"vnc:",
+static const char *Pattern = {
+	"("
+	"cvs|"
+	"ftp|"
+	"gemini|"
+	"git|"
+	"gopher|"
+	"http|"
+	"https|"
+	"irc|"
+	"ircs|"
+	"magnet|"
+	"sftp|"
+	"ssh|"
+	"svn|"
+	"telnet|"
+	"vnc"
+	")"
+	":([^[:space:]>\"()]|[(][^)]*[)])+"
 };
-static const size_t SchemesLen = sizeof(Schemes) / sizeof(Schemes[0]);
+static regex_t Regex;
 
-struct Entry {
-	size_t tag;
+static void compile(void) {
+	static bool compiled;
+	if (compiled) return;
+	compiled = true;
+	int error = regcomp(&Regex, Pattern, REG_EXTENDED);
+	if (!error) return;
+	char buf[256];
+	regerror(error, &Regex, buf, sizeof(buf));
+	errx(EX_SOFTWARE, "regcomp: %s: %s", buf, Pattern);
+}
+
+struct URL {
+	uint id;
+	char *nick;
 	char *url;
 };
 
-enum { RingLen = 32 };
-static_assert(!(RingLen & (RingLen - 1)), "power of two RingLen");
-
+enum { Cap = 64 };
 static struct {
-	struct Entry buf[RingLen];
-	size_t end;
+	struct URL urls[Cap];
+	size_t len;
 } ring;
+_Static_assert(!(Cap & (Cap - 1)), "Cap is power of two");
 
-static void ringPush(struct Tag tag, const char *url, size_t len) {
-	free(ring.buf[ring.end].url);
-	ring.buf[ring.end].tag = tag.id;
-	ring.buf[ring.end].url = strndup(url, len);
-	if (!ring.buf[ring.end].url) err(EX_OSERR, "strndup");
-	ring.end = (ring.end + 1) & (RingLen - 1);
+static void push(uint id, const char *nick, const char *str, size_t len) {
+	struct URL *url = &ring.urls[ring.len++ % Cap];
+	free(url->nick);
+	free(url->url);
+
+	url->id = id;
+	url->nick = NULL;
+	if (nick) {
+		url->nick = strdup(nick);
+		if (!url->nick) err(EX_OSERR, "strdup");
+	}
+	url->url = malloc(len + 1);
+	if (!url->url) err(EX_OSERR, "malloc");
+
+	char buf[1024];
+	snprintf(buf, sizeof(buf), "%.*s", (int)len, str);
+	styleStrip(url->url, len + 1, buf);
 }
 
-static struct Entry ringEntry(size_t i) {
-	return ring.buf[(ring.end + i) & (RingLen - 1)];
+void urlScan(uint id, const char *nick, const char *mesg) {
+	if (!mesg) return;
+	compile();
+	regmatch_t match = {0};
+	for (const char *ptr = mesg; *ptr; ptr += match.rm_eo) {
+		if (regexec(&Regex, ptr, 1, &match, 0)) break;
+		push(id, nick, &ptr[match.rm_so], match.rm_eo - match.rm_so);
+	}
 }
 
-void urlScan(struct Tag tag, const char *str) {
-	while (str[0]) {
-		size_t len = 1;
-		for (size_t i = 0; i < SchemesLen; ++i) {
-			if (strncmp(str, Schemes[i], strlen(Schemes[i]))) continue;
-			len = strcspn(str, " >\"");
-			ringPush(tag, str, len);
+struct Util urlOpenUtil;
+static const struct Util OpenUtils[] = {
+	{ 1, { "open" } },
+	{ 1, { "xdg-open" } },
+};
+
+static void urlOpen(const char *url) {
+	pid_t pid = fork();
+	if (pid < 0) err(EX_OSERR, "fork");
+	if (pid) return;
+
+	setsid();
+	close(STDIN_FILENO);
+	dup2(utilPipe[1], STDOUT_FILENO);
+	dup2(utilPipe[1], STDERR_FILENO);
+	if (urlOpenUtil.argc) {
+		struct Util util = urlOpenUtil;
+		utilPush(&util, url);
+		execvp(util.argv[0], (char *const *)util.argv);
+		warn("%s", util.argv[0]);
+		_exit(EX_CONFIG);
+	}
+	for (size_t i = 0; i < ARRAY_LEN(OpenUtils); ++i) {
+		struct Util util = OpenUtils[i];
+		utilPush(&util, url);
+		execvp(util.argv[0], (char *const *)util.argv);
+		if (errno != ENOENT) {
+			warn("%s", util.argv[0]);
+			_exit(EX_CONFIG);
 		}
-		str = &str[len];
 	}
+	warnx("no open utility found");
+	_exit(EX_CONFIG);
 }
 
-void urlList(struct Tag tag) {
-	uiHide();
-	for (size_t i = 0; i < RingLen; ++i) {
-		struct Entry entry = ringEntry(i);
-		if (!entry.url || entry.tag != tag.id) continue;
-		printf("%s\n", entry.url);
+struct Util urlCopyUtil;
+static const struct Util CopyUtils[] = {
+	{ 1, { "pbcopy" } },
+	{ 1, { "wl-copy" } },
+	{ 3, { "xclip", "-selection", "clipboard" } },
+	{ 3, { "xsel", "-i", "-b" } },
+};
+
+static void urlCopy(const char *url) {
+	int rw[2];
+	int error = pipe(rw);
+	if (error) err(EX_OSERR, "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");
+
+	error = close(rw[1]);
+	if (error) err(EX_IOERR, "close");
+
+	pid_t pid = fork();
+	if (pid < 0) err(EX_OSERR, "fork");
+	if (pid) {
+		close(rw[0]);
+		return;
+	}
+
+	setsid();
+	dup2(rw[0], STDIN_FILENO);
+	dup2(utilPipe[1], STDOUT_FILENO);
+	dup2(utilPipe[1], STDERR_FILENO);
+	close(rw[0]);
+	if (urlCopyUtil.argc) {
+		execvp(urlCopyUtil.argv[0], (char *const *)urlCopyUtil.argv);
+		warn("%s", urlCopyUtil.argv[0]);
+		_exit(EX_CONFIG);
+	}
+	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);
+		}
+	}
+	warnx("no copy utility found");
+	_exit(EX_CONFIG);
+}
+
+void urlOpenCount(uint id, uint count) {
+	for (uint i = 1; i <= Cap; ++i) {
+		const struct URL *url = &ring.urls[(ring.len - i) % Cap];
+		if (!url->url) break;
+		if (url->id != id) continue;
+		urlOpen(url->url);
+		if (!--count) break;
+	}
+}
+
+void urlOpenMatch(uint id, const char *str) {
+	for (uint i = 1; i <= Cap; ++i) {
+		const struct URL *url = &ring.urls[(ring.len - i) % Cap];
+		if (!url->url) break;
+		if (url->id != id) continue;
+		if ((url->nick && !strcmp(url->nick, str)) || strstr(url->url, str)) {
+			urlOpen(url->url);
+			break;
+		}
 	}
 }
 
-void urlOpenMatch(struct Tag tag, const char *substr) {
-	for (size_t i = RingLen - 1; i < RingLen; --i) {
-		struct Entry entry = ringEntry(i);
-		if (!entry.url || entry.tag != tag.id) continue;
-		if (!strstr(entry.url, substr)) continue;
-		eventPipe((const char *[]) { "open", entry.url, NULL });
-		break;
+void urlCopyMatch(uint id, const char *str) {
+	for (uint i = 1; i <= Cap; ++i) {
+		const struct URL *url = &ring.urls[(ring.len - i) % Cap];
+		if (!url->url) break;
+		if (url->id != id) continue;
+		if (
+			!str
+			|| (url->nick && !strcmp(url->nick, str))
+			|| strstr(url->url, str)
+		) {
+			urlCopy(url->url);
+			break;
+		}
 	}
 }
 
-void urlOpenRange(struct Tag tag, size_t at, size_t to) {
-	size_t argc = 1;
-	const char *argv[2 + RingLen] = { "open" };
-	size_t tagIndex = 0;
-	for (size_t i = RingLen - 1; i < RingLen; --i) {
-		struct Entry entry = ringEntry(i);
-		if (!entry.url || entry.tag != tag.id) continue;
-		if (tagIndex >= at && tagIndex < to) argv[argc++] = entry.url;
-		tagIndex++;
+static int writeString(FILE *file, const char *str) {
+	return (fwrite(str, strlen(str) + 1, 1, file) ? 0 : -1);
+}
+static ssize_t readString(FILE *file, char **buf, size_t *cap) {
+	ssize_t len = getdelim(buf, cap, '\0', file);
+	if (len < 0 && !feof(file)) err(EX_IOERR, "getdelim");
+	return len;
+}
+
+int urlSave(FILE *file) {
+	for (size_t i = 0; i < Cap; ++i) {
+		const struct URL *url = &ring.urls[(ring.len + i) % Cap];
+		if (!url->url) continue;
+		int error = 0
+			|| writeString(file, idNames[url->id])
+			|| writeString(file, (url->nick ?: ""))
+			|| writeString(file, url->url);
+		if (error) return error;
+	}
+	return writeString(file, "");
+}
+
+void urlLoad(FILE *file, size_t version) {
+	if (version < 5) return;
+	size_t cap = 0;
+	char *buf = NULL;
+	while (0 < readString(file, &buf, &cap) && buf[0]) {
+		struct URL *url = &ring.urls[ring.len++ % Cap];
+		free(url->nick);
+		free(url->url);
+		url->id = idFor(buf);
+		url->nick = NULL;
+		readString(file, &buf, &cap);
+		if (buf[0]) {
+			url->nick = strdup(buf);
+			if (!url->nick) err(EX_OSERR, "strdup");
+		}
+		readString(file, &buf, &cap);
+		url->url = strdup(buf);
+		if (!url->url) err(EX_OSERR, "strdup");
 	}
-	argv[argc] = NULL;
-	if (argc > 1) eventPipe(argv);
+	free(buf);
 }
diff --git a/window.c b/window.c
new file mode 100644
index 0000000..f700fd7
--- /dev/null
+++ b/window.c
@@ -0,0 +1,659 @@
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
+ */
+
+#define _XOPEN_SOURCE_EXTENDED
+
+#include <assert.h>
+#include <curses.h>
+#include <err.h>
+#include <limits.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+#include <time.h>
+
+#include "chat.h"
+
+#define MAIN_LINES (LINES - StatusLines - InputLines)
+
+static struct Window {
+	uint id;
+	int scroll;
+	bool mark;
+	bool mute;
+	bool time;
+	enum Heat thresh;
+	enum Heat heat;
+	uint unreadSoft;
+	uint unreadHard;
+	uint unreadWarm;
+	struct Buffer *buffer;
+} *windows[IDCap];
+
+static uint count;
+static uint show;
+static uint swap;
+static uint user;
+
+static uint windowPush(struct Window *window) {
+	assert(count < IDCap);
+	windows[count] = window;
+	return count++;
+}
+
+static uint windowInsert(uint num, struct Window *window) {
+	assert(count < IDCap);
+	assert(num <= count);
+	memmove(
+		&windows[num + 1],
+		&windows[num],
+		sizeof(*windows) * (count - num)
+	);
+	windows[num] = window;
+	count++;
+	return num;
+}
+
+static struct Window *windowRemove(uint num) {
+	assert(num < count);
+	struct Window *window = windows[num];
+	count--;
+	memmove(
+		&windows[num],
+		&windows[num + 1],
+		sizeof(*windows) * (count - num)
+	);
+	return window;
+}
+
+static void windowFree(struct Window *window) {
+	completeRemove(None, idNames[window->id]);
+	bufferFree(window->buffer);
+	free(window);
+}
+
+enum Heat windowThreshold = Cold;
+struct Time windowTime = { .format = "%X" };
+
+uint windowFor(uint id) {
+	for (uint num = 0; num < count; ++num) {
+		if (windows[num]->id == id) return num;
+	}
+
+	struct Window *window = calloc(1, sizeof(*window));
+	if (!window) err(EX_OSERR, "malloc");
+
+	window->id = id;
+	window->mark = true;
+	window->time = windowTime.enable;
+	if (id == Network || id == Debug) {
+		window->thresh = Cold;
+	} else {
+		window->thresh = windowThreshold;
+	}
+	window->buffer = bufferAlloc();
+	completePush(None, idNames[id], idColors[id]);
+
+	return windowPush(window);
+}
+
+enum { TimeCap = 64 };
+
+void windowInit(void) {
+	char fmt[TimeCap];
+	char buf[TimeCap];
+	styleStrip(fmt, sizeof(fmt), windowTime.format);
+
+	struct tm *time = localtime(&(time_t) { -22100400 });
+	size_t len = strftime(buf, sizeof(buf), fmt, time);
+	if (!len) errx(EX_CONFIG, "invalid timestamp format: %s", fmt);
+
+	int y;
+	waddstr(uiMain, buf);
+	waddch(uiMain, ' ');
+	getyx(uiMain, y, windowTime.width);
+	(void)y;
+
+	windowFor(Network);
+}
+
+static int styleAdd(WINDOW *win, struct Style init, const char *str) {
+	struct Style style = init;
+	while (*str) {
+		size_t len = styleParse(&style, &str);
+		if (!len) continue;
+		wattr_set(win, uiAttr(style), uiPair(style), NULL);
+		if (waddnstr(win, str, len) == ERR)
+			return -1;
+		str += len;
+	}
+	return 0;
+}
+
+static void statusUpdate(void) {
+	struct {
+		uint unread;
+		enum Heat heat;
+	} others = { 0, Cold };
+
+	wmove(uiStatus, 0, 0);
+	for (uint num = 0; num < count; ++num) {
+		const struct Window *window = windows[num];
+		if (num != show && !window->scroll && !inputPending(window->id)) {
+			if (window->heat < Warm) continue;
+			if (window->mute && window->heat < Hot) continue;
+		}
+		if (num != show) {
+			others.unread += window->unreadWarm;
+			if (window->heat > others.heat) others.heat = window->heat;
+		}
+		char buf[256], *end = &buf[sizeof(buf)];
+		char *ptr = seprintf(
+			buf, end, "\3%d%s %u%s%s %s ",
+			idColors[window->id], (num == show ? "\26" : ""),
+			num, window->thresh[(const char *[]) { "-", "", "+", "++" }],
+			&"="[!window->mute], idNames[window->id]
+		);
+		if (window->mark && window->unreadWarm) {
+			ptr = seprintf(
+				ptr, end, "\3%d+%d\3%d ",
+				(window->heat > Warm ? White : idColors[window->id]),
+				window->unreadWarm, idColors[window->id]
+			);
+		}
+		if (window->scroll) {
+			ptr = seprintf(ptr, end, "~%d ", window->scroll);
+		}
+		if (num != show && inputPending(window->id)) {
+			ptr = seprintf(ptr, end, "@ ");
+		}
+		if (styleAdd(uiStatus, StyleDefault, buf) < 0) break;
+	}
+	wclrtoeol(uiStatus);
+
+	const struct Window *window = windows[show];
+	char *end = &uiTitle[sizeof(uiTitle)];
+	char *ptr = seprintf(
+		uiTitle, end, "%s %s", network.name, idNames[window->id]
+	);
+	if (window->mark && window->unreadWarm) {
+		ptr = seprintf(
+			ptr, end, " +%d%s", window->unreadWarm, &"!"[window->heat < Hot]
+		);
+	}
+	if (others.unread) {
+		ptr = seprintf(
+			ptr, end, " (+%d%s)", others.unread, &"!"[others.heat < Hot]
+		);
+	}
+}
+
+static size_t windowTop(const struct Window *window) {
+	size_t top = BufferCap - MAIN_LINES - window->scroll;
+	if (window->scroll) top += MarkerLines;
+	return top;
+}
+
+static size_t windowBottom(const struct Window *window) {
+	size_t bottom = BufferCap - (window->scroll ?: 1);
+	if (window->scroll) bottom -= SplitLines + MarkerLines;
+	return bottom;
+}
+
+static void mainAdd(int y, bool time, const struct Line *line) {
+	int ny, nx;
+	wmove(uiMain, y, 0);
+	if (!line || !line->str[0]) {
+		wclrtoeol(uiMain);
+		return;
+	}
+	if (time && line->time) {
+		char buf[TimeCap];
+		strftime(buf, sizeof(buf), windowTime.format, localtime(&line->time));
+		struct Style init = { .fg = Gray, .bg = Default };
+		styleAdd(uiMain, init, buf);
+		waddch(uiMain, ' ');
+	} else if (time) {
+		whline(uiMain, ' ', windowTime.width);
+		wmove(uiMain, y, windowTime.width);
+	}
+	styleAdd(uiMain, StyleDefault, line->str);
+	getyx(uiMain, ny, nx);
+	if (ny != y) return;
+	wclrtoeol(uiMain);
+	(void)nx;
+}
+
+static void mainUpdate(void) {
+	const struct Window *window = windows[show];
+
+	int y = 0;
+	int marker = MAIN_LINES - SplitLines - MarkerLines;
+	for (size_t i = windowTop(window); i < BufferCap; ++i) {
+		mainAdd(y++, window->time, bufferHard(window->buffer, i));
+		if (window->scroll && y == marker) break;
+	}
+	if (!window->scroll) return;
+
+	y = MAIN_LINES - SplitLines;
+	for (size_t i = BufferCap - SplitLines; i < BufferCap; ++i) {
+		mainAdd(y++, window->time, bufferHard(window->buffer, i));
+	}
+	wattr_set(uiMain, A_NORMAL, 0, NULL);
+	mvwhline(uiMain, marker, 0, ACS_BULLET, COLS);
+}
+
+void windowUpdate(void) {
+	statusUpdate();
+	mainUpdate();
+}
+
+void windowBare(void) {
+	uiHide();
+	inputWait();
+
+	const struct Window *window = windows[show];
+	const struct Line *line = bufferHard(window->buffer, windowBottom(window));
+
+	uint num = 0;
+	if (line) num = line->num;
+	for (size_t i = 0; i < BufferCap; ++i) {
+		line = bufferSoft(window->buffer, i);
+		if (!line) continue;
+		if (line->num > num) break;
+		if (!line->str[0]) {
+			printf("\n");
+			continue;
+		}
+
+		char buf[TimeCap];
+		struct Style style = { .fg = Gray, .bg = Default };
+		strftime(buf, sizeof(buf), windowTime.format, localtime(&line->time));
+		vid_attr(uiAttr(style), uiPair(style), NULL);
+		printf("%s ", buf);
+
+		bool align = false;
+		style = StyleDefault;
+		for (const char *str = line->str; *str;) {
+			if (*str == '\t') {
+				printf("%c", (align ? '\t' : ' '));
+				align = true;
+				str++;
+			}
+
+			size_t len = styleParse(&style, &str);
+			size_t tab = strcspn(str, "\t");
+			if (tab < len) len = tab;
+
+			vid_attr(uiAttr(style), uiPair(style), NULL);
+			printf("%.*s", (int)len, str);
+			str += len;
+		}
+		printf("\n");
+	}
+}
+
+static void mark(struct Window *window) {
+	if (window->scroll) return;
+	window->mark = true;
+	window->unreadSoft = 0;
+	window->unreadWarm = 0;
+}
+
+static void unmark(struct Window *window) {
+	if (!window->scroll) {
+		window->mark = false;
+		window->heat = Cold;
+	}
+	statusUpdate();
+}
+
+static void scrollN(struct Window *window, int n) {
+	mark(window);
+	window->scroll += n;
+	if (window->scroll > BufferCap - MAIN_LINES) {
+		window->scroll = BufferCap - MAIN_LINES;
+	}
+	if (window->scroll < 0) window->scroll = 0;
+	unmark(window);
+	if (window == windows[show]) mainUpdate();
+}
+
+static void scrollTo(struct Window *window, int top) {
+	window->scroll = 0;
+	scrollN(window, top - MAIN_LINES + MarkerLines);
+}
+
+static int windowCols(const struct Window *window) {
+	return COLS - (window->time ? windowTime.width : 0);
+}
+
+bool windowWrite(uint id, enum Heat heat, const time_t *src, const char *str) {
+	struct Window *window = windows[windowFor(id)];
+	time_t ts = (src ? *src : time(NULL));
+
+	if (heat >= window->thresh) {
+		if (!window->unreadSoft++) window->unreadHard = 0;
+	}
+	if (window->mark && heat > Cold) {
+		if (!window->unreadWarm++) {
+			int lines = bufferPush(
+				window->buffer, windowCols(window),
+				window->thresh, Warm, ts, ""
+			);
+			if (window->scroll) scrollN(window, lines);
+			if (window->unreadSoft > 1) {
+				window->unreadSoft++;
+				window->unreadHard += lines;
+			}
+		}
+		if (heat > window->heat) window->heat = heat;
+		statusUpdate();
+	}
+	int lines = bufferPush(
+		window->buffer, windowCols(window),
+		window->thresh, heat, ts, str
+	);
+	window->unreadHard += lines;
+	if (window->scroll) scrollN(window, lines);
+	if (window == windows[show]) mainUpdate();
+
+	return window->mark && heat > Warm;
+}
+
+static void reflow(struct Window *window) {
+	uint num = 0;
+	const struct Line *line = bufferHard(window->buffer, windowTop(window));
+	if (line) num = line->num;
+	window->unreadHard = bufferReflow(
+		window->buffer, windowCols(window),
+		window->thresh, window->unreadSoft
+	);
+	if (!window->scroll || !num) return;
+	for (size_t i = 0; i < BufferCap; ++i) {
+		line = bufferHard(window->buffer, i);
+		if (!line || line->num != num) continue;
+		scrollTo(window, BufferCap - i);
+		break;
+	}
+}
+
+void windowResize(void) {
+	for (uint num = 0; num < count; ++num) {
+		reflow(windows[num]);
+	}
+	windowUpdate();
+}
+
+uint windowID(void) {
+	return windows[show]->id;
+}
+
+uint windowNum(void) {
+	return show;
+}
+
+void windowShow(uint num) {
+	if (num >= count) return;
+	if (num != show) {
+		swap = show;
+		mark(windows[swap]);
+	}
+	show = num;
+	user = num;
+	unmark(windows[show]);
+	mainUpdate();
+	inputUpdate();
+}
+
+void windowAuto(void) {
+	uint minHot = UINT_MAX, numHot = 0;
+	uint minWarm = UINT_MAX, numWarm = 0;
+	for (uint num = 0; num < count; ++num) {
+		struct Window *window = windows[num];
+		if (window->heat >= Hot) {
+			if (window->unreadWarm >= minHot) continue;
+			minHot = window->unreadWarm;
+			numHot = num;
+		}
+		if (window->heat >= Warm && !window->mute) {
+			if (window->unreadWarm >= minWarm) continue;
+			minWarm = window->unreadWarm;
+			numWarm = num;
+		}
+	}
+	uint oldUser = user;
+	if (minHot < UINT_MAX) {
+		windowShow(numHot);
+		user = oldUser;
+	} else if (minWarm < UINT_MAX) {
+		windowShow(numWarm);
+		user = oldUser;
+	} else if (user != show) {
+		windowShow(user);
+	}
+}
+
+void windowSwap(void) {
+	windowShow(swap);
+}
+
+void windowMove(uint from, uint to) {
+	if (from >= count) return;
+	struct Window *window = windowRemove(from);
+	if (to < count) {
+		windowShow(windowInsert(to, window));
+	} else {
+		windowShow(windowPush(window));
+	}
+}
+
+void windowClose(uint num) {
+	if (num >= count) return;
+	if (windows[num]->id == Network) return;
+	struct Window *window = windowRemove(num);
+	completeRemove(window->id, NULL);
+	windowFree(window);
+	if (swap >= num) swap--;
+	if (show == num) {
+		windowShow(swap);
+		swap = show;
+	} else if (show > num) {
+		show--;
+		mainUpdate();
+	}
+	statusUpdate();
+}
+
+void windowList(void) {
+	for (uint num = 0; num < count; ++num) {
+		const struct Window *window = windows[num];
+		uiFormat(
+			Network, Warm, NULL, "\3%02d%u %s",
+			idColors[window->id], num, idNames[window->id]
+		);
+	}
+}
+
+void windowMark(void) {
+	mark(windows[show]);
+}
+
+void windowUnmark(void) {
+	unmark(windows[show]);
+}
+
+void windowToggleMute(void) {
+	windows[show]->mute ^= true;
+	statusUpdate();
+}
+
+void windowToggleTime(void) {
+	windows[show]->time ^= true;
+	reflow(windows[show]);
+	windowUpdate();
+	inputUpdate();
+}
+
+void windowToggleThresh(int n) {
+	struct Window *window = windows[show];
+	if (n > 0 && window->thresh == Hot) return;
+	if (n < 0 && window->thresh == Ice) {
+		window->thresh = Cold;
+	} else {
+		window->thresh += n;
+	}
+	reflow(window);
+	windowUpdate();
+}
+
+bool windowTimeEnable(void) {
+	return windows[show]->time;
+}
+
+void windowScroll(enum Scroll by, int n) {
+	struct Window *window = windows[show];
+	switch (by) {
+		break; case ScrollOne: {
+			scrollN(window, n);
+		}
+		break; case ScrollPage: {
+			scrollN(window, n * (MAIN_LINES - SplitLines - MarkerLines - 1));
+		}
+		break; case ScrollAll: {
+			if (n < 0) {
+				scrollTo(window, 0);
+				break;
+			}
+			for (size_t i = 0; i < BufferCap; ++i) {
+				if (!bufferHard(window->buffer, i)) continue;
+				scrollTo(window, BufferCap - i);
+				break;
+			}
+		}
+		break; case ScrollUnread: {
+			scrollTo(window, window->unreadHard);
+		}
+		break; case ScrollHot: {
+			for (size_t i = windowTop(window) + n; i < BufferCap; i += n) {
+				const struct Line *line = bufferHard(window->buffer, i);
+				const struct Line *prev = bufferHard(window->buffer, i - 1);
+				if (!line || line->heat < Hot) continue;
+				if (prev && prev->heat > Warm) continue;
+				scrollTo(window, BufferCap - i);
+				break;
+			}
+		}
+	}
+}
+
+void windowSearch(const char *str, int dir) {
+	struct Window *window = windows[show];
+	for (size_t i = windowTop(window) + dir; i < BufferCap; i += dir) {
+		const struct Line *line = bufferHard(window->buffer, i);
+		if (!line || !strcasestr(line->str, str)) continue;
+		scrollTo(window, BufferCap - i);
+		break;
+	}
+}
+
+static int writeTime(FILE *file, time_t time) {
+	return (fwrite(&time, sizeof(time), 1, file) ? 0 : -1);
+}
+
+static int writeString(FILE *file, const char *str) {
+	return (fwrite(str, strlen(str) + 1, 1, file) ? 0 : -1);
+}
+
+int windowSave(FILE *file) {
+	int error;
+	for (uint num = 0; num < count; ++num) {
+		const struct Window *window = windows[num];
+		error = 0
+			|| writeString(file, idNames[window->id])
+			|| writeTime(file, window->mute)
+			|| writeTime(file, window->time)
+			|| writeTime(file, window->thresh)
+			|| writeTime(file, window->heat)
+			|| writeTime(file, window->unreadSoft)
+			|| writeTime(file, window->unreadWarm);
+		if (error) return error;
+		for (size_t i = 0; i < BufferCap; ++i) {
+			const struct Line *line = bufferSoft(window->buffer, i);
+			if (!line) continue;
+			error = 0
+				|| writeTime(file, line->time)
+				|| writeTime(file, line->heat)
+				|| writeString(file, line->str);
+			if (error) return error;
+		}
+		error = writeTime(file, 0);
+		if (error) return error;
+	}
+	return writeString(file, "");
+}
+
+static time_t readTime(FILE *file) {
+	time_t time;
+	fread(&time, sizeof(time), 1, file);
+	if (ferror(file)) err(EX_IOERR, "fread");
+	if (feof(file)) errx(EX_DATAERR, "unexpected end of save file");
+	return time;
+}
+
+static ssize_t readString(FILE *file, char **buf, size_t *cap) {
+	ssize_t len = getdelim(buf, cap, '\0', file);
+	if (len < 0 && !feof(file)) err(EX_IOERR, "getdelim");
+	return len;
+}
+
+void windowLoad(FILE *file, size_t version) {
+	size_t cap = 0;
+	char *buf = NULL;
+	while (0 < readString(file, &buf, &cap) && buf[0]) {
+		struct Window *window = windows[windowFor(idFor(buf))];
+		if (version > 3) window->mute = readTime(file);
+		if (version > 6) window->time = readTime(file);
+		if (version > 5) window->thresh = readTime(file);
+		if (version > 0) {
+			window->heat = readTime(file);
+			window->unreadSoft = readTime(file);
+			window->unreadWarm = readTime(file);
+		}
+		for (;;) {
+			time_t time = readTime(file);
+			if (!time) break;
+			enum Heat heat = (version > 2 ? readTime(file) : Cold);
+			readString(file, &buf, &cap);
+			bufferPush(window->buffer, COLS, window->thresh, heat, time, buf);
+		}
+		reflow(window);
+	}
+	free(buf);
+}
diff --git a/xdg.c b/xdg.c
new file mode 100644
index 0000000..75ee871
--- /dev/null
+++ b/xdg.c
@@ -0,0 +1,131 @@
+/* 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
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
+ */
+
+#include <err.h>
+#include <errno.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sysexits.h>
+
+#include "chat.h"
+
+#define SUBDIR "catgirl"
+
+struct Base {
+	const char *envHome;
+	const char *envDirs;
+	const char *defHome;
+	const char *defDirs;
+};
+
+static const struct Base Config = {
+	.envHome = "XDG_CONFIG_HOME",
+	.envDirs = "XDG_CONFIG_DIRS",
+	.defHome = ".config",
+	.defDirs = "/etc/xdg",
+};
+
+static const struct Base Data = {
+	.envHome = "XDG_DATA_HOME",
+	.envDirs = "XDG_DATA_DIRS",
+	.defHome = ".local/share",
+	.defDirs = "/usr/local/share:/usr/share",
+};
+
+static char *basePath(
+	struct Base base, char *buf, size_t cap, const char *path, int i
+) {
+	if (path[strspn(path, ".")] == '/') {
+		if (i > 0) return NULL;
+		snprintf(buf, cap, "%s", path);
+		return buf;
+	}
+
+	if (i > 0) {
+		const char *dirs = getenv(base.envDirs);
+		if (!dirs) dirs = base.defDirs;
+		for (; i > 1; --i) {
+			dirs += strcspn(dirs, ":");
+			dirs += (*dirs == ':');
+		}
+		if (!*dirs) return NULL;
+		snprintf(
+			buf, cap, "%.*s/" SUBDIR "/%s",
+			(int)strcspn(dirs, ":"), dirs, path
+		);
+		return buf;
+	}
+
+	const char *home = getenv("HOME");
+	const char *baseHome = getenv(base.envHome);
+	if (baseHome) {
+		snprintf(buf, cap, "%s/" SUBDIR "/%s", baseHome, path);
+	} else if (home) {
+		snprintf(buf, cap, "%s/%s/" SUBDIR "/%s", home, base.defHome, path);
+	} else {
+		errx(EX_USAGE, "HOME unset");
+	}
+	return buf;
+}
+
+char *configPath(char *buf, size_t cap, const char *path, int i) {
+	return basePath(Config, buf, cap, path, i);
+}
+
+char *dataPath(char *buf, size_t cap, const char *path, int i) {
+	return basePath(Data, buf, cap, path, i);
+}
+
+FILE *configOpen(const char *path, const char *mode) {
+	char buf[PATH_MAX];
+	for (int i = 0; configPath(buf, sizeof(buf), path, i); ++i) {
+		FILE *file = fopen(buf, mode);
+		if (file) return file;
+		if (errno != ENOENT) warn("%s", buf);
+	}
+	warn("%s", configPath(buf, sizeof(buf), path, 0));
+	return NULL;
+}
+
+FILE *dataOpen(const char *path, const char *mode) {
+	char buf[PATH_MAX];
+	for (int i = 0; dataPath(buf, sizeof(buf), path, i); ++i) {
+		FILE *file = fopen(buf, mode);
+		if (file) return file;
+		if (errno != ENOENT) warn("%s", buf);
+	}
+	if (mode[0] != 'r') {
+		int error = mkdir(dataPath(buf, sizeof(buf), "", 0), S_IRWXU);
+		if (error && errno != EEXIST) warn("%s", buf);
+	}
+	FILE *file = fopen(dataPath(buf, sizeof(buf), path, 0), mode);
+	if (!file) warn("%s", buf);
+	return file;
+}