commit 91b632582edaf81718fbffc9193f46706b137bd2 from: Isaac Meerleo date: Wed Jul 15 15:30:08 2026 UTC stream typed ui configuration 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 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 blob - /dev/null blob + 679cfc582966b2a7c1f4c4b94c48c556bb54f1ea (mode 644) --- /dev/null +++ regress/ui_config/ui_config_test.c @@ -0,0 +1,368 @@ +/* + * Copyright (c) 2026 Isaac + * + * 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 + +#include +#include +#include + +#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 +#include "proto.h" + /* * User-installed skills (behavior.md section 4): prompt templates * under ~/.fugu/skills//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 + * + * 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 + +#include +#include +#include +#include + +#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 + * + * 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 + +#include + +#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 */