Commit Diff


commit - 1ea23e34d9f698ee5ed967301a83ed578127e7a4
commit + 91b632582edaf81718fbffc9193f46706b137bd2
blob - 40d97bbcfbdff451c686c1743d770ecd648b8a54
blob + cb2cb4504dfaeeff1442b53c94c8e823f6989863
--- regress/Makefile
+++ regress/Makefile
@@ -1,4 +1,5 @@
 SUBDIR=	agentcfg anthropic buf conf deploy diff editor generation http imsgev journal json log markdown \
-	model_window openai output print sandbox skills sse term tools turn web xmalloc
+	model_window openai output print sandbox skills sse term tools turn ui_config \
+	web xmalloc
 
 .include <bsd.subdir.mk>
blob - ab6275d337f12bc5508e5c79157d134b29749fbd
blob + 57c2435bf5f099f573b300ffbe187a7635b10368
--- regress/README
+++ regress/README
@@ -47,6 +47,8 @@ I10 mutual distrust across roles	imsgev (partially con
 					and checked-send failures fail closed);
 					generation (canonical event ordering,
 					bounds, and exactly one terminal);
+					ui_config (typed ordering, bounded staging,
+					and atomic publication);
 					imsg length/type
 					validation in worker.c/coord.c; hub-and-spoke
 					authority map in proto.h; turn
@@ -179,6 +181,7 @@ src/fugu/agentcfg.c			agentcfg; turn (live schema/rout
 src/fugu/priv.c, coord.c		sandbox (spawn/handshake/teardown),
 					turn (lead and subagent turn loops)
 src/fugu/journal.c			journal, turn
+src/fugu-tty/ui_config_rx.c		ui_config; turn (full-palette PTY path)
 src/common/imsgev.c			imsgev (whole-payload views, checked
 					normal/teardown sends, event failures);
 					sandbox (event runtime)
blob - 735c7ebf5464536931e048b8cef4887693489da4
blob + 1a4b63676dc080f78a50128e54b7bce0dca9a89e
--- regress/turn/run.sh
+++ regress/turn/run.sh
@@ -656,6 +656,51 @@ else
 	fail "curses: 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
+# accepting input: Tab expands the unique prefix, and Enter runs that skill.
+if [ -x "$obj/ptydrive" ]; then
+	rm -rf "$HOME/.fugu"
+	rm -f "$dir/reqout"
+	skilldir=$HOME/.fugu/skills
+	mkdir -p "$skilldir"
+	bulk_tail=$(awk 'BEGIN { for (i = 0; i < 55; i++) printf "x" }')
+	i=0
+	while [ "$i" -lt 255 ]; do
+		num=$(printf '%03d' "$i")
+		name=bulk${num}_${bulk_tail}
+		mkdir -p "$skilldir/$name"
+		printf -- '---\ndisable-model-invocation: true\n---\nBULK-%s\n' \
+		    "$num" > "$skilldir/$name/SKILL.md"
+		i=$((i + 1))
+	done
+	target_tail=$(awk 'BEGIN { for (i = 0; i < 40; i++) printf "x" }')
+	target=typed_ui_config_target_${target_tail}
+	marker=TYPED-UI-CONFIG-SENTINEL-BODY
+	mkdir -p "$skilldir/$target"
+	printf -- '---\ndisable-model-invocation: true\n---\n%s\n' "$marker" \
+	    > "$skilldir/$target/SKILL.md"
+	start_stub "OK"
+	write_conf "$stubport"
+	printf '/typed_ui_config_\t\r\000\000/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" >/dev/null 2>&1
+	rc=$?
+	seen=0
+	grep -q "$marker" "$dir/reqout" 2>/dev/null && seen=1
+	if [ "$rc" -eq 0 ] && [ "$seen" -eq 1 ]; then
+		pass "typed UI config: full skill palette completes and Tab runs its sentinel"
+	else
+		echo "# typed UI config: ptydrive rc=$rc, sentinel_seen=$seen"
+		fail "typed UI config: full skill palette completes and Tab runs its sentinel"
+	fi
+	stop_stub
+else
+	fail "typed UI config: ptydrive helper not built"
+fi
+
 # --- 29: the external-editor compose room (behavior.md 2.1) ----------
 # `v` in normal mode suspends the screen and runs $VISUAL in the
 # fugu-editor room; a zero exit replaces the input with the file's
blob - /dev/null
blob + a2114fd327240c4762111431b7d5bb7ae96d4e4e (mode 644)
--- /dev/null
+++ regress/ui_config/Makefile
@@ -0,0 +1,14 @@
+PROG=	ui_config_test
+SRCS=	ui_config_test.c ui_config_rx.c xmalloc.c log.c
+NOMAN=	yes
+
+CFLAGS+=	-I${.CURDIR}/../../src/fugu
+CFLAGS+=	-I${.CURDIR}/../../src/fugu-tty
+.PATH:		${.CURDIR}/../../src/fugu-tty
+
+REGRESS_TARGETS=	run-ui-config
+
+run-ui-config: ui_config_test
+	./ui_config_test
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 679cfc582966b2a7c1f4c4b94c48c556bb54f1ea (mode 644)
--- /dev/null
+++ regress/ui_config/ui_config_test.c
@@ -0,0 +1,368 @@
+/*
+ * 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 <stdio.h>
+#include <string.h>
+
+#include "model_window.h"
+#include "proto.h"
+#include "ui_config_rx.h"
+
+#include "regress.h"
+
+static struct ui_config
+valid_config(void)
+{
+	struct ui_config	 cfg;
+
+	memset(&cfg, 0, sizeof(cfg));
+	cfg.ascii = 1;
+	cfg.notify = 0;
+	cfg.window = 200000;
+	strlcpy(cfg.model, "claude-test", sizeof(cfg.model));
+	return (cfg);
+}
+
+static void
+test_malformed_settings(void)
+{
+	struct ui_config_rx	*rx;
+	struct ui_config_snapshot *snap = NULL;
+	struct ui_config	 cfg;
+
+	cfg = valid_config();
+	rx = ui_config_rx_new();
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_BEGIN,
+	    &cfg, sizeof(cfg) - 1, &snap) == UI_CONFIG_RX_ERROR);
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_BEGIN,
+	    &cfg, sizeof(cfg), &snap) == UI_CONFIG_RX_ERROR);
+	ui_config_rx_free(rx);
+
+	cfg = valid_config();
+	cfg.ascii = 2;
+	rx = ui_config_rx_new();
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_BEGIN,
+	    &cfg, sizeof(cfg), &snap) == UI_CONFIG_RX_ERROR);
+	ui_config_rx_free(rx);
+
+	cfg = valid_config();
+	cfg.notify = -1;
+	rx = ui_config_rx_new();
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_BEGIN,
+	    &cfg, sizeof(cfg), &snap) == UI_CONFIG_RX_ERROR);
+	ui_config_rx_free(rx);
+
+	cfg = valid_config();
+	cfg.window = -1;
+	rx = ui_config_rx_new();
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_BEGIN,
+	    &cfg, sizeof(cfg), &snap) == UI_CONFIG_RX_ERROR);
+	ui_config_rx_free(rx);
+
+	cfg = valid_config();
+	cfg.window = FUGU_MODEL_WINDOW_MAX + 1;
+	rx = ui_config_rx_new();
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_BEGIN,
+	    &cfg, sizeof(cfg), &snap) == UI_CONFIG_RX_ERROR);
+	ui_config_rx_free(rx);
+
+	cfg = valid_config();
+	memset(cfg.model, 'm', sizeof(cfg.model));
+	rx = ui_config_rx_new();
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_BEGIN,
+	    &cfg, sizeof(cfg), &snap) == UI_CONFIG_RX_ERROR);
+	ui_config_rx_free(rx);
+}
+
+static int
+feed_one_skill(const void *name, size_t len)
+{
+	struct ui_config_rx	*rx;
+	struct ui_config_snapshot *snap = NULL;
+	struct ui_config	 cfg;
+	int			 status;
+
+	cfg = valid_config();
+	rx = ui_config_rx_new();
+	if (ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_BEGIN,
+	    &cfg, sizeof(cfg), &snap) != UI_CONFIG_RX_MORE)
+		status = UI_CONFIG_RX_ERROR;
+	else
+		status = ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_SKILL,
+		    name, len, &snap);
+	ui_config_rx_free(rx);
+	return (status);
+}
+
+static void
+test_malformed_skills(void)
+{
+	struct ui_config_rx	*rx;
+	struct ui_config_snapshot *snap = NULL;
+	struct ui_config	 cfg;
+	char			 longname[FUGU_SKILL_NAME_MAX];
+	const char		 embedded[] = { 'a', '\0', 'b' };
+
+	memset(longname, 'a', sizeof(longname));
+	CHECK(feed_one_skill(NULL, 0) == UI_CONFIG_RX_ERROR);
+	CHECK(feed_one_skill(NULL, 1) == UI_CONFIG_RX_ERROR);
+	CHECK(feed_one_skill(longname, sizeof(longname)) == UI_CONFIG_RX_ERROR);
+	CHECK(feed_one_skill(embedded, sizeof(embedded)) == UI_CONFIG_RX_ERROR);
+	CHECK(feed_one_skill("not/valid", 9) == UI_CONFIG_RX_ERROR);
+	CHECK(feed_one_skill("okay-Name_9", 11) == UI_CONFIG_RX_MORE);
+
+	cfg = valid_config();
+	rx = ui_config_rx_new();
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_BEGIN,
+	    &cfg, sizeof(cfg), &snap) == UI_CONFIG_RX_MORE);
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_SKILL,
+	    "same", 4, &snap) == UI_CONFIG_RX_MORE);
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_SKILL,
+	    "same", 4, &snap) == UI_CONFIG_RX_ERROR);
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_END,
+	    NULL, 0, &snap) == UI_CONFIG_RX_ERROR);
+	CHECK(snap == NULL);
+	ui_config_rx_free(rx);
+
+	cfg = valid_config();
+	rx = ui_config_rx_new();
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_BEGIN,
+	    &cfg, sizeof(cfg), &snap) == UI_CONFIG_RX_MORE);
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_SKILL,
+	    embedded, sizeof(embedded), &snap) == UI_CONFIG_RX_ERROR);
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_END,
+	    NULL, 0, &snap) == UI_CONFIG_RX_ERROR);
+	CHECK(snap == NULL);
+	ui_config_rx_free(rx);
+}
+
+static void
+test_skill_limit(void)
+{
+	struct ui_config_rx	*rx;
+	struct ui_config_snapshot *snap = NULL;
+	struct ui_config	 cfg;
+	char			 name[16];
+	int			 i, status = UI_CONFIG_RX_MORE;
+
+	cfg = valid_config();
+	rx = ui_config_rx_new();
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_BEGIN,
+	    &cfg, sizeof(cfg), &snap) == UI_CONFIG_RX_MORE);
+	for (i = 0; i < FUGU_SKILLS_MAX; i++) {
+		snprintf(name, sizeof(name), "skill%03d", i);
+		status = ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_SKILL,
+		    name, strlen(name), &snap);
+		if (status != UI_CONFIG_RX_MORE)
+			break;
+	}
+	CHECK(i == FUGU_SKILLS_MAX && status == UI_CONFIG_RX_MORE);
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_END,
+	    NULL, 0, &snap) == UI_CONFIG_RX_COMPLETE);
+	CHECK(snap != NULL && ui_config_snapshot_skill_count(snap) ==
+	    FUGU_SKILLS_MAX);
+	CHECK(strcmp(ui_config_snapshot_skill(snap, 0), "skill000") == 0);
+	CHECK(strcmp(ui_config_snapshot_skill(snap,
+	    FUGU_SKILLS_MAX - 1), "skill255") == 0);
+	ui_config_snapshot_free(snap);
+	ui_config_rx_free(rx);
+
+	snap = NULL;
+	cfg = valid_config();
+	rx = ui_config_rx_new();
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_BEGIN,
+	    &cfg, sizeof(cfg), &snap) == UI_CONFIG_RX_MORE);
+	for (i = 0; i < FUGU_SKILLS_MAX; i++) {
+		snprintf(name, sizeof(name), "skill%03d", i);
+		status = ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_SKILL,
+		    name, strlen(name), &snap);
+		if (status != UI_CONFIG_RX_MORE)
+			break;
+	}
+	CHECK(i == FUGU_SKILLS_MAX && status == UI_CONFIG_RX_MORE);
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_SKILL,
+	    "overflow", 8, &snap) == UI_CONFIG_RX_ERROR);
+	ui_config_rx_free(rx);
+}
+
+static void
+test_inclusive_boundaries(void)
+{
+	struct ui_config_rx	*rx;
+	struct ui_config_snapshot *snap = NULL;
+	struct ui_config	 cfg;
+	char			 name[FUGU_SKILL_NAME_MAX - 1];
+
+	cfg = valid_config();
+	cfg.ascii = 0;
+	cfg.notify = 1;
+	cfg.window = FUGU_MODEL_WINDOW_MAX;
+	memset(cfg.model, 'm', sizeof(cfg.model));
+	cfg.model[sizeof(cfg.model) - 1] = '\0';
+	memset(name, 's', sizeof(name));
+	rx = ui_config_rx_new();
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_BEGIN,
+	    &cfg, sizeof(cfg), &snap) == UI_CONFIG_RX_MORE);
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_SKILL,
+	    name, sizeof(name), &snap) == UI_CONFIG_RX_MORE);
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_END,
+	    NULL, 0, &snap) == UI_CONFIG_RX_COMPLETE);
+	CHECK(ui_config_snapshot_settings(snap)->window ==
+	    FUGU_MODEL_WINDOW_MAX);
+	CHECK(strlen(ui_config_snapshot_skill(snap, 0)) ==
+	    FUGU_SKILL_NAME_MAX - 1);
+	ui_config_snapshot_free(snap);
+	ui_config_rx_free(rx);
+}
+
+static struct ui_config_snapshot *
+complete_snapshot(struct ui_config_rx *rx, const char *model,
+    const char *skill)
+{
+	struct ui_config_snapshot *snap = NULL;
+	struct ui_config	 cfg;
+
+	cfg = valid_config();
+	strlcpy(cfg.model, model, sizeof(cfg.model));
+	if (ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_BEGIN,
+	    &cfg, sizeof(cfg), &snap) != UI_CONFIG_RX_MORE)
+		return (NULL);
+	if (skill != NULL && ui_config_rx_feed(rx,
+	    FUGU_IMSG_UI_CONFIG_SKILL, skill, strlen(skill),
+	    &snap) != UI_CONFIG_RX_MORE)
+		return (NULL);
+	if (ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_END,
+	    NULL, 0, &snap) != UI_CONFIG_RX_COMPLETE)
+		return (NULL);
+	return (snap);
+}
+
+static void
+test_sequence_and_atomicity(void)
+{
+	struct ui_config_rx	*rx;
+	struct ui_config_snapshot *old, *next = NULL;
+	struct ui_config	 cfg;
+	char			 byte = 'x';
+
+	cfg = valid_config();
+	rx = ui_config_rx_new();
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_TEXT,
+	    &byte, 1, &next) == UI_CONFIG_RX_OTHER);
+	ui_config_rx_free(rx);
+
+	rx = ui_config_rx_new();
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_SKILL,
+	    "early", 5, &next) == UI_CONFIG_RX_ERROR);
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_BEGIN,
+	    &cfg, sizeof(cfg), &next) == UI_CONFIG_RX_ERROR);
+	ui_config_rx_free(rx);
+
+	rx = ui_config_rx_new();
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_END,
+	    NULL, 0, &next) == UI_CONFIG_RX_ERROR);
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_BEGIN,
+	    &cfg, sizeof(cfg), &next) == UI_CONFIG_RX_ERROR);
+	ui_config_rx_free(rx);
+
+	rx = ui_config_rx_new();
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_BEGIN,
+	    &cfg, sizeof(cfg), &next) == UI_CONFIG_RX_MORE);
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_BEGIN,
+	    &cfg, sizeof(cfg), &next) == UI_CONFIG_RX_ERROR);
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_STATUS,
+	    &byte, 1, &next) == UI_CONFIG_RX_ERROR);
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_END,
+	    &byte, 1, &next) == UI_CONFIG_RX_ERROR);
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_END,
+	    NULL, 0, &next) == UI_CONFIG_RX_ERROR);
+	CHECK(next == NULL);
+	ui_config_rx_free(rx);
+
+	/* A later, incomplete stream cannot mutate an already published view. */
+	rx = ui_config_rx_new();
+	old = complete_snapshot(rx, "old-model", "old_skill");
+	CHECK(old != NULL);
+	cfg = valid_config();
+	strlcpy(cfg.model, "unpublished-model", sizeof(cfg.model));
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_BEGIN,
+	    &cfg, sizeof(cfg), &next) == UI_CONFIG_RX_MORE);
+	CHECK(next == NULL);
+	CHECK(strcmp(ui_config_snapshot_settings(old)->model,
+	    "old-model") == 0);
+	CHECK(ui_config_snapshot_skill_count(old) == 1);
+	CHECK(strcmp(ui_config_snapshot_skill(old, 0), "old_skill") == 0);
+	ui_config_rx_free(rx);
+	CHECK(strcmp(ui_config_snapshot_settings(old)->model,
+	    "old-model") == 0);
+	ui_config_snapshot_free(old);
+
+	/* END publishes a complete replacement and permits later reconfigure. */
+	rx = ui_config_rx_new();
+	old = complete_snapshot(rx, "first-model", NULL);
+	next = complete_snapshot(rx, "second-model", "second_skill");
+	CHECK(old != NULL && next != NULL);
+	CHECK(strcmp(ui_config_snapshot_settings(old)->model,
+	    "first-model") == 0);
+	CHECK(strcmp(ui_config_snapshot_settings(next)->model,
+	    "second-model") == 0);
+	CHECK(strcmp(ui_config_snapshot_skill(next, 0),
+	    "second_skill") == 0);
+	CHECK(ui_config_snapshot_skill(next, 1) == NULL);
+	ui_config_snapshot_free(old);
+	ui_config_snapshot_free(next);
+	ui_config_rx_free(rx);
+}
+
+int
+main(void)
+{
+	struct ui_config_rx	*rx;
+	struct ui_config_snapshot *snap = NULL;
+	struct ui_config	 cfg;
+
+	cfg = valid_config();
+
+	rx = ui_config_rx_new();
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_BEGIN,
+	    &cfg, sizeof(cfg), &snap) == UI_CONFIG_RX_MORE);
+	CHECK(snap == NULL);
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_SKILL,
+	    "greet", 5, &snap) == UI_CONFIG_RX_MORE);
+	CHECK(snap == NULL);
+	CHECK(ui_config_rx_feed(rx, FUGU_IMSG_UI_CONFIG_END,
+	    NULL, 0, &snap) == UI_CONFIG_RX_COMPLETE);
+	CHECK(snap != NULL);
+	CHECK(ui_config_snapshot_settings(snap)->ascii == 1);
+	CHECK(ui_config_snapshot_settings(snap)->notify == 0);
+	CHECK(ui_config_snapshot_settings(snap)->window == 200000);
+	CHECK(strcmp(ui_config_snapshot_settings(snap)->model,
+	    "claude-test") == 0);
+	CHECK(ui_config_snapshot_skill_count(snap) == 1);
+	CHECK(strcmp(ui_config_snapshot_skill(snap, 0), "greet") == 0);
+
+	ui_config_snapshot_free(snap);
+	ui_config_rx_free(rx);
+	test_malformed_settings();
+	test_malformed_skills();
+	test_skill_limit();
+	test_inclusive_boundaries();
+	test_sequence_and_atomicity();
+	REGRESS_END();
+}
blob - 23e224028b672b78a69570779b65f39049b5ca2e
blob + b995cdd31bd24787f89b407a6beeb22d9b533c9f
--- src/fugu/coord.c
+++ src/fugu/coord.c
@@ -1071,13 +1071,17 @@ stream_turn_fail(struct coord *c, const char *reason)
 	event_loopexit(NULL);
 }
 
-/* The interface settings, then the skill names as NUL-separated
- * completion candidates; re-sent whenever a setting changes (2.1). */
+/*
+ * One typed configuration transaction: fixed settings, bounded Skill
+ * names, then the terminal frame that makes the snapshot visible.  Keeping
+ * these checked sends contiguous prevents a render event from being
+ * accepted against a half-published palette.
+ */
 static void
 send_ui_config(struct coord *c)
 {
 	struct ui_config	 cfg;
-	struct buf		 cb;
+	size_t			 len;
 	int			 i;
 
 	if (!c->ui)
@@ -1088,15 +1092,22 @@ send_ui_config(struct coord *c)
 	cfg.window = effective_window(c);
 	if (c->conf->model != NULL)
 		strlcpy(cfg.model, c->conf->model, sizeof(cfg.model));
-	buf_init(&cb);
-	buf_add(&cb, &cfg, sizeof(cfg));
-	if (c->skills != NULL)
+	if (c->skills != NULL) {
+		if (c->skills->n < 0 || c->skills->n > FUGU_SKILLS_MAX)
+			fatalx("invalid UI Skill count");
 		for (i = 0; i < c->skills->n; i++) {
-			buf_addstr(&cb, c->skills->skills[i].name);
-			buf_addc(&cb, '\0');
+			len = strlen(c->skills->skills[i].name);
+			if (len == 0 || len >= FUGU_SKILL_NAME_MAX)
+				fatalx("invalid UI Skill name");
 		}
-	ui_send(c, FUGU_IMSG_UI_CONFIG, cb.data, cb.len);
-	buf_free(&cb);
+	}
+	ui_send(c, FUGU_IMSG_UI_CONFIG_BEGIN, &cfg, sizeof(cfg));
+	if (c->skills != NULL)
+		for (i = 0; i < c->skills->n; i++)
+			ui_send(c, FUGU_IMSG_UI_CONFIG_SKILL,
+			    c->skills->skills[i].name,
+			    strlen(c->skills->skills[i].name));
+	ui_send(c, FUGU_IMSG_UI_CONFIG_END, NULL, 0);
 }
 
 static void
@@ -3749,7 +3760,7 @@ line_command(struct coord *c, const char *line, int *t
 	{
 		const char		*rest = line + 1, *sp = strchr(rest, ' ');
 		const struct skill	*sk;
-		char			 name[SKILL_NAME_MAX];
+		char			 name[FUGU_SKILL_NAME_MAX];
 		size_t			 nlen = sp != NULL ?
 					    (size_t)(sp - rest) : strlen(rest);
 
blob - 750ea902fc8e0d16176f26e7f41b4c95e6aefdd8
blob + 34516048ceae9b0faa99bce0196bb2d6681151d6
--- src/fugu/proto.h
+++ src/fugu/proto.h
@@ -115,6 +115,10 @@
  */
 #define FUGU_EDIT_MAX		(64 * 1024)
 
+/* Canonical Skill-domain limits shared by loader, tool, and UI stream. */
+#define FUGU_SKILL_NAME_MAX	64	/* including terminating NUL */
+#define FUGU_SKILLS_MAX		256
+
 /* A model id on the wire (MODEL, UI_SELECT_MODEL), bounded on both
  * sides: provider listings are hostile input (I13). */
 #define FUGU_MODEL_ID_MAX	128
@@ -195,7 +199,9 @@ enum fugu_imsg {
 	 * that parses hostile terminal bytes and model markdown holds no
 	 * credentials and no journals (I7, I11).
 	 */
-	FUGU_IMSG_UI_CONFIG,	/* struct ui_config: ascii/notify/window/model */
+	FUGU_IMSG_UI_CONFIG_BEGIN, /* struct ui_config: fixed settings */
+	FUGU_IMSG_UI_CONFIG_SKILL, /* one raw bounded Skill name */
+	FUGU_IMSG_UI_CONFIG_END, /* atomically publish staged settings/Skills */
 	FUGU_IMSG_UI_TEXT,	/* assistant text delta (streamed, markdown) */
 	FUGU_IMSG_UI_TOOL,	/* a tool-call line */
 	FUGU_IMSG_UI_DIFF,	/* a presentation-only diff hunk (2.1) */
blob - fcf7a0a158819aa4e14897a5e75bed21befbeab6
blob + 11595ca8b0c821dfb763b78c394e054408221053
--- src/fugu/skills.c
+++ src/fugu/skills.c
@@ -30,8 +30,6 @@
 #include "skills.h"
 
 #define SKILL_FILE_MAX	(256 * 1024)
-#define SKILLS_MAX	256
-
 /* Read a regular file under the size cap; NULL if absent/oversize. */
 static char *
 slurp(const char *path, size_t *lenp)
@@ -68,7 +66,7 @@ valid_name(const char *s)
 {
 	size_t	 i;
 
-	if (s[0] == '\0' || strlen(s) >= SKILL_NAME_MAX)
+	if (s[0] == '\0' || strlen(s) >= FUGU_SKILL_NAME_MAX)
 		return (0);
 	for (i = 0; s[i] != '\0'; i++)
 		if (!isalnum((unsigned char)s[i]) && s[i] != '-' && s[i] != '_')
@@ -235,8 +233,9 @@ skills_load(const char *dir)
 	if ((d = opendir(dir)) == NULL)
 		return (ss);			/* no skills directory */
 
-	ss->skills = xreallocarray(NULL, SKILLS_MAX, sizeof(*ss->skills));
-	while ((de = readdir(d)) != NULL && ss->n < SKILLS_MAX) {
+	ss->skills = xreallocarray(NULL, FUGU_SKILLS_MAX,
+	    sizeof(*ss->skills));
+	while ((de = readdir(d)) != NULL && ss->n < FUGU_SKILLS_MAX) {
 		char	 path[1024];
 		char	*data;
 		size_t	 len;
blob - 68321bbc86101fb825efa0d3d75ce587b913ed68
blob + eeebe2832165b8c69a49bb65589449d50c7628b8
--- src/fugu/skills.h
+++ src/fugu/skills.h
@@ -19,6 +19,8 @@
 
 #include <sys/types.h>
 
+#include "proto.h"
+
 /*
  * User-installed skills (behavior.md section 4): prompt templates
  * under ~/.fugu/skills/<name>/SKILL.md, loaded once at startup.  A
@@ -27,10 +29,8 @@
  * tool (section 5), whose result is the same body.
  */
 
-#define SKILL_NAME_MAX	64
-
 struct skill {
-	char	 name[SKILL_NAME_MAX];
+	char	 name[FUGU_SKILL_NAME_MAX];
 	char	*description;		/* may be NULL */
 	char	*body;			/* the template; NUL-terminated */
 	size_t	 bodylen;
blob - 28a0b075625bd74a6f7adf174b92ef36812be65f
blob + c85c3f89ffa51c2863e5d736f1543051bb983324
--- src/fugu-tty/Makefile
+++ src/fugu-tty/Makefile
@@ -1,5 +1,5 @@
 PROG=	fugu-tty
-SRCS=	main.c ui.c markdown.c editor.c term_input.c utf8.c \
+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
 BINDIR=	${LIBEXECDIR}/fugu
 MAN=
blob - 5606c58654257e575fb61aaa0b53c773f7495e87
blob + eece9db7071abfafc31067ad2192b24c8a84511a
--- src/fugu-tty/ui.c
+++ src/fugu-tty/ui.c
@@ -36,6 +36,7 @@
 #include "markdown.h"
 #include "editor.h"
 #include "term_input.h"
+#include "ui_config_rx.h"
 #include "ui.h"
 
 /* transcript item kinds */
@@ -100,6 +101,7 @@ struct ui {
 	int			 ascii, notify;
 	int64_t			 window;
 	char			 model[64];
+	struct ui_config_rx	*config_rx;
 
 	/* status */
 	struct ui_status	 status;
@@ -1465,49 +1467,38 @@ compose_end(struct ui *u, const struct edit_result *r)
 }
 
 static void
-set_config(struct ui *u, const u_char *p, size_t len)
+publish_config(struct ui *u, const struct ui_config_snapshot *snapshot)
 {
-	struct ui_config	 cfg;
-	size_t			 off, i;
+	const struct ui_config	*cfg;
+	char			**comps, **old;
+	size_t			 ncomps, oldn, i;
 	const char		*builtin[] = { "clear", "compact", "context",
 				    "exit", "model", "quit" };
 
-	if (p == NULL || len < sizeof(cfg))
-		return;
-	memcpy(&cfg, p, sizeof(cfg));
-	cfg.model[sizeof(cfg.model) - 1] = '\0';
-	u->ascii = cfg.ascii;
-	u->notify = cfg.notify;
-	u->window = cfg.window;
-	strlcpy(u->model, cfg.model, sizeof(u->model));
+	/* Build the whole replacement before touching the live UI snapshot. */
+	ncomps = sizeof(builtin) / sizeof(builtin[0]) +
+	    ui_config_snapshot_skill_count(snapshot);
+	comps = xcalloc(ncomps, sizeof(*comps));
+	for (i = 0; i < sizeof(builtin) / sizeof(builtin[0]); i++)
+		comps[i] = xstrdup(builtin[i]);
+	for (i = sizeof(builtin) / sizeof(builtin[0]); i < ncomps; i++)
+		comps[i] = xstrdup(ui_config_snapshot_skill(snapshot,
+		    i - sizeof(builtin) / sizeof(builtin[0])));
 
-	/* completion candidates: the built-ins, then the skill names that
-	 * follow the struct as NUL-separated bytes */
-	for (i = 0; i < u->ncomps; i++)
-		free(u->comps[i]);
-	u->ncomps = 0;
-	for (i = 0; i < sizeof(builtin) / sizeof(builtin[0]); i++) {
-		u->comps = xreallocarray(u->comps, u->ncomps + 1,
-		    sizeof(*u->comps));
-		u->comps[u->ncomps++] = xstrdup(builtin[i]);
-	}
-	off = sizeof(cfg);
-	while (off < len) {
-		size_t	e = off;
+	old = u->comps;
+	oldn = u->ncomps;
+	ed_set_completions(&u->ed, comps, ncomps);
+	u->comps = comps;
+	u->ncomps = ncomps;
+	for (i = 0; i < oldn; i++)
+		free(old[i]);
+	free(old);
 
-		while (e < len && p[e] != '\0')
-			e++;
-		if (e > off) {
-			u->comps = xreallocarray(u->comps, u->ncomps + 1,
-			    sizeof(*u->comps));
-			u->comps[u->ncomps] = xmalloc(e - off + 1);
-			memcpy(u->comps[u->ncomps], p + off, e - off);
-			u->comps[u->ncomps][e - off] = '\0';
-			u->ncomps++;
-		}
-		off = e + 1;
-	}
-	ed_set_completions(&u->ed, u->comps, u->ncomps);
+	cfg = ui_config_snapshot_settings(snapshot);
+	u->ascii = cfg->ascii;
+	u->notify = cfg->notify;
+	u->window = cfg->window;
+	strlcpy(u->model, cfg->model, sizeof(u->model));
 	u->ready = 1;			/* input may now be processed */
 	drain_input(u);			/* keys typed before config land now */
 	u->dirty = 1;
@@ -1517,9 +1508,11 @@ int
 ui_render(struct worker *w, struct imsg *imsg)
 {
 	struct ui	*u = w->ctx;
+	struct ui_config_snapshot *config = NULL;
 	struct imsgev_payload payload;
 	const u_char	*p;
 	size_t		 len;
+	int		 status;
 
 	if (u == NULL)
 		return (-1);
@@ -1527,11 +1520,20 @@ ui_render(struct worker *w, struct imsg *imsg)
 		fatal("get UI payload");
 	p = payload.data;
 	len = payload.len;
+	status = ui_config_rx_feed(u->config_rx, imsg_get_type(imsg), p, len,
+	    &config);
+	if (status == UI_CONFIG_RX_ERROR)
+		return (-1);
+	if (status == UI_CONFIG_RX_MORE)
+		return (0);
+	if (status == UI_CONFIG_RX_COMPLETE) {
+		publish_config(u, config);
+		ui_config_snapshot_free(config);
+		draw(u);
+		return (0);
+	}
 
 	switch (imsg_get_type(imsg)) {
-	case FUGU_IMSG_UI_CONFIG:
-		set_config(u, p, len);
-		break;
 	case FUGU_IMSG_UI_TEXT:
 		if (p != NULL)
 			append_delta(u, (const char *)p, len);
@@ -1709,6 +1711,7 @@ ui_start(struct worker *w, int cols, int rows)
 	getmaxyx(stdscr, u->rows, u->cols);
 	u->scroll = -1;
 	u->status.state = UI_IDLE;
+	u->config_rx = ui_config_rx_new();
 	ed_init(&u->ed);
 	ti_init(&u->ti);
 	buf_init(&u->compose);
blob - /dev/null
blob + ecfeccb48792a5ba394cb7ee3b5b43308333d43e (mode 644)
--- /dev/null
+++ src/fugu-tty/ui_config_rx.c
@@ -0,0 +1,159 @@
+/*
+ * 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 <ctype.h>
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "xmalloc.h"
+#include "model_window.h"
+#include "proto.h"
+#include "ui_config_rx.h"
+
+struct ui_config_snapshot {
+	struct ui_config	 settings;
+	char		**skills;
+	size_t		  nskills;
+};
+
+struct ui_config_rx {
+	struct ui_config_snapshot	*pending;
+	int				 failed;
+};
+
+struct ui_config_rx *
+ui_config_rx_new(void)
+{
+	return (xcalloc(1, sizeof(struct ui_config_rx)));
+}
+
+void
+ui_config_snapshot_free(struct ui_config_snapshot *snapshot)
+{
+	size_t	 i;
+
+	if (snapshot == NULL)
+		return;
+	for (i = 0; i < snapshot->nskills; i++)
+		free(snapshot->skills[i]);
+	free(snapshot->skills);
+	free(snapshot);
+}
+
+void
+ui_config_rx_free(struct ui_config_rx *rx)
+{
+	if (rx == NULL)
+		return;
+	ui_config_snapshot_free(rx->pending);
+	free(rx);
+}
+
+static enum ui_config_rx_status
+ui_config_rx_error(struct ui_config_rx *rx)
+{
+	ui_config_snapshot_free(rx->pending);
+	rx->pending = NULL;
+	rx->failed = 1;
+	return (UI_CONFIG_RX_ERROR);
+}
+
+enum ui_config_rx_status
+ui_config_rx_feed(struct ui_config_rx *rx, uint32_t type, const void *data,
+    size_t len, struct ui_config_snapshot **result)
+{
+	struct ui_config_snapshot	*snapshot;
+	struct ui_config		 settings;
+	const unsigned char		*name;
+	size_t				 i;
+
+	if (rx == NULL)
+		return (UI_CONFIG_RX_ERROR);
+	if (result == NULL)
+		return (ui_config_rx_error(rx));
+	*result = NULL;
+	if (rx->failed)
+		return (UI_CONFIG_RX_ERROR);
+	if (type == FUGU_IMSG_UI_CONFIG_BEGIN) {
+		if (rx->pending != NULL || data == NULL ||
+		    len != sizeof(struct ui_config))
+			return (ui_config_rx_error(rx));
+		memcpy(&settings, data, sizeof(settings));
+		if ((settings.ascii != 0 && settings.ascii != 1) ||
+		    (settings.notify != 0 && settings.notify != 1) ||
+		    settings.window < 0 ||
+		    settings.window > FUGU_MODEL_WINDOW_MAX ||
+		    memchr(settings.model, '\0', sizeof(settings.model)) == NULL)
+			return (ui_config_rx_error(rx));
+		rx->pending = xcalloc(1, sizeof(*rx->pending));
+		rx->pending->settings = settings;
+		return (UI_CONFIG_RX_MORE);
+	}
+	if (type == FUGU_IMSG_UI_CONFIG_SKILL) {
+		if (rx->pending == NULL || data == NULL || len == 0 ||
+		    len >= FUGU_SKILL_NAME_MAX ||
+		    rx->pending->nskills >= FUGU_SKILLS_MAX)
+			return (ui_config_rx_error(rx));
+		snapshot = rx->pending;
+		name = data;
+		for (i = 0; i < len; i++)
+			if (!isalnum(name[i]) && name[i] != '-' && name[i] != '_')
+				return (ui_config_rx_error(rx));
+		for (i = 0; i < snapshot->nskills; i++)
+			if (strlen(snapshot->skills[i]) == len &&
+			    memcmp(snapshot->skills[i], name, len) == 0)
+				return (ui_config_rx_error(rx));
+		snapshot->skills = xreallocarray(snapshot->skills,
+		    snapshot->nskills + 1, sizeof(*snapshot->skills));
+		snapshot->skills[snapshot->nskills++] =
+		    xstrndup(data, len);
+		return (UI_CONFIG_RX_MORE);
+	}
+	if (type == FUGU_IMSG_UI_CONFIG_END) {
+		if (rx->pending == NULL || data != NULL || len != 0)
+			return (ui_config_rx_error(rx));
+		*result = rx->pending;
+		rx->pending = NULL;
+		return (UI_CONFIG_RX_COMPLETE);
+	}
+	if (rx->pending != NULL)
+		return (ui_config_rx_error(rx));
+	return (UI_CONFIG_RX_OTHER);
+}
+
+const struct ui_config *
+ui_config_snapshot_settings(const struct ui_config_snapshot *snapshot)
+{
+	return (&snapshot->settings);
+}
+
+size_t
+ui_config_snapshot_skill_count(const struct ui_config_snapshot *snapshot)
+{
+	return (snapshot->nskills);
+}
+
+const char *
+ui_config_snapshot_skill(const struct ui_config_snapshot *snapshot,
+    size_t index)
+{
+	if (index >= snapshot->nskills)
+		return (NULL);
+	return (snapshot->skills[index]);
+}
blob - /dev/null
blob + 59a520cfa1faae76c7a16fc10c9789de02eb3df1 (mode 644)
--- /dev/null
+++ src/fugu-tty/ui_config_rx.h
@@ -0,0 +1,56 @@
+/*
+ * 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_CONFIG_RX_H
+#define UI_CONFIG_RX_H
+
+#include <sys/types.h>
+
+#include <stdint.h>
+
+#include "proto.h"
+
+struct ui_config_rx;
+struct ui_config_snapshot;
+
+enum ui_config_rx_status {
+	UI_CONFIG_RX_ERROR = -1,
+	UI_CONFIG_RX_OTHER,
+	UI_CONFIG_RX_MORE,
+	UI_CONFIG_RX_COMPLETE
+};
+
+/*
+ * Feed every UI render frame through this Interface.  Unrelated frames are
+ * returned as OTHER only while idle; any interleaving inside BEGIN..END is a
+ * protocol error.  ERROR poisons the receiver permanently and discards its
+ * staging.  COMPLETE transfers an immutable snapshot to the caller.  Freeing
+ * a receiver with an incomplete stream discards only its staging.
+ */
+struct ui_config_rx	*ui_config_rx_new(void);
+void			 ui_config_rx_free(struct ui_config_rx *);
+enum ui_config_rx_status ui_config_rx_feed(struct ui_config_rx *, uint32_t,
+			     const void *, size_t, struct ui_config_snapshot **);
+
+const struct ui_config	*ui_config_snapshot_settings(
+			     const struct ui_config_snapshot *);
+size_t			 ui_config_snapshot_skill_count(
+			     const struct ui_config_snapshot *);
+const char		*ui_config_snapshot_skill(
+			     const struct ui_config_snapshot *, size_t);
+void			 ui_config_snapshot_free(struct ui_config_snapshot *);
+
+#endif /* UI_CONFIG_RX_H */