about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--README.717
-rw-r--r--catgirl.119
-rw-r--r--chat.c7
-rw-r--r--chat.h8
-rw-r--r--command.c2
-rw-r--r--edit.c3
-rw-r--r--handle.c32
-rw-r--r--scripts/chat.tmux.conf57
-rw-r--r--ui.c180
9 files changed, 242 insertions, 83 deletions
diff --git a/README.7 b/README.7
index a67ec77..a895d7f 100644
--- a/README.7
+++ b/README.7
@@ -1,4 +1,4 @@
-.Dd January 25, 2021
+.Dd February  8, 2021
 .Dt README 7
 .Os "Causal Agency"
 .\" To view this file, run: man ./README.7
@@ -98,9 +98,18 @@ provided by either
 .Lk https://git.causal.agency/libretls/about LibreTLS
 (for OpenSSL)
 or by LibreSSL.
+.Nm
+and
+.Sy libtls
+may be packaged for your system.
+Check the Repology pages for
+.Lk https://repology.org/project/catgirl/versions catgirl
+and
+.Lk https://repology.org/project/libretls/versions libretls .
 .
 .Pp
-It targets
+.Nm
+targets
 .Fx ,
 .Ox ,
 macOS
@@ -222,6 +231,10 @@ Contributions in any form can be sent to
 For sending patches by email, see
 .Aq Lk https://git-send-email.io .
 .
+.Pp
+Monetary contributions can be
+.Lk https://liberapay.com/june/donate "donated via Liberapay" .
+.
 .Sh SEE ALSO
 .Xr catgirl 1
 .
diff --git a/catgirl.1 b/catgirl.1
index b09d55e..0543be3 100644
--- a/catgirl.1
+++ b/catgirl.1
@@ -1,4 +1,4 @@
-.Dd January 25, 2021
+.Dd February 15, 2021
 .Dt CATGIRL 1
 .Os
 .
@@ -15,6 +15,7 @@
 .Op Fl N Ar notify
 .Op Fl O Ar open
 .Op Fl S Ar bind
+.Op Fl T Ar timestamp
 .Op Fl a Ar plain
 .Op Fl c Ar cert
 .Op Fl h Ar host
@@ -181,6 +182,14 @@ Bind to source address
 .Ar host
 when connecting to the server.
 .
+.It Fl T Ar format , Cm timestamp Op = Ar format
+Show timestamps by default,
+in the specified
+.Xr strftime 3
+.Ar format .
+The default format is
+.Qq \&%X .
+.
 .It Fl a Ar user : Ns Ar pass , Cm sasl-plain = Ar user : Ns Ar pass
 Authenticate as
 .Ar user
@@ -640,6 +649,8 @@ Insert a blank line in the window.
 Scroll to next highlight.
 .It Ic M-p
 Scroll to previous highlight.
+.It Ic M-t
+Toggle timestamps.
 .It Ic M-u
 Scroll to first unread line.
 .It Ic M-v
@@ -647,7 +658,9 @@ Scroll up a page.
 .El
 .
 .Ss IRC Formatting
-.Bl -tag -width Ds -compact
+.Bl -tag -width "C-z C-v" -compact
+.It Ic C-z C-v
+Insert the next input character literally.
 .It Ic C-z b
 Toggle bold.
 .It Ic C-z c
@@ -656,6 +669,8 @@ Set or reset color.
 Toggle italics.
 .It Ic C-z o
 Reset formatting.
+.It Ic C-z p
+Manually toggle paste mode.
 .It Ic C-z r
 Toggle reverse color.
 .It Ic C-z u
diff --git a/chat.c b/chat.c
index 6458925..60ec7d2 100644
--- a/chat.c
+++ b/chat.c
@@ -40,6 +40,7 @@
 #include <sys/stat.h>
 #include <sys/wait.h>
 #include <sysexits.h>
+#include <time.h>
 #include <tls.h>
 #include <unistd.h>
 
@@ -122,6 +123,7 @@ uint32_t hashBound = 75;
 static void parseHash(char *str) {
 	hashInit = strtoul(str, &str, 0);
 	if (*str) hashBound = strtoul(&str[1], NULL, 0);
+	if (hashBound < 2) errx(EX_USAGE, "hash bound must be >= 2");
 }
 
 #ifdef __OpenBSD__
@@ -197,6 +199,7 @@ int main(int argc, char *argv[]) {
 		{ .val = 'O', .name = "open", required_argument },
 		{ .val = 'R', .name = "restrict", no_argument },
 		{ .val = 'S', .name = "bind", required_argument },
+		{ .val = 'T', .name = "timestamp", optional_argument },
 		{ .val = 'a', .name = "sasl-plain", required_argument },
 		{ .val = 'c', .name = "cert", required_argument },
 		{ .val = 'e', .name = "sasl-external", no_argument },
@@ -234,6 +237,10 @@ int main(int argc, char *argv[]) {
 			break; case 'O': utilPush(&urlOpenUtil, optarg);
 			break; case 'R': self.restricted = true;
 			break; case 'S': bind = optarg;
+			break; case 'T': {
+				uiTime.enable = true;
+				if (optarg) uiTime.format = optarg;
+			}
 			break; case 'a': sasl = true; self.plain = optarg;
 			break; case 'c': cert = optarg;
 			break; case 'e': sasl = true;
diff --git a/chat.h b/chat.h
index 6ecd91a..6a21930 100644
--- a/chat.h
+++ b/chat.h
@@ -262,7 +262,9 @@ enum Reply {
 	ReplyList,
 	ReplyMode,
 	ReplyNames,
+	ReplyNamesAuto,
 	ReplyTopic,
+	ReplyTopicAuto,
 	ReplyWho,
 	ReplyWhois,
 	ReplyWhowas,
@@ -279,6 +281,12 @@ const char *commandIsAction(uint id, const char *input);
 void commandCompleteAdd(void);
 
 enum Heat { Ice, Cold, Warm, Hot };
+enum { TimeCap = 64 };
+extern struct Time {
+	bool enable;
+	const char *format;
+	int width;
+} uiTime;
 extern struct Util uiNotifyUtil;
 void uiInitEarly(void);
 void uiInitLate(void);
diff --git a/command.c b/command.c
index b1b4af4..1154942 100644
--- a/command.c
+++ b/command.c
@@ -468,7 +468,7 @@ static void commandHelp(uint id, char *params) {
 	if (pid) return;
 
 	char buf[256];
-	snprintf(buf, sizeof(buf), "%spCOMMANDS$", (getenv("LESS") ?: ""));
+	snprintf(buf, sizeof(buf), "%sp^COMMANDS$", (getenv("LESS") ?: ""));
 	setenv("LESS", buf, 1);
 	execlp("man", "man", "1", "catgirl", NULL);
 	dup2(utilPipe[1], STDERR_FILENO);
diff --git a/edit.c b/edit.c
index d9b7673..3e7e1af 100644
--- a/edit.c
+++ b/edit.c
@@ -114,6 +114,7 @@ static void macroExpand(void) {
 	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);
@@ -259,6 +260,8 @@ void edit(uint id, enum Edit op, wchar_t ch) {
 		}
 
 		break; case EditInsert: {
+			char mb[MB_LEN_MAX];
+			if (wctomb(mb, ch) < 0) return;
 			if (reserve(pos, 1)) {
 				buf[pos++] = ch;
 			}
diff --git a/handle.c b/handle.c
index eae5451..d889f8e 100644
--- a/handle.c
+++ b/handle.c
@@ -244,9 +244,9 @@ static void handleReplyWelcome(struct Message *msg) {
 			if (*ch == ',') count++;
 		}
 		ircFormat("JOIN %s\r\n", self.join);
-		replies[ReplyJoin] += count;
-		replies[ReplyTopic] += count;
-		replies[ReplyNames] += count;
+		if (count == 1) replies[ReplyJoin]++;
+		replies[ReplyTopicAuto] += count;
+		replies[ReplyNamesAuto] += count;
 	}
 }
 
@@ -529,17 +529,26 @@ static void handleReplyNames(struct Message *msg) {
 		char *user = strsep(&name, "@");
 		enum Color color = (user ? hash(user) : Default);
 		completeAdd(id, nick, color);
-		if (!replies[ReplyNames]) continue;
+		if (!replies[ReplyNames] && !replies[ReplyNamesAuto]) continue;
 		catf(&cat, "%s\3%02d%s\3", (buf[0] ? ", " : ""), color, prefixes);
 	}
 	if (!cat.len) return;
 	uiFormat(
-		id, Warm, tagTime(msg),
+		id, (replies[ReplyNamesAuto] ? Cold : Warm), tagTime(msg),
 		"In \3%02d%s\3 are %s",
 		hash(msg->params[2]), msg->params[2], buf
 	);
 }
 
+static void handleReplyEndOfNames(struct Message *msg) {
+	(void)msg;
+	if (replies[ReplyNamesAuto]) {
+		replies[ReplyNamesAuto]--;
+	} else if (replies[ReplyNames]) {
+		replies[ReplyNames]--;
+	}
+}
+
 static char whoBuf[1024];
 static struct Cat whoCat = { whoBuf, sizeof(whoBuf), 0 };
 
@@ -595,11 +604,10 @@ static void handleReplyTopic(struct Message *msg) {
 	require(msg, false, 3);
 	uint id = idFor(msg->params[1]);
 	topicComplete(id, msg->params[2]);
-	if (!replies[ReplyTopic]) return;
-	replies[ReplyTopic]--;
+	if (!replies[ReplyTopic] && !replies[ReplyTopicAuto]) return;
 	urlScan(id, NULL, msg->params[2]);
 	uiFormat(
-		id, Warm, tagTime(msg),
+		id, (replies[ReplyTopicAuto] ? Cold : Warm), tagTime(msg),
 		"The sign in \3%02d%s\3 reads: %s",
 		hash(msg->params[1]), msg->params[1], msg->params[2]
 	);
@@ -607,6 +615,11 @@ static void handleReplyTopic(struct Message *msg) {
 		id, tagTime(msg), "The sign in %s reads: %s",
 		msg->params[1], msg->params[2]
 	);
+	if (replies[ReplyTopicAuto]) {
+		replies[ReplyTopicAuto]--;
+	} else {
+		replies[ReplyTopic]--;
+	}
 }
 
 static void swap(wchar_t *a, wchar_t *b) {
@@ -1275,6 +1288,7 @@ static const struct Handler {
 	{ "330", +ReplyWhois, handleReplyWhoisGeneric },
 	{ "331", -ReplyTopic, handleReplyNoTopic },
 	{ "332", 0, handleReplyTopic },
+	{ "335", +ReplyWhois, handleReplyWhoisGeneric },
 	{ "341", 0, handleReplyInviting },
 	{ "346", +ReplyInvex, handleReplyInviteList },
 	{ "347", -ReplyInvex, NULL },
@@ -1282,7 +1296,7 @@ static const struct Handler {
 	{ "349", -ReplyExcepts, NULL },
 	{ "352", +ReplyWho, handleReplyWho },
 	{ "353", 0, handleReplyNames },
-	{ "366", -ReplyNames, NULL },
+	{ "366", 0, handleReplyEndOfNames },
 	{ "367", +ReplyBan, handleReplyBanList },
 	{ "368", -ReplyBan, NULL },
 	{ "369", -ReplyWhowas, handleReplyEndOfWhowas },
diff --git a/scripts/chat.tmux.conf b/scripts/chat.tmux.conf
index 9191b1a..e58f955 100644
--- a/scripts/chat.tmux.conf
+++ b/scripts/chat.tmux.conf
@@ -1,31 +1,62 @@
 # use `tmux -L chat -f ./chat.tmux.conf attach-session' (without any other
 # options or parameters) to access this session group in its own tmux server,
 # not interfering with existing servers/sessions/configurations
+
 new-session -t chat
 
+# catgirl(1) puts windows at the top
+set-option -g	-- status-position	top
+
 # intuitive navigation
-set-option -g	mode-keys	vi
-set-option -g	mouse	on
+set-option -g	-- mode-keys	vi
+set-option -g	-- mouse	on
 
 # indicate new messages
-set-option -g	monitor-activity	on
-set-option -g	monitor-bell	on
+set-option -g	-- monitor-activity	on
+set-option -g	-- monitor-bell	on
 
 # hardcode names during window creation
-set-option -g	automatic-rename	off
-set-option -g	allow-rename	off
-set-option -g	set-titles	off
-set-option -g	renumber-windows	on
+set-option -g	-- automatic-rename	off
+set-option -g	-- allow-rename	off
+set-option -g	-- set-titles	off
+set-option -g	-- renumber-windows	on
 
 # clients exit on network errors, restart them automatically
 # (use `kill-pane'/`C-b x' to destroy windows)
-set-option -g	remain-on-exit	on
-set-hook -g	pane-died	respawn-pane
+set-option -g	-- remain-on-exit	on
+set-hook -g	-- pane-died	respawn-pane
+
+
+# disarm ^C to avoid accidentially losing logs
+bind-key -n -N 'confirm INTR key'	-- C-c	\
+	confirm-before -p 'Send ^C? (y/N)'	-- 'send-keys	-- C-c'
+
+# one-click version of default `C-b w' (shows preview windows)
+bind-key -n -N 'pick chat network'	-- F1	choose-tree -Z
+
+# catgirl(1) might run in `-R'/`restrict'ed mode, i.e. `/help' is disabled
+bind-key -n -N 'read catgirl help'	-- F2	\
+	new-window -S -n help	-- man -s 1	-- catgirl
+
+# intuitive refresh, just don't spam it ;-)
+bind-key -n -N 'reconnect network'	-- F5	\
+	confirm-before -p 'reconnect network? (y/N)'	-- 'respawn-pane -k'
+
+# immersive mode ;-)
+bind-key -n -N 'toggle fullscreen'	-- F11	set status
+
+
+# this configuration is idempotent, i.e. reloading it only changes settings
+# and never duplicates already existing windows
+bind-key -N 'reload configuration'	-- R	{
+	source-file	-- ./chat.tmux.conf
+	display-message	-- 'configuration reloaded'
+}
 
 
 ## do not double-quote commands to avoid running through "sh -c"
 
 # IRC
-new-window -n efnet	-- catgirl efnet
-new-window -n freenode	-- catgirl freenode
-new-window -n hackint	-- catgirl hackint
+new-window -d -S -n hackint	-- catgirl	-- defaults hackint
+new-window -d -S -n freenode	-- catgirl	-- defaults freenode
+new-window -d -S -n efnet	-- catgirl	-- defaults efnet
diff --git a/ui.c b/ui.c
index 5d3f070..d18ea74 100644
--- a/ui.c
+++ b/ui.c
@@ -53,10 +53,6 @@
 #undef lines
 #undef tab
 
-#ifndef A_ITALIC
-#define A_ITALIC A_NORMAL
-#endif
-
 enum {
 	StatusLines = 1,
 	MarkerLines = 1,
@@ -78,6 +74,7 @@ struct Window {
 	int scroll;
 	bool mark;
 	bool mute;
+	bool time;
 	enum Heat thresh;
 	enum Heat heat;
 	uint unreadSoft;
@@ -129,15 +126,13 @@ 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;
 	window->thresh = Cold;
 	window->buffer = bufferAlloc();
-
 	return windowPush(window);
 }
 
@@ -199,6 +194,7 @@ static short colorPair(short fg, short bg) {
 	X(KeyMetaN, "\33n", NULL) \
 	X(KeyMetaP, "\33p", NULL) \
 	X(KeyMetaQ, "\33q", NULL) \
+	X(KeyMetaT, "\33t", NULL) \
 	X(KeyMetaU, "\33u", NULL) \
 	X(KeyMetaV, "\33v", NULL) \
 	X(KeyMetaEnter, "\33\r", "\33\n") \
@@ -211,7 +207,8 @@ static short colorPair(short fg, short bg) {
 	X(KeyFocusIn, "\33[I", NULL) \
 	X(KeyFocusOut, "\33[O", NULL) \
 	X(KeyPasteOn, "\33[200~", NULL) \
-	X(KeyPasteOff, "\33[201~", NULL)
+	X(KeyPasteOff, "\33[201~", NULL) \
+	X(KeyPasteManual, "\32p", "\32\20")
 
 enum {
 	KeyMax = KEY_MAX,
@@ -222,14 +219,14 @@ enum {
 
 // XXX: Assuming terminals will be fine with these even if they're unsupported,
 // since they're "private" modes.
-static const char *EnterFocusMode = "\33[?1004h";
-static const char *ExitFocusMode  = "\33[?1004l";
-static const char *EnterPasteMode = "\33[?2004h";
-static const char *ExitPasteMode  = "\33[?2004l";
+static const char *FocusMode[2] = { "\33[?1004l", "\33[?1004h" };
+static const char *PasteMode[2] = { "\33[?2004l", "\33[?2004h" };
+
+struct Time uiTime = { .format = "%X" };
 
 static void errExit(void) {
-	putp(ExitFocusMode);
-	putp(ExitPasteMode);
+	putp(FocusMode[false]);
+	putp(PasteMode[false]);
 	reset_shell_mode();
 }
 
@@ -240,6 +237,13 @@ void uiInitEarly(void) {
 	colorInit();
 	atexit(errExit);
 
+#ifndef A_ITALIC
+#define A_ITALIC A_BLINK
+	// Force ncurses to use individual enter_attr_mode strings:
+	set_attributes = NULL;
+	enter_blink_mode = enter_italics_mode;
+#endif
+
 	if (!to_status_line && !strncmp(termname(), "xterm", 5)) {
 		to_status_line = "\33]2;";
 		from_status_line = "\7";
@@ -255,6 +259,16 @@ void uiInitEarly(void) {
 	main = newwin(MAIN_LINES, COLS, StatusLines, 0);
 	if (!main) err(EX_OSERR, "newwin");
 
+	int y;
+	char buf[TimeCap];
+	struct tm *time = localtime(&(time_t) { -22100400 });
+	size_t len = strftime(buf, sizeof(buf), uiTime.format, time);
+	if (!len) errx(EX_CONFIG, "invalid timestamp format: %s", uiTime.format);
+	waddstr(main, buf);
+	waddch(main, ' ');
+	getyx(main, y, uiTime.width);
+	(void)y;
+
 	input = newpad(InputLines, InputCols);
 	if (!input) err(EX_OSERR, "newpad");
 	keypad(input, true);
@@ -386,14 +400,11 @@ static void statusUpdate(void) {
 		char buf[256] = "";
 		struct Cat cat = { buf, sizeof(buf), 0 };
 		catf(
-			&cat, "\3%d%s %u ",
-			idColors[window->id], (num == windows.show ? "\26" : ""), num
+			&cat, "\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->thresh != Cold || window->mute) {
-			const char *thresh[] = { "-", "", "+", "++" };
-			catf(&cat, "%s%s ", thresh[window->thresh], &"="[!window->mute]);
-		}
-		catf(&cat, "%s ", idNames[window->id]);
 		if (window->mark && window->unreadWarm) {
 			catf(
 				&cat, "\3%d+%d\3%d%s",
@@ -438,8 +449,8 @@ static void unmark(struct Window *window) {
 void uiShow(void) {
 	if (!hidden) return;
 	prevTitle[0] = '\0';
-	putp(EnterFocusMode);
-	putp(EnterPasteMode);
+	putp(FocusMode[true]);
+	putp(PasteMode[true]);
 	fflush(stdout);
 	hidden = false;
 	unmark(windows.ptrs[windows.show]);
@@ -449,8 +460,8 @@ void uiHide(void) {
 	if (hidden) return;
 	mark(windows.ptrs[windows.show]);
 	hidden = true;
-	putp(ExitFocusMode);
-	putp(ExitPasteMode);
+	putp(FocusMode[false]);
+	putp(PasteMode[false]);
 	endwin();
 }
 
@@ -466,12 +477,35 @@ static size_t windowBottom(const struct Window *window) {
 	return bottom;
 }
 
-static void mainAdd(int y, const char *str) {
+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);
-	styleAdd(main, str);
+	if (!line || !line->str[0]) {
+		wclrtoeol(main);
+		return;
+	}
+	if (time && line->time) {
+		char buf[TimeCap];
+		strftime(buf, sizeof(buf), uiTime.format, localtime(&line->time));
+		wattr_set(
+			main,
+			colorAttr(Colors[Gray]), colorPair(Colors[Gray], -1),
+			NULL
+		);
+		waddstr(main, buf);
+		waddch(main, ' ');
+	} else if (time) {
+		whline(main, ' ', uiTime.width);
+		wmove(main, y, uiTime.width);
+	}
+	styleAdd(main, line->str);
 	getyx(main, ny, nx);
-	if (ny == y) wclrtoeol(main);
+	if (ny != y) return;
+	wclrtoeol(main);
 	(void)nx;
 }
 
@@ -481,16 +515,14 @@ static void mainUpdate(void) {
 	int y = 0;
 	int marker = MAIN_LINES - SplitLines - MarkerLines;
 	for (size_t i = windowTop(window); i < BufferCap; ++i) {
-		const struct Line *line = bufferHard(window->buffer, i);
-		mainAdd(y++, (line ? line->str : ""));
+		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) {
-		const struct Line *line = bufferHard(window->buffer, i);
-		mainAdd(y++, (line ? line->str : ""));
+		mainAdd(y++, window->time, bufferHard(window->buffer, i));
 	}
 	wattr_set(main, A_NORMAL, 0, NULL);
 	mvwhline(main, marker, 0, ACS_BULLET, COLS);
@@ -540,7 +572,10 @@ void uiWrite(uint id, enum Heat heat, const time_t *src, const char *str) {
 	}
 	if (window->mark && heat > Cold) {
 		if (!window->unreadWarm++) {
-			int lines = bufferPush(window->buffer, COLS, false, Warm, ts, "");
+			int lines = bufferPush(
+				window->buffer, windowCols(window),
+				window->thresh, Warm, ts, ""
+			);
 			if (window->scroll) windowScroll(window, lines);
 			if (window->unreadSoft > 1) {
 				window->unreadSoft++;
@@ -550,7 +585,10 @@ void uiWrite(uint id, enum Heat heat, const time_t *src, const char *str) {
 		if (heat > window->heat) window->heat = heat;
 		statusUpdate();
 	}
-	int lines = bufferPush(window->buffer, COLS, window->thresh, heat, ts, str);
+	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();
@@ -583,7 +621,8 @@ static void windowReflow(struct Window *window) {
 	const struct Line *line = bufferHard(window->buffer, windowTop(window));
 	if (line) num = line->num;
 	window->unreadHard = bufferReflow(
-		window->buffer, COLS, window->thresh, window->unreadSoft
+		window->buffer, windowCols(window),
+		window->thresh, window->unreadSoft
 	);
 	if (!window->scroll || !num) return;
 	for (size_t i = 0; i < BufferCap; ++i) {
@@ -595,12 +634,12 @@ static void windowReflow(struct Window *window) {
 }
 
 static void resize(void) {
-	statusUpdate();
 	wclear(main);
 	wresize(main, MAIN_LINES, COLS);
 	for (uint num = 0; num < windows.len; ++num) {
 		windowReflow(windows.ptrs[num]);
 	}
+	statusUpdate();
 	mainUpdate();
 }
 
@@ -620,13 +659,10 @@ static void windowList(const struct Window *window) {
 			continue;
 		}
 
-		struct tm *tm = localtime(&line->time);
-		if (!tm) err(EX_OSERR, "localtime");
-
-		char buf[sizeof("00:00:00")];
-		strftime(buf, sizeof(buf), "%T", tm);
+		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);
+		printf("%s ", buf);
 
 		bool align = false;
 		struct Style style = StyleDefault;
@@ -679,7 +715,7 @@ static void inputAdd(struct Style *style, const char *str) {
 static void inputUpdate(void) {
 	size_t pos;
 	char *buf = editBuffer(&pos);
-	uint id = windows.ptrs[windows.show]->id;
+	struct Window *window = windows.ptrs[windows.show];
 
 	const char *prefix = "";
 	const char *prompt = self.nick;
@@ -688,9 +724,9 @@ static void inputUpdate(void) {
 	struct Style stylePrompt = { .fg = self.color, .bg = Default };
 	struct Style styleInput = StyleDefault;
 
-	const char *privmsg = commandIsPrivmsg(id, buf);
-	const char *notice = commandIsNotice(id, buf);
-	const char *action = commandIsAction(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;
@@ -703,7 +739,7 @@ static void inputUpdate(void) {
 		stylePrompt.attr |= Italic;
 		styleInput.attr |= Italic;
 		skip = action;
-	} else if (id == Debug && buf[0] != '/') {
+	} else if (window->id == Debug && buf[0] != '/') {
 		prompt = "<< ";
 		stylePrompt.fg = Gray;
 	} else {
@@ -716,6 +752,10 @@ static void inputUpdate(void) {
 
 	int y, x;
 	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);
@@ -732,11 +772,12 @@ static void inputUpdate(void) {
 }
 
 static void windowShow(uint num) {
-	windows.user = num;
-	if (windows.show == num) return;
-	windows.swap = windows.show;
+	if (num != windows.show) {
+		windows.swap = windows.show;
+		mark(windows.ptrs[windows.swap]);
+	}
 	windows.show = num;
-	mark(windows.ptrs[windows.swap]);
+	windows.user = num;
 	unmark(windows.ptrs[windows.show]);
 	mainUpdate();
 	inputUpdate();
@@ -787,6 +828,14 @@ 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);
@@ -807,6 +856,14 @@ static void scrollSearch(struct Window *window, const char *str, int dir) {
 	}
 }
 
+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) {
@@ -815,6 +872,7 @@ static void incThresh(struct Window *window, int n) {
 		window->thresh += n;
 	}
 	windowReflow(window);
+	statusUpdate();
 	mainUpdate();
 	statusUpdate();
 }
@@ -862,7 +920,7 @@ static void keyCode(int code) {
 		break; case KeyMetaSlash: windowShow(windows.swap);
 
 		break; case KeyMetaGt: scrollTo(window, 0);
-		break; case KeyMetaLt: scrollTo(window, BufferCap);
+		break; case KeyMetaLt: scrollTop(window);
 
 		break; case KeyMeta0 ... KeyMeta9: uiShowNum(code - KeyMeta0);
 		break; case KeyMetaA: showAuto();
@@ -874,6 +932,7 @@ static void keyCode(int code) {
 		break; case KeyMetaN: scrollHot(window, +1);
 		break; case KeyMetaP: scrollHot(window, -1);
 		break; case KeyMetaQ: edit(id, EditCollapse, 0);
+		break; case KeyMetaT: toggleTime(window);
 		break; case KeyMetaU: scrollTo(window, window->unreadHard);
 		break; case KeyMetaV: scrollPage(window, +1);
 
@@ -946,19 +1005,24 @@ void uiRead(void) {
 	}
 
 	wint_t ch;
-	static bool paste, style;
+	static bool paste, style, literal;
 	for (int ret; ERR != (ret = wget_wch(input, &ch));) {
 		if (ret == KEY_CODE_YES && ch == KeyPasteOn) {
 			paste = true;
 		} else if (ret == KEY_CODE_YES && ch == KeyPasteOff) {
 			paste = false;
-		} else if (paste) {
+		} 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)) {
@@ -967,6 +1031,7 @@ void uiRead(void) {
 			edit(windows.ptrs[windows.show]->id, EditInsert, ch);
 		}
 		style = false;
+		literal = false;
 	}
 	inputUpdate();
 }
@@ -978,7 +1043,8 @@ static const time_t Signatures[] = {
 	0x6C72696774616304, // no mute
 	0x6C72696774616305, // no URLs
 	0x6C72696774616306, // no thresh
-	0x6C72696774616307,
+	0x6C72696774616307, // no window time
+	0x6C72696774616308,
 };
 
 static size_t signatureVersion(time_t signature) {
@@ -1000,7 +1066,7 @@ int uiSave(const char *name) {
 	if (!file) return -1;
 
 	int error = 0
-		|| writeTime(file, Signatures[6])
+		|| writeTime(file, Signatures[7])
 		|| writeTime(file, self.pos);
 	if (error) return error;
 	for (uint num = 0; num < windows.len; ++num) {
@@ -1008,6 +1074,7 @@ int uiSave(const char *name) {
 		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)
@@ -1072,6 +1139,7 @@ void uiLoad(const char *name) {
 	while (0 < readString(file, &buf, &cap) && buf[0]) {
 		struct Window *window = windows.ptrs[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);