Commit Diff


commit - b10a8c42ca51a9080cc5df95ecd26eee613eaf87
commit + 9a9cb3b0fefe2093cd34035349db0fcb8e569ba6
blob - ab234efe25ba7c9fe57c68c6dd9950036920e21a
blob + 789d2e7baac27673436064c8a4bb921bb95b87c4
--- docs/design/m7-curses.md
+++ docs/design/m7-curses.md
@@ -34,17 +34,20 @@ dependency, each with its own regress suite, before an
   *styled display lines* (attribute runs over UTF-8 cells), with
   wide/combining-character width, fenced code blocks, headings,
   bullets, inline code, bold/italic, unmatched markers literal, and an
-  `ascii` fold (typographic punctuation to ASCII, unknown glyphs to
+  shared `ascii.c` fold (typographic punctuation to ASCII, unknown glyphs to
   `?`, markdown structure preserved). Pure; `regress/markdown`.
 - **editor.c** — the modal vi-like line editor over a byte buffer:
   insert/normal modes, the motion/delete verbs, history recall, the
   paste placeholder, input sanitation (NUL rejected, ESC stripped,
-  64 KiB cap), and slash-completion cycling. Pure; `regress/editor`
-  drives key-op sequences and asserts buffer, cursor, and submission.
-- **ui_layout.c** — plain transcript rows as UTF-8 spans plus an optional
-  fill attribute and pane width. Pure; `regress/ui_layout` pins User prefixes,
-  continuation indentation, word and wide/combining-character wrapping,
-  narrow panes, drawer clipping, and resize reflow before curses paints them.
+  64 KiB cap), slash-completion cycling, and presentation-only ASCII folding
+  with matching cursor geometry. Pure; `regress/editor` drives key-op
+  sequences and asserts buffer, cursor, display, and submission.
+- **ui_layout.c** — transcript rows as attributed UTF-8 spans, an optional
+  fill attribute and pane width, and normalization of malformed/control bytes
+  to visible Unicode cells. Pure; `regress/ui_layout` pins User prefixes,
+  continuation indentation, hostile bytes, ASCII expansion, word and
+  wide/combining-character wrapping, narrow panes and picker overlays, drawer
+  clipping, and resize reflow before the wide curses interface paints them.
 - **welcome.c** — the empty-transcript welcome's one-way dismissal state,
   exact copy, viewport layout, ASCII fold, and status-caption collision
   policy. Pure; `regress/welcome` proves that resize and temporary transcript
blob - 857a9356989627994510c409c83cc2fb50b15f83
blob + b483a31281f5a4eb246f31778a66d7c4a277086e
--- handoff/verification.md
+++ handoff/verification.md
@@ -154,16 +154,20 @@ states the property, not just examples:
 - **Owner-row treatment:** the pure UI layout suite pins the shared color and
   monochrome attributes, User prefix and continuation geometry, explicit
   full-pane fill, empty multiline rows, word wrapping, wide/combining UTF-8,
-  narrow-pane fallback, drawer clipping, and resize reflow. The curses pty
-  path exercises the styled input and transcript while retaining clean
-  terminal-mode startup and teardown.
+  visible normalization of NUL/control/malformed bytes, ASCII transliteration
+  and matching input-cursor width, narrow-pane fallback, drawer clipping, and
+  resize reflow. The curses pty path exercises the styled input and transcript
+  through wide curses while retaining clean terminal-mode startup and teardown.
 - **Empty-transcript welcome:** a pure suite pins the exact copy, centering in
   full and drawer-narrowed viewports, ASCII punctuation, whole-row omission on
   short/narrow terminals, nonoverlapping status captions, and one-way
-  dismissal. Staged curses pty snapshots prove startup visibility and removal
-  on the first nonempty edit, as well as absence from the Provider request and
-  Journal and suppression of the default and named Claude-profile warning only
-  in curses mode. Line/print regressions retain the stderr warning.
+  dismissal. Pure geometry checks keep tiny picker overlays above the status
+  row. Staged curses pty repaint captures prove startup visibility, restoration
+  after a presentation-only model selection, and, after a forced physical
+  clear, removal on the first nonempty edit. They also prove absence from the
+  Provider request and Journal and suppression of the default and named
+  Claude-profile warning only in curses mode. Line/print regressions retain the
+  stderr warning.
 - **Prompt caching:** request-builder assertions across consecutive
   requests — breakpoints on the final tool definition, the
   block-form system prompt, and the advancing tail; cold after
blob - ec86d3a9f8a8a8b6fec32b089294ebededae6004
blob + feef42d0e897dca0bb6064c1bcbf51cef1fd1623
--- regress/README
+++ regress/README
@@ -143,7 +143,9 @@ line mode (behavior.md 2.2)		turn (multi-turn piped se
 					slash commands, /model refused)
 User transcript/input treatment	ui_layout (attributes, prefix and
 					continuation geometry, full-pane fill,
-					UTF-8 and reflow); turn (monochrome pty
+					hostile-byte normalization, ASCII fold,
+					UTF-8 and reflow); editor (folded input
+					cursor); turn (monochrome pty
 					startup and teardown)
 empty-transcript welcome (2.1)	welcome (copy, layout, ASCII, caption
 					collision, one-way dismissal); turn (pty,
blob - 1b7816836acb53a114befc361938f11f5d6551df
blob + d4f8554a7ecabc3f5e3c9109a06ddc5e9c486367
--- regress/editor/Makefile
+++ regress/editor/Makefile
@@ -1,5 +1,5 @@
 PROG=	editor_test
-SRCS=	editor_test.c editor.c utf8.c buf.c log.c xmalloc.c
+SRCS=	editor_test.c editor.c ascii.c utf8.c buf.c log.c xmalloc.c
 NOMAN=	yes
 
 CFLAGS+=	-I${.CURDIR}/../../src/fugu-tty
blob - aca4847ad372cc198d61a8a0a60045a7772aff96
blob + 61a0c8af47da23239630abbd89df054cdd2959d8
--- regress/editor/editor_test.c
+++ regress/editor/editor_test.c
@@ -58,7 +58,7 @@ disp_is(struct editor *e, const char *want)
 	int		ok;
 
 	buf_init(&b);
-	ed_display(e, &b);
+	ed_display(e, &b, 0);
 	buf_addc(&b, '\0');
 	ok = strcmp((const char *)b.data, want) == 0;
 	buf_free(&b);
@@ -306,8 +306,39 @@ main(void)
 	CHECK(text_is(&e, "ab"));			/* ESC stripped */
 	ed_free(&e);
 
+	/* Controls draw as one visible replacement cell; the cursor agrees. */
 	ed_init(&e);
+	ed_feed(&e, K_PASTE, 0, "A\tB\xc2\x85" "C", 6);
+	CHECK(ed_cursor_col(&e, 0) == 5);
 	{
+		struct buf b;
+
+		buf_init(&b);
+		ed_display(&e, &b, 1);
+		CHECK(b.len == 5 && memcmp(b.data, "A B?C", 5) == 0);
+		buf_free(&b);
+	}
+	ed_free(&e);
+
+	/* ASCII mode folds display only and counts expansion at the cursor. */
+	ed_init(&e);
+	ed_feed(&e, K_PASTE, 0,
+	    "A\xe2\x80\xa6\xe2\x80\x94\xe4\xb8\xad", 10);
+	{
+		struct buf b;
+
+		buf_init(&b);
+		ed_display(&e, &b, 1);
+		CHECK(b.len == 7 && memcmp(b.data, "A...--?", 7) == 0);
+		buf_free(&b);
+	}
+	CHECK(ed_cursor_col(&e, 1) == 7);
+	CHECK(text_is(&e,
+	    "A\xe2\x80\xa6\xe2\x80\x94\xe4\xb8\xad"));
+	ed_free(&e);
+
+	ed_init(&e);
+	{
 		static char	big[70000];
 
 		memset(big, 'x', sizeof(big));
blob - a06f5d6422d99ef3616b6a456c6658c5a837b351
blob + 46205337201d56ce7feedcbc414d01d73b0d76bc
--- regress/markdown/Makefile
+++ regress/markdown/Makefile
@@ -1,5 +1,5 @@
 PROG=	markdown_test
-SRCS=	markdown_test.c markdown.c utf8.c buf.c log.c xmalloc.c
+SRCS=	markdown_test.c markdown.c ascii.c utf8.c buf.c log.c xmalloc.c
 NOMAN=	yes
 
 CFLAGS+=	-I${.CURDIR}/../../src/fugu-tty
blob - 16f347790f6041328417b70fb5cef7787808e526
blob + 610dddc751b30b0639fcad85e0bb5243fe807f14
--- regress/turn/run.sh
+++ regress/turn/run.sh
@@ -991,7 +991,7 @@ if [ -x "$obj/ptydrive" ]; then
 	rm -rf "$HOME/.fugu"
 	start_stub "CONTEXT"
 	write_conf "$stubport"
-	printf '\000\033o\000\033\014\000m\000\000\033\014\000iO\000WNER-BLOCK-MARKER\r/quit\r' | \
+	printf '\000\033o\000\033\014\000m\000\000\r\014\000m\000\000\033\014\000iOWNER-BLOCK-MARKER\014\000\r/quit\r' | \
 	    TERM=vt100 HOME=$HOME \
 	    FUGU_CONF=$dir/fugu.conf FUGU_LIBEXEC=$dir/libexec/fugu \
 	    FUGU_CA_FILE=$dir/cert.pem FUGU_RETRY_BASE_MS=50 \
@@ -1033,14 +1033,29 @@ if [ -x "$obj/ptydrive" ]; then
 	test -n "$picker_stage"
 	ok "curses: model picker overlays the undismissed welcome" $?
 	grep -a -Fq "I like Fugu" "$dir/welcome-stage.5" &&
-	    grep -a -Fq "context used / limit" "$dir/welcome-stage.5"
-	ok "curses: closing the model picker restores the welcome" $?
-	test -s "$dir/welcome-stage.6" &&
-	    ! grep -a -Fq "I like Fugu" "$dir/welcome-stage.6" &&
-	    ! grep -a -Fq "agent state" "$dir/welcome-stage.6" &&
-	    ! grep -a -Fq "context used / limit" "$dir/welcome-stage.6"
-	ok "curses: first nonempty edit removes welcome and captions" $?
-	grep -a -Fq "Context 46k/200k 23%" "$dir/cursesout"
+	    grep -a -Fq "context used / limit" "$dir/welcome-stage.5" &&
+	    ! grep -a -Fq "model: stub-model-a" "$dir/welcome-stage.5"
+	ok "curses: selecting from m restores welcome without scrollback" $?
+	picker_stage=''
+	for stage in 6 7; do
+		if grep -a -Fq "stub-model-b" "$dir/welcome-stage.$stage"; then
+			picker_stage=$stage
+		fi
+	done
+	test -n "$picker_stage"
+	ok "curses: model picker can reopen before Owner input" $?
+	grep -a -Fq "I like Fugu" "$dir/welcome-stage.8" &&
+	    grep -a -Fq "context used / limit" "$dir/welcome-stage.8"
+	ok "curses: cancelling the model picker restores the welcome" $?
+	test -s "$dir/welcome-stage.9" &&
+	    grep -a -Fq "OWNER-BLOCK-MARKER" "$dir/welcome-stage.9" &&
+	    ! grep -a -Fq "I like Fugu" "$dir/welcome-stage.9" &&
+	    ! grep -a -Fq "agent state" "$dir/welcome-stage.9" &&
+	    ! grep -a -Fq "context used / limit" "$dir/welcome-stage.9"
+	ok "curses: forced repaint after first edit omits welcome and captions" $?
+	grep -q '"model":"stub-model-a"' "$dir/reqout" 2>/dev/null
+	ok "curses: pre-input picker selection drives the request" $?
+	grep -a -Fq "Context 46k/111k 41%" "$dir/cursesout"
 	ok "curses: status shows Lead cache plus session context" $?
 	grep -q "I like Fugu" "$dir/reqout" 2>/dev/null
 	ok "curses: welcome is absent from the Provider request" \
blob - d5e67a6bd303a05123ca0616e4956ac8995b29a3
blob + d2c19e6eaf4259c491ac735405b37fcf6553b371
--- regress/ui_layout/Makefile
+++ regress/ui_layout/Makefile
@@ -1,6 +1,6 @@
 PROG=	ui_layout_test
-SRCS=	ui_layout_test.c ui_layout.c ui_palette.c utf8.c buf.c log.c \
-	xmalloc.c
+SRCS=	ui_layout_test.c ui_layout.c ui_palette.c ascii.c utf8.c buf.c \
+	log.c xmalloc.c
 NOMAN=	yes
 
 CFLAGS+=	-I${.CURDIR}/../../src/fugu-tty
blob - 5d8fdb1ce2fe947b2e679eaf92e1ee92cc7b8952
blob + cef615c14a41843149a5070594aec7c507885bd8
--- regress/ui_layout/ui_layout_test.c
+++ regress/ui_layout/ui_layout_test.c
@@ -101,13 +101,14 @@ main(void)
 	    "A\xef\xbf\xbd\xef\xbf\xbd\xef\xbf\xbd\xef\xbf\xbd"
 	    "\xef\xbf\xbd(\xef\xbf\xbd(B";
 	struct ui_layout layout = { 0 };
+	struct ui_picker_layout picker;
 	int		 attr = 0x1234;
 
 	/* One named treatment serves color and monochrome surfaces. */
 	CHECK(ui_owner_attr(0) == (A_BOLD | A_REVERSE));
 	CHECK(ui_owner_attr(1) == (A_BOLD | COLOR_PAIR(P_OWNER)));
 
-	ui_layout_plain(&layout, "hello", 5, 10, attr, "> ", "  ", 1);
+	ui_layout_plain(&layout, "hello", 5, 10, attr, "> ", "  ", 1, 0);
 	CHECK(layout.nlines == 1);
 	CHECK(line_is(&layout.lines[0], "> hello"));
 	CHECK(layout.lines[0].fill_width == 10);
@@ -117,9 +118,26 @@ main(void)
 	CHECK(ui_layout_visible_fill(&layout.lines[0], 8, 3) == 5);
 	CHECK(ui_layout_visible_fill(&layout.lines[0], 3, 3) == 0);
 
+	/* Tiny picker overlays never reach the input/status rows below them. */
+	ui_layout_picker(&picker, 3, 20);
+	CHECK(picker.top == 0 && picker.height == 1 &&
+	    picker.model_rows == 0 && picker.top + picker.height <= 3 - 2);
+	ui_layout_picker(&picker, 4, 20);
+	CHECK(picker.top == 0 && picker.height == 2 &&
+	    picker.model_rows == 0 && picker.top + picker.height <= 4 - 2);
+	ui_layout_picker(&picker, 5, 20);
+	CHECK(picker.top == 0 && picker.height == 3 &&
+	    picker.model_rows == 0 && picker.top + picker.height <= 5 - 2);
+	ui_layout_picker(&picker, 6, 20);
+	CHECK(picker.top == 0 && picker.height == 4 &&
+	    picker.model_rows == 1 && picker.top + picker.height <= 6 - 2);
+	ui_layout_picker(&picker, 40, 2);
+	CHECK(picker.top == 17 && picker.height == 5 &&
+	    picker.model_rows == 2 && picker.top + picker.height <= 40 - 2);
+
 	/* Every wrapped row has the prefix indent and the same full-width fill. */
 	ui_layout_reset(&layout);
-	ui_layout_plain(&layout, "abcdefghi", 9, 6, attr, "> ", "  ", 1);
+	ui_layout_plain(&layout, "abcdefghi", 9, 6, attr, "> ", "  ", 1, 0);
 	CHECK(layout.nlines == 3);
 	CHECK(line_is(&layout.lines[0], "> abcd"));
 	CHECK(line_is(&layout.lines[1], "  efgh"));
@@ -133,7 +151,7 @@ main(void)
 
 	/* Hard blank lines remain part of one coherent User block. */
 	ui_layout_reset(&layout);
-	ui_layout_plain(&layout, "a\n\nb", 4, 8, attr, "> ", "  ", 1);
+	ui_layout_plain(&layout, "a\n\nb", 4, 8, attr, "> ", "  ", 1, 0);
 	CHECK(layout.nlines == 3);
 	CHECK(line_is(&layout.lines[0], "> a"));
 	CHECK(line_is(&layout.lines[1], "  "));
@@ -143,7 +161,7 @@ main(void)
 
 	/* Word wrapping trims the break and reports the retained cell width. */
 	ui_layout_reset(&layout);
-	ui_layout_plain(&layout, "one two", 7, 8, attr, "> ", "  ", 1);
+	ui_layout_plain(&layout, "one two", 7, 8, attr, "> ", "  ", 1, 0);
 	CHECK(layout.nlines == 2);
 	CHECK(line_is(&layout.lines[0], "> one"));
 	CHECK(layout.lines[0].nspans == 2 &&
@@ -153,20 +171,30 @@ main(void)
 	/* Combining and wide cells use display width, not byte length. */
 	ui_layout_reset(&layout);
 	ui_layout_plain(&layout, wide_combining, sizeof(wide_combining) - 1,
-	    6, attr, "> ", "  ", 1);
+	    6, attr, "> ", "  ", 1, 0);
 	CHECK(layout.nlines == 1);
 	CHECK(layout.lines[0].nspans == 2 &&
 	    layout.lines[0].spans[1].len == sizeof(wide_combining) - 1 &&
 	    layout.lines[0].spans[1].width == 4);
 	CHECK(layout.lines[0].fill_width == 6);
 
+	/* ASCII folding precedes wrapping and applies to Owner rows. */
+	ui_layout_reset(&layout);
+	ui_layout_plain(&layout, "\xe2\x80\x94\xe4\xb8\xad", 6, 4,
+	    attr, "> ", "  ", 1, 1);
+	CHECK(layout.nlines == 2);
+	CHECK(line_is(&layout.lines[0], "> --"));
+	CHECK(line_is(&layout.lines[1], "  ?"));
+	CHECK(line_width(&layout.lines[0]) == 4 &&
+	    line_width(&layout.lines[1]) == 3);
+
 	/*
 	 * Hostile transcript bytes become visible UTF-8 cells before storage:
 	 * embedded NUL, C0/C1 controls, and each malformed byte are U+FFFD.
 	 */
 	ui_layout_reset(&layout);
 	ui_layout_plain(&layout, hostile, sizeof(hostile), 40, attr, NULL,
-	    NULL, 0);
+	    NULL, 0, 0);
 	CHECK(layout.nlines == 1);
 	CHECK(line_bytes_are(&layout.lines[0], normalized,
 	    sizeof(normalized) - 1));
@@ -188,16 +216,16 @@ main(void)
 
 	/* Re-rendering at a narrower pane pins resize and drawer reflow. */
 	ui_layout_reset(&layout);
-	ui_layout_plain(&layout, "abcdef", 6, 5, attr, "> ", "  ", 1);
+	ui_layout_plain(&layout, "abcdef", 6, 5, attr, "> ", "  ", 1, 0);
 	CHECK(layout.nlines == 2 && layout.lines[0].fill_width == 5);
 	ui_layout_reset(&layout);
-	ui_layout_plain(&layout, "abcdef", 6, 4, attr, "> ", "  ", 1);
+	ui_layout_plain(&layout, "abcdef", 6, 4, attr, "> ", "  ", 1, 0);
 	CHECK(layout.nlines == 3 && layout.lines[0].fill_width == 4 &&
 	    layout.lines[2].fill_width == 4);
 
 	/* Prefixes yield to content at widths one and two; nothing overdraws. */
 	ui_layout_reset(&layout);
-	ui_layout_plain(&layout, "ab", 2, 1, attr, "> ", "  ", 1);
+	ui_layout_plain(&layout, "ab", 2, 1, attr, "> ", "  ", 1, 0);
 	CHECK(layout.nlines == 3);
 	CHECK(line_is(&layout.lines[0], ">"));
 	CHECK(line_is(&layout.lines[1], "a"));
@@ -207,7 +235,7 @@ main(void)
 	    line_width(&layout.lines[2]) <= 1);
 
 	ui_layout_reset(&layout);
-	ui_layout_plain(&layout, "ab", 2, 2, attr, "> ", "  ", 1);
+	ui_layout_plain(&layout, "ab", 2, 2, attr, "> ", "  ", 1, 0);
 	CHECK(layout.nlines == 3);
 	CHECK(line_is(&layout.lines[0], "> "));
 	CHECK(line_is(&layout.lines[1], " a"));
@@ -218,7 +246,8 @@ main(void)
 
 	/* A glyph wider than the whole pane becomes one visible placeholder. */
 	ui_layout_reset(&layout);
-	ui_layout_plain(&layout, "\xe4\xb8\xad", 3, 1, attr, "> ", "  ", 1);
+	ui_layout_plain(&layout, "\xe4\xb8\xad", 3, 1, attr, "> ", "  ", 1,
+	    0);
 	CHECK(layout.nlines == 2 && line_is(&layout.lines[0], ">") &&
 	    line_is(&layout.lines[1], "?"));
 	CHECK(line_width(&layout.lines[0]) == 1 &&
@@ -226,14 +255,14 @@ main(void)
 
 	/* Other transcript kinds retain ordinary, unfilled lines. */
 	ui_layout_reset(&layout);
-	ui_layout_plain(&layout, "assistant", 9, 20, 7, NULL, NULL, 0);
+	ui_layout_plain(&layout, "assistant", 9, 20, 7, NULL, NULL, 0, 0);
 	CHECK(layout.nlines == 1 && layout.lines[0].fill_width == 0);
 	CHECK(ui_layout_visible_fill(&layout.lines[0], 80, 31) == 0);
 	CHECK(line_attrs_are(&layout.lines[0], 7));
 
 	/* An empty User message still owns its prefix and padded row. */
 	ui_layout_reset(&layout);
-	ui_layout_plain(&layout, NULL, 0, 12, attr, "> ", "  ", 1);
+	ui_layout_plain(&layout, NULL, 0, 12, attr, "> ", "  ", 1, 0);
 	CHECK(layout.nlines == 1 && line_is(&layout.lines[0], "> "));
 	CHECK(layout.lines[0].fill_width == 12);
 
blob - 9a92356bbf6c44202ee32acb8d09452d52504700
blob + 1bb802445df759a57bc8d38fc2c0dfa2fa850466
--- src/fugu/coord.c
+++ src/fugu/coord.c
@@ -176,6 +176,7 @@ struct coord {
 	struct listed_model	*listed_models;	/* validates picker selections */
 	size_t			 nlisted_models;
 	int			 models_collecting;
+	int			 models_announce;	/* slash-command feedback */
 	int			 default_provider_type;	/* to restore on slot 0 */
 	int			 active_provider_slot;
 	int64_t			 provider_window;	/* selected listing metadata */
@@ -1591,7 +1592,7 @@ configured_provider_name(const struct coord *c, int sl
 	return (NULL);
 }
 
-/* Start one live model listing; normal-mode `m` keeps it presentation-only. */
+/* Start one live model listing; normal-mode "m" stays presentation-only. */
 static void
 model_list_begin(struct coord *c, int announce)
 {
@@ -1604,6 +1605,7 @@ model_list_begin(struct coord *c, int announce)
 	c->listed_models = NULL;
 	c->nlisted_models = 0;
 	c->models_collecting = 1;
+	c->models_announce = announce;
 	imsgev_send(&c->iev[ROLE_API], FUGU_IMSG_LIST_MODELS, 0, NULL, 0);
 	if (announce)
 		emit_note(c, "fetching the model list...");
@@ -1750,7 +1752,9 @@ ui_event(struct coord *c, struct imsg *imsg)
 		turn_txn_cache_reset(c->turn);	/* the cache starts cold (8) */
 		context_invalidate(c);		/* tokenization/provider changed */
 		send_ui_config(c);		/* the status bar shows it */
-		emit_note(c, "model: %s", id);
+		if (c->models_announce)
+			emit_note(c, "model: %s", id);
+		c->models_announce = 0;
 		break;
 	}
 	}
blob - 3d338f1558d354235e859836b6c17d91327b78e2
blob + 653d4cb2e00b5fcbb159c201000a25766325e20f
--- src/fugu-tty/Makefile
+++ src/fugu-tty/Makefile
@@ -1,7 +1,7 @@
 PROG=	fugu-tty
 SRCS=	main.c ui.c ui_config_rx.c ui_layout.c ui_palette.c welcome.c \
-	markdown.c editor.c term_input.c utf8.c worker.c imsgev.c buf.c log.c \
-	xmalloc.c
+	markdown.c editor.c ascii.c term_input.c utf8.c worker.c imsgev.c \
+	buf.c log.c xmalloc.c
 BINDIR=	${LIBEXECDIR}/fugu
 MAN=
 
blob - 7ceca5556b0eebac5415e0f3df17f4faba18826d
blob + 21d6e3e8a798360c3f4b51b95c9fd51bba948c8c
--- src/fugu-tty/editor.c
+++ src/fugu-tty/editor.c
@@ -18,6 +18,7 @@
 #include <stdlib.h>
 #include <string.h>
 
+#include "ascii.h"
 #include "xmalloc.h"
 #include "buf.h"
 #include "utf8.h"
@@ -245,8 +246,36 @@ ed_text(struct editor *e, size_t *len)
 	return ((const char *)textbuf.data);
 }
 
+/* Append one display code point (or its ASCII fold), returning its width. */
+static size_t
+display_cp(struct buf *out, uint32_t cp, int ascii)
+{
+	unsigned char	 enc[4];
+	uint32_t	 folded[4];
+	size_t		 enclen, width = 0;
+	int		 i, n, w;
+
+	if (ascii)
+		n = ascii_fold_cp(cp, folded);
+	else {
+		folded[0] = cp;
+		n = 1;
+	}
+	for (i = 0; i < n; i++) {
+		w = utf8_width(folded[i]);
+		if (folded[i] == 0 || w < 0)
+			w = 1;		/* controls render as one visible cell */
+		width += (size_t)w;
+		if (out != NULL) {
+			enclen = utf8_encode(folded[i], enc);
+			buf_add(out, enc, enclen);
+		}
+	}
+	return (width);
+}
+
 void
-ed_display(const struct editor *e, struct buf *out)
+ed_display(const struct editor *e, struct buf *out, int ascii)
 {
 	size_t	i;
 
@@ -254,7 +283,7 @@ ed_display(const struct editor *e, struct buf *out)
 		if (e->items[i].paste)
 			buf_addf(out, "[paste: %d lines]", e->items[i].lines);
 		else
-			buf_add(out, e->items[i].bytes, e->items[i].nbytes);
+			(void)display_cp(out, e->items[i].cp, ascii);
 	}
 }
 
@@ -273,7 +302,7 @@ size_t	 ed_cursor(const struct editor *e) { return (e-
 size_t	 ed_nitems(const struct editor *e) { return (e->n); }
 
 size_t
-ed_cursor_col(const struct editor *e)
+ed_cursor_col(const struct editor *e, int ascii)
 {
 	size_t	i, col = 0;
 
@@ -285,11 +314,8 @@ ed_cursor_col(const struct editor *e)
 			k = snprintf(tmp, sizeof(tmp), "[paste: %d lines]",
 			    e->items[i].lines);
 			col += (k > 0 ? (size_t)k : 0);
-		} else {
-			int	w = utf8_width(e->items[i].cp);
-
-			col += (w < 0 ? 0 : (size_t)w);
-		}
+		} else
+			col += display_cp(NULL, e->items[i].cp, ascii);
 	}
 	return (col);
 }
blob - /dev/null
blob + 83dac1d1edf876b6e119211549cafbe56bb32023 (mode 644)
--- /dev/null
+++ src/fugu-tty/ascii.c
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <stddef.h>
+#include <stdint.h>
+
+#include "ascii.h"
+
+int
+ascii_fold_cp(uint32_t cp, uint32_t out[4])
+{
+	const char	*rep = NULL;
+	int		 i, n;
+
+	if (cp == '\n') {
+		out[0] = cp;		/* structural line break */
+		return (1);
+	}
+	if (cp == '\t') {
+		out[0] = ' ';
+		return (1);
+	}
+	if (cp < 0x20 || cp == 0x7f) {
+		out[0] = '?';
+		return (1);
+	}
+	if (cp < 0x80) {
+		out[0] = cp;
+		return (1);
+	}
+	switch (cp) {
+	case 0x2018: case 0x2019: case 0x2032:	out[0] = '\''; return (1);
+	case 0x201C: case 0x201D: case 0x2033:	out[0] = '"';  return (1);
+	case 0x2013:				out[0] = '-';  return (1);
+	case 0x00A0:				out[0] = ' ';  return (1);
+	case 0x2022: case 0x00B7: case 0x2023:	out[0] = '*';  return (1);
+	case 0x2014:				rep = "--";  break;
+	case 0x2026:				rep = "..."; break;
+	case 0x00A9:				rep = "(c)"; break;
+	case 0x00AE:				rep = "(r)"; break;
+	case 0x2122:				rep = "(tm)"; break;
+	case 0x2192:				rep = "->";  break;
+	case 0x2190:				rep = "<-";  break;
+	default:				out[0] = '?'; return (1);
+	}
+	for (i = 0, n = 0; rep[i] != '\0' && n < 4; i++)
+		out[n++] = (unsigned char)rep[i];
+	return (n);
+}
blob - c053afb616e32c0c496b53e6e50b136e60a78c14
blob + ca7d21a4814bda6c99255dbf3542ff88740295b2
--- src/fugu-tty/editor.h
+++ src/fugu-tty/editor.h
@@ -129,15 +129,15 @@ int		 ed_empty(const struct editor *);
 int		 ed_mode(const struct editor *);
 size_t		 ed_cursor(const struct editor *);
 size_t		 ed_nitems(const struct editor *);
-/* Display column of the cursor in the rendered line (paste placeholders
- * and wide characters counted), for the curses layer to place it. */
-size_t		 ed_cursor_col(const struct editor *);
+/* Display column of the cursor in the rendered line (paste placeholders,
+ * ASCII folds, and wide characters counted), for curses to place it. */
+size_t		 ed_cursor_col(const struct editor *, int ascii);
 
 void	ed_history_add(struct editor *, const char *, size_t);
 void	ed_set_completions(struct editor *, char **names, size_t n);
 
-/* The display string with paste placeholders; for the driver and tests. */
-void	ed_display(const struct editor *, struct buf *);
+/* The display string with paste placeholders and optional ASCII folding. */
+void	ed_display(const struct editor *, struct buf *, int ascii);
 /* A one-shot notice (oversize paste, &c.); NULL if none pending. */
 const char	*ed_take_notice(struct editor *);
 
blob - /dev/null
blob + aca64e010fcfcd99a51593168e8e188dbb8d6e84 (mode 644)
--- /dev/null
+++ src/fugu-tty/ascii.h
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef ASCII_H
+#define ASCII_H
+
+#include <stdint.h>
+
+/*
+ * Map one code point to a readable ASCII stand-in of at most four cells.
+ * Printable ASCII and newlines pass through, tabs become spaces, and unknown
+ * or nonprinting glyphs become '?'.
+ */
+int	ascii_fold_cp(uint32_t, uint32_t [4]);
+
+#endif /* ASCII_H */
blob - 0a218495e86efd4ffbb6c4a34691a14ced2164e5
blob + 5a2ac48675c6d5851fb83e62bb908d17770ad739
--- src/fugu-tty/markdown.c
+++ src/fugu-tty/markdown.c
@@ -18,6 +18,7 @@
 #include <stdlib.h>
 #include <string.h>
 
+#include "ascii.h"
 #include "xmalloc.h"
 #include "buf.h"
 #include "utf8.h"
@@ -118,43 +119,6 @@ lb_finish(struct lb *b)
 	return (ln);
 }
 
-/* ---- ascii fold ----------------------------------------------------- */
-
-/*
- * Under `ascii yes` map a code point to a short ASCII stand-in sequence
- * (out holds up to 4), preserving readability; any other non-ASCII glyph
- * becomes '?'.  Returns the count.  ASCII passes through unchanged.
- */
-static int
-ascii_fold(uint32_t cp, uint32_t out[4])
-{
-	const char	*rep = NULL;
-	int		 i, n;
-
-	if (cp < 0x80) {
-		out[0] = cp;
-		return (1);
-	}
-	switch (cp) {
-	case 0x2018: case 0x2019: case 0x2032:	out[0] = '\''; return (1);
-	case 0x201C: case 0x201D: case 0x2033:	out[0] = '"';  return (1);
-	case 0x2013:				out[0] = '-';  return (1);
-	case 0x00A0:				out[0] = ' ';  return (1);
-	case 0x2022: case 0x00B7: case 0x2023:	out[0] = '*';  return (1);
-	case 0x2014:				rep = "--";  break;
-	case 0x2026:				rep = "..."; break;
-	case 0x00A9:				rep = "(c)"; break;
-	case 0x00AE:				rep = "(r)"; break;
-	case 0x2122:				rep = "(tm)"; break;
-	case 0x2192:				rep = "->";  break;
-	case 0x2190:				rep = "<-";  break;
-	default:				out[0] = '?'; return (1);
-	}
-	for (i = 0, n = 0; rep[i] != '\0' && n < 4; i++)
-		out[n++] = (unsigned char)rep[i];
-	return (n);
-}
-
 /* ---- decode a logical line into cells ------------------------------- */
 
 static void
@@ -200,7 +164,7 @@ decode_line(const char *s, size_t len, int ascii, size
 			uint32_t	out[4];
 			int		k, m;
 
-			m = ascii_fold(cp, out);
+			m = ascii_fold_cp(cp, out);
 			for (k = 0; k < m; k++)
 				push_cp(&cells, &n, &cap, out[k]);
 		} else
blob - bac1fe598e0384069d9d1cf6244497a9fab59110
blob + c3a532af8c47fa980b25808e997af51c1e3d9e80
--- src/fugu-tty/ui.c
+++ src/fugu-tty/ui.c
@@ -241,7 +241,7 @@ render_diff(struct ui *u, const char *text, size_t len
 				    A_BOLD | A_UNDERLINE;
 		}
 		ui_layout_plain(&u->layout, text + i, j - i, width, attr,
-		    NULL, NULL, 0);
+		    NULL, NULL, 0, u->ascii);
 		i = (j < len) ? j + 1 : j;
 	}
 }
@@ -283,7 +283,7 @@ rebuild(struct ui *u)
 		switch (it->kind) {
 		case IT_USER:
 			ui_layout_plain(&u->layout, t, n, w,
-			    ui_owner_attr(u->color), "> ", "  ", 1);
+			    ui_owner_attr(u->color), "> ", "  ", 1, u->ascii);
 			break;
 		case IT_ASSIST:
 			render_assist(u, it, w);
@@ -291,19 +291,20 @@ rebuild(struct ui *u)
 		case IT_TOOL:
 			ui_layout_plain(&u->layout, t, n, w,
 			    u->color ? COLOR_PAIR(P_TOOL) : A_DIM,
-			    "  tool: ", "  ", 0);
+			    "  tool: ", "  ", 0, u->ascii);
 			break;
 		case IT_DIFF:
 			render_diff(u, t, n, w);
 			break;
 		case IT_NOTE:
 			ui_layout_plain(&u->layout, t, n, w,
-			    u->color ? COLOR_PAIR(P_DIM) : A_DIM, NULL, NULL, 0);
+			    u->color ? COLOR_PAIR(P_DIM) : A_DIM, NULL, NULL, 0,
+			    u->ascii);
 			break;
 		case IT_ERROR:
 			ui_layout_plain(&u->layout, t, n, w,
 			    u->color ? COLOR_PAIR(P_ERR) : A_BOLD, "! ", NULL,
-			    0);
+			    0, u->ascii);
 			break;
 		case IT_CLEAR:
 			u->clear_line = (int)u->layout.nlines;
@@ -437,12 +438,12 @@ static void
 draw_input(struct ui *u, int row)
 {
 	struct buf	disp;
-	size_t		ccol = ed_cursor_col(&u->ed);
+	size_t		ccol = ed_cursor_col(&u->ed, u->ascii);
 	size_t		start = 0, i;
 	int		attr, curscol;
 
 	buf_init(&disp);
-	ed_display(&u->ed, &disp);
+	ed_display(&u->ed, &disp, u->ascii);
 
 	/* horizontal scroll so the cursor stays on screen */
 	if ((int)ccol > u->cols - 1)
@@ -700,7 +701,8 @@ draw_welcome(const struct ui *u, int xoff, int height)
 			break;
 		}
 		attrset(attr);
-		mvaddnstr(row->row, row->col, row->text, (int)row->len);
+		move(row->row, row->col);
+		(void)draw_utf8_cells(row->text, row->len, row->width);
 	}
 	attrset(A_NORMAL);
 }
@@ -846,6 +848,7 @@ pick_matches(const struct ui *u, size_t *out, size_t c
 static void
 draw_picker(struct ui *u)
 {
+	struct ui_picker_layout layout;
 	size_t	match[256];
 	size_t	n = pick_matches(u, match, 256);
 	int	w = u->cols - 4 < 60 ? u->cols - 4 : 60;
@@ -853,26 +856,29 @@ draw_picker(struct ui *u)
 
 	if (w < 20)
 		w = u->cols < 20 ? u->cols : 20;
-	h = (int)n + 3;
-	if (h > u->rows - 2)
-		h = u->rows - 2;
+	ui_layout_picker(&layout, u->rows, n);
+	h = layout.height;
+	rows = layout.model_rows;
+	if (h == 0)
+		return;
 	x = (u->cols - w) / 2;
-	y = (u->rows - h) / 2;
+	y = layout.top;
 
 	if (u->pick_sel >= n)
 		u->pick_sel = n > 0 ? n - 1 : 0;
 
 	/* scroll the visible window to keep the selection on screen (2.1):
 	 * a real /v1/models listing has far more entries than rows */
-	rows = h - 3;
-	if (rows < 1)
-		rows = 1;
-	if (u->pick_sel < u->pick_top)
-		u->pick_top = u->pick_sel;
-	else if (u->pick_sel >= u->pick_top + (size_t)rows)
-		u->pick_top = u->pick_sel - rows + 1;
-	if (u->pick_top > n)
+	if (rows == 0)
 		u->pick_top = 0;
+	else {
+		if (u->pick_sel < u->pick_top)
+			u->pick_top = u->pick_sel;
+		else if (u->pick_sel >= u->pick_top + (size_t)rows)
+			u->pick_top = u->pick_sel - rows + 1;
+		if (u->pick_top > n)
+			u->pick_top = 0;
+	}
 
 	for (r = 0; r < h; r++) {
 		move(y + r, x);
@@ -881,8 +887,10 @@ draw_picker(struct ui *u)
 	}
 	attrset(A_REVERSE | A_BOLD);
 	mvaddnstr(y, x + 1, "model", w - 2);
-	attrset(A_REVERSE);
-	mvprintw(y + 1, x + 1, "> %.*s", w - 4, u->filter);
+	if (h > 1) {
+		attrset(A_REVERSE);
+		mvprintw(y + 1, x + 1, "> %.*s", w - 4, u->filter);
+	}
 	for (r = 0; r < rows && u->pick_top + (size_t)r < n; r++) {
 		size_t		 mi = u->pick_top + (size_t)r;
 		struct pick	*m = &u->models[match[mi]];
blob - e930cf754a8196847eda80497a1253fa5efae8c6
blob + cc5cdd88b5362488a68cf9051d3cd5574fc24c80
--- src/fugu-tty/ui_layout.c
+++ src/fugu-tty/ui_layout.c
@@ -16,10 +16,12 @@
 
 #include <sys/types.h>
 
+#include <limits.h>
 #include <stdint.h>
 #include <stdlib.h>
 #include <string.h>
 
+#include "ascii.h"
 #include "buf.h"
 #include "utf8.h"
 #include "xmalloc.h"
@@ -120,18 +122,44 @@ ui_layout_span(struct ui_line *line, int attr, const v
 	buf_free(&normalized);
 }
 
+/* Fold text before wrapping so expansions participate in row geometry. */
+static void
+fold_ascii(struct buf *out, const unsigned char *text, size_t len)
+{
+	unsigned char	 enc[4];
+	uint32_t	 cp, folded[4];
+	size_t		 adv, enclen, i;
+	int		 j, n;
+
+	for (i = 0; i < len; i += adv) {
+		adv = utf8_decode(text + i, len - i, &cp);
+		n = ascii_fold_cp(cp, folded);
+		for (j = 0; j < n; j++) {
+			enclen = utf8_encode(folded[j], enc);
+			buf_add(out, enc, enclen);
+		}
+	}
+}
+
 /* Wrap plain UTF-8 text to width columns, one attribute, into new lines. */
 void
 ui_layout_plain(struct ui_layout *layout, const void *vtext, size_t len,
     int width, int attr, const char *prefix, const char *continuation,
-    int fill)
+    int fill, int ascii)
 {
 	const unsigned char	*text = vtext;
 	struct ui_line		*line;
+	struct buf		 folded;
 	size_t			 i = 0;
 	int			 col, first = 1;
 	const char		*lead;
 
+	buf_init(&folded);
+	if (ascii && len > 0) {
+		fold_ascii(&folded, text, len);
+		text = folded.data;
+		len = folded.len;
+	}
 	if (width < 1)
 		width = 1;
 	do {
@@ -221,6 +249,7 @@ ui_layout_plain(struct ui_layout *layout, const void *
 			ui_layout_span(line, attr, seg.data, seg.len);
 		buf_free(&seg);
 	} while (i < len);
+	buf_free(&folded);
 }
 
 int
@@ -236,3 +265,18 @@ ui_layout_visible_fill(const struct ui_line *line, int
 		fill = available;
 	return (fill);
 }
+
+void
+ui_layout_picker(struct ui_picker_layout *layout, int screen_rows,
+    size_t matches)
+{
+	int	available, desired, usable;
+
+	available = screen_rows > 2 ? screen_rows - 2 : 0;
+	usable = screen_rows > 0 ? screen_rows - 1 : 0;
+	desired = matches > (size_t)INT_MAX - 3 ? INT_MAX :
+	    (int)matches + 3;		/* title, filter, bottom padding */
+	layout->height = desired < available ? desired : available;
+	layout->top = (usable - layout->height) / 2;
+	layout->model_rows = layout->height > 3 ? layout->height - 3 : 0;
+}
blob - 2ca324091c9504b1a0b68bf62b5670cca635284d
blob + fcefa1bfce29974926fa660ea36202ef6c5ac19a
--- src/fugu-tty/ui_layout.h
+++ src/fugu-tty/ui_layout.h
@@ -39,12 +39,20 @@ struct ui_layout {
 	size_t		 cap;
 };
 
+/* Rows available to the picker without touching the persistent status row. */
+struct ui_picker_layout {
+	int	top;
+	int	height;
+	int	model_rows;
+};
+
 void		 ui_layout_reset(struct ui_layout *);
 void		 ui_layout_free(struct ui_layout *);
 struct ui_line	*ui_layout_line(struct ui_layout *);
 void		 ui_layout_span(struct ui_line *, int, const void *, size_t);
 void		 ui_layout_plain(struct ui_layout *, const void *, size_t, int,
-		    int, const char *, const char *, int);
+		    int, const char *, const char *, int, int);
 int		 ui_layout_visible_fill(const struct ui_line *, int, int);
+void		 ui_layout_picker(struct ui_picker_layout *, int, size_t);
 
 #endif /* UI_LAYOUT_H */