commit babda3eaa7c8cc9a67a59ab6e6de39a31910713f from: Isaac Meerleo date: Fri Jul 17 14:27:37 2026 UTC Merge remote-tracking branch 'origin/main' into codex/context-compaction-01 # Conflicts: # regress/Makefile commit - 8d819406d7a0b6260ad8fd9433562a701de987e8 commit + babda3eaa7c8cc9a67a59ab6e6de39a31910713f blob - /dev/null blob + 04fa135dd892025bb9daaae87c151492d232f94f (mode 644) --- /dev/null +++ .scratch/context-compaction/issues/01-serialize-conversation-for-summarization.md @@ -0,0 +1,83 @@ +# Serialize conversation history for summarization + +Status: needs-triage +Type: task + +## What to build + +Change `/compact` so the summarizer receives one explicitly delimited text +document instead of the original messages in their live User/Assistant roles. +Render the canonical conversation with labels such as: + +```text +[User]: What the Owner said + +[Assistant]: Response text + +[Assistant tool calls]: read(path="foo.c") + +[Tool result]: Tool output +``` + +Wrap that serialization in `` tags and use a summarizer-specific +system instruction that says to summarize, never answer or continue the +embedded conversation. Tool results are capped at 2,000 bytes each and retain +their beginning followed by a conspicuous count of omitted bytes. + +This adapts pi's +[`serializeConversation()`](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/compaction/utils.ts) +and summarization request shape rather than copying TypeScript into fugu. The +current fugu message model has no thinking block, so it does not emit pi's +`[Assistant thinking]` record unless that becomes canonical conversation state +in a separate change. + +## Why + +Today `cmd_compact()` passes the active Projection as ordinary provider +messages and appends the summarization instruction as one more User message. +The model can interpret that as a conversation to continue. The raw tool +results also make the compaction request nearly as vulnerable to context +overflow as the request it is meant to rescue. + +## Acceptance criteria + +- [ ] A pure, length-aware serializer handles User text, Assistant text, + Assistant tool calls, and User tool results in Projection order. +- [ ] Labels and blank-line separation are byte-exact and covered by unit + tests; embedded role-looking text remains data inside the delimited document. +- [ ] Each tool result keeps at most 2,000 bytes, never splits valid UTF-8, and + appends an omission marker containing the exact omitted-byte count. +- [ ] Tool names and JSON arguments are rendered deterministically and remain + bounded by the existing Turn/request limits. +- [ ] `/compact` sends exactly one serialized User document under a + summarizer-specific system prompt, with no tools and no live conversation + roles preceding it. +- [ ] The generated summary still becomes the system-block continuation notes, + is journaled, resumes faithfully, and leaves manual `/compact` UI behavior + unchanged. +- [ ] Adversarial tests cover control bytes, malformed UTF-8, empty blocks, + 1,999/2,000/2,001-byte results, multiple tools, and the request-size bound. +- [ ] The compaction contract is documented in `docs/compaction.md` (creating + the document if this is the first compaction ticket implemented). + +## Code-cost analysis + +Expected size: **small-to-medium, about 350-600 lines including tests and +documentation, across 3-5 focused commits**. + +| Seam | Likely work | Cost/risk | +| --- | --- | --- | +| Serializer | New `compaction.c/.h` over `struct msg`/`struct block`, UTF-8-safe truncation, deterministic tool formatting | 140-220 production lines; medium I13 risk | +| Request shape | Let `cmd_compact()` supply a summarizer system prompt and one serialized message instead of the live Projection | 60-120 production lines; medium provider-parity risk | +| Unit and wire tests | New pure suite plus `regress/turn` assertions for exact Anthropic/OpenAI/Claude Subscription bodies | 140-230 test lines | +| Docs/build | Makefile registration and compaction documentation | 30-60 lines | + +The main uncertainty is whether “2 KB” means pi's exact 2,000-character +JavaScript cap or a 2,048-byte fugu cap. This ticket recommends a named +2,000-byte cap so the bound is deterministic in fugu's byte-oriented APIs. + +## Blocked by + +None - can start immediately. + +## Comments blob - /dev/null blob + 7a534471a6b428bfe98854a4d770d66043727fd5 (mode 644) --- /dev/null +++ .scratch/context-compaction/issues/02-cumulative-file-tracking-in-summaries.md @@ -0,0 +1,83 @@ +# Carry cumulative file tracking through compaction summaries + +Status: needs-triage +Type: task + +## What to build + +Every fugu-generated compaction summary carries deterministic +`` and `` blocks. Collect paths from structured +`read`, `write`, `edit`, and `multi_edit` tool calls in the span being +summarized, merge them with the previous fugu compaction's structured file +details, sort and deduplicate them, and append the blocks to the model-written +summary before it becomes continuation notes in the system block. + +This follows pi's cumulative file-operation design in +[`compaction.ts`](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/compaction/compaction.ts) +and +[`utils.ts`](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/compaction/utils.ts): +modified paths win over read-only paths, and prior structured details—not the +model's prose—are the accumulation source. + +## Why + +The information is cheap to derive from tool calls, survives lossy prose +summarization, and is directly useful to the same model when the summary is +injected into fugu's system block. Without cumulative state, a second +compaction can forget files that were important before the first cut. + +## Acceptance criteria + +- [ ] A length-aware collector extracts `path` only from the supported + structured file-tool arguments; shell command text is never guessed as a + path. +- [ ] Read-only paths appear under `` and any path written or + edited appears only under ``, regardless of operation order. +- [ ] Lists are bytewise deterministic, sorted, and deduplicated across the + current span and every prior fugu-generated compaction. +- [ ] The final XML-like blocks are generated by fugu and appended once after + model prose; the model is not trusted to preserve or generate the lists. +- [ ] Paths with newlines, controls, tag delimiters, invalid UTF-8, or excessive + length cannot forge block structure; the encoding/rejection rule is pinned + and tested. +- [ ] The compact journal record stores optional structured `read_files` and + `modified_files` details, remains backward-compatible with old summary-only + records, and restores the sets on resume. +- [ ] `/clear` clears cumulative file state. A failed or cancelled compaction + changes neither the active summary nor its file sets. +- [ ] Two successive `/compact` operations, resume-then-compact, read-then-edit + promotion, duplicates, malformed tool input, and empty lists have regression + coverage. +- [ ] `docs/compaction.md` documents the blocks, cumulative semantics, and that + they describe structured tool calls rather than an audited filesystem log. + +## Code-cost analysis + +Expected size: **medium, about 500-850 lines including tests and docs, across +4-6 focused commits**. + +| Seam | Likely work | Cost/risk | +| --- | --- | --- | +| Collection/formatting | Bounded path sets, JSON argument extraction, modified-wins normalization, safe block rendering | 180-300 production lines; medium untrusted-input risk | +| Durable state | Extend compact journal records and replay with optional arrays; carry/reset state in the coordinator | 100-180 production lines; medium I14 compatibility risk | +| Integration | Merge prior details with the compacted Projection and append generated blocks exactly once | 50-100 production lines | +| Tests/docs | Pure collector tests plus journal, resume, clear, and repeated-compaction integration coverage | 220-350 lines | + +Using optional journal fields costs more than parsing tags back out of prose, +but keeps cumulative state structural and makes malformed model output +irrelevant. It is still a bounded change and introduces no dependency or new +privilege. + +## Blocked by + +None - can start immediately. If ticket 01 is implemented concurrently, land +the shared compaction module first to avoid overlapping edits. + +## Triage decision + +Decide whether a failed file-tool call should be tracked as model-visible work +history (pi tracks calls) or omitted as an unsuccessful operation. The cheap +pi-compatible default is to track accepted tool calls and document that the +lists are not proof that an operation succeeded. + +## Comments blob - /dev/null blob + a1ee71280dbc6e9b4aa380b46c6066acfce926f4 (mode 644) --- /dev/null +++ .scratch/context-compaction/issues/03-auto-compaction-with-overflow-recovery.md @@ -0,0 +1,124 @@ +# Add automatic compaction with one-shot overflow recovery + +Status: needs-triage +Type: task + +## What to build + +Add automatic context compaction around the canonical Lead Turn. Trigger it +before a Generation when: + +```text +contextTokens > contextWindow - reserveTokens +``` + +Use defaults of 16,384 reserve tokens and 20,000 recent tokens kept. Select a +cut by walking backward over journal/Projection entries, normally cutting at a +Turn boundary and never at a tool-result message. If one Turn alone exceeds +the keep budget, summarize its early prefix separately and retain a valid +Assistant boundary with every tool call/result pair intact. + +If a provider rejects a request specifically for context overflow, compact the +same Turn state and retry that rejected Generation exactly once. Never rerun an +already executed tool, never retry after accepted content, and never recurse if +the summarization request itself overflows. + +This adopts the high-value behavior documented in pi's +[`docs/compaction.md`](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/compaction.md), +adapted to fugu's append-only Journal, active Turn transaction, provider-neutral +Generation, and privilege-separated API worker. + +## Current behavior and implementation constraints + +- `/compact` is manual, requires an idle `turn_txn`, summarizes the entire + Projection, journals one summary string, and resets all messages. +- `/context` has a byte-derived estimate and `effective_window()` can resolve a + configured, provider-reported, or compiled window. There is no tokenizer for + the unsent Projection. +- `turn_run()` treats every provider error as terminal failure. Provider error + type/message currently collapse into a display string, so overflow cannot be + classified safely without extending the Generation/API seam. +- A Lead Turn accepts its User message, Assistant tool calls, and tool results + into an active transaction before commit. Prefix replacement therefore must + preserve `pending_first`, Projection receipts, cache state, and journal + abandonment semantics. +- Resume currently treats a compact record as a full reset. Keeping a recent + suffix requires a durable cut reference or a bounded retained-message + snapshot; prose alone is insufficient. + +## Acceptance criteria + +- [ ] Auto-compaction defaults to enabled and uses named defaults of 16,384 + reserve tokens and 20,000 kept tokens; configuration syntax, validation, and + disable behavior are documented and tested. +- [ ] With a known effective window, the strict `>` threshold is evaluated on + every prospective Lead Generation, including continuations after tool + results. With an unknown window, threshold compaction is skipped but typed + overflow recovery remains available. +- [ ] `contextTokens` uses one documented conservative estimator over the + actual prospective Projection. Provider usage may calibrate it but stale + usage from a prior request is never treated as the exact next-request size. +- [ ] The cut planner normally retains whole recent Turns up to the 20,000-token + target and never starts retained context with a tool-result message or leaves + a tool call without its result. +- [ ] A Turn larger than the keep target is split only at a valid User or + Assistant boundary. Its early prefix receives a distinct Turn-context + summary that is merged with the history summary before the valid suffix. +- [ ] Compaction can replace a prefix while the transaction is idle or active + without committing, abandoning, duplicating, or losing pending Turn state. +- [ ] The compact journal record durably identifies or contains the retained + suffix, and resume reconstructs exactly the same provider-valid Projection. +- [ ] Provider adapters/API IPC expose a bounded typed context-overflow + category based on HTTP status and provider error type/code—not broad English + substring matching. Unknown errors retain today's failure behavior. +- [ ] An overflow-rejected Generation with no accepted content compacts and is + retried once. A second overflow, any post-content failure, cancellation, or + compaction failure ends the Turn conspicuously with no further retry. +- [ ] Tool side effects occur at most once across recovery; tests cover an + overflow after a completed tool-result batch and prove tools are not rerun. +- [ ] Prompt-cache receipts reset after a cut, usage remains additive and + nonduplicated, and the UI distinguishes summarization from the retry. +- [ ] Manual `/compact` remains available and shares the serializer, summary + policy, durable state, and safety checks rather than maintaining a second + implementation. +- [ ] `docs/compaction.md`, `fugu(1)`, `fugu.conf(5)`, behavior, design, and + verification docs define threshold, cuts, split Turns, overflow recovery, + unknown-window behavior, and the exactly-once limit. + +## Code-cost analysis + +Expected size: **medium-large and high-risk, about 1,800-3,000 lines including +tests and documentation, across 7-11 reviewable commits**. The value is high, +but this is not a one-condition change: the cost is transactional correctness +and durable replay. + +| Seam | Likely work | Cost/risk | +| --- | --- | --- | +| Token accounting and cut planner | Prospective Projection estimator, effective-window policy, Turn/tool-pair boundary planner, split-Turn preparation | 220-380 production + 300-500 test lines; medium algorithmic risk | +| Shared compactor | Reuse ticket 01 serialization, summarize history/prefix, merge prior summary/details, enforce no-tools and bounded output | 140-260 production + 180-300 test lines | +| Turn transaction and Journal | Prefix replacement while idle/active, retained-suffix durability, replay, cache receipt reset, clear/abandon behavior | 220-380 production + 350-550 test lines; high I14 risk | +| Provider error typing | Preserve provider error code/type and HTTP classification through `fugu-api` → Generation → Turn result | 120-220 production + 180-300 test lines; medium protocol risk | +| Retry orchestration | Re-enter only the rejected Generation, cap at one, preserve usage and tool exactly-once semantics | 160-300 production + 250-450 integration-test lines; high side-effect risk | +| Config/UI/docs | Defaults, parser/man page, summarize/retry notices, `docs/compaction.md` and verification matrix | 120-220 implementation/doc lines + config tests | + +The estimate assumes tickets 01 and 02 land first. Without them, add roughly +700-1,100 lines and two to four commits. No new runtime dependency or privilege +is required; the security boundary stays the same, but the coordinator and +Journal state machines gain new states that require adversarial crash/resume +tests. + +## Blocked by + +- [01 — Serialize conversation history for summarization](./01-serialize-conversation-for-summarization.md) +- [02 — Carry cumulative file tracking through compaction summaries](./02-cumulative-file-tracking-in-summaries.md) + +## Triage decisions + +1. Pin the configuration names and whether model-specific `context_limit` + remains an override for auto-compaction. +2. Choose the durable suffix representation: stable Journal entry references + or an explicit retained-message snapshot in the compact record. +3. Pin provider-specific overflow codes/statuses from captured primary-source + fixtures before implementing the typed classifier. + +## Comments blob - /dev/null blob + e6be03890dd3165767eaab2da1f7f733a2665e2e (mode 644) --- /dev/null +++ .scratch/context-epochs/issues/01-evaluate-immutable-context-epochs.md @@ -0,0 +1,94 @@ +# Evaluate immutable Context Epochs and chronological context deltas + +Status: needs-triage +Type: research + +## Research question + +Should fugu stop rebuilding its privileged system block when context changes +and instead persist an immutable Context Epoch plus chronological, model-visible +“context changed” deltas? + +OpenCode V2's +[`specs/v2/session.md`](https://github.com/anomalyco/opencode/blob/dev/specs/v2/session.md) +persists the exact privileged System Context shown to the model as an immutable +baseline. At safe provider-turn boundaries it observes context sources and, if +one changed, atomically journals one chronological System message and advances +the epoch snapshot. A completed compaction or Session move starts a fresh +baseline. This preserves history semantics and allows an unchanged provider +cache prefix to remain valid. + +Fugu currently composes `sysfull` from the base prompt, personal context, +project context, and compaction notes. It journals personal/project bytes and +the summary, rebuilds after startup, `/clear`, and `/compact`, and deliberately +starts cache state cold after `/clear`, `/compact`, `/model`, and resume. Its +canonical conversation has only User and Assistant roles, so chronological +System deltas would be a real model/journal/provider change rather than a +different representation of existing state. + +## What to investigate + +1. Pin the OpenCode source revision and describe Context Epoch admission, + source observation, atomic delta publication, compaction reset, unavailable + source behavior, and prompt-cache intent. +2. Inventory every fugu system-context source and every time it can actually + change during a Session: configured/built-in system prompt, personal and + project context, `#` capture, `/clear`, `/compact`, model/provider selection, + skills, environment/date facts, and resume under changed config/files. +3. Verify or reject the premise that fugu silently rewrites prior context. + `journal_context()` already snapshots project/personal bytes for replay; + determine exactly which baseline inputs remain unjournaled and which live + changes are not re-observed at all. +4. Compare three designs: + - keep full-system rebuilds and document their cache/replay semantics; + - journal full immutable Context Epoch snapshots but still rebuild on an + explicit epoch boundary; or + - add canonical chronological System-delta messages at safe boundaries. +5. For each design, analyze Anthropic and Claude Subscription cache breakpoint + stability, OpenAI behavior, provider request validity, context growth, + compaction interaction, prompt-injection presentation, and crash/resume + fidelity under invariant I14. +6. Measure or estimate whether cache savings justify adding a System role and + context-observation state machine in a one-Owner local process. + +## Deliverable + +A checked-in research note with: + +- a source-by-source current-state and replay table; +- a timeline comparison for startup → context edit → next Turn → compaction → + resume; +- explicit findings for semantic fidelity and provider-cache validity; +- a recommendation to retain, partially adopt, or fully adopt Context Epochs; +- an ADR proposal if the canonical conversation or Journal contract should + change; and +- separately scoped implementation tickets with code-cost estimates only if + adoption is recommended. + +## Acceptance criteria + +- [ ] The mutable OpenCode `dev` source is pinned to a commit. +- [ ] Claims about current fugu behavior cite code, behavior/design docs, and + journal/cache regressions; the ticket's premise is tested, not assumed. +- [ ] The analysis distinguishes a context-source snapshot, a system baseline, + a chronological delta, a compaction summary, and prompt-cache bookkeeping. +- [ ] `/clear`, `/compact`, `#` capture, model switch, changed files before + resume, and changed config before resume each receive an explicit verdict. +- [ ] Any proposal preserves fugu's provider-neutral Projection validity and + append-only crash/replay invariants. +- [ ] The recommendation quantifies implementation complexity and expected + cache benefit well enough to decide whether to build it. + +## Research cost + +Expected size: **medium, roughly 250-500 lines of evidence and design analysis**. +The research is inexpensive; full adoption would likely be medium-to-large +because it touches the canonical message roles, request builders, Journal +replay, context observation, compaction, and all provider cache tests. + +## Blocked by + +None - can start immediately. Coordinate conclusions with the context +compaction tickets because compaction is an epoch boundary in both designs. + +## Comments blob - /dev/null blob + 9796c7c5c1500c732624e5ef9ac18020942b7952 (mode 644) --- /dev/null +++ .scratch/durable-tool-output/issues/01-retain-bounded-tool-output-artifacts.md @@ -0,0 +1,115 @@ +# Retain full tool output as private, bounded artifacts + +Status: needs-triage +Type: task + +## What to build + +Treat output bounding as a settlement boundary. The model still receives a +small bounded preview, but output beyond that preview is retained—up to a +larger hard artifact cap—in Owner-only managed storage. The result names an +opaque output ID, retained byte count, truncation state, and the paged tool the +model can use to inspect another range without rerunning the command. + +Start with foreground `shell`, `grep`, `find`, and `ls`, whose subprocess +capture currently stops at 256 KiB, and `shell_output`, whose 256 KiB ring drops +overflow. Decide explicitly whether brokered web output belongs in the first +slice or a follow-up. + +Do **not** add OpenCode's stale-registration rejection. Fugu's tool definitions +are static C objects selected from startup configuration/skills; it has no +runtime plugin registry, registration generation, or stale executor handle to +reject. Revisit that rule only if dynamic tools/MCP/plugins are introduced. + +## Why the safe version is not just a larger buffer + +- Foreground subprocesses are killed when capture exceeds 256 KiB today, so + bytes beyond the marker do not exist to recover. +- Under `allow_write no`, `fugu-tool` has no `wpath`/`cpath`; creating a file in + the project would weaken the kernel-enforced read-only mode. +- The coordinator can create private 0600 files only inside its existing + session unveil, while the tool worker cannot open that directory. +- A raw session path is not useful to the model because normal file tools + cannot read it, and placing generated logs in the project risks accidental + commits and secret disclosure. +- Truly unbounded retention permits disk exhaustion. “Full” must mean complete + up to a documented per-artifact and aggregate-session quota, followed by a + second conspicuous truncation boundary. + +The preferred architecture is therefore a coordinator-owned artifact store. +The coordinator creates a private file and passes only a write descriptor to +the executing worker; the worker streams to it while constructing the preview. +A provider-visible retrieval tool addresses the artifact by unpredictable ID +and returns bounded offset/limit windows. No role receives a wider path unveil. + +## Acceptance criteria + +- [ ] Tool results return a bounded, useful preview and structured metadata: + opaque ID, total bytes observed, retained bytes, whether the preview was + abbreviated, and whether the artifact hard cap was reached. +- [ ] Preview policy is pinned (for example useful head and tail regions) and + remains within the existing per-tool/Turn model-context bounds. +- [ ] A new paged retrieval tool reads an artifact by ID with byte or line + offset/limit, reports the next cursor and total retained size, and never + accepts a filesystem path. +- [ ] Artifact IDs are unguessable within the process, length-checked, bound to + the current Session, and cannot traverse or alias another Owner file. +- [ ] Files and directories are Owner-only (0600/0700), created atomically + under the existing session authority, and never placed in the project tree. +- [ ] The executor receives only pre-opened descriptors. `allow_write no`, + subagent read-only confinement, API/web no-file authority, and every current + unveil set remain unchanged and are proved by sandbox regressions. +- [ ] Foreground commands continue after the 256 KiB preview boundary but are + killed at timeout or the larger artifact cap. Disk writes and IPC draining + are bounded so a fast producer cannot exhaust memory or starve the event + loop. +- [ ] Background jobs spool incrementally instead of silently dropping ring + overflow; polling remains bounded and does not replay bytes already returned. +- [ ] Artifact creation/finalization and the corresponding tool-result + settlement have defined crash behavior. A failed or abandoned Turn cannot + leave a journaled ID that points to another Turn's data. +- [ ] Resume either preserves journaled artifact IDs for paged retrieval or + marks them durably unavailable; the chosen retention and cleanup policy is + explicit and tested. +- [ ] Per-artifact, per-Session, and global cleanup/quota behavior prevents disk + exhaustion and handles a full filesystem conspicuously without losing the + bounded model result. +- [ ] Tests cover exact preview boundaries, multi-megabyte output, binary/NUL + bytes, invalid IDs/cursors, timeout, hard-cap termination, cancellation, + crash/resume, read-only mode, concurrent subagents/jobs, and cleanup. +- [ ] Behavior, security caveats, tool descriptions, manuals, and the + verification matrix explain that retained output may contain secrets and is + private Session state. + +## Code-cost analysis + +Expected size: **medium-large, roughly 1,000-1,800 lines including tests and +documentation, across 6-9 commits**. A project-local tempfile would be much +smaller, but would fail under `allow_write no`, pollute the working tree, and +weaken fugu's authority model. + +| Seam | Likely work | Cost/risk | +| --- | --- | --- | +| Artifact store | ID generation, private files, quotas, finalize/reopen/cleanup, crash behavior | 180-320 production lines; high disk/state risk | +| Descriptor protocol | Coordinator creates/passes fds; tool worker streams without new unveil rights | 100-200 production lines; medium IPC risk | +| Capture and jobs | Tee subprocess output to artifact while retaining bounded preview; replace lossy job ring semantics | 180-320 production lines; high backpressure/process risk | +| Retrieval tool | Definition, argument validation, bounded paging, Session lookup | 120-220 production lines | +| Tests/docs | Pure store tests, tool/Turn integration, sandbox/ktrace, resume/quota adversarial cases, manuals | 400-700 lines | + +## Blocked by + +None - can start after triage, but storage lifetime and quota choices must be +pinned before implementation. + +## Triage decisions + +1. Per-artifact and per-Session hard caps, and whether hitting the hard cap + kills the producer or continues while discarding. +2. Preview shape: head only, head+tail, or tool-specific. +3. Artifact lifetime across successful, failed, abandoned, and resumed Turns. +4. Whether the first slice covers web responses and subagent-internal tools or + only Lead local subprocess tools/background jobs. +5. Whether Owner-facing output exposes the private path while the model sees + only the opaque ID. + +## Comments blob - /dev/null blob + 40b2055ad61274f3a3f3f657a596f1e53de365a7 (mode 644) --- /dev/null +++ .scratch/model-thinking-effort/issues/01-configurable-thinking-effort.md @@ -0,0 +1,59 @@ +# Configurable model thinking effort + +Status: needs-triage +Type: task + +## Problem + +fugu has no way to select a model's thinking or reasoning effort. The +configuration carries the model, output-token cap, and context-window override, +but no effort setting. The Anthropic, OpenAI, and experimental Claude +Subscription request builders therefore rely entirely on provider defaults. + +This is especially visible with models that expose named levels such as +`low`, `medium`, `high`, and `xhigh`: changing models may silently change the +amount of reasoning because fugu cannot preserve an explicit Owner choice. + +## Evidence + +- `struct fugu_conf` and the parser have no effort field or directive. +- `anthropic_build_request()`, `openai_build_request()`, and + `claude_sub_build_request()` emit no thinking/reasoning effort parameter. +- The experimental Claude Subscription compatibility profile advertises an + effort beta, but its request adapter does not currently use it. + +## Desired behavior + +Add an optional symbolic effort setting that can express the levels supported +by the selected model/provider. Leaving it unset must preserve today's +provider-default behavior. + +The selected value must reach every Generation consistently, including tool +continuations and compaction. The design must explicitly decide whether +subagents inherit the Lead setting or receive a separate override. + +## Acceptance criteria + +- Configuration accepts and validates an effort directive with a documented + set of symbolic values. +- `fugu -n` prints the effective nonsecret value. +- Each supported provider adapter maps the symbolic value to its native wire + field; an unsupported provider/model combination fails conspicuously or has + a documented fallback rather than silently selecting a different level. +- The value survives model selection and every Generation within a Turn. +- Subagent inheritance or override behavior is explicit and covered by tests. +- Request-builder regressions assert the exact wire shape for every supported + provider, and config regressions cover valid, invalid, default, and match + precedence behavior. +- `fugu.conf(5)` documents values, defaults, provider limitations, and examples. + +## Triage decisions needed + +1. Directive name: `effort`, `thinking_effort`, or another term. +2. Canonical value set and how provider-specific names such as `xhigh`/`max` + map without pretending unsupported levels are equivalent. +3. Whether subagents inherit the Lead value or gain `subagent_effort`. +4. Whether unsupported model/provider combinations are rejected locally or + passed through for the provider to reject. + +## Comments blob - /dev/null blob + 6ad7e502e72b41786db571a40afe597dcd79f659 (mode 644) --- /dev/null +++ .scratch/prompt-admission-semantics/issues/01-compare-steer-and-queue-promotion-rules.md @@ -0,0 +1,84 @@ +# Compare steer and queue promotion rules with OpenCode V2 + +Status: needs-triage +Type: research + +## Research question + +Does fugu's specification completely define when queued and steered User input +becomes model-visible, especially when multiple inputs arrive around tool +continuations and the per-Turn tool allowance? + +OpenCode V2 separates durable admission (`PromptAdmitted`) from atomic +promotion into model-visible history (`Prompted`). Fugu already has a cleaner +single-process analogue: the tty queue is pending interface state, steers enter +the active Turn transaction only at a safe tool-result boundary, and the +Pending Projection becomes durable only when the Turn commits. There is no +structural inbox architecture to adopt. + +The useful comparison is OpenCode's more explicit promotion policy in +[`specs/v2/session.md`](https://github.com/anomalyco/opencode/blob/dev/specs/v2/session.md): + +- steers promote at the next safe provider-turn boundary; +- queues remain FIFO while the current drain requires continuation; +- when execution would become idle, promote exactly one queued input and then + reevaluate continuation before promoting another; and +- multiple steers promoted at one boundary reset the provider-turn allowance + once, not once per steer. + +## What to investigate + +1. Pin the OpenCode source revision and restate its `PromptAdmitted` → + `Prompted`, steer, queue, and allowance rules as a small state machine. +2. Trace fugu's actual behavior through `fugu-tty` queue ownership, + `UI_STEER`, `coord.c`'s aggregated `steer` buffer, `turn_mechanics`, the + Turn transaction, Journal commit/abandon, and resume. +3. Compare specification, implementation, and regressions for at least: + multiple steers before one tool-result boundary; a steer after the final + provider response; two or more FIFO queue entries; a queued prompt whose + Turn itself continues through tools; interleaved queue/steer input; + failure/cancellation/session switch; and resume. +4. Determine what “reset the Turn allowance once” should mean in fugu, whose + 50-step tool limit currently belongs to one canonical Turn. Do not assume + OpenCode's counter has identical semantics. +5. Identify specification gaps separately from implementation bugs. Do not + propose a durable inbox or multi-process event architecture unless a + concrete fugu requirement needs one. + +## Deliverable + +A checked-in research note containing: + +- an OpenCode-to-fugu state/transition table; +- exact evidence links and fugu file/line references; +- a verdict for each OpenCode rule: already guaranteed, implemented but + underspecified, missing test, or behavior change needed; +- proposed replacement wording for `handoff/behavior.md` and + `handoff/verification.md` where precision is missing; and +- separate implementation tickets only for confirmed code changes. + +## Acceptance criteria + +- [ ] The OpenCode commit is pinned; mutable `dev` documentation is not treated + as timeless evidence. +- [ ] The analysis distinguishes admission, promotion, provider dispatch, + Journal acceptance, Turn commit, and UI dequeue. +- [ ] “Promote one queue entry, then reevaluate” and “one allowance reset per + steer batch” each receive an explicit fugu verdict. +- [ ] Existing regressions are mapped to the edge cases they prove, and missing + cases are named with proposed fixtures/assertions. +- [ ] The recommendation preserves fugu's one-Owner, one-process queue design + unless evidence demonstrates a real durability requirement. + +## Research cost + +Expected size: **small, one focused research pass and roughly 150-300 lines of +notes/spec proposals**. If the implementation matches but the contract is +ambiguous, the follow-up should be docs plus targeted regression cases rather +than a runtime refactor. + +## Blocked by + +None - can start immediately. + +## Comments blob - /dev/null blob + 7706e4d36efa39d28cca044d88537b2435c88814 (mode 644) --- /dev/null +++ .scratch/user-message-background/issues/01-grey-background-for-user-transcript-messages.md @@ -0,0 +1,60 @@ +# Render transcript User messages on a grey background + +Status: needs-triage +Type: task + +## What to build + +Give each User message in the curses transcript a grey-background block so it +is visually distinct from light-grey Assistant prose and indented cyan tool +activity. Reuse the same palette/treatment intended for the User input area so +composing and reviewing the Owner's words feel like one visual language. + +The block must include the `> ` prefix, wrapped continuation lines, and padding +to a deliberate edge. It must not recolor Assistant output or change line and +print modes. + +## Acceptance criteria + +- [ ] Color terminals render every `IT_USER` transcript item with the chosen + grey background and readable foreground contrast. +- [ ] All visual rows belonging to one wrapped or multiline User message have + coherent background fill; no unpainted gaps appear between the prefix, text, + continuation indent, and chosen right edge. +- [ ] The treatment shares a named color-pair/palette helper with the input + area rather than duplicating numeric curses attributes. +- [ ] Monochrome terminals retain a clear, readable fallback (for example + bold/reverse) without relying on color. +- [ ] Narrow terminals, wide/combining UTF-8, `ascii yes`, resize/reflow, + scrollback, and the conversation drawer preserve correct geometry. +- [ ] Headless renderer tests pin attributes and wrapping, and the pty + regression proves terminal startup/teardown remains clean. +- [ ] `handoff/behavior.md`, `docs/design/m7-curses.md`, and `fugu(1)` describe + User transcript styling without pinning an implementation-only color number. + +## Code-cost analysis + +Expected size: **small, about 120-240 lines including tests/docs, in 2-3 +commits**. The text attribute is easy; the real cost is painting padding across +every wrapped display row without bleeding the background into adjacent +transcript items. + +Likely touch points are `src/fugu-tty/ui.c`, a render-focused regression (or a +small extraction that makes span attributes testable), the curses pty check, +and the three interface documents. + +## Blocked by + +None - can start immediately. + +## Triage decisions + +1. Should the block fill the transcript width, the content width plus padding, + or a fixed percentage/maximum width? +2. Is “grey box used for User input” already a desired-but-unimplemented style + (the current `draw_input()` has no background pair), in which case this + ticket should style both surfaces together? +3. Pick foreground/background values for 8-color terminals and define the + monochrome fallback. + +## Comments 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 - /dev/null blob + 2af38effe39ffa1982e46c542826e56f3d56f05c (mode 644) --- /dev/null +++ docs/research/pi-supply-chain-posture.md @@ -0,0 +1,275 @@ +# Pi supply-chain posture: what fugu should adopt + +Research date: 2026-07-16 + +Pi source inspected: the current canonical `earendil-works/pi` repository at +commit +[`216e672`](https://github.com/earendil-works/pi/tree/216e672e7c9fc65682553394b74e483c0c9e47f7). +The former `badlogic/pi-mono` URL redirects there. + +## Recommendation + +Do **not** copy Pi's npm controls wholesale. They are good controls for Pi's +large, registry-resolved Node dependency graph, but fugu has deliberately +removed that attack surface: it links only OpenBSD base libraries, has no +language-registry dependency resolution step, and carries one small source +dependency in the repository. + +Adopt two ideas in forms native to fugu: + +1. **Adapt exact pinning into explicit vendored-source provenance.** Record the + upstream URL, commit, and digest for `src/common/jsmn.h`, and optionally + verify the digest in `make check`. The file is already fixed by the fugu Git + commit; the missing part is an auditable upstream identity. +2. **Adopt the outside-the-repository artifact test in `make live-check`.** + Fugu already does most of this more strongly than Pi by building an OpenBSD + package and installing it on a clean host. Make the artifact-only property + explicit by running the installed binary from a temporary project outside + the checkout, with development worker/config overrides unavailable. + +Reject `min-release-age`, `--ignore-scripts`, and a lifecycle-script allowlist +for the current tree. They control registry selection and package-manager +execution that do not occur in fugu. Reconsider them only if fugu later adds a +registry dependency, ports library dependency, language runtime, generated +foreign build step, or plugin/package ecosystem. + +## Decision matrix + +| Pi control | Decision for fugu | Reason | Estimated cost | +|---|---|---|---| +| Exact-pin dependencies | **Adapt; mostly satisfied already** | Committed vendored source and checksummed release distfiles are immutable inputs, but jsmn's upstream commit/digest is not recorded | Low: documentation alone is under an hour; an enforced digest/provenance regression is roughly 20-60 lines and a few hours including tests | +| Minimum release age | **Reject now** | No registry resolver selects fresh releases during a fugu build or install | No implementation cost; a custom age mechanism would add maintenance without covering a current path | +| `--ignore-scripts` everywhere | **Reject as Node-specific** | No npm lifecycle scripts execute; BSD make and ports hooks are first-party, reviewed build instructions and cannot simply be disabled | No implementation cost; preserve the existing no-runtime/no-ports-dependency charter | +| Lifecycle-script allowlist | **Reject now; add only with such dependencies** | There is no lifecycle-script metadata or dependency graph to inspect | No implementation cost today; a premature checker would be policy theatre | +| Release install outside the repo | **Adopt and strengthen `make live-check`** | Detects source-tree fallback, missing package files, wrong installed paths, and environment leakage | Low-medium: about half to one day for shell changes, regression coverage, documentation, and one clean-host validation; no new runtime dependency | + +The estimates assume the current OpenBSD-only architecture and include tests +and documentation, but not release-machine provisioning. + +## What Pi actually implements + +Pi's stated policy is that npm dependency changes are reviewed code changes. +Its current README lists exact direct dependency versions, a lockfile and +published shrinkwrap, a two-day release-age gate, lifecycle-script suppression, +a lifecycle-script review list, audits, and isolated local release installs. +See the +[`Supply-chain hardening` section](https://github.com/earendil-works/pi/blob/216e672e7c9fc65682553394b74e483c0c9e47f7/README.md#L61-L73). + +### Exact direct and transitive versions + +Pi sets `save-exact=true` and checks every external entry in +`dependencies`, `devDependencies`, and `optionalDependencies` against an exact +semantic-version pattern. Workspace and non-registry references are treated +separately. The enforcement is source-visible in +[`check-pinned-deps.mjs`](https://github.com/earendil-works/pi/blob/216e672e7c9fc65682553394b74e483c0c9e47f7/scripts/check-pinned-deps.mjs#L1-L63), +not just a README convention. + +The root lockfile fixes development resolution, while the published coding +agent carries a generated `npm-shrinkwrap.json` for its transitive production +closure. This matters for Pi because an exact direct dependency can still +declare ranged transitive dependencies. Fugu has no corresponding resolver or +transitive package graph. + +### Two-day release cooldown + +Pi's +[`.npmrc`](https://github.com/earendil-works/pi/blob/216e672e7c9fc65682553394b74e483c0c9e47f7/.npmrc#L1-L2) +sets `min-release-age=2`. npm defines that value in days: versions newer than +the window are excluded from resolution. npm also documents the tradeoff that +the gate can temporarily block a newly published security fix, requiring an +exception or a relaxed window; see the official +[`min-release-age` configuration](https://docs.npmjs.com/cli/v11/commands/npm-install/#min-release-age). + +This is a quarantine control, not integrity verification. It gives registry +and community detection systems time to react to a malicious new publication, +but it neither proves that an older version is safe nor protects a version +already fixed in a lockfile from a compromised download channel. + +### Lifecycle scripts: suppression plus review + +Pi's development, CI, local-release install, publish, and self-update paths use +`--ignore-scripts` where supported. The policy is visible in the +[CI install](https://github.com/earendil-works/pi/blob/216e672e7c9fc65682553394b74e483c0c9e47f7/.github/workflows/ci.yml#L32-L39), +[publish path](https://github.com/earendil-works/pi/blob/216e672e7c9fc65682553394b74e483c0c9e47f7/scripts/publish.mjs#L52-L53), +and +[self-update construction](https://github.com/earendil-works/pi/blob/216e672e7c9fc65682553394b74e483c0c9e47f7/packages/coding-agent/src/config.ts#L115-L182), +as well as the local-release source below. npm defines that flag as suppressing +scripts from package manifests while still allowing an explicitly requested +`npm run` command; see npm's +[`ignore-scripts` semantics](https://docs.npmjs.com/cli/v11/commands/npm-install/#ignore-scripts). +This removes the install-time code-execution path used by malicious +`preinstall`, `install`, and `postinstall` scripts. + +Pi also inspects the generated production shrinkwrap for packages marked +`hasInstallScript`. The current allowlist names two exact package versions and +records why their scripts are considered harmless; any new marked package +fails generation until reviewed. See the +[`allowedInstallScriptPackages` list](https://github.com/earendil-works/pi/blob/216e672e7c9fc65682553394b74e483c0c9e47f7/scripts/generate-coding-agent-shrinkwrap.mjs#L7-L16) +and the +[`hasInstallScript` validation](https://github.com/earendil-works/pi/blob/216e672e7c9fc65682553394b74e483c0c9e47f7/scripts/generate-coding-agent-shrinkwrap.mjs#L224-L260). +In Pi this is primarily an audit gate: normal install paths still suppress the +scripts. It ensures that a dependency cannot silently acquire script-bearing +metadata without review. + +### The outside-repository release check + +Pi's `release:local` script requires its output directory to be outside the +repository, runs checks and tests, rebuilds publishable packages, packs them, +and creates fresh npm and Bun production installs from those tarballs. See +[`prepareOutputDirectory`](https://github.com/earendil-works/pi/blob/216e672e7c9fc65682553394b74e483c0c9e47f7/scripts/local-release.mjs#L107-L132) +and the +[`pack and isolated-install sequence`](https://github.com/earendil-works/pi/blob/216e672e7c9fc65682553394b74e483c0c9e47f7/scripts/local-release.mjs#L197-L255). + +One nuance matters when copying the pattern: the current script creates the +isolated installs and prints outside-repo `pi --help` commands, but does not +itself execute those commands. See its +[`final smoke instructions`](https://github.com/earendil-works/pi/blob/216e672e7c9fc65682553394b74e483c0c9e47f7/scripts/local-release.mjs#L259-L283). +The useful control is therefore artifact isolation; fugu can and should make +the actual invocation automatic. + +## Fugu's current posture + +### Dependency surface + +Fugu's [dependency charter](../../handoff/charter.md) permits only OpenBSD base +libraries in linked binaries and permits a small, permissively licensed, +single-file vendored component only where base has no safe equivalent. The +[port Makefile](../../port/productivity/fugu/Makefile) declares no +`BUILD_DEPENDS`, `RUN_DEPENDS`, `LIB_DEPENDS`, `TEST_DEPENDS`, or `MODULES`; its +only dependency declaration is the base-library `WANTLIB` set +`c curses event tls util`. The +[deployment regression](../../regress/deploy/deploy_test.sh) derives each +binary's actual ELF dependencies and checks that their union exactly matches +that set. + +The sole external source component is `src/common/jsmn.h`, documented in the +[README](../../README) and isolated behind `src/common/json.c`. It is committed +source, so every fugu commit already pins its exact bytes. At the research +snapshot its SHA-256 is +`c04533e9181e1e33baceb0f55ac449b05145bb936e8c68cc77dfe0d8277514fb`, +and it is byte-identical to upstream jsmn at +[`25647e6`](https://github.com/zserge/jsmn/blob/25647e692c7906b96ffd2b05ca54c097948e879c/jsmn.h). +Neither the upstream commit nor digest is currently recorded in the tree. That +is a traceability gap, not dynamic dependency drift. + +### Source and package integrity + +[`make local-port`](../../scripts/local-port) refuses a dirty worktree, archives +the exact current Git commit, stages the maintained port under `mystuff`, and +runs the ports framework's `makesum` and `checksum` targets. OpenBSD specifies +that `checksum` compares every fetched distfile and patch against the SHA-256 +values in `distinfo`; see +[`bsd.port.mk(5)`](https://man.openbsd.org/OpenBSD-7.9/bsd.port.mk.5#checksum). +The OpenBSD porting guide also requires visible source versions rather than a +moving `latest` file and recorded checksums for retrieved sources; see the +official [Porting Guide](https://www.openbsd.org/faq/ports/guide.html#PortsChecklist). + +This is the relevant equivalent of lockfile integrity for fugu's own release +distfile. It does not authenticate a malicious commit accepted into the fugu +repository, but neither do Pi's exact version strings; source review and +repository trust remain separate controls. + +### Build and release isolation + +[`make check`](../../scripts/check) removes prior build products, creates fresh +object directories, compiles with warnings treated as failures, and runs every +regression suite. The ports `package` target then builds from the checksummed +archive through extraction, fake installation, and package creation. OpenBSD's +framework verifies declared dependencies and `WANTLIB` before building and +can check the final package's library declarations; see +[`bsd.port.mk(5)` dependency and package targets](https://man.openbsd.org/OpenBSD-7.9/bsd.port.mk.5#prepare). +The official testing guide additionally warns against accidentally detecting +undeclared software already present on the build host; see the +[OpenBSD Ports Testing Guide](https://www.openbsd.org/faq/ports/testing.html#Testing). + +Fugu's [`make live-check-privileged`](../../scripts/live-check) is already +stronger than Pi's isolated install step in several respects. On a disposable +host it rejects a pre-existing fugu package, binary, group, or configuration; +snapshots and hashes the package; requires a hostname-plus-digest confirmation; +installs with `pkg_add`; verifies package registration, ownership, modes, and +files; and invokes the installed `fugu -n` as an ordinary user. The companion +[release procedure](../release-live-checks.md) keeps credentialed model tests +separate from privileged package installation. + +The remaining isolation gap is small but real: the privileged invocation +inherits the directory from which the make target was launched, while the +credentialed target defaults some invocations back to the fugu repository. +Several tool and subagent checks already use a temporary project, but the +artifact-only property is not a single enforced invariant. A packaged release +could therefore be insufficiently tested for a source-tree fallback or an +undeclared runtime file. + +OpenBSD package signing is a separate, ecosystem-native concern. Official +packages are signed with `signify`, and OpenBSD documents how maintainers can +sign their own packages in the +[Package Signatures guide](https://www.openbsd.org/faq/ports/ports.html#PkgSig). +Fugu's current clean-host check binds installation to an operator-confirmed +package SHA-256, which is suitable for the present local one-Owner flow. If +packages are later distributed across trust boundaries, signing them would be +more relevant than importing npm release-age or lifecycle controls. + +## Threat and cost analysis + +Pi's controls address three high-probability Node threats: + +- semver or transitive resolution selecting an attacker-controlled registry + publication; +- package lifecycle code executing before the dependency is reviewed; and +- a published tarball working only because it can see monorepo files or a + hoisted development dependency. + +For fugu, the first two paths are absent. The comparable current threats are: + +- an unreviewed change to the committed jsmn bytes or loss of its upstream + provenance; +- a release archive differing from the reviewed commit; +- a package omitting a worker, manual, sample, permission, or other runtime + resource; and +- an installed binary accidentally relying on the source checkout, development + environment variables, or build-host state. + +The current Git, ports checksum, package, ELF dependency, and clean-host checks +already cover most of these. That is why adding npm-shaped controls would add +more policy surface than protection. + +The two recommended adaptations have favorable cost-to-value ratios: + +- **Vendored provenance:** no runtime or build dependency, negligible ongoing + cost, and a direct audit trail for the only imported source. A digest check + should fail only when that file is intentionally reviewed and updated. +- **Artifact-only live smoke:** reuses the existing temporary project, + installed package, hostile development-override checks, and clean-host + harness. Its release-time cost is seconds for `-n`/startup probes, plus any + already-authorized provider quota if the credentialed phase is included. It + catches a class of packaging errors that source regressions cannot. + +By contrast, a minimum-age database for one manually updated header would need +timestamps and exception policy while providing no download-time enforcement. +An ignore/allow-script layer would have nothing to inspect and could encourage +confusing normal, reviewed OpenBSD port hooks with third-party package +lifecycle execution. Both should remain explicit reconsideration triggers, +not current implementation work. + +## Concrete release-policy shape + +Without adding a new dependency system, the eventual fugu policy should be: + +1. Every vendored source file names its upstream repository, immutable commit, + license, and digest; an intentional update changes those facts in the same + reviewed commit. +2. Release source is produced only from clean committed Git state and accepted + by the ports checksum target. +3. The package is built through the ports fake/package stages and its actual + linkage and packing list are checked. +4. `make live-check` runs the installed front end from a newly created project + outside the source checkout, with development path overrides absent or + deliberately poisoned, and fails if any checkout resource is required. +5. The privileged clean-host and ordinary-user credentialed phases remain + separate; this artifact smoke does not move network credentials into a root + process. +6. Registry cooldowns and lifecycle-script policy become mandatory design + questions only if a future change introduces a registry resolver or foreign + install-time executable code. + +This preserves fugu's smaller supply chain instead of recreating Pi's larger +one merely to apply Pi's mitigations. blob - d2a72edf15a09d3b1f6c29f88d0235e2f1957082 blob + 001035f60bfd1222df5c9ec3cc0a4c23a32e90b1 --- 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 @@ -648,9 +661,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 - 49aba19b71cdb0eb2531d3682f2d15e5ad74f3b7 blob + f53727c56c505a66fcf3934d6fc5b9b0af9c46c4 --- handoff/verification.md +++ handoff/verification.md @@ -168,7 +168,8 @@ states the property, not just examples: Generations separately prove the Projection and cache cursor stay paired. - **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 - 3dd3451603c9981854b74ef0417e547ffc0083e0 blob + c3f2ea6f4cc01df501a740da95851e17c54bc553 --- regress/Makefile +++ regress/Makefile @@ -1,5 +1,5 @@ SUBDIR= agentcfg anthropic buf claude_subscription compaction conf deploy diff editor generation http imsgev journal json local_port log markdown \ - model_window openai output print sandbox skills sse term tools turn \ + model_window openai output print sandbox skills sse term tool_display tools turn \ turn_mechanics turn_txn ui_config web xmalloc .include blob - f468e1ca79e277670ab24e661dc8fa3645ed129b blob + 3c5f8488d910e615a68f0ea2110ab7eac0b1f7bf --- regress/README +++ regress/README @@ -127,6 +127,8 @@ provider codec: anthropic anthropic (builder + stream provider codec: openai openai (builder, adversarial stream, Bearer-auth full worker mesh) tools (behavior.md 5) agentcfg (agent schema and routing); + tool_display (bounded, control-safe progress + summaries with secret-prone fields omitted); tools (executors, caps, freshness, NUL rejection, bounded background draining, supervisor/liveness cleanup); @@ -211,6 +213,7 @@ src/fugu-tool (tools.c, job.c, CTX read/append, ephemeral agent loop) src/fugu/tooldefs.c turn (tools offered per gates, the skill tool) +src/fugu/tool_display.c tool_display; turn (line-mode wiring) src/fugu/skills.c skills (load/parse); turn (dispatch) src/fugu/output.c output, print src/fugu-web (fetch.c, main.c) web (hermetic TLS flows); sandbox blob - 37bf30d1809764ae9daa9373ae61d981ff86ae69 blob + 798f9e90dbe3a9a6332e8b66247410dc489e689f --- regress/editor/editor_test.c +++ regress/editor/editor_test.c @@ -338,5 +338,15 @@ main(void) CHECK(ed_feed(&e, K_PGUP, 0, NULL, 0) == R_PAGE_UP); ed_free(&e); + /* Mouse-wheel events scroll the transcript without recalling history. */ + ed_init(&e); + ed_history_add(&e, "old command", 11); + type(&e, "draft"); + CHECK(ed_feed(&e, K_WHEEL_UP, 0, NULL, 0) == R_SCROLL_LINE_UP); + CHECK(text_is(&e, "draft")); + CHECK(ed_feed(&e, K_WHEEL_DOWN, 0, NULL, 0) == R_SCROLL_LINE_DOWN); + CHECK(text_is(&e, "draft")); + ed_free(&e); + REGRESS_END(); } blob - c40e8d28c1b55f1f46af4bb9b948aeba1739b689 blob + b562dc7a99bb00a01cdce4f625ba2c7ffab55a09 --- regress/term/term_test.c +++ regress/term/term_test.c @@ -65,6 +65,42 @@ main(void) CHECK(one_key("\x1b[6~", 4, NULL) == K_PGDN); CHECK(one_key("\x1b[Z", 3, NULL) == K_BTAB); + /* ---- SGR mouse wheel: distinct from editor-history arrows ---- */ + CHECK(one_key("\x1b[<64;12;7M", sizeof("\x1b[<64;12;7M") - 1, + NULL) == K_WHEEL_UP); + CHECK(one_key("\x1b[<65;12;7M", sizeof("\x1b[<65;12;7M") - 1, + NULL) == K_WHEEL_DOWN); + /* Ctrl-modified wheel still uses the low button bits. */ + CHECK(one_key("\x1b[<80;12;7M", sizeof("\x1b[<80;12;7M") - 1, + NULL) == K_WHEEL_UP); + { + struct term_input ti; + struct ti_key k; + + ti_init(&ti); + ti_feed(&ti, (const unsigned char *)"\x1b[<64;12", 8); + CHECK(ti_next(&ti, &k) == TI_NONE); + ti_feed(&ti, (const unsigned char *)";7M", 3); + CHECK(ti_next(&ti, &k) == TI_KEY && k.key == K_WHEEL_UP); + ti_free(&ti); + } + /* Unused mouse events are consumed whole; coordinates never become + * editor input, and the next ordinary key remains available. */ + { + struct term_input ti; + struct ti_key k; + + ti_init(&ti); + ti_feed(&ti, (const unsigned char *)"\x1b[<0;12;7Mx", + sizeof("\x1b[<0;12;7Mx") - 1); + CHECK(ti_next(&ti, &k) == TI_KEY && k.key == K_CHAR && + k.cp == 'x'); + CHECK(ti_next(&ti, &k) == TI_NONE); + ti_free(&ti); + } + CHECK(one_key("\x1b[<64;12;7m", sizeof("\x1b[<64;12;7m") - 1, + NULL) == -1); + /* ---- SS3 arrows ---- */ CHECK(one_key("\x1bOA", 3, NULL) == K_UP); CHECK(one_key("\x1bOF", 3, NULL) == K_END); blob - /dev/null blob + 272e18743e40ede8d02a30b0098eefae861bf99a (mode 644) --- /dev/null +++ regress/tool_display/Makefile @@ -0,0 +1,7 @@ +PROG= tool_display_test +SRCS= tool_display_test.c tool_display.c json.c buf.c log.c xmalloc.c + +CFLAGS+= -I${.CURDIR}/../../src/fugu +.PATH: ${.CURDIR}/../../src/fugu + +.include blob - /dev/null blob + 2fee78d2b5bbccbdb5ea42da729ae616a7d0f7b2 (mode 644) --- /dev/null +++ regress/tool_display/tool_display_test.c @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026 Isaac + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#include + +#include + +#include "buf.h" +#include "tool_display.h" +#include "regress.h" + +static int +format_is(const char *name, const char *input, const char *want) +{ + struct buf out; + int ok; + + buf_init(&out); + tool_display_format(&out, name, input, strlen(input)); + ok = out.len == strlen(want) && + memcmp(out.data, want, out.len) == 0 && + out.len <= TOOL_DISPLAY_MAX && + memchr(out.data, '\n', out.len) == NULL && + memchr(out.data, '\0', out.len) == NULL; + buf_free(&out); + return (ok); +} + +static void +test_read_and_search(void) +{ + CHECK(format_is("read", "{\"path\":\"src/fugu/coord.c\"," + "\"offset\":1017,\"limit\":20,\"content\":\"not shown\"}", + "read path=\"src/fugu/coord.c\" offset=1017 limit=20")); + CHECK(format_is("grep", "{\"pattern\":\"tool:\\nread\"," + "\"path\":\"src/fugu\"}", + "grep pattern=\"tool:\\nread\" path=\"src/fugu\"")); + CHECK(format_is("find", "{\"path\":\"src\",\"name\":\"*.c\"}", + "find path=\"src\" name=\"*.c\"")); + CHECK(format_is("ls", "{}", "ls")); +} + +static void +test_safe_allowlist(void) +{ + CHECK(format_is("edit", "{\"path\":\"notes.txt\"," + "\"old_string\":\"private old text\"," + "\"new_string\":\"private new text\"}", + "edit path=\"notes.txt\"")); + CHECK(format_is("http_request", "{\"method\":\"POST\"," + "\"url\":\"https://example.com/v1\"," + "\"headers\":{\"Authorization\":\"secret\"}," + "\"body\":\"private body\"}", + "http_request method=\"POST\" url=\"https://example.com/v1\"")); + CHECK(format_is("agent", "{\"label\":\"reader\"," + "\"model\":\"fast\",\"prompt\":\"private prompt\"}", + "agent label=\"reader\" model=\"fast\"")); + CHECK(format_is("shell", "{\"command\":\"printf 'a\\nb'\"}", + "shell command=\"printf 'a\\nb'\"")); +} + +static void +test_hostile_and_invalid_input(void) +{ + struct buf input, out; + int i; + + CHECK(format_is("read", "{\"path\":\"a\\u0000b\\tc\"}", + "read path=\"a\\x00b\\tc\"")); + CHECK(format_is("read", "not json", "read")); + CHECK(format_is("future_tool", "{\"secret\":\"not shown\"}", + "future_tool")); + + buf_init(&input); + buf_addstr(&input, "{\"path\":\""); + for (i = 0; i < 1000; i++) + buf_addc(&input, 'x'); + buf_addstr(&input, "\"}"); + buf_init(&out); + tool_display_format(&out, "read", input.data, input.len); + CHECK(out.len <= TOOL_DISPLAY_MAX); + CHECK(memmem(out.data, out.len, "...\"", 4) != NULL); + CHECK(memchr(out.data, '\n', out.len) == NULL); + CHECK(memchr(out.data, '\0', out.len) == NULL); + buf_free(&out); + buf_free(&input); +} + +int +main(void) +{ + test_read_and_search(); + test_safe_allowlist(); + test_hostile_and_invalid_input(); + REGRESS_END(); +} blob - 856be903b94c0ed52260d77fd10f15845994f437 blob + 1d654133634f2852306ac68ef95b3652f76f93b3 --- 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" @@ -986,16 +989,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 - 4b5cb3e5af223e4dba2ae36ed27cd3de82c39cc7 blob + 1caeb1d9d7fc99fd6cb999e6dfc32268d156f5bd --- 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 @@ -100,6 +101,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\"," @@ -1425,6 +1447,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 - 66f185c8b868152db5385174305a82e52e7dc40b blob + 270f362460e38a84a8a0639ff31e28dcc042408a --- src/fugu/Makefile +++ src/fugu/Makefile @@ -1,6 +1,6 @@ PROG= fugu SRCS= main.c coord.c priv.c conf.c parse.y \ - journal.c sysprompt.c tooldefs.c agentcfg.c skills.c output.c \ + journal.c sysprompt.c tooldefs.c tool_display.c agentcfg.c skills.c output.c \ turn_txn.c turn_mechanics.c anthropic_req.c claude_sub_req.c \ claude_sub_profile.c openai_req.c generation.c compaction.c msg.c \ json.c utf8.c model_window.c buf.c imsgev.c log.c xmalloc.c blob - a3ccad20603d14c4e15917c4d7a6cfd83641b3d5 blob + ded274898ad56b8359bd113ed666fd6b5169d436 --- src/fugu/coord.c +++ src/fugu/coord.c @@ -54,6 +54,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" @@ -208,7 +209,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]; @@ -1004,12 +1007,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 @@ -1074,21 +1086,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 @@ -1116,19 +1113,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 @@ -1286,7 +1352,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 @@ -1303,10 +1369,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, @@ -1662,6 +1725,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; @@ -2437,7 +2501,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 @@ -2798,7 +2862,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; @@ -2859,7 +2923,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); @@ -3423,7 +3487,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"); @@ -3548,7 +3612,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; @@ -3602,6 +3666,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); @@ -3612,6 +3677,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"); @@ -3670,6 +3736,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"); @@ -3774,6 +3841,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 - 6e230ba39625996c74c90c4214ffc8af98c7f310 blob + 1b7a16b8769e9b408730b8ed5e4c7c729edf5313 --- src/fugu/fugu.conf.5 +++ src/fugu/fugu.conf.5 @@ -256,10 +256,30 @@ The special pattern .Sq * matches any host. Host matching is case-insensitive. +The default is to allow no destinations and not offer the +.Cm http_request +tool. +This does not affect +.Cm web_search +or +.Cm web_fetch . .It Cm http_block Ar patterns Refuse destinations matching patterns in the same form as .Cm http_allow . A block always overrides an allow. +The default is to apply no block patterns. +Specifying +.Cm http_block +without +.Cm http_allow +does not offer the +.Cm http_request +tool. +Every destination must still match +.Cm http_allow +and pass the built-in URL and address checks; resolved private, loopback, +link-local, and other non-public addresses are refused independently of these +patterns. .It Cm max_bg_jobs Ar number Set the maximum number of concurrent background shell jobs. The value must be between 0 and 256 and defaults to 4. 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 - /dev/null blob + 70688281e28cba8bd06ce1b47a11dea3791b2c43 (mode 644) --- /dev/null +++ src/fugu/tool_display.c @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2026 Isaac + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#include + +#include +#include +#include + +#include "buf.h" +#include "json.h" +#include "tool_display.h" + +#define DISPLAY_NAME_MAX 64 +#define DISPLAY_VALUE_MAX 160 +#define DISPLAY_FIELDS_MAX 3 + +enum display_type { DISPLAY_STRING, DISPLAY_NUMBER }; + +struct display_field { + const char *name; + int type; +}; + +struct display_tool { + const char *name; + struct display_field fields[DISPLAY_FIELDS_MAX]; +}; + +/* + * This is deliberately an allowlist. Locator and action fields explain + * what ran; bulk or secret-prone fields (content, replacements, headers, + * bodies, and prompts) never become presentation text. + */ +static const struct display_tool tools[] = { + { "read", { + { "path", DISPLAY_STRING }, + { "offset", DISPLAY_NUMBER }, + { "limit", DISPLAY_NUMBER } } }, + { "write", { { "path", DISPLAY_STRING } } }, + { "edit", { { "path", DISPLAY_STRING } } }, + { "multi_edit", { { "path", DISPLAY_STRING } } }, + { "shell", { { "command", DISPLAY_STRING } } }, + { "grep", { + { "pattern", DISPLAY_STRING }, + { "path", DISPLAY_STRING } } }, + { "find", { + { "path", DISPLAY_STRING }, + { "name", DISPLAY_STRING } } }, + { "ls", { { "path", DISPLAY_STRING } } }, + { "web_search", { { "query", DISPLAY_STRING } } }, + { "web_fetch", { { "url", DISPLAY_STRING } } }, + { "http_request", { + { "method", DISPLAY_STRING }, + { "url", DISPLAY_STRING } } }, + { "shell_bg", { { "command", DISPLAY_STRING } } }, + { "shell_output", { { "id", DISPLAY_NUMBER } } }, + { "shell_kill", { { "id", DISPLAY_NUMBER } } }, + { "skill", { { "name", DISPLAY_STRING } } }, + { "agent", { + { "label", DISPLAY_STRING }, + { "model", DISPLAY_STRING } } } +}; + +static void +add_tool_name(struct buf *out, const char *name) +{ + size_t i; + + if (name == NULL || name[0] == '\0') { + buf_addstr(out, "tool"); + return; + } + for (i = 0; name[i] != '\0' && i < DISPLAY_NAME_MAX; i++) { + unsigned char c = (unsigned char)name[i]; + + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_' || c == '-') + buf_addc(out, c); + else + buf_addc(out, '?'); + } + if (name[i] != '\0') + buf_addstr(out, "..."); +} + +static size_t +utf8_len(unsigned char c) +{ + if (c < 0x80) + return (1); + if ((c & 0xe0) == 0xc0) + return (2); + if ((c & 0xf0) == 0xe0) + return (3); + return (4); +} + +/* Add a decoded JSON string without allowing terminal controls or new lines. */ +static void +add_quoted(struct buf *out, const unsigned char *s, size_t len) +{ + static const char hex[] = "0123456789abcdef"; + size_t i = 0, shown = 0; + + buf_addc(out, '"'); + while (i < len) { + unsigned char c = s[i]; + const char *escaped = NULL; + size_t n = 1, add = 1; + + switch (c) { + case '"': escaped = "\\\""; add = 2; break; + case '\\': escaped = "\\\\"; add = 2; break; + case '\n': escaped = "\\n"; add = 2; break; + case '\r': escaped = "\\r"; add = 2; break; + case '\t': escaped = "\\t"; add = 2; break; + default: + if (c < 0x20 || c == 0x7f) + add = 4; + else if (c >= 0x80) + n = add = utf8_len(c); + break; + } + if (shown + add > DISPLAY_VALUE_MAX) + break; + if (escaped != NULL) + buf_add(out, escaped, add); + else if (c < 0x20 || c == 0x7f) { + buf_addstr(out, "\\x"); + buf_addc(out, hex[c >> 4]); + buf_addc(out, hex[c & 0x0f]); + } else + buf_add(out, s + i, n); + shown += add; + i += n; + } + if (i < len) + buf_addstr(out, "..."); + buf_addc(out, '"'); +} + +static const struct display_tool * +find_tool(const char *name) +{ + size_t i; + + if (name == NULL) + return (NULL); + for (i = 0; i < sizeof(tools) / sizeof(tools[0]); i++) + if (strcmp(name, tools[i].name) == 0) + return (&tools[i]); + return (NULL); +} + +static void +add_field(struct buf *out, const struct json *j, int root, + const struct display_field *field) +{ + char *s; + size_t len; + int64_t number; + int token; + + if (field->name == NULL || + (token = json_obj_get(j, root, field->name)) == -1) + return; + if (field->type == DISPLAY_STRING) { + if ((s = json_get_str(j, token, &len)) == NULL) + return; + buf_addc(out, ' '); + buf_addstr(out, field->name); + buf_addc(out, '='); + add_quoted(out, (const unsigned char *)s, len); + free(s); + } else { + if (json_get_num(j, token, &number) == -1) + return; + buf_addc(out, ' '); + buf_addstr(out, field->name); + buf_addf(out, "=%lld", (long long)number); + } +} + +void +tool_display_format(struct buf *out, const char *name, const void *input, + size_t inputlen) +{ + const struct display_tool *tool; + struct buf summary; + struct json json; + int root; + size_t i; + + buf_reset(out); + buf_init(&summary); + add_tool_name(&summary, name); + tool = find_tool(name); + if (tool == NULL || input == NULL || inputlen == 0 || + json_parse(&json, input, inputlen, 0) == -1) + goto done; + root = json_root(&json); + if (json_is_object(&json, root)) + for (i = 0; i < DISPLAY_FIELDS_MAX; i++) + add_field(&summary, &json, root, &tool->fields[i]); + json_done(&json); + +done: + /* The allowlist and per-value cap should fit; fail terse if they drift. */ + if (summary.len > TOOL_DISPLAY_MAX) { + buf_reset(&summary); + add_tool_name(&summary, name); + } + buf_add(out, summary.data, summary.len); + buf_free(&summary); +} blob - /dev/null blob + ccab4611f2c3f1fd53fcee6e2068c961970d39bb (mode 644) --- /dev/null +++ src/fugu/tool_display.h @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Isaac + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#ifndef TOOL_DISPLAY_H +#define TOOL_DISPLAY_H + +#include + +#include "buf.h" + +/* One bounded, single-line tool-call summary for stderr or the transcript. */ +#define TOOL_DISPLAY_MAX 512 + +void tool_display_format(struct buf *, const char *, const void *, size_t); + +#endif /* TOOL_DISPLAY_H */ blob - 67abed12fae8a686c29bd7db01941150df10884b blob + 5409a30504055bbd8724f303b025b81677ac02d2 --- src/fugu-tty/editor.c +++ src/fugu-tty/editor.c @@ -586,6 +586,10 @@ feed_insert(struct editor *e, enum ed_key k, uint32_t case K_BTAB: do_complete(e, 0); return (R_NONE); + case K_WHEEL_UP: + return (R_SCROLL_LINE_UP); + case K_WHEEL_DOWN: + return (R_SCROLL_LINE_DOWN); case K_PGUP: return (R_PAGE_UP); case K_PGDN: @@ -637,6 +641,10 @@ feed_normal(struct editor *e, enum ed_key k, uint32_t case K_DOWN: recall_next(e); return (R_NONE); + case K_WHEEL_UP: + return (R_SCROLL_LINE_UP); + case K_WHEEL_DOWN: + return (R_SCROLL_LINE_DOWN); case K_PGUP: return (R_PAGE_UP); case K_PGDN: blob - 90a435e08fdadb892bf4903e1095c85f24c25c06 blob + e1002aeb280f617e8da7ed1cef8f8a3bc4fcd134 --- src/fugu-tty/editor.h +++ src/fugu-tty/editor.h @@ -56,6 +56,8 @@ enum ed_key { K_END, K_PGUP, K_PGDN, + K_WHEEL_UP, + K_WHEEL_DOWN, K_CTRL_C, K_CTRL_D, K_CTRL_L, blob - 9d083687063c4e4c052a31aa87a8db4a17ec48d2 blob + 8253851f7ef29861121167ec0be2a7fdb8bde858 --- src/fugu-tty/term_input.c +++ src/fugu-tty/term_input.c @@ -127,6 +127,75 @@ csi_key(int param, unsigned char final, struct ti_key return (0); } +/* + * Parse an xterm SGR mouse frame: CSI < Cb ; Cx ; Cy M/m. Basic mouse + * tracking reports wheel motion as button 64/65; modifier bits do not alter + * the low button bits. Every complete mouse frame is consumed, including + * clicks, releases, and horizontal-wheel events that fugu does not use, so + * their coordinates can never leak into the editor as typed text. + */ +static enum ti_result +step_sgr_mouse(struct term_input *ti, struct ti_key *out, int *again) +{ + size_t i = 3; + unsigned int button = 0; + int field; + unsigned char final; + + for (field = 0; field < 3; field++) { + if (i >= ti->len) + return (TI_NONE); + if (ti->buf[i] < '0' || ti->buf[i] > '9') + goto malformed; + while (i < ti->len && ti->buf[i] >= '0' && + ti->buf[i] <= '9') { + if (field == 0) { + if (button <= 255) + button = button * 10 + + (ti->buf[i] - '0'); + if (button > 255) + button = 256; + } + i++; + if (i > 32) + goto malformed; + } + if (i >= ti->len) + return (TI_NONE); + if (field < 2) { + if (ti->buf[i] != ';') + goto malformed; + i++; + } + } + + if (i >= ti->len) + return (TI_NONE); + final = ti->buf[i]; + if (final != 'M' && final != 'm') + goto malformed; + consume(ti, i + 1); + *again = 1; + if (final != 'M' || (button & 64) == 0) + return (TI_NONE); + if ((button & 3) == 0) { + out->key = K_WHEEL_UP; + return (TI_KEY); + } + if ((button & 3) == 1) { + out->key = K_WHEEL_DOWN; + return (TI_KEY); + } + return (TI_NONE); + +malformed: + /* Make progress on a malformed bounded frame without swallowing the + * ordinary byte that follows it. */ + consume(ti, i + 1); + *again = 1; + return (TI_NONE); +} + /* Parse one CSI/SS3 sequence at buf[0]. */ static enum ti_result step_escape(struct term_input *ti, struct ti_key *out, int *again) @@ -150,6 +219,8 @@ step_escape(struct term_input *ti, struct ti_key *out, *again = 1; return (TI_NONE); } + if (ti->len >= 3 && ti->buf[2] == '<') + return (step_sgr_mouse(ti, out, again)); { size_t i = 2; int param = 0, haveparam = 0; blob - 33e812698b9756cd4f898ba089f68dfac855b13c blob + b52badcb7e0f7370c675ffac5a63db6c48a127fc --- src/fugu-tty/term_input.h +++ src/fugu-tty/term_input.h @@ -26,8 +26,9 @@ /* * The terminal input decoder (behavior.md 2.1): a pure state machine * turning a raw byte stream from the terminal into editor key events -- - * UTF-8 assembly, CSI/SS3 escape sequences to key symbols, and xterm - * bracketed paste (ESC[200~ ... ESC[201~) collected as one atomic event. + * UTF-8 assembly, CSI/SS3 escape sequences to key symbols, xterm SGR mouse + * wheel reports, and bracketed paste (ESC[200~ ... ESC[201~) collected as + * one atomic event. * It is fed bytes incrementally (input arrives in bursts) and drained one * event at a time, so nothing about input handling depends on wgetch and * the whole thing is unit-tested headless. 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;