about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--Makefile24
-rw-r--r--README.721
-rw-r--r--catgirl.123
-rw-r--r--chat.c16
-rw-r--r--chat.h45
-rw-r--r--command.c69
-rw-r--r--complete.c (renamed from cache.c)140
-rwxr-xr-xconfigure1
-rw-r--r--handle.c133
-rw-r--r--input.c12
-rw-r--r--sandman.1 (renamed from scripts/sandman.1)0
-rw-r--r--sandman.m (renamed from scripts/sandman.m)0
-rw-r--r--scripts/.gitignore1
-rw-r--r--scripts/Makefile22
-rw-r--r--ui.c6
-rw-r--r--window.c9
17 files changed, 239 insertions, 284 deletions
diff --git a/.gitignore b/.gitignore
index b31d1c5..519791d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,4 +4,5 @@ catgirl
 chroot.tar
 config.mk
 root/
+sandman
 tags
diff --git a/Makefile b/Makefile
index e8ef63a..66fb408 100644
--- a/Makefile
+++ b/Makefile
@@ -8,14 +8,18 @@ CFLAGS += ${CEXTS:%=-Wno-%}
 LDADD.libtls = -ltls
 LDADD.ncursesw = -lncursesw
 
+BINS = catgirl
+MANS = ${BINS:=.1}
+
 -include config.mk
 
 LDLIBS = ${LDADD.libtls} ${LDADD.ncursesw}
+LDLIBS.sandman = -framework Cocoa
 
 OBJS += buffer.o
-OBJS += cache.o
 OBJS += chat.o
 OBJS += command.o
+OBJS += complete.o
 OBJS += config.o
 OBJS += edit.o
 OBJS += filter.o
@@ -28,11 +32,13 @@ OBJS += url.o
 OBJS += window.o
 OBJS += xdg.o
 
+OBJS.sandman = sandman.o
+
 TESTS += edit.t
 
 dev: tags all check
 
-all: catgirl
+all: ${BINS}
 
 catgirl: ${OBJS}
 	${CC} ${LDFLAGS} ${OBJS} ${LDLIBS} -o $@
@@ -41,6 +47,9 @@ ${OBJS}: chat.h
 
 edit.o edit.t input.o: edit.h
 
+sandman: ${OBJS.sandman}
+	${CC} ${LDFLAGS} ${OBJS.$@} ${LDLIBS.$@} -o $@
+
 check: ${TESTS}
 
 .SUFFIXES: .t
@@ -53,15 +62,16 @@ tags: *.[ch]
 	ctags -w *.[ch]
 
 clean:
-	rm -f catgirl ${OBJS} ${TESTS} tags
+	rm -f ${BINS} ${OBJS} ${OBJS.sandman} ${TESTS} tags
 
-install: catgirl catgirl.1
+install: ${BINS} ${MANS}
 	install -d ${DESTDIR}${BINDIR} ${DESTDIR}${MANDIR}/man1
-	install catgirl ${DESTDIR}${BINDIR}
-	install -m 644 catgirl.1 ${DESTDIR}${MANDIR}/man1
+	install ${BINS} ${DESTDIR}${BINDIR}
+	install -m 644 ${MANS} ${DESTDIR}${MANDIR}/man1
 
 uninstall:
-	rm -f ${DESTDIR}${BINDIR}/catgirl ${DESTDIR}${MANDIR}/man1/catgirl.1
+	rm -f ${BINS:%=${DESTDIR}${BINDIR}/%}
+	rm -f ${MANS:%=${DESTDIR}${MANDIR}/man1/%}
 
 CHROOT_USER = chat
 CHROOT_GROUP = ${CHROOT_USER}
diff --git a/README.7 b/README.7
index 64024ab..a26d270 100644
--- a/README.7
+++ b/README.7
@@ -1,5 +1,5 @@
 .\" To view this file: $ man ./README.7
-.Dd July 30, 2022
+.Dd July  9, 2023
 .Dt README 7
 .Os "Causal Agency"
 .
@@ -173,10 +173,10 @@ wrapper is provided for macOS
 to stop and start
 .Nm
 on system sleep and wake.
-Install it as follows:
+To enable it,
+configure with:
 .Bd -literal -offset indent
-$ make -C scripts sandman
-# make -C scripts install
+$ ./configure --enable-sandman
 .Ed
 .
 .Sh FILES
@@ -201,8 +201,8 @@ command handling
 line wrapping
 .It Pa edit.c
 line editing
-.It Pa cache.c
-ordered cache
+.It Pa complete.c
+tab complete
 .It Pa url.c
 URL detection
 .It Pa filter.c
@@ -213,6 +213,8 @@ chat logging
 configuration parsing
 .It Pa xdg.c
 XDG base directories
+.It Pa sandman.m
+sleep/wake wrapper for macOS
 .El
 .
 .Pp
@@ -222,8 +224,6 @@ example
 .Xr tmux 1
 configuration for multiple networks
 and automatic reconnects
-.It Pa scripts/sandman.m
-sleep/wake wrapper for macOS
 .It Pa scripts/notify-send.scpt
 .Xr notify-send 1
 in AppleScript
@@ -257,14 +257,15 @@ Monetary contributions can be
 .Lk https://liberapay.com/june/donate "donated via Liberapay" .
 .
 .Sh SEE ALSO
-.Xr catgirl 1
+.Xr catgirl 1 ,
+.Xr sandman 1
 .
 .Pp
 IRC bouncer:
 .Lk https://git.causal.agency/pounce "pounce"
 .
 .Rs
-.%A June Bug
+.%A June McEnroe
 .%T IRC Suite
 .%U https://text.causal.agency/010-irc-suite.txt
 .%D June 19, 2020
diff --git a/catgirl.1 b/catgirl.1
index 32eb365..815eade 100644
--- a/catgirl.1
+++ b/catgirl.1
@@ -1,4 +1,4 @@
-.Dd May 29, 2022
+.Dd October 11, 2023
 .Dt CATGIRL 1
 .Os
 .
@@ -8,7 +8,7 @@
 .
 .Sh SYNOPSIS
 .Nm
-.Op Fl KRelqv
+.Op Fl Relqv
 .Op Fl C Ar copy
 .Op Fl H Ar hash
 .Op Fl I Ar highlight
@@ -145,22 +145,6 @@ The commands which can be matched are:
 .Sy QUIT ,
 .Sy SETNAME .
 .
-.It Fl K | Cm kiosk
-Disable the
-.Ic /copy ,
-.Ic /debug ,
-.Ic /exec ,
-.Ic /join ,
-.Ic /list ,
-.Ic /msg ,
-.Ic /open ,
-.Ic /part ,
-.Ic /query ,
-.Ic /quote
-commands.
-Replace the username
-with a hash of its original value.
-.
 .It Fl N Ar util | Cm notify No = Ar util
 Send notifications using a utility.
 Subsequent
@@ -649,6 +633,9 @@ use the
 option.
 .It Ic /move Oo Ar name Oc Ar num
 Move the named or current window to number.
+.It Ic /o ...
+Alias of
+.Ic /open .
 .It Ic /open Op Ar count
 Open each of
 .Ar count
diff --git a/chat.c b/chat.c
index 39b1a93..6728240 100644
--- a/chat.c
+++ b/chat.c
@@ -245,7 +245,6 @@ int main(int argc, char *argv[]) {
 		{ .val = 'C', .name = "copy", required_argument },
 		{ .val = 'H', .name = "hash", required_argument },
 		{ .val = 'I', .name = "highlight", required_argument },
-		{ .val = 'K', .name = "kiosk", no_argument },
 		{ .val = 'N', .name = "notify", required_argument },
 		{ .val = 'O', .name = "open", required_argument },
 		{ .val = 'R', .name = "restrict", no_argument },
@@ -286,7 +285,6 @@ int main(int argc, char *argv[]) {
 			break; case 'C': utilPush(&urlCopyUtil, optarg);
 			break; case 'H': parseHash(optarg);
 			break; case 'I': filterAdd(Hot, optarg);
-			break; case 'K': self.kiosk = true;
 			break; case 'N': utilPush(&uiNotifyUtil, optarg);
 			break; case 'O': utilPush(&urlOpenUtil, optarg);
 			break; case 'R': self.restricted = true;
@@ -341,13 +339,6 @@ int main(int argc, char *argv[]) {
 	if (!user) user = self.nicks[0];
 	if (!real) real = self.nicks[0];
 
-	if (self.kiosk) {
-		char *hash;
-		int n = asprintf(&hash, "%08" PRIx32, _hash(user));
-		if (n < 0) err(EX_OSERR, "asprintf");
-		user = hash;
-	}
-
 	if (pass && !pass[0]) {
 		char *buf = malloc(512);
 		if (!buf) err(EX_OSERR, "malloc");
@@ -374,7 +365,7 @@ int main(int argc, char *argv[]) {
 	set(&network.name, host);
 	set(&self.nick, "*");
 
-	inputCache();
+	inputCompletion();
 
 	ircConfig(insecure, trust, cert, priv);
 
@@ -418,8 +409,7 @@ int main(int argc, char *argv[]) {
 	signal(SIGTERM, signalHandler);
 	signal(SIGCHLD, signalHandler);
 
-	bool pipes = !self.kiosk && !self.restricted;
-	if (pipes) {
+	if (!self.restricted) {
 		int error = pipe(utilPipe) || pipe(execPipe);
 		if (error) err(EX_OSERR, "pipe");
 
@@ -437,7 +427,7 @@ int main(int argc, char *argv[]) {
 		{ .events = POLLIN, .fd = execPipe[0] },
 	};
 	while (!self.quit) {
-		int nfds = poll(fds, (pipes ? ARRAY_LEN(fds) : 2), -1);
+		int nfds = poll(fds, (self.restricted ? 2 : ARRAY_LEN(fds)), -1);
 		if (nfds < 0 && errno != EINTR) err(EX_IOERR, "poll");
 		if (nfds > 0) {
 			if (fds[0].revents) inputRead();
diff --git a/chat.h b/chat.h
index 628d416..2a41cf6 100644
--- a/chat.h
+++ b/chat.h
@@ -168,6 +168,19 @@ extern struct Network {
 	char invex;
 } network;
 
+static inline uint prefixBit(char p) {
+	char *s = strchr(network.prefixes, p);
+	if (!s) return 0;
+	return 1 << (s - network.prefixes);
+}
+
+static inline char bitPrefix(uint p) {
+	for (uint i = 0; network.prefixes[i]; ++i) {
+		if (p & (1 << i)) return network.prefixes[i];
+	}
+	return '\0';
+}
+
 #define ENUM_CAP \
 	X("causal.agency/consumer", CapConsumer) \
 	X("chghost", CapChghost) \
@@ -189,7 +202,6 @@ enum Cap {
 
 extern struct Self {
 	bool debug;
-	bool kiosk;
 	bool restricted;
 	size_t pos;
 	enum Cap caps;
@@ -278,7 +290,6 @@ enum Reply {
 	ReplyNamesAuto,
 	ReplyTopic,
 	ReplyTopicAuto,
-	ReplyWho,
 	ReplyWhois,
 	ReplyWhowas,
 	ReplyCap,
@@ -292,7 +303,7 @@ const char *commandIsPrivmsg(uint id, const char *input);
 const char *commandIsNotice(uint id, const char *input);
 const char *commandIsAction(uint id, const char *input);
 size_t commandWillSplit(uint id, const char *input);
-void commandCache(void);
+void commandCompletion(void);
 
 enum Heat {
 	Ice,
@@ -334,7 +345,7 @@ void inputWait(void);
 void inputUpdate(void);
 bool inputPending(uint id);
 void inputRead(void);
-void inputCache(void);
+void inputCompletion(void);
 int inputSave(FILE *file);
 void inputLoad(FILE *file, size_t version);
 
@@ -396,24 +407,22 @@ int bufferReflow(
 	struct Buffer *buffer, int cols, enum Heat thresh, size_t tail
 );
 
-struct Entry {
-	enum Color color;
-	uint prefixBits;
-};
 struct Cursor {
 	uint gen;
 	struct Node *node;
 };
-struct Entry *cacheInsert(bool touch, uint id, const char *key);
-const struct Entry *cacheGet(uint id, const char *key);
-void cacheReplace(bool touch, const char *old, const char *new);
-const char *cacheComplete(struct Cursor *curs, uint id, const char *prefix);
-const char *cacheSearch(struct Cursor *curs, uint id, const char *substr);
-uint cacheID(struct Cursor *curs, const char *key);
-void cacheAccept(struct Cursor *curs);
-void cacheReject(struct Cursor *curs);
-void cacheRemove(uint id, const char *key);
-void cacheClear(uint id);
+void completePush(uint id, const char *str, enum Color color);
+void completePull(uint id, const char *str, enum Color color);
+void completeReplace(const char *old, const char *new);
+void completeRemove(uint id, const char *str);
+enum Color completeColor(uint id, const char *str);
+uint *completeBits(uint id, const char *str);
+const char *completePrefix(struct Cursor *curs, uint id, const char *prefix);
+const char *completeSubstr(struct Cursor *curs, uint id, const char *substr);
+const char *completeEach(struct Cursor *curs, uint id);
+uint completeEachID(struct Cursor *curs, const char *str);
+void completeAccept(struct Cursor *curs);
+void completeReject(struct Cursor *curs);
 
 extern struct Util urlOpenUtil;
 extern struct Util urlCopyUtil;
diff --git a/command.c b/command.c
index 3acbe76..502ff17 100644
--- a/command.c
+++ b/command.c
@@ -139,7 +139,7 @@ static void commandMsg(uint id, char *params) {
 	char *nick = strsep(&params, " ");
 	uint msg = idFor(nick);
 	if (idColors[msg] == Default) {
-		idColors[msg] = cacheGet(id, nick)->color;
+		idColors[msg] = completeColor(id, nick);
 	}
 	if (params) {
 		splitMessage("PRIVMSG", msg, params);
@@ -219,8 +219,31 @@ static void commandNames(uint id, char *params) {
 
 static void commandOps(uint id, char *params) {
 	(void)params;
-	ircFormat("WHO %s\r\n", idNames[id]);
-	replies[ReplyWho]++;
+	char buf[1024];
+	char *ptr = buf, *end = &buf[sizeof(buf)];
+	ptr = seprintf(
+		ptr, end, "The council of \3%02d%s\3 are ",
+		idColors[id], idNames[id]
+	);
+	bool first = true;
+	struct Cursor curs = {0};
+	for (const char *nick; (nick = completeEach(&curs, id));) {
+		char prefix = bitPrefix(*completeBits(id, nick));
+		if (!prefix || prefix == '+') continue;
+		ptr = seprintf(
+			ptr, end, "%s\3%02d%c%s\3",
+			(first ? "" : ", "), completeColor(id, nick), prefix, nick
+		);
+		first = false;
+	}
+	if (first) {
+		uiFormat(
+			id, Warm, NULL, "\3%02d%s\3 is a lawless wasteland",
+			idColors[id], idNames[id]
+		);
+	} else {
+		uiWrite(id, Warm, NULL, buf);
+	}
 }
 
 static void commandInvite(uint id, char *params) {
@@ -380,7 +403,7 @@ static void commandQuery(uint id, char *params) {
 	if (!params) return;
 	uint query = idFor(params);
 	if (idColors[query] == Default) {
-		idColors[query] = cacheGet(id, params)->color;
+		idColors[query] = completeColor(id, params);
 	}
 	windowShow(windowFor(query));
 }
@@ -397,10 +420,10 @@ static void commandWindow(uint id, char *params) {
 			return;
 		}
 		struct Cursor curs = {0};
-		for (const char *match; (match = cacheSearch(&curs, None, params));) {
-			id = idFind(match);
+		for (const char *str; (str = completeSubstr(&curs, None, params));) {
+			id = idFind(str);
 			if (!id) continue;
-			cacheAccept(&curs);
+			completeAccept(&curs);
 			windowShow(windowFor(id));
 			break;
 		}
@@ -537,7 +560,6 @@ static void commandHelp(uint id, char *params) {
 enum Flag {
 	BIT(Multiline),
 	BIT(Restrict),
-	BIT(Kiosk),
 };
 
 static const struct Handler {
@@ -549,37 +571,37 @@ static const struct Handler {
 	{ "/away", commandAway, 0, 0 },
 	{ "/ban", commandBan, 0, 0 },
 	{ "/close", commandClose, 0, 0 },
-	{ "/copy", commandCopy, Restrict | Kiosk, 0 },
+	{ "/copy", commandCopy, Restrict, 0 },
 	{ "/cs", commandCS, 0, 0 },
-	{ "/debug", commandDebug, Kiosk, 0 },
+	{ "/debug", commandDebug, 0, 0 },
 	{ "/deop", commandDeop, 0, 0 },
 	{ "/devoice", commandDevoice, 0, 0 },
 	{ "/except", commandExcept, 0, 0 },
-	{ "/exec", commandExec, Multiline | Restrict | Kiosk, 0 },
+	{ "/exec", commandExec, Multiline | Restrict, 0 },
 	{ "/help", commandHelp, 0, 0 }, // Restrict special case.
 	{ "/highlight", commandHighlight, 0, 0 },
 	{ "/ignore", commandIgnore, 0, 0 },
 	{ "/invex", commandInvex, 0, 0 },
 	{ "/invite", commandInvite, 0, 0 },
-	{ "/join", commandJoin, Kiosk, 0 },
+	{ "/join", commandJoin, 0, 0 },
 	{ "/kick", commandKick, 0, 0 },
-	{ "/list", commandList, Kiosk, 0 },
+	{ "/list", commandList, 0, 0 },
 	{ "/me", commandMe, Multiline, 0 },
 	{ "/mode", commandMode, 0, 0 },
 	{ "/move", commandMove, 0, 0 },
-	{ "/msg", commandMsg, Multiline | Kiosk, 0 },
+	{ "/msg", commandMsg, Multiline, 0 },
 	{ "/names", commandNames, 0, 0 },
 	{ "/nick", commandNick, 0, 0 },
 	{ "/notice", commandNotice, Multiline, 0 },
 	{ "/ns", commandNS, 0, 0 },
-	{ "/o", commandOpen, Restrict | Kiosk, 0 },
+	{ "/o", commandOpen, Restrict, 0 },
 	{ "/op", commandOp, 0, 0 },
-	{ "/open", commandOpen, Restrict | Kiosk, 0 },
+	{ "/open", commandOpen, Restrict, 0 },
 	{ "/ops", commandOps, 0, 0 },
-	{ "/part", commandPart, Kiosk, 0 },
-	{ "/query", commandQuery, Kiosk, 0 },
+	{ "/part", commandPart, 0, 0 },
+	{ "/query", commandQuery, 0, 0 },
 	{ "/quit", commandQuit, 0, 0 },
-	{ "/quote", commandQuote, Multiline | Kiosk, 0 },
+	{ "/quote", commandQuote, Multiline, 0 },
 	{ "/say", commandPrivmsg, Multiline, 0 },
 	{ "/setname", commandSetname, 0, CapSetname },
 	{ "/topic", commandTopic, 0, 0 },
@@ -649,7 +671,6 @@ size_t commandWillSplit(uint id, const char *input) {
 
 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;
 	}
@@ -672,8 +693,8 @@ void command(uint id, char *input) {
 
 	struct Cursor curs = {0};
 	const char *cmd = strsep(&input, " ");
-	const char *unique = cacheComplete(&curs, None, cmd);
-	if (unique && !cacheComplete(&curs, None, cmd)) {
+	const char *unique = completePrefix(&curs, None, cmd);
+	if (unique && !completePrefix(&curs, None, cmd)) {
 		cmd = unique;
 	}
 
@@ -701,9 +722,9 @@ void command(uint id, char *input) {
 	handler->fn(id, input);
 }
 
-void commandCache(void) {
+void commandCompletion(void) {
 	for (size_t i = 0; i < ARRAY_LEN(Commands); ++i) {
 		if (!commandAvailable(&Commands[i])) continue;
-		cacheInsert(false, None, Commands[i].cmd);
+		completePush(None, Commands[i].cmd, Default);
 	}
 }
diff --git a/cache.c b/complete.c
index 30ebef4..3552c7c 100644
--- a/cache.c
+++ b/complete.c
@@ -26,7 +26,6 @@
  */
 
 #include <err.h>
-#include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
 #include <sysexits.h>
@@ -35,25 +34,25 @@
 
 struct Node {
 	uint id;
-	char *key;
-	struct Entry entry;
+	char *str;
+	enum Color color;
+	uint bits;
 	struct Node *prev;
 	struct Node *next;
 };
 
-static const struct Entry DefaultEntry = { .color = Default };
-
 static uint gen;
 static struct Node *head;
 static struct Node *tail;
 
-static struct Node *alloc(uint id, const char *key) {
+static struct Node *alloc(uint id, const char *str, enum Color color) {
 	struct Node *node = calloc(1, sizeof(*node));
 	if (!node) err(EX_OSERR, "calloc");
 	node->id = id;
-	node->key = strdup(key);
-	node->entry = DefaultEntry;
-	if (!node->key) err(EX_OSERR, "strdup");
+	node->str = strdup(str);
+	if (!node->str) err(EX_OSERR, "strdup");
+	node->color = color;
+	node->bits = 0;
 	return node;
 }
 
@@ -85,50 +84,68 @@ static struct Node *append(struct Node *node) {
 	return node;
 }
 
-static struct Node *find(uint id, const char *key) {
+static struct Node *find(uint id, const char *str) {
 	for (struct Node *node = head; node; node = node->next) {
-		if (node->id != id) continue;
-		if (strcmp(node->key, key)) continue;
-		return node;
+		if (node->id == id && !strcmp(node->str, str)) return node;
 	}
 	return NULL;
 }
 
-static struct Node *insert(bool touch, uint id, const char *key) {
-	struct Node *node = find(id, key);
-	if (node && touch) {
-		return prepend(detach(node));
-	} else if (node) {
-		return node;
-	} else if (touch) {
-		return prepend(alloc(id, key));
+void completePush(uint id, const char *str, enum Color color) {
+	struct Node *node = find(id, str);
+	if (node) {
+		if (color != Default) node->color = color;
 	} else {
-		return append(alloc(id, key));
+		append(alloc(id, str, color));
 	}
 }
 
-struct Entry *cacheInsert(bool touch, uint id, const char *key) {
-	return &insert(touch, id, key)->entry;
+void completePull(uint id, const char *str, enum Color color) {
+	struct Node *node = find(id, str);
+	if (node) {
+		if (color != Default) node->color = color;
+		prepend(detach(node));
+	} else {
+		prepend(alloc(id, str, color));
+	}
 }
 
-const struct Entry *cacheGet(uint id, const char *key) {
-	struct Node *node = find(id, key);
-	return (node ? &node->entry : &DefaultEntry);
+void completeReplace(const char *old, const char *new) {
+	struct Node *next = NULL;
+	for (struct Node *node = head; node; node = next) {
+		next = node->next;
+		if (strcmp(node->str, old)) continue;
+		free(node->str);
+		node->str = strdup(new);
+		if (!node->str) err(EX_OSERR, "strdup");
+		prepend(detach(node));
+	}
 }
 
-void cacheReplace(bool touch, const char *old, const char *new) {
+void completeRemove(uint id, const char *str) {
 	struct Node *next = NULL;
 	for (struct Node *node = head; node; node = next) {
 		next = node->next;
-		if (strcmp(node->key, old)) continue;
-		free(node->key);
-		node->key = strdup(new);
-		if (!node->key) err(EX_OSERR, "strdup");
-		if (touch) prepend(detach(node));
+		if (id && node->id != id) continue;
+		if (str && strcmp(node->str, str)) continue;
+		detach(node);
+		free(node->str);
+		free(node);
 	}
+	gen++;
 }
 
-const char *cacheComplete(struct Cursor *curs, uint id, const char *prefix) {
+enum Color completeColor(uint id, const char *str) {
+	struct Node *node = find(id, str);
+	return (node ? node->color : Default);
+}
+
+uint *completeBits(uint id, const char *str) {
+	struct Node *node = find(id, str);
+	return (node ? &node->bits : NULL);
+}
+
+const char *completePrefix(struct Cursor *curs, uint id, const char *prefix) {
 	size_t len = strlen(prefix);
 	if (curs->gen != gen) curs->node = NULL;
 	for (
@@ -137,13 +154,12 @@ const char *cacheComplete(struct Cursor *curs, uint id, const char *prefix) {
 		curs->node = curs->node->next
 	) {
 		if (curs->node->id && curs->node->id != id) continue;
-		if (strncasecmp(curs->node->key, prefix, len)) continue;
-		return curs->node->key;
+		if (!strncasecmp(curs->node->str, prefix, len)) return curs->node->str;
 	}
 	return NULL;
 }
 
-const char *cacheSearch(struct Cursor *curs, uint id, const char *substr) {
+const char *completeSubstr(struct Cursor *curs, uint id, const char *substr) {
 	if (curs->gen != gen) curs->node = NULL;
 	for (
 		curs->gen = gen, curs->node = (curs->node ? curs->node->next : head);
@@ -151,13 +167,24 @@ const char *cacheSearch(struct Cursor *curs, uint id, const char *substr) {
 		curs->node = curs->node->next
 	) {
 		if (curs->node->id && curs->node->id != id) continue;
-		if (!strstr(curs->node->key, substr)) continue;
-		return curs->node->key;
+		if (strstr(curs->node->str, substr)) return curs->node->str;
+	}
+	return NULL;
+}
+
+const char *completeEach(struct Cursor *curs, uint id) {
+	if (curs->gen != gen) curs->node = NULL;
+	for (
+		curs->gen = gen, curs->node = (curs->node ? curs->node->next : head);
+		curs->node;
+		curs->node = curs->node->next
+	) {
+		if (curs->node->id == id) return curs->node->str;
 	}
 	return NULL;
 }
 
-uint cacheID(struct Cursor *curs, const char *key) {
+uint completeEachID(struct Cursor *curs, const char *str) {
 	if (curs->gen != gen) curs->node = NULL;
 	for (
 		curs->gen = gen, curs->node = (curs->node ? curs->node->next : head);
@@ -165,45 +192,18 @@ uint cacheID(struct Cursor *curs, const char *key) {
 		curs->node = curs->node->next
 	) {
 		if (!curs->node->id) continue;
-		if (strcmp(curs->node->key, key)) continue;
-		return curs->node->id;
+		if (!strcmp(curs->node->str, str)) return curs->node->id;
 	}
 	return None;
 }
 
-void cacheAccept(struct Cursor *curs) {
+void completeAccept(struct Cursor *curs) {
 	if (curs->gen == gen && curs->node) {
 		prepend(detach(curs->node));
 	}
 	curs->node = NULL;
 }
 
-void cacheReject(struct Cursor *curs) {
+void completeReject(struct Cursor *curs) {
 	curs->node = NULL;
 }
-
-void cacheRemove(uint id, const char *key) {
-	gen++;
-	struct Node *next = NULL;
-	for (struct Node *node = head; node; node = next) {
-		next = node->next;
-		if (id && node->id != id) continue;
-		if (strcmp(node->key, key)) continue;
-		detach(node);
-		free(node->key);
-		free(node);
-		if (id) break;
-	}
-}
-
-void cacheClear(uint id) {
-	gen++;
-	struct Node *next = NULL;
-	for (struct Node *node = head; node; node = next) {
-		next = node->next;
-		if (node->id != id) continue;
-		detach(node);
-		free(node->key);
-		free(node);
-	}
-}
diff --git a/configure b/configure
index 9465b77..07e3245 100755
--- a/configure
+++ b/configure
@@ -29,6 +29,7 @@ for opt; do
 		(--prefix=*) echo "PREFIX = ${opt#*=}" ;;
 		(--bindir=*) echo "BINDIR = ${opt#*=}" ;;
 		(--mandir=*) echo "MANDIR = ${opt#*=}" ;;
+		(--enable-sandman) echo 'BINS += sandman' ;;
 		(*) echo "warning: unsupported option ${opt}" >&2 ;;
 	esac
 done
diff --git a/handle.c b/handle.c
index b8434c6..5a2cf7c 100644
--- a/handle.c
+++ b/handle.c
@@ -266,7 +266,7 @@ static void handleErrorSASLFail(struct Message *msg) {
 static void handleReplyWelcome(struct Message *msg) {
 	require(msg, false, 1);
 	set(&self.nick, msg->params[0]);
-	cacheInsert(true, Network, self.nick);
+	completePull(Network, self.nick, Default);
 	if (self.mode) ircFormat("MODE %s %s\r\n", self.nick, self.mode);
 	if (self.join) {
 		uint count = 1;
@@ -278,7 +278,7 @@ static void handleReplyWelcome(struct Message *msg) {
 		replies[ReplyTopicAuto] += count;
 		replies[ReplyNamesAuto] += count;
 	}
-	commandCache();
+	commandCompletion();
 	handleReplyGeneric(msg);
 }
 
@@ -372,13 +372,13 @@ static void handleJoin(struct Message *msg) {
 			set(&self.host, msg->host);
 		}
 		idColors[id] = hash(msg->params[0]);
-		cacheInsert(true, None, msg->params[0])->color = idColors[id];
+		completePull(None, msg->params[0], idColors[id]);
 		if (replies[ReplyJoin]) {
 			windowShow(windowFor(id));
 			replies[ReplyJoin]--;
 		}
 	}
-	cacheInsert(true, id, msg->nick)->color = hash(msg->user);
+	completePull(id, msg->nick, hash(msg->user));
 	if (msg->params[2] && !strcasecmp(msg->params[2], msg->nick)) {
 		msg->params[2] = NULL;
 	}
@@ -410,9 +410,9 @@ static void handlePart(struct Message *msg) {
 	require(msg, true, 1);
 	uint id = idFor(msg->params[0]);
 	if (!strcmp(msg->nick, self.nick)) {
-		cacheClear(id);
+		completeRemove(id, NULL);
 	}
-	cacheRemove(id, msg->nick);
+	completeRemove(id, msg->nick);
 	enum Heat heat = filterCheck(Cold, id, msg);
 	if (heat > Ice) urlScan(id, msg->nick, msg->params[1]);
 	uiFormat(
@@ -432,14 +432,14 @@ static void handleKick(struct Message *msg) {
 	require(msg, true, 2);
 	uint id = idFor(msg->params[0]);
 	bool kicked = !strcmp(msg->params[1], self.nick);
-	cacheInsert(true, id, msg->nick)->color = hash(msg->user);
+	completePull(id, msg->nick, hash(msg->user));
 	urlScan(id, msg->nick, msg->params[2]);
 	uiFormat(
 		id, (kicked ? Hot : Cold), tagTime(msg),
 		"%s\3%02d%s\17\tkicks \3%02d%s\3 out of \3%02d%s\3%s%s",
 		(kicked ? "\26" : ""),
 		hash(msg->user), msg->nick,
-		cacheGet(id, msg->params[1])->color, msg->params[1],
+		completeColor(id, msg->params[1]), msg->params[1],
 		hash(msg->params[0]), msg->params[0],
 		(msg->params[2] ? ": " : ""), (msg->params[2] ?: "")
 	);
@@ -448,8 +448,8 @@ static void handleKick(struct Message *msg) {
 		msg->nick, msg->params[1], msg->params[0],
 		(msg->params[2] ? ": " : ""), (msg->params[2] ?: "")
 	);
-	cacheRemove(id, msg->params[1]);
-	if (kicked) cacheClear(id);
+	completeRemove(id, msg->params[1]);
+	if (kicked) completeRemove(id, NULL);
 }
 
 static void handleNick(struct Message *msg) {
@@ -459,7 +459,7 @@ static void handleNick(struct Message *msg) {
 		inputUpdate();
 	}
 	struct Cursor curs = {0};
-	for (uint id; (id = cacheID(&curs, msg->nick));) {
+	for (uint id; (id = completeEachID(&curs, msg->nick));) {
 		if (!strcmp(idNames[id], msg->nick)) {
 			set(&idNames[id], msg->params[0]);
 		}
@@ -474,13 +474,13 @@ static void handleNick(struct Message *msg) {
 			msg->nick, msg->params[0]
 		);
 	}
-	cacheReplace(true, msg->nick, msg->params[0]);
+	completeReplace(msg->nick, msg->params[0]);
 }
 
 static void handleSetname(struct Message *msg) {
 	require(msg, true, 1);
 	struct Cursor curs = {0};
-	for (uint id; (id = cacheID(&curs, msg->nick));) {
+	for (uint id; (id = completeEachID(&curs, msg->nick));) {
 		uiFormat(
 			id, filterCheck(Cold, id, msg), tagTime(msg),
 			"\3%02d%s\3\tis now known as \3%02d%s\3 (%s\17)",
@@ -493,7 +493,7 @@ static void handleSetname(struct Message *msg) {
 static void handleQuit(struct Message *msg) {
 	require(msg, true, 0);
 	struct Cursor curs = {0};
-	for (uint id; (id = cacheID(&curs, msg->nick));) {
+	for (uint id; (id = completeEachID(&curs, msg->nick));) {
 		enum Heat heat = filterCheck(Cold, id, msg);
 		if (heat > Ice) urlScan(id, msg->nick, msg->params[0]);
 		uiFormat(
@@ -509,7 +509,7 @@ static void handleQuit(struct Message *msg) {
 			(msg->params[0] ? ": " : ""), (msg->params[0] ?: "")
 		);
 	}
-	cacheRemove(None, msg->nick);
+	completeRemove(None, msg->nick);
 }
 
 static void handleInvite(struct Message *msg) {
@@ -555,17 +555,11 @@ static void handleErrorUserOnChannel(struct Message *msg) {
 	uiFormat(
 		id, Warm, tagTime(msg),
 		"\3%02d%s\3 is already in \3%02d%s\3",
-		cacheGet(id, msg->params[1])->color, msg->params[1],
+		completeColor(id, msg->params[1]), msg->params[1],
 		hash(msg->params[2]), msg->params[2]
 	);
 }
 
-static uint prefixBit(char p) {
-	char *s = strchr(network.prefixes, p);
-	if (!s) return 0;
-	return 1 << (s - network.prefixes);
-}
-
 static void handleReplyNames(struct Message *msg) {
 	require(msg, false, 4);
 	uint id = idFor(msg->params[2]);
@@ -581,9 +575,8 @@ static void handleReplyNames(struct Message *msg) {
 		for (char *p = prefixes; p < nick; ++p) {
 			bits |= prefixBit(*p);
 		}
-		struct Entry *entry = cacheInsert(false, id, nick);
-		if (user) entry->color = color;
-		entry->prefixBits = bits;
+		completePush(id, nick, color);
+		*completeBits(id, nick) = bits;
 		if (!replies[ReplyNames] && !replies[ReplyNamesAuto]) continue;
 		ptr = seprintf(
 			ptr, end, "%s\3%02d%s\3", (ptr > buf ? ", " : ""), color, prefixes
@@ -606,41 +599,6 @@ static void handleReplyEndOfNames(struct Message *msg) {
 	}
 }
 
-static struct {
-	char buf[1024];
-	char *ptr;
-	char *end;
-} who = {
-	.ptr = who.buf,
-	.end = &who.buf[sizeof(who.buf)],
-};
-
-static void handleReplyWho(struct Message *msg) {
-	require(msg, false, 7);
-	if (who.ptr == who.buf) {
-		who.ptr = seprintf(
-			who.ptr, who.end, "The council of \3%02d%s\3 are ",
-			hash(msg->params[1]), msg->params[1]
-		);
-	}
-	char *prefixes = &msg->params[6][1];
-	if (prefixes[0] == '*') prefixes++;
-	prefixes[strspn(prefixes, network.prefixes)] = '\0';
-	if (!prefixes[0] || prefixes[0] == '+') return;
-	who.ptr = seprintf(
-		who.ptr, who.end, "%s\3%02d%s%s\3%s",
-		(who.ptr[-1] == ' ' ? "" : ", "),
-		hash(msg->params[2]), prefixes, msg->params[5],
-		(msg->params[6][0] == 'H' ? "" : " (away)")
-	);
-}
-
-static void handleReplyEndOfWho(struct Message *msg) {
-	require(msg, false, 2);
-	uiWrite(idFor(msg->params[1]), Warm, tagTime(msg), who.buf);
-	who.ptr = who.buf;
-}
-
 static void handleReplyNoTopic(struct Message *msg) {
 	require(msg, false, 2);
 	uiFormat(
@@ -650,24 +608,24 @@ static void handleReplyNoTopic(struct Message *msg) {
 	);
 }
 
-static void topicCache(uint id, const char *topic) {
+static void topicComplete(uint id, const char *topic) {
 	char buf[512];
 	struct Cursor curs = {0};
-	const char *prev = cacheComplete(&curs, id, "/topic ");
+	const char *prev = completePrefix(&curs, id, "/topic ");
 	if (prev) {
 		snprintf(buf, sizeof(buf), "%s", prev);
-		cacheRemove(id, buf);
+		completeRemove(id, buf);
 	}
 	if (topic) {
 		snprintf(buf, sizeof(buf), "/topic %s", topic);
-		cacheInsert(false, id, buf);
+		completePush(id, buf, Default);
 	}
 }
 
 static void handleReplyTopic(struct Message *msg) {
 	require(msg, false, 3);
 	uint id = idFor(msg->params[1]);
-	topicCache(id, msg->params[2]);
+	topicComplete(id, msg->params[2]);
 	if (!replies[ReplyTopic] && !replies[ReplyTopicAuto]) return;
 	urlScan(id, NULL, msg->params[2]);
 	uiFormat(
@@ -718,7 +676,7 @@ static void handleTopic(struct Message *msg) {
 	require(msg, true, 2);
 	uint id = idFor(msg->params[0]);
 	if (!msg->params[1][0]) {
-		topicCache(id, NULL);
+		topicComplete(id, NULL);
 		uiFormat(
 			id, Warm, tagTime(msg),
 			"\3%02d%s\3\tremoves the sign in \3%02d%s\3",
@@ -732,7 +690,7 @@ static void handleTopic(struct Message *msg) {
 	}
 
 	struct Cursor curs = {0};
-	const char *prev = cacheComplete(&curs, id, "/topic ");
+	const char *prev = completePrefix(&curs, id, "/topic ");
 	if (prev) {
 		prev += 7;
 	} else {
@@ -782,7 +740,7 @@ log:
 		id, tagTime(msg), "%s places a new sign in %s: %s",
 		msg->nick, msg->params[0], msg->params[1]
 	);
-	topicCache(id, msg->params[1]);
+	topicComplete(id, msg->params[1]);
 	urlScan(id, msg->nick, msg->params[1]);
 }
 
@@ -905,16 +863,17 @@ static void handleMode(struct Message *msg) {
 			char prefix = network.prefixes[
 				strchr(network.prefixModes, *ch) - network.prefixModes
 			];
+			completePush(id, nick, Default);
 			if (set) {
-				cacheInsert(false, id, nick)->prefixBits |= prefixBit(prefix);
+				*completeBits(id, nick) |= prefixBit(prefix);
 			} else {
-				cacheInsert(false, id, nick)->prefixBits &= ~prefixBit(prefix);
+				*completeBits(id, nick) &= ~prefixBit(prefix);
 			}
 			uiFormat(
 				id, Cold, tagTime(msg),
 				"\3%02d%s\3\t%s \3%02d%c%s\3 %s%s in \3%02d%s\3",
 				hash(msg->user), msg->nick, verb,
-				cacheGet(id, nick)->color, prefix, nick,
+				completeColor(id, nick), prefix, nick,
 				mode, name, hash(msg->params[0]), msg->params[0]
 			);
 			logFormat(
@@ -1052,7 +1011,7 @@ static void handleReplyBanList(struct Message *msg) {
 			id, Warm, tagTime(msg),
 			"Banned from \3%02d%s\3 since %s by \3%02d%s\3: %s",
 			hash(msg->params[1]), msg->params[1],
-			since, cacheGet(id, msg->params[3])->color, msg->params[3],
+			since, completeColor(id, msg->params[3]), msg->params[3],
 			msg->params[2]
 		);
 	} else {
@@ -1075,7 +1034,7 @@ static void onList(const char *list, struct Message *msg) {
 			id, Warm, tagTime(msg),
 			"On the \3%02d%s\3 %s list since %s by \3%02d%s\3: %s",
 			hash(msg->params[1]), msg->params[1], list,
-			since, cacheGet(id, msg->params[3])->color, msg->params[3],
+			since, completeColor(id, msg->params[3]), msg->params[3],
 			msg->params[2]
 		);
 	} else {
@@ -1096,19 +1055,19 @@ static void handleReplyInviteList(struct Message *msg) {
 }
 
 static void handleReplyList(struct Message *msg) {
-	require(msg, false, 4);
+	require(msg, false, 3);
 	uiFormat(
 		Network, Warm, tagTime(msg),
 		"In \3%02d%s\3 are %ld under the banner: %s",
 		hash(msg->params[1]), msg->params[1],
 		strtol(msg->params[2], NULL, 10),
-		msg->params[3]
+		(msg->params[3] ?: "")
 	);
 }
 
 static void handleReplyWhoisUser(struct Message *msg) {
 	require(msg, false, 6);
-	cacheInsert(true, Network, msg->params[1])->color = hash(msg->params[2]);
+	completePull(Network, msg->params[1], hash(msg->params[2]));
 	uiFormat(
 		Network, Warm, tagTime(msg),
 		"\3%02d%s\3\tis %s!%s@%s (%s\17)",
@@ -1123,7 +1082,7 @@ static void handleReplyWhoisServer(struct Message *msg) {
 	uiFormat(
 		Network, Warm, tagTime(msg),
 		"\3%02d%s\3\t%s connected to %s (%s)",
-		cacheGet(Network, msg->params[1])->color, msg->params[1],
+		completeColor(Network, msg->params[1]), msg->params[1],
 		(replies[ReplyWhowas] ? "was" : "is"), msg->params[2], msg->params[3]
 	);
 }
@@ -1147,7 +1106,7 @@ static void handleReplyWhoisIdle(struct Message *msg) {
 	uiFormat(
 		Network, Warm, tagTime(msg),
 		"\3%02d%s\3\tis idle for %lu %s%s%s%s",
-		cacheGet(Network, msg->params[1])->color, msg->params[1],
+		completeColor(Network, msg->params[1]), msg->params[1],
 		idle, unit, (idle != 1 ? "s" : ""),
 		(msg->params[3] ? ", signed on " : ""), (msg->params[3] ? signon : "")
 	);
@@ -1169,7 +1128,7 @@ static void handleReplyWhoisChannels(struct Message *msg) {
 	uiFormat(
 		Network, Warm, tagTime(msg),
 		"\3%02d%s\3\tis in %s",
-		cacheGet(Network, msg->params[1])->color, msg->params[1], buf
+		completeColor(Network, msg->params[1]), msg->params[1], buf
 	);
 }
 
@@ -1183,7 +1142,7 @@ static void handleReplyWhoisGeneric(struct Message *msg) {
 	uiFormat(
 		Network, Warm, tagTime(msg),
 		"\3%02d%s\3\t%s%s%s",
-		cacheGet(Network, msg->params[1])->color, msg->params[1],
+		completeColor(Network, msg->params[1]), msg->params[1],
 		msg->params[2], (msg->params[3] ? " " : ""), (msg->params[3] ?: "")
 	);
 }
@@ -1191,13 +1150,13 @@ static void handleReplyWhoisGeneric(struct Message *msg) {
 static void handleReplyEndOfWhois(struct Message *msg) {
 	require(msg, false, 2);
 	if (strcmp(msg->params[1], self.nick)) {
-		cacheRemove(Network, msg->params[1]);
+		completeRemove(Network, msg->params[1]);
 	}
 }
 
 static void handleReplyWhowasUser(struct Message *msg) {
 	require(msg, false, 6);
-	cacheInsert(true, Network, msg->params[1])->color = hash(msg->params[2]);
+	completePull(Network, msg->params[1], hash(msg->params[2]));
 	uiFormat(
 		Network, Warm, tagTime(msg),
 		"\3%02d%s\3\twas %s!%s@%s (%s)",
@@ -1209,7 +1168,7 @@ static void handleReplyWhowasUser(struct Message *msg) {
 static void handleReplyEndOfWhowas(struct Message *msg) {
 	require(msg, false, 2);
 	if (strcmp(msg->params[1], self.nick)) {
-		cacheRemove(Network, msg->params[1]);
+		completeRemove(Network, msg->params[1]);
 	}
 }
 
@@ -1220,7 +1179,7 @@ static void handleReplyAway(struct Message *msg) {
 	uiFormat(
 		id, (id == Network ? Warm : Cold), tagTime(msg),
 		"\3%02d%s\3\tis away: %s",
-		cacheGet(id, msg->params[1])->color, msg->params[1], msg->params[2]
+		completeColor(id, msg->params[1]), msg->params[1], msg->params[2]
 	);
 	logFormat(
 		id, tagTime(msg), "%s is away: %s",
@@ -1291,7 +1250,7 @@ static char *colorMentions(char *ptr, char *end, uint id, const char *msg) {
 
 		size_t len = strcspn(msg, ",:<> ");
 		char *p = seprintf(ptr, end, "%.*s", (int)len, msg);
-		enum Color color = cacheGet(id, ptr)->color;
+		enum Color color = completeColor(id, ptr);
 		if (color != Default) {
 			ptr = seprintf(ptr, end, "\3%02d%.*s\3", color, (int)len, msg);
 		} else {
@@ -1331,7 +1290,7 @@ static void handlePrivmsg(struct Message *msg) {
 	heat = filterCheck(heat, id, msg);
 	if (heat > Warm && !mine && !query) highlight = true;
 	if (!notice && !mine && heat > Ice) {
-		cacheInsert(true, id, msg->nick)->color = hash(msg->user);
+		completePull(id, msg->nick, hash(msg->user));
 	}
 	if (heat > Ice) urlScan(id, msg->nick, msg->params[1]);
 
@@ -1398,7 +1357,6 @@ static const struct Handler {
 	{ "312", 0, handleReplyWhoisServer },
 	{ "313", +ReplyWhois, handleReplyWhoisGeneric },
 	{ "314", +ReplyWhowas, handleReplyWhowasUser },
-	{ "315", -ReplyWho, handleReplyEndOfWho },
 	{ "317", +ReplyWhois, handleReplyWhoisIdle },
 	{ "318", -ReplyWhois, handleReplyEndOfWhois },
 	{ "319", +ReplyWhois, handleReplyWhoisChannels },
@@ -1416,7 +1374,6 @@ static const struct Handler {
 	{ "347", -ReplyInvex, NULL },
 	{ "348", +ReplyExcepts, handleReplyExceptList },
 	{ "349", -ReplyExcepts, NULL },
-	{ "352", +ReplyWho, handleReplyWho },
 	{ "353", 0, handleReplyNames },
 	{ "366", 0, handleReplyEndOfNames },
 	{ "367", +ReplyBan, handleReplyBanList },
diff --git a/input.c b/input.c
index bcefee5..6b33b93 100644
--- a/input.c
+++ b/input.c
@@ -261,12 +261,12 @@ static const struct {
 	{ L"\\wave", L"ヾ(^∇^)" },
 };
 
-void inputCache(void) {
+void inputCompletion(void) {
 	char mbs[256];
 	for (size_t i = 0; i < ARRAY_LEN(Macros); ++i) {
 		size_t n = wcstombs(mbs, Macros[i].name, sizeof(mbs));
 		assert(n != (size_t)-1);
-		cacheInsert(false, None, mbs);
+		completePush(None, mbs, Default);
 	}
 }
 
@@ -300,12 +300,12 @@ static struct {
 } tab;
 
 static void tabAccept(void) {
-	cacheAccept(&tab.curs);
+	completeAccept(&tab.curs);
 	tab.len = 0;
 }
 
 static void tabReject(void) {
-	cacheReject(&tab.curs);
+	completeReject(&tab.curs);
 	tab.len = 0;
 }
 
@@ -333,9 +333,9 @@ static int tabComplete(struct Edit *e, uint id) {
 		tab.suffix = true;
 	}
 
-	const char *comp = cacheComplete(&tab.curs, id, tab.pre);
+	const char *comp = completePrefix(&tab.curs, id, tab.pre);
 	if (!comp) {
-		comp = cacheComplete(&tab.curs, id, tab.pre);
+		comp = completePrefix(&tab.curs, id, tab.pre);
 		tab.suffix ^= true;
 	}
 	if (!comp) {
diff --git a/scripts/sandman.1 b/sandman.1
index 92828c0..92828c0 100644
--- a/scripts/sandman.1
+++ b/sandman.1
diff --git a/scripts/sandman.m b/sandman.m
index 2e5c4db..2e5c4db 100644
--- a/scripts/sandman.m
+++ b/sandman.m
diff --git a/scripts/.gitignore b/scripts/.gitignore
deleted file mode 100644
index f6dc107..0000000
--- a/scripts/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-sandman
diff --git a/scripts/Makefile b/scripts/Makefile
deleted file mode 100644
index 179a2d3..0000000
--- a/scripts/Makefile
+++ /dev/null
@@ -1,22 +0,0 @@
-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 b2c3b4b..079ee19 100644
--- a/ui.c
+++ b/ui.c
@@ -157,7 +157,7 @@ void uiDraw(void) {
 	if (!to_status_line) return;
 	if (!strcmp(uiTitle, prevTitle)) return;
 	strcpy(prevTitle, uiTitle);
-	putp(tiparm(to_status_line, 0));
+	putp(tparm(to_status_line, 0));
 	putp(uiTitle);
 	putp(from_status_line);
 	fflush(stdout);
@@ -296,7 +296,7 @@ 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 %" PRIX64, signature);
+	errx(EX_DATAERR, "unknown save file signature %" PRIX64, signature);
 }
 
 static int writeUint64(FILE *file, uint64_t u) {
@@ -318,7 +318,7 @@ 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");
+	if (feof(file)) errx(EX_DATAERR, "unexpected end of save file");
 	return u;
 }
 
diff --git a/window.c b/window.c
index 0c675e9..f700fd7 100644
--- a/window.c
+++ b/window.c
@@ -93,7 +93,7 @@ static struct Window *windowRemove(uint num) {
 }
 
 static void windowFree(struct Window *window) {
-	cacheRemove(None, idNames[window->id]);
+	completeRemove(None, idNames[window->id]);
 	bufferFree(window->buffer);
 	free(window);
 }
@@ -118,7 +118,7 @@ uint windowFor(uint id) {
 		window->thresh = windowThreshold;
 	}
 	window->buffer = bufferAlloc();
-	cacheInsert(false, None, idNames[id])->color = idColors[id];
+	completePush(None, idNames[id], idColors[id]);
 
 	return windowPush(window);
 }
@@ -147,6 +147,7 @@ static int styleAdd(WINDOW *win, struct Style init, const char *str) {
 	struct Style style = init;
 	while (*str) {
 		size_t len = styleParse(&style, &str);
+		if (!len) continue;
 		wattr_set(win, uiAttr(style), uiPair(style), NULL);
 		if (waddnstr(win, str, len) == ERR)
 			return -1;
@@ -477,7 +478,7 @@ void windowClose(uint num) {
 	if (num >= count) return;
 	if (windows[num]->id == Network) return;
 	struct Window *window = windowRemove(num);
-	cacheClear(window->id);
+	completeRemove(window->id, NULL);
 	windowFree(window);
 	if (swap >= num) swap--;
 	if (show == num) {
@@ -622,7 +623,7 @@ 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");
+	if (feof(file)) errx(EX_DATAERR, "unexpected end of save file");
 	return time;
 }