Commit Diff


commit - 41aa0eed73347fe812858fd61ac90ba378a02500
commit + 5c30449123bfdaea256bb979bfd6955bf21cda49
blob - ba26133faea629c2563dd9fddba3ce0a0abd6c87
blob + ab234efe25ba7c9fe57c68c6dd9950036920e21a
--- docs/design/m7-curses.md
+++ docs/design/m7-curses.md
@@ -45,6 +45,11 @@ dependency, each with its own regress suite, before an
   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.
+- **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
+  rows cannot reset Owner-input dismissal and that narrow layouts omit rather
+  than wrap.
 - **diff.c** โ€” a unified-diff formatter, run in the tool executor role
   (below); pure line diff to hunk text; `regress/diff`.
 
@@ -115,6 +120,15 @@ treatment: high-contrast shaded rows on color terminal
 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.
+Until an editor mutation first leaves nonempty input, an empty transcript
+draws the pure welcome layout beneath drawer/model-picker overlays. Captions
+reuse the empty input row and are centered over stable status-field regions;
+they add no geometry. Every route that can replace editor content (typing,
+paste, completion, history, Queue edit, and external compose) passes through
+one observer, so dismissal is one-way and local to fugu-tty. The experimental
+Claude subscription warning stays on stderr in line/print mode but is not
+inserted as a curses transcript item; the durable manuals, `fugu -n`, and
+profile failure diagnostics retain the risk disclosure.
 `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
@@ -160,8 +174,9 @@ exec` for it (and, later, for M9 subagents).
 
 ## The model picker
 
-`/model` overlays every model offered by every configured provider,
-fetched live. The coordinator asks fugu-api (`LIST_MODELS`), which does
+`/model`, or `m` in normal mode, overlays every model offered by every
+configured provider, fetched live. The coordinator asks fugu-api
+(`LIST_MODELS`), which does
 the provider's model-listing GET with the credential it already holds
 and returns the ids; a provider that fails to answer contributes
 nothing and never blocks the overlay. Selecting a model makes its
blob - 5c60dfb315d71a46f754cc8efd17bc2e4597688b
blob + b04d825a68dd535995092237718fa517d72a8127
--- handoff/behavior.md
+++ handoff/behavior.md
@@ -78,6 +78,16 @@ configuration renders the entire interface translitera
 (typographic punctuation to ASCII stand-ins, unknown glyphs to `?`),
 losslessly for markdown structure.
 
+Before the transcript receives Owner input, the empty viewport shows a
+centered, presentation-only Fugu welcome with command hints. A caption row in
+the otherwise empty editor line labels the live agent-state, input-mode, and
+context fields below it. The first editor action that leaves nonempty input
+dismisses both permanently for that invocation; deleting the input, `/clear`,
+resize, failure, and `Ctrl-L` do not restore them. Drawer and picker overlays
+do not turn the welcome into transcript state. It never enters scrollback,
+the Queue, the Projection, or the Journal. Narrow viewports omit whole
+lower-priority rows instead of wrapping into the editor or status bar.
+
 Transcript behavior:
 
 - The assistant's reply streams in as it arrives, appended to the
@@ -151,7 +161,8 @@ Input editing is modal, vi-like, starting in insert mo
   enter insert mode at the usual positions; `x`, `D`, `dd`, and
   `d`+motion (`dw de db d$ d0`) delete; `j`/`k` recall history;
   `J`/`K` scroll the transcript one line (allowed even mid-turn);
-  `v` composes in the external editor (below).
+  `v` composes in the external editor (below); `m` opens the model picker
+  without editing the prompt.
 
 On taking the terminal the curses front end enables xterm basic mouse
 tracking with SGR coordinates and routes wheel reports to transcript
@@ -208,9 +219,9 @@ without leaving the program, `Esc` closes, `Tab` toggl
 drawer and editor. Switching away mid-turn abandons the in-flight turn
 cleanly (ยง7 governs what the abandoned journal must look like).
 
-Model picker (`/model`): an overlay listing every model offered by
-every configured provider, fetched live from each provider's model-
-listing endpoint. Type to fuzzy-filter; `Up`/`Down` (or `^P`/`^N`)
+Model picker (`/model`, or `m` in normal mode): an overlay listing every
+model offered by every configured provider, fetched live from each provider's
+model-listing endpoint. Type to fuzzy-filter; `Up`/`Down` (or `^P`/`^N`)
 move; `Enter` selects; `Esc` cancels. With multiple providers each
 entry is tagged with its provider name, and selecting a model also
 makes its provider active for the rest of the session. A provider that
@@ -659,8 +670,10 @@ back before IPC and still must match the exact Palette
 Generation; model-selected work occurs only in fugu's confined Tool roles.
 
 The compatibility profile is not an Anthropic-supported third-party API and
-may stop working or trigger account enforcement. Startup and failure messages
-name the active profile. fugu never probes alternate fingerprints, evades
+may stop working or trigger account enforcement. Line and print startup
+warnings and profile-specific failure messages name the active profile; the
+curses welcome stays quiet and the durable risk disclosures remain. fugu
+never probes alternate fingerprints, evades
 quota or safety controls, rotates accounts, or silently falls back to a
 billable API Provider or another subscription credential. Direct API routes
 are the supported alternative.
blob - 6279752052e03e05a5c783672834e84a75cbde05
blob + 857a9356989627994510c409c83cc2fb50b15f83
--- handoff/verification.md
+++ handoff/verification.md
@@ -157,6 +157,13 @@ states the property, not just examples:
   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.
+- **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.
 - **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 - 402a81eb1c7aaf5958cf491f6a98afa86a3fcc1c
blob + 0cf83890231a83ed1154edf1ccb795970108e993
--- 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 ui_layout web xmalloc
+	turn_mechanics turn_txn ui_config ui_layout web welcome xmalloc
 
 .include <bsd.subdir.mk>
blob - ed15645241409d09070e027ca8035f9eaf16f64e
blob + ec86d3a9f8a8a8b6fec32b089294ebededae6004
--- regress/README
+++ regress/README
@@ -145,6 +145,10 @@ User transcript/input treatment	ui_layout (attributes,
 					continuation geometry, full-pane fill,
 					UTF-8 and reflow); turn (monochrome pty
 					startup and teardown)
+empty-transcript welcome (2.1)	welcome (copy, layout, ASCII, caption
+					collision, one-way dismissal); turn (pty,
+					Projection/Journal exclusion, warning
+					placement)
 slash commands (behavior.md 3)		turn, print (/clear, /context, /compact,
 					/quit and Skill dispatch); journal
 					(clear replay)
@@ -205,6 +209,7 @@ src/fugu/turn_txn.c			turn_txn (pure ordering plus rea
 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/fugu-tty/welcome.c		welcome; turn (curses PTY path)
 src/common/imsgev.c			imsgev (whole-payload views, checked
 					normal/teardown sends, bounded chunk
 					emission, event failures);
blob - 5cb831a06e63f1c702d3fc803249456ec2d0e736
blob + aca4847ad372cc198d61a8a0a60045a7772aff96
--- regress/editor/editor_test.c
+++ regress/editor/editor_test.c
@@ -329,6 +329,12 @@ main(void)
 
 	/* ---- normal-mode routing keys ---- */
 	ed_init(&e);
+	key(&e, K_ESC);
+	CHECK(ed_feed(&e, K_CHAR, 'm', NULL, 0) == R_MODEL);
+	CHECK(ed_empty(&e));
+	ed_free(&e);
+
+	ed_init(&e);
 	type(&e, "x");
 	key(&e, K_ESC);
 	CHECK(ed_feed(&e, K_CHAR, 'J', NULL, 0) == R_SCROLL_LINE_DOWN);
blob - a4a2bd7e830d1d83a101d771581fd2deb6ab8f7a
blob + 3ec10882f2d980b32cb7df02346c10a2894c223a
--- regress/print/run.sh
+++ regress/print/run.sh
@@ -326,6 +326,9 @@ write_claude_conf
 run_claude_fugu -p "use the Claude subscription tool" -o ndjson
 ok "claude-subscription: complete tool round exits 0" \
     $([ "$rc" -eq 0 ]; echo $?)
+grep -q 'experimental Claude subscription profile claude-code-2.1.185' \
+    "$dir/errout"
+ok "claude-subscription: print mode retains the startup warning" $?
 "$obj/jsoncheck" ndjson "$dir/out"
 ok "claude-subscription: canonical tool events retain NDJSON shape" $?
 grep -q '^2$' "$dir/reqcount" 2>/dev/null && \
blob - 2d93adc805108fb60cec3422bef3465532912cb1
blob + f72ef457acb20e62c42f04774b8755aa10853574
--- regress/turn/ptydrive.c
+++ regress/turn/ptydrive.c
@@ -21,15 +21,21 @@
  * written to the pty master; a NUL byte in the script is a half-second
  * pause, for tests that must wait out a round trip before the next
  * keystroke.  The program's output is drained (and echoed to our stdout
- * for debugging).  We exit with the child's status, or 2 on a timeout.
+ * for debugging).  If PTYDRIVE_CAPTURE names a path prefix, output between
+ * pauses is also written to numbered stage files.  We exit with the child's
+ * status, or 2 on a timeout.
  */
 
 #include <sys/types.h>
 #include <sys/wait.h>
 
 #include <err.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
 #include <poll.h>
 #include <signal.h>
+#include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
 #include <termios.h>
@@ -37,14 +43,50 @@
 #include <unistd.h>
 #include <util.h>
 
+static int
+capture_open(const char *base, int sequence)
+{
+	char	path[PATH_MAX];
+	int	n;
+
+	if (base == NULL)
+		return (-1);
+	n = snprintf(path, sizeof(path), "%s.%d", base, sequence);
+	if (n < 0 || (size_t)n >= sizeof(path))
+		errx(2, "capture path too long");
+	return (open(path, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0600));
+}
+
+static void
+capture_write(int fd, const char *buf, size_t len)
+{
+	ssize_t	n;
+
+	if (fd == -1)
+		return;
+	while (len > 0) {
+		if ((n = write(fd, buf, len)) == -1) {
+			if (errno == EINTR)
+				continue;
+			err(2, "write capture");
+		}
+		if (n == 0)
+			errx(2, "short write to capture");
+		buf += n;
+		len -= (size_t)n;
+	}
+}
+
 int
 main(int argc, char *argv[])
 {
 	struct timespec	 t0, now;
 	struct winsize	 ws;
+	const char	*capture_base;
 	char		 script[8192];
 	ssize_t		 slen, off = 0;
 	long long	 elapsed, pause_until = 0;
+	int		 capture_fd, capture_sequence = 0;
 	int		 mfd, status, deadline_ms = 20000;
 	pid_t		 pid;
 
@@ -54,6 +96,10 @@ main(int argc, char *argv[])
 	memset(&ws, 0, sizeof(ws));
 	ws.ws_row = 40;
 	ws.ws_col = 100;
+	capture_base = getenv("PTYDRIVE_CAPTURE");
+	capture_fd = capture_open(capture_base, capture_sequence);
+	if (capture_base != NULL && capture_fd == -1)
+		err(2, "open capture");
 
 	/* read the whole keystroke script first */
 	slen = read(STDIN_FILENO, script, sizeof(script));
@@ -83,6 +129,14 @@ main(int argc, char *argv[])
 		if (pause_until > 0 && elapsed >= pause_until) {
 			pause_until = 0;
 			off++;			/* the pause is spent */
+			if (capture_fd != -1) {
+				if (close(capture_fd) == -1)
+					err(2, "close capture");
+				capture_fd = capture_open(capture_base,
+				    ++capture_sequence);
+				if (capture_fd == -1)
+					err(2, "open capture");
+			}
 		}
 		if (off < slen && script[off] == '\0' && pause_until == 0)
 			pause_until = elapsed + 500;
@@ -96,6 +150,8 @@ main(int argc, char *argv[])
 			n = read(mfd, buf, sizeof(buf));
 			if (n > 0)
 				(void)write(STDOUT_FILENO, buf, (size_t)n);
+			if (n > 0)
+				capture_write(capture_fd, buf, (size_t)n);
 			else if (n == 0) {	/* pty closed: reap for the status */
 				(void)waitpid(pid, &status, 0);
 				break;
@@ -118,6 +174,8 @@ main(int argc, char *argv[])
 		}
 	}
 
+	if (capture_fd != -1 && close(capture_fd) == -1)
+		err(2, "close capture");
 	if (WIFEXITED(status))
 		return (WEXITSTATUS(status));
 	return (2);
blob - d04352ab3fb3080232ecdb2227fb07527239e0e9
blob + 16f347790f6041328417b70fb5cef7787808e526
--- regress/turn/run.sh
+++ regress/turn/run.sh
@@ -991,9 +991,11 @@ if [ -x "$obj/ptydrive" ]; then
 	rm -rf "$HOME/.fugu"
 	start_stub "CONTEXT"
 	write_conf "$stubport"
-	printf 'OWNER-BLOCK-MARKER\r/quit\r' | TERM=vt100 HOME=$HOME \
+	printf '\000\033o\000\033\014\000m\000\000\033\014\000iO\000WNER-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 \
+	    PTYDRIVE_CAPTURE=$dir/welcome-stage \
 	    "$obj/ptydrive" "$fugu" >"$dir/cursesout" 2>&1
 	rc=$?
 	ok "curses: clean exit on /quit" $([ "$rc" -eq 0 ]; echo $?)
@@ -1007,8 +1009,45 @@ if [ -x "$obj/ptydrive" ]; then
 	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 "I like Fugu" "$dir/welcome-stage.0" &&
+	    grep -a -Fq "by Isaac Meerleo" "$dir/welcome-stage.0" &&
+	    grep -a -Fq "git ssh://anonymous@got.fugu.farm" \
+	    "$dir/welcome-stage.0"
+	ok "curses: empty transcript shows the welcome before input" $?
+	grep -a -Fq "agent state" "$dir/welcome-stage.0" &&
+	    grep -a -Fq "input mode" "$dir/welcome-stage.0" &&
+	    grep -a -Fq "context used / limit" "$dir/welcome-stage.0"
+	ok "curses: welcome captions label the live status fields" $?
+	grep -a -Fq "conversations" "$dir/welcome-stage.1" &&
+	    grep -a -Fq "I like Fugu" "$dir/welcome-stage.1"
+	ok "curses: drawer overlays the undismissed welcome" $?
+	grep -a -Fq "I like Fugu" "$dir/welcome-stage.2" &&
+	    grep -a -Fq "agent state" "$dir/welcome-stage.2"
+	ok "curses: closing the drawer restores the welcome" $?
+	picker_stage=''
+	for stage in 3 4; 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 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"
 	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" \
+	    $([ $? -ne 0 ]; echo $?)
+	grep -q "I like Fugu" "$sessions"/*.ndjson 2>/dev/null
+	ok "curses: welcome is absent from the Journal" \
+	    $([ $? -ne 0 ]; echo $?)
 	n=$(grep -c '"outcome":"ok"' "$sessions"/*.ndjson 2>/dev/null || echo 0)
 	ok "curses: the turn committed to the journal" \
 	    $([ "$n" -ge 1 ]; echo $?)
@@ -1017,6 +1056,52 @@ else
 	fail "curses: ptydrive helper not built"
 fi
 
+# --- 28w: subscription warnings stay outside the curses transcript ---
+# The accepted risk disclosure remains in line/print mode, -n, manuals, and
+# profile failures.  Both an active and an available named Claude route must
+# nevertheless leave the empty curses transcript available to the welcome.
+if [ -x "$obj/ptydrive" ]; then
+	for profile in default named; do
+		rm -rf "$HOME/.fugu"
+		if [ "$profile" = default ]; then
+			cat > "$dir/fugu.conf" <<EOF
+model "claude-sonnet-4-6"
+provider "claude"
+oauth_token "claude-oauth-turn-test"
+project_context no
+EOF
+		else
+			cat > "$dir/fugu.conf" <<EOF
+model "claude-sonnet-4-6"
+provider "anthropic"
+api_key "$KEY"
+project_context no
+provider "personal" {
+	type "claude"
+	model "claude-sonnet-4-6"
+	oauth_token "claude-oauth-turn-test"
+}
+EOF
+		fi
+		printf '\000/quit\r' | TERM=vt100 HOME=$HOME \
+		    FUGU_CONF=$dir/fugu.conf \
+		    FUGU_LIBEXEC=$dir/libexec/fugu \
+		    FUGU_CA_FILE=$dir/cert.pem \
+		    "$obj/ptydrive" "$fugu" >"$dir/profileout" 2>&1
+		rc=$?
+		ok "curses: $profile subscription profile exits cleanly" \
+		    $([ "$rc" -eq 0 ]; echo $?)
+		grep -a -Fq "experimental Claude subscription profile" \
+		    "$dir/profileout"
+		ok "curses: $profile subscription warning is not transcript" \
+		    $([ $? -ne 0 ]; echo $?)
+		grep -a -Fq "I like Fugu" "$dir/profileout"
+		ok "curses: $profile subscription profile retains welcome" $?
+	done
+else
+	fail "curses profile warning: ptydrive helper not built"
+fi
+
 # --- 28a: the full skill palette crosses the imsg payload boundary ---
 # 256 maximum-length names do not fit beside ui_config in one imsg.  The
 # typed config stream must nevertheless deliver the complete palette before
blob - /dev/null
blob + 9726a3322a870f22685c49c806ea47f22db1e398 (mode 644)
--- /dev/null
+++ regress/welcome/Makefile
@@ -0,0 +1,8 @@
+PROG=	welcome_test
+SRCS=	welcome_test.c welcome.c utf8.c
+NOMAN=	yes
+
+CFLAGS+=	-I${.CURDIR}/../../src/fugu-tty
+.PATH:		${.CURDIR}/../../src/fugu-tty
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + faff7f7a2086a2eaa36bae5ff1a68a662739a4db (mode 644)
--- /dev/null
+++ regress/welcome/welcome_test.c
@@ -0,0 +1,141 @@
+/*
+ * 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 <string.h>
+
+#include "welcome.h"
+#include "regress.h"
+
+static const struct welcome_row *
+find_row(const struct welcome_view *view, const char *text)
+{
+	size_t	i;
+
+	for (i = 0; i < view->nrows; i++)
+		if (strcmp(view->rows[i].text, text) == 0)
+			return (&view->rows[i]);
+	return (NULL);
+}
+
+int
+main(void)
+{
+	struct welcome_state		 state = { 0 };
+	struct welcome_view		 view;
+	struct welcome_caption_view captions;
+	struct welcome_field		 fields[WELCOME_CAPTIONS_MAX];
+	const struct welcome_row	*row;
+	size_t				 i;
+
+	CHECK(welcome_visible(&state, 0));
+	CHECK(!welcome_visible(&state, 1));
+	CHECK(welcome_visible(&state, 0));	/* a transient row is not input */
+	welcome_observe(&state, 0);
+	CHECK(welcome_visible(&state, 0));
+	welcome_observe(&state, 1);
+	CHECK(!welcome_visible(&state, 0));
+	welcome_observe(&state, 0);
+	CHECK(!welcome_visible(&state, 0));	/* deletion and /clear do not reset */
+
+	/* The normal viewport carries the exact copy with one group gap. */
+	welcome_build(&view, 0, 100, 30, 0);
+	CHECK(view.nrows == 8);
+	CHECK(strcmp(view.rows[0].text,
+	    "I like Fugu \xe2\x80\x94 Japanese for pufferfish.") == 0);
+	CHECK(view.rows[0].kind == WELCOME_ROW_TITLE);
+	CHECK(strcmp(view.rows[1].text, "by Isaac Meerleo") == 0);
+	CHECK(view.rows[1].kind == WELCOME_ROW_DIM);
+	CHECK(strcmp(view.rows[2].text,
+	    "git ssh://anonymous@got.fugu.farm") == 0);
+	CHECK(strcmp(view.rows[3].text,
+	    "Fugu is open source and freely available!") == 0);
+	CHECK(strcmp(view.rows[4].text,
+	    "type  /<Tab>                  to explore commands") == 0);
+	CHECK(strcmp(view.rows[5].text,
+	    "type  /model<Enter>           to choose a model") == 0);
+	CHECK(strcmp(view.rows[6].text,
+	    "press Esc, then v             to compose in your editor") == 0);
+	CHECK(strcmp(view.rows[7].text,
+	    "type  /quit<Enter>            to exit") == 0);
+	CHECK(view.rows[4].row == view.rows[3].row + 2);
+	for (i = 0; i < view.nrows; i++)
+		CHECK(view.rows[i].col >= 0 &&
+		    view.rows[i].col + view.rows[i].width <= 100);
+
+	/* ASCII mode folds only the em dash, consistently with markdown. */
+	welcome_build(&view, 0, 100, 30, 1);
+	CHECK(strcmp(view.rows[0].text,
+	    "I like Fugu -- Japanese for pufferfish.") == 0);
+
+	/* Drawer offset and resize recenter without changing the copy. */
+	welcome_build(&view, 31, 69, 20, 0);
+	CHECK(view.nrows == 8);
+	for (i = 0; i < view.nrows; i++)
+		CHECK(view.rows[i].col >= 31 &&
+		    view.rows[i].col + view.rows[i].width <= 100);
+	row = find_row(&view, "by Isaac Meerleo");
+	CHECK(row != NULL && row->col == 31 + (69 - row->width) / 2);
+
+	/* Narrow and short viewports omit whole low-priority rows, never wrap. */
+	welcome_build(&view, 0, 100, 4, 0);
+	CHECK(view.nrows == 4);
+	CHECK(strcmp(view.rows[0].text,
+	    "I like Fugu \xe2\x80\x94 Japanese for pufferfish.") == 0);
+	CHECK(strcmp(view.rows[1].text, "by Isaac Meerleo") == 0);
+	CHECK(strcmp(view.rows[2].text,
+	    "git ssh://anonymous@got.fugu.farm") == 0);
+	CHECK(strcmp(view.rows[3].text,
+	    "Fugu is open source and freely available!") == 0);
+	welcome_build(&view, 0, 40, 4, 0);
+	CHECK(view.nrows <= 4);
+	for (i = 0; i < view.nrows; i++)
+		CHECK(view.rows[i].row >= 0 && view.rows[i].row < 4 &&
+		    view.rows[i].width <= 40);
+	welcome_build(&view, 0, 3, 2, 0);
+	CHECK(view.nrows == 0);
+	welcome_build(&view, 0, 100, 1, 0);
+	CHECK(view.nrows == 1 && view.rows[0].row == 0);
+
+	/* Captions center over their live fields and disappear on collision. */
+	fields[WELCOME_AGENT_STATE].col = 4;
+	fields[WELCOME_AGENT_STATE].width = 5;
+	fields[WELCOME_INPUT_MODE].col = 16;
+	fields[WELCOME_INPUT_MODE].width = 8;
+	fields[WELCOME_CONTEXT_LIMIT].col = 81;
+	fields[WELCOME_CONTEXT_LIMIT].width = 18;
+	welcome_caption_build(&captions, 100, fields);
+	CHECK(captions.ncaptions == 3);
+	CHECK(strcmp(captions.captions[0].text, "agent state") == 0 &&
+	    captions.captions[0].col == 1);
+	CHECK(strcmp(captions.captions[1].text, "input mode") == 0 &&
+	    captions.captions[1].col == 15);
+	CHECK(strcmp(captions.captions[2].text,
+	    "context used / limit") == 0 && captions.captions[2].col == 80);
+
+	fields[WELCOME_AGENT_STATE].col = 0;
+	fields[WELCOME_AGENT_STATE].width = 5;
+	fields[WELCOME_INPUT_MODE].col = 7;
+	fields[WELCOME_INPUT_MODE].width = 8;
+	fields[WELCOME_CONTEXT_LIMIT].col = 15;
+	fields[WELCOME_CONTEXT_LIMIT].width = 8;
+	welcome_caption_build(&captions, 24, fields);
+	CHECK(captions.ncaptions < 3);
+	for (i = 0; i < captions.ncaptions; i++)
+		CHECK(captions.captions[i].col >= 0 &&
+		    captions.captions[i].col + captions.captions[i].width <= 24);
+
+	REGRESS_END();
+}
blob - ded274898ad56b8359bd113ed666fd6b5169d436
blob + 9a92356bbf6c44202ee32acb8d09452d52504700
--- src/fugu/coord.c
+++ src/fugu/coord.c
@@ -1591,6 +1591,24 @@ configured_provider_name(const struct coord *c, int sl
 	return (NULL);
 }
 
+/* Start one live model listing; normal-mode `m` keeps it presentation-only. */
+static void
+model_list_begin(struct coord *c, int announce)
+{
+	if (c->models_collecting) {
+		if (announce)
+			emit_note(c, "already fetching the model list...");
+		return;
+	}
+	free(c->listed_models);
+	c->listed_models = NULL;
+	c->nlisted_models = 0;
+	c->models_collecting = 1;
+	imsgev_send(&c->iev[ROLE_API], FUGU_IMSG_LIST_MODELS, 0, NULL, 0);
+	if (announce)
+		emit_note(c, "fetching the model list...");
+}
+
 /* Input events from the curses front end. */
 static void
 ui_event(struct coord *c, struct imsg *imsg)
@@ -1680,6 +1698,11 @@ ui_event(struct coord *c, struct imsg *imsg)
 			event_loopexit(NULL);
 		}
 		break;
+	case FUGU_IMSG_UI_LIST_MODELS:
+		if (p != NULL || len != 0)
+			fatalx("bad model-list request");
+		model_list_begin(c, 0);
+		break;
 	case FUGU_IMSG_UI_SELECT_MODEL: {
 		struct model_sel	 sel;
 		struct fugu_provider	*fp;
@@ -1899,6 +1922,7 @@ allowed_from(enum fugu_role role, uint32_t type)
 	case FUGU_IMSG_UI_STEER:
 	case FUGU_IMSG_UI_STEER_END:
 	case FUGU_IMSG_UI_SWITCH:
+	case FUGU_IMSG_UI_LIST_MODELS:
 	case FUGU_IMSG_UI_SELECT_MODEL:
 	case FUGU_IMSG_UI_COMPOSE:
 	case FUGU_IMSG_UI_COMPOSE_GO:
@@ -2191,6 +2215,7 @@ coord_dispatch(int fd, short event, void *arg)
 		case FUGU_IMSG_UI_STEER:
 		case FUGU_IMSG_UI_STEER_END:
 		case FUGU_IMSG_UI_SWITCH:
+		case FUGU_IMSG_UI_LIST_MODELS:
 		case FUGU_IMSG_UI_SELECT_MODEL:
 			ui_event(coord, &imsg);
 			break;
@@ -4034,15 +4059,9 @@ line_command(struct coord *c, const char *line, int *t
 		 * overlay opens in fugu-tty when the list lands.  Line
 		 * mode politely refuses (2.2).
 		 */
-		if (c->ui) {
-			free(c->listed_models);
-			c->listed_models = NULL;
-			c->nlisted_models = 0;
-			c->models_collecting = 1;
-			imsgev_send(&c->iev[ROLE_API],
-			    FUGU_IMSG_LIST_MODELS, 0, NULL, 0);
-			emit_note(c, "fetching the model list...");
-		} else
+		if (c->ui)
+			model_list_begin(c, 1);
+		else
 			fprintf(stderr, "fugu: /model is interactive-only\n");
 		if (!c->ui && turn_status != NULL)
 			*turn_status = -2;
@@ -4503,17 +4522,14 @@ coord_run(struct fugu_conf *cf, const char *prompt, si
 	turn_jobs.commit = turn_jobs_commit;
 	turn_jobs.abort = turn_jobs_abort;
 	c.turn = turn_txn_new(c.journal, &seed, &turn_jobs);
-	if (claude_subscription_configured(cf)) {
+	if (claude_subscription_configured(cf) && !c.ui) {
 		static const char warning[] =
 		    "experimental Claude subscription profile "
 		    CLAUDE_SUB_PROFILE_ID " (snapshot " CLAUDE_SUB_PROFILE_DATE
 		    "): unofficial; it may stop working "
 		    "or trigger account enforcement";
 
-		if (c.ui)
-			emit_note(&c, "%s", warning);
-		else
-			fprintf(stderr, "fugu: warning: %s\n", warning);
+		fprintf(stderr, "fugu: warning: %s\n", warning);
 	}
 
 	if (prompt != NULL) {
blob - ac80260ee463e3dcd4a63f24b029ef2cae430ac0
blob + e49cacbcde9a144630ae9fdc3c81335d4efbdb3d
--- src/fugu/fugu.1
+++ src/fugu/fugu.1
@@ -138,6 +138,21 @@ May be repeated for debug-level logging.
 .Sh CURSES INTERFACE
 The curses interface displays a scrolling transcript, a one-line modal
 editor, and a status bar.
+While the transcript is empty and the Owner has not begun editing input, it
+shows a centered Fugu welcome and a temporary caption row identifying the
+live status fields.
+The first editing action that leaves nonempty input dismisses the welcome for
+the invocation; clearing input, resizing, or
+.Cm /clear
+does not restore it.
+The welcome is presentation only and is never sent to a Provider or written to
+the Journal.
+An experimental Claude subscription profile continues to print its startup
+warning on standard error in line and print modes.
+The warning is omitted from the curses transcript; explicit risk disclosure
+remains in this manual, the
+.Fl n
+dry-run output, and profile-specific failure diagnostics.
 Assistant markdown headings, lists, fenced code, emphasis, and inline code
 are rendered with terminal attributes.
 The Owner's User messages share a shaded, high-contrast treatment with the
@@ -212,6 +227,8 @@ suspends the display and invokes
 or
 .Xr vi 1 ,
 in that order, in a separately confined process.
+.Ic m
+opens the model picker without editing the prompt.
 The edited text replaces the input only when the editor exits successfully.
 .
 .Pp
@@ -247,7 +264,9 @@ Switching during a turn abandons that turn transaction
 .Pp
 The
 .Cm /model
-command opens a live model picker.
+command, or
+.Ic m
+in normal mode, opens a live model picker.
 It queries all configured providers, tags results by provider, supports fuzzy
 filtering, and uses the arrow keys or
 .Ic Ctrl-P , Ctrl-N
blob - 6c69d306111f7d24e254e16cf6e009bf954d370b
blob + 7b8578e1ec86394caa443f5a5995fcec73cd9bb9
--- src/fugu/proto.h
+++ src/fugu/proto.h
@@ -265,6 +265,7 @@ enum fugu_imsg {
 	FUGU_IMSG_SET_PROVIDER,	/* coordinator -> api: struct model_sel */
 	FUGU_IMSG_UI_MODEL,	/* coordinator -> tty: relayed MODEL */
 	FUGU_IMSG_UI_MODELS_END,	/* coordinator -> tty: relayed end */
+	FUGU_IMSG_UI_LIST_MODELS,	/* tty -> coordinator: open picker */
 	FUGU_IMSG_UI_SELECT_MODEL,	/* tty -> coordinator: model_sel + id */
 
 	/* A web call is sequential like a file-tool call, but uses distinct
blob - 31c9a5089db29c5e37b47f4841a1ed13e884411b
blob + 3d338f1558d354235e859836b6c17d91327b78e2
--- src/fugu-tty/Makefile
+++ src/fugu-tty/Makefile
@@ -1,6 +1,7 @@
 PROG=	fugu-tty
-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
+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
 BINDIR=	${LIBEXECDIR}/fugu
 MAN=
 
blob - 5b067769c083e34d32f84fed895da3b0e9ba0742
blob + 7ceca5556b0eebac5415e0f3df17f4faba18826d
--- src/fugu-tty/editor.c
+++ src/fugu-tty/editor.c
@@ -778,6 +778,8 @@ feed_normal(struct editor *e, enum ed_key k, uint32_t 
 		return (R_DRAWER);
 	case 'q':
 		return (R_QUEUE);
+	case 'm':
+		return (R_MODEL);
 	default:
 		break;
 	}
blob - 22dacce44c398472f114e73eda2ebf38ea3dd66b
blob + c053afb616e32c0c496b53e6e50b136e60a78c14
--- src/fugu-tty/editor.h
+++ src/fugu-tty/editor.h
@@ -78,7 +78,8 @@ enum ed_result {
 	R_CLEAR_SCREEN,	/* Ctrl-L: clear the displayed transcript */
 	R_COMPOSE,	/* v: run the external editor on ed_text */
 	R_DRAWER,	/* o: open the conversation drawer */
-	R_QUEUE		/* q: focus the pending-message queue */
+	R_QUEUE,	/* q: focus the pending-message queue */
+	R_MODEL		/* m: open the model picker */
 };
 
 enum ed_paste_status { PASTE_OK = 0, PASTE_NUL, PASTE_OVERSIZE };
blob - b20c911fc66c960252025317d69aa62cca89493c
blob + d4568d6e529f82144f31100410917050e0ef7a0f
--- src/fugu-tty/ui.c
+++ src/fugu-tty/ui.c
@@ -39,6 +39,7 @@
 #include "ui_config_rx.h"
 #include "ui_layout.h"
 #include "ui_palette.h"
+#include "welcome.h"
 #include "ui.h"
 
 /* transcript item kinds */
@@ -98,6 +99,7 @@ struct ui {
 
 	/* input */
 	struct editor		 ed;
+	struct welcome_state	 welcome;
 	struct term_input	 ti;
 	char			**comps;
 	size_t			 ncomps;
@@ -145,6 +147,10 @@ struct ui {
 
 static struct ui	*UI;		/* for the atexit terminal restore */
 
+struct status_fields {
+	struct welcome_field	 field[WELCOME_CAPTIONS_MAX];
+};
+
 /* Terminal modes owned by fugu-tty.  Disable in reverse order so every
  * survivable teardown and external-editor handoff restores the terminal. */
 static const char terminal_modes_on[] =
@@ -187,6 +193,18 @@ md_attr(const struct ui *u, int md)
 	return (a);
 }
 
+/* Every editor mutation passes here, making welcome dismissal one-way. */
+static enum ed_result
+editor_feed(struct ui *u, enum ed_key key, uint32_t cp, const char *paste,
+    size_t pastelen)
+{
+	enum ed_result	result;
+
+	result = ed_feed(&u->ed, key, cp, paste, pastelen);
+	welcome_observe(&u->welcome, !ed_empty(&u->ed));
+	return (result);
+}
+
 /* The transcript wrap width: an open drawer narrows it (2.1). */
 static int
 wrap_width(const struct ui *u)
@@ -447,38 +465,45 @@ draw_input(struct ui *u, int row)
 	move(row, curscol);		/* leave the hardware cursor here */
 }
 
-static void
+static int
 fmt_token_count(int64_t tokens, char *out, size_t sz)
 {
 	int64_t rounded;
+	int	n;
 
 	if (tokens < 1000) {
-		snprintf(out, sz, "%lld", (long long)tokens);
-		return;
+		n = snprintf(out, sz, "%lld", (long long)tokens);
+		return (n < 0 || (size_t)n >= sz ? -1 : 0);
 	}
 	rounded = tokens / 1000 + (tokens % 1000 >= 500);
-	snprintf(out, sz, "%lldk", (long long)rounded);
+	n = snprintf(out, sz, "%lldk", (long long)rounded);
+	return (n < 0 || (size_t)n >= sz ? -1 : 0);
 }
 
-static void
+static int
 fmt_context(const struct ui *u, char *out, size_t sz)
 {
 	char current[32], maximum[32];
+	int n;
 
-	fmt_token_count(u->status.context_tokens, current, sizeof(current));
+	if (fmt_token_count(u->status.context_tokens, current,
+	    sizeof(current)) == -1)
+		return (-1);
 	if (u->window > 0) {
 		int64_t q, tail, pct;
 
-		fmt_token_count(u->window, maximum, sizeof(maximum));
+		if (fmt_token_count(u->window, maximum, sizeof(maximum)) == -1)
+			return (-1);
 		q = u->status.context_tokens / u->window;
 		tail = (u->status.context_tokens % u->window) * 100 /
 		    u->window;
 		pct = q > (INT64_MAX - tail) / 100 ? INT64_MAX :
 		    q * 100 + tail;
-		snprintf(out, sz, "Context %s/%s %lld%%", current, maximum,
+		n = snprintf(out, sz, "Context %s/%s %lld%%", current, maximum,
 		    (long long)pct);
 	} else
-		snprintf(out, sz, "Context %s/?", current);
+		n = snprintf(out, sz, "Context %s/?", current);
+	return (n < 0 || (size_t)n >= sz ? -1 : 0);
 }
 
 static int
@@ -492,71 +517,183 @@ context_warn(const struct ui *u)
 	return (u->status.context_tokens >= threshold);
 }
 
-static void
-draw_status(struct ui *u, int row)
+static int
+status_text(const struct ui *u, char *state, size_t statesz, char *mode,
+    size_t modesz)
 {
-	char	left[128], right[160], context[96];
-	int	warn;
+	const char	*m;
+	int		 n;
 
 	switch (u->status.state) {
 	case UI_BUSY:
-		snprintf(left, sizeof(left), "working");
+		if (strlcpy(state, "working", statesz) >= statesz)
+			return (-1);
 		break;
 	case UI_SUMMARIZE:
-		snprintf(left, sizeof(left), "summarizing");
+		if (strlcpy(state, "summarizing", statesz) >= statesz)
+			return (-1);
 		break;
 	case UI_RETRY:
-		snprintf(left, sizeof(left), "retrying (attempt %d, %.1fs)",
+		n = snprintf(state, statesz, "retrying (attempt %d, %.1fs)",
 		    u->status.attempt, u->status.delay_ms / 1000.0);
+		if (n < 0 || (size_t)n >= statesz)
+			return (-1);
 		break;
 	default:
-		snprintf(left, sizeof(left), "ready");
+		if (strlcpy(state, "ready", statesz) >= statesz)
+			return (-1);
 		break;
 	}
-	strlcat(left, u->drawer_focus ? "  [drawer]" :
-	    u->qfocus ? "  [queue]" :
-	    u->ed.mode == ED_NORMAL ? "  [normal]" : "  [insert]",
-	    sizeof(left));
-	if (u->scroll != -1)
-		strlcat(left, "  -- scrolled (End to return) --", sizeof(left));
-	if (u->nqueue > 0) {
-		char	q[32];
+	m = u->drawer_focus ? "[drawer]" :
+	    u->qfocus ? "[queue]" :
+	    u->ed.mode == ED_NORMAL ? "[normal]" : "[insert]";
+	if (strlcpy(mode, m, modesz) >= modesz)
+		return (-1);
+	return (0);
+}
 
-		snprintf(q, sizeof(q), "  q:%zu", u->nqueue);
-		strlcat(left, q, sizeof(left));
-	}
+static void
+draw_status(struct ui *u, int row, int expanded, struct status_fields *fields)
+{
+	char	 state[64], mode[16], left[128], right[160], context[96];
+	char	 q[32];
+	size_t	 len;
+	int	 attr, col, context_value, modelen, n, rightcol, statelen, warn;
 
+	memset(fields, 0, sizeof(*fields));
+	if (status_text(u, state, sizeof(state), mode, sizeof(mode)) == -1)
+		return;
+	statelen = (int)strlen(state);
+	modelen = (int)strlen(mode);
 	right[0] = '\0';
-	if (u->status.have_context) {
-		fmt_context(u, context, sizeof(context));
-		strlcpy(right, context, sizeof(right));
+	context_value = 0;
+	/* The welcome explains the live field before the first Generation. */
+	if ((u->status.have_context || expanded) &&
+	    fmt_context(u, context, sizeof(context)) == 0 &&
+	    strlcpy(right, context, sizeof(right)) < sizeof(right)) {
+		context_value = 1;
 	}
-	if (u->notice[0] != '\0')
-		strlcpy(right, u->notice, sizeof(right));
+	if (u->notice[0] != '\0') {
+		if (strlcpy(right, u->notice, sizeof(right)) >= sizeof(right))
+			right[0] = '\0';
+		context_value = 0;
+	}
 	warn = context_warn(u);
+	attr = warn ? (u->color ? COLOR_PAIR(P_ERR) : A_BOLD) : A_REVERSE;
 
 	move(row, 0);
-	attrset(warn ? (u->color ? COLOR_PAIR(P_ERR) : A_BOLD) : A_REVERSE);
-	if (!warn)
-		attrset(A_REVERSE);
-	{
-		char	bar[512];
-		int	pad;
+	attrset(attr);
+	hline(' ', u->cols);
+	rightcol = u->cols - (int)strlen(right) - 1;
+	/* Below this width the ordinary status geometry is more useful. */
+	if (expanded && right[0] != '\0' && rightcol > 27) {
+		/* Leave stable caption regions above the live state and mode. */
+		col = 1 + (11 - (statelen < 11 ? statelen : 11)) / 2;
+		if (col < u->cols) {
+			mvaddnstr(row, col, state,
+			    statelen < 11 ? statelen : 11);
+			fields->field[WELCOME_AGENT_STATE].col = col;
+			fields->field[WELCOME_AGENT_STATE].width =
+			    statelen < 11 ? statelen : 11;
+		}
+		col = 15 + (10 - (modelen < 10 ? modelen : 10)) / 2;
+		if (col < u->cols) {
+			mvaddnstr(row, col, mode,
+			    modelen < 10 ? modelen : 10);
+			fields->field[WELCOME_INPUT_MODE].col = col;
+			fields->field[WELCOME_INPUT_MODE].width =
+			    modelen < 10 ? modelen : 10;
+		}
+		mvaddnstr(row, rightcol, right, (int)strlen(right));
+		if (context_value) {
+			fields->field[WELCOME_CONTEXT_LIMIT].col = rightcol;
+			fields->field[WELCOME_CONTEXT_LIMIT].width =
+			    (int)strlen(right);
+		}
+	} else {
+		n = snprintf(left, sizeof(left), "%s  %s", state, mode);
+		if (n < 0 || (size_t)n >= sizeof(left))
+			left[0] = '\0';
+		if (u->scroll != -1 && strlcat(left,
+		    "  -- scrolled (End to return) --", sizeof(left)) >=
+		    sizeof(left))
+			left[sizeof(left) - 1] = '\0';
+		if (u->nqueue > 0) {
+			n = snprintf(q, sizeof(q), "  q:%zu", u->nqueue);
+			if (n >= 0 && (size_t)n < sizeof(q) &&
+			    strlcat(left, q, sizeof(left)) >= sizeof(left))
+				left[sizeof(left) - 1] = '\0';
+		}
+		if (u->cols > 1)
+			mvaddnstr(row, 1, left, u->cols - 1);
+		if (right[0] != '\0' && rightcol > (int)strlen(left) + 2)
+			mvaddnstr(row, rightcol, right, u->cols - rightcol);
 
-		snprintf(bar, sizeof(bar), " %s", left);
-		pad = u->cols - (int)strlen(bar) - (int)strlen(right) - 1;
-		if (pad < 1)
-			pad = 1;
-		clrtoeol();
-		mvaddnstr(row, 0, bar, u->cols);
-		if (right[0] != '\0' &&
-		    (int)strlen(bar) + pad + (int)strlen(right) < u->cols)
-			mvaddnstr(row, (int)strlen(bar) + pad, right,
-			    u->cols - (int)strlen(bar) - pad);
+		len = strlen(state);
+		fields->field[WELCOME_AGENT_STATE].col = 1;
+		fields->field[WELCOME_AGENT_STATE].width =
+		    len < (size_t)(u->cols - 1) ? (int)len : u->cols - 1;
+		col = 1 + statelen + 2;
+		if (col < u->cols) {
+			fields->field[WELCOME_INPUT_MODE].col = col;
+			fields->field[WELCOME_INPUT_MODE].width =
+			    modelen < u->cols - col ? modelen : u->cols - col;
+		}
+		if (context_value && right[0] != '\0' &&
+		    rightcol > (int)strlen(left) + 2) {
+			fields->field[WELCOME_CONTEXT_LIMIT].col = rightcol;
+			fields->field[WELCOME_CONTEXT_LIMIT].width =
+			    (int)strlen(right);
+		}
 	}
 	attrset(A_NORMAL);
 }
 
+static void
+draw_welcome(const struct ui *u, int xoff, int height)
+{
+	struct welcome_view	view;
+	size_t			i;
+
+	welcome_build(&view, xoff, u->cols - xoff, height, u->ascii);
+	for (i = 0; i < view.nrows; i++) {
+		const struct welcome_row *row = &view.rows[i];
+		int	attr;
+
+		switch (row->kind) {
+		case WELCOME_ROW_TITLE:
+			attr = A_BOLD | (u->color ? COLOR_PAIR(P_HEAD) : 0);
+			break;
+		case WELCOME_ROW_DIM:
+			attr = u->color ? COLOR_PAIR(P_DIM) : A_DIM;
+			break;
+		default:
+			attr = u->color ? COLOR_PAIR(P_TEXT) : A_NORMAL;
+			break;
+		}
+		attrset(attr);
+		mvaddnstr(row->row, row->col, row->text, (int)row->len);
+	}
+	attrset(A_NORMAL);
+}
+
+static void
+draw_welcome_captions(const struct ui *u, int row,
+    const struct status_fields *fields)
+{
+	struct welcome_caption_view	view;
+	size_t				i;
+
+	welcome_caption_build(&view, u->cols, fields->field);
+	attrset(ui_owner_attr(u->color));
+	for (i = 0; i < view.ncaptions; i++) {
+		const struct welcome_caption *caption = &view.captions[i];
+
+		mvaddnstr(row, caption->col, caption->text, (int)caption->len);
+	}
+	attrset(A_NORMAL);
+}
+
 /* A coarse relative age for the drawer (behavior.md 2.1). */
 static void
 fmt_age(int64_t mtime, char *out, size_t outsz)
@@ -751,8 +888,9 @@ tail_top(const struct ui *u, int height)
 static void
 draw(struct ui *u)
 {
+	struct status_fields	fields;
 	int	th = transcript_height(u);
-	int	top, r, xoff = u->drawer ? DRAWER_W + 1 : 0;
+	int	show_welcome, top, r, xoff = u->drawer ? DRAWER_W + 1 : 0;
 
 	if (u->composing)
 		return;			/* the editor room has the terminal */
@@ -764,6 +902,8 @@ draw(struct ui *u)
 	}
 	if (u->dirty)
 		rebuild(u);
+	show_welcome = u->ready &&
+	    welcome_visible(&u->welcome, u->layout.nlines);
 
 	if (u->scroll == -1)
 		top = tail_top(u, th);
@@ -781,15 +921,23 @@ draw(struct ui *u)
 		if (li >= 0 && li < (int)u->layout.nlines)
 			draw_line(u, r, &u->layout.lines[li], xoff);
 	}
+	if (show_welcome)
+		draw_welcome(u, xoff, th);
 	draw_input(u, th);
 	draw_queue(u, th + 1);
-	draw_status(u, u->rows - 1);
+	draw_status(u, u->rows - 1, show_welcome, &fields);
+	if (show_welcome)
+		draw_welcome_captions(u, th, &fields);
 	if (u->drawer)
 		draw_drawer(u);
 	if (u->picker)
 		draw_picker(u);
-	if (!u->drawer_focus && !u->picker && !u->qfocus)
-		draw_input(u, th);	/* re-place the cursor last */
+	if (!u->drawer_focus && !u->picker && !u->qfocus) {
+		if (show_welcome)
+			move(th, 0);
+		else
+			draw_input(u, th);	/* re-place the cursor last */
+	}
 	refresh();
 }
 
@@ -1117,7 +1265,7 @@ queue_key(struct ui *u, enum ed_key k, uint32_t cp)
 		qremove(u, u->qsel, &b);
 		ed_clear(&u->ed);
 		if (b.len > 0)
-			(void)ed_feed(&u->ed, K_PASTE, 0,
+			(void)editor_feed(u, K_PASTE, 0,
 			    (const char *)b.data, b.len);
 		buf_free(&b);
 		u->qfocus = 0;
@@ -1192,6 +1340,11 @@ handle_result(struct ui *u, enum ed_result r)
 			strlcpy(u->notice, "the queue is empty",
 			    sizeof(u->notice));
 		break;
+	case R_MODEL:
+		u->notice[0] = '\0';
+		imsgev_send(&u->w->iev, FUGU_IMSG_UI_LIST_MODELS, 0,
+		    NULL, 0);
+		break;
 	case R_NONE:
 		break;
 	}
@@ -1217,7 +1370,7 @@ drain_input(struct ui *u)
 				    "paste too large (64K); discarded",
 				    sizeof(u->notice));
 			else
-				handle_result(u, ed_feed(&u->ed, K_PASTE, 0,
+				handle_result(u, editor_feed(u, K_PASTE, 0,
 				    (const char *)ti_paste_data(&u->ti),
 				    ti_paste_len(&u->ti)));
 		} else if (u->picker)
@@ -1229,7 +1382,7 @@ drain_input(struct ui *u)
 		else if (u->qfocus)
 			queue_key(u, k.key, k.cp);
 		else
-			handle_result(u, ed_feed(&u->ed, k.key, k.cp, NULL, 0));
+			handle_result(u, editor_feed(u, k.key, k.cp, NULL, 0));
 	}
 	arm_esc(u);			/* a lone ESC resolves on a short timer */
 }
@@ -1286,7 +1439,15 @@ on_esc(int fd, short ev, void *arg)
 	(void)ev;
 	u->esc_pending = 0;
 	if (ti_flush_esc(&u->ti, &k)) {
-		handle_result(u, ed_feed(&u->ed, k.key, k.cp, NULL, 0));
+		if (u->picker)
+			pick_key(u, k.key, k.cp);
+		else if (u->drawer_focus)
+			drawer_key(u, k.key, k.cp);
+		else if (u->qfocus)
+			queue_key(u, k.key, k.cp);
+		else
+			handle_result(u,
+			    editor_feed(u, k.key, k.cp, NULL, 0));
 		draw(u);
 	}
 }
@@ -1369,7 +1530,7 @@ compose_end(struct ui *u, const struct edit_result *r)
 			rlen--;
 		ed_clear(&u->ed);
 		if (rlen > 0)
-			(void)ed_feed(&u->ed, K_PASTE, 0,
+			(void)editor_feed(u, K_PASTE, 0,
 			    (const char *)u->compose.data, rlen);
 	}
 	buf_reset(&u->compose);
blob - /dev/null
blob + 08bf7571eb0a391928f20d47fc251059e0f3d4f0 (mode 644)
--- /dev/null
+++ src/fugu-tty/welcome.c
@@ -0,0 +1,199 @@
+/*
+ * 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 <string.h>
+
+#include "utf8.h"
+#include "welcome.h"
+
+struct welcome_candidate {
+	const char	*text;
+	const char	*ascii;
+	enum welcome_row_kind kind;
+	int		 group;
+	int		 priority;
+};
+
+static const struct welcome_candidate candidates[WELCOME_ROWS_MAX] = {
+	{ "I like Fugu \xe2\x80\x94 Japanese for pufferfish.",
+	  "I like Fugu -- Japanese for pufferfish.", WELCOME_ROW_TITLE, 0, 0 },
+	{ "by Isaac Meerleo", NULL, WELCOME_ROW_DIM, 0, 1 },
+	{ "git ssh://anonymous@got.fugu.farm", NULL, WELCOME_ROW_DIM, 0, 2 },
+	{ "Fugu is open source and freely available!", NULL,
+	  WELCOME_ROW_TEXT, 0, 3 },
+	{ "type  /<Tab>                  to explore commands", NULL,
+	  WELCOME_ROW_TEXT, 1, 5 },
+	{ "type  /model<Enter>           to choose a model", NULL,
+	  WELCOME_ROW_TEXT, 1, 7 },
+	{ "press Esc, then v             to compose in your editor", NULL,
+	  WELCOME_ROW_TEXT, 1, 8 },
+	{ "type  /quit<Enter>            to exit", NULL,
+	  WELCOME_ROW_TEXT, 1, 6 }
+};
+
+static int
+text_width(const char *text)
+{
+	const unsigned char	*p = (const unsigned char *)text;
+	size_t			 i, len, adv;
+	uint32_t		 cp;
+	int			 width = 0, w;
+
+	len = strlen(text);
+	for (i = 0; i < len; i += adv) {
+		adv = utf8_decode(p + i, len - i, &cp);
+		w = utf8_width(cp);
+		if (w > 0)
+			width += w;
+	}
+	return (width);
+}
+
+void
+welcome_observe(struct welcome_state *state, int nonempty)
+{
+	if (nonempty)
+		state->dismissed = 1;
+}
+
+int
+welcome_visible(const struct welcome_state *state, size_t transcript_rows)
+{
+	return (!state->dismissed && transcript_rows == 0);
+}
+
+static void
+selection_counts(const int keep[WELCOME_ROWS_MAX], int *count, int *groups)
+{
+	int	i, seen[2] = { 0, 0 };
+
+	*count = 0;
+	for (i = 0; i < WELCOME_ROWS_MAX; i++) {
+		if (!keep[i])
+			continue;
+		(*count)++;
+		seen[candidates[i].group] = 1;
+	}
+	*groups = seen[0] + seen[1];
+}
+
+void
+welcome_build(struct welcome_view *view, int xoff, int width, int height,
+    int ascii)
+{
+	const char	*text[WELCOME_ROWS_MAX];
+	int		 cells[WELCOME_ROWS_MAX], keep[WELCOME_ROWS_MAX];
+	int		 count, gap, groups, i, last_group, row, total, victim;
+
+	memset(view, 0, sizeof(*view));
+	if (xoff < 0 || width <= 0 || height <= 0)
+		return;
+	for (i = 0; i < WELCOME_ROWS_MAX; i++) {
+		text[i] = ascii && candidates[i].ascii != NULL ?
+		    candidates[i].ascii : candidates[i].text;
+		cells[i] = text_width(text[i]);
+		keep[i] = cells[i] <= width;
+	}
+
+	for (;;) {
+		selection_counts(keep, &count, &groups);
+		gap = groups == 2 && count + 1 <= height;
+		total = count + gap;
+		if (total <= height)
+			break;
+		victim = -1;
+		for (i = 0; i < WELCOME_ROWS_MAX; i++) {
+			if (keep[i] && (victim == -1 ||
+			    candidates[i].priority >= candidates[victim].priority))
+				victim = i;
+		}
+		if (victim == -1)
+			return;
+		keep[victim] = 0;
+	}
+	if (count == 0)
+		return;
+
+	row = (height - total) / 2;
+	last_group = -1;
+	for (i = 0; i < WELCOME_ROWS_MAX; i++) {
+		struct welcome_row	*out;
+
+		if (!keep[i])
+			continue;
+		if (gap && last_group == 0 && candidates[i].group == 1)
+			row++;
+		out = &view->rows[view->nrows++];
+		out->text = text[i];
+		out->len = strlen(text[i]);
+		out->kind = candidates[i].kind;
+		out->row = row++;
+		out->width = cells[i];
+		out->col = xoff + (width - cells[i]) / 2;
+		last_group = candidates[i].group;
+	}
+}
+
+static int
+caption_overlaps(const struct welcome_caption_view *view, int col, int width)
+{
+	size_t	i;
+
+	for (i = 0; i < view->ncaptions; i++) {
+		const struct welcome_caption *caption = &view->captions[i];
+
+		if (col < caption->col + caption->width &&
+		    caption->col < col + width)
+			return (1);
+	}
+	return (0);
+}
+
+void
+welcome_caption_build(struct welcome_caption_view *view, int cols,
+    const struct welcome_field fields[WELCOME_CAPTIONS_MAX])
+{
+	static const char *labels[WELCOME_CAPTIONS_MAX] = {
+		"agent state", "input mode", "context used / limit"
+	};
+	int	col, i, width;
+
+	memset(view, 0, sizeof(*view));
+	if (cols <= 0)
+		return;
+	for (i = 0; i < WELCOME_CAPTIONS_MAX; i++) {
+		struct welcome_caption *caption;
+
+		width = (int)strlen(labels[i]);
+		if (fields[i].width <= 0 || width > cols)
+			continue;
+		col = fields[i].col + (fields[i].width - width) / 2;
+		if (col < 0)
+			col = 0;
+		if (col + width > cols)
+			col = cols - width;
+		if (caption_overlaps(view, col, width))
+			continue;
+		caption = &view->captions[view->ncaptions++];
+		caption->text = labels[i];
+		caption->len = strlen(labels[i]);
+		caption->col = col;
+		caption->width = width;
+	}
+}
blob - /dev/null
blob + f34769d92fabad12fad2c073772cf932b44a1cd4 (mode 644)
--- /dev/null
+++ src/fugu-tty/welcome.h
@@ -0,0 +1,78 @@
+/*
+ * 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 WELCOME_H
+#define WELCOME_H
+
+#include <stddef.h>
+
+#define WELCOME_ROWS_MAX	8
+#define WELCOME_CAPTIONS_MAX	3
+
+enum welcome_caption_kind {
+	WELCOME_AGENT_STATE,
+	WELCOME_INPUT_MODE,
+	WELCOME_CONTEXT_LIMIT
+};
+
+enum welcome_row_kind {
+	WELCOME_ROW_TITLE,
+	WELCOME_ROW_DIM,
+	WELCOME_ROW_TEXT
+};
+
+struct welcome_state {
+	int	 dismissed;
+};
+
+struct welcome_row {
+	const char	*text;
+	size_t		 len;
+	enum welcome_row_kind kind;
+	int		 row;
+	int		 col;
+	int		 width;
+};
+
+struct welcome_view {
+	struct welcome_row	 rows[WELCOME_ROWS_MAX];
+	size_t			 nrows;
+};
+
+struct welcome_field {
+	int	 col;
+	int	 width;
+};
+
+struct welcome_caption {
+	const char	*text;
+	size_t		 len;
+	int		 col;
+	int		 width;
+};
+
+struct welcome_caption_view {
+	struct welcome_caption	 captions[WELCOME_CAPTIONS_MAX];
+	size_t			 ncaptions;
+};
+
+void	welcome_observe(struct welcome_state *, int);
+int	welcome_visible(const struct welcome_state *, size_t);
+void	welcome_build(struct welcome_view *, int, int, int, int);
+void	welcome_caption_build(struct welcome_caption_view *, int,
+	    const struct welcome_field [WELCOME_CAPTIONS_MAX]);
+
+#endif /* WELCOME_H */