Commit Diff


commit - 17d56da6b3808a59155d2afbc938cd8484cc43b7
commit + 41aa0eed73347fe812858fd61ac90ba378a02500
blob - a2d0f85c129b3981c63a794afb366ca4adefbf63
blob + ba26133faea629c2563dd9fddba3ce0a0abd6c87
--- docs/design/m7-curses.md
+++ docs/design/m7-curses.md
@@ -41,6 +41,10 @@ dependency, each with its own regress suite, before an
   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.
 - **diff.c** — a unified-diff formatter, run in the tool executor role
   (below); pure line diff to hunk text; `regress/diff`.
 
@@ -106,6 +110,11 @@ bracketed paste (`ESC[200~` … `ESC[201~`) collected 
 event (NUL rejected, other ESC stripped, 64 KiB cap, overflow
 discarded with a notice). Decoded events feed the pure editor/UI
 model; nothing about input depends on `wgetch`, so it is testable.
+The input row and the Owner's User transcript rows use one named palette
+treatment: high-contrast shaded rows on color terminals and bold reverse
+video otherwise. Every visual row of a User message is explicitly filled to
+the current transcript-pane edge, including its prefix and continuation
+indent; resize and drawer changes rebuild that edge from the pure layout.
 `Ctrl-L` records a reflow-stable display boundary and forces a physical clear.
 The tail view starts after that boundary, while scrolling up can still reach
 earlier lines. It sends no coordinator message and does not alter conversation
blob - 4a797f13fee9166dbbba6ce13a27ab6b59a9a652
blob + 5c60dfb315d71a46f754cc8efd17bc2e4597688b
--- handoff/behavior.md
+++ handoff/behavior.md
@@ -86,7 +86,11 @@ Transcript behavior:
   bullet lists, inline code, bold/italic — via curses attributes.
   Unmatched markers render literally. Ordinary assistant text is light
   grey when color is available; markdown-specific colors take precedence.
-  User text renders plain.
+  The Owner's User messages share a shaded, high-contrast treatment with
+  the input row. The `> ` prefix, wrapped or hard-break continuation rows,
+  and padding through the transcript pane's right edge all carry that
+  treatment; an open conversation drawer narrows the edge. Monochrome
+  terminals use bold reverse video. Line and print modes remain plain.
 - Tool calls appear as they run, indented and visually distinct from prose.
   Each `tool:`-prefixed line includes a bounded, control-safe allowlist of
   useful arguments: for example, a read's path and line window or a grep's
blob - f53727c56c505a66fcf3934d6fc5b9b0af9c46c4
blob + 6279752052e03e05a5c783672834e84a75cbde05
--- handoff/verification.md
+++ handoff/verification.md
@@ -151,6 +151,12 @@ states the property, not just examples:
   hostile diff bytes are length-checked at the role boundary (I13);
   and a resume/projection check proves diff output appears in
   neither the provider projection nor the replayed journal (I14).
+- **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.
 - **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 - c3f2ea6f4cc01df501a740da95851e17c54bc553
blob + 402a81eb1c7aaf5958cf491f6a98afa86a3fcc1c
--- regress/Makefile
+++ regress/Makefile
@@ -1,5 +1,5 @@
 SUBDIR=	agentcfg anthropic buf claude_subscription compaction conf deploy diff editor generation http imsgev journal json local_port log markdown \
 	model_window openai output print sandbox skills sse term tool_display tools turn \
-	turn_mechanics turn_txn ui_config web xmalloc
+	turn_mechanics turn_txn ui_config ui_layout web xmalloc
 
 .include <bsd.subdir.mk>
blob - 3c5f8488d910e615a68f0ea2110ab7eac0b1f7bf
blob + ed15645241409d09070e027ca8035f9eaf16f64e
--- regress/README
+++ regress/README
@@ -141,6 +141,10 @@ headless turn (behavior.md 1)		turn, print (real -p tu
 					codes, key flow, transactional cancel)
 line mode (behavior.md 2.2)		turn (multi-turn piped session,
 					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
+					startup and teardown)
 slash commands (behavior.md 3)		turn, print (/clear, /context, /compact,
 					/quit and Skill dispatch); journal
 					(clear replay)
@@ -199,6 +203,8 @@ src/fugu/journal.c			journal, turn
 src/fugu/turn_txn.c			turn_txn (pure ordering plus real-Journal
 					integration); turn (cache/SIGINT path)
 src/fugu-tty/ui_config_rx.c		ui_config; turn (full-palette PTY path)
+src/fugu-tty/ui_layout.c,
+  ui_palette.c			ui_layout; turn (curses PTY path)
 src/common/imsgev.c			imsgev (whole-payload views, checked
 					normal/teardown sends, bounded chunk
 					emission, event failures);
blob - 1d654133634f2852306ac68ef95b3652f76f93b3
blob + d04352ab3fb3080232ecdb2227fb07527239e0e9
--- 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 'hello\r/quit\r' | TERM=vt100 HOME=$HOME \
+	printf 'OWNER-BLOCK-MARKER\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 \
 	    "$obj/ptydrive" "$fugu" >"$dir/cursesout" 2>&1
@@ -1003,8 +1003,10 @@ if [ -x "$obj/ptydrive" ]; then
 	    grep -a -Fq "${esc}[?1006l" "$dir/cursesout" &&
 	    grep -a -Fq "${esc}[?1000l" "$dir/cursesout"
 	ok "curses: mouse reporting is enabled and restored" $?
-	grep -q "hello" "$dir/reqout" 2>/dev/null
+	grep -q "OWNER-BLOCK-MARKER" "$dir/reqout" 2>/dev/null
 	ok "curses: the typed prompt reached the provider" $?
+	grep -a -Fq "OWNER-BLOCK-MARKER" "$dir/cursesout"
+	ok "curses: Owner marker renders over a monochrome pty" $?
 	grep -a -Fq "Context 46k/200k 23%" "$dir/cursesout"
 	ok "curses: status shows Lead cache plus session context" $?
 	n=$(grep -c '"outcome":"ok"' "$sessions"/*.ndjson 2>/dev/null || echo 0)
blob - /dev/null
blob + d5e67a6bd303a05123ca0616e4956ac8995b29a3 (mode 644)
--- /dev/null
+++ regress/ui_layout/Makefile
@@ -0,0 +1,11 @@
+PROG=	ui_layout_test
+SRCS=	ui_layout_test.c ui_layout.c ui_palette.c utf8.c buf.c log.c \
+	xmalloc.c
+NOMAN=	yes
+
+CFLAGS+=	-I${.CURDIR}/../../src/fugu-tty
+LDADD=		-lcurses
+DPADD=		${LIBCURSES}
+.PATH:		${.CURDIR}/../../src/fugu-tty
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 2ff3486fbd247746fb4e110747f2f0b72b2092d0 (mode 644)
--- /dev/null
+++ regress/ui_layout/ui_layout_test.c
@@ -0,0 +1,183 @@
+/*
+ * 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 <curses.h>
+
+#include <string.h>
+
+#include "buf.h"
+#include "ui_layout.h"
+#include "ui_palette.h"
+#include "regress.h"
+
+static int
+line_is(const struct ui_line *line, const char *want)
+{
+	struct buf	buf;
+	size_t		i;
+	int		ok;
+
+	buf_init(&buf);
+	for (i = 0; i < line->nspans; i++)
+		buf_add(&buf, line->spans[i].text, line->spans[i].len);
+	buf_addc(&buf, '\0');
+	ok = strcmp((const char *)buf.data, want) == 0;
+	buf_free(&buf);
+	return (ok);
+}
+
+static int
+line_attrs_are(const struct ui_line *line, int attr)
+{
+	size_t	i;
+
+	for (i = 0; i < line->nspans; i++)
+		if (line->spans[i].attr != attr)
+			return (0);
+	return (1);
+}
+
+static int
+line_width(const struct ui_line *line)
+{
+	size_t	i;
+	int	width = 0;
+
+	for (i = 0; i < line->nspans; i++)
+		width += line->spans[i].width;
+	return (width);
+}
+
+int
+main(void)
+{
+	static const char wide_combining[] =
+	    "A\xcc\x81\xe4\xb8\xad" "B";
+	struct ui_layout layout = { 0 };
+	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);
+	CHECK(layout.nlines == 1);
+	CHECK(line_is(&layout.lines[0], "> hello"));
+	CHECK(layout.lines[0].fill_width == 10);
+	CHECK(layout.lines[0].fill_attr == attr);
+	CHECK(line_attrs_are(&layout.lines[0], attr));
+	CHECK(ui_layout_visible_fill(&layout.lines[0], 10, 0) == 10);
+	CHECK(ui_layout_visible_fill(&layout.lines[0], 8, 3) == 5);
+	CHECK(ui_layout_visible_fill(&layout.lines[0], 3, 3) == 0);
+
+	/* 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);
+	CHECK(layout.nlines == 3);
+	CHECK(line_is(&layout.lines[0], "> abcd"));
+	CHECK(line_is(&layout.lines[1], "  efgh"));
+	CHECK(line_is(&layout.lines[2], "  i"));
+	CHECK(layout.lines[0].fill_width == 6 &&
+	    layout.lines[1].fill_width == 6 &&
+	    layout.lines[2].fill_width == 6);
+	CHECK(line_attrs_are(&layout.lines[0], attr) &&
+	    line_attrs_are(&layout.lines[1], attr) &&
+	    line_attrs_are(&layout.lines[2], attr));
+
+	/* 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);
+	CHECK(layout.nlines == 3);
+	CHECK(line_is(&layout.lines[0], "> a"));
+	CHECK(line_is(&layout.lines[1], "  "));
+	CHECK(line_is(&layout.lines[2], "  b"));
+	CHECK(layout.lines[1].fill_width == 8 &&
+	    layout.lines[1].fill_attr == attr);
+
+	/* 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);
+	CHECK(layout.nlines == 2);
+	CHECK(line_is(&layout.lines[0], "> one"));
+	CHECK(layout.lines[0].nspans == 2 &&
+	    layout.lines[0].spans[1].width == 3);
+	CHECK(line_is(&layout.lines[1], "  two"));
+
+	/* 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);
+	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);
+
+	/* Re-rendering at a narrower pane pins resize and drawer reflow. */
+	ui_layout_reset(&layout);
+	ui_layout_plain(&layout, "abcdef", 6, 5, attr, "> ", "  ", 1);
+	CHECK(layout.nlines == 2 && layout.lines[0].fill_width == 5);
+	ui_layout_reset(&layout);
+	ui_layout_plain(&layout, "abcdef", 6, 4, attr, "> ", "  ", 1);
+	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);
+	CHECK(layout.nlines == 3);
+	CHECK(line_is(&layout.lines[0], ">"));
+	CHECK(line_is(&layout.lines[1], "a"));
+	CHECK(line_is(&layout.lines[2], "b"));
+	CHECK(line_width(&layout.lines[0]) <= 1 &&
+	    line_width(&layout.lines[1]) <= 1 &&
+	    line_width(&layout.lines[2]) <= 1);
+
+	ui_layout_reset(&layout);
+	ui_layout_plain(&layout, "ab", 2, 2, attr, "> ", "  ", 1);
+	CHECK(layout.nlines == 3);
+	CHECK(line_is(&layout.lines[0], "> "));
+	CHECK(line_is(&layout.lines[1], " a"));
+	CHECK(line_is(&layout.lines[2], " b"));
+	CHECK(line_width(&layout.lines[0]) <= 2 &&
+	    line_width(&layout.lines[1]) <= 2 &&
+	    line_width(&layout.lines[2]) <= 2);
+
+	/* 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);
+	CHECK(layout.nlines == 2 && line_is(&layout.lines[0], ">") &&
+	    line_is(&layout.lines[1], "?"));
+	CHECK(line_width(&layout.lines[0]) == 1 &&
+	    line_width(&layout.lines[1]) == 1);
+
+	/* Other transcript kinds retain ordinary, unfilled lines. */
+	ui_layout_reset(&layout);
+	ui_layout_plain(&layout, "assistant", 9, 20, 7, NULL, NULL, 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);
+	CHECK(layout.nlines == 1 && line_is(&layout.lines[0], "> "));
+	CHECK(layout.lines[0].fill_width == 12);
+
+	ui_layout_free(&layout);
+	CHECK(layout.lines == NULL && layout.nlines == 0 && layout.cap == 0);
+	REGRESS_END();
+}
blob - f7c35cbc03654903dfa84d3caded4baf0ebccbea
blob + ac80260ee463e3dcd4a63f24b029ef2cae430ac0
--- src/fugu/fugu.1
+++ src/fugu/fugu.1
@@ -139,7 +139,10 @@ May be repeated for debug-level logging.
 The curses interface displays a scrolling transcript, a one-line modal
 editor, and a status bar.
 Assistant markdown headings, lists, fenced code, emphasis, and inline code
-are rendered with terminal attributes; user messages remain plain text.
+are rendered with terminal attributes.
+The Owner's User messages share a shaded, high-contrast treatment with the
+input row; every wrapped row fills the current transcript pane.
+Monochrome terminals use bold reverse video.
 Tool calls, errors, and edit diffs are visually distinct.
 The status bar identifies idle, active-turn, summarization, and retry states,
 and shows
blob - c85c3f89ffa51c2863e5d736f1543051bb983324
blob + 31c9a5089db29c5e37b47f4841a1ed13e884411b
--- src/fugu-tty/Makefile
+++ src/fugu-tty/Makefile
@@ -1,6 +1,6 @@
 PROG=	fugu-tty
-SRCS=	main.c ui.c ui_config_rx.c markdown.c editor.c term_input.c utf8.c \
-	worker.c imsgev.c buf.c log.c xmalloc.c
+SRCS=	main.c ui.c ui_config_rx.c ui_layout.c ui_palette.c markdown.c \
+	editor.c term_input.c utf8.c worker.c imsgev.c buf.c log.c xmalloc.c
 BINDIR=	${LIBEXECDIR}/fugu
 MAN=
 
blob - 9c20233d383357a221d7316c0cccb7b58dfad897
blob + b20c911fc66c960252025317d69aa62cca89493c
--- src/fugu-tty/ui.c
+++ src/fugu-tty/ui.c
@@ -37,6 +37,8 @@
 #include "editor.h"
 #include "term_input.h"
 #include "ui_config_rx.h"
+#include "ui_layout.h"
+#include "ui_palette.h"
 #include "ui.h"
 
 /* transcript item kinds */
@@ -47,28 +49,6 @@ struct titem {
 	struct buf	raw;
 };
 
-/* a rendered display line: coalesced spans with resolved curses attrs */
-struct span {
-	int		attr;
-	char		*text;
-	size_t		len;
-	int		width;
-};
-struct dline {
-	struct span	*spans;
-	size_t		 n;
-};
-
-/* curses color pairs (when the terminal has color) */
-#define P_HEAD	1
-#define P_CODE	2
-#define P_ADD	3
-#define P_DEL	4
-#define P_TOOL	5
-#define P_ERR	6
-#define P_DIM	7
-#define P_TEXT	8
-
 /* the conversation drawer (behavior.md 2.1): reference width, and the
  * narrowest terminal it will occupy without starving the transcript */
 #define DRAWER_W	30
@@ -111,8 +91,7 @@ struct ui {
 	/* transcript and its rendered scrollback */
 	struct titem		*items;
 	size_t			 nitems, icap;
-	struct dline		*lines;
-	size_t			 nlines, lcap;
+	struct ui_layout		 layout;
 	int			 dirty;
 	int			 scroll;	/* top line; -1 follows the tail */
 	int			 clear_line;	/* latest Ctrl-L display boundary */
@@ -208,124 +187,6 @@ md_attr(const struct ui *u, int md)
 	return (a);
 }
 
-/* ---- rendered-line construction ------------------------------------- */
-
-static void
-lines_reset(struct ui *u)
-{
-	size_t	i, j;
-
-	for (i = 0; i < u->nlines; i++) {
-		for (j = 0; j < u->lines[i].n; j++)
-			free(u->lines[i].spans[j].text);
-		free(u->lines[i].spans);
-	}
-	u->nlines = 0;
-}
-
-static struct dline *
-line_new(struct ui *u)
-{
-	if (u->nlines == u->lcap) {
-		u->lcap = u->lcap ? u->lcap * 2 : 128;
-		u->lines = xreallocarray(u->lines, u->lcap, sizeof(*u->lines));
-	}
-	memset(&u->lines[u->nlines], 0, sizeof(u->lines[u->nlines]));
-	return (&u->lines[u->nlines++]);
-}
-
-static void
-line_add(struct dline *l, int attr, const char *text, size_t len, int width)
-{
-	struct span	*s;
-
-	l->spans = xreallocarray(l->spans, l->n + 1, sizeof(*l->spans));
-	s = &l->spans[l->n++];
-	s->attr = attr;
-	s->width = width;
-	s->len = len;
-	s->text = xmalloc(len + 1);
-	memcpy(s->text, text, len);
-	s->text[len] = '\0';
-}
-
-/* Wrap plain UTF-8 text to width columns, one attr, into new lines. */
-static void
-render_plain(struct ui *u, const char *text, size_t len, int width, int attr,
-    const char *prefix, const char *continuation)
-{
-	struct dline	*l;
-	size_t		 i = 0;
-	int		 col;
-	size_t		 last_break, seg_start;
-	const char	*lead;
-
-	if (width < 1)
-		width = 1;
-	do {
-		l = line_new(u);
-		col = 0;
-		lead = i == 0 ? prefix : continuation;
-		if (lead != NULL) {
-			int	pw = 0;
-			size_t	k;
-
-			for (k = 0; lead[k] != '\0'; k++)
-				pw++;
-			line_add(l, attr, lead, strlen(lead), pw);
-			col = pw;
-		}
-		seg_start = i;
-		last_break = (size_t)-1;
-		{
-			struct buf	seg;
-			int		segw = 0;
-
-			buf_init(&seg);
-			while (i < len) {
-				uint32_t	cp;
-				size_t		adv;
-				int		w;
-
-				adv = utf8_decode((const unsigned char *)text + i,
-				    len - i, &cp);
-				if (cp == '\n') {	/* hard break */
-					i += adv;
-					break;
-				}
-				w = utf8_width(cp);
-				if (w < 0)
-					w = 0;
-				if (col + w > width && seg.len > 0) {
-					/* wrap: prefer the last space */
-					if (last_break != (size_t)-1 &&
-					    last_break > seg_start) {
-						size_t	back = i - last_break;
-
-						seg.len -= back;    /* trim */
-						i = last_break;
-						/* skip the space */
-						i += utf8_decode(
-						    (const unsigned char *)text +
-						    i, len - i, &cp);
-					}
-					break;
-				}
-				buf_add(&seg, text + i, adv);
-				segw += w;
-				if (cp == ' ')
-					last_break = i;
-				i += adv;
-				col += w;
-			}
-			if (seg.len > 0)
-				line_add(l, attr, (char *)seg.data, seg.len,
-				    segw);
-			buf_free(&seg);
-		}
-	} while (i < len);
-}
-
 /* The transcript wrap width: an open drawer narrows it (2.1). */
 static int
 wrap_width(const struct ui *u)
@@ -358,7 +219,8 @@ render_diff(struct ui *u, const char *text, size_t len
 				attr = u->color ? COLOR_PAIR(P_TOOL) :
 				    A_BOLD | A_UNDERLINE;
 		}
-		render_plain(u, text + i, j - i, width, attr, NULL, NULL);
+		ui_layout_plain(&u->layout, text + i, j - i, width, attr,
+		    NULL, NULL, 0);
 		i = (j < len) ? j + 1 : j;
 	}
 }
@@ -372,12 +234,12 @@ render_assist(struct ui *u, const struct titem *it, in
 	md_render((const char *)it->raw.data, it->raw.len, width, u->ascii,
 	    &d);
 	for (i = 0; i < d.nlines; i++) {
-		struct dline	*l = line_new(u);
+		struct ui_line	*l = ui_layout_line(&u->layout);
 
 		for (j = 0; j < d.lines[i].nspans; j++) {
 			struct md_span	*s = &d.lines[i].spans[j];
 
-			line_add(l, md_attr(u, s->attr), s->text, s->len,
+			ui_layout_span(l, md_attr(u, s->attr), s->text, s->len,
 			    s->width);
 		}
 	}
@@ -391,7 +253,7 @@ rebuild(struct ui *u)
 	size_t	i;
 	int	w = wrap_width(u);
 
-	lines_reset(u);
+	ui_layout_reset(&u->layout);
 	u->clear_line = 0;
 	for (i = 0; i < u->nitems; i++) {
 		struct titem	*it = &u->items[i];
@@ -400,30 +262,31 @@ rebuild(struct ui *u)
 
 		switch (it->kind) {
 		case IT_USER:
-			render_plain(u, t, n, w,
-			    A_BOLD, "> ", NULL);
+			ui_layout_plain(&u->layout, t, n, w,
+			    ui_owner_attr(u->color), "> ", "  ", 1);
 			break;
 		case IT_ASSIST:
 			render_assist(u, it, w);
 			break;
 		case IT_TOOL:
-			render_plain(u, t, n, w,
+			ui_layout_plain(&u->layout, t, n, w,
 			    u->color ? COLOR_PAIR(P_TOOL) : A_DIM,
-			    "  tool: ", "  ");
+			    "  tool: ", "  ", 0);
 			break;
 		case IT_DIFF:
 			render_diff(u, t, n, w);
 			break;
 		case IT_NOTE:
-			render_plain(u, t, n, w,
-			    u->color ? COLOR_PAIR(P_DIM) : A_DIM, NULL, NULL);
+			ui_layout_plain(&u->layout, t, n, w,
+			    u->color ? COLOR_PAIR(P_DIM) : A_DIM, NULL, NULL, 0);
 			break;
 		case IT_ERROR:
-			render_plain(u, t, n, w,
-			    u->color ? COLOR_PAIR(P_ERR) : A_BOLD, "! ", NULL);
+			ui_layout_plain(&u->layout, t, n, w,
+			    u->color ? COLOR_PAIR(P_ERR) : A_BOLD, "! ", NULL,
+			    0);
 			break;
 		case IT_CLEAR:
-			u->clear_line = (int)u->nlines;
+			u->clear_line = (int)u->layout.nlines;
 			break;
 		}
 	}
@@ -502,13 +365,19 @@ transcript_height(const struct ui *u)
 }
 
 static void
-draw_line(const struct ui *u, int row, const struct dline *l, int xoff)
+draw_line(const struct ui *u, int row, const struct ui_line *l, int xoff)
 {
 	size_t	i;
-	int	col = xoff;
+	int	col = xoff, fill;
 
-	for (i = 0; i < l->n && col < u->cols; i++) {
-		struct span	*s = &l->spans[i];
+	fill = ui_layout_visible_fill(l, u->cols, xoff);
+	if (fill > 0) {
+		move(row, xoff);
+		attrset(l->fill_attr);
+		hline(' ', fill);
+	}
+	for (i = 0; i < l->nspans && col < u->cols; i++) {
+		struct ui_span	*s = &l->spans[i];
 
 		move(row, col);
 		attrset(s->attr);
@@ -524,7 +393,7 @@ draw_input(struct ui *u, int row)
 	struct buf	disp;
 	size_t		ccol = ed_cursor_col(&u->ed);
 	size_t		start = 0, i;
-	int		curscol;
+	int		attr, curscol;
 
 	buf_init(&disp);
 	ed_display(&u->ed, &disp);
@@ -534,8 +403,10 @@ draw_input(struct ui *u, int row)
 		start = ccol - (u->cols - 1);
 	curscol = (int)(ccol - start);
 
+	attr = ui_owner_attr(u->color);
 	move(row, 0);
-	clrtoeol();
+	attrset(attr);
+	hline(' ', u->cols);
 	i = 0;
 	{
 		size_t	scanned = 0;
@@ -572,6 +443,7 @@ draw_input(struct ui *u, int row)
 		curscol = 0;
 	if (curscol > u->cols - 1)
 		curscol = u->cols - 1;
+	attrset(A_NORMAL);
 	move(row, curscol);		/* leave the hardware cursor here */
 }
 
@@ -869,7 +741,7 @@ draw_picker(struct ui *u)
 static int
 tail_top(const struct ui *u, int height)
 {
-	int	top = (int)u->nlines - height;
+	int	top = (int)u->layout.nlines - height;
 
 	if (top < u->clear_line)
 		top = u->clear_line;
@@ -899,15 +771,15 @@ draw(struct ui *u)
 		top = u->scroll;
 	if (top < 0)
 		top = 0;
-	if (top > (int)u->nlines)
-		top = (int)u->nlines;
+	if (top > (int)u->layout.nlines)
+		top = (int)u->layout.nlines;
 
 	erase();
 	for (r = 0; r < th; r++) {
 		int	li = top + r;
 
-		if (li >= 0 && li < (int)u->nlines)
-			draw_line(u, r, &u->lines[li], xoff);
+		if (li >= 0 && li < (int)u->layout.nlines)
+			draw_line(u, r, &u->layout.lines[li], xoff);
 	}
 	draw_input(u, th);
 	draw_queue(u, th + 1);
@@ -1720,6 +1592,7 @@ ui_stop(void)
 {
 	if (UI == NULL)
 		return;
+	ui_layout_free(&UI->layout);
 	terminal_modes(0);
 	endwin();
 	UI = NULL;
@@ -1738,18 +1611,8 @@ ui_start(struct worker *w, int cols, int rows)
 	noecho();
 	nonl();
 	curs_set(1);
-	if (has_colors()) {
-		start_color();
-		use_default_colors();
-		init_pair(P_HEAD, COLOR_YELLOW, -1);
-		init_pair(P_CODE, COLOR_CYAN, -1);
-		init_pair(P_ADD, COLOR_GREEN, -1);
-		init_pair(P_DEL, COLOR_RED, -1);
-		init_pair(P_TOOL, COLOR_CYAN, -1);
-		init_pair(P_ERR, COLOR_RED, -1);
-		init_pair(P_DIM, COLOR_BLUE, -1);
-		init_pair(P_TEXT, COLOR_WHITE, -1);
-	}
+	if (has_colors())
+		ui_palette_init();
 	terminal_modes(1);
 
 	u = xcalloc(1, sizeof(*u));
blob - /dev/null
blob + 7e7e63c09138ee4872a6f61b1188b07b44368e93 (mode 644)
--- /dev/null
+++ src/fugu-tty/ui_layout.c
@@ -0,0 +1,213 @@
+/*
+ * 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 <sys/types.h>
+
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "buf.h"
+#include "utf8.h"
+#include "xmalloc.h"
+#include "ui_layout.h"
+
+/* Width of the next visible cell, skipping leading combining characters. */
+static int
+next_cell_width(const unsigned char *text, size_t len)
+{
+	uint32_t	cp;
+	size_t		adv, i = 0;
+	int		w;
+
+	while (i < len) {
+		adv = utf8_decode(text + i, len - i, &cp);
+		if (cp == '\n')
+			return (0);
+		w = utf8_width(cp);
+		if (w > 0)
+			return (w);
+		i += adv;
+	}
+	return (0);
+}
+
+void
+ui_layout_reset(struct ui_layout *layout)
+{
+	size_t	i, j;
+
+	for (i = 0; i < layout->nlines; i++) {
+		for (j = 0; j < layout->lines[i].nspans; j++)
+			free(layout->lines[i].spans[j].text);
+		free(layout->lines[i].spans);
+	}
+	layout->nlines = 0;
+}
+
+void
+ui_layout_free(struct ui_layout *layout)
+{
+	ui_layout_reset(layout);
+	free(layout->lines);
+	memset(layout, 0, sizeof(*layout));
+}
+
+struct ui_line *
+ui_layout_line(struct ui_layout *layout)
+{
+	struct ui_line	*line;
+
+	if (layout->nlines == layout->cap) {
+		layout->cap = layout->cap ? layout->cap * 2 : 128;
+		layout->lines = xreallocarray(layout->lines, layout->cap,
+		    sizeof(*layout->lines));
+	}
+	line = &layout->lines[layout->nlines++];
+	memset(line, 0, sizeof(*line));
+	return (line);
+}
+
+void
+ui_layout_span(struct ui_line *line, int attr, const void *text, size_t len,
+    int width)
+{
+	struct ui_span	*span;
+
+	line->spans = xreallocarray(line->spans, line->nspans + 1,
+	    sizeof(*line->spans));
+	span = &line->spans[line->nspans++];
+	span->attr = attr;
+	span->width = width;
+	span->len = len;
+	span->text = xmalloc(len + 1);
+	memcpy(span->text, text, len);
+	span->text[len] = '\0';
+}
+
+/* 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)
+{
+	const unsigned char	*text = vtext;
+	struct ui_line		*line;
+	size_t			 i = 0;
+	int			 col, first = 1;
+	const char		*lead;
+
+	if (width < 1)
+		width = 1;
+	do {
+		struct buf	seg;
+		size_t		last_break, break_len, leadlen;
+		int		segw, break_width, lead_limit, need;
+
+		line = ui_layout_line(layout);
+		if (fill) {
+			line->fill_attr = attr;
+			line->fill_width = width;
+		}
+		col = 0;
+		lead = first ? prefix : continuation;
+		if (lead != NULL) {
+			leadlen = strlen(lead);
+			lead_limit = width;
+			if (!first && i < len) {
+				need = next_cell_width(text + i, len - i);
+				if (need > width)
+					need = width;
+				if (need > 0)
+					lead_limit = width - need;
+			}
+			if (leadlen > (size_t)lead_limit)
+				leadlen = (size_t)lead_limit;
+
+			if (leadlen > 0) {
+				ui_layout_span(line, attr, lead, leadlen,
+				    (int)leadlen);
+				col = (int)leadlen;
+			}
+		}
+		first = 0;
+
+		buf_init(&seg);
+		segw = 0;
+		last_break = (size_t)-1;
+		break_len = 0;
+		break_width = 0;
+		while (i < len) {
+			uint32_t	cp;
+			size_t		adv;
+			int		w;
+
+			adv = utf8_decode(text + i, len - i, &cp);
+			if (cp == '\n') {
+				i += adv;
+				break;
+			}
+			w = utf8_width(cp);
+			if (w < 0)
+				w = 0;
+			if (col + w > width) {
+				if (segw > 0) {
+					if (last_break != (size_t)-1) {
+						seg.len = break_len;
+						segw = break_width;
+						i = last_break;
+						i += utf8_decode(text + i,
+						    len - i, &cp);
+					}
+					break;
+				}
+				if (col > 0)
+					break;
+				/* One glyph cannot fit this pane at all. */
+				buf_addc(&seg, '?');
+				segw = col = 1;
+				i += adv;
+				continue;
+			}
+			buf_add(&seg, text + i, adv);
+			segw += w;
+			col += w;
+			if (cp == ' ' && seg.len > adv) {
+				last_break = i;
+				break_len = seg.len - adv;
+				break_width = segw - w;
+			}
+			i += adv;
+		}
+		if (seg.len > 0)
+			ui_layout_span(line, attr, seg.data, seg.len, segw);
+		buf_free(&seg);
+	} while (i < len);
+}
+
+int
+ui_layout_visible_fill(const struct ui_line *line, int cols, int xoff)
+{
+	int	available, fill;
+
+	available = cols - xoff;
+	if (available <= 0 || line->fill_width <= 0)
+		return (0);
+	fill = line->fill_width;
+	if (fill > available)
+		fill = available;
+	return (fill);
+}
blob - /dev/null
blob + 85c490f435a7c16cfa3993077783f5fb53324520 (mode 644)
--- /dev/null
+++ src/fugu-tty/ui_layout.h
@@ -0,0 +1,51 @@
+/*
+ * 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 UI_LAYOUT_H
+#define UI_LAYOUT_H
+
+#include <stddef.h>
+
+struct ui_span {
+	int	 attr;
+	char	*text;
+	size_t	 len;
+	int	 width;
+};
+
+struct ui_line {
+	struct ui_span	*spans;
+	size_t		 nspans;
+	int		 fill_attr;
+	int		 fill_width;
+};
+
+struct ui_layout {
+	struct ui_line	*lines;
+	size_t		 nlines;
+	size_t		 cap;
+};
+
+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,
+		    int);
+void		 ui_layout_plain(struct ui_layout *, const void *, size_t, int,
+		    int, const char *, const char *, int);
+int		 ui_layout_visible_fill(const struct ui_line *, int, int);
+
+#endif /* UI_LAYOUT_H */
blob - /dev/null
blob + e7d9863b6f3d1952791f34d8555e14c3e2822a62 (mode 644)
--- /dev/null
+++ src/fugu-tty/ui_palette.c
@@ -0,0 +1,41 @@
+/*
+ * 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 <curses.h>
+
+#include "ui_palette.h"
+
+void
+ui_palette_init(void)
+{
+	start_color();
+	use_default_colors();
+	init_pair(P_HEAD, COLOR_YELLOW, -1);
+	init_pair(P_CODE, COLOR_CYAN, -1);
+	init_pair(P_ADD, COLOR_GREEN, -1);
+	init_pair(P_DEL, COLOR_RED, -1);
+	init_pair(P_TOOL, COLOR_CYAN, -1);
+	init_pair(P_ERR, COLOR_RED, -1);
+	init_pair(P_DIM, COLOR_BLUE, -1);
+	init_pair(P_TEXT, COLOR_WHITE, -1);
+	init_pair(P_OWNER, COLOR_BLACK, COLOR_WHITE);
+}
+
+int
+ui_owner_attr(int color)
+{
+	return (A_BOLD | (color ? COLOR_PAIR(P_OWNER) : A_REVERSE));
+}
blob - /dev/null
blob + e49ac5a61b752f0d1b47c30dd6c30d32e7aede06 (mode 644)
--- /dev/null
+++ src/fugu-tty/ui_palette.h
@@ -0,0 +1,35 @@
+/*
+ * 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 UI_PALETTE_H
+#define UI_PALETTE_H
+
+enum ui_pair {
+	P_HEAD = 1,
+	P_CODE,
+	P_ADD,
+	P_DEL,
+	P_TOOL,
+	P_ERR,
+	P_DIM,
+	P_TEXT,
+	P_OWNER
+};
+
+void	ui_palette_init(void);
+int	ui_owner_attr(int);
+
+#endif /* UI_PALETTE_H */