commit - d37c6289137cc9ab2fb068f9e633d3ea779caddb
commit + 565182c8b8094abb4bd19d037ae6e2201a181704
blob - /dev/null
blob + 3f732f153360e454f3756dc0f4d20e3bb21d27bf (mode 644)
--- /dev/null
+++ CONTEXT.md
+# 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
#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>
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);
}
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");
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
#include <sys/types.h>
#include <sys/stat.h>
+#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
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
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
#include <signal.h>
#include <stdlib.h>
#include <string.h>
+#include <time.h>
#include <unistd.h>
#include <wchar.h>
#include <wctype.h>
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;
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;
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)
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));
blocks_free(&t);
hist_free(&t);
picker_free_models(&t);
+ drawer_free(&t);
free(t.pmatch);
free(t.in);
return (0);
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];
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);
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++)
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;
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;
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;
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;