Commit Diff


commit - d37c6289137cc9ab2fb068f9e633d3ea779caddb
commit + 565182c8b8094abb4bd19d037ae6e2201a181704
blob - /dev/null
blob + 3f732f153360e454f3756dc0f4d20e3bb21d27bf (mode 644)
--- /dev/null
+++ CONTEXT.md
@@ -0,0 +1,36 @@
+# fugu — context
+
+Shared domain language for fugu. Keep these terms stable and keep the code in
+step with them; they are the vocabulary the UI, journal, and net layers all
+assume.
+
+## Glossary
+
+### Session
+An append-only conversation log: one file at
+`~/.fugu/sessions/<epoch>-<pid>.jsonl`, written as it happens and never
+rewritten. Created when fugu starts (or a new conversation begins) and reopened
+with `-r <id>` or from the drawer.
+
+### Conversation
+What the user sees and switches between — one-to-one with a Session (file). A
+`/clear` resets the context *within* a conversation (a `{"fugu":"clear"}` marker
+in the same file); it does not start a new conversation.
+
+### Drawer
+The side panel in the curses UI, opened with `o` in vi normal mode. It lists
+past Conversations newest-first (by last activity) with a preview of each, and
+switches the active conversation. `Tab` moves focus between the drawer and the
+conversation.
+
+### Turn
+One user message plus the model's full response (assistant text and any tool
+calls/results). Turns stream incrementally. Switching conversations mid-turn
+*soft-abandons* the in-flight turn: its partial output is discarded and the
+worker drains it in the background.
+
+### Projection
+The full conversation rendered as the provider's `messages[]` array. The UI
+rebuilds and resends the whole projection every turn; the net worker holds no
+per-conversation state between turns, which is why switching conversations is a
+UI-only operation.
blob - 6afae8114be3161e21fe79fb54bb65fa9e53c337
blob + c0140ba51ca181bc5a313f5aa64918450f5b6e0b
--- regress/tui/tui_test.c
+++ regress/tui/tui_test.c
@@ -27,12 +27,14 @@
 #include <sys/ioctl.h>
 #include <sys/socket.h>
 #include <sys/stat.h>
+#include <sys/time.h>
 #include <sys/wait.h>
 
 #include <netinet/in.h>
 
 #include <dirent.h>
 #include <err.h>
+#include <errno.h>
 #include <fcntl.h>
 #include <signal.h>
 #include <stdio.h>
@@ -206,22 +208,23 @@ write_config(const char *home)
 	fclose(fp);
 }
 
-/* Pre-seed one resumable session so the -r path has prior history to replay. */
+/* Pre-seed a resumable session so the -r and drawer paths have history. */
 static void
-write_session(const char *home, const char *id)
+write_session(const char *home, const char *id, const char *user,
+    const char *assistant)
 {
 	char	dir[1024], path[1100];
 	FILE	*fp;
 
 	snprintf(dir, sizeof(dir), "%s/.fugu/sessions", home);
-	if (mkdir(dir, 0700) == -1)
+	if (mkdir(dir, 0700) == -1 && errno != EEXIST)
 		err(1, "mkdir %s", dir);
 	snprintf(path, sizeof(path), "%s/%s.jsonl", dir, id);
 	if ((fp = fopen(path, "w")) == NULL)
 		err(1, "open %s", path);
-	fputs("{\"role\":\"user\",\"content\":\"capital of France\"}\n", fp);
-	fputs("{\"role\":\"assistant\",\"content\":[{\"type\":\"text\","
-	    "\"text\":\"Paris\"}]}\n", fp);
+	fprintf(fp, "{\"role\":\"user\",\"content\":\"%s\"}\n", user);
+	fprintf(fp, "{\"role\":\"assistant\",\"content\":[{\"type\":\"text\","
+	    "\"text\":\"%s\"}]}\n", assistant);
 	fclose(fp);
 }
 
@@ -259,7 +262,8 @@ main(int argc, char *argv[])
 	if (mkdtemp(home) == NULL)
 		err(1, "mkdtemp");
 	write_config(home);
-	write_session(home, "55-55");
+	write_session(home, "55-55", "capital of France", "Paris");
+	write_session(home, "66-66", "capital of Japan", "Tokyo");
 
 	if ((lfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
 		err(1, "socket");
@@ -414,6 +418,77 @@ main(int argc, char *argv[])
 	if (write(master, "\003", 1) != 1)	/* Ctrl-C clears the input line */
 		CHECK(0, "write Ctrl-C to clear");
 
+	/*
+	 * Conversation drawer: `o` opens it, `Tab` focuses it, `j` selects the
+	 * newest past conversation, `Enter` switches to it.  Bump 66-66's mtime so
+	 * it is unambiguously the top row, then confirm the drawer lists it and
+	 * that switching replaces the transcript with ITS content ("Tokyo"),
+	 * not 55-55's ("Paris").  Resize to a known width first so the drawer fits.
+	 */
+	ws.ws_row = 24;
+	ws.ws_col = 80;
+	ioctl(master, TIOCSWINSZ, &ws);
+	{
+		char	bp[1100];
+
+		snprintf(bp, sizeof(bp), "%s/.fugu/sessions/66-66.jsonl", home);
+		if (utimes(bp, NULL) == -1)
+			CHECK(0, "touch session 66-66");
+	}
+	/* Enter normal mode first (Esc + Ctrl-L flushes the Esc cleanly). */
+	outlen = 0;
+	out[0] = '\0';
+	if (write(master, "\033\014", 2) != 2)
+		CHECK(0, "write Esc for drawer");
+	found = 0;
+	while (outlen < sizeof(out) - 1) {
+		k = read(master, out + outlen, sizeof(out) - 1 - outlen);
+		if (k <= 0)
+			break;
+		outlen += (size_t)k;
+		out[outlen] = '\0';
+		if (strstr(out, "NORMAL") != NULL) {
+			found = 1;
+			break;
+		}
+	}
+	/* Now a bare `o` opens the drawer. */
+	outlen = 0;
+	out[0] = '\0';
+	if (write(master, "o\014", 2) != 2)
+		CHECK(0, "write o");
+	found = 0;
+	while (outlen < sizeof(out) - 1) {
+		k = read(master, out + outlen, sizeof(out) - 1 - outlen);
+		if (k <= 0)
+			break;
+		outlen += (size_t)k;
+		out[outlen] = '\0';
+		if (strstr(out, "Japan") != NULL) {	/* the other session's preview */
+			found = 1;
+			break;
+		}
+	}
+	CHECK(found, "drawer lists past conversations with a preview");
+
+	outlen = 0;
+	out[0] = '\0';
+	if (write(master, "\tj\r\014", 4) != 4)	/* Tab focus, j select, Enter, Ctrl-L */
+		CHECK(0, "write Tab j Enter");
+	found = 0;
+	while (outlen < sizeof(out) - 1) {
+		k = read(master, out + outlen, sizeof(out) - 1 - outlen);
+		if (k <= 0)
+			break;
+		outlen += (size_t)k;
+		out[outlen] = '\0';
+		if (strstr(out, "Tokyo") != NULL) {
+			found = 1;
+			break;
+		}
+	}
+	CHECK(found, "drawer switches the active conversation");
+
 	/* Ctrl-D on the (now empty) input line quits gracefully. */
 	if (write(master, "\004", 1) != 1)
 		CHECK(0, "write Ctrl-D");
blob - 6f86803733749d77cd79cd6eee565b373f76d17a
blob + f11dc9bf6cf6d392ffc7e329240e69ccfaa48e50
--- src/ui/journal.c
+++ src/ui/journal.c
@@ -17,6 +17,7 @@
 #include <sys/types.h>
 #include <sys/stat.h>
 
+#include <dirent.h>
 #include <errno.h>
 #include <fcntl.h>
 #include <stdio.h>
@@ -164,3 +165,80 @@ journal_close(struct journal *j)
 		j->fd = -1;
 	}
 }
+
+/* Newest first; ties broken by id descending (epoch-pid is itself sortable). */
+static int
+entry_cmp(const void *a, const void *b)
+{
+	const struct journal_entry	*x = a, *y = b;
+
+	if (x->mtime > y->mtime)
+		return (-1);
+	if (x->mtime < y->mtime)
+		return (1);
+	return (strcmp(y->id, x->id));
+}
+
+int
+journal_list(struct journal_entry **out, size_t *n)
+{
+	char			 dir[PATH_MAX], path[PATH_MAX];
+	DIR			*dp;
+	struct dirent		*de;
+	struct journal_entry	*arr = NULL;
+	size_t			 cap = 0, cnt = 0;
+
+	*out = NULL;
+	*n = 0;
+	if (sessions_dir(dir, sizeof(dir)) == -1)
+		return (-1);
+	if ((dp = opendir(dir)) == NULL)
+		return (-1);
+
+	while ((de = readdir(dp)) != NULL) {
+		char		 id[64];
+		const char	*dot;
+		size_t		 idlen;
+		struct stat	 st;
+
+		dot = strrchr(de->d_name, '.');
+		if (dot == NULL || strcmp(dot, ".jsonl") != 0)
+			continue;
+		idlen = (size_t)(dot - de->d_name);
+		if (idlen == 0 || idlen >= sizeof(id))
+			continue;
+		memcpy(id, de->d_name, idlen);
+		id[idlen] = '\0';
+		if (!valid_id(id))
+			continue;
+		if (snprintf(path, sizeof(path), "%s/%s", dir, de->d_name) >=
+		    (int)sizeof(path))
+			continue;
+		if (stat(path, &st) == -1 || !S_ISREG(st.st_mode) ||
+		    st.st_size == 0)
+			continue;		/* skip empty / non-files */
+
+		if (cnt == cap) {
+			size_t			 ncap = cap ? cap * 2 : 16;
+			struct journal_entry	*na;
+
+			if ((na = reallocarray(arr, ncap, sizeof(*na))) == NULL) {
+				free(arr);
+				closedir(dp);
+				return (-1);
+			}
+			arr = na;
+			cap = ncap;
+		}
+		strlcpy(arr[cnt].id, id, sizeof(arr[cnt].id));
+		arr[cnt].mtime = st.st_mtime;
+		cnt++;
+	}
+	closedir(dp);
+
+	if (cnt > 1)
+		qsort(arr, cnt, sizeof(*arr), entry_cmp);
+	*out = arr;
+	*n = cnt;
+	return (0);
+}
blob - 8a28ae0cd532daa7c43a296bbaca4b7f1516ad20
blob + 59551c83daa0999e9350d8b5967d158834483c2f
--- src/ui/journal.h
+++ src/ui/journal.h
@@ -59,4 +59,16 @@ void	journal_close(struct journal *);
 int	journal_foreach(const char *id,
 	    void (*cb)(void *, const char *line, size_t len), void *ctx);
 
+/* One listed session, for the conversation drawer. */
+struct journal_entry {
+	char	id[64];
+	time_t	mtime;		/* last activity (file mtime) */
+};
+
+/*
+ * List sessions newest-first by mtime, skipping empty files.  Allocates *out
+ * (caller frees) and sets *n.  Returns 0, or -1 on error (*out=NULL, *n=0).
+ */
+int	journal_list(struct journal_entry **out, size_t *n);
+
 #endif /* FUGU_JOURNAL_H */
blob - 0d3d6804cff82fb41cd4f5db94914b584c74aa90
blob + 8c313c5272c89d0697b671214be504c57f591384
--- src/ui/ui_curses.c
+++ src/ui/ui_curses.c
@@ -42,6 +42,7 @@
 #include <signal.h>
 #include <stdlib.h>
 #include <string.h>
+#include <time.h>
 #include <unistd.h>
 #include <wchar.h>
 #include <wctype.h>
@@ -67,6 +68,14 @@ struct block {
 	struct buf	 text;		/* UTF-8 bytes			*/
 };
 
+/* One row in the conversation drawer. */
+struct dconv {
+	char	id[64];
+	char	preview[160];	/* first user message (UTF-8), newline-stripped */
+	time_t	mtime;
+	int	current;	/* the session we are in right now */
+};
+
 struct tui {
 	struct imsgproto	*ip;
 	struct convo		*cv;
@@ -93,6 +102,14 @@ struct tui {
 	size_t			 npmatch;
 	int			 psel;		/* selected row (index into pmatch) */
 	int			 ptop;		/* first visible row (scroll)	*/
+	/* conversation drawer (o): list of past sessions, switchable */
+	int			 drawer_open;	/* drawer visible		*/
+	int			 drawer_focus;	/* keys drive the list, not editor */
+	int			 abandoning;	/* dropping a soft-abandoned turn */
+	struct dconv		*dconvs;	/* listed conversations		*/
+	size_t			 ndconvs;
+	size_t			 dsel;		/* 0 = new-conversation row; else dconvs[dsel-1] */
+	size_t			 dtop;		/* first visible list row (scroll) */
 	wchar_t			**hist;		/* prompts sent this session	*/
 	size_t			*histlen;	/* length of each hist entry	*/
 	size_t			 nhist;
@@ -157,6 +174,15 @@ static void	hist_reset(struct tui *);
 static void	hist_free(struct tui *);
 static void	submit(struct tui *);
 static void	do_command(struct tui *, enum ui_cmd);
+static int	drawer_width(struct tui *);
+static void	drawer_load(struct tui *);
+static void	drawer_free(struct tui *);
+static void	drawer_close(struct tui *);
+static int	drawer_input(struct tui *, wint_t, int iskey);
+static void	drawer_select(struct tui *);
+static void	drawer_switch(struct tui *, const char *id);
+static void	draw_drawer(struct tui *);
+static void	reltime(time_t, char *, size_t);
 
 static void
 on_winch(int sig)
@@ -245,8 +271,12 @@ ui_curses_run(struct imsgproto *ip, struct convo *cv, 
 	curs_set(1);
 	getmaxyx(stdscr, t.rows, t.cols);
 
-	/* terminfo has been read; no more file access is needed. */
-	if (pledge("stdio tty", NULL) == -1)
+	/*
+	 * terminfo has been read.  Keep rpath/wpath/cpath: the conversation
+	 * drawer reads the session list and switches journals at runtime.  unveil
+	 * still confines all of it to ~/.fugu/sessions (set up before the lock).
+	 */
+	if (pledge("stdio tty rpath wpath cpath", NULL) == -1)
 		fatal("pledge");
 
 	memset(&sa, 0, sizeof(sa));
@@ -298,6 +328,7 @@ ui_curses_run(struct imsgproto *ip, struct convo *cv, 
 	blocks_free(&t);
 	hist_free(&t);
 	picker_free_models(&t);
+	drawer_free(&t);
 	free(t.pmatch);
 	free(t.in);
 	return (0);
@@ -562,7 +593,8 @@ static int
 render(struct tui *t, int draw_it, int vfrom, int vto)
 {
 	size_t	bi;
-	int	g = 0, w = t->cols > 0 ? t->cols : 1;
+	int	dw = drawer_width(t);		/* transcript is offset past the drawer */
+	int	g = 0, w = t->cols - dw > 0 ? t->cols - dw : 1;
 
 	for (bi = 0; bi < t->nblocks; bi++) {
 		struct block	*b = &t->blocks[bi];
@@ -612,7 +644,7 @@ render(struct tui *t, int draw_it, int vfrom, int vto)
 						e++;
 					if (a)
 						attron(a);
-					mvaddnwstr(g - vfrom, x, wc + r,
+					mvaddnwstr(g - vfrom, dw + x, wc + r,
 					    (int)(e - r));
 					if (a)
 						attroff(a);
@@ -665,6 +697,9 @@ draw(struct tui *t)
 		t->scroll = 0;
 	render(t, 1, t->scroll, t->scroll + h);
 
+	if (t->drawer_open)
+		draw_drawer(t);
+
 	/* status bar */
 	attron(A_REVERSE);
 	for (i = 0; i < t->cols; i++)
@@ -744,7 +779,308 @@ draw(struct tui *t)
 	refresh();
 }
 
+/* ------------------------------------------------------------------------ */
+/* Conversation drawer: browse past sessions and switch between them.       */
+/* ------------------------------------------------------------------------ */
+
+#define DRAWER_W	30	/* drawer column width when open */
+
+/* Drawer width for the current terminal, or 0 if closed / it would not fit. */
+static int
+drawer_width(struct tui *t)
+{
+	if (!t->drawer_open)
+		return (0);
+	if (t->cols < DRAWER_W + 20)	/* keep >= 20 cols for the transcript */
+		return (0);
+	return (DRAWER_W);
+}
+
+/* "just now", "5m", "3h", "2d" for a past time. */
 static void
+reltime(time_t then, char *buf, size_t cap)
+{
+	time_t	d = time(NULL) - then;
+
+	if (d < 0)
+		d = 0;
+	if (d < 60)
+		strlcpy(buf, "just now", cap);
+	else if (d < 3600)
+		snprintf(buf, cap, "%lldm", (long long)(d / 60));
+	else if (d < 86400)
+		snprintf(buf, cap, "%lldh", (long long)(d / 3600));
+	else
+		snprintf(buf, cap, "%lldd", (long long)(d / 86400));
+}
+
+/* Capture the first user message of a session as a one-line preview. */
+struct preview_ctx {
+	char	*buf;
+	size_t	 cap;
+	int	 found;
+};
+
+static void
+preview_part(void *ctx, enum convo_part part, const char *text, size_t len)
+{
+	struct preview_ctx	*p = ctx;
+	size_t			 i, o = 0;
+
+	if (p->found || part != CP_USER || p->cap == 0)
+		return;
+	for (i = 0; i < len && o + 1 < p->cap; i++) {
+		unsigned char	c = (unsigned char)text[i];
+
+		p->buf[o++] = (c == '\n' || c == '\r' || c == '\t') ? ' ' :
+		    (char)c;
+	}
+	p->buf[o] = '\0';
+	p->found = 1;
+}
+
+static void
+preview_line(void *ctx, const char *line, size_t len)
+{
+	struct preview_ctx	*p = ctx;
+
+	if (!p->found)
+		convo_replay(line, len, preview_part, p);
+}
+
+static void
+drawer_free(struct tui *t)
+{
+	free(t->dconvs);
+	t->dconvs = NULL;
+	t->ndconvs = 0;
+}
+
+/* (Re)load the session list newest-first with previews; skip empty sessions. */
+static void
+drawer_load(struct tui *t)
+{
+	struct journal_entry	*ents = NULL;
+	size_t			 n = 0, i;
+
+	drawer_free(t);
+	if (journal_list(&ents, &n) == -1 || n == 0) {
+		free(ents);
+		return;
+	}
+	t->dconvs = xreallocarray(NULL, n, sizeof(*t->dconvs));
+	for (i = 0; i < n; i++) {
+		struct dconv		*dc = &t->dconvs[t->ndconvs];
+		struct preview_ctx	 p;
+
+		p.buf = dc->preview;
+		p.cap = sizeof(dc->preview);
+		p.found = 0;
+		dc->preview[0] = '\0';
+		journal_foreach(ents[i].id, preview_line, &p);
+		if (!p.found)
+			continue;	/* no user message -> not a conversation */
+		strlcpy(dc->id, ents[i].id, sizeof(dc->id));
+		dc->mtime = ents[i].mtime;
+		dc->current = (t->j != NULL && strcmp(dc->id, t->j->id) == 0);
+		t->ndconvs++;
+	}
+	free(ents);
+}
+
+static void
+drawer_close(struct tui *t)
+{
+	t->drawer_open = 0;
+	t->drawer_focus = 0;
+}
+
+/*
+ * Switch the active conversation to <id> (NULL = a fresh session).  Replaces
+ * the transcript and convo; new turns append to the selected journal.  If a
+ * turn is in flight, soft-abandon it: mark the old journal so its records do
+ * not replay as orphans, then let handle_msg() drop the old turn's late output.
+ */
+static void
+drawer_switch(struct tui *t, const char *id)
+{
+	size_t	b;
+
+	if (t->busy) {				/* soft-abandon the in-flight turn */
+		convo_journal_turn_failed(t->j, t->cv, t->cv_mark);
+		convo_truncate(t->cv, t->cv_mark);
+		t->abandoning = 1;		/* busy stays set until it drains */
+	}
+
+	journal_close(t->j);
+	blocks_free(t);
+	convo_clear(t->cv);
+
+	if (id != NULL) {
+		if (convo_resume(t->cv, id, replay_block, t) == -1) {
+			b = block_new(t, BK_ERROR);
+			buf_printf(&t->blocks[b].text, "cannot read session %s", id);
+		}
+		journal_open(t->j, id);
+	} else
+		journal_open(t->j, NULL);
+
+	b = block_new(t, BK_INFO);
+	if (id != NULL && t->j->id[0] != '\0')
+		buf_printf(&t->blocks[b].text, "switched to session %s", t->j->id);
+	else
+		buf_puts_cstr(&t->blocks[b].text, "new conversation");
+
+	t->cv_mark = t->cv->n;
+	t->ilen = t->icur = 0;			/* drop the unsent draft */
+	hist_reset(t);
+	t->imode = IM_NORMAL;
+	t->scroll = 0;
+	t->follow = 1;
+	drawer_close(t);
+}
+
+static void
+drawer_select(struct tui *t)
+{
+	if (t->dsel == 0) {			/* the "[+ New conversation]" row */
+		drawer_switch(t, NULL);
+		return;
+	}
+	if (t->dsel - 1 < t->ndconvs) {
+		struct dconv	*dc = &t->dconvs[t->dsel - 1];
+
+		if (dc->current)		/* already here: just close */
+			drawer_close(t);
+		else
+			drawer_switch(t, dc->id);
+	}
+}
+
+/*
+ * Drawer key routing.  Returns 1 if the key was consumed.  `o` toggles the
+ * drawer; Tab moves focus in/out; when focused, j/k and the arrows move the
+ * selection and Enter switches.  All allowed mid-turn -- browsing is safe and
+ * Enter does a soft-abandon.  Resize and the terminal controls (Ctrl-L/D/C)
+ * are never swallowed so the rest of the UI still works.
+ */
+static int
+drawer_input(struct tui *t, wint_t wch, int iskey)
+{
+	size_t	nrows = t->ndconvs + 1;		/* + the new-conversation row */
+
+	if (!iskey && wch == L'o' && t->imode == IM_NORMAL) {
+		if (t->drawer_open)
+			drawer_close(t);
+		else {
+			drawer_load(t);
+			t->drawer_open = 1;
+			t->drawer_focus = 0;	/* focus stays on the conversation */
+			t->dsel = 0;
+			t->dtop = 0;
+		}
+		return (1);
+	}
+	if (!t->drawer_open)
+		return (0);
+	if ((!iskey && wch == L'\t') || (iskey && wch == KEY_BTAB)) {
+		t->drawer_focus = !t->drawer_focus;
+		return (1);
+	}
+	if (!t->drawer_focus)
+		return (0);			/* drawer open, conversation has focus */
+
+	if (iskey) {
+		switch (wch) {
+		case KEY_UP:
+			if (t->dsel > 0)
+				t->dsel--;
+			return (1);
+		case KEY_DOWN:
+			if (t->dsel + 1 < nrows)
+				t->dsel++;
+			return (1);
+		case KEY_RESIZE:
+			return (0);		/* let the resize path run */
+		default:
+			return (1);		/* swallow other keypad keys */
+		}
+	}
+	switch (wch) {
+	case L'k':
+		if (t->dsel > 0)
+			t->dsel--;
+		return (1);
+	case L'j':
+		if (t->dsel + 1 < nrows)
+			t->dsel++;
+		return (1);
+	case L'\r':
+	case L'\n':
+		drawer_select(t);
+		return (1);
+	case 27:				/* Esc: close the drawer */
+		drawer_close(t);
+		return (1);
+	case 3:					/* Ctrl-C/D/L: let the globals run */
+	case 4:
+	case 12:
+		return (0);
+	default:
+		return (1);			/* focused: swallow other plain keys */
+	}
+}
+
+/* Paint the drawer in the left drawer_width(t) columns. */
+static void
+draw_drawer(struct tui *t)
+{
+	int	dw = drawer_width(t), h = t->rows - 2, row, i;
+	size_t	nrows = t->ndconvs + 1, vis;
+
+	if (dw <= 0)
+		return;
+	if (h < 1)
+		h = 1;
+
+	attron(A_BOLD);
+	mvaddnstr(0, 0, "Conversations", dw - 1);
+	attroff(A_BOLD);
+	for (row = 0; row < h; row++)
+		mvaddch(row, dw - 1, ACS_VLINE);
+
+	vis = (size_t)(h - 1);			/* list rows below the header */
+	if (t->dsel < t->dtop)
+		t->dtop = t->dsel;
+	else if (vis > 0 && t->dsel >= t->dtop + vis)
+		t->dtop = t->dsel - vis + 1;
+
+	for (i = 0; (size_t)i < vis && t->dtop + (size_t)i < nrows; i++) {
+		size_t	idx = t->dtop + (size_t)i;
+		int	y = 1 + i, sel = (idx == t->dsel && t->drawer_focus);
+		char	line[256];
+
+		if (idx == 0)
+			snprintf(line, sizeof(line), "%c[+ New conversation]",
+			    idx == t->dsel ? '>' : ' ');
+		else {
+			struct dconv	*dc = &t->dconvs[idx - 1];
+			char		 rt[16];
+
+			reltime(dc->mtime, rt, sizeof(rt));
+			snprintf(line, sizeof(line), "%c%s%s (%s)",
+			    idx == t->dsel ? '>' : ' ',
+			    dc->current ? "*" : "", dc->preview, rt);
+		}
+		if (sel)
+			attron(A_REVERSE);
+		mvaddnstr(y, 0, line, dw - 1);
+		if (sel)
+			attroff(A_REVERSE);
+	}
+}
+
+static void
 do_resize(struct tui *t)
 {
 	struct winsize	ws;
@@ -779,6 +1115,29 @@ drain_imsg(struct tui *t)
 static void
 handle_msg(struct tui *t, struct fugu_msg *m)
 {
+	/*
+	 * A soft-abandoned turn (the user switched conversations mid-stream) is
+	 * still draining from net.  Drop its content so it cannot pollute the new
+	 * conversation; only watch for completion to release `busy`.  A pending
+	 * M_TOOL_REQUEST must still be answered (below) or net would block forever
+	 * waiting for the tool result and never finish draining.
+	 */
+	if (t->abandoning) {
+		switch (m->type) {
+		case M_TURN_DONE:
+		case M_ERROR:
+			t->busy = 0;
+			t->abandoning = 0;
+			t->compacting = 0;
+			t->loading_models = 0;
+			return;
+		case M_TOOL_REQUEST:
+			break;			/* answer it, fall through */
+		default:
+			return;			/* drop the abandoned turn's output */
+		}
+	}
+
 	switch (m->type) {
 	case M_DELTA: {
 		size_t	bi;
@@ -941,6 +1300,10 @@ handle_input(struct tui *t, wint_t wch, int iskey)
 		return;
 	}
 
+	/* Conversation drawer: o toggles it; when focused it drives the list. */
+	if (drawer_input(t, wch, iskey))
+		return;
+
 	/* Any key but Tab/Shift-Tab ends an in-progress completion cycle. */
 	if (!((!iskey && wch == L'\t') || (iskey && wch == KEY_BTAB)))
 		t->comping = 0;
@@ -1121,8 +1484,7 @@ normal_key(struct tui *t, wint_t wch)
 			t->icur++;
 		t->imode = IM_INSERT;
 		return;
-	case L'A':
-	case L'o':			/* single line: open-below == append */
+	case L'A':			/* o is the drawer toggle; A still appends */
 		t->icur = t->ilen;
 		t->imode = IM_INSERT;
 		return;