Commit Diff


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;