about summary refs log tree commit diff
diff options
context:
space:
mode:
authorKylie McClain <kylie@somas.is>2022-03-01 20:17:25 -0500
committerKylie McClain <kylie@somas.is>2022-03-01 20:17:25 -0500
commit11aa8aefb6aa62bf0ffbd3701037daf147dd19d3 (patch)
tree1a3326ab200bfcd28347d80bc7f0651b4b1bb5a9
parentchat.c: o pona e toki lon open ilo (diff)
parentSpecify commands which depend on caps (diff)
downloadcatgirl-11aa8aefb6aa62bf0ffbd3701037daf147dd19d3.tar.gz
catgirl-11aa8aefb6aa62bf0ffbd3701037daf147dd19d3.zip
Merge branch 'master' of git.causal.agency:pub/catgirl into somasis/tokipona
Diffstat (limited to '')
-rw-r--r--.gitignore1
-rw-r--r--Makefile32
-rw-r--r--README.714
-rw-r--r--catgirl.1117
-rw-r--r--chat.c46
-rw-r--r--chat.h116
-rw-r--r--command.c132
-rw-r--r--compat_readpassphrase.c206
-rwxr-xr-xconfigure1
-rw-r--r--edit.c444
-rw-r--r--edit.h79
-rw-r--r--handle.c25
-rw-r--r--input.c628
-rw-r--r--scripts/Makefile22
-rw-r--r--ui.c1012
-rw-r--r--window.c658
16 files changed, 2196 insertions, 1337 deletions
diff --git a/.gitignore b/.gitignore
index e96e0c1..b31d1c5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
 *.o
+*.t
 catgirl
 chroot.tar
 config.mk
diff --git a/Makefile b/Makefile
index 48fc350..3abba03 100644
--- a/Makefile
+++ b/Makefile
@@ -3,7 +3,8 @@ BINDIR ?= ${PREFIX}/bin
 MANDIR ?= ${PREFIX}/man
 
 CEXTS = gnu-case-range gnu-conditional-omitted-operand
-CFLAGS += -std=c11 -Wall -Wextra -Wpedantic ${CEXTS:%=-Wno-%}
+CFLAGS += -std=c11 -Wall -Wextra -Wpedantic -Wmissing-prototypes
+CFLAGS += ${CEXTS:%=-Wno-%}
 LDADD.libtls = -ltls
 LDADD.ncursesw = -lncursesw
 
@@ -19,13 +20,17 @@ OBJS += config.o
 OBJS += edit.o
 OBJS += filter.o
 OBJS += handle.o
+OBJS += input.o
 OBJS += irc.o
 OBJS += log.o
 OBJS += ui.o
 OBJS += url.o
+OBJS += window.o
 OBJS += xdg.o
 
-dev: tags all
+TESTS += edit.t
+
+dev: tags all check
 
 all: catgirl
 
@@ -34,11 +39,21 @@ catgirl: ${OBJS}
 
 ${OBJS}: chat.h
 
+edit.o edit.t input.o: edit.h
+
+check: ${TESTS}
+
+.SUFFIXES: .t
+
+.c.t:
+	${CC} ${CFLAGS} -DTEST ${LDFLAGS} $< ${LDLIBS} -o $@
+	./$@ || rm $@
+
 tags: *.[ch]
 	ctags -w *.[ch]
 
 clean:
-	rm -f catgirl ${OBJS} tags
+	rm -f catgirl ${OBJS} ${TESTS} tags
 
 install: catgirl catgirl.1
 	install -d ${DESTDIR}${BINDIR} ${DESTDIR}${MANDIR}/man1
@@ -48,17 +63,6 @@ install: catgirl catgirl.1
 uninstall:
 	rm -f ${DESTDIR}${BINDIR}/catgirl ${DESTDIR}${MANDIR}/man1/catgirl.1
 
-scripts/sandman: scripts/sandman.o
-	${CC} ${LDFLAGS} scripts/sandman.o -framework Cocoa -o $@
-
-install-sandman: scripts/sandman scripts/sandman.1
-	install -d ${DESTDIR}${BINDIR} ${DESTDIR}${MANDIR}/man1
-	install scripts/sandman ${DESTDIR}${BINDIR}
-	install -m 644 scripts/sandman.1 ${DESTDIR}${MANDIR}/man1
-
-uninstall-sandman:
-	rm -f ${DESTDIR}${BINDIR}/sandman ${DESTDIR}${MANDIR}/man1/sandman.1
-
 CHROOT_USER = chat
 CHROOT_GROUP = ${CHROOT_USER}
 
diff --git a/README.7 b/README.7
index 5a614e8..32ee50f 100644
--- a/README.7
+++ b/README.7
@@ -1,5 +1,5 @@
 .\" To view this file, run: man ./README.7
-.Dd June 28, 2021
+.Dd February 19, 2022
 .Dt README 7
 .Os "Causal Agency"
 .
@@ -132,7 +132,7 @@ $ make all
 .Pp
 Packagers are encouraged
 to patch in their own text macros in
-.Pa edit.c .
+.Pa input.c .
 .
 .Pp
 If installing
@@ -167,8 +167,8 @@ to stop and start
 on system sleep and wake.
 Install it as follows:
 .Bd -literal -offset indent
-$ make scripts/sandman
-# make install-sandman
+$ make -C scripts sandman
+# make -C scripts install
 .Ed
 .
 .Sh FILES
@@ -181,10 +181,14 @@ startup and event loop
 IRC connection and parsing
 .It Pa ui.c
 curses interface
+.It Pa window.c
+window management
+.It Pa input.c
+input handling
 .It Pa handle.c
 IRC message handling
 .It Pa command.c
-input command handling
+command handling
 .It Pa buffer.c
 line wrapping
 .It Pa edit.c
diff --git a/catgirl.1 b/catgirl.1
index 34b9718..e9b124b 100644
--- a/catgirl.1
+++ b/catgirl.1
@@ -1,4 +1,4 @@
-.Dd February  3, 2022
+.Dd February 22, 2022
 .Dt CATGIRL 1
 .Os
 .
@@ -222,11 +222,9 @@ Authenticate as
 with
 .Ar pass
 using SASL PLAIN.
-Since this requires the account password
-in plain text,
-it is recommended to use CertFP instead.
-See
-.Sx Configuring CertFP .
+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
@@ -328,7 +326,8 @@ The default port is 6697.
 .It Fl q | Cm quiet
 Raise the default message visibility threshold
 for new windows,
-hiding general events.
+hiding general events
+(joins, quits, etc.).
 .
 .It Fl r Ar real | Cm real No = Ar real
 Set realname to
@@ -375,6 +374,9 @@ if it is not a terminal.
 .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
@@ -428,6 +430,104 @@ trust = example.pem
 .Ed
 .El
 .
+.Sh INTERFACE
+The
+.Nm
+interface is split
+into three areas.
+.
+.Ss Status Line
+The top line of the terminal
+shows window statuses.
+Only the currently active window
+and windows with activity are listed.
+The status line for a window
+might look like this:
+.Bd -literal -offset indent
+1+ #ascii.town +3 ~7 @
+.Ed
+.Pp
+The number on the left
+is the window number.
+Following it may be one of
+.Ql - ,
+.Ql + ,
+.Ql ++ ,
+as well as
+.Ql = .
+These indicate
+the message visibility threshold
+and mute status
+of the window.
+.Pp
+On the right side,
+the number following
+.Ql +
+indicates the number of unread messages.
+The number following
+.Ql ~
+indicates how many lines
+are below the scroll position.
+An
+.Ql @
+indicates that there is unsent input
+in the window's
+.Sx Input Line .
+.Pp
+.Nm
+will also set the terminal title,
+if possible,
+to the name of the network
+and active window,
+followed by the unread count
+for that window,
+and the unread count
+for all other windows
+in parentheses.
+.
+.Ss Chat Area
+The chat area shows
+messages and events.
+Regular messages are shown
+with the nick between
+.Ql <>
+angle brackets.
+Actions are shown
+with the nick preceded by
+.Ql * .
+Notices are shown
+with the nick between
+.Ql -
+hyphens.
+.Pp
+Blank lines are inserted into the chat
+as unread markers.
+.Pp
+While scrolling,
+the most recent 5 lines of chat
+are kept visible below a marker line.
+.
+.Ss Input Line
+The bottom line of the terminal
+is where messages and commands are entered.
+When entering a message, action or notice,
+your nick appears on the left,
+as it would in the
+.Sx Chat Area .
+When entering a command,
+no nick is shown.
+.Pp
+Formatting codes are shown
+in the input line
+as reverse-video uppercase letters.
+These will not appear in the sent message.
+.Pp
+Input that is too long
+to send as a single message
+will have a red background
+starting at the point where it will be split
+into a second message.
+.
 .Sh COMMANDS
 Any unique prefix can be used to abbreviate a command.
 For example,
@@ -666,7 +766,8 @@ Scroll down a page.
 .It Ic M-+
 Raise message visibility threshold,
 hiding ignored messages,
-general events,
+general events
+(joins, quits, etc.),
 or non-highlighted messages.
 .It Ic M--
 Lower message visibility threshold,
diff --git a/chat.c b/chat.c
index 5a856f7..1fc3b98 100644
--- a/chat.c
+++ b/chat.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2020  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -50,6 +50,8 @@
 #include <capsicum_helpers.h>
 #endif
 
+char *readpassphrase(const char *prompt, char *buf, size_t bufsiz, int flags);
+
 #include "chat.h"
 
 #ifndef OPENSSL_BIN
@@ -131,6 +133,12 @@ static void parseHash(char *str) {
 	if (*str) hashBound = strtoul(&str[1], NULL, 0);
 }
 
+static void parsePlain(char *str) {
+	self.plainUser = strsep(&str, ":");
+	if (!str) errx(EX_USAGE, "SASL PLAIN missing colon");
+	self.plainPass = str;
+}
+
 static volatile sig_atomic_t signals[NSIG];
 static void signalHandler(int signal) {
 	signals[signal] = 1;
@@ -285,10 +293,10 @@ int main(int argc, char *argv[]) {
 			break; case 'R': self.restricted = true;
 			break; case 'S': bind = optarg;
 			break; case 'T': {
-				uiTime.enable = true;
-				if (optarg) uiTime.format = optarg;
+				windowTime.enable = true;
+				if (optarg) windowTime.format = optarg;
 			}
-			break; case 'a': sasl = true; self.plain = optarg;
+			break; case 'a': sasl = true; parsePlain(optarg);
 			break; case 'c': cert = optarg;
 			break; case 'e': sasl = true;
 			break; case 'g': genCert(optarg);
@@ -301,7 +309,7 @@ int main(int argc, char *argv[]) {
 			break; case 'n': nick = optarg;
 			break; case 'o': printCert = true;
 			break; case 'p': port = optarg;
-			break; case 'q': uiThreshold = Warm;
+			break; case 'q': windowThreshold = Warm;
 			break; case 'r': real = optarg;
 			break; case 's': save = optarg;
 			break; case 't': trust = optarg;
@@ -337,6 +345,20 @@ int main(int argc, char *argv[]) {
 		user = hash;
 	}
 
+	if (pass && !pass[0]) {
+		char *buf = malloc(512);
+		if (!buf) err(EX_OSERR, "malloc");
+		pass = readpassphrase("Server password: ", buf, 512, 0);
+		if (!pass) errx(EX_IOERR, "unable to read passphrase");
+	}
+
+	if (self.plainPass && !self.plainPass[0]) {
+		char *buf = malloc(512);
+		if (!buf) err(EX_OSERR, "malloc");
+		self.plainPass = readpassphrase("Account password: ", buf, 512, 0);
+		if (!self.plainPass) errx(EX_IOERR, "unable to read passphrase");
+	}
+
 	// Modes defined in RFC 1459:
 	set(&network.chanTypes, "#&");
 	set(&network.prefixes, "@+");
@@ -349,18 +371,17 @@ int main(int argc, char *argv[]) {
 	set(&network.name, host);
 	set(&self.nick, "*");
 
-	editCompleteAdd();
-	commandCompleteAdd();
+	inputCompleteAdd();
 
 	ircConfig(insecure, trust, cert, priv);
 
-	uiInitEarly();
+	uiInit();
 	sig_t cursesWinch = signal(SIGWINCH, signalHandler);
 	if (save) {
 		uiLoad(save);
 		atexit(exitSave);
 	}
-	uiShowID(Network);
+	windowShow(windowFor(Network));
 	uiFormat(
 		Network, Cold, NULL,
 		"jan ale li ken kepeken, li ken lukin, li ken ante e kon pi ilo \3%dMeli Soweli\3 a!",
@@ -393,7 +414,8 @@ int main(int argc, char *argv[]) {
 	ircFormat("NICK :%s\r\n", nick);
 	ircFormat("USER %s 0 * :%s\r\n", user, real);
 
-	uiInitLate();
+	// Avoid disabling VINTR until main loop.
+	inputInit();
 	signal(SIGHUP, signalHandler);
 	signal(SIGINT, signalHandler);
 	signal(SIGALRM, signalHandler);
@@ -422,7 +444,7 @@ int main(int argc, char *argv[]) {
 		int nfds = poll(fds, (pipes ? ARRAY_LEN(fds) : 2), -1);
 		if (nfds < 0 && errno != EINTR) err(EX_IOERR, "poll");
 		if (nfds > 0) {
-			if (fds[0].revents) uiRead();
+			if (fds[0].revents) inputRead();
 			if (fds[1].revents) ircRecv();
 			if (fds[2].revents) utilRead();
 			if (fds[3].revents) execRead();
@@ -474,7 +496,7 @@ int main(int argc, char *argv[]) {
 			cursesWinch(SIGWINCH);
 			// doupdate(3) needs to be called for KEY_RESIZE to be picked up.
 			uiDraw();
-			uiRead();
+			inputRead();
 		}
 
 		uiDraw();
diff --git a/chat.h b/chat.h
index 19d2b18..1c46f00 100644
--- a/chat.h
+++ b/chat.h
@@ -193,7 +193,8 @@ extern struct Self {
 	bool restricted;
 	size_t pos;
 	enum Cap caps;
-	char *plain;
+	char *plainUser;
+	char *plainPass;
 	char *mode;
 	char *join;
 	char *nick;
@@ -292,27 +293,34 @@ const char *commandIsAction(uint id, const char *input);
 size_t commandWillSplit(uint id, const char *input);
 void commandCompleteAdd(void);
 
-enum Heat { Ice, Cold, Warm, Hot };
-enum { TimeCap = 64 };
-extern enum Heat uiThreshold;
-extern struct Time {
-	bool enable;
-	const char *format;
-	int width;
-} uiTime;
+enum Heat {
+	Ice,
+	Cold,
+	Warm,
+	Hot,
+};
+
+enum {
+	TitleCap = 256,
+	StatusLines = 1,
+	MarkerLines = 1,
+	SplitLines = 5,
+	InputLines = 1,
+	InputCols = 1024,
+};
+extern char uiTitle[TitleCap];
+extern struct _win_st *uiStatus;
+extern struct _win_st *uiMain;
+extern struct _win_st *uiInput;
+extern bool uiSpoilerReveal;
 extern struct Util uiNotifyUtil;
-void uiInitEarly(void);
-void uiInitLate(void);
+void uiInit(void);
+uint uiAttr(struct Style style);
+short uiPair(struct Style style);
 void uiShow(void);
 void uiHide(void);
 void uiDraw(void);
-void uiWindows(void);
-void uiShowID(uint id);
-void uiShowNum(uint num);
-void uiMoveID(uint id, uint num);
-void uiCloseID(uint id);
-void uiCloseNum(uint id);
-void uiRead(void);
+void uiResize(void);
 void uiWrite(uint id, enum Heat heat, const time_t *time, const char *str);
 void uiFormat(
 	uint id, enum Heat heat, const time_t *time, const char *format, ...
@@ -320,6 +328,53 @@ void uiFormat(
 void uiLoad(const char *name);
 int uiSave(void);
 
+void inputInit(void);
+void inputWait(void);
+void inputUpdate(void);
+bool inputPending(uint id);
+void inputRead(void);
+void inputCompleteAdd(void);
+int inputSave(FILE *file);
+void inputLoad(FILE *file, size_t version);
+
+enum Scroll {
+	ScrollOne,
+	ScrollPage,
+	ScrollAll,
+	ScrollUnread,
+	ScrollHot,
+};
+extern struct Time {
+	bool enable;
+	const char *format;
+	int width;
+} windowTime;
+extern enum Heat windowThreshold;
+void windowInit(void);
+void windowUpdate(void);
+void windowResize(void);
+bool windowWrite(uint id, enum Heat heat, const time_t *time, const char *str);
+void windowBare(void);
+uint windowID(void);
+uint windowNum(void);
+uint windowFor(uint id);
+void windowShow(uint num);
+void windowAuto(void);
+void windowSwap(void);
+void windowMove(uint from, uint to);
+void windowClose(uint num);
+void windowList(void);
+void windowMark(void);
+void windowUnmark(void);
+void windowToggleMute(void);
+void windowToggleTime(void);
+void windowToggleThresh(int n);
+bool windowTimeEnable(void);
+void windowScroll(enum Scroll by, int n);
+void windowSearch(const char *str, int dir);
+int windowSave(FILE *file);
+void windowLoad(FILE *file, size_t version);
+
 enum { BufferCap = 1024 };
 struct Buffer;
 struct Line {
@@ -340,31 +395,6 @@ int bufferReflow(
 	struct Buffer *buffer, int cols, enum Heat thresh, size_t tail
 );
 
-enum Edit {
-	EditHead,
-	EditTail,
-	EditPrev,
-	EditNext,
-	EditPrevWord,
-	EditNextWord,
-	EditDeleteHead,
-	EditDeleteTail,
-	EditDeletePrev,
-	EditDeleteNext,
-	EditDeletePrevWord,
-	EditDeleteNextWord,
-	EditPaste,
-	EditTranspose,
-	EditCollapse,
-	EditInsert,
-	EditComplete,
-	EditExpand,
-	EditEnter,
-};
-void edit(uint id, enum Edit op, wchar_t ch);
-char *editBuffer(size_t *pos);
-void editCompleteAdd(void);
-
 const char *complete(uint id, const char *prefix);
 const char *completeSubstr(uint id, const char *substr);
 void completeAccept(void);
diff --git a/command.c b/command.c
index 43315f0..f73c213 100644
--- a/command.c
+++ b/command.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2020  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -144,7 +144,7 @@ static void commandMsg(uint id, char *params) {
 	if (params) {
 		splitMessage("PRIVMSG", msg, params);
 	} else {
-		uiShowID(msg);
+		windowShow(windowFor(msg));
 	}
 }
 
@@ -376,25 +376,25 @@ static void commandQuery(uint id, char *params) {
 	if (idColors[query] == Default) {
 		idColors[query] = completeColor(id, params);
 	}
-	uiShowID(query);
+	windowShow(windowFor(query));
 }
 
 static void commandWindow(uint id, char *params) {
 	if (!params) {
-		uiWindows();
+		windowList();
 	} else if (isdigit(params[0])) {
-		uiShowNum(strtoul(params, NULL, 10));
+		windowShow(strtoul(params, NULL, 10));
 	} else {
 		id = idFind(params);
 		if (id) {
-			uiShowID(id);
+			windowShow(windowFor(id));
 			return;
 		}
 		for (const char *match; (match = completeSubstr(None, params));) {
 			id = idFind(match);
 			if (!id) continue;
 			completeAccept();
-			uiShowID(id);
+			windowShow(windowFor(id));
 			break;
 		}
 	}
@@ -405,20 +405,20 @@ static void commandMove(uint id, char *params) {
 	char *name = strsep(&params, " ");
 	if (params) {
 		id = idFind(name);
-		if (id) uiMoveID(id, strtoul(params, NULL, 10));
+		if (id) windowMove(windowFor(id), strtoul(params, NULL, 10));
 	} else {
-		uiMoveID(id, strtoul(name, NULL, 10));
+		windowMove(windowFor(id), strtoul(name, NULL, 10));
 	}
 }
 
 static void commandClose(uint id, char *params) {
 	if (!params) {
-		uiCloseID(id);
+		windowClose(windowFor(id));
 	} else if (isdigit(params[0])) {
-		uiCloseNum(strtoul(params, NULL, 10));
+		windowClose(strtoul(params, NULL, 10));
 	} else {
 		id = idFind(params);
-		if (id) uiCloseID(id);
+		if (id) windowClose(windowFor(id));
 	}
 }
 
@@ -537,53 +537,54 @@ static const struct Handler {
 	const char *cmd;
 	Command *fn;
 	enum Flag flags;
+	enum Cap caps;
 } Commands[] = {
-	{ "/away", commandAway, 0 },
-	{ "/ban", commandBan, 0 },
-	{ "/close", commandClose, 0 },
-	{ "/copy", commandCopy, Restrict | Kiosk },
-	{ "/cs", commandCS, 0 },
-	{ "/debug", commandDebug, Kiosk },
-	{ "/deop", commandDeop, 0 },
-	{ "/devoice", commandDevoice, 0 },
-	{ "/except", commandExcept, 0 },
-	{ "/exec", commandExec, Multiline | Restrict | Kiosk },
-	{ "/help", commandHelp, 0 }, // Restrict special case.
-	{ "/highlight", commandHighlight, 0 },
-	{ "/ignore", commandIgnore, 0 },
-	{ "/invex", commandInvex, 0 },
-	{ "/invite", commandInvite, 0 },
-	{ "/join", commandJoin, Kiosk },
-	{ "/kick", commandKick, 0 },
-	{ "/list", commandList, Kiosk },
-	{ "/me", commandMe, Multiline },
-	{ "/mode", commandMode, 0 },
-	{ "/move", commandMove, 0 },
-	{ "/msg", commandMsg, Multiline | Kiosk },
-	{ "/names", commandNames, 0 },
-	{ "/nick", commandNick, 0 },
-	{ "/notice", commandNotice, Multiline },
-	{ "/ns", commandNS, 0 },
-	{ "/o", commandOpen, Restrict | Kiosk },
-	{ "/op", commandOp, 0 },
-	{ "/open", commandOpen, Restrict | Kiosk },
-	{ "/ops", commandOps, 0 },
-	{ "/part", commandPart, Kiosk },
-	{ "/query", commandQuery, Kiosk },
-	{ "/quit", commandQuit, 0 },
-	{ "/quote", commandQuote, Multiline | Kiosk },
-	{ "/say", commandPrivmsg, Multiline },
-	{ "/setname", commandSetname, 0 },
-	{ "/topic", commandTopic, 0 },
-	{ "/unban", commandUnban, 0 },
-	{ "/unexcept", commandUnexcept, 0 },
-	{ "/unhighlight", commandUnhighlight, 0 },
-	{ "/unignore", commandUnignore, 0 },
-	{ "/uninvex", commandUninvex, 0 },
-	{ "/voice", commandVoice, 0 },
-	{ "/whois", commandWhois, 0 },
-	{ "/whowas", commandWhowas, 0 },
-	{ "/window", commandWindow, 0 },
+	{ "/away", commandAway, 0, 0 },
+	{ "/ban", commandBan, 0, 0 },
+	{ "/close", commandClose, 0, 0 },
+	{ "/copy", commandCopy, Restrict | Kiosk, 0 },
+	{ "/cs", commandCS, 0, 0 },
+	{ "/debug", commandDebug, Kiosk, 0 },
+	{ "/deop", commandDeop, 0, 0 },
+	{ "/devoice", commandDevoice, 0, 0 },
+	{ "/except", commandExcept, 0, 0 },
+	{ "/exec", commandExec, Multiline | Restrict | Kiosk, 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, Kiosk, 0 },
+	{ "/kick", commandKick, 0, 0 },
+	{ "/list", commandList, Kiosk, 0 },
+	{ "/me", commandMe, Multiline, 0 },
+	{ "/mode", commandMode, 0, 0 },
+	{ "/move", commandMove, 0, 0 },
+	{ "/msg", commandMsg, Multiline | Kiosk, 0 },
+	{ "/names", commandNames, 0, 0 },
+	{ "/nick", commandNick, 0, 0 },
+	{ "/notice", commandNotice, Multiline, 0 },
+	{ "/ns", commandNS, 0, 0 },
+	{ "/o", commandOpen, Restrict | Kiosk, 0 },
+	{ "/op", commandOp, 0, 0 },
+	{ "/open", commandOpen, Restrict | Kiosk, 0 },
+	{ "/ops", commandOps, 0, 0 },
+	{ "/part", commandPart, Kiosk, 0 },
+	{ "/query", commandQuery, Kiosk, 0 },
+	{ "/quit", commandQuit, 0, 0 },
+	{ "/quote", commandQuote, Multiline | Kiosk, 0 },
+	{ "/say", commandPrivmsg, Multiline, 0 },
+	{ "/setname", commandSetname, 0, CapSetname },
+	{ "/topic", commandTopic, 0, 0 },
+	{ "/unban", commandUnban, 0, 0 },
+	{ "/unexcept", commandUnexcept, 0, 0 },
+	{ "/unhighlight", commandUnhighlight, 0, 0 },
+	{ "/unignore", commandUnignore, 0, 0 },
+	{ "/uninvex", commandUninvex, 0, 0 },
+	{ "/voice", commandVoice, 0, 0 },
+	{ "/whois", commandWhois, 0, 0 },
+	{ "/whowas", commandWhowas, 0, 0 },
+	{ "/window", commandWindow, 0, 0 },
 };
 
 static int compar(const void *cmd, const void *_handler) {
@@ -639,6 +640,15 @@ size_t commandWillSplit(uint id, const char *input) {
 	return 0;
 }
 
+static bool commandAvailable(const struct Handler *handler) {
+	if (handler->flags & Restrict && self.restricted) return false;
+	if (handler->flags & Kiosk && self.kiosk) 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);
@@ -667,10 +677,7 @@ void command(uint id, char *input) {
 		uiFormat(id, Warm, NULL, "mi sona ala e toki ilo '%s'", cmd);
 		return;
 	}
-	if (
-		(self.restricted && handler->flags & Restrict) ||
-		(self.kiosk && handler->flags & Kiosk)
-	) {
+	if (!commandAvailable(handler)) {
 		uiFormat(id, Warm, NULL, "sina ken ala kepeken toki ilo '%s'", cmd);
 		return;
 	}
@@ -689,6 +696,7 @@ void command(uint id, char *input) {
 
 void commandCompleteAdd(void) {
 	for (size_t i = 0; i < ARRAY_LEN(Commands); ++i) {
+		if (!commandAvailable(&Commands[i])) continue;
 		completeAdd(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/configure b/configure
index 3459f94..9465b77 100755
--- a/configure
+++ b/configure
@@ -45,6 +45,7 @@ case "$(uname)" in
 		cflags -Wno-pedantic -D_GNU_SOURCE
 		config libtls ncursesw
 		defvar OPENSSL_BIN openssl exec_prefix /bin/openssl
+		echo 'OBJS += compat_readpassphrase.o'
 		;;
 	(Darwin)
 		cflags -D__STDC_WANT_LIB_EXT1__=1
diff --git a/edit.c b/edit.c
index 71c5cca..bb92edf 100644
--- a/edit.c
+++ b/edit.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2020  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2020, 2022  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -25,276 +25,272 @@
  * covered work.
  */
 
-#include <assert.h>
+#include <errno.h>
 #include <limits.h>
 #include <stdbool.h>
-#include <stdio.h>
 #include <stdlib.h>
 #include <wchar.h>
 #include <wctype.h>
 
-#include "chat.h"
+#include "edit.h"
 
-enum { Cap = 1024 };
-static wchar_t buf[Cap];
-static size_t len;
-static size_t pos;
+static bool isword(wchar_t ch) {
+	return !iswspace(ch) && !iswpunct(ch);
+}
 
-char *editBuffer(size_t *mbsPos) {
-	static char mbs[MB_LEN_MAX * Cap];
+char *editString(const struct Edit *e, char **buf, size_t *cap, size_t *pos) {
+	size_t req = e->len * MB_CUR_MAX + 1;
+	if (req > *cap) {
+		char *new = realloc(*buf, req);
+		if (!new) return NULL;
+		*buf = new;
+		*cap = req;
+	}
 
-	const wchar_t *ptr = buf;
-	size_t mbsLen = wcsnrtombs(mbs, &ptr, pos, sizeof(mbs) - 1, NULL);
-	assert(mbsLen != (size_t)-1);
-	if (mbsPos) *mbsPos = mbsLen;
+	const wchar_t *ptr = e->buf;
+	size_t len = wcsnrtombs(*buf, &ptr, e->pos, *cap-1, NULL);
+	if (len == (size_t)-1) return NULL;
+	if (pos) *pos = len;
 
-	ptr = &buf[pos];
+	ptr = &e->buf[e->pos];
 	size_t n = wcsnrtombs(
-		&mbs[mbsLen], &ptr, len - pos, sizeof(mbs) - mbsLen - 1, NULL
+		*buf + len, &ptr, e->len - e->pos, *cap-1 - len, NULL
 	);
-	assert(n != (size_t)-1);
-	mbsLen += n;
-
-	mbs[mbsLen] = '\0';
-	return mbs;
-}
-
-static struct {
-	wchar_t buf[Cap];
-	size_t len;
-} cut;
+	if (n == (size_t)-1) return NULL;
+	len += n;
 
-static bool reserve(size_t index, size_t count) {
-	if (len + count > Cap) return false;
-	wmemmove(&buf[index + count], &buf[index], len - index);
-	len += count;
-	return true;
+	(*buf)[len] = '\0';
+	return *buf;
 }
 
-static void delete(bool copy, size_t index, size_t count) {
-	if (index + count > len) return;
-	if (copy) {
-		wmemcpy(cut.buf, &buf[index], count);
-		cut.len = count;
+int editReserve(struct Edit *e, size_t index, size_t count) {
+	if (index > e->len) {
+		errno = EINVAL;
+		return -1;
 	}
-	wmemmove(&buf[index], &buf[index + count], len - index - count);
-	len -= count;
-}
-
-static const struct {
-	const wchar_t *name;
-	const wchar_t *string;
-} Macros[] = {
-	{ L"\\banhammer", L"▬▬▬▬▬▬▬▋ Ò╭╮Ó" },
-	{ L"\\bear", L"ʕっ•ᴥ•ʔっ" },
-	{ L"\\blush", L"(˶′◡‵˶)" },
-	{ L"\\com", L"\0038,4\2 ☭ " },
-	{ L"\\cool", L"(⌐■_■)" },
-	{ L"\\flip", L"(╯°□°)╯︵ ┻━┻" },
-	{ L"\\gary", L"ᕕ( ᐛ )ᕗ" },
-	{ L"\\hug", L"(っ・∀・)っ" },
-	{ L"\\lenny", L"( ͡° ͜ʖ ͡°)" },
-	{ L"\\look", L"ಠ_ಠ" },
-	{ L"\\shrug", L"¯\\_(ツ)_/¯" },
-	{ L"\\unflip", L"┬─┬ノ(º_ºノ)" },
-	{ L"\\wave", L"ヾ(^∇^)" },
-};
-
-void editCompleteAdd(void) {
-	char mbs[256];
-	for (size_t i = 0; i < ARRAY_LEN(Macros); ++i) {
-		size_t n = wcstombs(mbs, Macros[i].name, sizeof(mbs));
-		assert(n != (size_t)-1);
-		completeAdd(None, mbs, Default);
+	if (e->len + count > e->cap) {
+		size_t cap = (e->cap ?: 256);
+		while (cap < e->len + count) cap *= 2;
+		wchar_t *buf = realloc(e->buf, sizeof(*buf) * cap);
+		if (!buf) return -1;
+		e->buf = buf;
+		e->cap = cap;
 	}
+	wmemmove(&e->buf[index + count], &e->buf[index], e->len - index);
+	e->len += count;
+	return 0;
 }
 
-static void macroExpand(void) {
-	size_t macro = pos;
-	while (macro && buf[macro] != L'\\') macro--;
-	if (macro == pos) return;
-	for (size_t i = 0; i < ARRAY_LEN(Macros); ++i) {
-		if (wcsncmp(Macros[i].name, &buf[macro], pos - macro)) continue;
-		if (wcstombs(NULL, Macros[i].string, 0) == (size_t)-1) continue;
-		delete(false, macro, pos - macro);
-		pos = macro;
-		size_t expand = wcslen(Macros[i].string);
-		if (reserve(macro, expand)) {
-			wcsncpy(&buf[macro], Macros[i].string, expand);
-			pos += expand;
-		}
+int editCopy(struct Edit *e, size_t index, size_t count) {
+	if (index + count > e->len) {
+		errno = EINVAL;
+		return -1;
 	}
+	if (!e->cut) return 0;
+	e->cut->len = 0;
+	if (editReserve(e->cut, 0, count) < 0) return -1;
+	wmemcpy(e->cut->buf, &e->buf[index], count);
+	return 0;
 }
 
-static struct {
-	size_t pos;
-	size_t pre;
-	size_t len;
-	bool suffix;
-} tab;
-
-static void tabComplete(uint id) {
-	if (!tab.len) {
-		tab.pos = pos;
-		while (tab.pos && !iswspace(buf[tab.pos - 1])) tab.pos--;
-		if (tab.pos == pos) return;
-		tab.pre = pos - tab.pos;
-		tab.len = tab.pre;
-		tab.suffix = true;
-	}
-
-	char mbs[MB_LEN_MAX * Cap];
-	const wchar_t *ptr = &buf[tab.pos];
-	size_t n = wcsnrtombs(mbs, &ptr, tab.pre, sizeof(mbs) - 1, NULL);
-	assert(n != (size_t)-1);
-	mbs[n] = '\0';
-
-	const char *comp = complete(id, mbs);
-	if (!comp) {
-		comp = complete(id, mbs);
-		tab.suffix ^= true;
+int editDelete(struct Edit *e, bool cut, size_t index, size_t count) {
+	if (index + count > e->len) {
+		errno = EINVAL;
+		return -1;
 	}
-	if (!comp) {
-		tab.len = 0;
-		return;
-	}
-
-	wchar_t wcs[Cap];
-	n = mbstowcs(wcs, comp, Cap);
-	assert(n != (size_t)-1);
-	if (tab.pos + n + 2 > Cap) {
-		completeReject();
-		tab.len = 0;
-		return;
-	}
-
-	bool colon = (tab.len >= 2 && buf[tab.pos + tab.len - 2] == L':');
-
-	delete(false, tab.pos, tab.len);
-	tab.len = n;
-	if (wcs[0] == L'\\' || wcschr(wcs, L' ')) {
-		reserve(tab.pos, tab.len);
-	} else if (wcs[0] != L'/' && tab.suffix && (!tab.pos || colon)) {
-		tab.len += 2;
-		reserve(tab.pos, tab.len);
-		buf[tab.pos + n + 0] = L':';
-		buf[tab.pos + n + 1] = L' ';
-	} else if (tab.suffix && tab.pos >= 2 && buf[tab.pos - 2] == L':') {
-		tab.len += 2;
-		reserve(tab.pos, tab.len);
-		buf[tab.pos - 2] = L',';
-		buf[tab.pos + n + 0] = L':';
-		buf[tab.pos + n + 1] = L' ';
-	} else {
-		tab.len++;
-		reserve(tab.pos, tab.len);
-		if (!tab.suffix && tab.pos >= 2 && buf[tab.pos - 2] == L',') {
-			buf[tab.pos - 2] = L':';
-		}
-		buf[tab.pos + n] = L' ';
-	}
-	wmemcpy(&buf[tab.pos], wcs, n);
-	pos = tab.pos + tab.len;
+	if (cut && editCopy(e, index, count) < 0) return -1;
+	wmemmove(&e->buf[index], &e->buf[index + count], e->len - index - count);
+	e->len -= count;
+	if (e->pos > e->len) e->pos = e->len;
+	return 0;
 }
 
-static void tabAccept(void) {
-	completeAccept();
-	tab.len = 0;
-}
-
-static void tabReject(void) {
-	completeReject();
-	tab.len = 0;
-}
-
-void edit(uint id, enum Edit op, wchar_t ch) {
-	size_t init = pos;
-	switch (op) {
-		break; case EditHead: pos = 0;
-		break; case EditTail: pos = len;
-		break; case EditPrev: if (pos) pos--;
-		break; case EditNext: if (pos < len) pos++;
+int editFn(struct Edit *e, enum EditFn fn) {
+	int ret = 0;
+	switch (fn) {
+		break; case EditHead: e->pos = 0;
+		break; case EditTail: e->pos = e->len;
+		break; case EditPrev: if (e->pos) e->pos--;
+		break; case EditNext: if (e->pos < e->len) e->pos++;
 		break; case EditPrevWord: {
-			if (pos) pos--;
-			while (pos && !iswspace(buf[pos - 1])) pos--;
+			while (e->pos && !isword(e->buf[e->pos-1])) e->pos--;
+			while (e->pos && isword(e->buf[e->pos-1])) e->pos--;
 		}
 		break; case EditNextWord: {
-			if (pos < len) pos++;
-			while (pos < len && !iswspace(buf[pos])) pos++;
+			while (e->pos < e->len && isword(e->buf[e->pos])) e->pos++;
+			while (e->pos < e->len && !isword(e->buf[e->pos])) e->pos++;
 		}
 
-		break; case EditDeleteHead: delete(true, 0, pos); pos = 0;
-		break; case EditDeleteTail: delete(true, pos, len - pos);
-		break; case EditDeletePrev: if (pos) delete(false, --pos, 1);
-		break; case EditDeleteNext: delete(false, pos, 1);
+		break; case EditDeleteHead: {
+			ret = editDelete(e, true, 0, e->pos);
+			e->pos = 0;
+		}
+		break; case EditDeleteTail: {
+			ret = editDelete(e, true, e->pos, e->len - e->pos);
+		}
+		break; case EditDeletePrev: {
+			if (e->pos) editDelete(e, false, --e->pos, 1);
+		}
+		break; case EditDeleteNext: {
+			editDelete(e, false, e->pos, 1);
+		}
 		break; case EditDeletePrevWord: {
-			if (!pos) break;
-			size_t word = pos - 1;
-			while (word && !iswspace(buf[word - 1])) word--;
-			delete(true, word, pos - word);
-			pos = word;
+			if (!e->pos) break;
+			size_t word = e->pos;
+			while (word && !isword(e->buf[word-1])) word--;
+			while (word && isword(e->buf[word-1])) word--;
+			ret = editDelete(e, true, word, e->pos - word);
+			e->pos = word;
 		}
 		break; case EditDeleteNextWord: {
-			if (pos == len) break;
-			size_t word = pos + 1;
-			while (word < len && !iswspace(buf[word])) word++;
-			delete(true, pos, word - pos);
+			if (e->pos == e->len) break;
+			size_t word = e->pos;
+			while (word < e->len && !isword(e->buf[word])) word++;
+			while (word < e->len && isword(e->buf[word])) word++;
+			ret = editDelete(e, true, e->pos, word - e->pos);
 		}
+
 		break; case EditPaste: {
-			if (reserve(pos, cut.len)) {
-				wmemcpy(&buf[pos], cut.buf, cut.len);
-				pos += cut.len;
+			if (!e->cut) break;
+			ret = editReserve(e, e->pos, e->cut->len);
+			if (ret == 0) {
+				wmemcpy(&e->buf[e->pos], e->cut->buf, e->cut->len);
+				e->pos += e->cut->len;
 			}
 		}
-
 		break; case EditTranspose: {
-			if (!pos || len < 2) break;
-			if (pos == len) pos--;
-			wchar_t t = buf[pos - 1];
-			buf[pos - 1] = buf[pos];
-			buf[pos++] = t;
+			if (e->len < 2) break;
+			if (!e->pos) e->pos++;
+			if (e->pos == e->len) e->pos--;
+			wchar_t x = e->buf[e->pos-1];
+			e->buf[e->pos-1] = e->buf[e->pos];
+			e->buf[e->pos++] = x;
 		}
 		break; case EditCollapse: {
 			size_t ws;
-			for (pos = 0; pos < len;) {
-				for (; pos < len && !iswspace(buf[pos]); ++pos);
-				for (ws = pos; ws < len && iswspace(buf[ws]); ++ws);
-				if (pos && ws < len) {
-					delete(false, pos, ws - pos - 1);
-					buf[pos++] = L' ';
+			for (e->pos = 0; e->pos < e->len;) {
+				for (; e->pos < e->len && !iswspace(e->buf[e->pos]); ++e->pos);
+				for (ws = e->pos; ws < e->len && iswspace(e->buf[ws]); ++ws);
+				if (e->pos && ws < e->len) {
+					editDelete(e, false, e->pos, ws - e->pos - 1);
+					e->buf[e->pos++] = L' ';
 				} else {
-					delete(false, pos, ws - pos);
+					editDelete(e, false, e->pos, ws - e->pos);
 				}
 			}
 		}
 
-		break; case EditInsert: {
-			char mb[MB_LEN_MAX];
-			if (wctomb(mb, ch) < 0) return;
-			if (reserve(pos, 1)) {
-				buf[pos++] = ch;
-			}
-		}
-		break; case EditComplete: {
-			tabComplete(id);
-			return;
-		}
-		break; case EditExpand: {
-			macroExpand();
-			tabAccept();
-			return;
-		}
-		break; case EditEnter: {
-			tabAccept();
-			command(id, editBuffer(NULL));
-			len = pos = 0;
-			return;
-		}
+		break; case EditClear: e->len = e->pos = 0;
 	}
+	return ret;
+}
 
-	if (pos < init) {
-		tabReject();
-	} else {
-		tabAccept();
+int editInsert(struct Edit *e, wchar_t ch) {
+	char mb[MB_LEN_MAX];
+	if (wctomb(mb, ch) < 0) return -1;
+	if (editReserve(e, e->pos, 1) < 0) return -1;
+	e->buf[e->pos++] = ch;
+	return 0;
+}
+
+#ifdef TEST
+#undef NDEBUG
+#include <assert.h>
+#include <string.h>
+
+static void fix(struct Edit *e, const char *str) {
+	assert(0 == editFn(e, EditClear));
+	for (const char *ch = str; *ch; ++ch) {
+		assert(0 == editInsert(e, (wchar_t)*ch));
 	}
 }
+
+static bool eq(struct Edit *e, const char *str1) {
+	size_t pos;
+	static size_t cap;
+	static char *buf;
+	assert(NULL != editString(e, &buf, &cap, &pos));
+	const char *str2 = &str1[strlen(str1) + 1];
+	return pos == strlen(str1)
+		&& !strncmp(buf, str1, pos)
+		&& !strcmp(&buf[pos], str2);
+}
+
+#define editFn(...) assert(0 == editFn(__VA_ARGS__))
+
+int main(void) {
+	struct Edit cut = {0};
+	struct Edit e = { .cut = &cut };
+
+	fix(&e, "foo bar");
+	editFn(&e, EditHead);
+	assert(eq(&e, "\0foo bar"));
+	editFn(&e, EditTail);
+	assert(eq(&e, "foo bar\0"));
+	editFn(&e, EditPrev);
+	assert(eq(&e, "foo ba\0r"));
+	editFn(&e, EditNext);
+	assert(eq(&e, "foo bar\0"));
+
+	fix(&e, "foo, bar");
+	editFn(&e, EditPrevWord);
+	assert(eq(&e, "foo, \0bar"));
+	editFn(&e, EditPrevWord);
+	assert(eq(&e, "\0foo, bar"));
+	editFn(&e, EditNextWord);
+	assert(eq(&e, "foo, \0bar"));
+	editFn(&e, EditNextWord);
+	assert(eq(&e, "foo, bar\0"));
+
+	fix(&e, "foo bar");
+	editFn(&e, EditPrevWord);
+	editFn(&e, EditDeleteHead);
+	assert(eq(&e, "\0bar"));
+
+	fix(&e, "foo bar");
+	editFn(&e, EditPrevWord);
+	editFn(&e, EditDeleteTail);
+	assert(eq(&e, "foo \0"));
+
+	fix(&e, "foo bar");
+	editFn(&e, EditDeletePrev);
+	assert(eq(&e, "foo ba\0"));
+	editFn(&e, EditHead);
+	editFn(&e, EditDeleteNext);
+	assert(eq(&e, "\0oo ba"));
+
+	fix(&e, "foo, bar");
+	editFn(&e, EditDeletePrevWord);
+	assert(eq(&e, "foo, \0"));
+	editFn(&e, EditDeletePrevWord);
+	assert(eq(&e, "\0"));
+
+	fix(&e, "foo, bar");
+	editFn(&e, EditHead);
+	editFn(&e, EditDeleteNextWord);
+	assert(eq(&e, "\0, bar"));
+	editFn(&e, EditDeleteNextWord);
+	assert(eq(&e, "\0"));
+
+	fix(&e, "foo bar");
+	editFn(&e, EditDeletePrevWord);
+	editFn(&e, EditPaste);
+	assert(eq(&e, "foo bar\0"));
+	editFn(&e, EditPaste);
+	assert(eq(&e, "foo barbar\0"));
+
+	fix(&e, "bar");
+	editFn(&e, EditTranspose);
+	assert(eq(&e, "bra\0"));
+	editFn(&e, EditHead);
+	editFn(&e, EditTranspose);
+	assert(eq(&e, "rb\0a"));
+	editFn(&e, EditTranspose);
+	assert(eq(&e, "rab\0"));
+
+	fix(&e, "  foo  bar  ");
+	editFn(&e, EditCollapse);
+	assert(eq(&e, "foo bar\0"));
+}
+
+#endif /* TEST */
diff --git a/edit.h b/edit.h
new file mode 100644
index 0000000..db0d416
--- /dev/null
+++ b/edit.h
@@ -0,0 +1,79 @@
+/* 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>
+
+enum EditMode {
+	EditInsert,
+};
+
+struct Edit {
+	enum EditMode mode;
+	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/handle.c b/handle.c
index e5c55d4..cb52ba6 100644
--- a/handle.c
+++ b/handle.c
@@ -1,4 +1,4 @@
-/* Copyright (C) 2020  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -164,7 +164,9 @@ static void handleCap(struct Message *msg) {
 	} else if (!strcmp(msg->params[1], "ACK")) {
 		self.caps |= caps;
 		if (caps & CapSASL) {
-			ircFormat("AUTHENTICATE %s\r\n", (self.plain ? "PLAIN" : "EXTERNAL"));
+			ircFormat(
+				"AUTHENTICATE %s\r\n", (self.plainUser ? "PLAIN" : "EXTERNAL")
+			);
 		}
 		if (!(self.caps & CapSASL)) ircFormat("CAP END\r\n");
 	} else if (!strcmp(msg->params[1], "NAK")) {
@@ -203,18 +205,18 @@ static void base64(char *dst, const byte *src, size_t len) {
 
 static void handleAuthenticate(struct Message *msg) {
 	(void)msg;
-	if (!self.plain) {
+	if (!self.plainUser) {
 		ircFormat("AUTHENTICATE +\r\n");
 		return;
 	}
 
 	byte buf[299] = {0};
-	size_t len = 1 + strlen(self.plain);
+	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, "open nasin SASL PLAIN li mute ike");
-	memcpy(&buf[1], self.plain, len - 1);
-	byte *sep = memchr(buf, ':', len);
-	if (!sep) errx(EX_USAGE, "open nasin SASL PLAIN li jo ala e nimi lili ':'");
-	*sep = 0;
+	memcpy(&buf[1], self.plainUser, userLen);
+	memcpy(&buf[1 + userLen + 1], self.plainPass, passLen);
 
 	char b64[BASE64_SIZE(sizeof(buf))];
 	base64(b64, buf, len);
@@ -224,7 +226,7 @@ static void handleAuthenticate(struct Message *msg) {
 
 	explicit_bzero(b64, sizeof(b64));
 	explicit_bzero(buf, sizeof(buf));
-	explicit_bzero(self.plain, strlen(self.plain));
+	explicit_bzero(self.plainPass, strlen(self.plainPass));
 }
 
 static void handleReplyLoggedIn(struct Message *msg) {
@@ -252,6 +254,7 @@ static void handleReplyWelcome(struct Message *msg) {
 		replies[ReplyTopicAuto] += count;
 		replies[ReplyNamesAuto] += count;
 	}
+	commandCompleteAdd();
 }
 
 static void handleReplyISupport(struct Message *msg) {
@@ -341,7 +344,7 @@ static void handleJoin(struct Message *msg) {
 		idColors[id] = hash(msg->params[0]);
 		completeTouch(None, msg->params[0], idColors[id]);
 		if (replies[ReplyJoin]) {
-			uiShowID(id);
+			windowShow(windowFor(id));
 			replies[ReplyJoin]--;
 		}
 	}
@@ -423,7 +426,7 @@ static void handleNick(struct Message *msg) {
 	require(msg, true, 1);
 	if (!strcmp(msg->nick, self.nick)) {
 		set(&self.nick, msg->params[0]);
-		uiRead(); // Update prompt.
+		inputUpdate();
 	}
 	for (uint id; (id = completeID(msg->nick));) {
 		if (!strcmp(idNames[id], msg->nick)) {
diff --git a/input.c b/input.c
new file mode 100644
index 0000000..d7b1e85
--- /dev/null
+++ b/input.c
@@ -0,0 +1,628 @@
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
+ */
+
+#define _XOPEN_SOURCE_EXTENDED
+
+#include <assert.h>
+#include <curses.h>
+#include <err.h>
+#include <signal.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+#include <termios.h>
+#include <unistd.h>
+#include <wchar.h>
+#include <wctype.h>
+
+#include "chat.h"
+#include "edit.h"
+
+#define ENUM_KEY \
+	X(KeyCtrlLeft, "\33[1;5D", NULL) \
+	X(KeyCtrlRight, "\33[1;5C", NULL) \
+	X(KeyMeta0, "\0330", "\33)") \
+	X(KeyMeta1, "\0331", "\33!") \
+	X(KeyMeta2, "\0332", "\33@") \
+	X(KeyMeta3, "\0333", "\33#") \
+	X(KeyMeta4, "\0334", "\33$") \
+	X(KeyMeta5, "\0335", "\33%") \
+	X(KeyMeta6, "\0336", "\33^") \
+	X(KeyMeta7, "\0337", "\33&") \
+	X(KeyMeta8, "\0338", "\33*") \
+	X(KeyMeta9, "\0339", "\33(") \
+	X(KeyMetaA, "\33a", NULL) \
+	X(KeyMetaB, "\33b", NULL) \
+	X(KeyMetaD, "\33d", NULL) \
+	X(KeyMetaF, "\33f", NULL) \
+	X(KeyMetaL, "\33l", NULL) \
+	X(KeyMetaM, "\33m", NULL) \
+	X(KeyMetaN, "\33n", NULL) \
+	X(KeyMetaP, "\33p", NULL) \
+	X(KeyMetaQ, "\33q", NULL) \
+	X(KeyMetaS, "\33s", NULL) \
+	X(KeyMetaT, "\33t", NULL) \
+	X(KeyMetaU, "\33u", NULL) \
+	X(KeyMetaV, "\33v", NULL) \
+	X(KeyMetaEnter, "\33\r", "\33\n") \
+	X(KeyMetaGt, "\33>", "\33.") \
+	X(KeyMetaLt, "\33<", "\33,") \
+	X(KeyMetaEqual, "\33=", NULL) \
+	X(KeyMetaMinus, "\33-", "\33_") \
+	X(KeyMetaPlus, "\33+", NULL) \
+	X(KeyMetaSlash, "\33/", "\33?") \
+	X(KeyFocusIn, "\33[I", NULL) \
+	X(KeyFocusOut, "\33[O", NULL) \
+	X(KeyPasteOn, "\33[200~", NULL) \
+	X(KeyPasteOff, "\33[201~", NULL) \
+	X(KeyPasteManual, "\32p", "\32\20")
+
+enum {
+	KeyMax = KEY_MAX,
+#define X(id, seq, alt) id,
+	ENUM_KEY
+#undef X
+};
+
+static struct Edit cut;
+static struct Edit edits[IDCap];
+
+void inputInit(void) {
+	for (size_t i = 0; i < ARRAY_LEN(edits); ++i) {
+		edits[i].cut = &cut;
+	}
+
+	struct termios term;
+	int error = tcgetattr(STDOUT_FILENO, &term);
+	if (error) err(EX_OSERR, "tcgetattr");
+
+	// Gain use of C-q, C-s, C-c, C-z, C-y, C-v, C-o.
+	term.c_iflag &= ~IXON;
+	term.c_cc[VINTR] = _POSIX_VDISABLE;
+	term.c_cc[VSUSP] = _POSIX_VDISABLE;
+#ifdef VDSUSP
+	term.c_cc[VDSUSP] = _POSIX_VDISABLE;
+#endif
+	term.c_cc[VLNEXT] = _POSIX_VDISABLE;
+	term.c_cc[VDISCARD] = _POSIX_VDISABLE;
+
+	error = tcsetattr(STDOUT_FILENO, TCSANOW, &term);
+	if (error) err(EX_OSERR, "tcsetattr");
+
+	def_prog_mode();
+
+#define X(id, seq, alt) define_key(seq, id); if (alt) define_key(alt, id);
+	ENUM_KEY
+#undef X
+
+	keypad(uiInput, true);
+	nodelay(uiInput, true);
+}
+
+static void inputAdd(struct Style reset, struct Style *style, const char *str) {
+	while (*str) {
+		const char *code = str;
+		size_t len = styleParse(style, &str);
+		wattr_set(uiInput, A_BOLD | A_REVERSE, 0, NULL);
+		switch (*code) {
+			break; case B: waddch(uiInput, 'B');
+			break; case C: waddch(uiInput, 'C');
+			break; case O: waddch(uiInput, 'O');
+			break; case R: waddch(uiInput, 'R');
+			break; case I: waddch(uiInput, 'I');
+			break; case U: waddch(uiInput, 'U');
+			break; case '\n': waddch(uiInput, 'N');
+		}
+		if (str - code > 1) waddnstr(uiInput, &code[1], str - &code[1]);
+		if (str[0] == '\n') {
+			*style = reset;
+			str++;
+			len--;
+		}
+		size_t nl = strcspn(str, "\n");
+		if (nl < len) len = nl;
+		wattr_set(uiInput, uiAttr(*style), uiPair(*style), NULL);
+		waddnstr(uiInput, str, len);
+		str += len;
+	}
+}
+
+static char *inputStop(
+	struct Style reset, struct Style *style,
+	const char *str, char *stop
+) {
+	char ch = *stop;
+	*stop = '\0';
+	inputAdd(reset, style, str);
+	*stop = ch;
+	return stop;
+}
+
+static size_t cap;
+static char *buf;
+
+void inputUpdate(void) {
+	uint id = windowID();
+
+	size_t pos = 0;
+	const char *ptr = editString(&edits[id], &buf, &cap, &pos);
+	if (!ptr) err(EX_OSERR, "editString");
+
+	const char *prefix = "";
+	const char *prompt = self.nick;
+	const char *suffix = "";
+	const char *skip = buf;
+	struct Style stylePrompt = { .fg = self.color, .bg = Default };
+	struct Style styleInput = StyleDefault;
+
+	size_t split = commandWillSplit(id, buf);
+	const char *privmsg = commandIsPrivmsg(id, buf);
+	const char *notice = commandIsNotice(id, buf);
+	const char *action = commandIsAction(id, buf);
+	if (privmsg) {
+		prefix = "<"; suffix = "> ";
+		skip = privmsg;
+	} else if (notice) {
+		prefix = "-"; suffix = "- ";
+		styleInput.fg = LightGray;
+		skip = notice;
+	} else if (action) {
+		prefix = "* jan "; suffix = " ";
+		stylePrompt.attr |= Italic;
+		styleInput.attr |= Italic;
+		skip = action;
+	} else if (id == Debug && buf[0] != '/') {
+		prompt = "<< ";
+		stylePrompt.fg = Gray;
+	} else {
+		prompt = "";
+	}
+	if (skip > &buf[pos]) {
+		prefix = prompt = suffix = "";
+		skip = buf;
+	}
+
+	int y, x;
+	wmove(uiInput, 0, 0);
+	if (windowTimeEnable() && id != Network) {
+		whline(uiInput, ' ', windowTime.width);
+		wmove(uiInput, 0, windowTime.width);
+	}
+	wattr_set(uiInput, uiAttr(stylePrompt), uiPair(stylePrompt), NULL);
+	waddstr(uiInput, prefix);
+	waddstr(uiInput, prompt);
+	waddstr(uiInput, suffix);
+	getyx(uiInput, y, x);
+
+	int posx;
+	struct Style style = styleInput;
+	inputStop(styleInput, &style, skip, &buf[pos]);
+	getyx(uiInput, y, posx);
+	wmove(uiInput, y, x);
+
+	ptr = skip;
+	style = styleInput;
+	if (split) {
+		ptr = inputStop(styleInput, &style, ptr, &buf[split]);
+		style = styleInput;
+		style.bg = Red;
+	}
+	inputAdd(styleInput, &style, ptr);
+	wclrtoeol(uiInput);
+	wmove(uiInput, y, posx);
+}
+
+bool inputPending(uint id) {
+	return edits[id].len;
+}
+
+static const struct {
+	const wchar_t *name;
+	const wchar_t *string;
+} Macros[] = {
+	{ L"\\banhammer", L"▬▬▬▬▬▬▬▋ Ò╭╮Ó" },
+	{ L"\\bear", L"ʕっ•ᴥ•ʔっ" },
+	{ L"\\blush", L"(˶′◡‵˶)" },
+	{ L"\\com", L"\0038,4\2 ☭ " },
+	{ L"\\cool", L"(⌐■_■)" },
+	{ L"\\flip", L"(╯°□°)╯︵ ┻━┻" },
+	{ L"\\gary", L"ᕕ( ᐛ )ᕗ" },
+	{ L"\\hug", L"(っ・∀・)っ" },
+	{ L"\\lenny", L"( ͡° ͜ʖ ͡°)" },
+	{ L"\\look", L"ಠ_ಠ" },
+	{ L"\\shrug", L"¯\\_(ツ)_/¯" },
+	{ L"\\unflip", L"┬─┬ノ(º_ºノ)" },
+	{ L"\\wave", L"ヾ(^∇^)" },
+};
+
+void inputCompleteAdd(void) {
+	char mbs[256];
+	for (size_t i = 0; i < ARRAY_LEN(Macros); ++i) {
+		size_t n = wcstombs(mbs, Macros[i].name, sizeof(mbs));
+		assert(n != (size_t)-1);
+		completeAdd(None, mbs, Default);
+	}
+}
+
+static int macroExpand(struct Edit *e) {
+	size_t macro = e->pos;
+	while (macro && e->buf[macro] != L'\\') macro--;
+	if (macro == e->pos) return 0;
+	for (size_t i = 0; i < ARRAY_LEN(Macros); ++i) {
+		if (wcslen(Macros[i].name) != e->pos - macro) continue;
+		if (wcsncmp(Macros[i].name, &e->buf[macro], e->pos - macro)) continue;
+		if (wcstombs(NULL, Macros[i].string, 0) == (size_t)-1) continue;
+		size_t expand = wcslen(Macros[i].string);
+		int error = 0
+			|| editDelete(e, false, macro, e->pos - macro)
+			|| editReserve(e, macro, expand);
+		if (error) return error;
+		wcsncpy(&e->buf[macro], Macros[i].string, expand);
+		e->pos = macro + expand;
+		break;
+	}
+	return 0;
+}
+
+static struct {
+	uint id;
+	char *pre;
+	size_t pos;
+	size_t len;
+	bool suffix;
+} tab;
+
+static void tabAccept(void) {
+	completeAccept();
+	tab.len = 0;
+}
+
+static void tabReject(void) {
+	completeReject();
+	tab.len = 0;
+}
+
+static int tabComplete(struct Edit *e, uint id) {
+	if (tab.len && id != tab.id) {
+		tabAccept();
+	}
+
+	if (!tab.len) {
+		tab.id = id;
+		tab.pos = e->pos;
+		while (tab.pos && !iswspace(e->buf[tab.pos-1])) tab.pos--;
+		tab.len = e->pos - tab.pos;
+		if (!tab.len) return 0;
+
+		size_t cap = tab.len * MB_CUR_MAX + 1;
+		char *buf = realloc(tab.pre, cap);
+		if (!buf) return -1;
+		tab.pre = buf;
+
+		const wchar_t *ptr = &e->buf[tab.pos];
+		size_t n = wcsnrtombs(tab.pre, &ptr, tab.len, cap-1, NULL);
+		if (n == (size_t)-1) return -1;
+		tab.pre[n] = '\0';
+		tab.suffix = true;
+	}
+
+	const char *comp = complete(id, tab.pre);
+	if (!comp) {
+		comp = complete(id, tab.pre);
+		tab.suffix ^= true;
+	}
+	if (!comp) {
+		tab.len = 0;
+		return 0;
+	}
+
+	size_t cap = strlen(comp) + 1;
+	wchar_t *wcs = malloc(sizeof(*wcs) * cap);
+	if (!wcs) return -1;
+
+	size_t n = mbstowcs(wcs, comp, cap);
+	assert(n != (size_t)-1);
+
+	bool colon = (tab.len >= 2 && e->buf[tab.pos + tab.len - 2] == L':');
+
+	int error = editDelete(e, false, tab.pos, tab.len);
+	if (error) goto fail;
+
+	tab.len = n;
+	if (wcs[0] == L'\\' || wcschr(wcs, L' ')) {
+		error = editReserve(e, tab.pos, tab.len);
+		if (error) goto fail;
+	} else if (wcs[0] != L'/' && tab.suffix && (!tab.pos || colon)) {
+		tab.len += 2;
+		error = editReserve(e, tab.pos, tab.len);
+		if (error) goto fail;
+		e->buf[tab.pos + n + 0] = L':';
+		e->buf[tab.pos + n + 1] = L' ';
+	} else if (tab.suffix && tab.pos >= 2 && e->buf[tab.pos - 2] == L':') {
+		tab.len += 2;
+		error = editReserve(e, tab.pos, tab.len);
+		if (error) goto fail;
+		e->buf[tab.pos - 2] = L',';
+		e->buf[tab.pos + n + 0] = L':';
+		e->buf[tab.pos + n + 1] = L' ';
+	} else {
+		tab.len++;
+		error = editReserve(e, tab.pos, tab.len);
+		if (error) goto fail;
+		if (!tab.suffix && tab.pos >= 2 && e->buf[tab.pos - 2] == L',') {
+			e->buf[tab.pos - 2] = L':';
+		}
+		e->buf[tab.pos + n] = L' ';
+	}
+	wmemcpy(&e->buf[tab.pos], wcs, n);
+	e->pos = tab.pos + tab.len;
+	free(wcs);
+	return 0;
+
+fail:
+	free(wcs);
+	return -1;
+}
+
+static void inputEnter(void) {
+	uint id = windowID();
+	char *cmd = editString(&edits[id], &buf, &cap, NULL);
+	if (!cmd) err(EX_OSERR, "editString");
+
+	tabAccept();
+	editFn(&edits[id], EditClear);
+	command(id, cmd);
+}
+
+static void keyCode(int code) {
+	int error = 0;
+	struct Edit *edit = &edits[windowID()];
+	switch (code) {
+		break; case KEY_RESIZE:  uiResize();
+		break; case KeyFocusIn:  windowUnmark();
+		break; case KeyFocusOut: windowMark();
+
+		break; case KeyMetaEnter: error = editInsert(edit, L'\n');
+		break; case KeyMetaEqual: windowToggleMute();
+		break; case KeyMetaMinus: windowToggleThresh(-1);
+		break; case KeyMetaPlus:  windowToggleThresh(+1);
+		break; case KeyMetaSlash: windowSwap();
+
+		break; case KeyMetaGt: windowScroll(ScrollAll, -1);
+		break; case KeyMetaLt: windowScroll(ScrollAll, +1);
+
+		break; case KeyMeta0 ... KeyMeta9: windowShow(code - KeyMeta0);
+		break; case KeyMetaA: windowAuto();
+		break; case KeyMetaB: error = editFn(edit, EditPrevWord);
+		break; case KeyMetaD: error = editFn(edit, EditDeleteNextWord);
+		break; case KeyMetaF: error = editFn(edit, EditNextWord);
+		break; case KeyMetaL: windowBare();
+		break; case KeyMetaM: uiWrite(windowID(), Warm, NULL, "");
+		break; case KeyMetaN: windowScroll(ScrollHot, +1);
+		break; case KeyMetaP: windowScroll(ScrollHot, -1);
+		break; case KeyMetaQ: error = editFn(edit, EditCollapse);
+		break; case KeyMetaS: uiSpoilerReveal ^= true; windowUpdate();
+		break; case KeyMetaT: windowToggleTime();
+		break; case KeyMetaU: windowScroll(ScrollUnread, 0);
+		break; case KeyMetaV: windowScroll(ScrollPage, +1);
+
+		break; case KeyCtrlLeft: error = editFn(edit, EditPrevWord);
+		break; case KeyCtrlRight: error = editFn(edit, EditNextWord);
+
+		break; case KEY_BACKSPACE: error = editFn(edit, EditDeletePrev);
+		break; case KEY_DC: error = editFn(edit, EditDeleteNext);
+		break; case KEY_DOWN: windowScroll(ScrollOne, -1);
+		break; case KEY_END: error = editFn(edit, EditTail);
+		break; case KEY_ENTER: inputEnter();
+		break; case KEY_HOME: error = editFn(edit, EditHead);
+		break; case KEY_LEFT: error = editFn(edit, EditPrev);
+		break; case KEY_NPAGE: windowScroll(ScrollPage, -1);
+		break; case KEY_PPAGE: windowScroll(ScrollPage, +1);
+		break; case KEY_RIGHT: error = editFn(edit, EditNext);
+		break; case KEY_SEND: windowScroll(ScrollAll, -1);
+		break; case KEY_SHOME: windowScroll(ScrollAll, +1);
+		break; case KEY_UP: windowScroll(ScrollOne, +1);
+	}
+	if (error) err(EX_OSERR, "editFn");
+}
+
+static void keyCtrl(wchar_t ch) {
+	int error = 0;
+	struct Edit *edit = &edits[windowID()];
+	switch (ch ^ L'@') {
+		break; case L'?': error = editFn(edit, EditDeletePrev);
+		break; case L'A': error = editFn(edit, EditHead);
+		break; case L'B': error = editFn(edit, EditPrev);
+		break; case L'C': raise(SIGINT);
+		break; case L'D': error = editFn(edit, EditDeleteNext);
+		break; case L'E': error = editFn(edit, EditTail);
+		break; case L'F': error = editFn(edit, EditNext);
+		break; case L'H': error = editFn(edit, EditDeletePrev);
+		break; case L'I': error = tabComplete(edit, windowID());
+		break; case L'J': inputEnter();
+		break; case L'K': error = editFn(edit, EditDeleteTail);
+		break; case L'L': clearok(curscr, true);
+		break; case L'N': windowShow(windowNum() + 1);
+		break; case L'P': windowShow(windowNum() - 1);
+		break; case L'R': windowSearch(editString(edit, &buf, &cap, NULL), -1);
+		break; case L'S': windowSearch(editString(edit, &buf, &cap, NULL), +1);
+		break; case L'T': error = editFn(edit, EditTranspose);
+		break; case L'U': error = editFn(edit, EditDeleteHead);
+		break; case L'V': windowScroll(ScrollPage, -1);
+		break; case L'W': error = editFn(edit, EditDeletePrevWord);
+		break; case L'X': error = macroExpand(edit); tabAccept();
+		break; case L'Y': error = editFn(edit, EditPaste);
+	}
+	if (error) err(EX_OSERR, "editFn");
+}
+
+static void keyStyle(wchar_t ch) {
+	if (iswcntrl(ch)) ch = towlower(ch ^ L'@');
+	char buf[8] = {0};
+	enum Color color = Default;
+	switch (ch) {
+		break; case L'A': color = Gray;
+		break; case L'B': color = Blue;
+		break; case L'C': color = Cyan;
+		break; case L'G': color = Green;
+		break; case L'K': color = Black;
+		break; case L'M': color = Magenta;
+		break; case L'N': color = Brown;
+		break; case L'O': color = Orange;
+		break; case L'P': color = Pink;
+		break; case L'R': color = Red;
+		break; case L'W': color = White;
+		break; case L'Y': color = Yellow;
+		break; case L'b': buf[0] = B;
+		break; case L'c': buf[0] = C;
+		break; case L'i': buf[0] = I;
+		break; case L'o': buf[0] = O;
+		break; case L'r': buf[0] = R;
+		break; case L's': {
+			snprintf(buf, sizeof(buf), "%c%02d,%02d", C, Black, Black);
+		}
+		break; case L'u': buf[0] = U;
+	}
+	if (color != Default) {
+		snprintf(buf, sizeof(buf), "%c%02d", C, color);
+	}
+	struct Edit *edit = &edits[windowID()];
+	for (char *ch = buf; *ch; ++ch) {
+		int error = editInsert(edit, *ch);
+		if (error) err(EX_OSERR, "editInsert");
+	}
+}
+
+static bool waiting;
+
+void inputWait(void) {
+	waiting = true;
+}
+
+void inputRead(void) {
+	if (isendwin()) {
+		if (waiting) {
+			uiShow();
+			flushinp();
+			waiting = false;
+		} else {
+			return;
+		}
+	}
+
+	wint_t ch;
+	static bool paste, style, literal;
+	for (int ret; ERR != (ret = wget_wch(uiInput, &ch));) {
+		bool tabbing = false;
+		size_t pos = edits[tab.id].pos;
+		bool spr = uiSpoilerReveal;
+
+		if (ret == KEY_CODE_YES && ch == KeyPasteOn) {
+			paste = true;
+		} else if (ret == KEY_CODE_YES && ch == KeyPasteOff) {
+			paste = false;
+		} else if (ret == KEY_CODE_YES && ch == KeyPasteManual) {
+			paste ^= true;
+		} else if (paste || literal) {
+			int error = editInsert(&edits[windowID()], ch);
+			if (error) err(EX_OSERR, "editInsert");
+		} else if (ret == KEY_CODE_YES) {
+			keyCode(ch);
+		} else if (ch == (L'Z' ^ L'@')) {
+			style = true;
+			continue;
+		} else if (style && ch == (L'V' ^ L'@')) {
+			literal = true;
+			continue;
+		} else if (style) {
+			keyStyle(ch);
+		} else if (iswcntrl(ch)) {
+			tabbing = (ch == (L'I' ^ L'@'));
+			keyCtrl(ch);
+		} else {
+			int error = editInsert(&edits[windowID()], ch);
+			if (error) err(EX_OSERR, "editInsert");
+		}
+		style = false;
+		literal = false;
+
+		if (!tabbing) {
+			if (edits[tab.id].pos > pos) {
+				tabAccept();
+			} else if (edits[tab.id].pos < pos) {
+				tabReject();
+			}
+		}
+
+		if (spr) {
+			uiSpoilerReveal = false;
+			windowUpdate();
+		}
+	}
+	inputUpdate();
+}
+
+static int writeString(FILE *file, const char *str) {
+	return (fwrite(str, strlen(str) + 1, 1, file) ? 0 : -1);
+}
+
+int inputSave(FILE *file) {
+	int error;
+	for (uint id = 0; id < IDCap; ++id) {
+		if (!edits[id].len) continue;
+		char *ptr = editString(&edits[id], &buf, &cap, NULL);
+		if (!ptr) return -1;
+		error = 0
+			|| writeString(file, idNames[id])
+			|| writeString(file, ptr);
+		if (error) return error;
+	}
+	return writeString(file, "");
+}
+
+static ssize_t readString(FILE *file, char **buf, size_t *cap) {
+	ssize_t len = getdelim(buf, cap, '\0', file);
+	if (len < 0 && !feof(file)) err(EX_IOERR, "getdelim");
+	return len;
+}
+
+void inputLoad(FILE *file, size_t version) {
+	if (version < 8) return;
+	while (0 < readString(file, &buf, &cap) && buf[0]) {
+		uint id = idFor(buf);
+		readString(file, &buf, &cap);
+		size_t max = strlen(buf);
+		int error = editReserve(&edits[id], 0, max);
+		if (error) err(EX_OSERR, "editReserve");
+		size_t len = mbstowcs(edits[id].buf, buf, max);
+		assert(len != (size_t)-1);
+		edits[id].len = len;
+		edits[id].pos = len;
+	}
+}
diff --git a/scripts/Makefile b/scripts/Makefile
new file mode 100644
index 0000000..179a2d3
--- /dev/null
+++ b/scripts/Makefile
@@ -0,0 +1,22 @@
+PREFIX ?= /usr/local
+BINDIR ?= ${PREFIX}/bin
+MANDIR ?= ${PREFIX}/man
+
+CFLAGS += -Wall -Wextra
+
+-include ../config.mk
+
+LDLIBS = -framework Cocoa
+
+all: sandman
+
+clean:
+	rm -f sandman
+
+install: sandman sandman.1
+	install -d ${DESTDIR}${BINDIR} ${DESTDIR}${MANDIR}/man1
+	install sandman ${DESTDIR}${BINDIR}
+	install -m 644 sandman.1 ${DESTDIR}${MANDIR}/man1
+
+uninstall:
+	rm -f ${DESTDIR}${BINDIR}/sandman ${DESTDIR}/man/man1/sandman.1
diff --git a/ui.c b/ui.c
index 61c0b0a..64bd6ce 100644
--- a/ui.c
+++ b/ui.c
@@ -28,26 +28,21 @@
 #define _XOPEN_SOURCE_EXTENDED
 
 #include <assert.h>
-#include <ctype.h>
 #include <curses.h>
 #include <err.h>
 #include <errno.h>
 #include <fcntl.h>
-#include <limits.h>
-#include <signal.h>
+#include <inttypes.h>
 #include <stdarg.h>
 #include <stdbool.h>
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
-#include <sysexits.h>
 #include <sys/file.h>
+#include <sysexits.h>
 #include <term.h>
-#include <termios.h>
 #include <time.h>
 #include <unistd.h>
-#include <wchar.h>
-#include <wctype.h>
 
 #ifdef __FreeBSD__
 #include <capsicum_helpers.h>
@@ -55,105 +50,13 @@
 
 #include "chat.h"
 
-// Annoying stuff from <term.h>:
-#undef lines
-#undef tab
-
-enum {
-	StatusLines = 1,
-	MarkerLines = 1,
-	SplitLines = 5,
-	InputLines = 1,
-	InputCols = 1024,
-};
-
 #define BOTTOM (LINES - 1)
 #define RIGHT (COLS - 1)
 #define MAIN_LINES (LINES - StatusLines - InputLines)
 
-static WINDOW *status;
-static WINDOW *main;
-static WINDOW *input;
-
-struct Window {
-	uint id;
-	int scroll;
-	bool mark;
-	bool mute;
-	bool time;
-	enum Heat thresh;
-	enum Heat heat;
-	uint unreadSoft;
-	uint unreadHard;
-	uint unreadWarm;
-	struct Buffer *buffer;
-};
-
-static struct {
-	struct Window *ptrs[IDCap];
-	uint len;
-	uint show;
-	uint swap;
-	uint user;
-} windows;
-
-static uint windowPush(struct Window *window) {
-	assert(windows.len < IDCap);
-	windows.ptrs[windows.len] = window;
-	return windows.len++;
-}
-
-static uint windowInsert(uint num, struct Window *window) {
-	assert(windows.len < IDCap);
-	assert(num <= windows.len);
-	memmove(
-		&windows.ptrs[num + 1],
-		&windows.ptrs[num],
-		sizeof(*windows.ptrs) * (windows.len - num)
-	);
-	windows.ptrs[num] = window;
-	windows.len++;
-	return num;
-}
-
-static struct Window *windowRemove(uint num) {
-	assert(num < windows.len);
-	struct Window *window = windows.ptrs[num];
-	windows.len--;
-	memmove(
-		&windows.ptrs[num],
-		&windows.ptrs[num + 1],
-		sizeof(*windows.ptrs) * (windows.len - num)
-	);
-	return window;
-}
-
-enum Heat uiThreshold = Cold;
-
-static uint windowFor(uint id) {
-	for (uint num = 0; num < windows.len; ++num) {
-		if (windows.ptrs[num]->id == id) return num;
-	}
-	struct Window *window = calloc(1, sizeof(*window));
-	if (!window) err(EX_OSERR, "malloc");
-	window->id = id;
-	window->mark = true;
-	window->time = uiTime.enable;
-	if (id == Network || id == Debug) {
-		window->thresh = Cold;
-	} else {
-		window->thresh = uiThreshold;
-	}
-	window->buffer = bufferAlloc();
-	completeAdd(None, idNames[id], idColors[id]);
-	return windowPush(window);
-}
-
-static void windowFree(struct Window *window) {
-	completeRemove(None, idNames[window->id]);
-	bufferFree(window->buffer);
-	free(window);
-}
+WINDOW *uiStatus;
+WINDOW *uiMain;
+WINDOW *uiInput;
 
 static short colorPairs;
 
@@ -188,66 +91,18 @@ static short colorPair(short fg, short bg) {
 	return colorPairs++;
 }
 
-#define ENUM_KEY \
-	X(KeyCtrlLeft, "\33[1;5D", NULL) \
-	X(KeyCtrlRight, "\33[1;5C", NULL) \
-	X(KeyMeta0, "\0330", "\33)") \
-	X(KeyMeta1, "\0331", "\33!") \
-	X(KeyMeta2, "\0332", "\33@") \
-	X(KeyMeta3, "\0333", "\33#") \
-	X(KeyMeta4, "\0334", "\33$") \
-	X(KeyMeta5, "\0335", "\33%") \
-	X(KeyMeta6, "\0336", "\33^") \
-	X(KeyMeta7, "\0337", "\33&") \
-	X(KeyMeta8, "\0338", "\33*") \
-	X(KeyMeta9, "\0339", "\33(") \
-	X(KeyMetaA, "\33a", NULL) \
-	X(KeyMetaB, "\33b", NULL) \
-	X(KeyMetaD, "\33d", NULL) \
-	X(KeyMetaF, "\33f", NULL) \
-	X(KeyMetaL, "\33l", NULL) \
-	X(KeyMetaM, "\33m", NULL) \
-	X(KeyMetaN, "\33n", NULL) \
-	X(KeyMetaP, "\33p", NULL) \
-	X(KeyMetaQ, "\33q", NULL) \
-	X(KeyMetaS, "\33s", NULL) \
-	X(KeyMetaT, "\33t", NULL) \
-	X(KeyMetaU, "\33u", NULL) \
-	X(KeyMetaV, "\33v", NULL) \
-	X(KeyMetaEnter, "\33\r", "\33\n") \
-	X(KeyMetaGt, "\33>", "\33.") \
-	X(KeyMetaLt, "\33<", "\33,") \
-	X(KeyMetaEqual, "\33=", NULL) \
-	X(KeyMetaMinus, "\33-", "\33_") \
-	X(KeyMetaPlus, "\33+", NULL) \
-	X(KeyMetaSlash, "\33/", "\33?") \
-	X(KeyFocusIn, "\33[I", NULL) \
-	X(KeyFocusOut, "\33[O", NULL) \
-	X(KeyPasteOn, "\33[200~", NULL) \
-	X(KeyPasteOff, "\33[201~", NULL) \
-	X(KeyPasteManual, "\32p", "\32\20")
-
-enum {
-	KeyMax = KEY_MAX,
-#define X(id, seq, alt) id,
-	ENUM_KEY
-#undef X
-};
-
 // XXX: Assuming terminals will be fine with these even if they're unsupported,
 // since they're "private" modes.
 static const char *FocusMode[2] = { "\33[?1004l", "\33[?1004h" };
 static const char *PasteMode[2] = { "\33[?2004l", "\33[?2004h" };
 
-struct Time uiTime = { .format = "%X" };
-
 static void errExit(void) {
 	putp(FocusMode[false]);
 	putp(PasteMode[false]);
 	reset_shell_mode();
 }
 
-void uiInitEarly(void) {
+void uiInit(void) {
 	initscr();
 	cbreak();
 	noecho();
@@ -266,73 +121,32 @@ void uiInitEarly(void) {
 		from_status_line = "\7";
 	}
 
-#define X(id, seq, alt) define_key(seq, id); if (alt) define_key(alt, id);
-	ENUM_KEY
-#undef X
-
-	status = newwin(StatusLines, COLS, 0, 0);
-	if (!status) err(EX_OSERR, "newwin");
-
-	main = newwin(MAIN_LINES, COLS, StatusLines, 0);
-	if (!main) err(EX_OSERR, "newwin");
+	uiStatus = newwin(StatusLines, COLS, 0, 0);
+	if (!uiStatus) err(EX_OSERR, "newwin");
 
-	int y;
-	char fmt[TimeCap];
-	char buf[TimeCap];
-	styleStrip(fmt, sizeof(fmt), uiTime.format);
-	struct tm *time = localtime(&(time_t) { -22100400 });
-	size_t len = strftime(buf, sizeof(buf), fmt, time);
-	if (!len) errx(EX_CONFIG, "invalid timestamp format: %s", fmt);
-	waddstr(main, buf);
-	waddch(main, ' ');
-	getyx(main, y, uiTime.width);
-	(void)y;
+	uiMain = newwin(MAIN_LINES, COLS, StatusLines, 0);
+	if (!uiMain) err(EX_OSERR, "newwin");
 
-	input = newpad(InputLines, InputCols);
-	if (!input) err(EX_OSERR, "newpad");
-	keypad(input, true);
-	nodelay(input, true);
+	uiInput = newpad(InputLines, InputCols);
+	if (!uiInput) err(EX_OSERR, "newpad");
 
-	windowFor(Network);
+	windowInit();
 	uiShow();
 }
 
-// Avoid disabling VINTR until main loop.
-void uiInitLate(void) {
-	struct termios term;
-	int error = tcgetattr(STDOUT_FILENO, &term);
-	if (error) err(EX_OSERR, "tcgetattr");
-
-	// Gain use of C-q, C-s, C-c, C-z, C-y, C-v, C-o.
-	term.c_iflag &= ~IXON;
-	term.c_cc[VINTR] = _POSIX_VDISABLE;
-	term.c_cc[VSUSP] = _POSIX_VDISABLE;
-#ifdef VDSUSP
-	term.c_cc[VDSUSP] = _POSIX_VDISABLE;
-#endif
-	term.c_cc[VLNEXT] = _POSIX_VDISABLE;
-	term.c_cc[VDISCARD] = _POSIX_VDISABLE;
-
-	error = tcsetattr(STDOUT_FILENO, TCSANOW, &term);
-	if (error) err(EX_OSERR, "tcsetattr");
-
-	def_prog_mode();
-}
-
 static bool hidden = true;
-static bool waiting;
 
-static char title[256];
-static char prevTitle[sizeof(title)];
+char uiTitle[TitleCap];
+static char prevTitle[TitleCap];
 
 void uiDraw(void) {
 	if (hidden) return;
-	wnoutrefresh(status);
-	wnoutrefresh(main);
+	wnoutrefresh(uiStatus);
+	wnoutrefresh(uiMain);
 	int y, x;
-	getyx(input, y, x);
+	getyx(uiInput, y, x);
 	pnoutrefresh(
-		input,
+		uiInput,
 		0, (x + 1 > RIGHT ? x + 1 - RIGHT : 0),
 		LINES - InputLines, 0,
 		BOTTOM, RIGHT
@@ -341,10 +155,10 @@ void uiDraw(void) {
 	doupdate();
 
 	if (!to_status_line) return;
-	if (!strcmp(title, prevTitle)) return;
-	strcpy(prevTitle, title);
+	if (!strcmp(uiTitle, prevTitle)) return;
+	strcpy(prevTitle, uiTitle);
 	putp(to_status_line);
-	putp(title);
+	putp(uiTitle);
 	putp(from_status_line);
 	fflush(stdout);
 }
@@ -376,7 +190,7 @@ static const short Colors[ColorCap] = {
 	16, 233, 235, 237, 239, 241, 244, 247, 250, 254, 231,
 };
 
-static attr_t styleAttr(struct Style style) {
+uint uiAttr(struct Style style) {
 	attr_t attr = A_NORMAL;
 	if (style.attr & Bold) attr |= A_BOLD;
 	if (style.attr & Reverse) attr |= A_REVERSE;
@@ -385,98 +199,15 @@ static attr_t styleAttr(struct Style style) {
 	return attr | colorAttr(Colors[style.fg]);
 }
 
-static bool spoilerReveal;
+bool uiSpoilerReveal;
 
-static short stylePair(struct Style style) {
-	if (spoilerReveal && style.fg == style.bg) {
+short uiPair(struct Style style) {
+	if (uiSpoilerReveal && style.fg == style.bg) {
 		return colorPair(Colors[Default], Colors[style.bg]);
 	}
 	return colorPair(Colors[style.fg], Colors[style.bg]);
 }
 
-static int styleAdd(WINDOW *win, struct Style init, const char *str) {
-	struct Style style = init;
-	while (*str) {
-		size_t len = styleParse(&style, &str);
-		wattr_set(win, styleAttr(style), stylePair(style), NULL);
-		if (waddnstr(win, str, len) == ERR)
-			return -1;
-		str += len;
-	}
-	return 0;
-}
-
-static void statusUpdate(void) {
-	struct {
-		uint unread;
-		enum Heat heat;
-	} others = { 0, Cold };
-
-	wmove(status, 0, 0);
-	for (uint num = 0; num < windows.len; ++num) {
-		const struct Window *window = windows.ptrs[num];
-		if (num != windows.show && !window->scroll) {
-			if (window->heat < Warm) continue;
-			if (window->mute && window->heat < Hot) continue;
-		}
-		if (num != windows.show) {
-			others.unread += window->unreadWarm;
-			if (window->heat > others.heat) others.heat = window->heat;
-		}
-		char buf[256], *end = &buf[sizeof(buf)];
-		char *ptr = seprintf(
-			buf, end, "\3%d%s %u%s%s %s ",
-			idColors[window->id], (num == windows.show ? "\26" : ""),
-			num, window->thresh[(const char *[]) { "-", "", "+", "++" }],
-			&"="[!window->mute], idNames[window->id]
-		);
-		if (window->mark && window->unreadWarm) {
-			ptr = seprintf(
-				ptr, end, "\3%d+%d\3%d%s",
-				(window->heat > Warm ? White : idColors[window->id]),
-				window->unreadWarm, idColors[window->id],
-				(window->scroll ? "" : " ")
-			);
-		}
-		if (window->scroll) {
-			ptr = seprintf(ptr, end, "~%d ", window->scroll);
-		}
-		if (styleAdd(status, StyleDefault, buf) < 0) break;
-	}
-	wclrtoeol(status);
-
-	const struct Window *window = windows.ptrs[windows.show];
-	char *end = &title[sizeof(title)];
-	char *ptr = seprintf(
-		title, end, "%s %s", network.name, idNames[window->id]
-	);
-	if (window->mark && window->unreadWarm) {
-		ptr = seprintf(
-			ptr, end, " +%d%s", window->unreadWarm, &"!"[window->heat < Hot]
-		);
-	}
-	if (others.unread) {
-		ptr = seprintf(
-			ptr, end, " (+%d%s)", others.unread, &"!"[others.heat < Hot]
-		);
-	}
-}
-
-static void mark(struct Window *window) {
-	if (window->scroll) return;
-	window->mark = true;
-	window->unreadSoft = 0;
-	window->unreadWarm = 0;
-}
-
-static void unmark(struct Window *window) {
-	if (!window->scroll) {
-		window->mark = false;
-		window->heat = Cold;
-	}
-	statusUpdate();
-}
-
 void uiShow(void) {
 	if (!hidden) return;
 	prevTitle[0] = '\0';
@@ -484,88 +215,18 @@ void uiShow(void) {
 	putp(PasteMode[true]);
 	fflush(stdout);
 	hidden = false;
-	unmark(windows.ptrs[windows.show]);
+	windowUnmark();
 }
 
 void uiHide(void) {
 	if (hidden) return;
-	mark(windows.ptrs[windows.show]);
+	windowMark();
 	hidden = true;
 	putp(FocusMode[false]);
 	putp(PasteMode[false]);
 	endwin();
 }
 
-static size_t windowTop(const struct Window *window) {
-	size_t top = BufferCap - MAIN_LINES - window->scroll;
-	if (window->scroll) top += MarkerLines;
-	return top;
-}
-
-static size_t windowBottom(const struct Window *window) {
-	size_t bottom = BufferCap - (window->scroll ?: 1);
-	if (window->scroll) bottom -= SplitLines + MarkerLines;
-	return bottom;
-}
-
-static int windowCols(const struct Window *window) {
-	return COLS - (window->time ? uiTime.width : 0);
-}
-
-static void mainAdd(int y, bool time, const struct Line *line) {
-	int ny, nx;
-	wmove(main, y, 0);
-	if (!line || !line->str[0]) {
-		wclrtoeol(main);
-		return;
-	}
-	if (time && line->time) {
-		char buf[TimeCap];
-		strftime(buf, sizeof(buf), uiTime.format, localtime(&line->time));
-		struct Style init = { .fg = Gray, .bg = Default };
-		styleAdd(main, init, buf);
-		waddch(main, ' ');
-	} else if (time) {
-		whline(main, ' ', uiTime.width);
-		wmove(main, y, uiTime.width);
-	}
-	styleAdd(main, StyleDefault, line->str);
-	getyx(main, ny, nx);
-	if (ny != y) return;
-	wclrtoeol(main);
-	(void)nx;
-}
-
-static void mainUpdate(void) {
-	struct Window *window = windows.ptrs[windows.show];
-
-	int y = 0;
-	int marker = MAIN_LINES - SplitLines - MarkerLines;
-	for (size_t i = windowTop(window); i < BufferCap; ++i) {
-		mainAdd(y++, window->time, bufferHard(window->buffer, i));
-		if (window->scroll && y == marker) break;
-	}
-	if (!window->scroll) return;
-
-	y = MAIN_LINES - SplitLines;
-	for (size_t i = BufferCap - SplitLines; i < BufferCap; ++i) {
-		mainAdd(y++, window->time, bufferHard(window->buffer, i));
-	}
-	wattr_set(main, A_NORMAL, 0, NULL);
-	mvwhline(main, marker, 0, ACS_BULLET, COLS);
-}
-
-static void windowScroll(struct Window *window, int n) {
-	mark(window);
-	window->scroll += n;
-	if (window->scroll > BufferCap - MAIN_LINES) {
-		window->scroll = BufferCap - MAIN_LINES;
-	}
-	if (window->scroll < 0) window->scroll = 0;
-	unmark(window);
-	if (window == windows.ptrs[windows.show]) mainUpdate();
-}
-
 struct Util uiNotifyUtil;
 static void notify(uint id, const char *str) {
 	if (self.restricted) return;
@@ -592,36 +253,8 @@ static void notify(uint id, const char *str) {
 }
 
 void uiWrite(uint id, enum Heat heat, const time_t *src, const char *str) {
-	struct Window *window = windows.ptrs[windowFor(id)];
-	time_t ts = (src ? *src : time(NULL));
-
-	if (heat >= window->thresh) {
-		if (!window->unreadSoft++) window->unreadHard = 0;
-	}
-	if (window->mark && heat > Cold) {
-		if (!window->unreadWarm++) {
-			int lines = bufferPush(
-				window->buffer, windowCols(window),
-				window->thresh, Warm, ts, ""
-			);
-			if (window->scroll) windowScroll(window, lines);
-			if (window->unreadSoft > 1) {
-				window->unreadSoft++;
-				window->unreadHard += lines;
-			}
-		}
-		if (heat > window->heat) window->heat = heat;
-		statusUpdate();
-	}
-	int lines = bufferPush(
-		window->buffer, windowCols(window),
-		window->thresh, heat, ts, str
-	);
-	window->unreadHard += lines;
-	if (window->scroll) windowScroll(window, lines);
-	if (window == windows.ptrs[windows.show]) mainUpdate();
-
-	if (window->mark && heat > Warm) {
+	bool note = windowWrite(id, heat, src, str);
+	if (note) {
 		beep();
 		notify(id, str);
 	}
@@ -639,497 +272,15 @@ void uiFormat(
 	uiWrite(id, heat, time, buf);
 }
 
-static void scrollTo(struct Window *window, int top) {
-	window->scroll = 0;
-	windowScroll(window, top - MAIN_LINES + MarkerLines);
-}
-
-static void windowReflow(struct Window *window) {
-	uint num = 0;
-	const struct Line *line = bufferHard(window->buffer, windowTop(window));
-	if (line) num = line->num;
-	window->unreadHard = bufferReflow(
-		window->buffer, windowCols(window),
-		window->thresh, window->unreadSoft
-	);
-	if (!window->scroll || !num) return;
-	for (size_t i = 0; i < BufferCap; ++i) {
-		line = bufferHard(window->buffer, i);
-		if (!line || line->num != num) continue;
-		scrollTo(window, BufferCap - i);
-		break;
-	}
-}
-
-static void resize(void) {
-	wclear(main);
-	wresize(main, MAIN_LINES, COLS);
-	for (uint num = 0; num < windows.len; ++num) {
-		windowReflow(windows.ptrs[num]);
-	}
-	statusUpdate();
-	mainUpdate();
+void uiResize(void) {
+	wclear(uiMain);
+	wresize(uiMain, MAIN_LINES, COLS);
+	windowResize();
 }
 
-static void windowList(const struct Window *window) {
-	uiHide();
-	waiting = true;
-
-	uint num = 0;
-	const struct Line *line = bufferHard(window->buffer, windowBottom(window));
-	if (line) num = line->num;
-	for (size_t i = 0; i < BufferCap; ++i) {
-		line = bufferSoft(window->buffer, i);
-		if (!line) continue;
-		if (line->num > num) break;
-		if (!line->str[0]) {
-			printf("\n");
-			continue;
-		}
-
-		char buf[TimeCap];
-		strftime(buf, sizeof(buf), uiTime.format, localtime(&line->time));
-		vid_attr(colorAttr(Colors[Gray]), colorPair(Colors[Gray], -1), NULL);
-		printf("%s ", buf);
-
-		bool align = false;
-		struct Style style = StyleDefault;
-		for (const char *str = line->str; *str;) {
-			if (*str == '\t') {
-				printf("%c", (align ? '\t' : ' '));
-				align = true;
-				str++;
-			}
-
-			size_t len = styleParse(&style, &str);
-			size_t tab = strcspn(str, "\t");
-			if (tab < len) len = tab;
-
-			vid_attr(styleAttr(style), stylePair(style), NULL);
-			printf("%.*s", (int)len, str);
-			str += len;
-		}
-		printf("\n");
-	}
-}
-
-static void inputAdd(struct Style reset, struct Style *style, const char *str) {
-	while (*str) {
-		const char *code = str;
-		size_t len = styleParse(style, &str);
-		wattr_set(input, A_BOLD | A_REVERSE, 0, NULL);
-		switch (*code) {
-			break; case B: waddch(input, 'B');
-			break; case C: waddch(input, 'C');
-			break; case O: waddch(input, 'O');
-			break; case R: waddch(input, 'R');
-			break; case I: waddch(input, 'I');
-			break; case U: waddch(input, 'U');
-			break; case '\n': waddch(input, 'N');
-		}
-		if (str - code > 1) waddnstr(input, &code[1], str - &code[1]);
-		if (str[0] == '\n') {
-			*style = reset;
-			str++;
-			len--;
-		}
-		size_t nl = strcspn(str, "\n");
-		if (nl < len) len = nl;
-		wattr_set(input, styleAttr(*style), stylePair(*style), NULL);
-		waddnstr(input, str, len);
-		str += len;
-	}
-}
-
-static char *inputStop(
-	struct Style reset, struct Style *style,
-	const char *str, char *stop
-) {
-	char ch = *stop;
-	*stop = '\0';
-	inputAdd(reset, style, str);
-	*stop = ch;
-	return stop;
-}
-
-static void inputUpdate(void) {
-	size_t pos;
-	char *buf = editBuffer(&pos);
-	struct Window *window = windows.ptrs[windows.show];
-
-	const char *prefix = "";
-	const char *prompt = self.nick;
-	const char *suffix = "";
-	const char *skip = buf;
-	struct Style stylePrompt = { .fg = self.color, .bg = Default };
-	struct Style styleInput = StyleDefault;
-
-	size_t split = commandWillSplit(window->id, buf);
-	const char *privmsg = commandIsPrivmsg(window->id, buf);
-	const char *notice = commandIsNotice(window->id, buf);
-	const char *action = commandIsAction(window->id, buf);
-	if (privmsg) {
-		prefix = "<"; suffix = "> ";
-		skip = privmsg;
-	} else if (notice) {
-		prefix = "-"; suffix = "- ";
-		styleInput.fg = LightGray;
-		skip = notice;
-	} else if (action) {
-		prefix = "* jan "; suffix = " ";
-		stylePrompt.attr |= Italic;
-		styleInput.attr |= Italic;
-		skip = action;
-	} else if (window->id == Debug && buf[0] != '/') {
-		prompt = "<< ";
-		stylePrompt.fg = Gray;
-	} else {
-		prompt = "";
-	}
-	if (skip > &buf[pos]) {
-		prefix = prompt = suffix = "";
-		skip = buf;
-	}
-
-	wmove(input, 0, 0);
-	if (window->time && window->id != Network) {
-		whline(input, ' ', uiTime.width);
-		wmove(input, 0, uiTime.width);
-	}
-	wattr_set(input, styleAttr(stylePrompt), stylePair(stylePrompt), NULL);
-	waddstr(input, prefix);
-	waddstr(input, prompt);
-	waddstr(input, suffix);
-
-	int y, x;
-	const char *ptr = skip;
-	struct Style style = styleInput;
-	if (split && split < pos) {
-		ptr = inputStop(styleInput, &style, ptr, &buf[split]);
-		style = styleInput;
-		style.bg = Red;
-	}
-	ptr = inputStop(styleInput, &style, ptr, &buf[pos]);
-	getyx(input, y, x);
-	if (split && split >= pos) {
-		ptr = inputStop(styleInput, &style, ptr, &buf[split]);
-		style = styleInput;
-		style.bg = Red;
-	}
-	inputAdd(styleInput, &style, ptr);
-	wclrtoeol(input);
-	wmove(input, y, x);
-}
-
-void uiWindows(void) {
-	for (uint num = 0; num < windows.len; ++num) {
-		const struct Window *window = windows.ptrs[num];
-		uiFormat(
-			Network, Warm, NULL, "\3%02d%u %s",
-			idColors[window->id], num, idNames[window->id]
-		);
-	}
-}
-
-static void windowShow(uint num) {
-	if (num != windows.show) {
-		windows.swap = windows.show;
-		mark(windows.ptrs[windows.swap]);
-	}
-	windows.show = num;
-	windows.user = num;
-	unmark(windows.ptrs[windows.show]);
-	mainUpdate();
-	inputUpdate();
-}
-
-void uiShowID(uint id) {
-	windowShow(windowFor(id));
-}
-
-void uiShowNum(uint num) {
-	if (num < windows.len) windowShow(num);
-}
-
-void uiMoveID(uint id, uint num) {
-	struct Window *window = windowRemove(windowFor(id));
-	if (num < windows.len) {
-		windowShow(windowInsert(num, window));
-	} else {
-		windowShow(windowPush(window));
-	}
-}
-
-static void windowClose(uint num) {
-	if (windows.ptrs[num]->id == Network) return;
-	struct Window *window = windowRemove(num);
-	completeClear(window->id);
-	windowFree(window);
-	if (windows.swap >= num) windows.swap--;
-	if (windows.show == num) {
-		windowShow(windows.swap);
-		windows.swap = windows.show;
-	} else if (windows.show > num) {
-		windows.show--;
-		mainUpdate();
-	}
-	statusUpdate();
-}
-
-void uiCloseID(uint id) {
-	windowClose(windowFor(id));
-}
-
-void uiCloseNum(uint num) {
-	if (num < windows.len) windowClose(num);
-}
-
-static void scrollPage(struct Window *window, int n) {
-	windowScroll(window, n * (MAIN_LINES - SplitLines - MarkerLines - 1));
-}
-
-static void scrollTop(struct Window *window) {
-	for (size_t i = 0; i < BufferCap; ++i) {
-		if (!bufferHard(window->buffer, i)) continue;
-		scrollTo(window, BufferCap - i);
-		break;
-	}
-}
-
-static void scrollHot(struct Window *window, int dir) {
-	for (size_t i = windowTop(window) + dir; i < BufferCap; i += dir) {
-		const struct Line *line = bufferHard(window->buffer, i);
-		const struct Line *prev = bufferHard(window->buffer, i - 1);
-		if (!line || line->heat < Hot) continue;
-		if (prev && prev->heat > Warm) continue;
-		scrollTo(window, BufferCap - i);
-		break;
-	}
-}
-
-static void scrollSearch(struct Window *window, const char *str, int dir) {
-	for (size_t i = windowTop(window) + dir; i < BufferCap; i += dir) {
-		const struct Line *line = bufferHard(window->buffer, i);
-		if (!line || !strcasestr(line->str, str)) continue;
-		scrollTo(window, BufferCap - i);
-		break;
-	}
-}
-
-static void toggleTime(struct Window *window) {
-	window->time ^= true;
-	windowReflow(window);
-	statusUpdate();
-	mainUpdate();
-	inputUpdate();
-}
-
-static void incThresh(struct Window *window, int n) {
-	if (n > 0 && window->thresh == Hot) return;
-	if (n < 0 && window->thresh == Ice) {
-		window->thresh = Cold;
-	} else {
-		window->thresh += n;
-	}
-	windowReflow(window);
-	statusUpdate();
-	mainUpdate();
-	statusUpdate();
-}
-
-static void showAuto(void) {
-	uint minHot = UINT_MAX, numHot = 0;
-	uint minWarm = UINT_MAX, numWarm = 0;
-	for (uint num = 0; num < windows.len; ++num) {
-		struct Window *window = windows.ptrs[num];
-		if (window->heat >= Hot) {
-			if (window->unreadWarm >= minHot) continue;
-			minHot = window->unreadWarm;
-			numHot = num;
-		}
-		if (window->heat >= Warm && !window->mute) {
-			if (window->unreadWarm >= minWarm) continue;
-			minWarm = window->unreadWarm;
-			numWarm = num;
-		}
-	}
-	uint user = windows.user;
-	if (minHot < UINT_MAX) {
-		windowShow(numHot);
-		windows.user = user;
-	} else if (minWarm < UINT_MAX) {
-		windowShow(numWarm);
-		windows.user = user;
-	} else if (user != windows.show) {
-		windowShow(user);
-	}
-}
-
-static void keyCode(int code) {
-	struct Window *window = windows.ptrs[windows.show];
-	uint id = window->id;
-	switch (code) {
-		break; case KEY_RESIZE:  resize();
-		break; case KeyFocusIn:  unmark(window);
-		break; case KeyFocusOut: mark(window);
-
-		break; case KeyMetaEnter: edit(id, EditInsert, L'\n');
-		break; case KeyMetaEqual: window->mute ^= true; statusUpdate();
-		break; case KeyMetaMinus: incThresh(window, -1);
-		break; case KeyMetaPlus:  incThresh(window, +1);
-		break; case KeyMetaSlash: windowShow(windows.swap);
-
-		break; case KeyMetaGt: scrollTo(window, 0);
-		break; case KeyMetaLt: scrollTop(window);
-
-		break; case KeyMeta0 ... KeyMeta9: uiShowNum(code - KeyMeta0);
-		break; case KeyMetaA: showAuto();
-		break; case KeyMetaB: edit(id, EditPrevWord, 0);
-		break; case KeyMetaD: edit(id, EditDeleteNextWord, 0);
-		break; case KeyMetaF: edit(id, EditNextWord, 0);
-		break; case KeyMetaL: windowList(window);
-		break; case KeyMetaM: uiWrite(id, Warm, NULL, "");
-		break; case KeyMetaN: scrollHot(window, +1);
-		break; case KeyMetaP: scrollHot(window, -1);
-		break; case KeyMetaQ: edit(id, EditCollapse, 0);
-		break; case KeyMetaS: spoilerReveal ^= true; mainUpdate();
-		break; case KeyMetaT: toggleTime(window);
-		break; case KeyMetaU: scrollTo(window, window->unreadHard);
-		break; case KeyMetaV: scrollPage(window, +1);
-
-		break; case KeyCtrlLeft: edit(id, EditPrevWord, 0);
-		break; case KeyCtrlRight: edit(id, EditNextWord, 0);
-
-		break; case KEY_BACKSPACE: edit(id, EditDeletePrev, 0);
-		break; case KEY_DC: edit(id, EditDeleteNext, 0);
-		break; case KEY_DOWN: windowScroll(window, -1);
-		break; case KEY_END: edit(id, EditTail, 0);
-		break; case KEY_ENTER: edit(id, EditEnter, 0);
-		break; case KEY_HOME: edit(id, EditHead, 0);
-		break; case KEY_LEFT: edit(id, EditPrev, 0);
-		break; case KEY_NPAGE: scrollPage(window, -1);
-		break; case KEY_PPAGE: scrollPage(window, +1);
-		break; case KEY_RIGHT: edit(id, EditNext, 0);
-		break; case KEY_SEND: scrollTo(window, 0);
-		break; case KEY_SHOME: scrollTo(window, BufferCap);
-		break; case KEY_UP: windowScroll(window, +1);
-	}
-}
-
-static void keyCtrl(wchar_t ch) {
-	struct Window *window = windows.ptrs[windows.show];
-	uint id = window->id;
-	switch (ch ^ L'@') {
-		break; case L'?': edit(id, EditDeletePrev, 0);
-		break; case L'A': edit(id, EditHead, 0);
-		break; case L'B': edit(id, EditPrev, 0);
-		break; case L'C': raise(SIGINT);
-		break; case L'D': edit(id, EditDeleteNext, 0);
-		break; case L'E': edit(id, EditTail, 0);
-		break; case L'F': edit(id, EditNext, 0);
-		break; case L'H': edit(id, EditDeletePrev, 0);
-		break; case L'I': edit(id, EditComplete, 0);
-		break; case L'J': edit(id, EditEnter, 0);
-		break; case L'K': edit(id, EditDeleteTail, 0);
-		break; case L'L': clearok(curscr, true);
-		break; case L'N': uiShowNum(windows.show + 1);
-		break; case L'P': uiShowNum(windows.show - 1);
-		break; case L'R': scrollSearch(window, editBuffer(NULL), -1);
-		break; case L'S': scrollSearch(window, editBuffer(NULL), +1);
-		break; case L'T': edit(id, EditTranspose, 0);
-		break; case L'U': edit(id, EditDeleteHead, 0);
-		break; case L'V': scrollPage(window, -1);
-		break; case L'W': edit(id, EditDeletePrevWord, 0);
-		break; case L'X': edit(id, EditExpand, 0);
-		break; case L'Y': edit(id, EditPaste, 0);
-	}
-}
-
-static void keyStyle(wchar_t ch) {
-	uint id = windows.ptrs[windows.show]->id;
-	if (iswcntrl(ch)) ch = towlower(ch ^ L'@');
-	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);
-	}
-	for (char *ch = buf; *ch; ++ch) {
-		edit(id, EditInsert, *ch);
-	}
-}
-
-void uiRead(void) {
-	if (hidden) {
-		if (waiting) {
-			uiShow();
-			flushinp();
-			waiting = false;
-		} else {
-			return;
-		}
-	}
-
-	wint_t ch;
-	static bool paste, style, literal;
-	for (int ret; ERR != (ret = wget_wch(input, &ch));) {
-		bool spr = spoilerReveal;
-		if (ret == KEY_CODE_YES && ch == KeyPasteOn) {
-			paste = true;
-		} else if (ret == KEY_CODE_YES && ch == KeyPasteOff) {
-			paste = false;
-		} else if (ret == KEY_CODE_YES && ch == KeyPasteManual) {
-			paste ^= true;
-		} else if (paste || literal) {
-			edit(windows.ptrs[windows.show]->id, EditInsert, ch);
-		} else if (ret == KEY_CODE_YES) {
-			keyCode(ch);
-		} else if (ch == (L'Z' ^ L'@')) {
-			style = true;
-			continue;
-		} else if (style && ch == (L'V' ^ L'@')) {
-			literal = true;
-			continue;
-		} else if (style) {
-			keyStyle(ch);
-		} else if (iswcntrl(ch)) {
-			keyCtrl(ch);
-		} else {
-			edit(windows.ptrs[windows.show]->id, EditInsert, ch);
-		}
-		style = false;
-		literal = false;
-		if (spr) {
-			spoilerReveal = false;
-			mainUpdate();
-		}
-	}
-	inputUpdate();
-}
+static FILE *saveFile;
 
-static const time_t Signatures[] = {
+static const uint64_t Signatures[] = {
 	0x6C72696774616301, // no heat, unread, unreadWarm
 	0x6C72696774616302, // no self.pos
 	0x6C72696774616303, // no buffer line heat
@@ -1137,71 +288,38 @@ static const time_t Signatures[] = {
 	0x6C72696774616305, // no URLs
 	0x6C72696774616306, // no thresh
 	0x6C72696774616307, // no window time
-	0x6C72696774616308,
+	0x6C72696774616308, // no input
+	0x6C72696774616309,
 };
 
-static size_t signatureVersion(time_t signature) {
+static size_t signatureVersion(uint64_t signature) {
 	for (size_t i = 0; i < ARRAY_LEN(Signatures); ++i) {
 		if (signature == Signatures[i]) return i;
 	}
-	errx(EX_DATAERR, "unknown file signature %jX", (uintmax_t)signature);
+	errx(EX_DATAERR, "unknown file signature %" PRIX64, signature);
 }
 
-static int writeTime(FILE *file, time_t time) {
-	return (fwrite(&time, sizeof(time), 1, file) ? 0 : -1);
-}
-static int writeString(FILE *file, const char *str) {
-	return (fwrite(str, strlen(str) + 1, 1, file) ? 0 : -1);
+static int writeUint64(FILE *file, uint64_t u) {
+	return (fwrite(&u, sizeof(u), 1, file) ? 0 : -1);
 }
 
-static FILE *saveFile;
-
 int uiSave(void) {
-	int error = 0
-		|| ftruncate(fileno(saveFile), 0)
-		|| writeTime(saveFile, Signatures[7])
-		|| writeTime(saveFile, self.pos);
-	if (error) return error;
-	for (uint num = 0; num < windows.len; ++num) {
-		const struct Window *window = windows.ptrs[num];
-		error = 0
-			|| writeString(saveFile, idNames[window->id])
-			|| writeTime(saveFile, window->mute)
-			|| writeTime(saveFile, window->time)
-			|| writeTime(saveFile, window->thresh)
-			|| writeTime(saveFile, window->heat)
-			|| writeTime(saveFile, window->unreadSoft)
-			|| writeTime(saveFile, window->unreadWarm);
-		if (error) return error;
-		for (size_t i = 0; i < BufferCap; ++i) {
-			const struct Line *line = bufferSoft(window->buffer, i);
-			if (!line) continue;
-			error = 0
-				|| writeTime(saveFile, line->time)
-				|| writeTime(saveFile, line->heat)
-				|| writeString(saveFile, line->str);
-			if (error) return error;
-		}
-		error = writeTime(saveFile, 0);
-		if (error) return error;
-	}
 	return 0
-		|| writeString(saveFile, "")
+		|| ftruncate(fileno(saveFile), 0)
+		|| writeUint64(saveFile, Signatures[8])
+		|| writeUint64(saveFile, self.pos)
+		|| windowSave(saveFile)
+		|| inputSave(saveFile)
 		|| urlSave(saveFile)
 		|| fclose(saveFile);
 }
 
-static time_t readTime(FILE *file) {
-	time_t time;
-	fread(&time, sizeof(time), 1, file);
+static uint64_t readUint64(FILE *file) {
+	uint64_t u;
+	fread(&u, sizeof(u), 1, file);
 	if (ferror(file)) err(EX_IOERR, "fread");
 	if (feof(file)) errx(EX_DATAERR, "unexpected eof");
-	return time;
-}
-static ssize_t readString(FILE *file, char **buf, size_t *cap) {
-	ssize_t len = getdelim(buf, cap, '\0', file);
-	if (len < 0 && !feof(file)) err(EX_IOERR, "getdelim");
-	return len;
+	return u;
 }
 
 void uiLoad(const char *name) {
@@ -1231,31 +349,9 @@ void uiLoad(const char *name) {
 	size_t version = signatureVersion(signature);
 
 	if (version > 1) {
-		self.pos = readTime(saveFile);
-	}
-
-	char *buf = NULL;
-	size_t cap = 0;
-	while (0 < readString(saveFile, &buf, &cap) && buf[0]) {
-		struct Window *window = windows.ptrs[windowFor(idFor(buf))];
-		if (version > 3) window->mute = readTime(saveFile);
-		if (version > 6) window->time = readTime(saveFile);
-		if (version > 5) window->thresh = readTime(saveFile);
-		if (version > 0) {
-			window->heat = readTime(saveFile);
-			window->unreadSoft = readTime(saveFile);
-			window->unreadWarm = readTime(saveFile);
-		}
-		for (;;) {
-			time_t time = readTime(saveFile);
-			if (!time) break;
-			enum Heat heat = (version > 2 ? readTime(saveFile) : Cold);
-			readString(saveFile, &buf, &cap);
-			bufferPush(window->buffer, COLS, window->thresh, heat, time, buf);
-		}
-		windowReflow(window);
+		self.pos = readUint64(saveFile);
 	}
+	windowLoad(saveFile, version);
+	inputLoad(saveFile, version);
 	urlLoad(saveFile, version);
-
-	free(buf);
 }
diff --git a/window.c b/window.c
new file mode 100644
index 0000000..ee0911f
--- /dev/null
+++ b/window.c
@@ -0,0 +1,658 @@
+/* Copyright (C) 2020  June McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
+ */
+
+#define _XOPEN_SOURCE_EXTENDED
+
+#include <assert.h>
+#include <curses.h>
+#include <err.h>
+#include <limits.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <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();
+	completeAdd(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);
+		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);
+	completeClear(window->id);
+	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 eof");
+	return time;
+}
+
+static ssize_t readString(FILE *file, char **buf, size_t *cap) {
+	ssize_t len = getdelim(buf, cap, '\0', file);
+	if (len < 0 && !feof(file)) err(EX_IOERR, "getdelim");
+	return len;
+}
+
+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);
+}