commit 5c30449123bfdaea256bb979bfd6955bf21cda49 from: Isaac Meerleo date: Fri Jul 17 16:15:52 2026 UTC Add dismissible terminal welcome 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 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 #include #include +#include +#include +#include #include #include +#include #include #include #include @@ -37,14 +43,50 @@ #include #include +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" < "$dir/fugu.conf" <"$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 blob - /dev/null blob + faff7f7a2086a2eaa36bae5ff1a68a662739a4db (mode 644) --- /dev/null +++ regress/welcome/welcome_test.c @@ -0,0 +1,141 @@ +/* + * 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 "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 / to explore commands") == 0); + CHECK(strcmp(view.rows[5].text, + "type /model 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 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 + * + * 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 "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 / to explore commands", NULL, + WELCOME_ROW_TEXT, 1, 5 }, + { "type /model 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 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 + * + * 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 + +#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 */