commit - 41aa0eed73347fe812858fd61ac90ba378a02500
commit + 5c30449123bfdaea256bb979bfd6955bf21cda49
blob - ba26133faea629c2563dd9fddba3ce0a0abd6c87
blob + ab234efe25ba7c9fe57c68c6dd9950036920e21a
--- docs/design/m7-curses.md
+++ docs/design/m7-curses.md
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`.
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
## 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
(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
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
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
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
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
SUBDIR= agentcfg anthropic buf claude_subscription compaction conf deploy diff editor generation http imsgev journal json local_port log markdown \
model_window openai output print sandbox skills sse term tool_display tools turn \
- turn_mechanics turn_txn ui_config ui_layout web xmalloc
+ turn_mechanics turn_txn ui_config ui_layout web welcome xmalloc
.include <bsd.subdir.mk>
blob - ed15645241409d09070e027ca8035f9eaf16f64e
blob + ec86d3a9f8a8a8b6fec32b089294ebededae6004
--- regress/README
+++ regress/README
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)
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
/* ---- 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
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
* written to the pty master; a NUL byte in the script is a half-second
* pause, for tests that must wait out a round trip before the next
* keystroke. The program's output is drained (and echoed to our stdout
- * for debugging). We exit with the child's status, or 2 on a timeout.
+ * for debugging). If PTYDRIVE_CAPTURE names a path prefix, output between
+ * pauses is also written to numbered stage files. We exit with the child's
+ * status, or 2 on a timeout.
*/
#include <sys/types.h>
#include <sys/wait.h>
#include <err.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
#include <poll.h>
#include <signal.h>
+#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <termios.h>
#include <unistd.h>
#include <util.h>
+static int
+capture_open(const char *base, int sequence)
+{
+ char path[PATH_MAX];
+ int n;
+
+ if (base == NULL)
+ return (-1);
+ n = snprintf(path, sizeof(path), "%s.%d", base, sequence);
+ if (n < 0 || (size_t)n >= sizeof(path))
+ errx(2, "capture path too long");
+ return (open(path, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0600));
+}
+
+static void
+capture_write(int fd, const char *buf, size_t len)
+{
+ ssize_t n;
+
+ if (fd == -1)
+ return;
+ while (len > 0) {
+ if ((n = write(fd, buf, len)) == -1) {
+ if (errno == EINTR)
+ continue;
+ err(2, "write capture");
+ }
+ if (n == 0)
+ errx(2, "short write to capture");
+ buf += n;
+ len -= (size_t)n;
+ }
+}
+
int
main(int argc, char *argv[])
{
struct timespec t0, now;
struct winsize ws;
+ const char *capture_base;
char script[8192];
ssize_t slen, off = 0;
long long elapsed, pause_until = 0;
+ int capture_fd, capture_sequence = 0;
int mfd, status, deadline_ms = 20000;
pid_t pid;
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));
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;
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;
}
}
+ 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
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 $?)
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 $?)
fail "curses: ptydrive helper not built"
fi
+# --- 28w: subscription warnings stay outside the curses transcript ---
+# The accepted risk disclosure remains in line/print mode, -n, manuals, and
+# profile failures. Both an active and an available named Claude route must
+# nevertheless leave the empty curses transcript available to the welcome.
+if [ -x "$obj/ptydrive" ]; then
+ for profile in default named; do
+ rm -rf "$HOME/.fugu"
+ if [ "$profile" = default ]; then
+ cat > "$dir/fugu.conf" <<EOF
+model "claude-sonnet-4-6"
+provider "claude"
+oauth_token "claude-oauth-turn-test"
+project_context no
+EOF
+ else
+ cat > "$dir/fugu.conf" <<EOF
+model "claude-sonnet-4-6"
+provider "anthropic"
+api_key "$KEY"
+project_context no
+provider "personal" {
+ type "claude"
+ model "claude-sonnet-4-6"
+ oauth_token "claude-oauth-turn-test"
+}
+EOF
+ fi
+ printf '\000/quit\r' | TERM=vt100 HOME=$HOME \
+ FUGU_CONF=$dir/fugu.conf \
+ FUGU_LIBEXEC=$dir/libexec/fugu \
+ FUGU_CA_FILE=$dir/cert.pem \
+ "$obj/ptydrive" "$fugu" >"$dir/profileout" 2>&1
+ rc=$?
+ ok "curses: $profile subscription profile exits cleanly" \
+ $([ "$rc" -eq 0 ]; echo $?)
+ grep -a -Fq "experimental Claude subscription profile" \
+ "$dir/profileout"
+ ok "curses: $profile subscription warning is not transcript" \
+ $([ $? -ne 0 ]; echo $?)
+ grep -a -Fq "I like Fugu" "$dir/profileout"
+ ok "curses: $profile subscription profile retains welcome" $?
+ done
+else
+ fail "curses profile warning: ptydrive helper not built"
+fi
+
# --- 28a: the full skill palette crosses the imsg payload boundary ---
# 256 maximum-length names do not fit beside ui_config in one imsg. The
# typed config stream must nevertheless deliver the complete palette before
blob - /dev/null
blob + 9726a3322a870f22685c49c806ea47f22db1e398 (mode 644)
--- /dev/null
+++ regress/welcome/Makefile
+PROG= welcome_test
+SRCS= welcome_test.c welcome.c utf8.c
+NOMAN= yes
+
+CFLAGS+= -I${.CURDIR}/../../src/fugu-tty
+.PATH: ${.CURDIR}/../../src/fugu-tty
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + faff7f7a2086a2eaa36bae5ff1a68a662739a4db (mode 644)
--- /dev/null
+++ regress/welcome/welcome_test.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <string.h>
+
+#include "welcome.h"
+#include "regress.h"
+
+static const struct welcome_row *
+find_row(const struct welcome_view *view, const char *text)
+{
+ size_t i;
+
+ for (i = 0; i < view->nrows; i++)
+ if (strcmp(view->rows[i].text, text) == 0)
+ return (&view->rows[i]);
+ return (NULL);
+}
+
+int
+main(void)
+{
+ struct welcome_state state = { 0 };
+ struct welcome_view view;
+ struct welcome_caption_view captions;
+ struct welcome_field fields[WELCOME_CAPTIONS_MAX];
+ const struct welcome_row *row;
+ size_t i;
+
+ CHECK(welcome_visible(&state, 0));
+ CHECK(!welcome_visible(&state, 1));
+ CHECK(welcome_visible(&state, 0)); /* a transient row is not input */
+ welcome_observe(&state, 0);
+ CHECK(welcome_visible(&state, 0));
+ welcome_observe(&state, 1);
+ CHECK(!welcome_visible(&state, 0));
+ welcome_observe(&state, 0);
+ CHECK(!welcome_visible(&state, 0)); /* deletion and /clear do not reset */
+
+ /* The normal viewport carries the exact copy with one group gap. */
+ welcome_build(&view, 0, 100, 30, 0);
+ CHECK(view.nrows == 8);
+ CHECK(strcmp(view.rows[0].text,
+ "I like Fugu \xe2\x80\x94 Japanese for pufferfish.") == 0);
+ CHECK(view.rows[0].kind == WELCOME_ROW_TITLE);
+ CHECK(strcmp(view.rows[1].text, "by Isaac Meerleo") == 0);
+ CHECK(view.rows[1].kind == WELCOME_ROW_DIM);
+ CHECK(strcmp(view.rows[2].text,
+ "git ssh://anonymous@got.fugu.farm") == 0);
+ CHECK(strcmp(view.rows[3].text,
+ "Fugu is open source and freely available!") == 0);
+ CHECK(strcmp(view.rows[4].text,
+ "type /<Tab> to explore commands") == 0);
+ CHECK(strcmp(view.rows[5].text,
+ "type /model<Enter> to choose a model") == 0);
+ CHECK(strcmp(view.rows[6].text,
+ "press Esc, then v to compose in your editor") == 0);
+ CHECK(strcmp(view.rows[7].text,
+ "type /quit<Enter> to exit") == 0);
+ CHECK(view.rows[4].row == view.rows[3].row + 2);
+ for (i = 0; i < view.nrows; i++)
+ CHECK(view.rows[i].col >= 0 &&
+ view.rows[i].col + view.rows[i].width <= 100);
+
+ /* ASCII mode folds only the em dash, consistently with markdown. */
+ welcome_build(&view, 0, 100, 30, 1);
+ CHECK(strcmp(view.rows[0].text,
+ "I like Fugu -- Japanese for pufferfish.") == 0);
+
+ /* Drawer offset and resize recenter without changing the copy. */
+ welcome_build(&view, 31, 69, 20, 0);
+ CHECK(view.nrows == 8);
+ for (i = 0; i < view.nrows; i++)
+ CHECK(view.rows[i].col >= 31 &&
+ view.rows[i].col + view.rows[i].width <= 100);
+ row = find_row(&view, "by Isaac Meerleo");
+ CHECK(row != NULL && row->col == 31 + (69 - row->width) / 2);
+
+ /* Narrow and short viewports omit whole low-priority rows, never wrap. */
+ welcome_build(&view, 0, 100, 4, 0);
+ CHECK(view.nrows == 4);
+ CHECK(strcmp(view.rows[0].text,
+ "I like Fugu \xe2\x80\x94 Japanese for pufferfish.") == 0);
+ CHECK(strcmp(view.rows[1].text, "by Isaac Meerleo") == 0);
+ CHECK(strcmp(view.rows[2].text,
+ "git ssh://anonymous@got.fugu.farm") == 0);
+ CHECK(strcmp(view.rows[3].text,
+ "Fugu is open source and freely available!") == 0);
+ welcome_build(&view, 0, 40, 4, 0);
+ CHECK(view.nrows <= 4);
+ for (i = 0; i < view.nrows; i++)
+ CHECK(view.rows[i].row >= 0 && view.rows[i].row < 4 &&
+ view.rows[i].width <= 40);
+ welcome_build(&view, 0, 3, 2, 0);
+ CHECK(view.nrows == 0);
+ welcome_build(&view, 0, 100, 1, 0);
+ CHECK(view.nrows == 1 && view.rows[0].row == 0);
+
+ /* Captions center over their live fields and disappear on collision. */
+ fields[WELCOME_AGENT_STATE].col = 4;
+ fields[WELCOME_AGENT_STATE].width = 5;
+ fields[WELCOME_INPUT_MODE].col = 16;
+ fields[WELCOME_INPUT_MODE].width = 8;
+ fields[WELCOME_CONTEXT_LIMIT].col = 81;
+ fields[WELCOME_CONTEXT_LIMIT].width = 18;
+ welcome_caption_build(&captions, 100, fields);
+ CHECK(captions.ncaptions == 3);
+ CHECK(strcmp(captions.captions[0].text, "agent state") == 0 &&
+ captions.captions[0].col == 1);
+ CHECK(strcmp(captions.captions[1].text, "input mode") == 0 &&
+ captions.captions[1].col == 15);
+ CHECK(strcmp(captions.captions[2].text,
+ "context used / limit") == 0 && captions.captions[2].col == 80);
+
+ fields[WELCOME_AGENT_STATE].col = 0;
+ fields[WELCOME_AGENT_STATE].width = 5;
+ fields[WELCOME_INPUT_MODE].col = 7;
+ fields[WELCOME_INPUT_MODE].width = 8;
+ fields[WELCOME_CONTEXT_LIMIT].col = 15;
+ fields[WELCOME_CONTEXT_LIMIT].width = 8;
+ welcome_caption_build(&captions, 24, fields);
+ CHECK(captions.ncaptions < 3);
+ for (i = 0; i < captions.ncaptions; i++)
+ CHECK(captions.captions[i].col >= 0 &&
+ captions.captions[i].col + captions.captions[i].width <= 24);
+
+ REGRESS_END();
+}
blob - ded274898ad56b8359bd113ed666fd6b5169d436
blob + 9a92356bbf6c44202ee32acb8d09452d52504700
--- src/fugu/coord.c
+++ src/fugu/coord.c
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)
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;
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:
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;
* 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;
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
.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
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
.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
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
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
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
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
#include "ui_config_rx.h"
#include "ui_layout.h"
#include "ui_palette.h"
+#include "welcome.h"
#include "ui.h"
/* transcript item kinds */
/* input */
struct editor ed;
+ struct welcome_state welcome;
struct term_input ti;
char **comps;
size_t ncomps;
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[] =
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)
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
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)
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 */
}
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);
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();
}
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;
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;
}
"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)
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 */
}
(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);
}
}
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
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <stdint.h>
+#include <string.h>
+
+#include "utf8.h"
+#include "welcome.h"
+
+struct welcome_candidate {
+ const char *text;
+ const char *ascii;
+ enum welcome_row_kind kind;
+ int group;
+ int priority;
+};
+
+static const struct welcome_candidate candidates[WELCOME_ROWS_MAX] = {
+ { "I like Fugu \xe2\x80\x94 Japanese for pufferfish.",
+ "I like Fugu -- Japanese for pufferfish.", WELCOME_ROW_TITLE, 0, 0 },
+ { "by Isaac Meerleo", NULL, WELCOME_ROW_DIM, 0, 1 },
+ { "git ssh://anonymous@got.fugu.farm", NULL, WELCOME_ROW_DIM, 0, 2 },
+ { "Fugu is open source and freely available!", NULL,
+ WELCOME_ROW_TEXT, 0, 3 },
+ { "type /<Tab> to explore commands", NULL,
+ WELCOME_ROW_TEXT, 1, 5 },
+ { "type /model<Enter> to choose a model", NULL,
+ WELCOME_ROW_TEXT, 1, 7 },
+ { "press Esc, then v to compose in your editor", NULL,
+ WELCOME_ROW_TEXT, 1, 8 },
+ { "type /quit<Enter> to exit", NULL,
+ WELCOME_ROW_TEXT, 1, 6 }
+};
+
+static int
+text_width(const char *text)
+{
+ const unsigned char *p = (const unsigned char *)text;
+ size_t i, len, adv;
+ uint32_t cp;
+ int width = 0, w;
+
+ len = strlen(text);
+ for (i = 0; i < len; i += adv) {
+ adv = utf8_decode(p + i, len - i, &cp);
+ w = utf8_width(cp);
+ if (w > 0)
+ width += w;
+ }
+ return (width);
+}
+
+void
+welcome_observe(struct welcome_state *state, int nonempty)
+{
+ if (nonempty)
+ state->dismissed = 1;
+}
+
+int
+welcome_visible(const struct welcome_state *state, size_t transcript_rows)
+{
+ return (!state->dismissed && transcript_rows == 0);
+}
+
+static void
+selection_counts(const int keep[WELCOME_ROWS_MAX], int *count, int *groups)
+{
+ int i, seen[2] = { 0, 0 };
+
+ *count = 0;
+ for (i = 0; i < WELCOME_ROWS_MAX; i++) {
+ if (!keep[i])
+ continue;
+ (*count)++;
+ seen[candidates[i].group] = 1;
+ }
+ *groups = seen[0] + seen[1];
+}
+
+void
+welcome_build(struct welcome_view *view, int xoff, int width, int height,
+ int ascii)
+{
+ const char *text[WELCOME_ROWS_MAX];
+ int cells[WELCOME_ROWS_MAX], keep[WELCOME_ROWS_MAX];
+ int count, gap, groups, i, last_group, row, total, victim;
+
+ memset(view, 0, sizeof(*view));
+ if (xoff < 0 || width <= 0 || height <= 0)
+ return;
+ for (i = 0; i < WELCOME_ROWS_MAX; i++) {
+ text[i] = ascii && candidates[i].ascii != NULL ?
+ candidates[i].ascii : candidates[i].text;
+ cells[i] = text_width(text[i]);
+ keep[i] = cells[i] <= width;
+ }
+
+ for (;;) {
+ selection_counts(keep, &count, &groups);
+ gap = groups == 2 && count + 1 <= height;
+ total = count + gap;
+ if (total <= height)
+ break;
+ victim = -1;
+ for (i = 0; i < WELCOME_ROWS_MAX; i++) {
+ if (keep[i] && (victim == -1 ||
+ candidates[i].priority >= candidates[victim].priority))
+ victim = i;
+ }
+ if (victim == -1)
+ return;
+ keep[victim] = 0;
+ }
+ if (count == 0)
+ return;
+
+ row = (height - total) / 2;
+ last_group = -1;
+ for (i = 0; i < WELCOME_ROWS_MAX; i++) {
+ struct welcome_row *out;
+
+ if (!keep[i])
+ continue;
+ if (gap && last_group == 0 && candidates[i].group == 1)
+ row++;
+ out = &view->rows[view->nrows++];
+ out->text = text[i];
+ out->len = strlen(text[i]);
+ out->kind = candidates[i].kind;
+ out->row = row++;
+ out->width = cells[i];
+ out->col = xoff + (width - cells[i]) / 2;
+ last_group = candidates[i].group;
+ }
+}
+
+static int
+caption_overlaps(const struct welcome_caption_view *view, int col, int width)
+{
+ size_t i;
+
+ for (i = 0; i < view->ncaptions; i++) {
+ const struct welcome_caption *caption = &view->captions[i];
+
+ if (col < caption->col + caption->width &&
+ caption->col < col + width)
+ return (1);
+ }
+ return (0);
+}
+
+void
+welcome_caption_build(struct welcome_caption_view *view, int cols,
+ const struct welcome_field fields[WELCOME_CAPTIONS_MAX])
+{
+ static const char *labels[WELCOME_CAPTIONS_MAX] = {
+ "agent state", "input mode", "context used / limit"
+ };
+ int col, i, width;
+
+ memset(view, 0, sizeof(*view));
+ if (cols <= 0)
+ return;
+ for (i = 0; i < WELCOME_CAPTIONS_MAX; i++) {
+ struct welcome_caption *caption;
+
+ width = (int)strlen(labels[i]);
+ if (fields[i].width <= 0 || width > cols)
+ continue;
+ col = fields[i].col + (fields[i].width - width) / 2;
+ if (col < 0)
+ col = 0;
+ if (col + width > cols)
+ col = cols - width;
+ if (caption_overlaps(view, col, width))
+ continue;
+ caption = &view->captions[view->ncaptions++];
+ caption->text = labels[i];
+ caption->len = strlen(labels[i]);
+ caption->col = col;
+ caption->width = width;
+ }
+}
blob - /dev/null
blob + f34769d92fabad12fad2c073772cf932b44a1cd4 (mode 644)
--- /dev/null
+++ src/fugu-tty/welcome.h
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef WELCOME_H
+#define WELCOME_H
+
+#include <stddef.h>
+
+#define WELCOME_ROWS_MAX 8
+#define WELCOME_CAPTIONS_MAX 3
+
+enum welcome_caption_kind {
+ WELCOME_AGENT_STATE,
+ WELCOME_INPUT_MODE,
+ WELCOME_CONTEXT_LIMIT
+};
+
+enum welcome_row_kind {
+ WELCOME_ROW_TITLE,
+ WELCOME_ROW_DIM,
+ WELCOME_ROW_TEXT
+};
+
+struct welcome_state {
+ int dismissed;
+};
+
+struct welcome_row {
+ const char *text;
+ size_t len;
+ enum welcome_row_kind kind;
+ int row;
+ int col;
+ int width;
+};
+
+struct welcome_view {
+ struct welcome_row rows[WELCOME_ROWS_MAX];
+ size_t nrows;
+};
+
+struct welcome_field {
+ int col;
+ int width;
+};
+
+struct welcome_caption {
+ const char *text;
+ size_t len;
+ int col;
+ int width;
+};
+
+struct welcome_caption_view {
+ struct welcome_caption captions[WELCOME_CAPTIONS_MAX];
+ size_t ncaptions;
+};
+
+void welcome_observe(struct welcome_state *, int);
+int welcome_visible(const struct welcome_state *, size_t);
+void welcome_build(struct welcome_view *, int, int, int, int);
+void welcome_caption_build(struct welcome_caption_view *, int,
+ const struct welcome_field [WELCOME_CAPTIONS_MAX]);
+
+#endif /* WELCOME_H */