commit e61bc0a1de536144d337a2ad8ed4bfc0163d0d5c from: Isaac Meerleo date: Fri Jul 17 14:00:13 2026 UTC rework the status bar around the Lead context count commit - 4bf2f54fab51f79f5a7f751e2d342775a53fd316 commit + e61bc0a1de536144d337a2ad8ed4bfc0163d0d5c blob - 4763e2d1c582f79c0fc3fdf32bea3a3e753638cd blob + b276003cb91cfc767e9147af854ff9031fdd8fab --- docs/design/m7-curses.md +++ docs/design/m7-curses.md @@ -2,10 +2,10 @@ Milestone M7 builds the full-screen terminal front end (behavior.md §2.1): scrolling transcript, modal vi-like input editor, status bar, -markdown and diff rendering, scrollback, bracketed paste, the external -editor, the conversation drawer, and the model picker. It is by far the -largest milestone, so it is split into testable pieces that land green -one at a time. +markdown and diff rendering, mouse-wheel scrollback, bracketed paste, +the external editor, the conversation drawer, and the model picker. It +is by far the largest milestone, so it is split into testable pieces +that land green one at a time. ## Where the UI runs, and why @@ -67,11 +67,14 @@ Render events (coordinator → fugu-tty), each a lengt - `T_CONFIG` — ascii, notify, mode label, effective window, model id. - `T_USER` — echo a submitted user line into the transcript. - `T_TEXT` — an assistant text delta, appended to the live block. -- `T_TOOL` — a tool call line (name + one-line summary). +- `T_TOOL` — an indented tool call line: name plus a bounded, control-safe + allowlist of useful arguments (paths, search patterns, line windows, and + similar action identifiers; never bulk content, HTTP headers/bodies, edit + replacements, or subagent prompts). - `T_DIFF` — a formatted diff hunk (presentation only). - `T_ERROR` — an error line, visually distinct. - `T_STATUS` — status-bar state: idle/busy/summarizing/retry (with - attempt and delay), plus the usage counters and percent-of-window. + attempt and delay), plus the committed Lead context count. - `T_DONE` — turn (or summarization) complete; fugu-tty bells if `notify`. - `T_CLEARED` — drop the transcript (after `/clear`). @@ -154,13 +157,14 @@ provider active for the session and cold-starts the pr ## Status bar and the context meter The status bar shows idle/busy/summarizing/retry (retry naming the -attempt and pending delay), the input mode, and — once a usage report -exists — input/output and cache token counts plus percent-of-window -against the effective window (`context_limit`, else provider-reported, -else the compiled table, else unknown). At or above 80% the field is a -warning attribute and the first crossing emits a one-line `/compact` -notice. On completion the bell fires unless `notify no`. Line and print -modes never bell. +attempt and pending delay), the input mode, and — once a Lead usage report +commits — `Context current/max N%` against the effective window (`context_limit`, +else provider-reported, else the compiled table, else unknown). `current` +combines the main conversation's cache and session tokens without ephemeral +subagent usage. Counts of at least 1,000 are rounded to the nearest `k`. At or +above 80% the field is a warning attribute and the first crossing emits a +one-line `/compact` notice. On completion the bell fires unless `notify no`. +Line and print modes never bell. ## Status @@ -168,7 +172,8 @@ Built and green (both builds): the render/edit/diff/in (`markdown.c`, `editor.c`, `diff.c`, `term_input.c`, each unit-tested), the coordinator↔tty render/input protocol and terminal-mode driver, and the curses front end (`ui.c`) — layout, markdown/diff/tool/error -rendering, scrollback, status bar with the context gauge, bell, +rendering, scrollback (including xterm basic/SGR mouse-wheel reports), +status bar with the context gauge, bell, bracketed paste, resize, modal editing, completion, history, and the message queue. A pty driver exercises the whole path end to end. blob - 97cd41f060170cfc5dcf29672ebc19cc1040ae5e blob + 809b36c6cdf7bd16c4c795a8cf38403c4b5dfd31 --- handoff/behavior.md +++ handoff/behavior.md @@ -84,10 +84,14 @@ Transcript behavior: current assistant block. - Assistant text renders markdown: headings, fenced code blocks, bullet lists, inline code, bold/italic — via curses attributes. - Unmatched markers render literally. User text renders plain. -- Tool calls appear as they run, visually distinct from prose (the - reference used a `tool:`-prefixed line per call; equivalent clarity - is required, exact dress free). + Unmatched markers render literally. Ordinary assistant text is light + grey when color is available; markdown-specific colors take precedence. + User text renders plain. +- Tool calls appear as they run, indented and visually distinct from prose. + Each `tool:`-prefixed line includes a bounded, control-safe allowlist of + useful arguments: for example, a read's path and line window or a grep's + pattern and path. Bulk or secret-prone values such as file content, edit + replacements, HTTP headers/bodies, and subagent prompts are omitted. - A successful `edit` or `multi_edit` (one before/after diff across all applied pairs), or a `write` replacing an existing file, additionally renders as a unified-diff hunk — hunk header, a few @@ -104,20 +108,23 @@ Transcript behavior: line. Presentation only — it never enters the provider projection or the journal's replayable context (I14). - Errors are visually distinct from assistant output. -- Scrollback: page keys always; while scrolled away from the tail, the - status bar says so and how to return; new output does not yank the - view while the Owner is reading history unless they were at the tail. +- Scrollback: page keys and the mouse wheel always; while scrolled away + from the tail, the status bar says so and how to return; new output does + not yank the view while the Owner is reading history unless they were at + the tail. Status bar: an idle/busy indicator (distinct states for a normal turn, a summarization, and an automatic retry — the retry state shows attempt count and pending delay, §8), the current input mode, and — -once known — the running token usage for the session (input/output -and, when the provider reports them, cache-read/cache-write counts). -Once a usage report exists the bar also shows percent-of-window, -computed from the last report's input tokens against the effective -window (§3, /context); at or above 80% the field renders in a warning -attribute, and the first crossing emits a one-line notice naming -`/compact`. +once known — `Context current/max N%` against the effective window (§3, +`/context`). `current` is the latest committed Lead Generation's active +context: Anthropic and Claude add uncached input, output, cache-read, and +cache-created tokens; OpenAI adds prompt and completion tokens because its +cached-token count is already a subset of prompt tokens. Ephemeral subagent +usage is excluded from this display, while remaining part of machine-readable +whole-Turn usage. Counts of at least 1,000 are rounded to the nearest thousand +and shown with `k`. At or above 80% the field renders in a warning attribute, +and the first crossing emits a one-line notice naming `/compact`. When a turn or summarization completes, the curses interface writes the terminal bell (BEL) so multiplexers and unfocused terminals can @@ -128,8 +135,8 @@ Input editing is modal, vi-like, starting in insert mo - Either mode: `Enter` submits; `Tab`/`Shift-Tab` cycle slash-command completions when the line starts with `/` (built-ins first, then - skills alphabetically); `PgUp`/`PgDn` scroll the transcript; - `Up`/`Down` recall prompt history (this session); `Ctrl-L` repaints; + skills alphabetically); `PgUp`/`PgDn` and the mouse wheel scroll the + transcript; `Up`/`Down` recall prompt history (this session); `Ctrl-L` repaints; `Ctrl-C` clears a non-empty line, quits on an empty one; `Ctrl-D` quits on an empty line; `Esc` enters normal mode. - Normal mode: `h l 0 $ ^ w b e` motions; `i a A I O` (and `o` is @@ -139,6 +146,12 @@ Input editing is modal, vi-like, starting in insert mo `J`/`K` scroll the transcript one line (allowed even mid-turn); `v` composes in the external editor (below). +On taking the terminal the curses front end enables xterm basic mouse +tracking with SGR coordinates and routes wheel reports to transcript +scrollback; click and release reports are consumed without editing input. +It disables mouse reporting on every survivable teardown and while an +external editor owns the terminal. `Up`/`Down` remain prompt-history keys. + Paste is atomic: on taking the terminal the curses front end enables xterm bracketed-paste mode (and disables it on every teardown path the process survives — pledge-violation death leaves the terminal @@ -641,9 +654,10 @@ are the supported alternative. The conversation has one canonical in-memory shape; each route translates at its boundary. Direct routes provide model-listing calls to the picker; a subscription route contributes its configured model and does not issue a -listing call. Usage (input/output tokens, plus cache-read/cache-write when -reported) is surfaced to the UI. Provider errors reach the Owner without -revealing credentials. A turn that errors mid-stream obeys §7. +listing call. Lead usage becomes the UI's provider-aware context count; +subagent usage remains only in whole-Turn accounting. Provider errors reach +the Owner without revealing credentials. A turn that errors mid-stream obeys +§7. Prompt caching: on the direct anthropic protocol every request — lead and subagent — marks cache breakpoints: `cache_control` ephemeral on the blob - 6dac0e8d6065a92ad1ecb31eb628cc8e20be4180 blob + 30d7e413e30c776a622a0944b25a44a210a0eb2d --- handoff/verification.md +++ handoff/verification.md @@ -158,7 +158,8 @@ states the property, not just examples: included; the openai builder emits no cache fields. - **Context meter:** the effective-window resolution order exercised (`context_limit`, provider-reported, compiled table, - unknown); `/context` leaves the journal byte-identical; the 80% + unknown); `/context` leaves the journal byte-identical; the status count + combines Lead cache and session tokens without subagent usage; the 80% gauge threshold and its single first-crossing notice. - **Notify:** bell emitted on turn completion with `notify yes`, absent with `notify no`, never in line or print mode. blob - fad71189bc0121f1a51aae272ac685ff851074a8 blob + ef74eca62ff8762a9ec7a136a3c140102c93357b --- regress/turn/run.sh +++ regress/turn/run.sh @@ -490,6 +490,9 @@ ok "tool loop: final assistant text on stdout" $? # the tool's output was fed back to the provider in the 2nd request grep -q "tool-ran-ok" "$dir/reqout" ok "tool loop: the tool result reached the next request" $? +grep -Fq 'fugu: shell command="if ( exec 4>&3 ) 2>/dev/null; then n=1; else n=0; fi; echo FD3=$n; echo tool-ran-ok"' \ + "$dir/errout" +ok "tool loop: progress shows the bounded command arguments" $? # the coordinator control fd did not leak into the tool subprocess (I10): # the computed FD3=0 marks it closed; FD3=1 would mean a leak grep -q "FD3=0" "$dir/reqout" @@ -901,16 +904,24 @@ stop_stub # the request, the journal commits it) and /quit must exit cleanly. if [ -x "$obj/ptydrive" ]; then rm -rf "$HOME/.fugu" - start_stub "OK" + start_stub "CONTEXT" write_conf "$stubport" printf 'hello\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 \ - "$obj/ptydrive" "$fugu" >/dev/null 2>&1 + "$obj/ptydrive" "$fugu" >"$dir/cursesout" 2>&1 rc=$? ok "curses: clean exit on /quit" $([ "$rc" -eq 0 ]; echo $?) + esc=$(printf '\033') + grep -a -Fq "${esc}[?1000h" "$dir/cursesout" && + grep -a -Fq "${esc}[?1006h" "$dir/cursesout" && + grep -a -Fq "${esc}[?1006l" "$dir/cursesout" && + grep -a -Fq "${esc}[?1000l" "$dir/cursesout" + ok "curses: mouse reporting is enabled and restored" $? grep -q "hello" "$dir/reqout" 2>/dev/null ok "curses: the typed prompt reached the provider" $? + grep -a -Fq "Context 46k/200k 23%" "$dir/cursesout" + ok "curses: status shows Lead cache plus session context" $? n=$(grep -c '"outcome":"ok"' "$sessions"/*.ndjson 2>/dev/null || echo 0) ok "curses: the turn committed to the journal" \ $([ "$n" -ge 1 ]; echo $?) blob - 9155028c505545b78aa5190d9a4026fe25079c59 blob + 280083f566a5ef0fb0dfe2f33789f7df045e9868 --- regress/turn/stub.c +++ regress/turn/stub.c @@ -18,7 +18,8 @@ * Hermetic Anthropic Messages stub for the turn suite: over TLS, it * checks the request carried the x-api-key header, then serves a * canned chunked SSE stream chosen per accepted connection (in argv - * order). Scenarios: OK (a two-delta text reply), ERROR (an SSE + * order). Scenarios: OK (a two-delta text reply), CONTEXT (a reply with + * distinct uncached/cache-read/cache-created/output usage), ERROR (an SSE * error event), HTTP500 (a non-2xx before any stream), HTTP401 (an * Anthropic-style JSON error body), RETRY503/RETRY429 (retryable * statuses, 429 carrying Retry-After: 2), EMPTY (a well-formed @@ -96,6 +97,27 @@ static const char sse_ok[] = "event: message_stop\n" "data: {\"type\":\"message_stop\"}\n\n"; +/* Distinct counters make the context sum observable: 11k+23k+5k+7k=46k. */ +static const char sse_context[] = +"event: message_start\n" +"data: {\"type\":\"message_start\",\"message\":{\"usage\":" + "{\"input_tokens\":11000,\"output_tokens\":0," + "\"cache_read_input_tokens\":23000," + "\"cache_creation_input_tokens\":5000}}}\n\n" +"event: content_block_start\n" +"data: {\"type\":\"content_block_start\",\"index\":0," + "\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n" +"event: content_block_delta\n" +"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":" + "{\"type\":\"text_delta\",\"text\":\"Context reply.\"}}\n\n" +"event: content_block_stop\n" +"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n" +"event: message_delta\n" +"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":" + "\"end_turn\"},\"usage\":{\"output_tokens\":7000}}\n\n" +"event: message_stop\n" +"data: {\"type\":\"message_stop\"}\n\n"; + static const char sse_error[] = "event: error\n" "data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\"," @@ -1366,6 +1388,12 @@ main(int argc, char *argv[]) strlen(body_ok)); write_chunk(conn, sse_ok, strlen(sse_ok)); (void)tls_write_all(conn, "0\r\n\r\n", 5); + } else if (strcmp(argv[i], "CONTEXT") == 0) { + (void)tls_write_all(conn, body_ok, + strlen(body_ok)); + write_chunk(conn, sse_context, + strlen(sse_context)); + (void)tls_write_all(conn, "0\r\n\r\n", 5); } else if (strcmp(argv[i], "ERROR") == 0) { (void)tls_write_all(conn, body_ok, strlen(body_ok)); blob - ad716e87ff862050b2394139462bf2d884011c2b blob + 56b3dbadc3c2724c5f9183464e10d95916ebe673 --- src/fugu/coord.c +++ src/fugu/coord.c @@ -53,6 +53,7 @@ #include "sysprompt.h" #include "skills.h" #include "tooldefs.h" +#include "tool_display.h" #include "agentcfg.h" #include "turn_mechanics.h" #include "turn_txn.h" @@ -218,7 +219,9 @@ struct coord { /* accepted Generation views used by the existing presentation/tool seams */ struct buf text; - struct a_usage usage; + struct a_usage lead_usage; /* latest Lead Generation only */ + int have_context; /* published main-session context */ + int64_t context_tokens; struct tool_call tcalls[MAX_TOOL_CALLS]; int ntcalls; char stop_reason[FUGU_STOP_REASON_MAX]; @@ -1014,12 +1017,21 @@ emit_answer(struct coord *c) } static void -emit_tool(struct coord *c, const char *name) +emit_tool(struct coord *c, const struct tool_call *tc) { + struct buf display; + + buf_init(&display); + tool_display_format(&display, tc->name, tc->input.data, tc->input.len); if (c->ui) - ui_send(c, FUGU_IMSG_UI_TOOL, name, strlen(name)); - else - fprintf(stderr, "fugu: %s\n", name); /* terse (S1) */ + ui_send(c, FUGU_IMSG_UI_TOOL, display.data, display.len); + else { + fputs("fugu: ", stderr); + if (display.len > 0) + fwrite(display.data, 1, display.len, stderr); + fputc('\n', stderr); + } + buf_free(&display); } /* A diff can carry very long lines (the line count is capped, not the @@ -1084,21 +1096,6 @@ emit_msg(struct coord *c, int uitype, FILE *fallback, /* System feedback (stdout / UI_NOTE). */ #define emit_note(c, ...) emit_msg((c), FUGU_IMSG_UI_NOTE, stdout, __VA_ARGS__) -/* Push a status-bar update to the front end (behavior.md 2.1). */ -static void -send_status(struct coord *c, int state) -{ - struct ui_status s; - - if (!c->ui) - return; - memset(&s, 0, sizeof(s)); - s.state = state; - s.have_usage = (c->usage.input_tokens > 0 || c->usage.output_tokens > 0); - s.usage = c->usage; - ui_send(c, FUGU_IMSG_UI_STATUS, &s, sizeof(s)); -} - static int64_t effective_window(struct coord *); /* defined below */ static int @@ -1126,19 +1123,88 @@ usage_add(struct a_usage *dst, const struct a_usage *s return (0); } +/* + * Provider usage does not have one cross-provider cache convention. + * Anthropic (including the Claude subscription route) reports uncached, + * cache-read, and cache-created input as disjoint counters. OpenAI's + * prompt_tokens already includes cached_tokens, which is only a detail of + * that total. Output is part of the context after the Generation settles. + */ +static int64_t +lead_context_tokens(const struct coord *c) +{ + const struct a_usage *u = &c->lead_usage; + int64_t total; + + total = u->input_tokens + u->output_tokens; + if (c->conf->provider_type != PROVIDER_OPENAI) + total += u->cache_read + u->cache_write; + return (total); +} + +static void +context_publish(struct coord *c) +{ + const struct a_usage *u = &c->lead_usage; + + c->have_context = (u->input_tokens > 0 || u->output_tokens > 0 || + u->cache_read > 0 || u->cache_write > 0); + c->context_tokens = c->have_context ? lead_context_tokens(c) : 0; +} + +static void +context_invalidate(struct coord *c) +{ + c->have_context = 0; + c->context_tokens = 0; +} + static int -usage_account(struct coord *c, const struct a_usage *u) +lead_usage_account(struct coord *c, const struct a_usage *u) { - struct a_usage session = c->usage; + struct a_usage lead = c->lead_usage; struct a_usage turn = c->turn_usage; - if (usage_add(&session, u) == -1 || usage_add(&turn, u) == -1) + if (usage_add(&lead, u) == -1 || usage_add(&turn, u) == -1) return (-1); - c->usage = session; + c->lead_usage = lead; c->turn_usage = turn; return (0); } +/* Subagent usage remains part of whole-Turn accounting, never Lead context. */ +static int +subagent_usage_account(struct coord *c, const struct a_usage *u) +{ + struct a_usage turn = c->turn_usage; + + if (usage_add(&turn, u) == -1) + return (-1); + c->turn_usage = turn; + return (0); +} + +static void +status_fill(struct coord *c, struct ui_status *s, int state) +{ + memset(s, 0, sizeof(*s)); + s->state = state; + s->have_context = c->have_context; + s->context_tokens = c->context_tokens; +} + +/* Push a status-bar update to the front end (behavior.md 2.1). */ +static void +send_status(struct coord *c, int state) +{ + struct ui_status s; + + if (!c->ui) + return; + status_fill(c, &s, state); + ui_send(c, FUGU_IMSG_UI_STATUS, &s, sizeof(s)); +} + /* * One typed configuration transaction: fixed settings, bounded Skill * names, then the terminal frame that makes the snapshot visible. Keeping @@ -1296,7 +1362,7 @@ stream_event(struct coord *c, struct imsg *imsg) au.cache_read = ev.usage.cache_read; au.cache_write = ev.usage.cache_write; if (!c->turn_data_exceeded && !c->turn_usage_exceeded) { - if (usage_account(c, &au) == -1) { + if (lead_usage_account(c, &au) == -1) { if (c->turn_generation_io != NULL) c->turn_usage_exceeded = 1; else @@ -1313,10 +1379,7 @@ stream_event(struct coord *c, struct imsg *imsg) ui_send(c, FUGU_IMSG_UI_NOTE, ev.payload.data, ev.payload.len); - memset(&s, 0, sizeof(s)); - s.state = UI_RETRY; - s.have_usage = 1; - s.usage = c->usage; + status_fill(c, &s, UI_RETRY); ui_send(c, FUGU_IMSG_UI_STATUS, &s, sizeof(s)); } else fprintf(stderr, "fugu: %.*s\n", (int)ev.payload.len, @@ -1672,6 +1735,7 @@ ui_event(struct coord *c, struct imsg *imsg) free(c->conf->model); c->conf->model = xstrdup(id); turn_txn_cache_reset(c->turn); /* the cache starts cold (8) */ + context_invalidate(c); /* tokenization/provider changed */ send_ui_config(c); /* the status bar shows it */ emit_note(c, "model: %s", id); break; @@ -2447,7 +2511,7 @@ agent_dispatch(int fd, short event, void *arg) if (turn_tool_results_add_usage(c->turn_tool_results, &gu) == -1) c->turn_tool_results_rejected = 1; - observer_rc = usage_account(c, &u); + observer_rc = subagent_usage_account(c, &u); if (observer_rc == -1) c->turn_usage_exceeded = 1; else @@ -2806,7 +2870,7 @@ lead_generate(void *arg, const struct turn_generation_ reset_tcalls(c); buf_reset(&c->text); - memset(&c->usage, 0, sizeof(c->usage)); + memset(&c->lead_usage, 0, sizeof(c->lead_usage)); c->stop_reason[0] = '\0'; c->err[0] = '\0'; c->generation_terminal = 0; @@ -2867,7 +2931,7 @@ pump_generation(struct coord *c, size_t data_available memset(result, 0, sizeof(*result)); reset_tcalls(c); buf_reset(&c->text); - memset(&c->usage, 0, sizeof(c->usage)); + memset(&c->lead_usage, 0, sizeof(c->lead_usage)); c->stop_reason[0] = '\0'; c->err[0] = '\0'; c->generation = generation_new(data_available); @@ -3431,7 +3495,7 @@ lead_execute(void *arg, const struct turn_tool_batch * call = &batch->calls[i]; c->turn_tool_calls++; machine_tool_call(c, tc); - emit_tool(c, tc->name); /* terse (S1) */ + emit_tool(c, tc); if (strcmp(tc->name, "agent") == 0) { if (!tc->result_ready) agent_call_error(tc, "agent: no result"); @@ -3556,7 +3620,7 @@ run_turn(struct coord *c, const char *prompt, size_t p msg_add_text(um, prompt, promptlen); memset(&c->turn_usage, 0, sizeof(c->turn_usage)); - memset(&c->usage, 0, sizeof(c->usage)); + memset(&c->lead_usage, 0, sizeof(c->lead_usage)); c->turn_tool_calls = 0; c->turn_data = promptlen; c->turn_data_exceeded = 0; @@ -3610,6 +3674,7 @@ run_turn(struct coord *c, const char *prompt, size_t p "turn abandoned by session switch")); if (gate != COMMIT_GATE_COMMITTED) return (cancel_turn(c)); + context_publish(c); emit_answer(c); machine_finish(c, 0); return (0); @@ -3620,6 +3685,7 @@ run_turn(struct coord *c, const char *prompt, size_t p "turn abandoned by session switch")); if (gate != COMMIT_GATE_COMMITTED) return (cancel_turn(c)); + context_publish(c); emit_error(c, "fugu: reached the %d-step tool limit for one turn", TURN_ITER_MAX); machine_error(c, "tool-loop iteration limit reached"); @@ -3678,6 +3744,7 @@ cmd_clear(struct coord *c) turn_txn_clear(c->turn, contextp); buf_reset(&c->compaction); /* a reset voids the summary */ rebuild_system(c); /* drop the summary either way */ + context_invalidate(c); if (c->ui) ui_send(c, FUGU_IMSG_UI_CLEARED, NULL, 0); emit_note(c, "context cleared"); @@ -3770,6 +3837,7 @@ cmd_compact(struct coord *c) buf_reset(&c->compaction); buf_add(&c->compaction, c->text.data, c->text.len); rebuild_system(c); + context_invalidate(c); if (c->ui) ui_send(c, FUGU_IMSG_UI_CLEARED, NULL, 0); emit_note(c, "conversation compacted (~%lld tokens of notes)", blob - 2811160f11ee550272b36ff18b2e241e843b186d blob + c842be5413d99c5bbb441fd8cb9ef666c916c8ec --- src/fugu/fugu.1 +++ src/fugu/fugu.1 @@ -142,7 +142,15 @@ Assistant markdown headings, lists, fenced code, empha are rendered with terminal attributes; user messages remain plain text. Tool calls, errors, and edit diffs are visually distinct. The status bar identifies idle, active-turn, summarization, and retry states, -and shows token usage and effective-context-window use when known. +and shows +.Dq Context current/max N% +when known. +The current count combines cache and session tokens from the main +conversation's latest committed generation; ephemeral subagent usage is +excluded. +Counts of at least 1,000 are rounded to the nearest thousand and carry a +.Dq k +suffix. At 80 percent of the effective window it warns once and names .Cm /compact . . @@ -160,6 +168,8 @@ Cycle built-in slash-command and skill completions whe Recall prompt history. .It Ic PageUp , PageDown Scroll the transcript. +.It Ic Mouse wheel +Scroll the transcript one line per report without changing prompt history. .It Ic Ctrl-L Repaint the display. .It Ic Ctrl-C blob - 33b066010d4001607e945821266929cb6d00ae58 blob + 6c69d306111f7d24e254e16cf6e009bf954d370b --- src/fugu/proto.h +++ src/fugu/proto.h @@ -423,8 +423,8 @@ struct ui_status { int state; /* enum ui_state */ int attempt; /* retry attempt number (UI_RETRY) */ int delay_ms; /* pending retry delay (UI_RETRY) */ - int have_usage; /* usage fields are meaningful */ - struct a_usage usage; /* running token counts */ + int have_context; /* context_tokens is meaningful */ + int64_t context_tokens; /* Lead context only; excludes subagents */ }; /* A tool_use block opening; the id and name follow as "id\0name". */ blob - 05f771be2db08fe8748ce411937e60884c6fc586 blob + a01c9a8bd283e2f1a2f45eca0eebf5effc9fb437 --- src/fugu-tty/ui.c +++ src/fugu-tty/ui.c @@ -67,6 +67,7 @@ struct dline { #define P_TOOL 5 #define P_ERR 6 #define P_DIM 7 +#define P_TEXT 8 /* the conversation drawer (behavior.md 2.1): reference width, and the * narrowest terminal it will occupy without starving the transcript */ @@ -164,6 +165,24 @@ struct ui { static struct ui *UI; /* for the atexit terminal restore */ +/* 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[] = + "\033[?2004h\033[?1000h\033[?1006h"; +static const char terminal_modes_off[] = + "\033[?1006l\033[?1000l\033[?2004l"; + +static void +terminal_modes(int enable) +{ + if (enable) + (void)write(STDOUT_FILENO, terminal_modes_on, + sizeof(terminal_modes_on) - 1); + else + (void)write(STDOUT_FILENO, terminal_modes_off, + sizeof(terminal_modes_off) - 1); +} + /* ---- small helpers -------------------------------------------------- */ static int @@ -183,6 +202,8 @@ md_attr(const struct ui *u, int md) a |= u->color ? COLOR_PAIR(P_CODE) : A_DIM; if (md & MD_BULLET) a |= A_BOLD; + if (u->color && !(md & (MD_HEADING | MD_CODE | MD_CODEBLOCK))) + a |= COLOR_PAIR(P_TEXT); return (a); } @@ -230,25 +251,27 @@ line_add(struct dline *l, int attr, const char *text, /* Wrap plain UTF-8 text to width columns, one attr, into new lines. */ static void render_plain(struct ui *u, const char *text, size_t len, int width, int attr, - const char *prefix) + const char *prefix, const char *continuation) { struct dline *l; size_t i = 0; int col; size_t last_break, seg_start; + const char *lead; if (width < 1) width = 1; do { l = line_new(u); col = 0; - if (prefix != NULL && i == 0) { + lead = i == 0 ? prefix : continuation; + if (lead != NULL) { int pw = 0; size_t k; - for (k = 0; prefix[k] != '\0'; k++) + for (k = 0; lead[k] != '\0'; k++) pw++; - line_add(l, attr, prefix, strlen(prefix), pw); + line_add(l, attr, lead, strlen(lead), pw); col = pw; } seg_start = i; @@ -334,7 +357,7 @@ render_diff(struct ui *u, const char *text, size_t len attr = u->color ? COLOR_PAIR(P_TOOL) : A_BOLD | A_UNDERLINE; } - render_plain(u, text + i, j - i, width, attr, NULL); + render_plain(u, text + i, j - i, width, attr, NULL, NULL); i = (j < len) ? j + 1 : j; } } @@ -376,25 +399,26 @@ rebuild(struct ui *u) switch (it->kind) { case IT_USER: render_plain(u, t, n, w, - A_BOLD, "> "); + A_BOLD, "> ", NULL); break; case IT_ASSIST: render_assist(u, it, w); break; case IT_TOOL: render_plain(u, t, n, w, - u->color ? COLOR_PAIR(P_TOOL) : A_DIM, "tool: "); + u->color ? COLOR_PAIR(P_TOOL) : A_DIM, + " tool: ", " "); break; case IT_DIFF: render_diff(u, t, n, w); break; case IT_NOTE: render_plain(u, t, n, w, - u->color ? COLOR_PAIR(P_DIM) : A_DIM, NULL); + u->color ? COLOR_PAIR(P_DIM) : A_DIM, NULL, NULL); break; case IT_ERROR: render_plain(u, t, n, w, - u->color ? COLOR_PAIR(P_ERR) : A_BOLD, "! "); + u->color ? COLOR_PAIR(P_ERR) : A_BOLD, "! ", NULL); break; } } @@ -546,34 +570,55 @@ draw_input(struct ui *u, int row) } static void -fmt_usage(const struct ui *u, char *out, size_t sz) +fmt_token_count(int64_t tokens, char *out, size_t sz) { - const struct a_usage *g = &u->status.usage; - int64_t pct = -1; + int64_t rounded; - if (u->window > 0 && g->input_tokens > 0) - pct = g->input_tokens * 100 / u->window; - if (g->cache_read > 0 || g->cache_write > 0) - snprintf(out, sz, "in %lld out %lld cache %lld/%lld", - (long long)g->input_tokens, (long long)g->output_tokens, - (long long)g->cache_read, (long long)g->cache_write); - else - snprintf(out, sz, "in %lld out %lld", - (long long)g->input_tokens, (long long)g->output_tokens); - if (pct >= 0) { - char pbuf[32]; - - snprintf(pbuf, sizeof(pbuf), " %lld%%", (long long)pct); - strlcat(out, pbuf, sz); + if (tokens < 1000) { + snprintf(out, sz, "%lld", (long long)tokens); + return; } + rounded = tokens / 1000 + (tokens % 1000 >= 500); + snprintf(out, sz, "%lldk", (long long)rounded); } static void +fmt_context(const struct ui *u, char *out, size_t sz) +{ + char current[32], maximum[32]; + + fmt_token_count(u->status.context_tokens, current, sizeof(current)); + if (u->window > 0) { + int64_t q, tail, pct; + + fmt_token_count(u->window, maximum, sizeof(maximum)); + 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, + (long long)pct); + } else + snprintf(out, sz, "Context %s/?", current); +} + +static int +context_warn(const struct ui *u) +{ + int64_t threshold; + + if (!u->status.have_context || u->window <= 0) + return (0); + threshold = (u->window * 80 + 99) / 100; + return (u->status.context_tokens >= threshold); +} + +static void draw_status(struct ui *u, int row) { - char left[128], right[160], usage[96]; - int64_t pct = -1; - int warn = 0; + char left[128], right[160], context[96]; + int warn; switch (u->status.state) { case UI_BUSY: @@ -604,15 +649,13 @@ draw_status(struct ui *u, int row) } right[0] = '\0'; - if (u->status.have_usage) { - fmt_usage(u, usage, sizeof(usage)); - strlcpy(right, usage, sizeof(right)); - if (u->window > 0 && u->status.usage.input_tokens > 0) - pct = u->status.usage.input_tokens * 100 / u->window; + if (u->status.have_context) { + fmt_context(u, context, sizeof(context)); + strlcpy(right, context, sizeof(right)); } if (u->notice[0] != '\0') strlcpy(right, u->notice, sizeof(right)); - warn = (pct >= 80); + warn = context_warn(u); move(row, 0); attrset(warn ? (u->color ? COLOR_PAIR(P_ERR) : A_BOLD) : A_REVERSE); @@ -993,7 +1036,7 @@ compose_begin(struct ui *u) u->esc_pending = 0; } event_del(&u->ev_in); - (void)write(STDOUT_FILENO, "\033[?2004l", 8); /* paste off */ + terminal_modes(0); endwin(); imsgev_send_chunks(&u->w->iev, FUGU_IMSG_UI_COMPOSE, 0, text, len); imsgev_send(&u->w->iev, FUGU_IMSG_UI_COMPOSE_GO, 0, @@ -1408,7 +1451,7 @@ compose_end(struct ui *u, const struct edit_result *r) return; } u->composing = 0; - (void)write(STDOUT_FILENO, "\033[?2004h", 8); /* paste back on */ + terminal_modes(1); refresh(); /* return from endwin to program mode */ clearok(stdscr, TRUE); @@ -1537,9 +1580,7 @@ ui_render(struct worker *w, struct imsg *imsg) if (p != NULL && len == sizeof(u->status)) { memcpy(&u->status, p, sizeof(u->status)); u->busy = (u->status.state != UI_IDLE); - if (u->window > 0 && u->status.usage.input_tokens > 0 && - u->status.usage.input_tokens * 100 / u->window >= 80 - && !u->warned80) { + if (context_warn(u) && !u->warned80) { add_item(u, IT_NOTE, "context is over 80% of " "the window -- consider /compact", 54); u->warned80 = 1; @@ -1657,7 +1698,7 @@ ui_stop(void) { if (UI == NULL) return; - (void)write(STDOUT_FILENO, "\033[?2004l", 8); /* bracketed paste off */ + terminal_modes(0); endwin(); UI = NULL; } @@ -1685,8 +1726,9 @@ ui_start(struct worker *w, int cols, int rows) init_pair(P_TOOL, COLOR_CYAN, -1); init_pair(P_ERR, COLOR_RED, -1); init_pair(P_DIM, COLOR_BLUE, -1); + init_pair(P_TEXT, COLOR_WHITE, -1); } - (void)write(STDOUT_FILENO, "\033[?2004h", 8); /* bracketed paste on */ + terminal_modes(1); u = xcalloc(1, sizeof(*u)); u->w = w;