commit 565182c8b8094abb4bd19d037ae6e2201a181704 from: isaac date: Sat Jun 27 02:15:21 2026 UTC ui: conversation drawer to browse and switch sessions Add a side drawer to the curses UI (o in vi normal mode) listing past conversations newest-first with a preview and relative time. Tab moves focus between the drawer and the conversation; j/k + arrows select; Enter switches. A "[+ New conversation]" row starts a fresh session. Switching is a pure UI + journal operation -- net is stateless per turn (the UI resends the full projection), so the next turn carries the new history with no net/parent changes. Switching mid-turn soft-abandons the in-flight turn: the old journal gets a turn_failed marker, the UI drops the abandoned turn's late output (handle_msg gates on an `abandoning` flag, still answering M_TOOL_REQUEST so net can drain), and busy stays set until it finishes so a send in the new conversation waits. The drawer needs runtime file access (list/read/create journals), so the curses worker keeps rpath/wpath/cpath after startup instead of narrowing to stdio+tty; unveil still confines it to ~/.fugu/sessions. - journal_list(): scandir the sessions dir, newest-first, skipping empty files. - ui_curses.c: drawer state/render/input, the soft-abandon switch, the o/Tab rebinding (o no longer enters insert in normal mode). - regress/tui: seed two sessions, open the drawer, switch, assert the transcript becomes the other session's content. - CONTEXT.md: session / conversation / drawer / turn / projection glossary. 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/-.jsonl`, written as it happens and never +rewritten. Created when fugu starts (or a new conversation begins) and reopened +with `-r ` 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 #include #include +#include #include #include #include #include +#include #include #include #include @@ -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 #include +#include #include #include #include @@ -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 #include #include +#include #include #include #include @@ -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 (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;