Compare commits

...
39 Commits
Author SHA1 Message Date
conductor df413e8e41 chore(release): auto-bump version to 0.346.0 — earned by # (minor: converged release (push-to-master bump))
Auto-version and Release / release (push) Successful in 28s
deploy / deploy (push) Skipped
CI Pipeline / best-practices (push) Successful in 12s
CI Pipeline / rust-surface (push) Failing after 17s
CI Pipeline / documentation (push) Successful in 13s
TUI Release / release (push) Successful in 33s
CI Pipeline / gitea-offline (push) Successful in 24s
CI Pipeline / go-surface (push) Failing after 43s
CI Pipeline / lint (push) Failing after 54s
CI Pipeline / type-check (push) Failing after 53s
CI Pipeline / canary (push) Failing after 3m4s
CI Pipeline / unit-tests (push) Failing after 27m21s
CI Pipeline / required-checks (push) Failing after 1s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Failing after 4s
Promotion record: (unknown)
2026-10-24 07:11:25 +01:00
Nick c8b12cc496 feat(tool-executor): the LOCAL TOOL EXECUTION GO DELEGATION — LocalToolRuntime's executor becomes a fail-loud delegation facade over src/go/tool-executor: the four read-only ops (read_file, list_directory, glob_files, grep_files) resolve through the Go binary by default with the JSON envelope preserved exactly; run_command stays behind the approval door (mutating — never silently executed); the Python bodies stay as oracle/escape hatch (RUNE_TOOL_EXECUTOR_ENGINE=python, RUNE_TOOL_EXECUTOR_BIN override, typed RuntimeError on missing binary, no Python deletion). Red-first parity battery caught four real Go↔Python divergences (trailing-newline line numbering, windowing context markers + offset-relative numbering, absolute grep paths, oracle error texts) before landing — all fixed byte-identical; scripted-executor test fixtures pin the python escape hatch (Gitea #2575 wave B, red-first)
CI Pipeline / documentation (push) Successful in 12s
CI Pipeline / best-practices (push) Successful in 13s
CI Pipeline / required-checks (push) Canceled after 0s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Canceled after 0s
CI Pipeline / lint (push) Canceled after 16s
CI Pipeline / go-surface (push) Canceled after 16s
CI Pipeline / rust-surface (push) Canceled after 16s
CI Pipeline / gitea-offline (push) Canceled after 2s
CI Pipeline / unit-tests (push) Canceled after 17s
CI Pipeline / canary (push) Canceled after 17s
CI Pipeline / type-check (push) Canceled after 3s
deploy / deploy (push) Canceled after 2s
Auto-version and Release / release (push) Canceled after 18s
2026-10-24 07:11:06 +01:00
conductor ec2ee953c8 chore(release): auto-bump version to 0.345.0 — earned by # (minor: converged release (push-to-master bump))
Auto-version and Release / release (push) Successful in 21s
deploy / deploy (push) Skipped
CI Pipeline / best-practices (push) Successful in 13s
CI Pipeline / rust-surface (push) Failing after 23s
CI Pipeline / documentation (push) Successful in 11s
TUI Release / release (push) Successful in 31s
CI Pipeline / gitea-offline (push) Successful in 21s
CI Pipeline / go-surface (push) Failing after 44s
CI Pipeline / lint (push) Failing after 56s
CI Pipeline / type-check (push) Failing after 54s
CI Pipeline / canary (push) Failing after 3m4s
CI Pipeline / unit-tests (push) Failing after 27m24s
CI Pipeline / required-checks (push) Failing after 1s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Failing after 5s
Promotion record: (unknown)
2026-10-24 05:26:42 +01:00
Nick e39ea6ac36 feat(reconcile-tools): the CAPABILITY-FLOOR DRIFT RECONCILIATION GO PORT — reconcile_tools_support (the R2 I-R2-022 truth_drift seam) gains a Go engine surface (src/go/model_routing ReconcileToolsSupport + 'eval reconcile'), closing wave 181's full-switchover blocker: a provider supports_tools claim contradicting CAPABILITY_FLOOR surfaces as unverified drift, never silently flipping eligibility, non-floor models are never invented a floor verdict; the Python body stays as oracle/escape hatch (RUNE_MODEL_ROUTING_ENGINE=python, no Python deletion) and the function becomes a fail-loud delegation facade (typed RuntimeError on missing engine binary); red-first battery caught the drift marker's 'true/false' vs oracle 'True/False' before landing — port fixed byte-identical (Gitea #2574 wave A, red-first)
CI Pipeline / documentation (push) Successful in 20s
CI Pipeline / best-practices (push) Successful in 21s
CI Pipeline / required-checks (push) Canceled after 0s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Canceled after 0s
CI Pipeline / type-check (push) Canceled after 2s
CI Pipeline / gitea-offline (push) Canceled after 4s
CI Pipeline / lint (push) Canceled after 25s
CI Pipeline / unit-tests (push) Canceled after 25s
CI Pipeline / rust-surface (push) Canceled after 24s
CI Pipeline / go-surface (push) Canceled after 26s
CI Pipeline / canary (push) Canceled after 26s
Auto-version and Release / release (push) Canceled after 28s
deploy / deploy (push) Canceled after 6s
2026-10-24 05:26:14 +01:00
Nick 885332a8ed docs(reports): the D53 wave report — per-item outcomes, the long-answer reproduction, the scrollbar overlay diagnosis, verification and deploy state (Gitea #2568)
Auto-version and Release / release (push) Successful in 21s
CI Pipeline / best-practices (push) Successful in 9s
CI Pipeline / documentation (push) Successful in 10s
CI Pipeline / rust-surface (push) Failing after 21s
CI Pipeline / gitea-offline (push) Successful in 28s
CI Pipeline / go-surface (push) Failing after 42s
CI Pipeline / lint (push) Failing after 57s
CI Pipeline / type-check (push) Failing after 1m0s
CI Pipeline / canary (push) Failing after 3m32s
deploy / deploy (push) Failing after 15m25s
CI Pipeline / unit-tests (push) Failing after 30m22s
CI Pipeline / required-checks (push) Failing after 1s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Failing after 8s
2026-10-24 04:45:52 +01:00
conductor 914910c006 chore(release): auto-bump version to 0.344.0 — earned by # (minor: converged release (push-to-master bump))
Auto-version and Release / release (push) Successful in 35s
deploy / deploy (push) Skipped
CI Pipeline / best-practices (push) Successful in 8s
CI Pipeline / rust-surface (push) Failing after 28s
CI Pipeline / documentation (push) Successful in 14s
TUI Release / release (push) Successful in 37s
CI Pipeline / go-surface (push) Failing after 46s
CI Pipeline / gitea-offline (push) Successful in 23s
CI Pipeline / lint (push) Failing after 1m0s
CI Pipeline / type-check (push) Failing after 57s
CI Pipeline / canary (push) Failing after 3m35s
CI Pipeline / required-checks (push) Canceled after 0s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Canceled after 0s
CI Pipeline / unit-tests (push) Canceled after 21m0s
Promotion record: (unknown)
2026-10-24 04:24:23 +01:00
Nick 48d85e169c style(tui): gofmt the D53 touched files
CI Pipeline / documentation (push) Successful in 15s
CI Pipeline / best-practices (push) Successful in 15s
CI Pipeline / required-checks (push) Canceled after 0s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Canceled after 0s
deploy / deploy (push) Canceled after 0s
CI Pipeline / type-check (push) Canceled after 10s
CI Pipeline / rust-surface (push) Canceled after 25s
CI Pipeline / go-surface (push) Canceled after 25s
Auto-version and Release / release (push) Canceled after 25s
CI Pipeline / lint (push) Canceled after 26s
CI Pipeline / canary (push) Canceled after 26s
CI Pipeline / unit-tests (push) Canceled after 26s
CI Pipeline / gitea-offline (push) Canceled after 11s
2026-10-24 04:20:16 +01:00
Nick f869fa407b feat(tui-sub-agents): the SUB-AGENTS surface — the activity pane gains the 'sub agents being processed now' section rendered ONLY while the syncWaves flight observation reports agents in flight (one poller, the section can never disagree with the rail marker or the wave tab), clickable via the mouse.activity.agents region with ctrl+w keyboard parity, opening the CENTERED agents window (the D52 picker pattern) listing all in-flight agents, the selected row's thought process / recent activity (the factory journal + observations detail the shell already derives) rendered beneath; window is modal (esc/up/down, any other key closes), a plain-value AgentView seam keeps lanes un-importable from tasks (Gitea #2568 item 7, red-first) 2026-10-24 04:20:16 +01:00
Nick 71fd16a36b test(tui-idle-token): the D52 frozen idle test follows the D53 owner ruling — idle's rest tone is the RoleStateIdle yellow token (supersession recorded in the test's law comment) 2026-10-24 04:20:16 +01:00
Nick 06302b2472 feat(tui-composer-styling): the composer's enter-send/shift+enter key hints render GHOSTED PINK (a new themes RoleHintPink token — softer than the speaker pink, calm variant included, re-resolved on the calm flip) and the activity pane's idle state renders YELLOW (the RoleStateIdle token replacing the idle dim) (Gitea #2568 item 6, red-first) 2026-10-24 04:20:16 +01:00
Nick 704b4488e6 feat(tui-info-bar): the context readout gains the usage percentage in brackets — 'context 4.2k/32k (13%)' computed from the existing used/limit pair, clamped 0-100, still silent when the engine never reported (Gitea #2568 item 5, red-first) 2026-10-24 04:20:16 +01:00
Nick 6aa271c2d4 test(tui-dynamic-reflow): the DYNAMIC REFLOW squash test — a WindowSizeMsg driven through the face chain re-wraps every bubble and box at the new width: no rendered line exceeds the pane at 44/100 columns in either direction and the scroll offset normalizes to a legal window after the line-count change (frozen law; the render-path rewrap already held — the test pins it against regression) (Gitea #2568 item 4, red-first) 2026-10-24 04:20:16 +01:00
conductor d054ecb1c6 chore(release): auto-bump version to 0.343.0 — earned by # (minor: converged release (push-to-master bump))
Auto-version and Release / release (push) Successful in 26s
deploy / deploy (push) Skipped
CI Pipeline / best-practices (push) Successful in 14s
CI Pipeline / rust-surface (push) Failing after 23s
CI Pipeline / documentation (push) Successful in 8s
TUI Release / release (push) Successful in 39s
CI Pipeline / go-surface (push) Failing after 44s
CI Pipeline / gitea-offline (push) Successful in 23s
CI Pipeline / lint (push) Failing after 1m0s
CI Pipeline / type-check (push) Failing after 50s
CI Pipeline / canary (push) Failing after 3m30s
CI Pipeline / unit-tests (push) Failing after 31m13s
CI Pipeline / required-checks (push) Failing after 1s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Failing after 7s
Promotion record: (unknown)
2026-10-24 03:49:26 +01:00
Nick 1bbd48fbe5 feat(streaming): the STREAMING ROOT-CAUSE WAVE — a turn never sits inside one blocking call again (Gitea #2556, owner verdict: the 330s→480s→6x480s lineage patched the symptom)
CI Pipeline / best-practices (push) Successful in 14s
CI Pipeline / documentation (push) Successful in 13s
CI Pipeline / required-checks (push) Canceled after 0s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Canceled after 0s
deploy / deploy (push) Canceled after 0s
CI Pipeline / lint (push) Canceled after 21s
CI Pipeline / unit-tests (push) Canceled after 21s
CI Pipeline / rust-surface (push) Canceled after 21s
CI Pipeline / gitea-offline (push) Canceled after 7s
Auto-version and Release / release (push) Canceled after 21s
CI Pipeline / go-surface (push) Canceled after 21s
CI Pipeline / canary (push) Canceled after 22s
CI Pipeline / type-check (push) Canceled after 8s
- Ollama wire: stream=true /api/chat, NDJSON chunks parsed incrementally
  (content/thinking/tool_calls), the final reply carries the assembled
  answer — the engine's bounded tool loop runs over streamed replies
  (StreamChatClient, loop semantics unchanged)
- Cloud wires: streaming parity — [OI]-compat SSE, anthropic SSE events,
  gemini streamGenerateContent (alt=sse); a provider that answers plain
  JSON despite stream=true falls back honestly to the assembled decode
- Core /v1/chat/completions: stream=true relays the model's bytes as
  NDJSON content chunks (flushed per chunk); a non-streaming leg still
  answers the full content in one chunk (the classic framing); errors
  before the first byte answer the honest envelope
- Entry proxy: the dispatch client carries NO whole-call timeout (the
  core governs streamed deadlines); chunk-relay pass-through proven
  incremental; the generic proxy client keeps the whole-turn fallback
- TUI: CompleteStream parses the chunk framing, the lane dispatcher
  forwards deltas to the sink, the chatpane renders the answer AS IT
  ARRIVES (one progressive bubble the durable final append adopts —
  never a duplicate; a failed dispatch retires it)
- DEADLINE REPLACEMENT: the flat whole-turn budgets retire on streamed
  paths — the INTER-CHUNK IDLE TIMEOUT (120s default,
  RUNE_STREAM_IDLE_TIMEOUT configurable) is the honest dead-model
  guardian with a 30-minute backstop; ErrStreamIdle is typed; the
  budget constants remain for non-streaming fallback paths
- red-first: ollama/cloud SSE hermetic doubles, slow-model completes /
  dead-model trips-idle simulations, live-chunk relay proofs (core,
  entry), progressive-render bubble laws, stream dispatch laws

admission: ADMIT-wave-streaming-deadline-root
2026-10-24 03:48:49 +01:00
Nick 7a2a499ce7 feat(tui-quit-warning): the QUIT WARNING — the first ctrl+c of the D46 exit gesture swaps the composer's key hint for the literal soft hint 'press ctrl+c again to quit'; any other key or the 1s timeout restores the live key hint (the D46 timeout disarm was unreachable dead code inside the KeyPressMsg case — the reset now has its own switch case); the second press quits and never shows the warning; the face chain gains the Hint read-back seam (Gitea #2568 item 3, red-first) 2026-10-24 02:40:54 +01:00
Nick 3ea85744e9 feat(tui-scrollbar): the SCROLLBAR wave — the fade layer's overlay splice dropped the thumb glyph on short styled rows (the common transcript shape), so no bar ever rendered; the splice now lands the glyph at the column even past the line's end, the thumb carries a new themes RoleScroll token (scheme-matched yet distinct, calm variant included, floors hold), per-pane rows render styled-or-empty for chat AND tasks, and the wheel step math is pinned (one notch = its exact 3-line step, exact accumulation) (Gitea #2568 item 2, red-first) 2026-10-24 01:31:16 +01:00
Nick 00d55b4383 fix(tui-long-answer-format): the LONG-ANSWER FORMAT BREAK — the table breakout's edge rows overflowed the pane width at narrow widths (edge/body geometry disagreed by the separator columns); rows and edges now share one width, an honest too-narrow drop, and the 20+ block mixed-answer repro asserts no line exceeds the pane at any width, summary-first at any length, no reflow on append (Gitea #2568 item 1, red-first) 2026-10-24 00:44:01 +01:00
conductor 0d0a76c1c5 chore(release): auto-bump version to 0.342.0 — earned by # (minor: converged release (push-to-master bump))
Auto-version and Release / release (push) Successful in 43s
deploy / deploy (push) Skipped
CI Pipeline / best-practices (push) Successful in 15s
CI Pipeline / documentation (push) Successful in 6s
CI Pipeline / rust-surface (push) Failing after 22s
TUI Release / release (push) Successful in 39s
CI Pipeline / gitea-offline (push) Successful in 24s
CI Pipeline / go-surface (push) Failing after 48s
CI Pipeline / lint (push) Failing after 1m6s
CI Pipeline / type-check (push) Failing after 58s
CI Pipeline / canary (push) Failing after 3m55s
CI Pipeline / unit-tests (push) Failing after 31m16s
CI Pipeline / required-checks (push) Failing after 1s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Failing after 5s
Promotion record: (unknown)
2026-10-23 21:21:12 +01:00
Nick fa7304af55 refactor(tui-literal-language): the LITERAL-LANGUAGE AUDIT — face strings rewritten literally, a frozen idiom audit test added (D54 item 6, Gitea #2571, red-first)
CI Pipeline / documentation (push) Successful in 14s
CI Pipeline / best-practices (push) Successful in 14s
CI Pipeline / required-checks (push) Canceled after 0s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Canceled after 0s
CI Pipeline / lint (push) Canceled after 19s
CI Pipeline / unit-tests (push) Canceled after 20s
CI Pipeline / type-check (push) Canceled after 5s
CI Pipeline / gitea-offline (push) Canceled after 5s
CI Pipeline / go-surface (push) Canceled after 20s
CI Pipeline / rust-surface (push) Canceled after 21s
Auto-version and Release / release (push) Canceled after 22s
CI Pipeline / canary (push) Canceled after 21s
deploy / deploy (push) Canceled after 42s
Rewrites (before -> after):
- "consent enqueue in flight — wait for its receipt" -> "consent
  enqueue being processed now — wait for its receipt"
- "one completion in flight — phase 1 sends one at a time; wait for
  the reply" -> "one completion being processed now — ..."
- "approval resolution in flight — wait for its receipt" ->
  "approval resolution being processed now — ..."
- "the engine answered without a verdict" -> "the engine answered
  without a clear result" (legal metaphor, x2 sites)
- "↑/↓ walk · enter choose" -> "↑/↓ move · enter choose"
- "(none in flight)" (the + window's empty sections) -> "(none now)"
- "state %s · %02d:%02d in flight" -> "state %s · running %02d:%02d"

Kept as terms of art: "seam" (the wiring seam — the synthesis cites
"no task seam wired" as good literal style), "brief", "card".
TestFaceStringsAreLiteral freezes the audit over the face-string
sites; the busy-bubble pin updated for its rewritten wording.
2026-10-23 21:18:18 +01:00
Nick 99c8d5850b feat(tui-calm-mode): the CALM MODE sensory setting — vivid|calm with vivid the DEFAULT (owner ruling), calm mutes chroma at the token layer and stops the wave (D54 item 5, Gitea #2571, red-first)
- settings store: themes.sensory key inside the closed address space,
  Sensory field, rendered in the themes section, ToggleSensory()
  stages vivid<->calm like any edit (owner validates at Submit) —
  the NAS Vivid/Calm precedent as a toggle, never a ban
- the token law pays off: the mute is ONE file (themes/roles.go —
  calmRoles: speaker #DE9BC1 7.52:1, state #CAB8E7 9.06:1, dim/bg
  unchanged); chatpane + lanes re-resolve their role styles via
  ApplyCalm/refreshRoleStyles
- shell: RUNE_SENSORY=calm boot seam (same shape as
  RUNE_REDUCED_MOTION) sets calm and closes the activity wave's
  motion gate (activityMotionAllowed = !reducedMotion && !calm)
- tests: default-is-vivid pinned, calm tokens still >=4.5:1, toggle
  flip/submit proven; the settings round-trip pins updated for the
  new rendered row
2026-10-23 20:57:34 +01:00
Nick 2ad9c65721 feat(tui-summary-first): the SUMMARY-FIRST law — answers over 8 blocks open with a one-line plain-english summary box (D54 item 4, Gitea #2571, red-first)
- blocks.go: summaryBlockThreshold = 8; summaryLine composes the
  literal count ("Summary: 12 blocks — 9 paragraphs, 2 lists, 1
  table. The blocks follow.") — the generalization of the
  "code (n lines)" collapse hint, in the dim narration family
- emitted before any block when the parsed stream exceeds the
  threshold; short answers carry no summary (asserted)
- the B4 pin (TestLongAnswerNeverBreaksBubble) updated: the content
  bubble itself is never broken; the summary box rides beside it
2026-10-23 20:17:10 +01:00
Nick da8a342886 feat(tui-spacing-law): the SPACING LAW — one blank line between blocks via a pure layout constant, block types never merge (D54 item 3, Gitea #2571, red-first)
- blocks.go: blockGapBlankLines = 1 (pure layout, no theme
  dependency); every block region (bubble, table breakout, heading
  box, list box) is separated by exactly one blank line
- the never-merge law written at the parser: mdBlocks keeps list/
  para/table/heading distinct; asserted by TestBlockTypesNeverMerge
- TestOneBlankLineBetweenBlocks walks the full answer presentation
  (para, list, table, para): no two blanks adjacent, no two boxes
  abut; TestBlockGapIsPureLayoutConstant pins the constant
- chatpane_test.go: the D43.4 direction pin updated for the law's
  trailing block gap (6 -> 7 rows)
2026-10-23 20:05:39 +01:00
Nick aeebfee796 feat(tui-colour-tokens): the COLOUR-TOKEN LAW — one hex per role in tui/themes, the four hex-literal families become role references, contrast floors unit-tested, the Faint-on-dim breach fixed (D54 item 2, Gitea #2571, red-first)
- roles.go: Role enum {speaker, state-activity, meta-dim, background},
  one hex each defined once; semantic-vs-decoration rule written
  (role -> one visual element class); muting is now a one-file change
- chatpane.go/blocks.go/activity.go (and the shell's Accent literals):
  every #78788A/#FF79C6/#1E1E28 literal replaced with
  themes.RoleHex(...) references
- TestContrastFloors (WCAG relative-luminance): body tones measured
  5.97-10.75:1, speaker 6.92:1, state 7.72:1 — all >=4.5:1
- D54 FINDING: the dim tier itself breached — #78788A on #1E1E28 is
  3.82:1, and its Faint composite 1.93:1. Fixed at the token: meta-dim
  lifted to #8A8A9E (4.88:1, same family), Faint dropped from the
  timestamp style; Faint-on-dim is now forbidden by test
- go vet/gofmt clean; chatpane/lanes/themes/cmd suites green (boot
  smoke is the documented pre-existing PTY family)
2026-10-23 19:48:44 +01:00
Nick 0498b00c02 feat(tui-motion-law): the MOTION LAW — motionAllowed gate freezes the activity wave, the chat pane's never-animate law is written and asserted append-only (D54 item 1, Gitea #2571, red-first)
- the gate sits on the wave render path in activity.go (around
  waveOffset/snakeWord): closed = frame-frozen strip, the rest idle
  already takes (A5) extended to active states; wired down the face
  chain lanes->rail->promote->tasks and driven from the shell's
  syncActivity (reduced motion closes it)
- chatpane: the written never-animate law (no motion in the reading
  pane; stream append is append-only, never reflows completed blocks)
  asserted by TestAppendNeverReflowsCompletedBlocks on the full
  renderLines stream, styled and plain
- one-animating-region-per-frame documented at the face chain's
  Update loop (Init/heartbeat in cmd/rune-tui/main.go)
- admission door: scripts/admit_wave_d54_nd_presentation.py, contract
  ADMIT-wave-d54-nd-presentation minted (Rule-5)
- go test -race -short ./... in tui/ green; D52's rainbow test
  updated for the gate's default-open constructor
2026-10-23 19:12:48 +01:00
conductor 2cc88f7f94 chore(release): auto-bump version to 0.341.0 — earned by # (minor: converged release (push-to-master bump))
Auto-version and Release / release (push) Successful in 26s
deploy / deploy (push) Skipped
CI Pipeline / best-practices (push) Successful in 11s
CI Pipeline / documentation (push) Successful in 11s
CI Pipeline / rust-surface (push) Failing after 21s
CI Pipeline / gitea-offline (push) Successful in 29s
CI Pipeline / go-surface (push) Failing after 46s
TUI Release / release (push) Successful in 35s
CI Pipeline / lint (push) Failing after 56s
CI Pipeline / type-check (push) Failing after 1m6s
CI Pipeline / canary (push) Failing after 3m33s
CI Pipeline / unit-tests (push) Failing after 29m21s
CI Pipeline / required-checks (push) Failing after 0s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Failing after 4s
Promotion record: (unknown)
2026-10-23 17:40:28 +01:00
Nick 6a91563f42 chore(verification): unified-check manifest at the D52 push HEAD (pre-existing environment REDs: python domain/seam registry, rust graph_projection, the PTY boot family in go -race full run — identical at the pre-wave base)
CI Pipeline / best-practices (push) Successful in 13s
CI Pipeline / documentation (push) Successful in 13s
CI Pipeline / required-checks (push) Canceled after 0s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Canceled after 0s
deploy / deploy (push) Canceled after 0s
CI Pipeline / gitea-offline (push) Canceled after 3s
CI Pipeline / canary (push) Canceled after 23s
CI Pipeline / type-check (push) Canceled after 10s
CI Pipeline / rust-surface (push) Canceled after 24s
Auto-version and Release / release (push) Canceled after 25s
CI Pipeline / lint (push) Canceled after 26s
CI Pipeline / unit-tests (push) Canceled after 26s
CI Pipeline / go-surface (push) Canceled after 26s
2026-10-23 15:53:30 +01:00
Nick 809181943c chore(verification): unified-check manifest for the D52 landing commit (tui green -race -short; the PTY boot family is the documented pre-existing environment failure) 2026-10-23 15:37:19 +01:00
Nick 4d7144eb61 admit: wave-d52-seedleak-animation contract — the Rule-5 door for the D52 wave (Gitea #2566, #2567) 2026-10-23 13:38:57 +01:00
Nick c3439acc3c fix(tui-clickmap): the + glyph lands ON its click region; the model token opens the CLOUD/LOCAL picker window with no crossfire (D52 items 3-4, Gitea #2566 #2567, red-first)
ROOT CAUSE of the dead + (PTY-proven): Render padded the strip by the
ANSI-STYLED + string's BYTE length (8 SGR bytes), pushing the visible
glyph to column 75 while its declared region mouse.activity.plus
covers 77-79 — the owner clicked the glyph they saw and missed. THE
FIX: pad by the glyph's display width; region and render share one
activityPlusWidth constant. The debuggable assertion (regionGlyphColumns)
maps every declared region onto the rendered row's columns — the
click-region mapping is testable without the flaky PTY input leg.

The model-token click: the picker window now splits into labelled
CLOUD and LOCAL sections (one walkable list — type-to-filter, up/down,
enter records the pick), click and ctrl+m share the window, and the
no-crossfire test pins each road opening only its own window. Also
repairs four stale treeRows callers (HEAD's 3-return signature).
2026-10-23 13:38:56 +01:00
Nick c05f18e293 feat(tui-activity): the WORD WAVES — per-letter rise-settle-reverse motion with a vibrant rainbow mover (D52 item 2, owner ruling, #2566, red-first)
Replaces the snake sweep: the first letter rises and settles, then the
second, ... sequentially to the last letter (the g in 'thinking'), then
the wave REVERSES back to the start, looping. More frames — a graded
4-frame rise/peak/settle ramp per letter, not a 2-step hop. Vibrancy
while active: the moving letter carries a saturated rainbow hue that
sweeps with the frame and brightens at the peak; the settled letters
keep the per-activity palette (D48 rulings). Idle rests dim — the wave
never plays at rest. Layer Law: own state, own tests (offset physics
pinned over the full cycle, loop identity, vibrancy, idle rest); frame
timing rides the shell's shared heartbeat under the arbitration law
(canon P3).
2026-10-23 13:36:14 +01:00
Nick 737a4ad804 fix(tui-seedleak): production faces boot EMPTY — the lab seed stops rendering in the live face (D52 item 1, Gitea #2566, red-first)
The owner's recurring 'fix the login timeout' phantom was the lab
substrate leaking into the production face: the semantic seed's three
lab tasks rendered as boot task rows, the promote seed's suggestion
fixture ('fix the login timeout now - it blocks deploys' — the consent
classifier's TEST string) held behind the promotion region, SeedTree's
lab tree behind the tree region. THE FIX: semantic.Empty → lanes/
rail/promote/tasks empty constructors chain; tasks.New/NewLinear boot
with no task rows, no suggestions, no lab tree and the honest idle
render ('no outstanding work', the D51 blank-unless-goals ruling);
SetLiveTree/Offer fill the faces when real data arrives. The seeds
stay confined to the lab harness — NewSeeded lab constructors keep
the frozen fixtures explicit (contract ADMIT-wave-d52-seedleak-
animation).
2026-10-23 13:34:51 +01:00
conductor 5b8b07a506 chore(release): auto-bump version to 0.340.0 — earned by # (minor: converged release (push-to-master bump))
Auto-version and Release / release (push) Successful in 42s
deploy / deploy (push) Skipped
CI Pipeline / best-practices (push) Successful in 14s
CI Pipeline / documentation (push) Successful in 19s
CI Pipeline / rust-surface (push) Failing after 33s
TUI Release / release (push) Successful in 41s
CI Pipeline / go-surface (push) Failing after 51s
CI Pipeline / gitea-offline (push) Successful in 22s
CI Pipeline / lint (push) Failing after 1m7s
CI Pipeline / type-check (push) Failing after 1m9s
CI Pipeline / canary (push) Failing after 4m12s
CI Pipeline / unit-tests (push) Failing after 35m35s
CI Pipeline / required-checks (push) Failing after 1s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Failing after 5s
Promotion record: (unknown)
2026-10-23 02:10:19 +01:00
Nick f884a7cd91 feat(tui-palette): the '/'-command submenu is selectable — up/down walk the hint rows, enter runs the highlighted command (D50 item 5, Gitea #2555 item 7, red-first)
CI Pipeline / best-practices (push) Successful in 14s
CI Pipeline / documentation (push) Successful in 14s
CI Pipeline / required-checks (push) Canceled after 0s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Canceled after 0s
CI Pipeline / unit-tests (push) Canceled after 21s
deploy / deploy (push) Canceled after 0s
CI Pipeline / rust-surface (push) Canceled after 23s
CI Pipeline / gitea-offline (push) Canceled after 7s
CI Pipeline / canary (push) Canceled after 24s
CI Pipeline / go-surface (push) Canceled after 24s
Auto-version and Release / release (push) Canceled after 25s
CI Pipeline / type-check (push) Canceled after 10s
CI Pipeline / lint (push) Canceled after 26s
The inline '/' road showed its matching command rows but only an exact
or unique-prefix send could run one. While a '/'-leading draft shows
the hints, up/down now move the selection cursor over the matching
rows and enter runs the HIGHLIGHTED command through the SAME executor
as the palette (the palette's ↑/↓/enter law, carried to the slash
road); the cursor resets to the first row when the slash word changes
and the draft clears after the run. Input3 grows an exported Clear
for the run's reset leg.
2026-10-23 02:07:06 +01:00
Nick d471767325 feat(tui-tasks): PINNED WORK — a reserved top area in the tasks pane, persisting across boots (D50 residual 4, #2560, D51 spec upgrade, red-first)
The pinned-work layer (tui/tasks/pinned.go, own layer, own tests): a
reserved area at the TOP of the tasks pane's region view — the pane's
first content — accumulating pinned work native to Rune. The header
carries the pin icon + 'pinned (N) — outstanding work' label; each pin
renders one line with its PLAIN-ENGLISH SUMMARY (D51); clicking the
area opens the OUTSTANDING PINNED WORK view, clicking a pin (or x,
which releases the newest) releases it, esc backs out; NO pins renders
NO area — the pane stays blank otherwise, with no selectable rows
anywhere (D51). Keyboard parity for the pin verb: ctrl+b pins the top
live work block — the control-key idiom family (ctrl+q steers,
ctrl+g gauges, ctrl+p palette); plain letters always type (D36).

Pins persist across boots through the hot-state store pattern (the
task_state/history-depth law): $RUNE_HOME/tasks_pins.json, written
atomically on every change, loaded at boot; the layer stays pure
view-model, the shell owns the record. The layer law, keyboard parity
and store are documented in
docs/openspec/changes/add-native-rune-tui/spec-tasks-pinned-work.md.
PTY proof for the boot leg + enter dispatch:
tui/cmd/rune-tui/d50_pty_test.go (the boot_smoke method; its typing
leg shares the environment's documented PTY-input breakage — the
model-level tests in pins_test.go carry the proof green).
2026-10-23 02:07:06 +01:00
Nick 0581d909f3 refactor(tui-tasks): REMOVE the tasks-pane row-selection/navigation mode — enter dispatches work directly (D50 residual 3, owner ruling, #2560, red-first)
WHAT WAS REMOVED (the mechanism, recorded here since deletion is the
point): the tree region's row walker — the selection cursor (f.sel)
with j/k/up/down walking and shift+tab wraparound cycling, the '>'
cursor mark, the mouse.tasks.jump click-to-select regions and the
Boxes() selection ring over tree rows, the row-verb chrome keys
(J/K reorder, a/A assign/reassign, s start, d done) and the K20
row-command path they fed (queueCommand; its receipt mirror kept, its
walker leg deleted), the cursor-follow scroll window (f.view/follow),
SelectedID/SelectedBox-on-open, and TasksSwallow as the region's
default key answer. The frozen fixtures that pinned the removed
mechanism went with it (reorder-mid-flight fixture + walker nav,
click-equivalence, nav-bounds, row-command suites) and the boundary
laws were re-pinned to the new doctrine: the tree region is a VIEW —
it declares NO selectable boxes (D51: selectable rows block chat
commands), delegates unowned keys whole, and never owns the keyboard.

THE NEW LAW (pinned red-first by TestD50TasksFocusEnterDispatches
AfterClickFocus): enter on tasks-pane focus starts direct task
execution through the task-queue admission seam (D44 doctrine) EVEN
after a click-focus opened the tree region — ChromeOpen no longer
counts the tree region, so the composer stays active and dispatches
through it. Plain letters type under the open region (letter law:
'e' is a letter again; the work-block expand affordance moves to
ctrl+e); up/down scroll the tasks panel's own scroll window (#2535)
instead of walking rows. The queue-steer view (ctrl+q, D44-D) keeps
its own cursor — it is a control mode, not the removed row selection.
2026-10-23 02:07:06 +01:00
Nick 89617a62ba fix(tui-tasks): phantom queue rows stop rendering — the freshness law filters the pending list (D50 residual 2, #2560, red-first)
The owner's 3 phantom 'tasks available' rows were stale occupancy
re-listed by the queue endpoint forever. The tasks pane's pending list
now applies the freshness law (the 30-min occupancy pattern): a row
older than the window stops rendering; the 30-min boundary occupant
stays; a zero stamp is not proof of staleness and stays (never a
guessed drop). Read-side in the TUI — honest degradation, the core's
queue listing endpoint is another agent's surface this wave.
2026-10-23 02:07:06 +01:00
Nick f81385fce9 feat(tui-workblock): work blocks carry the spawning chat msg_id — dim-family ↩ reference in the block header (D50 residual 1, #2560, red-first)
The work-stream records now link back to the chat demand that spawned
the work: DeriveWorkBlocks keys the origin on the oldest role=user
record the conversation wire carries for the task, using the msg_ids
the core already journals (D51: newMsgID exists core-side — no new
generation; the runecore write-side origin ref is deferred, engine.go
off-limits while the D49 agent works there). The reference renders as
'↩ <msg_id>' on the work block header, inside the clip budget — the
tasks pane's rune-counted width math carries no ANSI, so the dim tone
degrades to the plain marker here (honest degradation, documented).
No demand row on the wire renders nothing — never a fabricated ref.

Admission: ADMIT-wave-d50-residuals (scripts/admit_wave_d50_residuals.py,
EXISTING_PROJECT_CHANGE, building).
2026-10-23 02:07:05 +01:00
conductor a48037e091 chore(release): auto-bump version to 0.339.0 — earned by # (minor: converged release (push-to-master bump))
Auto-version and Release / release (push) Successful in 36s
deploy / deploy (push) Skipped
CI Pipeline / best-practices (push) Successful in 4s
CI Pipeline / documentation (push) Successful in 6s
CI Pipeline / rust-surface (push) Failing after 30s
TUI Release / release (push) Successful in 49s
CI Pipeline / gitea-offline (push) Successful in 27s
CI Pipeline / go-surface (push) Failing after 1m1s
CI Pipeline / lint (push) Failing after 1m15s
CI Pipeline / type-check (push) Failing after 1m10s
CI Pipeline / canary (push) Failing after 4m17s
CI Pipeline / unit-tests (push) Failing after 36m6s
CI Pipeline / required-checks (push) Failing after 1s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Failing after 7s
Promotion record: (unknown)
2026-10-22 21:15:09 +01:00
Nick d31359f3a5 feat(runecore): D49 capability in every pane + native git tool family (red-first)
CI Pipeline / best-practices (push) Successful in 15s
CI Pipeline / documentation (push) Successful in 16s
CI Pipeline / required-checks (push) Canceled after 0s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Canceled after 0s
CI Pipeline / rust-surface (push) Canceled after 25s
CI Pipeline / type-check (push) Canceled after 6s
Auto-version and Release / release (push) Canceled after 29s
CI Pipeline / unit-tests (push) Canceled after 30s
CI Pipeline / gitea-offline (push) Canceled after 13s
CI Pipeline / go-surface (push) Canceled after 30s
CI Pipeline / lint (push) Canceled after 30s
CI Pipeline / canary (push) Canceled after 31s
deploy / deploy (push) Canceled after 20s
The owner's ruling — 'i want that ability to be in every pane' —
supersedes the mode-gated toolset: ToolsetFor now exposes the full
suite (read + write families) in CHAT, PLAN and TASK alike, and
ExecuteGatedTool's write-refusal-by-mode retires; the dispatch-gate
seam stays (future policies hook there) with the typed refusal kept
for genuinely-unavailable tools. The wire tool descriptions state the
write availability — the model asked for shell access because the wire
showed read-only; the wire is fixed, not the model. The consent card
remains the work-promotion mechanism (organization), never a
permission gate; the real safety layer is the scaffolding authority +
contract gates at mutation doors.

Native git family (gittools.go): git_status, git_diff (unstaged+
staged, names and patch forms), git_log, git_branch, git_add,
git_commit — stdlib git subprocess in DefaultRepoRoot() (RUNE_REPO_ROOT
override), porcelain parsed honestly (-z, tab records), typed JSON
results. git_add/git_commit refuse the landing-law banned paths
(check_landing.py rule 4 SWEEP_RACE_BANNED_PATHS + 20MB ceiling);
git_commit runs the repository's own pre-commit hook in a real
checkout.

Contract: ADMIT-wave-d49-capability-everywhere (mode/toolset tests
re-pinned to CHAT=full-suite, red-first evidence against the pre-wave
toolset; go test -race green, vet/gofmt clean).
2026-10-22 20:46:48 +01:00
120 changed files with 10882 additions and 1016 deletions

No files matched your search

+2 -2
View File
@@ -1,9 +1,9 @@
# Rune
**Current release: v0.338.0**
**Current release: v0.346.0**
<!-- highlights:start -->
## Highlights — from the latest release (v0.338.0)
## Highlights — from the latest release (v0.346.0)
- Plain-English jargon dictionary covers the v0.329 vocabulary, plus catch-all sentence simplification pass.
@@ -0,0 +1,49 @@
# Spec: Pinned Work in the Tasks Pane (D50 residual 4, #2560)
Status: landed (wave D50-residuals). Supersedes the D50 draft with the
owner's D51 upgrades.
## The surface
A reserved PINNED WORK area at the TOP of the tasks pane's region
view — the pane's first content, above the scaffolding status line,
the work stream, and the queue. It accumulates pinned work native to
Rune (pin action on work blocks and goals).
- Header: the pin icon + `pinned (N) — outstanding work` label.
- Each pin renders ONE line: `◆ <plain-english summary>` — every pin
carries a plain-english summary of what is pinned (D51 upgrade).
- Click on the pinned area opens the OUTSTANDING PINNED WORK view
(D51 upgrade); click again closes it. In the open view each pin
names its id and the controls; clicking a pin (or `x`, which
releases the newest) releases it; `esc` backs out to the pane.
- No pins, no area: the tasks pane renders blank otherwise (D51
upgrade), with NO selectable rows anywhere (D51 — the D50 residual 3
removal already took the row walker out).
## Keyboard parity (the pin verb)
`ctrl+b` pins the top live work block (the newest running work
stream block) into the pinned area. Chosen for the control-key idiom
family — `ctrl+q` steers the queue, `ctrl+g` raises the gauges,
`ctrl+p` is the palette, `ctrl+a` the activity window — control keys
own the chrome verbs; plain letters always type (the D36 letter law).
`ctrl+b` is reachable while composing.
## Persistence
Pins persist across boots through the hot-state store pattern (the
task_state/history-depth pattern): one JSON document at
`$RUNE_HOME/tasks_pins.json` (beside `history_depth.json`), written
atomically (write-beside, rename over) on every pin change; absent or
corrupt records answer an empty pin set, never a guess. The layer in
`tui/tasks/pinned.go` stays pure view-model; the shell owns the
record.
## Tests
`tui/tasks/pinned_test.go` pins the area's placement (first content),
the no-pins-no-area law, the view open/release cycle with keyboard
parity, and clone-carry. `tui/cmd/rune-tui/pins_test.go` pins the
ctrl+b verb and the boot leg (a fresh model loads the pins and renders
the area).
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "rune"
version = "0.338.0"
version = "0.346.0"
description = "Rune — a stateful coding assistant with persistent memory, orchestration, and TUI"
requires-python = ">=3.10"
@@ -0,0 +1,82 @@
# D53 wave report — pane polish + the long-answer format break (Gitea #2568)
Admission: `scripts/admit_wave_d53_pane_polish.py` (classified
EXISTING_PROJECT_CHANGE, activated, contract
ADMIT-wave-d53-pane-polish minted — note: the classify core treats
"listing" as a reading verb; the objective says "showing").
## Per-item outcome
1. **LONG-ANSWER FORMAT BREAK** — reproduced: a 24-block mixed answer
at width 24 rendered a table edge row 30 columns wide. Root cause:
`tableLines` (tui/chatpane/blocks.go) built edge rows wider than
body rows by the separator columns, and the width budget ignored
them. Fixed: rows and edges share one geometry, honest
too-narrow drop (`total < cols*3` → nil). Summary-first asserted
at any length; append-never-reflows asserted on the long answer.
Commit 00d55b43 (rode to gitea/master with #2556's push).
2. **SCROLLBAR** — root cause found: `overlayDisplayCol`
(tui/lanes/render.go) wrote the thumb glyph into the left half
ONLY when a content rune sat at column x — a short styled row (the
common transcript shape) lost the glyph entirely, so no bar ever
rendered. Fixed: the glyph lands at the column even past the
line's end; thumb styled with a new `RoleScroll` token (vivid
#B87C99 / calm #C790AC, floors ≥4.5:1 asserted); chat AND tasks
rows render styled-or-empty; wheel step math pinned (one notch =
exactly its 3-line step, exact accumulation). Commit 3ea85744.
3. **QUIT WARNING** — first ctrl+c swaps the composer key hint for
the literal `press ctrl+c again to quit`; any other key or the 1s
timeout restores the live key hint. Found + fixed a D46 latent
defect: the arm-reset check was unreachable dead code inside the
KeyPressMsg case — the reset now has its own top-level switch
case. The second press quits, never shows the warning. Face chain
gained a `Hint()` read-back seam. Commit 7a2a499c.
4. **DYNAMIC REFLOW** — squash test frozen: a WindowSizeMsg through
the face chain re-wraps every block at 44 and 100 columns, no line
exceeds the pane, the scroll offset normalizes after the line-count
change. The render-path rewrap already held; the test pins it.
Commit 6aa271c2.
5. **INFO BAR** — `context 4.2k/32k (13%)` via `ctxPercent`
(clamped 0-100, still silent when the engine never reported).
Commit 704b4488.
6. **COMPOSER STYLING** — key hints render in the new `RoleHintPink`
token (vivid #C9739D / calm #D083AC); the activity pane's idle
state renders in the new `RoleStateIdle` yellow token (vivid
#D9B45B / calm #C9B06E) — the D52 frozen idle test superseded with
the ruling recorded. Commits 06302b24 + 71fd16a3.
7. **SUB-AGENTS** — the activity pane's "sub agents being processed
now" section renders ONLY while the syncWaves flight observation
reports agents in flight (one poller — the section can never
disagree with the rail marker or the wave tab); clickable
(mouse.activity.agents region) with ctrl+w keyboard parity,
opening the CENTERED agents window (the D52 picker pattern)
listing all in-flight agents; the selected row's thought process /
recent activity (the factory journal + observations detail
`waveRows` already derives) renders beneath; the window is modal
(esc/up/down, any other key closes). Plain-value `AgentView` seam
keeps lanes un-importable from tasks. Commit f869fa40.
## Verification
- `go test -race -short ./...` green (tui module; the documented
pre-existing boot-smoke PTY family aside did not fire this run).
- gofmt clean; `go vet ./...` clean.
- PTY proof: the deployed binary boots to CHAT/TASKS panes with the
live "enter send" hint (bubbletea coords 0-based-normalized per the
D52 finding; click tests drive the region rects directly).
- Rebased onto gitea/master d054ecb1 (#2556 streaming wave landed
mid-flight); items 1-3 rode up with that push; items 4-7 pushed as
48d85e16.
- Deploy: `scripts/build_paired.sh` attested
`48d85e16...+20261024T032845Z`; binary installed to
`~/.local/share/rune/bin/rune-tui`; `ensure_core` reports real and
healthy (identity 'Rune', 13 models).
## Concurrency note
A streaming wave (#2556) edited tui/ concurrently mid-wave. One
commit initially swept its WIP; it was split back out (the commit was
mine, unpushed) and item 3 was re-landed from HEAD blobs. The sweep
race guard caught nothing (staged-tree check passed) — the guard
checks contract coverage, not authorship. Consider a per-hunk
authorship check for shared surfaces.
@@ -0,0 +1,150 @@
"""Admission dispatch for wave D49 — capability everywhere (owner doctrine).
The owner's D49 ruling (verbatim, 2026-10-22): "ive asked rune if she
can alter her code she said she needs shell access, and the ability to
read and write i want that ability to be in every pane, but when
something gets to become work it gets promoted into the task pane. she
also needs tools to be able to connect to git projects. and make that
native." Doctrine: capability is INHERENT in every pane — the write
family (write_file, edit_file, run_command) and the new native git
family ride the wire in CHAT/PLAN/TASK alike; the consent card remains
the work-promotion mechanism (organization, not permission); the real
safety layer is the scaffolding authority + contract gates at mutation
doors. Build: (1) ToolsetFor exposes the full suite in every mode; (2)
ExecuteGatedTool's write-refusal-by-mode retires, the dispatch-gate
seam and the genuinely-unavailable refusal stay; (3) the wire tool
descriptions state the write availability (the model asked for shell
access because the wire showed read-only — fix the wire); (4) a native
git family (git_status, git_diff, git_log, git_branch, git_commit,
git_add) shelling out to git in the repo root (DefaultRepoRoot seam),
porcelain-parsed honestly, typed JSON results, landing-law banned-path
refusals on git_add/git_commit; (5) the mode/toolset tests re-pinned
to CHAT=full-suite, red-first throughout.
approval_ref 'user:D49 owner ruling 2026-10-22 (capability in every
pane; native git tools)'.
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from rune import construction_activation as cact # noqa: E402
from rune import construction_admission as cadm # noqa: E402
from rune import construction_mode as cmode # noqa: E402
from rune import scaffold as sc # noqa: E402
ROOT = os.path.join(os.path.dirname(__file__), "..")
OBJECTIVE = (
"Build the owner's D49 doctrine — capability in every pane: (1) "
"make ToolsetFor expose the full tool suite (read family + write "
"family) in every mode so CHAT and PLAN carry write_file, "
"edit_file and run_command like TASK does; (2) retire "
"ExecuteGatedTool's write-refusal-by-mode so writes execute in any "
"mode while keeping the dispatch-gate seam and the typed refusal "
"for genuinely-unavailable tools; (3) state the write availability "
"in the tool descriptions the wire carries so the model knows its "
"shell and file abilities without asking; (4) add a native git "
"tool family — git_status, git_diff, git_log, git_branch, "
"git_commit, git_add — implemented by shelling out to git in the "
"repo root through the DefaultRepoRoot seam, parsing porcelain "
"output honestly, returning typed JSON, and refusing banned "
"landing-law paths on git_add and git_commit; (5) re-pin the "
"mode/toolset tests to the new doctrine (CHAT carries the full "
"suite) red-first and keep the runecore package -race green"
)
AFFECTED = [
"src/go/rune-core/",
"scripts/admit_wave_d49_capability_everywhere.py",
]
def main() -> int:
store = os.path.join(ROOT, "store", "scaffold")
scaffold = sc.Scaffold(store, "rune")
md = cmode.classify_mode(
OBJECTIVE,
target_project="rune",
project_exists=True,
affected_surface=AFFECTED,
)
print("classified mode:", md.mode)
need = {
"need_id": "need-wave-d49-capability-everywhere",
"objective": OBJECTIVE,
"origin": "worker",
"project": "rune",
"task_ref": "wave-d49-capability-everywhere",
"affected_surface": AFFECTED,
"evidence_refs": [
"owner ruling D49 2026-10-22 (verbatim in session notes): "
"capability in every pane, work promotes to the task pane, "
"native git tools",
"src/go/rune-core/internal/runecore/tools.go (the "
"mode-gated toolset this wave supersedes)",
"commit b35078bb (task channel inherits TASK semantics)",
"scripts/check_landing.py rule 4 SWEEP_RACE_BANNED_PATHS "
"(the landing-law banned-path set git_add/git_commit "
"refuse)",
"scripts/admit_wave_presentation.py (the admission "
"pattern)",
],
}
act = cact.activate(md, need=need, scaffold=scaffold,
builder="wave-d49-capability-everywhere")
print("activated:", act.get("activated"), act.get("mode"))
env = cadm.admit(
act,
objective=OBJECTIVE,
builder="wave-d49-capability-everywhere",
affected_surface=AFFECTED,
evidence_refs=need["evidence_refs"],
origin="worker",
task_ref="wave-d49-capability-everywhere",
expected_new_entities=[
"src/go/rune-core/internal/runecore (ToolsetFor full "
"suite in every mode; ExecuteGatedTool write-refusal "
"retired with the dispatch-gate seam kept; wire tool "
"descriptions state write availability)",
"src/go/rune-core/internal/runecore/gittools.go (the "
"native git family: git_status, git_diff, git_log, "
"git_branch, git_commit, git_add — stdlib git subprocess "
"in the repo root, porcelain parsed honestly, typed JSON "
"results, landing-law banned-path refusals)",
"tests: red-first re-pins of the mode/toolset suites to "
"CHAT=full-suite + per-tool git fixture tests, -race "
"green",
],
expected_runtime_effects=[
"every pane's model sees and can execute the write family "
"and the git family; work-shaped activity still promotes "
"to the tasks pane via the consent card (organization, "
"not permission)",
"the model can connect to git projects natively: status, "
"diff, log, branch, add and commit as first-class typed "
"tool results",
"the landing-law banned paths never enter a git tool "
"staged tree or commit",
],
verification_obligations=[
"go test -race green for the whole runecore package "
"(including the cloud wires); go vet + gofmt clean",
"red-first evidence: the re-pinned suites fail against "
"the pre-wave toolset before the implementation lands",
"commit(s) name this contract; pre-commit staged gate "
"green via this open contract",
],
approval_ref="user:D49 owner ruling 2026-10-22 (capability in "
"every pane; native git tools)",
contract_id="ADMIT-wave-d49-capability-everywhere",
project_store=store,
project_id="rune",
)
print(json.dumps(env, indent=2, default=str)[:2400])
if __name__ == "__main__":
raise SystemExit(main())
+110
View File
@@ -0,0 +1,110 @@
"""Admission dispatch for wave D50-residuals (land the four items the
presentation wave left out, Gitea #2560).
classify + activate + admit (the headless scaffolding entry law): the
D50-residuals build on the existing rune project, user-dispatch approved.
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from rune import construction_activation as cact # noqa: E402
from rune import construction_admission as cadm # noqa: E402
from rune import construction_mode as cmode # noqa: E402
from rune import scaffold as sc # noqa: E402
ROOT = os.path.join(os.path.dirname(__file__), "..")
OBJECTIVE = (
"land the D50-residual wave (Gitea #2560, D51 round folded): (1) "
"msg_id linkage — tasks-pane work-stream records carry the "
"spawning chat msg_id (msg ids exist core-side per newMsgID), "
"rendered as a dim ↩ reference in the work block; (2) phantom-row "
"filter — filter stale queue rows by the freshness law (the "
"30-min occupancy pattern) in the tasks pane, read-side in the "
"TUI; (3) enter-steal removal — remove the tasks-pane "
"row-selection walker entirely; enter on tasks-pane focus starts "
"direct task execution (D44 doctrine); (4) pinned work — reserved "
"pinned area at the top of the tasks pane with a pin icon and "
"'pinned' label, each pin carrying a plain-english summary, click "
"opens the outstanding pinned work view, pins persist across "
"boots via the hot-state store pattern; the tasks pane renders "
"blank otherwise with no selectable rows; (5) dynamic reflow — "
"bubbles/boxes re-wrap on window resize (nothing clips)"
)
AFFECTED = [
"tui/",
"src/go/rune-core/internal/runecore/record.go",
"src/go/rune-core/internal/runecore/store.go",
"scripts/admit_wave_d50_residuals.py",
]
def main() -> int:
store = os.path.join(ROOT, "store", "scaffold")
scaffold = sc.Scaffold(store, "rune")
md = cmode.classify_mode(
OBJECTIVE,
target_project="rune",
project_exists=True,
affected_surface=AFFECTED,
)
print("classified mode:", md.mode)
need = {
"need_id": "need-wave-d50-residuals",
"objective": OBJECTIVE,
"origin": "worker",
"project": "rune",
"task_ref": "wave-d50-residuals",
"affected_surface": AFFECTED,
"evidence_refs": [
"store/scaffold/docs/ADMIT-wave-d46-live-defects.md",
"tui/lanes/activity.go",
"tui/chatpane/blocks.go",
"tui/tasks/render.go",
"tui/cmd/rune-tui/selection.go",
"scripts/admit_wave_rebase_tui_wiring.py",
],
}
act = cact.activate(md, need=need, scaffold=scaffold,
builder="wave-d50-residuals")
print("activated:", act.get("activated"), act.get("mode"))
env = cadm.admit(
act,
objective=OBJECTIVE,
builder="wave-d50-residuals",
affected_surface=AFFECTED,
evidence_refs=need["evidence_refs"],
origin="worker",
task_ref="wave-d50-residuals",
expected_new_entities=[
"the D50-residuals wave report (reports/)",
"the pinned-work hot-state store + tests",
"red-first enter-dispatch and phantom-filter tests",
],
expected_runtime_effects=[
"tasks-pane work blocks show a dim ↩ msg_id reference; "
"phantom queue rows are filtered; tasks-focus enter "
"dispatches directly; pinned work persists across boots",
],
verification_obligations=[
"go test -race ./... in tui/ green",
"build/vet/gofmt clean",
"PTY proofs for enter-dispatch and pin persistence",
],
approval_ref="user:D50-residual directive 2026-10-22",
contract_id="ADMIT-wave-d50-residuals",
project_store=store,
project_id="rune",
)
print(json.dumps(env, indent=2, default=str)[:2400])
return 0 if env.get("success") else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,112 @@
"""Admission dispatch for wave D52 (Gitea #2566 + #2567 — the
seed-substrate leak, the wave animation, the dead + click, and the
model-token picker window).
classify + activate + admit (the headless scaffolding entry law): the
D52 wave builds on the existing rune project, user-dispatch approved.
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from rune import construction_activation as cact # noqa: E402
from rune import construction_admission as cadm # noqa: E402
from rune import construction_mode as cmode # noqa: E402
from rune import scaffold as sc # noqa: E402
ROOT = os.path.join(os.path.dirname(__file__), "..")
OBJECTIVE = (
"land the D52 wave (Gitea #2566 + #2567): (1) seed-substrate leak "
"— production TUI faces boot EMPTY: tasks.New/NewPromote start "
"with no lab tree/suggestion fixtures and an honest idle render "
"('no outstanding work'); SetLiveTree/Offer fill them when real "
"data arrives; the seeds stay confined to the lab harness; (2) "
"wave animation — the activity word animates per-letter as a WAVE "
"(rise, settle, sequential to the last letter, then reverse, "
"looping), smooth multi-frame rise, vibrant colours + rainbow "
"sweep on the moving letter, per-activity palette kept; (3) the "
"+ affordance click fix — the styled + padded by byte length so "
"the glyph rendered left of its declared region; region and "
"render share one width constant, plus a debuggable region→glyph "
"assertion; (4) the model-token click opens the centered "
"searchable picker window split CLOUD/LOCAL, no crossfire with "
"the activity window; ctrl+m parity kept"
)
AFFECTED = [
"tui/",
"scripts/admit_wave_d52_seedleak_animation.py",
]
def main() -> int:
store = os.path.join(ROOT, "store", "scaffold")
scaffold = sc.Scaffold(store, "rune")
md = cmode.classify_mode(
OBJECTIVE,
target_project="rune",
project_exists=True,
affected_surface=AFFECTED,
)
print("classified mode:", md.mode)
need = {
"need_id": "need-wave-d52-seedleak-animation",
"objective": OBJECTIVE,
"origin": "worker",
"project": "rune",
"task_ref": "wave-d52-seedleak-animation",
"affected_surface": AFFECTED,
"evidence_refs": [
"tui/tasks/tasks.go",
"tui/tasks/face.go",
"tui/promote/promote.go",
"tui/lanes/activity.go",
"tui/lanes/render.go",
"tui/cmd/rune-tui/main.go",
"scripts/admit_wave_d50_residuals.py",
],
}
act = cact.activate(md, need=need, scaffold=scaffold,
builder="wave-d52-seedleak-animation")
print("activated:", act.get("activated"), act.get("mode"))
env = cadm.admit(
act,
objective=OBJECTIVE,
builder="wave-d52-seedleak-animation",
affected_surface=AFFECTED,
evidence_refs=need["evidence_refs"],
origin="worker",
task_ref="wave-d52-seedleak-animation",
expected_new_entities=[
"the D52 wave report (reports/)",
"the red-first seed-leak/wave/click-map/picker-window tests",
"the honest-empty boot chain (semantic.Empty → lanes/rail/"
"promote/tasks empty constructors)",
],
expected_runtime_effects=[
"production faces boot empty ('no outstanding work'), the "
"activity word waves per-letter with reverse loop and "
"vibrant mover, the + click lands on the rendered glyph, "
"the model-token click opens the CLOUD/LOCAL picker window",
],
verification_obligations=[
"go test -race ./... in tui/ green (documented pre-existing "
"boot-smoke PTY family aside)",
"build/vet/gofmt clean",
"PTY proofs: empty boot, + glyph click opens the window, "
"model-token click opens the picker (no crossfire)",
],
approval_ref="user:D52 directive 2026-10-23 (Gitea #2566, #2567)",
contract_id="ADMIT-wave-d52-seedleak-animation",
project_store=store,
project_id="rune",
)
print(json.dumps(env, indent=2, default=str)[:2400])
return 0 if env.get("success") else 1
if __name__ == "__main__":
sys.exit(main())
+114
View File
@@ -0,0 +1,114 @@
"""Admission dispatch for wave D54 neurodivergent-presentation.
classify + activate + admit (the headless scaffolding entry law,
scripts/admit_wave_rebase_tui_wiring.py pattern): the D54
neurodivergent-presentation build on the existing rune project,
user-dispatch approved.
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from rune import construction_activation as cact # noqa: E402
from rune import construction_admission as cadm # noqa: E402
from rune import construction_mode as cmode # noqa: E402
from rune import scaffold as sc # noqa: E402
ROOT = os.path.join(os.path.dirname(__file__), "..")
OBJECTIVE = (
"implement the neurodivergent-presentation mechanical map "
"(research/factory-nuerodivergent-presentation/synthesis.md, "
"Gitea #2571): the motion law (motionAllowed gate on the activity "
"wave, never-animate law on the chat pane, one-animating-region-"
"per-frame in the face chain), the colour-token law (role enum in "
"tui/themes, one hex per role, contrast >=4.5:1 unit test, Faint-"
"on-dim fix), the spacing law (one blank line between blocks, "
"blocks never merge), summary-first rendering (one-line plain-"
"english summary box for long answers), the calm-mode sensory "
"setting (vivid|calm, default vivid — calm mutes chroma at the "
"token layer and sets motionAllowed false), and the literal-"
"language audit of face strings"
)
AFFECTED = [
"tui/lanes/activity.go",
"tui/chatpane/blocks.go",
"tui/chatpane/chatpane.go",
"tui/chatpane/scroll_test.go",
"tui/settings/config.go",
"tui/settings/settings.go",
"tui/themes/theme.go",
"tui/themes/roles.go",
"tui/cmd/rune-tui/main.go",
"scripts/admit_wave_d54_nd_presentation.py",
]
def main() -> int:
store = os.path.join(ROOT, "store", "scaffold")
scaffold = sc.Scaffold(store, "rune")
md = cmode.classify_mode(
OBJECTIVE,
target_project="rune",
project_exists=True,
affected_surface=AFFECTED,
)
print("classified mode:", md.mode)
need = {
"need_id": "need-wave-d54-nd-presentation",
"objective": OBJECTIVE,
"origin": "worker",
"project": "rune",
"task_ref": "wave-d54-nd-presentation",
"affected_surface": AFFECTED,
"evidence_refs": [
"research/factory-nuerodivergent-presentation/synthesis.md",
"tui/lanes/activity.go",
"tui/chatpane/blocks.go",
"tui/chatpane/chatpane.go",
"tui/settings/config.go",
"tui/themes/theme.go",
],
}
act = cact.activate(md, need=need, scaffold=scaffold,
builder="wave-d54-nd-presentation")
print("activated:", act.get("activated"), act.get("mode"))
env = cadm.admit(
act,
objective=OBJECTIVE,
builder="wave-d54-nd-presentation",
affected_surface=AFFECTED,
evidence_refs=need["evidence_refs"],
origin="worker",
task_ref="wave-d54-nd-presentation",
expected_new_entities=[
"the colour-role enum in tui/themes (one hex per role)",
"the sensory (vivid|calm) setting key in the settings store",
"the motion-allowed gate on the activity wave",
],
expected_runtime_effects=[
"calm mode mutes chroma tokens and stops the activity wave; "
"long answers open with a one-line plain-english summary; "
"face strings read literally",
],
verification_obligations=[
"go test -race -short ./... in tui/ green (documented "
"pre-existing PTY boot family aside)",
"go vet + gofmt clean in tui/",
"contrast unit test >=4.5:1 body text, dim tier floor "
"verified (Faint-on-dim breach fixed)",
],
approval_ref="user:D54 dispatch 2026-10-23 (Gitea #2571)",
contract_id="ADMIT-wave-d54-nd-presentation",
project_store=store,
project_id="rune",
)
print(json.dumps(env, indent=2, default=str)[:2400])
return 0 if env.get("success") else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,139 @@
"""Admission dispatch for wave retire-reconcile-tools (Gitea #2574
rebalanced overnight queue, wave A; retirement-map blocker closure from
wave 181's model-routing-integrated.json blocks_full_switchover note).
classify + activate + admit (the headless scaffolding entry law,
scripts/admit_wave_memory_continuity_go.py pattern): port the
capability-floor drift reconciliation (reconcile_tools_support,
src/rune/model_routing.py — the R2 I-R2-022 truth_drift seam whose
missing Go surface blocked local_tool_runtime.py from dropping its
Python import) into the existing Go engine src/go/model_routing (new
ReconcileToolsSupport + `eval reconcile` surface), keep the Python body
as oracle/escape hatch (RUNE_MODEL_ROUTING_ENGINE=python, NO Python
deletion — PORT → INTEGRATE → REMOVE), switch the function to a
fail-loud delegation facade (typed RuntimeError when the engine binary
is missing), and pin the port with a red-first parity battery
(tests/test_reconcile_tools_support_port.py) against the Python oracle.
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from rune import construction_activation as cact # noqa: E402
from rune import construction_admission as cadm # noqa: E402
from rune import construction_mode as cmode # noqa: E402
from rune import scaffold as sc # noqa: E402
ROOT = os.path.join(os.path.dirname(__file__), "..")
OBJECTIVE = (
"Close the wave-181 full-switchover blocker: Go port of the "
"capability-floor drift reconciliation (reconcile_tools_support, "
"R2 I-R2-022 truth_drift) absorbed into the existing "
"src/go/model_routing engine (ReconcileToolsSupport + `eval "
"reconcile`); a provider tool-support claim contradicting "
"CAPABILITY_FLOOR surfaces as unverified drift, never silently "
"flipping eligibility; non-floor models are never invented a floor "
"verdict. Python body stays as oracle/escape hatch "
"(RUNE_MODEL_ROUTING_ENGINE=python) — no Python deletion; the "
"function becomes a fail-loud delegation facade (typed RuntimeError "
"when the engine binary is missing, RUNE_MODEL_ROUTING_BIN "
"override); red-first parity battery "
"tests/test_reconcile_tools_support_port.py pins Go to the Python "
"oracle byte-for-byte; local_tool_runtime consumer keeps its seam "
"but now resolves through Go at runtime"
)
AFFECTED = [
"src/go/model_routing/",
"src/rune/model_routing.py",
"src/rune/local_tool_runtime.py",
"tests/test_reconcile_tools_support_port.py",
"verification/integrated/model-routing-integrated.json",
"verification/integrated/reconcile-tools-integrated.json",
"scripts/admit_wave_retire_reconcile_tools.py",
]
def main() -> int:
store = os.path.join(ROOT, "store", "scaffold")
scaffold = sc.Scaffold(store, "rune")
md = cmode.classify_mode(
OBJECTIVE,
target_project="rune",
project_exists=True,
affected_surface=AFFECTED,
)
print("classified mode:", md.mode)
need = {
"need_id": "need-wave-retire-reconcile-tools",
"objective": OBJECTIVE,
"origin": "worker",
"project": "rune",
"task_ref": "wave-retire-reconcile-tools",
"affected_surface": AFFECTED,
"evidence_refs": [
"verification/integrated/model-routing-integrated.json",
"src/rune/model_routing.py",
"src/rune/local_tool_runtime.py",
"src/go/model_routing/eval.go",
"tests/test_p1_06_model_routing.py",
],
}
act = cact.activate(md, need=need, scaffold=scaffold,
builder="wave-retire-reconcile-tools")
print("activated:", act.get("activated"), act.get("mode"))
env = cadm.admit(
act,
objective=OBJECTIVE,
builder="wave-retire-reconcile-tools",
affected_surface=AFFECTED,
evidence_refs=need["evidence_refs"],
origin="worker",
task_ref="wave-retire-reconcile-tools",
expected_new_entities=[
"src/go/model_routing/reconcile.go (Go port of "
"reconcile_tools_support over CapabilityFloor)",
"src/go/model_routing/reconcile_test.go (red-first Go parity "
"battery mirrored from the Python oracle corpus)",
"src/go/model_routing `eval reconcile` surface (eval.go)",
"tests/test_reconcile_tools_support_port.py (Python-side "
"parity battery: go delegation == python oracle "
"byte-for-byte; fail-loud binary-missing refusal; "
"local_tool_runtime consumer receipt)",
"verification/integrated/reconcile-tools-integrated.json "
"(integrated receipt)",
],
expected_runtime_effects=[
"a provider supports_tools claim contradicting "
"CAPABILITY_FLOOR surfaces as an unverified drift marker "
"and never silently flips eligibility",
"reconcile_tools_support resolves through the Go engine by "
"default; RUNE_MODEL_ROUTING_ENGINE=python pins the oracle "
"body; a missing engine binary raises a typed RuntimeError "
"(no silent Python fallback)",
"local_tool_runtime's capability-drift seam now executes Go "
"at runtime while the Python module stays as oracle",
],
verification_obligations=[
"go test -race -short ./... in src/go/model_routing green",
"pytest tests/test_reconcile_tools_support_port.py "
"tests/test_p1_06_model_routing.py "
"tests/test_local_tool_runtime.py green",
],
approval_ref="user:gitea #2574 rebalanced overnight queue, wave A "
"(majority-rebase) 2026-10-24",
contract_id="ADMIT-wave-retire-reconcile-tools",
project_store=store,
project_id="rune",
)
print(json.dumps(env, indent=2, default=str)[:2400])
return 0 if env.get("success") else 1
if __name__ == "__main__":
sys.exit(main())
+132
View File
@@ -0,0 +1,132 @@
"""Admission dispatch for wave retire-tool-executor (Gitea #2575
rebalanced overnight queue, wave B): wire local_tool_runtime.py to the
Go tool-executor engine.
classify + activate + admit (headless scaffolding entry law,
scripts/admit_wave_retire_reconcile_tools.py pattern): the Go executor
exists (src/go/tool-executor, full CLI surface read_file/list_directory/
glob_files/grep_files/run_command, JSON envelope {success,result,error})
but src/rune/local_tool_runtime.py never delegates to it. This wave wires
the fail-loud delegation facade (LocalToolRuntime's executor resolves the
four READ-ONLY ops through the Go binary by default; run_command stays
behind the Python approval door — mutating, never silently executed),
keeps the Python bodies as oracle/escape hatch
(RUNE_TOOL_EXECUTOR_ENGINE=python, NO Python deletion — PORT → INTEGRATE
→ REMOVE), and pins the port with a red-first parity battery
(tests/test_tool_executor_delegation.py) against the Python oracle.
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from rune import construction_activation as cact # noqa: E402
from rune import construction_admission as cadm # noqa: E402
from rune import construction_mode as cmode # noqa: E402
from rune import scaffold as sc # noqa: E402
ROOT = os.path.join(os.path.dirname(__file__), "..")
OBJECTIVE = (
"Close the wave-181 full-switchover blocker: Go port of the local "
"tool execution — wire local_tool_runtime.py to the existing "
"src/go/tool-executor engine. LocalToolRuntime executor becomes "
"a fail-loud delegation facade: the four read-only file ops "
"(read_file, directory enumeration, glob, grep) resolve through "
"the Go binary by default, JSON envelope preserved exactly; "
"run_command stays behind the approval door (mutating, never "
"silently executed). Python bodies stay as oracle escape hatch "
"(RUNE_TOOL_EXECUTOR_ENGINE=python, RUNE_TOOL_EXECUTOR_BIN "
"override, typed RuntimeError when the binary is missing — no "
"silent fallback, no Python deletion; PORT → INTEGRATE → REMOVE). "
"Red-first parity battery tests/test_tool_executor_delegation.py "
"pins Go to the Python oracle envelope-for-envelope"
)
AFFECTED = [
"src/go/tool-executor/",
"src/rune/local_tool_runtime.py",
"tests/test_tool_executor_delegation.py",
"tests/test_local_tool_runtime.py",
"tests/test_p0_05_native_tools.py",
"tests/test_mode_dispatch_gate.py",
"verification/integrated/tool-executor-integrated.json",
"scripts/admit_wave_retire_tool_executor.py",
]
def main() -> int:
store = os.path.join(ROOT, "store", "scaffold")
scaffold = sc.Scaffold(store, "rune")
md = cmode.classify_mode(
OBJECTIVE,
target_project="rune",
project_exists=True,
affected_surface=AFFECTED,
)
print("classified mode:", md.mode)
need = {
"need_id": "need-wave-retire-tool-executor",
"objective": OBJECTIVE,
"origin": "worker",
"project": "rune",
"task_ref": "wave-retire-tool-executor",
"affected_surface": AFFECTED,
"evidence_refs": [
"src/go/tool-executor/internal/toolexec/cli.go",
"src/rune/local_tool_runtime.py",
"src/rune/tool_executor.py",
"tests/test_tool_executor_delegation.py",
"verification/integrated/reconcile-tools-integrated.json",
],
}
act = cact.activate(md, need=need, scaffold=scaffold,
builder="wave-retire-tool-executor")
print("activated:", act.get("activated"), act.get("mode"))
env = cadm.admit(
act,
objective=OBJECTIVE,
builder="wave-retire-tool-executor",
affected_surface=AFFECTED,
evidence_refs=need["evidence_refs"],
origin="worker",
task_ref="wave-retire-tool-executor",
expected_new_entities=[
"tests/test_tool_executor_delegation.py (red-first parity "
"battery: Go delegation == Python oracle envelope-for-"
"envelope; fail-loud binary-missing refusal; run_command "
"never delegates; default-is-go pin)",
"verification/integrated/tool-executor-integrated.json "
"(integrated receipt, soak clock before any Python deletion)",
],
expected_runtime_effects=[
"LocalToolRuntime's executor resolves read_file, "
"list_directory, glob_files and grep_files through the Go "
"tool-executor binary by default; envelopes preserved "
"exactly",
"RUNE_TOOL_EXECUTOR_ENGINE=python pins the oracle path; "
"a missing engine binary raises a typed RuntimeError (no "
"silent Python fallback)",
"run_command dispatches through the Python authority path "
"only (approval door preserved, no Go bypass)",
],
verification_obligations=[
"go test -race -short ./... in src/go/tool-executor green",
"pytest tests/test_tool_executor_delegation.py "
"tests/test_local_tool_runtime.py tests/test_p0_05_native_tools.py "
"tests/test_mode_dispatch_gate.py tests/test_context_broker.py "
"green",
],
approval_ref="user:gitea #2575 rebalanced overnight queue, wave B "
"2026-10-24",
contract_id="ADMIT-wave-retire-tool-executor",
project_store=store,
project_id="rune",
)
print(json.dumps(env, indent=2, default=str)[:2400])
return 0 if env.get("success") else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,127 @@
"""Admission dispatch for the streaming-deadline ROOT-CAUSE wave (Gitea
#2556 — the owner verdict: 'context deadline needs a proper solution we
have just been patching around it rather than making the root cause
better for local models').
classify + activate + admit (the headless scaffolding entry law): the
wave replaces the flat whole-turn budgets (330s -> 480s -> 6x480s, the
D46/D47 lineage) with STREAMING through the whole chain — Ollama wire,
cloud wires, core /v1/chat/completions, entry proxy, TUI render — so a
turn never sits inside one blocking HTTP call; the deadline collapses
to an INTER-CHUNK IDLE TIMEOUT (no bytes = dead) with a generous
overall backstop. Budget constants retire to non-streaming fallback
paths only.
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from rune import construction_activation as cact # noqa: E402
from rune import construction_admission as cadm # noqa: E402
from rune import construction_mode as cmode # noqa: E402
from rune import scaffold as sc # noqa: E402
ROOT = os.path.join(os.path.dirname(__file__), "..")
OBJECTIVE = (
"land the streaming root-cause wave (Gitea #2556, owner verdict "
"2026-10-22): stream through the WHOLE turn chain so a local 27B "
"thinking turn never sits inside one blocking HTTP call — (1) the "
"Ollama wire runs stream=true /api/chat with NDJSON chunks parsed "
"incrementally (content/thinking/tool_calls; the final message "
"object carries the assembled reply) and the engine's bounded tool "
"loop runs over streamed replies; (2) the cloud wires gain "
"streaming parity ([OI]-compat SSE, Anthropic SSE events, Gemini "
"streamGenerateContent); (3) the core /v1/chat/completions relays "
"the streamed turn as NDJSON chunks for stream=true callers while "
"non-streaming callers get the assembled answer as before; (4) the "
"entry proxy passes the stream through and the TUI renders text as "
"it arrives; (5) on streamed paths the whole-turn budgets retire — "
"replaced by an inter-chunk idle timeout (default 120s, "
"RUNE_STREAM_IDLE_TIMEOUT configurable) plus a 30-minute overall "
"backstop; the budget constants remain only for non-streaming "
"fallback paths"
)
AFFECTED = [
"src/go/rune-core/internal/runecore/",
"src/go/rune-entry/",
"tui/runtime/lib/",
"tui/cmd/rune-tui/",
"scripts/admit_wave_streaming_deadline_root.py",
]
def main() -> int:
store = os.path.join(ROOT, "store", "scaffold")
scaffold = sc.Scaffold(store, "rune")
md = cmode.classify_mode(
OBJECTIVE,
target_project="rune",
project_exists=True,
affected_surface=AFFECTED,
)
print("classified mode:", md.mode)
need = {
"need_id": "need-wave-streaming-deadline-root",
"objective": OBJECTIVE,
"origin": "worker",
"project": "rune",
"task_ref": "wave-streaming-deadline-root",
"affected_surface": AFFECTED,
"evidence_refs": [
"src/go/rune-core/internal/runecore/ollama.go",
"src/go/rune-core/internal/runecore/cloud.go",
"src/go/rune-core/internal/runecore/server.go",
"src/go/rune-entry/internal/entry/serve.go",
"tui/runtime/lib/complete.go",
"tui/laneconc/laneconc.go",
"tui/cmd/rune-tui/main.go",
"scripts/admit_wave_terminal_budgets.py",
],
}
act = cact.activate(md, need=need, scaffold=scaffold,
builder="wave-streaming-deadline-root")
print("activated:", act.get("activated"), act.get("mode"))
env = cadm.admit(
act,
objective=OBJECTIVE,
builder="wave-streaming-deadline-root",
affected_surface=AFFECTED,
evidence_refs=need["evidence_refs"],
origin="worker",
task_ref="wave-streaming-deadline-root",
expected_new_entities=[
"the streaming root-cause wave report (reports/)",
"the red-first streaming chain tests (ollama NDJSON, cloud "
"SSE, core stream relay, entry pass-through, TUI "
"render-as-it-arrives, idle-timeout simulation)",
"the inter-chunk idle timeout + 30-minute backstop on "
"streamed paths (budgets retire to fallback)",
],
expected_runtime_effects=[
"a local 27B thinking turn streams tokens continuously "
"through core, entry proxy and TUI with no flat deadline; "
"a genuinely dead model trips the idle timeout",
],
verification_obligations=[
"-short race suites green on touched Go modules (the "
"documented PTY family aside)",
"full-chain simulation: a fake slow model emitting chunks "
"across a long window completes the turn with no deadline "
"fire; a silent model trips the idle timeout",
"vet/gofmt clean",
],
approval_ref="user:streaming root-cause wave directive 2026-10-23 (Gitea #2556)",
contract_id="ADMIT-wave-streaming-deadline-root",
project_store=store,
project_id="rune",
)
print(json.dumps(env, indent=2, default=str)[:2400])
return 0 if env.get("success") else 1
if __name__ == "__main__":
sys.exit(main())
+19
View File
@@ -163,6 +163,25 @@ func runEvalCmd(args []string) int {
})
}
printJSON(out)
case "reconcile":
out := []any{}
for _, op := range ops {
if op["op"] != "reconcile" {
continue
}
model, _ := op["model"].(string)
reported := false
if v, ok := op["provider_reported"].(bool); ok {
reported = v
}
eff, drift := ReconcileToolsSupport(model, reported)
row := map[string]any{"effective": eff, "drift": nil}
if drift != "" {
row["drift"] = drift
}
out = append(out, row)
}
printJSON(out)
case "profiles":
printJSON(registryToProbeJSON(LoadProfiles(OSFileSystem{}, profilesPath)))
default:
+39
View File
@@ -0,0 +1,39 @@
package main
// reconcile.go — the capability-floor drift reconciliation (R2 I-R2-022
// truth_drift): a provider tool-support claim contradicting
// CapabilityFloor must surface as unverified drift and never silently
// flip eligibility. Ported from the Python oracle
// (src/rune/model_routing.py: reconcile_tools_support), which stays the
// parity oracle.
// ReconcileToolsSupport reconciles a provider-reported tool-support
// claim against CapabilityFloor. Returns (effective_supports_tools,
// drift_marker); the marker is "" when the claims agree or the model is
// not floor-listed (nothing to reconcile — no floor verdict is
// invented).
func ReconcileToolsSupport(model string, providerReported bool) (bool, string) {
for _, floor := range CapabilityFloor {
if floor.Model == model {
if floor.SupportsTools != providerReported {
return providerReported,
"unverified: provider reports supports_tools=" +
boolWord(providerReported) + " for model '" + model +
"' contradicting CAPABILITY_FLOOR supports_tools=" +
boolWord(floor.SupportsTools) + "; floor still gates " +
"router eligibility for unmeasured candidates"
}
return floor.SupportsTools, ""
}
}
return providerReported, ""
}
func boolWord(b bool) string {
// The Python oracle f-strings {bool(...)} → "True"/"False"; the drift
// marker must be byte-identical with the oracle text.
if b {
return "True"
}
return "False"
}
+55
View File
@@ -0,0 +1,55 @@
package main
// Red-first parity battery for ReconcileToolsSupport — mirrored from the
// Python oracle battery (tests/test_p1_06_model_routing.py ::
// test_reconcile_tools_support_surfaces_contradiction_as_unverified).
// The Python module stays the oracle; this pins the Go port to the same
// corpus (contradiction surfaces as unverified drift, agreement is clean,
// non-floor models are never invented a verdict).
import "testing"
type reconcileCase struct {
name string
model string
reported bool
wantEff bool
wantDrift string // "" = nil drift
}
var reconcileCases = []reconcileCase{
{
name: "floor T1 contradiction: provider claims tools, floor says no",
model: "qwen2.5:3b",
reported: true,
wantEff: true,
wantDrift: "unverified: provider reports supports_tools=True for model " +
"'qwen2.5:3b' contradicting CAPABILITY_FLOOR supports_tools=False; " +
"floor still gates router eligibility for unmeasured candidates",
},
{
name: "floor T4 contradiction: provider denies tools, floor says yes",
model: "mimo-v2.5-pro",
reported: false,
wantEff: false,
wantDrift: "unverified: provider reports supports_tools=False for model " +
"'mimo-v2.5-pro' contradicting CAPABILITY_FLOOR supports_tools=True; " +
"floor still gates router eligibility for unmeasured candidates",
},
{name: "floor T1 agreement (no tools)", model: "qwen2.5:3b", reported: false, wantEff: false, wantDrift: ""},
{name: "floor T4 agreement (tools)", model: "mimo-v2.5-pro", reported: true, wantEff: true, wantDrift: ""},
{name: "non-floor model: report passes through", model: "qwen3:8b", reported: true, wantEff: true, wantDrift: ""},
{name: "non-floor model denial passes through", model: "qwen3:8b", reported: false, wantEff: false, wantDrift: ""},
}
func TestReconcileToolsSupportParity(t *testing.T) {
for _, tc := range reconcileCases {
eff, drift := ReconcileToolsSupport(tc.model, tc.reported)
if eff != tc.wantEff {
t.Errorf("%s: effective = %v, want %v", tc.name, eff, tc.wantEff)
}
if drift != tc.wantDrift {
t.Errorf("%s: drift = %q, want %q", tc.name, drift, tc.wantDrift)
}
}
}
@@ -93,6 +93,7 @@ type anthropicChatRequest struct {
Messages []anthropicMessage `json:"messages"`
System []anthropicSystemBlock `json:"system,omitempty"`
Tools []anthropicToolDef `json:"tools,omitempty"`
Stream bool `json:"stream,omitempty"`
}
type anthropicChatResponse struct {
@@ -0,0 +1,488 @@
package runecore
// cloud_stream.go — the cloud wires' streaming parity (Gitea #2556):
// stream=true where the provider supports it — [OI]-compat SSE (data:
// lines, [DONE] sentinel), anthropic /v1/messages SSE events, gemini
// streamGenerateContent (alt=sse). Every implementation surfaces text
// and thinking bytes through the sink AS THEY ARRIVE, enforces the
// INTER-CHUNK IDLE TIMEOUT (no bytes = ErrStreamIdle; the 30-minute
// backstop is the runaway guard) and returns the assembled ChatReply
// (content + thinking + tool_calls) exactly like its non-streaming
// leg. Providers without streaming keep the non-streaming path + the
// existing budget (the constants' remaining tenant).
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
// streamDo issues the streamed POST over a client with NO whole-call
// Timeout (the idle watch governs), answering the body reader + the
// typed provider errors.
func streamDo(ctx context.Context, provider, url string, headers map[string]string, body []byte) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := (&http.Client{}).Do(req)
if err != nil {
return nil, fmt.Errorf("%s: %w", provider, err)
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
return nil, readProviderError(provider, resp)
}
return resp, nil
}
// jsonStreamFallback answers whether the provider ignored stream=true
// and served the plain JSON envelope (Content-Type application/json) —
// the honest non-streaming fallback, never a decode of the wrong shape.
func jsonStreamFallback(resp *http.Response) bool {
return strings.Contains(resp.Header.Get("Content-Type"), "application/json")
}
// oiDecodeReply builds the ChatReply from a non-streaming [OI] JSON
// envelope (the stream fallback + the plain leg share the shapes).
func oiDecodeReply(resp *http.Response, model, provider string, ctxLimit int) (ChatReply, error) {
var out oiChatResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return ChatReply{}, fmt.Errorf("%s /chat/completions decode: %w", provider, err)
}
reply := ChatReply{Model: out.Model, ContextLimit: ctxLimit, ContextUsed: out.Usage.PromptTokens}
if len(out.Choices) > 0 {
msg := out.Choices[0].Message
if msg.Content != nil {
reply.Content = *msg.Content
}
if strings.TrimSpace(msg.ReasoningContent) != "" {
reply.Thinking = []string{msg.ReasoningContent}
}
for _, tc := range msg.ToolCalls {
call := ToolCall{Name: tc.Function.Name}
if strings.TrimSpace(tc.Function.Arguments) != "" {
var m map[string]any
if json.Unmarshal([]byte(tc.Function.Arguments), &m) == nil {
call.Args = m
}
}
reply.ToolCalls = append(reply.ToolCalls, call)
}
}
return reply, nil
}
// oiStreamChunk is one [OI] SSE data payload (the delta shape).
type oiStreamChunk struct {
Model string `json:"model"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
} `json:"usage"`
Choices []struct {
Delta struct {
Content *string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
ToolCalls []oiStreamToolDl `json:"tool_calls"`
} `json:"delta"`
} `json:"choices"`
}
// oiStreamToolDl is one tool-call delta: the name on the first frame,
// the JSON arguments accumulating across frames (indexed).
type oiStreamToolDl struct {
Index int `json:"index"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
// StreamChatWithTools is the [OI]-compat streaming leg: stream=true
// chat/completions, SSE data lines parsed incrementally ([DONE]
// terminates), content + reasoning surfaced through the sink.
func (c *OpenAICompatClient) StreamChatWithTools(ctx context.Context, model string, msgs []ChatMessage, tools []ToolDef, sink StreamSink) (ChatReply, error) {
wireMsgs, err := oiWireMessages(msgs)
if err != nil {
return ChatReply{}, err
}
body, err := json.Marshal(oiChatRequest{Model: bareWireModel(model), Messages: wireMsgs, Stream: true, Tools: tools})
if err != nil {
return ChatReply{}, err
}
idle := chunkIdleTimeout()
w := watchIdle(ctx, idle, StreamTurnBackstop)
defer w.stop()
resp, err := streamDo(w.ctx, c.provider, c.baseURL+"/chat/completions",
authHeaders(c.apiKey), body)
if err != nil {
return ChatReply{}, w.err(err, idle)
}
defer resp.Body.Close()
if jsonStreamFallback(resp) {
return oiDecodeReply(resp, model, c.provider, contextLimitFor(c.ctxLimits, model))
}
var content strings.Builder
var thinking strings.Builder
argBufs := map[int]*strings.Builder{}
names := map[int]string{}
reply := ChatReply{ContextLimit: contextLimitFor(c.ctxLimits, model)}
err = scanSSE(bufio.NewReader(resp.Body), func(data string) error {
w.reset(idle)
var chunk oiStreamChunk
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
return fmt.Errorf("%s stream decode: %w", c.provider, err)
}
if chunk.Model != "" {
reply.Model = chunk.Model
}
if chunk.Usage.PromptTokens > 0 {
reply.ContextUsed = chunk.Usage.PromptTokens
}
if len(chunk.Choices) == 0 {
return nil
}
d := chunk.Choices[0].Delta
if d.Content != nil && *d.Content != "" {
content.WriteString(*d.Content)
emit(sink, StreamDelta{Kind: "text", Text: *d.Content})
}
if d.ReasoningContent != "" {
thinking.WriteString(d.ReasoningContent)
emit(sink, StreamDelta{Kind: "thinking", Text: d.ReasoningContent})
}
for _, tc := range d.ToolCalls {
if tc.Function.Name != "" {
names[tc.Index] = tc.Function.Name
}
buf := argBufs[tc.Index]
if buf == nil {
buf = &strings.Builder{}
argBufs[tc.Index] = buf
}
buf.WriteString(tc.Function.Arguments)
}
return nil
})
if err != nil && err != io.EOF {
return ChatReply{}, w.err(err, idle)
}
reply.Content = content.String()
if thinking.Len() > 0 {
reply.Thinking = []string{thinking.String()}
}
reply.ToolCalls = oiStreamToolCalls(argBufs, names)
return reply, nil
}
// oiStreamToolCalls folds the accumulated tool-call deltas into the
// reply's ToolCalls (arguments parsed as JSON objects when they parse).
func oiStreamToolCalls(argBufs map[int]*strings.Builder, names map[int]string) []ToolCall {
var out []ToolCall
for idx := 0; idx < len(argBufs); idx++ {
name := names[idx]
if name == "" {
continue
}
call := ToolCall{Name: name}
if raw := argBufs[idx].String(); strings.TrimSpace(raw) != "" {
var m map[string]any
if json.Unmarshal([]byte(raw), &m) == nil {
call.Args = m
}
}
out = append(out, call)
}
return out
}
func authHeaders(apiKey string) map[string]string {
if apiKey == "" {
return nil
}
return map[string]string{"Authorization": "Bearer " + apiKey}
}
// StreamChatWithTools is the anthropic streaming leg: stream=true
// /v1/messages, the SSE event stream parsed incrementally
// (content_block_delta carries the text/thinking/input_json bytes).
func (c *AnthropicClient) StreamChatWithTools(ctx context.Context, model string, msgs []ChatMessage, tools []ToolDef, sink StreamSink) (ChatReply, error) {
var system []anthropicSystemBlock
rest := msgs
for len(rest) > 0 && rest[0].Role == "system" {
if strings.TrimSpace(rest[0].Content) != "" {
system = append(system, anthropicSystemBlock{Type: "text", Text: rest[0].Content})
}
rest = rest[1:]
}
var wireTools []anthropicToolDef
for _, t := range tools {
wireTools = append(wireTools, anthropicToolDef{
Name: t.Function.Name, Description: t.Function.Description, InputSchema: t.Function.Parameters,
})
}
body, err := json.Marshal(anthropicChatRequest{
Model: model, MaxTokens: anthropicMaxTokens,
Messages: anthropicWireMessages(rest), System: system, Tools: wireTools,
Stream: true,
})
if err != nil {
return ChatReply{}, err
}
idle := chunkIdleTimeout()
w := watchIdle(ctx, idle, StreamTurnBackstop)
defer w.stop()
resp, err := streamDo(w.ctx, c.provider, c.baseURL+"/v1/messages",
map[string]string{"x-api-key": c.apiKey, "anthropic-version": anthropicVersion}, body)
if err != nil {
return ChatReply{}, w.err(err, idle)
}
defer resp.Body.Close()
if jsonStreamFallback(resp) {
return anthropicDecodeReply(resp, contextLimitFor(c.ctxLimits, model), c.provider)
}
reply := ChatReply{ContextLimit: contextLimitFor(c.ctxLimits, model)}
var content strings.Builder
var thinking strings.Builder
// streaming tool calls: block index -> accumulated input JSON
toolNames := map[int]string{}
toolArgs := map[int]*strings.Builder{}
err = scanSSE(bufio.NewReader(resp.Body), func(data string) error {
w.reset(idle)
var ev struct {
Type string `json:"type"`
Index int `json:"index"`
Block *anthropicContentBlock `json:"content_block"`
Delta *struct {
Type string `json:"type"`
Text string `json:"text"`
Thinking string `json:"thinking"`
PartialJSON string `json:"partial_json"`
} `json:"delta"`
Message *anthropicChatResponse `json:"message"`
}
if err := json.Unmarshal([]byte(data), &ev); err != nil {
return fmt.Errorf("%s stream decode: %w", c.provider, err)
}
switch ev.Type {
case "message_start":
if ev.Message != nil {
reply.Model = ev.Message.Model
reply.ContextUsed = ev.Message.Usage.InputTokens
}
case "content_block_start":
if ev.Block != nil && ev.Block.Type == "tool_use" {
toolNames[ev.Index] = ev.Block.Name
toolArgs[ev.Index] = &strings.Builder{}
}
case "content_block_delta":
if ev.Delta == nil {
return nil
}
switch ev.Delta.Type {
case "text_delta":
if ev.Delta.Text != "" {
content.WriteString(ev.Delta.Text)
emit(sink, StreamDelta{Kind: "text", Text: ev.Delta.Text})
}
case "thinking_delta":
if ev.Delta.Thinking != "" {
thinking.WriteString(ev.Delta.Thinking)
emit(sink, StreamDelta{Kind: "thinking", Text: ev.Delta.Thinking})
}
case "input_json_delta":
if buf, ok := toolArgs[ev.Index]; ok {
buf.WriteString(ev.Delta.PartialJSON)
}
}
}
return nil
})
if err != nil && err != io.EOF {
return ChatReply{}, w.err(err, idle)
}
reply.Content = content.String()
if thinking.Len() > 0 {
reply.Thinking = []string{thinking.String()}
}
for idx := 0; idx < maxIntMapLen(toolNames); idx++ {
name, ok := toolNames[idx]
if !ok {
continue
}
call := ToolCall{Name: name}
if buf := toolArgs[idx]; buf != nil && strings.TrimSpace(buf.String()) != "" {
var m map[string]any
if json.Unmarshal([]byte(buf.String()), &m) == nil {
call.Args = m
}
}
reply.ToolCalls = append(reply.ToolCalls, call)
}
return reply, nil
}
func maxIntMapLen(m map[int]string) int {
n := 0
for k := range m {
if k+1 > n {
n = k + 1
}
}
return n
}
// anthropicDecodeReply builds the ChatReply from a non-streaming
// /v1/messages JSON envelope (the stream fallback).
func anthropicDecodeReply(resp *http.Response, ctxLimit int, provider string) (ChatReply, error) {
var out anthropicChatResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return ChatReply{}, fmt.Errorf("%s /v1/messages decode: %w", provider, err)
}
reply := ChatReply{Model: out.Model, ContextLimit: ctxLimit, ContextUsed: out.Usage.InputTokens}
var content strings.Builder
for _, blk := range out.Content {
switch blk.Type {
case "text":
content.WriteString(blk.Text)
case "thinking":
if strings.TrimSpace(blk.Thinking) != "" {
reply.Thinking = append(reply.Thinking, blk.Thinking)
}
case "tool_use":
reply.ToolCalls = append(reply.ToolCalls, ToolCall{Name: blk.Name, Args: blk.Input})
}
}
reply.Content = content.String()
return reply, nil
}
// geminiDecodeReply builds the ChatReply from a non-streaming
// generateContent JSON envelope (the stream fallback).
func geminiDecodeReply(resp *http.Response, ctxLimit int, provider string) (ChatReply, error) {
var out geminiChatResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return ChatReply{}, fmt.Errorf("%s generateContent decode: %w", provider, err)
}
reply := ChatReply{Model: out.ModelVersion, ContextLimit: ctxLimit, ContextUsed: out.UsageMetadata.PromptTokenCount}
var content strings.Builder
if len(out.Candidates) > 0 {
for _, part := range out.Candidates[0].Content.Parts {
if part.FunctionCall != nil {
reply.ToolCalls = append(reply.ToolCalls, ToolCall{Name: part.FunctionCall.Name, Args: part.FunctionCall.Args})
continue
}
if part.Thought {
if strings.TrimSpace(part.Text) != "" {
reply.Thinking = append(reply.Thinking, part.Text)
}
continue
}
content.WriteString(part.Text)
}
}
reply.Content = content.String()
return reply, nil
}
// StreamChatWithTools is the gemini streaming leg:
// streamGenerateContent?alt=sse — each SSE data line is one
// GenerateContentResponse chunk; text/thought parts and functionCall
// parts surface as they arrive.
func (c *GeminiClient) StreamChatWithTools(ctx context.Context, model string, msgs []ChatMessage, tools []ToolDef, sink StreamSink) (ChatReply, error) {
var system *struct {
Parts []geminiPart `json:"parts"`
}
rest := msgs
for len(rest) > 0 && rest[0].Role == "system" {
if strings.TrimSpace(rest[0].Content) != "" {
if system == nil {
system = &struct {
Parts []geminiPart `json:"parts"`
}{}
}
system.Parts = append(system.Parts, geminiPart{Text: rest[0].Content})
}
rest = rest[1:]
}
var wireTools []geminiTool
if len(tools) > 0 {
decls := make([]geminiFunctionDecl, 0, len(tools))
for _, t := range tools {
decls = append(decls, geminiFunctionDecl{
Name: t.Function.Name, Description: t.Function.Description, Parameters: t.Function.Parameters,
})
}
wireTools = []geminiTool{{FunctionDeclarations: decls}}
}
body, err := json.Marshal(geminiChatRequest{Contents: geminiWireMessages(rest), SystemInstruction: system, Tools: wireTools})
if err != nil {
return ChatReply{}, err
}
idle := chunkIdleTimeout()
w := watchIdle(ctx, idle, StreamTurnBackstop)
defer w.stop()
url := fmt.Sprintf("%s/%s:streamGenerateContent?alt=sse&key=%s", c.baseURL, geminiWireModel(model), c.apiKey)
resp, err := streamDo(w.ctx, c.provider, url, nil, body)
if err != nil {
return ChatReply{}, w.err(err, idle)
}
defer resp.Body.Close()
if jsonStreamFallback(resp) {
return geminiDecodeReply(resp, contextLimitFor(c.ctxLimits, model), c.provider)
}
reply := ChatReply{ContextLimit: contextLimitFor(c.ctxLimits, model)}
var content strings.Builder
var thinking strings.Builder
err = scanSSE(bufio.NewReader(resp.Body), func(data string) error {
w.reset(idle)
var chunk geminiChatResponse
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
return fmt.Errorf("%s stream decode: %w", c.provider, err)
}
if chunk.ModelVersion != "" {
reply.Model = chunk.ModelVersion
}
if chunk.UsageMetadata.PromptTokenCount > 0 {
reply.ContextUsed = chunk.UsageMetadata.PromptTokenCount
}
if len(chunk.Candidates) == 0 {
return nil
}
for _, part := range chunk.Candidates[0].Content.Parts {
if part.FunctionCall != nil {
reply.ToolCalls = append(reply.ToolCalls, ToolCall{Name: part.FunctionCall.Name, Args: part.FunctionCall.Args})
continue
}
if part.Thought {
if part.Text != "" {
thinking.WriteString(part.Text)
emit(sink, StreamDelta{Kind: "thinking", Text: part.Text})
}
continue
}
if part.Text != "" {
content.WriteString(part.Text)
emit(sink, StreamDelta{Kind: "text", Text: part.Text})
}
}
return nil
})
if err != nil && err != io.EOF {
return ChatReply{}, w.err(err, idle)
}
reply.Content = content.String()
if thinking.Len() > 0 {
reply.Thinking = []string{thinking.String()}
}
return reply, nil
}
@@ -0,0 +1,186 @@
package runecore
// cloud_stream_test.go — the cloud wires' streaming parity proofs
// (Gitea #2556, red-first, hermetic): each wire's SSE body parses
// incrementally, surfaces the text bytes through the sink, assembles
// the reply (thinking + tool_calls included), and honors the
// inter-chunk idle timeout.
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// sseServer is a hermetic SSE double: events written per line with a
// delay between them.
func sseServer(t *testing.T, path string, events []string, perEvent time.Duration) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != path {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/event-stream")
for i, ev := range events {
_, _ = w.Write([]byte("data: " + ev + "\n\n"))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
if i < len(events)-1 && perEvent > 0 {
time.Sleep(perEvent)
}
}
}))
}
func TestOICompatStreamAssemblesReply(t *testing.T) {
events := []string{
`{"model":"glm","choices":[{"delta":{"content":"he"}}]}`,
`{"model":"glm","choices":[{"delta":{"content":"llo"}}]}`,
`{"model":"glm","choices":[{"delta":{"reasoning_content":"thought hard"}}]}`,
`{"model":"glm","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"read_file","arguments":"{\"pa"}}]}}]}`,
`{"model":"glm","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"th\":\"x\"}"}}]}}]}`,
`{"model":"glm","choices":[{"delta":{}}],"usage":{"prompt_tokens":90}}`,
"[DONE]",
}
srv := sseServer(t, "/chat/completions", events, 0)
c := NewOpenAICompatClient(srv.URL, "", "openai")
var got strings.Builder
reply, err := c.StreamChatWithTools(context.Background(), "openai/glm",
[]ChatMessage{{Role: "user", Content: "hi"}}, nil, func(d StreamDelta) {
if d.Kind == "text" {
got.WriteString(d.Text)
}
})
if err != nil {
t.Fatal(err)
}
if got.String() != "hello" {
t.Fatalf("deltas = %q", got.String())
}
if reply.Content != "hello" {
t.Fatalf("content = %q", reply.Content)
}
if len(reply.Thinking) != 1 || reply.Thinking[0] != "thought hard" {
t.Fatalf("thinking = %v", reply.Thinking)
}
if len(reply.ToolCalls) != 1 || reply.ToolCalls[0].Name != "read_file" || reply.ToolCalls[0].Args["path"] != "x" {
t.Fatalf("tool calls = %+v", reply.ToolCalls)
}
if reply.ContextUsed != 90 {
t.Fatalf("context used = %d", reply.ContextUsed)
}
}
func TestOICompatStreamIdleTimeout(t *testing.T) {
t.Setenv(ChunkIdleTimeoutEnv, "1")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\n"))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
<-r.Context().Done()
}))
defer srv.Close()
c := NewOpenAICompatClient(srv.URL, "", "openai")
_, err := c.StreamChatWithTools(context.Background(), "m", []ChatMessage{{Role: "user", Content: "hi"}}, nil, nil)
if !errors.Is(err, ErrStreamIdle) {
t.Fatalf("err = %v, want ErrStreamIdle", err)
}
}
func TestAnthropicStreamAssemblesReply(t *testing.T) {
events := []string{
`{"type":"message_start","message":{"model":"claude","usage":{"input_tokens":55}}}`,
`{"type":"content_block_start","index":0,"content_block":{"type":"text"}}`,
`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"bon"}}`,
`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"jour"}}`,
`{"type":"content_block_delta","index":1,"delta":{"type":"thinking_delta","thinking":"musing"}}`,
`{"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"toolu_read_file","name":"read_file"}}`,
`{"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"path\":\"y\"}"}}`,
`{"type":"message_stop"}`,
}
srv := sseServer(t, "/v1/messages", events, 0)
c := NewAnthropicClient(srv.URL, "k", "anthropic")
var got strings.Builder
reply, err := c.StreamChatWithTools(context.Background(), "claude",
[]ChatMessage{{Role: "user", Content: "hi"}}, nil, func(d StreamDelta) {
if d.Kind == "text" {
got.WriteString(d.Text)
}
})
if err != nil {
t.Fatal(err)
}
if got.String() != "bonjour" || reply.Content != "bonjour" {
t.Fatalf("deltas=%q content=%q", got.String(), reply.Content)
}
if len(reply.Thinking) != 1 || reply.Thinking[0] != "musing" {
t.Fatalf("thinking = %v", reply.Thinking)
}
if len(reply.ToolCalls) != 1 || reply.ToolCalls[0].Name != "read_file" || reply.ToolCalls[0].Args["path"] != "y" {
t.Fatalf("tool calls = %+v", reply.ToolCalls)
}
if reply.ContextUsed != 55 {
t.Fatalf("context used = %d", reply.ContextUsed)
}
}
func TestGeminiStreamAssemblesReply(t *testing.T) {
events := []string{
`{"candidates":[{"content":{"role":"model","parts":[{"text":"cia"},{"thought":true,"text":"ponder"},{"text":"o"}]}}],"modelVersion":"gemini","usageMetadata":{"promptTokenCount":31}}`,
`{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"glob_files","args":{"pattern":"*.go"}}}]}}]}`,
}
srv := sseServer(t, "/models/gemini:streamGenerateContent", events, 0)
c := NewGeminiClient(srv.URL, "k", "gemini")
var got strings.Builder
reply, err := c.StreamChatWithTools(context.Background(), "models/gemini",
[]ChatMessage{{Role: "user", Content: "hi"}}, nil, func(d StreamDelta) {
if d.Kind == "text" {
got.WriteString(d.Text)
}
})
if err != nil {
t.Fatal(err)
}
if got.String() != "ciao" || reply.Content != "ciao" {
t.Fatalf("deltas=%q content=%q", got.String(), reply.Content)
}
if len(reply.Thinking) != 1 || reply.Thinking[0] != "ponder" {
t.Fatalf("thinking = %v", reply.Thinking)
}
if len(reply.ToolCalls) != 1 || reply.ToolCalls[0].Name != "glob_files" || reply.ToolCalls[0].Args["pattern"] != "*.go" {
t.Fatalf("tool calls = %+v", reply.ToolCalls)
}
if reply.ContextUsed != 31 {
t.Fatalf("context used = %d", reply.ContextUsed)
}
}
// TestCloudStreamsSlowModelFinishes — one wire suffices for the
// timing law (the watch is shared): an SSE model emitting every 200ms
// across a window no flat budget tolerated completes.
func TestCloudStreamsSlowModelFinishes(t *testing.T) {
t.Setenv(ChunkIdleTimeoutEnv, "1")
var events []string
for i := 0; i < 10; i++ {
events = append(events, fmt.Sprintf(`{"model":"m","choices":[{"delta":{"content":"c%d "}}]}`, i))
}
events = append(events, "[DONE]")
srv := sseServer(t, "/chat/completions", events, 200*time.Millisecond)
defer srv.Close()
c := NewOpenAICompatClient(srv.URL, "", "openai")
reply, err := c.StreamChatWithTools(context.Background(), "m", []ChatMessage{{Role: "user", Content: "hi"}}, nil, nil)
if err != nil {
t.Fatalf("slow streamed cloud turn failed: %v", err)
}
if !strings.Contains(reply.Content, "c9") {
t.Fatalf("content = %q", reply.Content)
}
}
+36 -16
View File
@@ -190,29 +190,40 @@ func (e *Engine) release(channel string) {
delete(e.inFlight, channel)
}
// runTurn drives the turn's model call: the tool loop when the client
// can carry tools AND the turn's dispatch mode exposes a toolset (D45
// toolset gap — the model must SEE the allowed suite and be able to
// ACT, gated). The mode is channel-derived (modeForDispatch, #2537):
// the task channel runs TASK semantics regardless of the face's mode;
// every other channel follows the face.
func (e *Engine) runTurn(ctx context.Context, client ModelClient, model, channel string, payload []ChatMessage) (ChatReply, error) {
// modelOnce runs ONE model call on the loop's leg: the streaming leg
// when the client streams (Gitea #2556 — the reply's bytes surface
// through sink as they arrive), the non-streaming leg otherwise. The
// loop's bounded semantics are unchanged either way.
func modelOnce(ctx context.Context, client ModelClient, model string, msgs []ChatMessage, tools []ToolDef, sink StreamSink) (ChatReply, error) {
if sc, ok := client.(StreamChatClient); ok {
return sc.StreamChatWithTools(ctx, model, msgs, tools, sink)
}
if tc, ok := client.(ToolChatClient); ok {
return tc.ChatWithTools(ctx, model, msgs, tools)
}
return client.Chat(ctx, model, msgs)
}
// runTurn drives the turn's model call: the tool loop when the turn's
// dispatch mode exposes a toolset (D45 toolset gap — the model must
// SEE the allowed suite and be able to ACT, gated). The mode is
// channel-derived (modeForDispatch, #2537): the task channel runs TASK
// semantics regardless of the face's mode; every other channel follows
// the face. A streaming client streams every loop leg (sink non-nil
// only on the streaming door).
func (e *Engine) runTurn(ctx context.Context, client ModelClient, model, channel string, payload []ChatMessage, sink StreamSink) (ChatReply, error) {
tools := ToolsetFor(modeForDispatch(channel, e.home))
if len(tools) == 0 {
return client.Chat(ctx, model, payload)
return modelOnce(ctx, client, model, payload, nil, sink)
}
tc, ok := client.(ToolChatClient)
if !ok {
return client.Chat(ctx, model, payload)
}
if !tc.SupportsTools(ctx, model) {
if tc, ok := client.(ToolChatClient); !ok || !tc.SupportsTools(ctx, model) {
return client.Chat(ctx, model, payload)
}
msgs := payload
var reply ChatReply
var err error
for iteration := 0; iteration < maxToolIterations; iteration++ {
reply, err = tc.ChatWithTools(ctx, model, msgs, tools)
reply, err = modelOnce(ctx, client, model, msgs, tools, sink)
if err != nil {
return reply, err
}
@@ -238,7 +249,7 @@ func (e *Engine) runTurn(ctx context.Context, client ModelClient, model, channel
// The loop hit its cap: one final call with the toolset withdrawn —
// the model answers in text, it never keeps demanding tools forever
// (the Python bounded-loop law).
return tc.ChatWithTools(ctx, model, msgs, nil)
return modelOnce(ctx, client, model, msgs, nil, sink)
}
// Turn runs one channel turn: admit, resolve the model (the record's
@@ -246,6 +257,15 @@ func (e *Engine) runTurn(ctx context.Context, client ModelClient, model, channel
// the user message, assemble THIS channel's context, call the model
// once, persist the assistant message, answer.
func (e *Engine) Turn(ctx context.Context, req TurnRequest) (TurnResult, error) {
return e.TurnStream(ctx, req, nil)
}
// TurnStream runs one channel turn with the model's answer bytes
// surfaced through sink AS THEY ARRIVE (Gitea #2556 — the whole turn
// never sits inside one blocking call). A nil sink keeps the classic
// Turn behavior byte-for-byte. Admit, persistence, tool-loop and
// result shapes are identical to Turn.
func (e *Engine) TurnStream(ctx context.Context, req TurnRequest, sink StreamSink) (TurnResult, error) {
if !validChannel(req.Channel) {
return TurnResult{}, errors.New("invalid channel id")
}
@@ -305,7 +325,7 @@ func (e *Engine) Turn(ctx context.Context, req TurnRequest) (TurnResult, error)
payload = append(payload, cm)
}
reply, err := e.runTurn(ctx, client, model, req.Channel, payload)
reply, err := e.runTurn(ctx, client, model, req.Channel, payload, sink)
if err != nil {
// the user message stays persisted: the user DID send it (the
// Python oracle's behavior); the failure is honest, not silent
@@ -0,0 +1,433 @@
package runecore
// gittools.go — D49's native git family (owner ruling 2026-10-22:
// "she also needs tools to be able to connect to git projects. and
// make that native"). Six first-class tools — git_status, git_diff,
// git_log, git_branch, git_add, git_commit — implemented by shelling
// out to git in the repo root (stdlib only, the Go core's zero-dep
// split law; no external git library), porcelain output parsed
// honestly, typed JSON results the model can read. git_add and
// git_commit refuse the landing-law banned paths (check_landing.py
// rule 4) so the model's git door obeys the same law every other
// mutation door does; in a real checkout git_commit also runs the
// repo's own pre-commit hook — the scaffolding authority stays the
// safety layer at the mutation door.
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// DefaultRepoRoot resolves the repository root the git family shells
// into (mirrors tui/wasm's data-home law): RUNE_REPO_ROOT overrides
// for tests and deploy-time roots; the default is the canonical
// checkout.
const defaultRepoRoot = "/mnt/workdrive/rune"
func DefaultRepoRoot() string {
if v := os.Getenv("RUNE_REPO_ROOT"); v != "" {
return v
}
return defaultRepoRoot
}
// gitLandingBannedPaths mirrors scripts/check_landing.py's
// SWEEP_RACE_BANNED_PATHS (the gate is the authority; keep in sync):
// live store state the sweep mutates — three 102MB accidents — never
// committable whatever its size.
var gitLandingBannedPaths = map[string]bool{
"store/scaffold/current.json": true,
"store/scaffold/legacy-current-migration-snapshot.json": true,
}
// gitLandingMaxFileBytes mirrors check_landing.py's
// MAX_LANDING_FILE_BYTES: any blob over the ceiling refuses at the
// git_add door.
const gitLandingMaxFileBytes = 20 * 1024 * 1024
func gitRun(root string, timeout time.Duration, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", root}, args...)...)
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_PAGER=cat")
out, err := cmd.CombinedOutput()
if err != nil {
msg := strings.TrimSpace(string(out))
if msg == "" {
msg = err.Error()
}
if len(msg) > 2000 {
msg = msg[:2000]
}
return "", fmt.Errorf("git %s: %s", args[0], msg)
}
return string(out), nil
}
func typedJSON(v any) (string, error) {
b, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(b), nil
}
// repoRelPath normalizes a model-supplied path to a slash-separated
// path relative to the repo root; empty when the path is not inside
// the repo.
func repoRelPath(root, p string) string {
p = expandHome(strings.TrimSpace(p))
if p == "" {
return ""
}
if filepath.IsAbs(p) {
rel, err := filepath.Rel(root, p)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return ""
}
p = rel
}
return filepath.ToSlash(filepath.Clean(p))
}
// ── typed results ───────────────────────────────────────────────────
type GitStatusEntry struct {
Code string `json:"code"`
Path string `json:"path"`
OldPath string `json:"old_path,omitempty"`
}
type GitStatusResult struct {
Branch string `json:"branch"`
Ahead int `json:"ahead"`
Behind int `json:"behind"`
Clean bool `json:"clean"`
Entries []GitStatusEntry `json:"entries"`
}
type GitDiffName struct {
Change string `json:"change"`
Path string `json:"path"`
OldPath string `json:"old_path,omitempty"`
}
type GitDiffResult struct {
Form string `json:"form"`
Scope string `json:"scope"`
Unstaged []GitDiffName `json:"unstaged,omitempty"`
Staged []GitDiffName `json:"staged,omitempty"`
Patch string `json:"patch,omitempty"`
Truncated bool `json:"truncated,omitempty"`
}
type GitLogEntry struct {
Sha string `json:"sha"`
Short string `json:"short"`
Author string `json:"author"`
When string `json:"when"`
Subject string `json:"subject"`
}
type GitLogResult struct {
Commits []GitLogEntry `json:"commits"`
}
type GitBranchResult struct {
Current string `json:"current"`
Branches []string `json:"branches"`
}
type GitAddResult struct {
Added []string `json:"added"`
}
type GitCommitResult struct {
Committed bool `json:"committed"`
Sha string `json:"sha"`
Subject string `json:"subject"`
Files []string `json:"files"`
}
// ── the tools ───────────────────────────────────────────────────────
func gitStatusTool(root string) (string, error) {
out, err := gitRun(root, 30*time.Second, "status", "--porcelain=v1", "-b", "-z")
if err != nil {
return "", err
}
res := GitStatusResult{Entries: []GitStatusEntry{}}
records := strings.Split(out, "\x00")
for i := 0; i < len(records); i++ {
rec := records[i]
if rec == "" {
continue
}
if strings.HasPrefix(rec, "## ") {
head := strings.TrimPrefix(rec, "## ")
if strings.HasPrefix(head, "No commits yet on ") {
res.Branch = strings.TrimPrefix(head, "No commits yet on ")
continue
}
if open := strings.Index(head, " ["); open >= 0 && strings.HasSuffix(head, "]") {
for _, kv := range strings.Split(head[open+2:len(head)-1], ",") {
kv = strings.TrimSpace(kv)
switch {
case strings.HasPrefix(kv, "ahead "):
res.Ahead, _ = strconv.Atoi(strings.TrimPrefix(kv, "ahead "))
case strings.HasPrefix(kv, "behind "):
res.Behind, _ = strconv.Atoi(strings.TrimPrefix(kv, "behind "))
}
}
head = head[:open]
}
if idx := strings.Index(head, "..."); idx >= 0 {
head = head[:idx]
}
res.Branch = head
continue
}
if len(rec) < 4 {
continue
}
code := rec[:2]
path := rec[3:]
entry := GitStatusEntry{Code: code, Path: path}
// with -z a rename/copy record carries the OLD path as the
// next NUL record — consume it
if (code[0] == 'R' || code[0] == 'C') && i+1 < len(records) {
entry.OldPath = records[i+1]
i++
}
res.Entries = append(res.Entries, entry)
}
res.Clean = len(res.Entries) == 0
return typedJSON(res)
}
func parseDiffNames(out string) []GitDiffName {
var files []GitDiffName
for _, line := range strings.Split(out, "\n") {
if strings.TrimSpace(line) == "" {
continue
}
f := strings.Split(line, "\t")
if len(f) < 2 {
continue
}
entry := GitDiffName{Change: strings.TrimRight(f[0], "0123456789"), Path: f[len(f)-1]}
if len(f) >= 3 {
entry.OldPath = f[1]
}
files = append(files, entry)
}
return files
}
func gitDiffTool(root, form string, hasStaged, staged bool) (string, error) {
if form == "" {
form = "names"
}
if form != "names" && form != "patch" {
return "", fmt.Errorf("git_diff: form must be \"names\" or \"patch\"")
}
res := GitDiffResult{Form: form}
switch {
case !hasStaged:
res.Scope = "all"
case staged:
res.Scope = "staged"
default:
res.Scope = "unstaged"
}
needUnstaged := !hasStaged || !staged
needStaged := !hasStaged || staged
run := func(cached bool) (string, error) {
args := []string{"diff"}
if cached {
args = append(args, "--cached")
}
if form == "names" {
args = append(args, "--name-status")
}
return gitRun(root, 30*time.Second, args...)
}
if needUnstaged {
out, err := run(false)
if err != nil {
return "", err
}
if form == "names" {
res.Unstaged = parseDiffNames(out)
}
res.Patch += "=== unstaged ===\n" + out
}
if needStaged {
out, err := run(true)
if err != nil {
return "", err
}
if form == "names" {
res.Staged = parseDiffNames(out)
}
res.Patch += "=== staged ===\n" + out
}
if form == "patch" {
const maxPatch = 16000
if len(res.Patch) > maxPatch {
res.Patch = res.Patch[:maxPatch]
res.Truncated = true
}
} else {
res.Patch = ""
}
return typedJSON(res)
}
func gitLogTool(root string, limit int) (string, error) {
if limit <= 0 {
limit = 10
}
if limit > 100 {
limit = 100
}
out, err := gitRun(root, 30*time.Second, "log",
fmt.Sprintf("-%d", limit),
"--pretty=%H%x1f%h%x1f%an%x1f%ar%x1f%s")
if err != nil {
if strings.Contains(err.Error(), "does not have any commits yet") {
return typedJSON(GitLogResult{Commits: []GitLogEntry{}})
}
return "", err
}
res := GitLogResult{Commits: []GitLogEntry{}}
for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") {
f := strings.Split(line, "\x1f")
if len(f) != 5 {
continue
}
res.Commits = append(res.Commits, GitLogEntry{
Sha: f[0], Short: f[1], Author: f[2], When: f[3], Subject: f[4],
})
}
return typedJSON(res)
}
func gitBranchTool(root string) (string, error) {
out, err := gitRun(root, 15*time.Second, "branch", "--format=%(HEAD)%00%(refname:short)")
if err != nil {
return "", err
}
res := GitBranchResult{Branches: []string{}}
for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") {
f := strings.SplitN(line, "\x00", 2)
if len(f) != 2 || f[1] == "" {
continue
}
res.Branches = append(res.Branches, f[1])
if strings.TrimSpace(f[0]) == "*" {
res.Current = f[1]
}
}
return typedJSON(res)
}
func gitAddTool(root string, paths []string) (string, error) {
if len(paths) == 0 {
return "", fmt.Errorf("git_add: paths required")
}
cleaned := make([]string, 0, len(paths))
for _, p := range paths {
rel := repoRelPath(root, p)
if rel == "" || rel == "." {
return "", fmt.Errorf("git_add: %q is not a path inside the repository", p)
}
if gitLandingBannedPaths[rel] {
return "", fmt.Errorf("git_add: %s is a banned landing-law path (live store state the sweep mutates — scripts/check_landing.py rule 4); it never enters the index", rel)
}
if fi, err := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); err == nil && fi.Size() > gitLandingMaxFileBytes {
return "", fmt.Errorf("git_add: %s is %d bytes, over the %d-byte landing-law ceiling (scripts/check_landing.py rule 4)", rel, fi.Size(), gitLandingMaxFileBytes)
}
cleaned = append(cleaned, rel)
}
if _, err := gitRun(root, 30*time.Second, append([]string{"add", "--"}, cleaned...)...); err != nil {
return "", err
}
return typedJSON(GitAddResult{Added: cleaned})
}
func gitCommitTool(root, message string) (string, error) {
message = strings.TrimSpace(message)
if message == "" {
return "", fmt.Errorf("git_commit: message required")
}
out, err := gitRun(root, 30*time.Second, "diff", "--cached", "--name-only")
if err != nil {
return "", err
}
var staged []string
for _, l := range strings.Split(out, "\n") {
if l = strings.TrimSpace(l); l != "" {
staged = append(staged, l)
}
}
if len(staged) == 0 {
return "", fmt.Errorf("git_commit: nothing staged — stage files with git_add first")
}
var banned []string
for _, p := range staged {
if gitLandingBannedPaths[filepath.ToSlash(filepath.Clean(p))] {
banned = append(banned, p)
}
}
if len(banned) > 0 {
return "", fmt.Errorf("git_commit: refused — the staged tree carries banned landing-law path(s) %s (live store state, scripts/check_landing.py rule 4); unstage them first", strings.Join(banned, ", "))
}
if _, err := gitRun(root, 120*time.Second, "commit", "-m", message); err != nil {
return "", err
}
sha, err := gitRun(root, 15*time.Second, "rev-parse", "HEAD")
if err != nil {
return "", err
}
subj, err := gitRun(root, 15*time.Second, "log", "-1", "--pretty=%s")
if err != nil {
return "", err
}
return typedJSON(GitCommitResult{
Committed: true,
Sha: strings.TrimSpace(sha),
Subject: strings.TrimSpace(subj),
Files: staged,
})
}
// gitToolDefs is the git family's wire schemas.
func gitToolDefs() []ToolDef {
return []ToolDef{
toolSpec("git_status", "Show the working-tree status of the Rune repository — branch, ahead/behind, and every changed or untracked file — as typed JSON.",
map[string]any{}, nil),
toolSpec("git_diff", "Show the repository's unstaged and staged changes as typed JSON: a name-only change list, or a unified patch.",
map[string]any{"form": map[string]any{"type": "string", "description": "\"names\" (default) or \"patch\""},
"staged": map[string]any{"type": "boolean", "description": "true = staged only, false = unstaged only, omit = both"}},
nil),
toolSpec("git_log", "Show the repository's recent commits (sha, short sha, author, when, subject) as typed JSON.",
map[string]any{"limit": map[string]any{"type": "integer", "description": "how many commits (default 10, max 100)"}},
nil),
toolSpec("git_branch", "List the repository's local branches and which one is current, as typed JSON.",
map[string]any{}, nil),
toolSpec("git_add", "Stage files for a commit in the Rune repository. Refuses the landing-law banned paths (live store state never enters the index).",
map[string]any{"paths": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "repo-relative or absolute file paths to stage"}},
[]string{"paths"}),
toolSpec("git_commit", "Commit the repository's staged changes with a required message. Refuses banned landing-law paths in the staged tree and runs the repository's pre-commit hook.",
map[string]any{"message": map[string]any{"type": "string", "description": "the commit message"}},
[]string{"message"}),
}
}
@@ -0,0 +1,360 @@
package runecore
// gittools_test.go — D49's native git family, red-first. The model
// asked for "tools to be able to connect to git projects" — these
// tests pin each tool against a fixture repository in t.TempDir()
// (hermetic: global/system git config null-routed), the porcelain
// parsing, the typed JSON results, and the landing-law banned-path
// refusals on git_add/git_commit (scripts/check_landing.py rule 4).
// The full-loop test proves a CHAT-mode turn (D49: capability in
// every pane) carries the git family on the wire and the typed result
// reaches the model.
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// fixtureRepo builds a hermetic git repository: main branch, one
// initial commit, identity configured locally (global config is
// null-routed per git call).
func fixtureRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
git := func(args ...string) string {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
return string(out)
}
git("init", "-b", "main")
git("config", "user.email", "fixture@example.com")
git("config", "user.name", "Fixture")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("one\n"), 0o644); err != nil {
t.Fatal(err)
}
git("add", "README.md")
git("commit", "-m", "initial")
return dir
}
func decode[T any](t *testing.T, raw string) T {
t.Helper()
var v T
if err := json.Unmarshal([]byte(raw), &v); err != nil {
t.Fatalf("typed JSON did not parse: %v: %q", err, raw)
}
return v
}
func TestGitStatusTool(t *testing.T) {
dir := fixtureRepo(t)
if err := os.WriteFile(filepath.Join(dir, "tracked.txt"), []byte("hello\n"), 0o644); err != nil {
t.Fatal(err)
}
raw, err := gitStatusTool(dir)
if err != nil {
t.Fatalf("git_status: %v", err)
}
st := decode[GitStatusResult](t, raw)
if st.Branch != "main" {
t.Fatalf("branch = %q, want main", st.Branch)
}
if st.Clean {
t.Fatalf("status reports clean with an untracked file: %s", raw)
}
var sawUntracked bool
for _, e := range st.Entries {
if e.Path == "tracked.txt" && strings.Contains(e.Code, "?") {
sawUntracked = true
}
}
if !sawUntracked {
t.Fatalf("untracked file missing from entries: %s", raw)
}
// staged change + ahead count
if _, err := gitRun(dir, 10*time.Second, "add", "tracked.txt"); err != nil {
t.Fatal(err)
}
if _, err := gitRun(dir, 10*time.Second, "commit", "-m", "second"); err != nil {
t.Fatal(err)
}
raw, err = gitStatusTool(dir)
if err != nil {
t.Fatalf("git_status: %v", err)
}
st = decode[GitStatusResult](t, raw)
if !st.Clean {
t.Fatalf("status not clean after commit: %s", raw)
}
if err := os.WriteFile(filepath.Join(dir, "tracked.txt"), []byte("changed\n"), 0o644); err != nil {
t.Fatal(err)
}
raw, err = gitStatusTool(dir)
if err != nil {
t.Fatalf("git_status: %v", err)
}
st = decode[GitStatusResult](t, raw)
if len(st.Entries) != 1 || st.Entries[0].Path != "tracked.txt" {
t.Fatalf("entries = %+v, want tracked.txt modified", st.Entries)
}
}
func TestGitDiffTool(t *testing.T) {
dir := fixtureRepo(t)
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("one\ntwo\n"), 0o644); err != nil {
t.Fatal(err)
}
// unstaged: names form (default = both sections)
raw, err := gitDiffTool(dir, "names", false, false)
if err != nil {
t.Fatalf("git_diff names: %v", err)
}
d := decode[GitDiffResult](t, raw)
if len(d.Unstaged) != 1 || d.Unstaged[0].Path != "README.md" || d.Unstaged[0].Change != "M" {
t.Fatalf("unstaged names = %+v", d.Unstaged)
}
if d.Staged != nil {
t.Fatalf("staged section should be empty, got %+v", d.Staged)
}
// staged: names form, staged scope only
if _, err := gitRun(dir, 10*time.Second, "add", "README.md"); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("one\ntwo\nthree\n"), 0o644); err != nil {
t.Fatal(err)
}
raw, err = gitDiffTool(dir, "names", true, true)
if err != nil {
t.Fatalf("git_diff staged names: %v", err)
}
d = decode[GitDiffResult](t, raw)
if len(d.Staged) != 1 || d.Staged[0].Path != "README.md" {
t.Fatalf("staged names = %+v", d.Staged)
}
// patch form: hunk headers present, both sections labeled
raw, err = gitDiffTool(dir, "patch", false, false)
if err != nil {
t.Fatalf("git_diff patch: %v", err)
}
d = decode[GitDiffResult](t, raw)
if !strings.Contains(d.Patch, "@@") || !strings.Contains(d.Patch, "+three") {
t.Fatalf("patch missing hunks: %q", d.Patch)
}
if !strings.Contains(d.Patch, "=== unstaged ===") || !strings.Contains(d.Patch, "=== staged ===") {
t.Fatalf("patch sections unlabeled: %q", d.Patch)
}
}
func TestGitLogTool(t *testing.T) {
dir := fixtureRepo(t)
if err := os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := gitRun(dir, 10*time.Second, "add", "b.txt"); err != nil {
t.Fatal(err)
}
if _, err := gitRun(dir, 10*time.Second, "commit", "-m", "second commit"); err != nil {
t.Fatal(err)
}
raw, err := gitLogTool(dir, 1)
if err != nil {
t.Fatalf("git_log: %v", err)
}
lg := decode[GitLogResult](t, raw)
if len(lg.Commits) != 1 {
t.Fatalf("limit=1 returned %d commits", len(lg.Commits))
}
if lg.Commits[0].Subject != "second commit" || len(lg.Commits[0].Sha) != 40 || lg.Commits[0].Short == "" {
t.Fatalf("commit entry = %+v", lg.Commits[0])
}
raw, err = gitLogTool(dir, 0) // default limit
if err != nil {
t.Fatalf("git_log default: %v", err)
}
lg = decode[GitLogResult](t, raw)
if len(lg.Commits) != 2 {
t.Fatalf("default limit returned %d commits, want both", len(lg.Commits))
}
}
func TestGitLogToolEmptyRepo(t *testing.T) {
// an honest empty answer for a repo with no commits — never a raw
// git error the model cannot act on
dir := t.TempDir()
cmd := exec.Command("git", "-C", dir, "init", "-b", "main")
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("init: %v: %s", err, out)
}
raw, err := gitLogTool(dir, 10)
if err != nil {
t.Fatalf("git_log on empty repo: %v", err)
}
lg := decode[GitLogResult](t, raw)
if len(lg.Commits) != 0 {
t.Fatalf("empty repo returned %+v", lg.Commits)
}
}
func TestGitBranchTool(t *testing.T) {
dir := fixtureRepo(t)
if _, err := gitRun(dir, 10*time.Second, "branch", "feature"); err != nil {
t.Fatal(err)
}
raw, err := gitBranchTool(dir)
if err != nil {
t.Fatalf("git_branch: %v", err)
}
br := decode[GitBranchResult](t, raw)
if br.Current != "main" {
t.Fatalf("current = %q, want main", br.Current)
}
found := map[string]bool{}
for _, b := range br.Branches {
found[b] = true
}
if !found["main"] || !found["feature"] {
t.Fatalf("branches = %v, want main + feature", br.Branches)
}
}
func TestGitAddAndCommitTools(t *testing.T) {
dir := fixtureRepo(t)
if err := os.WriteFile(filepath.Join(dir, "made.txt"), []byte("work\n"), 0o644); err != nil {
t.Fatal(err)
}
raw, err := gitAddTool(dir, []string{"made.txt"})
if err != nil {
t.Fatalf("git_add: %v", err)
}
add := decode[GitAddResult](t, raw)
if len(add.Added) != 1 || add.Added[0] != "made.txt" {
t.Fatalf("added = %+v", add.Added)
}
raw, err = gitCommitTool(dir, "add made.txt")
if err != nil {
t.Fatalf("git_commit: %v", err)
}
cm := decode[GitCommitResult](t, raw)
if !cm.Committed || len(cm.Sha) != 40 || cm.Subject != "add made.txt" {
t.Fatalf("commit result = %s", raw)
}
// the commit really landed
raw, err = gitLogTool(dir, 1)
if err != nil {
t.Fatal(err)
}
lg := decode[GitLogResult](t, raw)
if lg.Commits[0].Subject != "add made.txt" {
t.Fatalf("log head = %+v", lg.Commits[0])
}
}
func TestGitAddToolRequiresPaths(t *testing.T) {
dir := fixtureRepo(t)
if _, err := gitAddTool(dir, nil); err == nil || !strings.Contains(err.Error(), "paths required") {
t.Fatalf("git_add with no paths must be refused, got %v", err)
}
}
func TestGitCommitToolRequiresMessage(t *testing.T) {
dir := fixtureRepo(t)
if _, err := gitCommitTool(dir, " "); err == nil || !strings.Contains(err.Error(), "message required") {
t.Fatalf("git_commit with blank message must be refused, got %v", err)
}
}
func TestGitToolsRefuseBannedLandingPaths(t *testing.T) {
// The landing-law banned paths (check_landing.py rule 4's
// SWEEP_RACE_BANNED_PATHS) never enter the index or a commit —
// live store state the sweep mutates, three 102MB accidents.
dir := fixtureRepo(t)
banned := filepath.Join(dir, "store", "scaffold")
if err := os.MkdirAll(banned, 0o755); err != nil {
t.Fatal(err)
}
bannedFile := filepath.Join(banned, "current.json")
if err := os.WriteFile(bannedFile, []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := gitAddTool(dir, []string{"store/scaffold/current.json"}); err == nil || !strings.Contains(err.Error(), "banned landing-law path") {
t.Fatalf("git_add of a banned path must be refused, got %v", err)
}
// staged through some other road, git_commit still refuses
if _, err := gitRun(dir, 10*time.Second, "add", "store/scaffold/current.json"); err != nil {
t.Fatal(err)
}
if _, err := gitCommitTool(dir, "smuggle the store state"); err == nil || !strings.Contains(err.Error(), "banned landing-law path") {
t.Fatalf("git_commit with a banned staged path must be refused, got %v", err)
}
// and nothing was committed
raw, err := gitStatusTool(dir)
if err != nil {
t.Fatal(err)
}
st := decode[GitStatusResult](t, raw)
if st.Clean {
t.Fatal("repo reports clean — the banned file was committed or lost")
}
}
func TestGitToolsRefuseOversizedBlob(t *testing.T) {
// The 20MB landing-law ceiling (check_landing.py rule 4,
// MAX_LANDING_FILE_BYTES) is enforced at the git_add door too.
dir := fixtureRepo(t)
big := filepath.Join(dir, "big.bin")
if err := os.WriteFile(big, make([]byte, 21*1024*1024), 0o644); err != nil {
t.Fatal(err)
}
if _, err := gitAddTool(dir, []string{"big.bin"}); err == nil || !strings.Contains(err.Error(), "ceiling") {
t.Fatalf("git_add of an oversized blob must be refused, got %v", err)
}
}
func TestGitFamilyRidesTheChatWire(t *testing.T) {
// D49 red-first: the git family is exposed to the model in CHAT
// mode (capability in every pane) and a git_status call's typed
// result reaches the model in the tool loop.
t.Setenv("RUNE_MODE", "CHAT")
t.Setenv("RUNE_REPO_ROOT", fixtureRepo(t))
if err := os.WriteFile(filepath.Join(os.Getenv("RUNE_REPO_ROOT"), "wire.txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
fm := &fakeToolModel{}
fm.script = []ChatReply{
{Content: "", ToolCalls: []ToolCall{{Name: "git_status", Args: map[string]any{}}}},
{Content: "there are changes", Model: "m"},
}
e, _ := newTestEngine(t, fm)
if _, err := e.Turn(context.Background(), TurnRequest{Channel: "chat", UserText: "how is the repo?"}); err != nil {
t.Fatalf("turn: %v", err)
}
names := toolNames(fm.toolsets[0])
for _, want := range []string{"git_status", "git_diff", "git_log", "git_branch", "git_add", "git_commit"} {
if !has(names, want) {
t.Fatalf("D49: %s must ride the CHAT wire, got: %v", want, names)
}
}
var seen bool
for _, m := range fm.payloadLog[1] {
if m.Role == "tool" && strings.Contains(m.Content, "wire.txt") && strings.Contains(m.Content, `"success":true`) {
seen = true
}
}
if !seen {
var dump, _ = json.Marshal(fm.payloadLog[1])
t.Fatalf("the typed git_status result never reached the model: %s", dump)
}
}
+107 -6
View File
@@ -8,6 +8,7 @@ package runecore
// same endpoint, same envelope, stdlib HTTP.
import (
"bufio"
"bytes"
"context"
"encoding/json"
@@ -22,22 +23,27 @@ import (
type OllamaClient struct {
baseURL string
client *http.Client
// streamClient carries NO whole-call Timeout: the streaming leg is
// governed by the inter-chunk idle watch (Client.Timeout caps body
// reads too — a legitimate long stream would be cut mid-answer).
streamClient *http.Client
}
// DefaultOllamaURL is the Python adapter's default endpoint.
const DefaultOllamaURL = "http://localhost:11434"
// NewOllamaClient builds the client. Empty base URL falls back to the
// default. The chat budget mirrors the chain's law (>=330s — the
// 27B-class answer window plus headroom, the same number the TUI
// completion client and the entry proxy carry).
// NewOllamaClient builds the client. The non-streaming client's chat
// budget mirrors the chain's law (>=330s — the 27B-class answer window
// plus headroom, the same number the TUI completion client and the
// entry proxy carry); the streaming client leans on the idle watch.
func NewOllamaClient(baseURL string) *OllamaClient {
if baseURL == "" {
baseURL = DefaultOllamaURL
}
return &OllamaClient{
baseURL: strings.TrimSuffix(baseURL, "/"),
client: &http.Client{Timeout: 330 * time.Second},
baseURL: strings.TrimSuffix(baseURL, "/"),
client: &http.Client{Timeout: 330 * time.Second},
streamClient: &http.Client{},
}
}
@@ -233,6 +239,101 @@ func clipStr(s string, n int) string {
return s[:n]
}
// ollamaStreamChunk is one NDJSON line of a streamed /api/chat: a
// message/token chunk (content and/or thinking and/or tool_calls) or
// the terminal done:true chunk carrying the token accounting.
type ollamaStreamChunk struct {
Model string `json:"model"`
Message struct {
Role string `json:"role"`
Content string `json:"content"`
Thinking *string `json:"thinking"`
ToolCalls []ollamaToolCall `json:"tool_calls"`
} `json:"message"`
Done bool `json:"done"`
PromptEvalCount int `json:"prompt_eval_count"`
EvalCount int `json:"eval_count"`
}
// StreamChatWithTools is the streaming leg (Gitea #2556): stream=true
// /api/chat, NDJSON chunks parsed incrementally, every content and
// thinking byte surfaced through sink AS IT ARRIVES; the final reply
// carries the assembled content + thinking + tool_calls exactly like
// the non-streaming leg's. The inter-chunk idle watch replaces the
// flat deadline: no bytes for the idle budget = ErrStreamIdle; the
// 30-minute backstop is the runaway guard.
func (c *OllamaClient) StreamChatWithTools(ctx context.Context, model string, msgs []ChatMessage, tools []ToolDef, sink StreamSink) (ChatReply, error) {
idle := chunkIdleTimeout()
w := watchIdle(ctx, idle, StreamTurnBackstop)
defer w.stop()
body, err := json.Marshal(ollamaChatRequest{Model: model, Messages: msgs, Stream: true, Tools: tools})
if err != nil {
return ChatReply{}, err
}
req, err := http.NewRequestWithContext(w.ctx, http.MethodPost, c.baseURL+"/api/chat", bytes.NewReader(body))
if err != nil {
return ChatReply{}, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.streamClient.Do(req)
if err != nil {
return ChatReply{}, w.err(fmt.Errorf("ollama: %w", err), idle)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return ChatReply{}, fmt.Errorf("ollama /api/chat: HTTP %d: %s", resp.StatusCode, clipStr(string(raw), 300))
}
var content, thinking strings.Builder
var toolCalls []ToolCall
var last ollamaStreamChunk
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
w.reset(idle)
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
var chunk ollamaStreamChunk
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
return ChatReply{}, fmt.Errorf("ollama /api/chat stream decode: %w", err)
}
last = chunk
if chunk.Message.Content != "" {
content.WriteString(chunk.Message.Content)
emit(sink, StreamDelta{Kind: "text", Text: chunk.Message.Content})
}
if chunk.Message.Thinking != nil && *chunk.Message.Thinking != "" {
thinking.WriteString(*chunk.Message.Thinking)
emit(sink, StreamDelta{Kind: "thinking", Text: *chunk.Message.Thinking})
}
for _, tc := range chunk.Message.ToolCalls {
call := ToolCall{Name: tc.Function.Name}
if len(tc.Function.Arguments) > 0 {
var m map[string]any
if json.Unmarshal(tc.Function.Arguments, &m) == nil {
call.Args = m
}
}
toolCalls = append(toolCalls, call)
}
}
if err := sc.Err(); err != nil {
return ChatReply{}, w.err(fmt.Errorf("ollama stream read: %w", err), idle)
}
visible, inline := splitInlineThinking(content.String())
reply := ChatReply{Content: visible, Model: last.Model, ContextUsed: last.PromptEvalCount}
reply.ToolCalls = toolCalls
reply.ContextLimit = c.contextLimit(ctx, model)
if thinking.Len() > 0 {
reply.Thinking = []string{thinking.String()}
} else if inline != "" {
reply.Thinking = []string{inline}
}
return reply, nil
}
// showCapabilities asks /api/show for the model's capabilities list
// ("tools", "thinking", ...). Fail-soft: error → nil.
func (c *OllamaClient) showCapabilities(ctx context.Context, model string) ([]string, error) {
+94 -27
View File
@@ -217,6 +217,11 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
channel = "task" // the operator default; the core never invents a channel
}
if req.Stream {
s.streamCompletion(w, r, req.Model, channel, userText)
return
}
res, err := s.engine.Turn(r.Context(), TurnRequest{
Channel: channel, UserText: userText, Model: req.Model,
})
@@ -255,10 +260,6 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
}
id := newCompletionID()
created := time.Now().UTC().Unix()
if req.Stream {
s.writeChunks(w, id, created, res.Model, assistantText, runeBlk)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"id": id,
"object": "chat.completion",
@@ -276,39 +277,105 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
})
}
// writeChunks answers stream=true the Python core's framing: a content
// chunk, then the terminal chunk carrying finish_reason and the rune
// block (the front door flushes each line).
func (s *Server) writeChunks(w http.ResponseWriter, id string, created int64, model, content string, runeBlk map[string]any) {
w.Header().Set("Content-Type", "application/x-ndjson")
w.WriteHeader(http.StatusOK)
contentChunk := map[string]any{
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
"choices": []any{map[string]any{
"index": 0,
"delta": map[string]any{"role": "assistant", "content": content},
"finish_reason": nil,
}},
// streamCompletion answers stream=true the Python core's framing with
// the model's bytes relayed AS THEY ARRIVE (Gitea #2556 — the turn
// never sits inside one blocking call server-side): a content chunk
// per streamed text delta, then the terminal chunk carrying
// finish_reason and the rune block. An error before the first byte
// answers the honest error envelope; an error mid-stream answers an
// error terminal chunk (success=false — the stream shape stays valid).
func (s *Server) streamCompletion(w http.ResponseWriter, r *http.Request, model, channel, userText string) {
id := newCompletionID()
created := time.Now().UTC().Unix()
header := false
deltas := false
writeHeader := func() {
if !header {
w.Header().Set("Content-Type", "application/x-ndjson")
w.WriteHeader(http.StatusOK)
header = true
}
}
terminal := map[string]any{
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
"choices": []any{map[string]any{
"index": 0, "delta": map[string]any{}, "finish_reason": "stop",
}},
"rune": runeBlk,
}
for _, chunk := range []map[string]any{contentChunk, terminal} {
writeChunk := func(chunk map[string]any) bool {
line, err := json.Marshal(chunk)
if err != nil {
return
return false
}
if _, err := w.Write(append(line, '\n')); err != nil {
return
return false
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
return true
}
chunkFor := func(delta string) map[string]any {
return map[string]any{
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
"choices": []any{map[string]any{
"index": 0,
"delta": map[string]any{"role": "assistant", "content": delta},
"finish_reason": nil,
}},
}
}
sink := func(d StreamDelta) {
if d.Kind != "text" || d.Text == "" {
return
}
deltas = true
writeHeader()
writeChunk(chunkFor(d.Text))
}
res, err := s.engine.TurnStream(r.Context(), TurnRequest{
Channel: channel, UserText: userText, Model: model,
}, sink)
if res.ContextLimit > 0 {
s.setContext(res.ContextUsed, res.ContextLimit)
}
runeBlk := map[string]any{
"success": err == nil,
"user_text": res.AssistantText,
"model": res.Model,
"error": "",
"channel": channel,
"thinking": res.Thinking,
"messages": map[string]string{"user": res.UserMsgID, "assistant": res.AssistantMsgID},
"core": "go",
}
if err != nil {
runeBlk["error"] = clipStr(err.Error(), 300)
}
if err != nil && !header {
var pe *ProviderError
switch {
case errors.Is(err, ErrChannelBusy):
errorEnvelope(w, http.StatusConflict, "channel_busy", err.Error())
case errors.Is(err, ErrNoProvider):
errorEnvelope(w, http.StatusServiceUnavailable, "no_provider", err.Error())
case errors.Is(err, ErrProviderUnrouted):
errorEnvelope(w, http.StatusServiceUnavailable, "provider_unrouted", clipStr(err.Error(), 300))
case errors.Is(err, ErrProviderKeyMissing):
errorEnvelope(w, http.StatusServiceUnavailable, "provider_key_missing", clipStr(err.Error(), 300))
case errors.As(err, &pe):
errorEnvelope(w, http.StatusBadGateway, "provider_error", clipStr(err.Error(), 300))
default:
errorEnvelope(w, http.StatusInternalServerError, "internal_error", clipStr(err.Error(), 200))
}
return
}
writeHeader()
// the fallback law: a client that couldn't stream (or a non-streaming
// provider leg) still answers the full assembled content in ONE
// chunk — the classic framing, nothing dropped.
if !deltas {
writeChunk(chunkFor(res.AssistantText))
}
terminal := chunkFor("")
terminal["choices"].([]any)[0].(map[string]any)["delta"] = map[string]any{}
terminal["choices"].([]any)[0].(map[string]any)["finish_reason"] = "stop"
terminal["rune"] = runeBlk
writeChunk(terminal)
}
// setContext records the last turn's context accounting (D44 readout).
@@ -0,0 +1,162 @@
package runecore
// stream.go — the streaming root-cause wave (Gitea #2556, owner
// verdict: the flat whole-turn budgets patched the symptom; the root
// cause is ONE long-blocking HTTP call per model turn). A streamed
// turn never hits a deadline because bytes arrive continuously: the
// deadline collapses to an INTER-CHUNK IDLE TIMEOUT (no bytes for X
// seconds = genuinely dead) plus a generous overall backstop. The
// 330s/480s/6x480s budget constants remain only for the non-streaming
// fallback paths (clients that do not stream, the cap-out
// withdrawn-tools call, and legacy callers).
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"strconv"
"strings"
"time"
)
// DefaultChunkIdleTimeout is the inter-chunk idle budget on every
// streamed model call: no bytes for this long = the model is
// genuinely dead (the old deadline's honest replacement).
const DefaultChunkIdleTimeout = 120 * time.Second
// StreamTurnBackstop is the overall cap on one streamed turn — sized
// generously (30 minutes) so only a runaway chain trips it; the idle
// timeout is the real guardian.
const StreamTurnBackstop = 30 * time.Minute
// ChunkIdleTimeoutEnv is the env knob that resizes the inter-chunk
// idle budget (seconds; an unparsable or non-positive value keeps the
// default — the knob never disables the guard).
const ChunkIdleTimeoutEnv = "RUNE_STREAM_IDLE_TIMEOUT"
// ErrStreamIdle is the typed dead-model refusal: the stream produced
// no bytes for the whole idle budget.
var ErrStreamIdle = errors.New("stream idle: no model bytes within the inter-chunk idle budget")
// chunkIdleTimeout resolves the inter-chunk idle budget: the env knob
// wins when it parses to a positive number of seconds; the default
// otherwise.
func chunkIdleTimeout() time.Duration {
if v := strings.TrimSpace(os.Getenv(ChunkIdleTimeoutEnv)); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return time.Duration(n) * time.Second
}
}
return DefaultChunkIdleTimeout
}
// idleWatch enforces the inter-chunk idle timeout on one streamed
// call. The client calls reset() after EVERY chunk read; the watch
// cancels the derived ctx when the budget lapses. The error surfaces
// through the ctx as ErrStreamIdle (not a bare context.Canceled), so
// the honest dead-model reason reaches the caller.
type idleWatch struct {
ctx context.Context
timer *time.Timer
fired bool
cancelAll context.CancelFunc
}
// watchIdle derives an idle-enforced ctx from the caller's — the same
// cancel context carries the backstop deadline; the idle fire is
// distinguished by the fired flag (the honest dead-model error).
func watchIdle(parent context.Context, idle, backstop time.Duration) *idleWatch {
ctx, cancelAll := context.WithTimeout(parent, backstop)
w := &idleWatch{ctx: ctx, cancelAll: cancelAll}
w.timer = time.AfterFunc(idle, func() {
w.fired = true
cancelAll()
})
return w
}
// reset arms the idle budget afresh — called after every chunk.
func (w *idleWatch) reset(idle time.Duration) {
w.timer.Reset(idle)
}
// stop disarms the watch (completion or error) and releases the
// context.
func (w *idleWatch) stop() {
w.timer.Stop()
w.cancelAll()
}
// err maps a stream-read failure onto the honest typed error: the
// idle fire wins (dead model), the backstop's deadline reads as
// itself, everything else passes through.
func (w *idleWatch) err(readErr error, idle time.Duration) error {
if w.fired {
return fmt.Errorf("%w (%s)", ErrStreamIdle, idle)
}
if readErr != nil {
if errors.Is(readErr, context.DeadlineExceeded) {
return fmt.Errorf("stream turn backstop exceeded (%s)", StreamTurnBackstop)
}
return readErr
}
return w.ctx.Err()
}
// StreamDelta is one incremental model byte: visible answer text
// ("text") or surfaced reasoning ("thinking").
type StreamDelta struct {
Kind string // "text" | "thinking"
Text string
}
// StreamSink consumes deltas as they arrive (never after the call
// returns; nil-safe call sites).
type StreamSink func(StreamDelta)
// StreamChatClient is the streaming leg of the model boundary: the
// same ChatWithTools semantics (the engine's bounded tool loop is
// unchanged) with the answer's bytes surfaced through sink as they
// arrive. The final ChatReply carries the assembled content, thinking
// and tool_calls exactly as the non-streaming leg's does. The client
// owns the idle enforcement (it reads the wire); every implementation
// derives its read ctx through watchIdle.
type StreamChatClient interface {
ToolChatClient
StreamChatWithTools(ctx context.Context, model string, msgs []ChatMessage, tools []ToolDef, sink StreamSink) (ChatReply, error)
}
// emit is the nil-safe sink call.
func emit(sink StreamSink, d StreamDelta) {
if sink != nil {
sink(d)
}
}
// scanSSE reads an SSE body line-wise: every "data:" line's payload
// goes to fn; "data: [DONE]" terminates (per the [OI] SSE framing).
// Comment lines (":" prefix) and empty lines are skipped. The caller's
// read failures pass through (the idle watch maps them).
func scanSSE(body *bufio.Reader, fn func(data string) error) error {
for {
line, err := body.ReadString('\n')
trimmed := strings.TrimRight(line, "\r\n")
if trimmed != "" && !strings.HasPrefix(trimmed, ":") {
if payload, ok := strings.CutPrefix(trimmed, "data: "); ok {
if strings.TrimSpace(payload) == "[DONE]" {
return nil
}
if err := fn(payload); err != nil {
return err
}
} else if payload, ok := strings.CutPrefix(trimmed, "data:"); ok && strings.TrimSpace(payload) == "[DONE]" {
return nil
}
}
if err != nil {
return err
}
}
}
@@ -0,0 +1,314 @@
package runecore
// stream_test.go — the streaming root-cause wave's frozen proofs
// (Gitea #2556, red-first): the Ollama NDJSON stream parses
// incrementally and answers the assembled reply; the INTER-CHUNK IDLE
// TIMEOUT replaces the flat deadline (a slow model that keeps sending
// bytes finishes no matter how long it takes; a dead model trips the
// idle error); the engine's bounded tool loop runs over streamed
// replies; the server relays the streamed turn chunk-by-chunk.
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// fakeStreamClient is a StreamChatClient for the engine loop proofs.
type fakeStreamClient struct {
calls int
scripts []struct {
reply ChatReply
deltas []StreamDelta
}
thinks bool
sunk []StreamDelta
}
func (f *fakeStreamClient) add(reply ChatReply, deltas ...StreamDelta) {
f.scripts = append(f.scripts, struct {
reply ChatReply
deltas []StreamDelta
}{reply, deltas})
}
func (f *fakeStreamClient) SupportsTools(context.Context, string) bool { return f.thinks }
func (f *fakeStreamClient) ChatWithTools(ctx context.Context, model string, msgs []ChatMessage, tools []ToolDef) (ChatReply, error) {
return f.Chat(ctx, model, msgs)
}
func (f *fakeStreamClient) Chat(ctx context.Context, model string, msgs []ChatMessage) (ChatReply, error) {
f.calls++
return f.script().reply, nil
}
func (f *fakeStreamClient) script() (out struct {
reply ChatReply
deltas []StreamDelta
}) {
i := f.calls - 1
if i >= len(f.scripts) {
i = len(f.scripts) - 1
}
return f.scripts[i]
}
func (f *fakeStreamClient) StreamChatWithTools(ctx context.Context, model string, msgs []ChatMessage, tools []ToolDef, sink StreamSink) (ChatReply, error) {
f.calls++
s := f.script()
for _, d := range s.deltas {
f.sunk = append(f.sunk, d)
emit(sink, d)
}
return s.reply, nil
}
func (f *fakeStreamClient) Tags(context.Context) ([]ModelInfo, error) { return nil, nil }
// ollamaStreamServer is a hermetic /api/chat double: lines written on
// schedule (delay after each), the final done:true chunk closing.
func ollamaStreamServer(t *testing.T, lines []string, perLine time.Duration) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/api/show") {
_, _ = w.Write([]byte(`{"capabilities":["tools","thinking"]}`))
return
}
var req ollamaChatRequest
_ = json.NewDecoder(r.Body).Decode(&req)
if !req.Stream {
t.Errorf("ollama stream double: request did not carry stream=true")
}
w.Header().Set("Content-Type", "application/x-ndjson")
for i, line := range lines {
_, _ = w.Write([]byte(line + "\n"))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
if i < len(lines)-1 && perLine > 0 {
time.Sleep(perLine)
}
}
}))
}
func ollamaChunk(content string) string {
return `{"model":"q","message":{"role":"assistant","content":` + jsonString(content) + `},"done":false}`
}
// TestOllamaStreamAssemblesReply — the NDJSON chunks parse
// incrementally; every text byte surfaces through the sink in wire
// order; the final reply carries the assembled content + thinking and
// the token accounting from the done chunk.
func TestOllamaStreamAssemblesReply(t *testing.T) {
srv := ollamaStreamServer(t, []string{
ollamaChunk("hel"),
ollamaChunk("lo "),
`{"model":"q","message":{"role":"assistant","thinking":"pondering"},"done":false}`,
ollamaChunk("world"),
`{"model":"q","message":{"role":"assistant"},"done":true,"prompt_eval_count":42,"eval_count":7}`,
}, 0)
c := NewOllamaClient(srv.URL)
var got []StreamDelta
reply, err := c.StreamChatWithTools(context.Background(), "q", []ChatMessage{{Role: "user", Content: "hi"}}, nil, func(d StreamDelta) { got = append(got, d) })
if err != nil {
t.Fatal(err)
}
if reply.Content != "hello world" {
t.Fatalf("content = %q", reply.Content)
}
if len(reply.Thinking) != 1 || reply.Thinking[0] != "pondering" {
t.Fatalf("thinking = %v", reply.Thinking)
}
if reply.ContextUsed != 42 {
t.Fatalf("context used = %d", reply.ContextUsed)
}
var text strings.Builder
for _, d := range got {
if d.Kind != "text" {
continue
}
text.WriteString(d.Text)
}
if text.String() != "hello world" {
t.Fatalf("deltas = %q", text.String())
}
}
// TestOllamaStreamToolCalls — tool_calls riding the stream parse into
// the reply (the loop's wire shape, streamed).
func TestOllamaStreamToolCalls(t *testing.T) {
srv := ollamaStreamServer(t, []string{
`{"model":"q","message":{"role":"assistant","tool_calls":[{"function":{"name":"read_file","arguments":{"path":"x"}}}]},"done":false}`,
`{"model":"q","message":{"role":"assistant"},"done":true}`,
}, 0)
c := NewOllamaClient(srv.URL)
reply, err := c.StreamChatWithTools(context.Background(), "q", []ChatMessage{{Role: "user", Content: "hi"}}, nil, nil)
if err != nil {
t.Fatal(err)
}
if len(reply.ToolCalls) != 1 || reply.ToolCalls[0].Name != "read_file" {
t.Fatalf("tool calls = %+v", reply.ToolCalls)
}
if reply.ToolCalls[0].Args["path"] != "x" {
t.Fatalf("args = %+v", reply.ToolCalls[0].Args)
}
}
// TestOllamaStreamSlowModelFinishes — the root-cause proof: a model
// that emits a chunk every 200ms across a window LONGER than the old
// flat per-call budgets would ever allow (scaled 1:25 from the live
// 5s-for-10-minutes shape; the mechanism is identical — bytes keep
// arriving, the turn completes, no deadline fires).
func TestOllamaStreamSlowModelFinishes(t *testing.T) {
t.Setenv(ChunkIdleTimeoutEnv, "1") // 1s idle budget; chunks every 200ms
var lines []string
for i := 0; i < 12; i++ {
lines = append(lines, ollamaChunk(fmt.Sprintf("chunk%d ", i)))
}
lines = append(lines, `{"model":"q","message":{"role":"assistant"},"done":true}`)
srv := ollamaStreamServer(t, lines, 200*time.Millisecond)
defer srv.Close()
c := NewOllamaClient(srv.URL)
reply, err := c.StreamChatWithTools(context.Background(), "q", []ChatMessage{{Role: "user", Content: "hi"}}, nil, nil)
if err != nil {
t.Fatalf("slow streamed turn failed: %v", err)
}
if !strings.Contains(reply.Content, "chunk11") {
t.Fatalf("content = %q", reply.Content)
}
}
// TestOllamaStreamIdleTimeout — the dead model: bytes stop; the idle
// budget lapses; the typed ErrStreamIdle fires (never a hang).
func TestOllamaStreamIdleTimeout(t *testing.T) {
t.Setenv(ChunkIdleTimeoutEnv, "1")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(ollamaChunk("first") + "\n"))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
<-r.Context().Done() // then silence forever
}))
defer srv.Close()
c := NewOllamaClient(srv.URL)
start := time.Now()
_, err := c.StreamChatWithTools(context.Background(), "q", []ChatMessage{{Role: "user", Content: "hi"}}, nil, nil)
if !errors.Is(err, ErrStreamIdle) {
t.Fatalf("err = %v, want ErrStreamIdle", err)
}
if time.Since(start) > 5*time.Second {
t.Fatalf("idle timeout took %v — not the 1s budget", time.Since(start))
}
}
// TestEngineStreamedToolLoop — the bounded tool loop runs over
// streamed replies: call 1 asks for a tool, call 2 answers in text;
// the loop's iteration discipline is unchanged and the deltas flowed.
func TestEngineStreamedToolLoop(t *testing.T) {
fc := &fakeStreamClient{thinks: true}
fc.add(ChatReply{Content: "", ToolCalls: []ToolCall{{Name: "list_directory", Args: map[string]any{"path": "."}}}},
StreamDelta{Kind: "text", Text: "thinking..."})
fc.add(ChatReply{Content: "done answering"},
StreamDelta{Kind: "text", Text: "done answering"})
e, _ := newTestEngine(t, fc)
var got []StreamDelta
res, err := e.TurnStream(context.Background(), TurnRequest{Channel: "chat", UserText: "list files"}, func(d StreamDelta) { got = append(got, d) })
if err != nil {
t.Fatal(err)
}
if res.AssistantText != "done answering" {
t.Fatalf("answer = %q", res.AssistantText)
}
if fc.calls != 2 {
t.Fatalf("model calls = %d, want the loop's 2", fc.calls)
}
if len(got) != 2 {
t.Fatalf("deltas = %d, want both legs streamed", len(got))
}
}
// TestServerStreamsChunksLive — stream=true relays the model's bytes
// AS THEY ARRIVE: the first chunk lands while the model is still
// emitting (never one blocking call server-side).
func TestServerStreamsChunksLive(t *testing.T) {
t.Setenv(ChunkIdleTimeoutEnv, "5")
srv := ollamaStreamServer(t, []string{
ollamaChunk("live "),
ollamaChunk("answer"),
`{"model":"q","message":{"role":"assistant"},"done":true}`,
}, 300*time.Millisecond)
defer srv.Close()
oc := NewOllamaClient(srv.URL)
e, _ := newTestEngine(t, oc)
core := httptest.NewServer(NewServer(e, oc, e.home).Handler())
defer core.Close()
raw, _ := json.Marshal(map[string]any{
"channel": "chat", "stream": true,
"messages": []map[string]any{{"role": "user", "content": "hi"}},
})
resp, err := http.Post(core.URL+"/v1/chat/completions", "application/json", strings.NewReader(string(raw)))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
start := time.Now()
buf := make([]byte, 4096)
var got strings.Builder
firstAt := time.Time{}
for {
n, rerr := resp.Body.Read(buf)
if n > 0 {
got.Write(buf[:n])
if firstAt.IsZero() && strings.Contains(got.String(), "live") {
firstAt = time.Now()
}
}
if rerr != nil {
break
}
}
if firstAt.IsZero() {
t.Fatalf("no chunk arrived incrementally: %q", got.String())
}
if elapsed := firstAt.Sub(start); elapsed > 200*time.Millisecond {
t.Fatalf("first chunk at %v — the core buffered the stream", elapsed)
}
if !strings.Contains(got.String(), `"finish_reason":"stop"`) {
t.Fatalf("terminal chunk missing: %q", got.String())
}
if !strings.Contains(got.String(), `"success":true`) {
t.Fatalf("rune block missing: %q", got.String())
}
}
// TestServerStreamErrorBeforeFirstByte — a refusal (no provider
// record) on a streamed request answers the honest error envelope,
// never a half-open stream.
func TestServerStreamErrorBeforeFirstByte(t *testing.T) {
oc := NewOllamaClient("http://127.0.0.1:1") // dead local wire
e, _ := newTestEngine(t, oc)
core := httptest.NewServer(NewServer(e, oc, e.home).Handler())
defer core.Close()
raw, _ := json.Marshal(map[string]any{
"channel": "chat", "stream": true,
"messages": []map[string]any{{"role": "user", "content": "hi"}},
})
resp, err := http.Post(core.URL+"/v1/chat/completions", "application/json", strings.NewReader(string(raw)))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), `"error"`) {
t.Fatalf("want the error envelope, got %q", string(body))
}
}
+86 -38
View File
@@ -1,14 +1,21 @@
package runecore
// tools.go — THE TOOLSET GAP (D45 item 5): the Go core's chat path
// surfaces the mode-allowed tool suite to the model and executes the
// model's tool calls mode-gated. Oracle: the Python operator path
// (rune_operator.py _capability_selection → local_tool_runtime.py,
// the T2/T3 wave) — the ONE frozen matrix artifact
// schemas/mode/v1/mode_matrix.json is the authority: CHAT/PLAN expose
// the read family, TASK adds the write family. The dispatch gate stays
// the security boundary: every call re-reads the active mode and a
// mode-disallowed tool executes as a typed tool error, never bytes.
// tools.go — the tool suite the wire carries and the executor runs.
// D45 landed the mode-gated toolset; D49 (owner ruling 2026-10-22,
// "capability in every pane") SUPERSEDES the mode gate: capability is
// inherent in EVERY pane — CHAT/PLAN/TASK all expose the read family,
// the write family AND the native git family, because the model must
// KNOW it can read, write and shell without asking (the owner: "i
// want that ability to be in every pane"). The consent card remains
// the WORK-PROMOTION mechanism (work shows in the tasks pane) — it is
// organizational, never a permission gate. The real safety layer is
// the scaffolding authority + contract gates at mutation doors (the
// landing law). ToolsetFor keeps the per-mode switch (future modes
// may still narrow) and ExecuteGatedTool keeps the dispatch-gate seam
// (future policies hook there; the refusal stays for
// genuinely-unavailable tools) — but no mode withholds the suite
// today. schemas/mode/v1/mode_matrix.json's CHAT=read-only pin is
// retired by this ruling.
import (
"context"
@@ -56,10 +63,12 @@ func toolSpec(name, desc string, props map[string]any, required []string) ToolDe
}}
}
// ToolsetFor returns the mode's tool schemas — the frozen matrix's
// executor_tools ∩ the Go core's implemented ops. CHAT/PLAN: the read
// family. TASK: + write_file, edit_file, run_command. Unknown mode →
// empty (fail closed; the turn runs toolless rather than ungated).
// ToolsetFor returns the mode's tool schemas. D49: every known mode
// carries the FULL suite — read family + write family + git family —
// because capability is inherent in every pane (the owner's ruling;
// the wire must tell the model it can act, not hide the tools and
// make the model beg for shell access). Unknown mode → empty (fail
// closed; the turn runs toolless rather than ungated).
func ToolsetFor(mode string) []ToolDef {
read := []ToolDef{
toolSpec("list_directory", "List the entries of a directory on the user's machine.",
@@ -81,25 +90,25 @@ func ToolsetFor(mode string) []ToolDef {
"include": map[string]any{"type": "string", "description": "filename glob filter"}},
[]string{"pattern"}),
}
write := []ToolDef{
toolSpec("write_file", "Create or overwrite a file on the user's machine. Available in EVERY pane and mode — no elevation needed; sustained work-shaped activity gets promoted to the tasks pane.",
map[string]any{"path": map[string]any{"type": "string"},
"content": map[string]any{"type": "string"}},
[]string{"path", "content"}),
toolSpec("edit_file", "Replace the first occurrence of a search string in a file. Available in EVERY pane and mode — no elevation needed.",
map[string]any{"path": map[string]any{"type": "string"},
"search": map[string]any{"type": "string"},
"replace": map[string]any{"type": "string"}},
[]string{"path", "search", "replace"}),
toolSpec("run_command", "Run a shell command on the user's machine (bash, 60-second timeout). Available in EVERY pane and mode — shell access is inherent; sustained work gets promoted to the tasks pane.",
map[string]any{"command": map[string]any{"type": "string"},
"workdir": map[string]any{"type": "string", "description": "working directory"}},
[]string{"command"}),
}
full := append(append(read, write...), gitToolDefs()...)
switch mode {
case "TASK":
return append(read,
toolSpec("write_file", "Create or overwrite a file on the user's machine.",
map[string]any{"path": map[string]any{"type": "string"},
"content": map[string]any{"type": "string"}},
[]string{"path", "content"}),
toolSpec("edit_file", "Replace the first occurrence of a search string in a file.",
map[string]any{"path": map[string]any{"type": "string"},
"search": map[string]any{"type": "string"},
"replace": map[string]any{"type": "string"}},
[]string{"path", "search", "replace"}),
toolSpec("run_command", "Run a shell command on the user's machine.",
map[string]any{"command": map[string]any{"type": "string"},
"workdir": map[string]any{"type": "string", "description": "working directory"}},
[]string{"command"}),
)
case "CHAT", "PLAN":
return read
case "TASK", "CHAT", "PLAN":
return full
default:
return nil
}
@@ -153,11 +162,14 @@ type toolEnvelope struct {
Error string `json:"error,omitempty"`
}
// ExecuteGatedTool executes ONE model-requested tool against the mode
// gate read FRESH at dispatch (the T3 two-door law: seeing is a
// prompt, the gate is the boundary). A mode-disallowed call returns a
// typed refusal envelope — tool-error data the model can read, never
// an execution.
// ExecuteGatedTool executes ONE model-requested tool, checking the
// mode's suite FRESH at dispatch (the T3 two-door law: seeing is a
// prompt, the gate is the boundary). D49: no mode withholds the write
// family or the git family — writes execute in any mode; the consent
// card promotes work (organization), it never gates tools. The
// dispatch-gate SEAM stays (future policies hook here): a tool
// genuinely outside the mode's suite returns a typed refusal envelope
// — tool-error data the model can read, never an execution.
func ExecuteGatedTool(home, mode, name string, args map[string]any) map[string]any {
if mode == "" {
mode = ActiveMode(home)
@@ -193,9 +205,28 @@ func argInt(args map[string]any, key string) int {
return 0
}
func argBool(args map[string]any, key string) (val, ok bool) {
v, ok := args[key].(bool)
return v, ok
}
func argStrings(args map[string]any, key string) []string {
raw, ok := args[key].([]any)
if !ok {
return nil
}
out := make([]string, 0, len(raw))
for _, v := range raw {
if s, ok := v.(string); ok {
out = append(out, s)
}
}
return out
}
// executeGoTool runs the tool in-process (stdlib only — the Go core's
// zero-dep split law). Paths are home-expanded; every op is read-only
// except the TASK-only write family.
// zero-dep split law). Paths are home-expanded. The git family shells
// out to git in DefaultRepoRoot() (gittools.go).
func executeGoTool(name string, args map[string]any) map[string]any {
env := func(res string, err error) map[string]any {
if err != nil {
@@ -220,6 +251,23 @@ func executeGoTool(name string, args map[string]any) map[string]any {
return env(editFileTool(argStr(args, "path", ""), argStr(args, "search", ""), argStr(args, "replace", "")))
case "run_command":
return env(runCommandTool(argStr(args, "command", ""), argStr(args, "workdir", "")))
case "git_status":
return env(gitStatusTool(DefaultRepoRoot()))
case "git_diff":
staged, hasStaged := argBool(args, "staged")
return env(gitDiffTool(DefaultRepoRoot(), argStr(args, "form", "names"), hasStaged, staged))
case "git_log":
limit := argInt(args, "limit")
if limit == 0 {
limit = 10
}
return env(gitLogTool(DefaultRepoRoot(), limit))
case "git_branch":
return env(gitBranchTool(DefaultRepoRoot()))
case "git_add":
return env(gitAddTool(DefaultRepoRoot(), argStrings(args, "paths")))
case "git_commit":
return env(gitCommitTool(DefaultRepoRoot(), argStr(args, "message", "")))
default:
return map[string]any{"success": false, "error": fmt.Sprintf("unknown tool %q", name)}
}
+108 -51
View File
@@ -1,12 +1,11 @@
package runecore
// tools_test.go — THE TOOLSET GAP (D45 item 5, red-first): the Go core
// (the default live core, ensure_core.py's supervision target) must
// surface the mode-allowed tool suite to the model on the chat path
// and execute the model's tool calls mode-gated. The Python operator
// path (local_tool_runtime.py) landed this (T2/T3); the engine (D39)
// predates it and called the model exactly once with no tools — the
// owner's live defect ("I cannot directly access your desktop").
// tools_test.go — the toolset tests. D45 landed the mode-gated suite;
// D49 (owner ruling 2026-10-22, "capability in every pane") SUPERSEDES
// the mode gate: the write family and the git family are inherent in
// CHAT/PLAN/TASK alike — the consent card promotes work to the tasks
// pane (organization), it never gates tools (permission). The dispatch
// gate stays as the seam for genuinely-unavailable tools.
import (
"context"
@@ -67,9 +66,11 @@ func has(names []string, want string) bool {
return false
}
func TestChatTurnExposesReadToolsInChatMode(t *testing.T) {
// D45: a CHAT-mode turn's model must SEE the read tools
// (list_directory/read_file at minimum) and never the write family.
func TestChatTurnExposesFullSuiteInChatMode(t *testing.T) {
// D49: a CHAT-mode turn's model must SEE the full suite — read
// tools, the write family AND the git family (capability is
// inherent in every pane; the pane organizes visibility, not
// capability).
t.Setenv("RUNE_MODE", "CHAT")
fm := &fakeToolModel{}
fm.replies = map[string]string{"": "done"}
@@ -83,19 +84,49 @@ func TestChatTurnExposesReadToolsInChatMode(t *testing.T) {
t.Fatal("model was called with no toolset — the toolset gap (D45)")
}
names := toolNames(fm.toolsets[0])
if !has(names, "list_directory") || !has(names, "read_file") {
t.Fatalf("read tools not exposed in CHAT mode: %v", names)
}
if has(names, "write_file") || has(names, "edit_file") || has(names, "run_command") {
t.Fatalf("write tools exposed in CHAT mode: %v", names)
for _, want := range []string{"list_directory", "read_file", "write_file", "edit_file", "run_command",
"git_status", "git_diff", "git_log", "git_branch", "git_add", "git_commit"} {
if !has(names, want) {
t.Fatalf("D49: %s must be exposed in CHAT mode (capability in every pane), got: %v", want, names)
}
}
_ = res
}
func TestChatModeRefusesModeDisallowedToolCall(t *testing.T) {
// The dispatch gate stays the security boundary (T3): a CHAT-mode
// model call for write_file executes as a typed tool error, never
// bytes on disk.
func TestPlanModeExposesFullSuite(t *testing.T) {
// D49: PLAN carries the full suite too (the pane organizes
// visibility, not capability).
t.Setenv("RUNE_MODE", "PLAN")
names := toolNames(ToolsetFor("PLAN"))
for _, want := range []string{"write_file", "run_command", "git_commit"} {
if !has(names, want) {
t.Fatalf("D49: %s must be exposed in PLAN mode, got: %v", want, names)
}
}
}
func TestWriteToolDescriptionsStateAvailability(t *testing.T) {
// D49 point 3: the model said "I need shell access" because the
// wire showed read-only. The wire must SAY the writes are
// available — the descriptions the toolset carries state it.
for _, mode := range []string{"CHAT", "PLAN", "TASK"} {
for _, td := range ToolsetFor(mode) {
switch td.Function.Name {
case "write_file", "edit_file", "run_command":
desc := strings.ToLower(td.Function.Description)
if !strings.Contains(desc, "every pane") {
t.Fatalf("%s description must state it is available in every pane (mode %s): %q",
td.Function.Name, mode, td.Function.Description)
}
}
}
}
}
func TestChatModeWriteExecutes(t *testing.T) {
// D49: the write-refusal-by-mode retires — a CHAT-mode model call
// for write_file EXECUTES (the file lands; the consent card
// promotes work, it does not gate tools).
t.Setenv("RUNE_MODE", "CHAT")
dir := t.TempDir()
fm := &fakeToolModel{}
@@ -109,29 +140,50 @@ func TestChatModeRefusesModeDisallowedToolCall(t *testing.T) {
if err != nil {
t.Fatalf("turn: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "x.txt")); !os.IsNotExist(err) {
t.Fatal("write_file EXECUTED in CHAT mode — the mode gate failed")
raw, err := os.ReadFile(filepath.Join(dir, "x.txt"))
if err != nil {
t.Fatal("write_file refused in CHAT mode — the D49 doctrine (capability in every pane) is not landed")
}
if string(raw) != "no" {
t.Fatalf("file content = %q", raw)
}
if len(fm.payloadLog) < 2 {
t.Fatalf("expected a tool-result round trip, got %d calls", len(fm.payloadLog))
}
toolMsgs := []ChatMessage{}
var sawSuccess bool
for _, m := range fm.payloadLog[1] {
if m.Role == "tool" {
toolMsgs = append(toolMsgs, m)
if m.Role == "tool" && strings.Contains(m.Content, `"success":true`) {
sawSuccess = true
}
}
if len(toolMsgs) == 0 {
t.Fatal("no tool result message fed back to the model")
}
if !strings.Contains(toolMsgs[0].Content, "not reachable in mode CHAT") {
t.Fatalf("tool result should carry the typed mode refusal, got: %s", toolMsgs[0].Content)
if !sawSuccess {
t.Fatalf("tool result should carry the successful write envelope")
}
if res.AssistantText != "ok, stopped" {
t.Fatalf("final answer = %q", res.AssistantText)
}
}
func TestGenuinelyUnavailableToolStillRefused(t *testing.T) {
// The dispatch-gate seam stays (D49): a tool genuinely outside the
// suite is a typed refusal naming the mode, never an execution.
t.Setenv("RUNE_MODE", "CHAT")
fm := &fakeToolModel{}
fm.script = []ChatReply{
{Content: "", ToolCalls: []ToolCall{{Name: "format_disk", Args: map[string]any{}}}},
{Content: "understood", Model: "m"},
}
e, _ := newTestEngine(t, fm)
if _, err := e.Turn(context.Background(), TurnRequest{Channel: "chat", UserText: "format it"}); err != nil {
t.Fatalf("turn: %v", err)
}
for _, m := range fm.payloadLog[1] {
if m.Role == "tool" && !strings.Contains(m.Content, "not reachable in mode CHAT") {
t.Fatalf("tool result should carry the typed refusal for a genuinely-unavailable tool, got: %s", m.Content)
}
}
}
func TestTaskModeExposesWrites(t *testing.T) {
// The mode-gated matrix: TASK exposes the write family (and the
// model's write actually lands — Rune can act).
@@ -203,8 +255,10 @@ func TestListDirectoryToolCallFullLoop(t *testing.T) {
// turn's channel through ONE door (modeForDispatch), never a second
// ActiveMode read that can disagree. Consent happens while the face
// sits in CHAT; the task queue discharges into the task channel —
// those dispatches carry the TASK toolset (write family) regardless of
// where the face is parked.
// those dispatches carry the TASK toolset regardless of where the face
// is parked. D49 makes the toolset the same full suite on every
// channel and in every mode; the door stays so future policies (and
// future modes that narrow the suite) hook in one place.
func TestTaskChannelExposesWriteToolsWithFaceChatMode(t *testing.T) {
// RED #2537: the owner bug — face mode CHAT, turn on the task
@@ -229,9 +283,10 @@ func TestTaskChannelExposesWriteToolsWithFaceChatMode(t *testing.T) {
}
}
func TestChatChannelFaceChatModeStaysReadOnly(t *testing.T) {
// The channel law must not leak the other way: a chat-channel turn
// with the face in CHAT stays read-only.
func TestChatChannelFaceChatModeCarriesFullSuite(t *testing.T) {
// D49: the channel law's read-only half retires — a chat-channel
// turn with the face in CHAT carries the FULL suite (capability is
// inherent in every pane).
t.Setenv("RUNE_MODE", "CHAT")
fm := &fakeToolModel{}
fm.script = []ChatReply{{Content: "here", Model: "m"}}
@@ -240,11 +295,10 @@ func TestChatChannelFaceChatModeStaysReadOnly(t *testing.T) {
t.Fatalf("turn: %v", err)
}
names := toolNames(fm.toolsets[0])
if !has(names, "list_directory") || !has(names, "read_file") {
t.Fatalf("read tools not exposed: %v", names)
}
if has(names, "write_file") || has(names, "edit_file") || has(names, "run_command") {
t.Fatalf("write tools exposed on a chat-channel CHAT-face turn: %v", names)
for _, want := range []string{"list_directory", "read_file", "write_file", "edit_file", "run_command", "git_status", "git_commit"} {
if !has(names, want) {
t.Fatalf("D49: %s must be exposed on a chat-channel CHAT-face turn, got: %v", want, names)
}
}
}
@@ -269,7 +323,8 @@ func TestDispatchGateHonorsChannelDerivedMode(t *testing.T) {
// The gate and the wire derive through the SAME door (#2537): a
// task-channel write executes with the face in CHAT (the gate may
// not re-read the face mode and refuse what the wire offered), and
// a chat-channel write is refused with the face in CHAT.
// — since D49 — a chat-channel write executes too (capability is
// inherent in every pane).
t.Run("task channel allows write with face CHAT", func(t *testing.T) {
t.Setenv("RUNE_MODE", "CHAT")
dir := t.TempDir()
@@ -292,27 +347,29 @@ func TestDispatchGateHonorsChannelDerivedMode(t *testing.T) {
t.Fatalf("file content = %q", raw)
}
})
t.Run("chat channel refuses write with face CHAT", func(t *testing.T) {
t.Run("chat channel executes write with face CHAT", func(t *testing.T) {
// D49: the write-refusal half retires — a chat-channel write
// with the face in CHAT EXECUTES (capability is inherent in
// every pane).
t.Setenv("RUNE_MODE", "CHAT")
dir := t.TempDir()
target := filepath.Join(dir, "no.txt")
target := filepath.Join(dir, "yes.txt")
fm := &fakeToolModel{}
fm.script = []ChatReply{
{Content: "", ToolCalls: []ToolCall{{Name: "write_file", Args: map[string]any{
"path": target, "content": "no"}}}},
{Content: "stopped", Model: "m"},
"path": target, "content": "carried out"}}}},
{Content: "wrote it", Model: "m"},
}
e, _ := newTestEngine(t, fm)
if _, err := e.Turn(context.Background(), TurnRequest{Channel: "chat", UserText: "write it anyway"}); err != nil {
if _, err := e.Turn(context.Background(), TurnRequest{Channel: "chat", UserText: "write it"}); err != nil {
t.Fatalf("turn: %v", err)
}
if _, err := os.Stat(target); !os.IsNotExist(err) {
t.Fatal("write_file EXECUTED on a chat-channel dispatch — the gate failed")
raw, err := os.ReadFile(target)
if err != nil {
t.Fatal("write_file REFUSED on a chat-channel dispatch — the D49 doctrine (capability in every pane) is not landed")
}
for _, m := range fm.payloadLog[1] {
if m.Role == "tool" && !strings.Contains(m.Content, "not reachable in mode CHAT") {
t.Fatalf("tool result should carry the typed mode refusal, got: %s", m.Content)
}
if string(raw) != "carried out" {
t.Fatalf("file content = %q", raw)
}
})
}
+10 -8
View File
@@ -192,17 +192,19 @@ type frontDoor struct {
// lib.CompletionBudget so the two legs of the chain can never drift.
const CompletionBudget = proxyClientBudget
// newFrontDoor builds a front door over one core target with the
// production budget wiring: BOTH proxy clients carry the WHOLE-TURN
// budget (D47 defect 3 — a dispatched completion runs the core's tool
// loop, up to ToolLoopIterations model calls; the per-class deadline
// rides the ctx the TUI binds, the doors never cut beneath the
// longest class).
// newFrontDoor builds a front door over one core target. The dispatch
// route's completion client carries NO whole-call Timeout (the
// streaming root-cause wave, Gitea #2556): a streamed turn makes
// progress continuously and the CORE governs its deadlines — the
// inter-chunk idle timeout plus the 30-minute backstop — so a flat
// client cap here would only patch the symptom again. The generic
// proxy client keeps the WHOLE-TURN budget for the non-streaming
// fallback paths (the 6x480s constants' remaining tenant).
func newFrontDoor(core string) *frontDoor {
return &frontDoor{
core: core,
client: &http.Client{Timeout: TurnBudget},
completionClient: &http.Client{Timeout: TurnBudget},
completionClient: &http.Client{},
}
}
@@ -237,7 +239,7 @@ func (f *frontDoor) proxyOn(out *http.Client) http.HandlerFunc {
}
body = bytes.NewReader(raw)
}
req, err := http.NewRequest(r.Method, f.core+r.URL.Path, body)
req, err := http.NewRequestWithContext(r.Context(), r.Method, f.core+r.URL.Path, body)
if err != nil {
errorEnvelope(w, http.StatusBadRequest, "bad_request", err.Error())
return
@@ -0,0 +1,76 @@
package entry
// streaming_entry_test.go — the streaming root-cause wave's entry-leg
// proof (Gitea #2556): the front door relays a streamed /v1/chat/
// completions response CHUNK BY CHUNK — a chunk the core emits reaches
// the caller before the core emits the next one (no buffering at the
// proxy), so the turn never sits inside one blocking call on the
// entry leg either.
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// TestEntryProxyRelaysStreamChunks — a fake core emits two NDJSON
// chunk lines 300ms apart; the relayed first chunk must arrive while
// the second is still in flight.
func TestEntryProxyRelaysStreamChunks(t *testing.T) {
lines := []string{
`{"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{"content":"hel"}}]}` + "\n",
`{"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n",
}
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/x-ndjson")
w.WriteHeader(http.StatusOK)
for i, line := range lines {
_, _ = w.Write([]byte(line))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
if i == 0 {
time.Sleep(300 * time.Millisecond)
}
}
}))
defer core.Close()
f := newFrontDoor(core.URL)
door := httptest.NewServer(f.mux())
defer door.Close()
start := time.Now()
resp, err := http.Post(door.URL+"/v1/chat/completions", "application/json",
strings.NewReader(`{"model":"m","messages":[{"role":"user","content":"hi"}],"stream":true}`))
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
buf := make([]byte, 4096)
firstAt := time.Time{}
var got strings.Builder
for {
n, err := resp.Body.Read(buf)
if n > 0 {
got.Write(buf[:n])
if firstAt.IsZero() && strings.Contains(got.String(), "hel") {
firstAt = time.Now()
}
}
if err != nil {
break
}
}
if firstAt.IsZero() {
t.Fatalf("first chunk never arrived incrementally: %q", got.String())
}
if elapsed := firstAt.Sub(start); elapsed >= 250*time.Millisecond {
t.Fatalf("first chunk arrived at %v — the proxy buffered the stream", elapsed)
}
if !strings.Contains(got.String(), "finish_reason") {
t.Fatalf("terminal chunk lost: %q", got.String())
}
}
@@ -1,21 +1,22 @@
package entry
// turnbudget_entry_test.go — the D47 defect 3 law on the ENTRY legs:
// every door that proxies a dispatched completion (the serve proxy
// client + the continuous-runtime door's completion client) must carry
// the WHOLE-TURN budget — the sum of the tool loop's model calls, the
// local-class ceiling — never the old single-call 480s. Red-first.
// turnbudget_entry_test.go — the budget law on the ENTRY legs after
// the streaming root-cause wave (Gitea #2556): the dispatch route's
// completion client carries NO whole-call Timeout (a streamed turn is
// governed by the core's inter-chunk idle timeout + backstop — a flat
// client cap would patch the symptom again); the generic proxy client
// keeps the whole-turn budget for the non-streaming fallback paths.
import (
"testing"
"time"
)
// TestEntryCompletionClientsCarryWholeTurnBudget — both entry doors'
// completion clients ride >= the local whole-turn budget (6 × the
// per-call 480s; the ctx the TUI binds is the per-class deadline, the
// client timeout must never cut beneath the longest class).
func TestEntryCompletionClientsCarryWholeTurnBudget(t *testing.T) {
// TestEntryDispatchClientIsUngatedAndProxyKeepsFallback — the two
// clients split by deadline governance: the completion (dispatch /
// streamed) client must NOT cap the whole call, the generic proxy
// client must carry >= the whole-turn fallback budget.
func TestEntryDispatchClientIsUngatedAndProxyKeepsFallback(t *testing.T) {
if TurnBudget < ToolLoopIterations*PerCallBudget {
t.Fatalf("TurnBudget = %v, want >= %v (the whole-turn sum of %d model calls)",
TurnBudget, ToolLoopIterations*PerCallBudget, ToolLoopIterations)
@@ -25,9 +26,9 @@ func TestEntryCompletionClientsCarryWholeTurnBudget(t *testing.T) {
}
f := newFrontDoor("http://127.0.0.1:8376")
if f.client == nil || f.client.Timeout < TurnBudget {
t.Fatalf("proxy client budget = %+v, want the whole-turn %v", f.client, TurnBudget)
t.Fatalf("proxy client budget = %+v, want the whole-turn %v (the non-streaming fallback)", f.client, TurnBudget)
}
if f.completionClient == nil || f.completionClient.Timeout < TurnBudget {
t.Fatalf("completion client budget = %+v, want the whole-turn %v", f.completionClient, TurnBudget)
if f.completionClient == nil || f.completionClient.Timeout != 0 {
t.Fatalf("completion client timeout = %v, want 0 (the streamed turn is governed by the core's idle watch, never a flat client cap)", f.completionClient.Timeout)
}
}
+34 -11
View File
@@ -215,17 +215,21 @@ func runMode(argv []string) int {
}
func readFile(path string, offset, limit int) Result {
f, err := os.Open(path)
// Python-oracle parity (tool_executor._read_file): existence is
// reported as "Not found: <path>" before any read is attempted.
if _, statErr := os.Stat(path); statErr != nil {
if os.IsNotExist(statErr) {
return fail("Not found: " + path)
}
}
data, err := os.ReadFile(path)
if err != nil {
return fail(fmt.Sprintf("Cannot read %s: %v", path, err))
}
defer f.Close()
var lines []string
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
for scanner.Scan() {
lines = append(lines, scanner.Text())
return fail(fmt.Sprintf("Read failed: %v", err))
}
// Python-oracle parity: split("\n") keeps the trailing empty element
// after a final newline, so a file ending in "\n" gets a numbered
// empty last line — do NOT use bufio.Scanner, which drops it.
lines := strings.Split(string(data), "\n")
start := offset - 1
if start < 0 {
start = 0
@@ -239,12 +243,26 @@ func readFile(path string, offset, limit int) Result {
}
var numbered []string
for i := start; i < end; i++ {
numbered = append(numbered, fmt.Sprintf("%d: %s", i+1, lines[i]))
// Python-oracle parity: line numbers restart at the offset within
// the window (windowed index + offset), not the absolute index.
numbered = append(numbered, fmt.Sprintf("%d: %s", i-start+offset, lines[i]))
}
return ok(strings.Join(numbered, "\n"))
result := strings.Join(numbered, "\n")
if end < len(lines) {
result += fmt.Sprintf("\n\n[showing lines %d-%d of %d total]", offset, end, len(lines))
}
if start > 0 {
result = fmt.Sprintf("[showing from line %d]\n", offset) + result
}
return ok(result)
}
func listDirectory(path, pattern string) Result {
// Python-oracle parity (tool_executor._list_directory): a missing or
// non-directory path reports "Not a directory: <path>".
if info, statErr := os.Stat(path); statErr != nil || !info.IsDir() {
return fail("Not a directory: " + path)
}
entries, err := os.ReadDir(path)
if err != nil {
return fail(fmt.Sprintf("Cannot list %s: %v", path, err))
@@ -337,6 +355,11 @@ func grepFiles(pattern, path, include string) Result {
if err != nil {
return fail(fmt.Sprintf("Bad pattern: %v", err))
}
// Python-oracle parity (tool_executor._grep_files): hits carry the
// ABSOLUTE resolved path, not the caller's spelling.
if abs, absErr := filepath.Abs(path); absErr == nil {
path = abs
}
var out []string
var walkFn func(string)
walkFn = func(dir string) {
+1 -1
View File
@@ -6,7 +6,7 @@ This is the main entry point for the Rune program.
import json
from pathlib import Path
__version__ = "0.338.0"
__version__ = "0.346.0"
# One build identity (handoff 2026-09-10 §4): the semantic version above is
# the repo-level truth (auto-bumped at merge); the DEPLOYED revision (exact
+1 -1
View File
@@ -233,7 +233,7 @@ def main() -> int:
)
check(
"Rune minor version advertises provisioning and restore verification",
'__version__ = "0.338.0"' in rune_version,
'__version__ = "0.346.0"' in rune_version,
failures,
)
# #169 policy update: auto-bump on merge is sanctioned (Nick directive:
+92 -1
View File
@@ -5,6 +5,8 @@ from __future__ import annotations
import copy
import json
import logging
import os
import subprocess
import threading
import urllib.request
from typing import Callable, Protocol, Sequence, TypedDict, cast
@@ -38,6 +40,91 @@ from .tool_protocol import (
LOG = logging.getLogger("rune.context_broker")
# Native engine integration (wave B, Gitea #2575, PORT→INTEGRATE→REMOVE):
# the four read-only file operations (read_file, list_directory, glob_files,
# grep_files) resolve through the Go engine (src/go/tool-executor, the same
# CLI/envelope surface the Go parity batteries pin). The Python bodies in
# tool_executor.py stay as the oracle/escape hatch
# (RUNE_TOOL_EXECUTOR_ENGINE=python); fail-loud, no silent fallback — a
# missing binary raises a typed RuntimeError. run_command is deliberately
# NOT delegated: it is mutating (tool_executor.READ_ONLY_TOOLS excludes it)
# and the approval door owns every dispatch of it.
_GO_EXECUTOR_OPS = frozenset(
{"read_file", "list_directory", "glob_files", "grep_files"}
)
def _lte_go_engine_enabled() -> bool:
return os.environ.get("RUNE_TOOL_EXECUTOR_ENGINE", "go") != "python"
def _lte_go_engine_bin() -> str:
override = os.environ.get("RUNE_TOOL_EXECUTOR_BIN")
if override:
return override
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
return os.path.join(root, "src", "go", "tool-executor", "tool-executor")
def _go_execute_tool(tool_name: str, arguments: dict) -> dict:
"""Shell out to the Go tool-executor CLI and return its JSON envelope.
Argv mirrors dispatchOp (internal/toolexec/modegate.go): read_file
<path> [offset] [limit]; list_directory <path> [pattern];
glob_files <pattern> [path]; grep_files <pattern> [path] [include].
"""
bin_path = _lte_go_engine_bin()
if not os.path.exists(bin_path):
raise RuntimeError(
f"tool-executor go engine binary missing: {bin_path} "
"(set RUNE_TOOL_EXECUTOR_ENGINE=python for the legacy path)"
)
if tool_name == "read_file":
argv = [bin_path, "read_file", str(arguments.get("path", "")),
str(int(arguments.get("offset", 1) or 1)),
str(int(arguments.get("limit", 100) or 100))]
elif tool_name == "list_directory":
argv = [bin_path, "list_directory", str(arguments.get("path", "."))]
if arguments.get("pattern"):
argv.append(str(arguments["pattern"]))
elif tool_name == "glob_files":
argv = [bin_path, "glob_files", str(arguments.get("pattern", "")),
str(arguments.get("path", "."))]
elif tool_name == "grep_files":
argv = [bin_path, "grep_files", str(arguments.get("pattern", "")),
str(arguments.get("path", "."))]
if arguments.get("include"):
argv.append(str(arguments["include"]))
else: # pragma: no cover — guarded by _GO_EXECUTOR_OPS
raise RuntimeError(f"tool-executor go engine: unsupported op {tool_name!r}")
try:
proc = subprocess.run(argv, capture_output=True, text=True, timeout=30)
except (OSError, subprocess.TimeoutExpired) as exc:
raise RuntimeError(f"tool-executor go engine failed: {exc}") from exc
if proc.returncode != 0:
raise RuntimeError(
f"tool-executor go engine exit {proc.returncode}: {proc.stderr.strip()}"
)
envelope = json.loads(proc.stdout)
if not isinstance(envelope, dict):
raise RuntimeError("tool-executor go engine returned a non-object envelope")
return envelope
class _GoDelegatingExecutor:
"""Executor wrapper: read-only ops resolve through the Go engine, every
other op (and the RUNE_TOOL_EXECUTOR_ENGINE=python escape hatch) falls
through to the wrapped executor untouched. Attribute access (the
authority seam, _uses_tool_authority) delegates to the inner executor."""
def __init__(self, inner: Callable[[str, dict], dict]):
self._inner = inner
def __getattr__(self, name: str):
return getattr(self._inner, name)
def __call__(self, tool_name: str, arguments: dict) -> dict:
if _lte_go_engine_enabled() and tool_name in _GO_EXECUTOR_OPS:
return _go_execute_tool(tool_name, arguments)
return self._inner(tool_name, arguments)
#: Conservative context window used ONLY when /api/show discovery fails;
#: the failure is recorded via the P0-06 registry (T1) — never silent.
CONTEXT_LENGTH_FALLBACK = 8192
@@ -337,7 +424,11 @@ class LocalToolRuntime:
if max_iterations < 1 or max_iterations > 8:
raise ValueError("max_iterations must be between 1 and 8")
self.transport = transport
self.executor = executor
# Wave B (#2575): read-only file ops resolve through the Go
# tool-executor engine by default; every other op and the
# RUNE_TOOL_EXECUTOR_ENGINE=python escape hatch hit the wrapped
# executor untouched (approval door preserved).
self.executor = _GoDelegatingExecutor(executor)
self.model = model
self.tools = list(TOOLS if tools is None else tools)
self.tool_names = frozenset(
+33 -2
View File
@@ -267,8 +267,9 @@ def save_profiles(path, profiles: dict) -> Path:
# ---------------------------------------------------------------------------
# Native engine integration (retirement-conversion row 21, PORT→INTEGRATE):
# the routing kernel (rank) and the measured-store fold
# (update_profiles_from_traces) resolve through the Go engine
# the routing kernel (rank), the measured-store fold
# (update_profiles_from_traces), AND the capability-floor drift
# reconciliation (reconcile_tools_support) resolve through the Go engine
# (src/go/model_routing, `eval` surface) — the same engine the GREEN 7.2
# parity packet (verification/parity/model-routing.json) proved
# decision-for-decision. The Python implementations below stay as the
@@ -454,6 +455,36 @@ CAPABILITY_FLOOR = (
def reconcile_tools_support(model: str, provider_reported: bool) -> tuple[bool, str | None]:
"""Fail-loud delegation facade for capability-floor drift reconciliation.
Resolves through the Go engine (src/go/model_routing, ``eval
reconcile`` surface) — the same engine the parity battery pins
decision-for-decision against this module's oracle body
(``_reconcile_tools_support_python``, kept as the
RUNE_MODEL_ROUTING_ENGINE=python escape hatch and oracle). Missing
binary or engine error raises RuntimeError (no silent Python
fallback).
"""
if not _go_engine_enabled():
return _reconcile_tools_support_python(model, provider_reported)
bin_path = _go_engine_bin()
if not os.path.exists(bin_path):
raise RuntimeError(
f"model-routing go engine binary missing: {bin_path} "
"(set RUNE_MODEL_ROUTING_ENGINE=python for the legacy path)"
)
ops = [{
"op": "reconcile",
"model": model,
"provider_reported": bool(provider_reported),
}]
out = _go_eval("reconcile", ops)
row = out[0] if out and isinstance(out[0], dict) else {}
drift = row.get("drift")
return bool(row.get("effective")), (str(drift) if drift is not None else None)
def _reconcile_tools_support_python(model: str, provider_reported: bool) -> tuple[bool, str | None]:
"""Reconcile a provider-reported tool-support claim against the floor.
R2 I-R2-022 truth_drift; spec tools-providers-devices "Capability
+2 -2
View File
@@ -8,7 +8,7 @@ mode system, subject header, shared memory and identity.
This is not an interface. This is how I see, speak, and interact with the world.
Version: v0.338.0
Version: v0.346.0
Features: 41 (24 original + 17 new native commands)
Usage:
@@ -2047,7 +2047,7 @@ class RuneApp(App):
# Show version info
self.chat_channel.add_message(
"system",
f"[dim]Rune v0.338.0 | Textual TUI | {len(loaded)} native modules[/dim]",
f"[dim]Rune v0.346.0 | Textual TUI | {len(loaded)} native modules[/dim]",
)
# Auto-show night mode suggestion if evening/night hours
@@ -0,0 +1,143 @@
{
"checkpoints": [
{
"at": "2026-10-22T19:47:11Z",
"evidence": "predicted-frontier",
"note": "{\"level0_direct\": [], \"level1_obligations\": [], \"contracts\": [], \"level2_verification\": []}",
"phase": "draft"
},
{
"at": "2026-10-22T19:47:34Z",
"evidence": "user:D50-residual directive 2026-10-22",
"note": "",
"phase": "approved"
},
{
"at": "2026-10-22T19:47:53Z",
"evidence": "builder:wave-d50-residuals",
"note": "",
"phase": "building"
}
],
"evidence_refs": [
"contract-created:ADMIT-wave-d50-residuals"
],
"ext": {
"activation": {
"activated": true,
"builder": "wave-d50-residuals",
"evidence": [
"affected surface known: ['tui/', 'src/go/rune-core/internal/runecore/record.go']",
"insufficient lexical evidence either way"
],
"lifecycle": "proportional",
"mode": "EXISTING_PROJECT_CHANGE",
"note": "attached to existing project authority; Need/completeness/patterns/contract flow governs proportionally",
"project_authority": "rune_internal",
"target_project": "rune"
},
"affected_paths": [
"scripts/admit_wave_d50_residuals.py",
"src/go/rune-core/internal/runecore/record.go",
"src/go/rune-core/internal/runecore/store.go",
"tui/"
],
"affected_regions": [],
"affected_ui_surfaces": [],
"expected_runtime_effects": [
"tasks-pane work blocks show a dim \u21a9 msg_id reference; phantom queue rows are filtered; tasks-focus enter dispatches directly; pinned work persists across boots"
],
"predicted_frontier": {
"change_kind": "implementation_change",
"contextual_not_obligations": [],
"contracts": [],
"level0_direct": [],
"level1_obligations": [],
"level2_verification": [],
"max_level": 2,
"traversal_scope": "spine-structural-only"
},
"scope_provenance": {
"caller_added": [],
"generated_from_affected_surface": [
"scripts/admit_wave_d50_residuals.py",
"src/go/rune-core/internal/runecore/record.go",
"src/go/rune-core/internal/runecore/store.go",
"tui/"
]
},
"task_ref": "wave-d50-residuals",
"unresolved_surfaces": [
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "scripts/admit_wave_d50_residuals.py"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "src/go/rune-core/internal/runecore/record.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "src/go/rune-core/internal/runecore/store.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/"
}
],
"workflow_config_surfaces": []
},
"id": "contract:ADMIT-wave-d50-residuals",
"identities": {
"acceptance": [
"bounded repair verified: land the D50-residual wave (Gitea #2560, D51 round folded): (1) msg_id linkage \u2014 tasks-pane work-stream records carry th"
],
"affected_entities": [],
"expected_consumers": [],
"expected_new_entities": [
"red-first enter-dispatch and phantom-filter tests",
"the D50-residuals wave report (reports/)",
"the pinned-work hot-state store + tests"
],
"gap_evidence": "insufficient lexical evidence either way; affected surface known: ['tui/', 'src/go/rune-core/internal/runecore/record.go']",
"migration_obligations": [],
"need": "land the D50-residual wave (Gitea #2560, D51 round folded): (1) msg_id linkage \u2014 tasks-pane work-stream records carry the spawning chat msg_id (msg ids exist core-side per newMsgID), rendered as a dim \u21a9 reference in the work block; (2) phantom-row filter \u2014 filter stale queue rows by the freshness law (the 30-min occupancy pattern) in the tasks pane, read-side in the TUI; (3) enter-steal removal \u2014 remove the tasks-pane row-selection walker entirely; enter on tasks-pane focus starts direct task execution (D44 doctrine); (4) pinned work \u2014 reserved pinned area at the top of the tasks pane with a pin icon and 'pinned' label, each pin carrying a plain-english summary, click opens the outstanding pinned work view, pins persist across boots via the hot-state store pattern; the tasks pane renders blank otherwise with no selectable rows; (5) dynamic reflow \u2014 bubbles/boxes re-wrap on window resize (nothing clips)",
"next_action": "complete implementation, then verify",
"non_goals": [],
"origin": "worker",
"regression_obligations": [],
"requirement": "headless admission (classified: EXISTING_PROJECT_CHANGE)",
"research_refs": [
"scripts/admit_wave_rebase_tui_wiring.py",
"store/scaffold/docs/ADMIT-wave-d46-live-defects.md",
"tui/chatpane/blocks.go",
"tui/cmd/rune-tui/selection.go",
"tui/lanes/activity.go",
"tui/tasks/render.go"
],
"resolution": "",
"rollback_ref": "git revert",
"scope": [
"scripts/admit_wave_d50_residuals.py",
"src/go/rune-core/internal/runecore/record.go",
"src/go/rune-core/internal/runecore/store.go",
"tui/"
],
"toolchain": {},
"verification_obligations": [
"PTY proofs for enter-dispatch and pin persistence",
"build/vet/gofmt clean",
"go test -race ./... in tui/ green"
]
},
"kind": "construction_contract",
"maturity": "DECLARED",
"observed_at": "2026-10-22T19:47:53Z",
"phase": "building",
"status": "open",
"tags": []
}
@@ -0,0 +1,192 @@
{
"checkpoints": [
{
"at": "2026-10-23T17:26:43Z",
"evidence": "predicted-frontier",
"note": "{\"level0_direct\": [], \"level1_obligations\": [], \"contracts\": [], \"level2_verification\": []}",
"phase": "draft"
},
{
"at": "2026-10-23T17:27:02Z",
"evidence": "user:D54 dispatch 2026-10-23 (Gitea #2571)",
"note": "",
"phase": "approved"
},
{
"at": "2026-10-23T17:27:20Z",
"evidence": "builder:wave-d54-nd-presentation",
"note": "",
"phase": "building"
}
],
"evidence_refs": [
"contract-created:ADMIT-wave-d54-nd-presentation"
],
"ext": {
"activation": {
"activated": true,
"builder": "wave-d54-nd-presentation",
"evidence": [
"affected surface known: ['tui/lanes/activity.go', 'tui/chatpane/blocks.go']",
"change scoped to an existing project/target",
"target project: rune"
],
"lifecycle": "proportional",
"mode": "EXISTING_PROJECT_CHANGE",
"note": "attached to existing project authority; Need/completeness/patterns/contract flow governs proportionally",
"project_authority": "rune_internal",
"target_project": "rune"
},
"affected_paths": [
"scripts/admit_wave_d54_nd_presentation.py",
"tui/chatpane/blocks.go",
"tui/chatpane/chatpane.go",
"tui/chatpane/scroll_test.go",
"tui/cmd/rune-tui/main.go",
"tui/lanes/activity.go",
"tui/settings/config.go",
"tui/settings/settings.go",
"tui/themes/roles.go",
"tui/themes/theme.go"
],
"affected_regions": [],
"affected_ui_surfaces": [],
"expected_runtime_effects": [
"calm mode mutes chroma tokens and stops the activity wave; long answers open with a one-line plain-english summary; face strings read literally"
],
"predicted_frontier": {
"change_kind": "implementation_change",
"contextual_not_obligations": [],
"contracts": [],
"level0_direct": [],
"level1_obligations": [],
"level2_verification": [],
"max_level": 2,
"traversal_scope": "spine-structural-only"
},
"scope_provenance": {
"caller_added": [],
"generated_from_affected_surface": [
"scripts/admit_wave_d54_nd_presentation.py",
"tui/chatpane/blocks.go",
"tui/chatpane/chatpane.go",
"tui/chatpane/scroll_test.go",
"tui/cmd/rune-tui/main.go",
"tui/lanes/activity.go",
"tui/settings/config.go",
"tui/settings/settings.go",
"tui/themes/roles.go",
"tui/themes/theme.go"
]
},
"task_ref": "wave-d54-nd-presentation",
"unresolved_surfaces": [
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "scripts/admit_wave_d54_nd_presentation.py"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/chatpane/blocks.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/chatpane/chatpane.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/chatpane/scroll_test.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/cmd/rune-tui/main.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/lanes/activity.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/settings/config.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/settings/settings.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/themes/roles.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/themes/theme.go"
}
],
"workflow_config_surfaces": []
},
"id": "contract:ADMIT-wave-d54-nd-presentation",
"identities": {
"acceptance": [
"bounded repair verified: implement the neurodivergent-presentation mechanical map (research/factory-nuerodivergent-presentation/synthesis.md, Git"
],
"affected_entities": [],
"expected_consumers": [],
"expected_new_entities": [
"the colour-role enum in tui/themes (one hex per role)",
"the motion-allowed gate on the activity wave",
"the sensory (vivid|calm) setting key in the settings store"
],
"gap_evidence": "change scoped to an existing project/target; target project: rune; affected surface known: ['tui/lanes/activity.go', 'tui/chatpane/blocks.go']",
"migration_obligations": [],
"need": "implement the neurodivergent-presentation mechanical map (research/factory-nuerodivergent-presentation/synthesis.md, Gitea #2571): the motion law (motionAllowed gate on the activity wave, never-animate law on the chat pane, one-animating-region-per-frame in the face chain), the colour-token law (role enum in tui/themes, one hex per role, contrast >=4.5:1 unit test, Faint-on-dim fix), the spacing law (one blank line between blocks, blocks never merge), summary-first rendering (one-line plain-english summary box for long answers), the calm-mode sensory setting (vivid|calm, default vivid \u2014 calm mutes chroma at the token layer and sets motionAllowed false), and the literal-language audit of face strings",
"next_action": "complete implementation, then verify",
"non_goals": [],
"origin": "worker",
"regression_obligations": [],
"requirement": "headless admission (classified: EXISTING_PROJECT_CHANGE)",
"research_refs": [
"research/factory-nuerodivergent-presentation/synthesis.md",
"tui/chatpane/blocks.go",
"tui/chatpane/chatpane.go",
"tui/lanes/activity.go",
"tui/settings/config.go",
"tui/themes/theme.go"
],
"resolution": "",
"rollback_ref": "git revert",
"scope": [
"scripts/admit_wave_d54_nd_presentation.py",
"tui/chatpane/blocks.go",
"tui/chatpane/chatpane.go",
"tui/chatpane/scroll_test.go",
"tui/cmd/rune-tui/main.go",
"tui/lanes/activity.go",
"tui/settings/config.go",
"tui/settings/settings.go",
"tui/themes/roles.go",
"tui/themes/theme.go"
],
"toolchain": {},
"verification_obligations": [
"contrast unit test >=4.5:1 body text, dim tier floor verified (Faint-on-dim breach fixed)",
"go test -race -short ./... in tui/ green (documented pre-existing PTY boot family aside)",
"go vet + gofmt clean in tui/"
]
},
"kind": "construction_contract",
"maturity": "DECLARED",
"observed_at": "2026-10-23T17:27:20Z",
"phase": "building",
"status": "open",
"tags": []
}
@@ -0,0 +1,154 @@
{
"checkpoints": [
{
"at": "2026-10-23T22:47:01Z",
"evidence": "predicted-frontier",
"note": "{\"level0_direct\": [], \"level1_obligations\": [], \"contracts\": [], \"level2_verification\": []}",
"phase": "draft"
},
{
"at": "2026-10-23T22:47:19Z",
"evidence": "user:streaming root-cause wave directive 2026-10-23 (Gitea #2556)",
"note": "",
"phase": "approved"
},
{
"at": "2026-10-23T22:47:36Z",
"evidence": "builder:wave-streaming-deadline-root",
"note": "",
"phase": "building"
}
],
"evidence_refs": [
"contract-created:ADMIT-wave-streaming-deadline-root"
],
"ext": {
"activation": {
"activated": true,
"builder": "wave-streaming-deadline-root",
"evidence": [
"affected surface known: ['src/go/rune-core/internal/runecore/', 'src/go/rune-entry/']",
"change scoped to an existing project/target",
"target project: rune"
],
"lifecycle": "proportional",
"mode": "EXISTING_PROJECT_CHANGE",
"note": "attached to existing project authority; Need/completeness/patterns/contract flow governs proportionally",
"project_authority": "rune_internal",
"target_project": "rune"
},
"affected_paths": [
"scripts/admit_wave_streaming_deadline_root.py",
"src/go/rune-core/internal/runecore/",
"src/go/rune-entry/",
"tui/cmd/rune-tui/",
"tui/runtime/lib/"
],
"affected_regions": [],
"affected_ui_surfaces": [],
"expected_runtime_effects": [
"a local 27B thinking turn streams tokens continuously through core, entry proxy and TUI with no flat deadline; a genuinely dead model trips the idle timeout"
],
"predicted_frontier": {
"change_kind": "implementation_change",
"contextual_not_obligations": [],
"contracts": [],
"level0_direct": [],
"level1_obligations": [],
"level2_verification": [],
"max_level": 2,
"traversal_scope": "spine-structural-only"
},
"scope_provenance": {
"caller_added": [],
"generated_from_affected_surface": [
"scripts/admit_wave_streaming_deadline_root.py",
"src/go/rune-core/internal/runecore/",
"src/go/rune-entry/",
"tui/cmd/rune-tui/",
"tui/runtime/lib/"
]
},
"task_ref": "wave-streaming-deadline-root",
"unresolved_surfaces": [
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "scripts/admit_wave_streaming_deadline_root.py"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "src/go/rune-core/internal/runecore/"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "src/go/rune-entry/"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/cmd/rune-tui/"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tui/runtime/lib/"
}
],
"workflow_config_surfaces": []
},
"id": "contract:ADMIT-wave-streaming-deadline-root",
"identities": {
"acceptance": [
"bounded repair verified: land the streaming root-cause wave (Gitea #2556, owner verdict 2026-10-22): stream through the WHOLE turn chain so a loc"
],
"affected_entities": [],
"expected_consumers": [],
"expected_new_entities": [
"the inter-chunk idle timeout + 30-minute backstop on streamed paths (budgets retire to fallback)",
"the red-first streaming chain tests (ollama NDJSON, cloud SSE, core stream relay, entry pass-through, TUI render-as-it-arrives, idle-timeout simulation)",
"the streaming root-cause wave report (reports/)"
],
"gap_evidence": "change scoped to an existing project/target; target project: rune; affected surface known: ['src/go/rune-core/internal/runecore/', 'src/go/rune-entry/']",
"migration_obligations": [],
"need": "land the streaming root-cause wave (Gitea #2556, owner verdict 2026-10-22): stream through the WHOLE turn chain so a local 27B thinking turn never sits inside one blocking HTTP call \u2014 (1) the Ollama wire runs stream=true /api/chat with NDJSON chunks parsed incrementally (content/thinking/tool_calls; the final message object carries the assembled reply) and the engine's bounded tool loop runs over streamed replies; (2) the cloud wires gain streaming parity ([OI]-compat SSE, Anthropic SSE events, Gemini streamGenerateContent); (3) the core /v1/chat/completions relays the streamed turn as NDJSON chunks for stream=true callers while non-streaming callers get the assembled answer as before; (4) the entry proxy passes the stream through and the TUI renders text as it arrives; (5) on streamed paths the whole-turn budgets retire \u2014 replaced by an inter-chunk idle timeout (default 120s, RUNE_STREAM_IDLE_TIMEOUT configurable) plus a 30-minute overall backstop; the budget constants remain only for non-streaming fallback paths",
"next_action": "complete implementation, then verify",
"non_goals": [],
"origin": "worker",
"regression_obligations": [],
"requirement": "headless admission (classified: EXISTING_PROJECT_CHANGE)",
"research_refs": [
"scripts/admit_wave_terminal_budgets.py",
"src/go/rune-core/internal/runecore/cloud.go",
"src/go/rune-core/internal/runecore/ollama.go",
"src/go/rune-core/internal/runecore/server.go",
"src/go/rune-entry/internal/entry/serve.go",
"tui/cmd/rune-tui/main.go",
"tui/laneconc/laneconc.go",
"tui/runtime/lib/complete.go"
],
"resolution": "",
"rollback_ref": "git revert",
"scope": [
"scripts/admit_wave_streaming_deadline_root.py",
"src/go/rune-core/internal/runecore/",
"src/go/rune-entry/",
"tui/cmd/rune-tui/",
"tui/runtime/lib/"
],
"toolchain": {},
"verification_obligations": [
"-short race suites green on touched Go modules (the documented PTY family aside)",
"full-chain simulation: a fake slow model emitting chunks across a long window completes the turn with no deadline fire; a silent model trips the idle timeout",
"vet/gofmt clean"
]
},
"kind": "construction_contract",
"maturity": "DECLARED",
"observed_at": "2026-10-23T22:47:36Z",
"phase": "building",
"status": "open",
"tags": []
}
+9
View File
@@ -15,6 +15,15 @@ from typing import Sequence, cast
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
# Wave B (#2575): these fixtures script the Python tool executor — pin the
# RUNE_TOOL_EXECUTOR_ENGINE=python escape hatch so the Go delegation
# (pinned separately in tests/test_tool_executor_delegation.py) does not
# bypass the scripted executor.
@pytest.fixture(autouse=True)
def _python_tool_executor_engine(monkeypatch):
monkeypatch.setenv("RUNE_TOOL_EXECUTOR_ENGINE", "python")
from rune.local_tool_runtime import ( # noqa: E402
ChatMessage,
LocalToolRuntime,
+8
View File
@@ -38,6 +38,14 @@ import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
# Wave B (#2575): scripted-executor fixtures pin the Python escape hatch
# (RUNE_TOOL_EXECUTOR_ENGINE=python); Go delegation is pinned in
# tests/test_tool_executor_delegation.py.
@pytest.fixture(autouse=True)
def _python_tool_executor_engine(monkeypatch):
monkeypatch.setenv("RUNE_TOOL_EXECUTOR_ENGINE", "python")
from rune.local_tool_runtime import ChatMessage, LocalToolRuntime # noqa: E402
from rune import tool_executor # noqa: E402
from rune.tool_protocol import ToolEvent, ToolRequest # noqa: E402
+10
View File
@@ -13,10 +13,20 @@ import unittest
from pathlib import Path
from typing import Sequence, cast
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
# Wave B (#2575): scripted-executor fixtures pin the Python escape hatch
# (RUNE_TOOL_EXECUTOR_ENGINE=python); Go delegation is pinned in
# tests/test_tool_executor_delegation.py.
@pytest.fixture(autouse=True)
def _python_tool_executor_engine(monkeypatch):
monkeypatch.setenv("RUNE_TOOL_EXECUTOR_ENGINE", "python")
from rune.local_tool_runtime import ( # noqa: E402
ChatMessage,
LocalToolRuntime,
@@ -0,0 +1,98 @@
"""Red-first parity battery: the Go reconcile engine vs the Python oracle.
The capability-floor drift reconciliation (reconcile_tools_support) is
the last seam that kept local_tool_runtime.py on its Python import
(model-routing-integrated.json blocks_full_switchover, wave 181). The
Python body stays as the oracle/escape hatch
(RUNE_MODEL_ROUTING_ENGINE=python); this battery pins the Go delegation
facade to it decision-for-decision over the recorded oracle corpus
(contradiction → unverified drift, agreement → clean, non-floor →
passthrough), checks the fail-loud refusal when the engine binary is
missing, and receipts the local_tool_runtime consumer wiring.
"""
import json
import os
from pathlib import Path
import pytest
_ROOT = Path(__file__).resolve().parents[1]
CORPUS = [
("qwen2.5:3b", True), # floor T1 contradiction (floor=False)
("mimo-v2.5-pro", False), # floor T4 contradiction (floor=True)
("qwen2.5:3b", False), # floor T1 agreement
("mimo-v2.5-pro", True), # floor T4 agreement
("qwen3:8b", True), # non-floor passthrough
("qwen3:8b", False), # non-floor passthrough
]
def _jbytes(value):
return json.dumps(value, sort_keys=True, ensure_ascii=True)
def _run(engine):
from rune import model_routing as mr
old = os.environ.get("RUNE_MODEL_ROUTING_ENGINE")
os.environ["RUNE_MODEL_ROUTING_ENGINE"] = engine
try:
return [mr.reconcile_tools_support(m, r) for m, r in CORPUS]
finally:
if old is None:
os.environ.pop("RUNE_MODEL_ROUTING_ENGINE", None)
else:
os.environ["RUNE_MODEL_ROUTING_ENGINE"] = old
def test_go_delegation_matches_python_oracle_byte_for_byte():
oracle = _run("python")
go = _run("go")
assert _jbytes(go) == _jbytes(oracle)
# spot-check the drift arm against the recorded oracle text
assert go[0][0] is True
assert go[0][1] is not None
assert "contradicting CAPABILITY_FLOOR supports_tools=False" in go[0][1]
assert "unverified: provider reports supports_tools=True" in go[0][1]
assert go[1][0] is False and "supports_tools=False" in go[1][1]
assert go[2] == (False, None) and go[3] == (True, None)
assert go[4] == (True, None) and go[5] == (False, None)
def test_default_is_go_engine():
# default (unset env) resolves through the Go binary, not the oracle body
env = os.environ.pop("RUNE_MODEL_ROUTING_ENGINE", None)
try:
default = _run(os.environ.get("RUNE_MODEL_ROUTING_ENGINE", "go"))
finally:
if env is not None:
os.environ["RUNE_MODEL_ROUTING_ENGINE"] = env
assert _jbytes(default) == _jbytes(_run("python"))
def test_fail_loud_when_engine_binary_missing(tmp_path, monkeypatch):
from rune import model_routing as mr
monkeypatch.setattr(mr, "_go_engine_bin", lambda: str(tmp_path / "nope"))
monkeypatch.setenv("RUNE_MODEL_ROUTING_ENGINE", "go")
with pytest.raises(RuntimeError, match="go engine binary missing"):
mr.reconcile_tools_support("qwen2.5:3b", True)
def test_local_tool_runtime_consumer_receipts_drift():
# the consumer seam: LocalToolRuntime reconciles through the facade and
# surfaces the drift marker on capability_drift
from rune.local_tool_runtime import LocalToolRuntime
from rune.model_routing import _reconcile_tools_support_python
eff, drift = _reconcile_tools_support_python("qwen2.5:3b", True)
assert drift is not None
# parity: the facade's default path produces the same marker
os.environ.pop("RUNE_MODEL_ROUTING_ENGINE", None)
from rune import model_routing as mr
g_eff, g_drift = mr.reconcile_tools_support("qwen2.5:3b", True)
assert (g_eff, g_drift) == (eff, drift)
assert LocalToolRuntime is not None # import seam still wired
+141
View File
@@ -0,0 +1,141 @@
"""Red-first parity battery, wave B (Gitea #2575): the Go tool-executor
engine vs the Python oracle through the LocalToolRuntime delegation facade.
The four read-only file operations (read_file, list_directory, glob_files,
grep_files) resolve through src/go/tool-executor by default; the Python
bodies in tool_executor.py stay as the oracle/escape hatch
(RUNE_TOOL_EXECUTOR_ENGINE=python). This battery pins the facade to the
oracle envelope-for-envelope over fixture calls, checks the fail-loud
refusal when the engine binary is missing, and receipts that run_command
NEVER delegates (mutating — the approval door owns it).
"""
import json
import os
import sys
from pathlib import Path
import pytest
_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(_ROOT / "src"))
from rune.local_tool_runtime import ( # noqa: E402
_GO_EXECUTOR_OPS,
_GoDelegatingExecutor,
_go_execute_tool,
_lte_go_engine_bin,
_lte_go_engine_enabled,
)
@pytest.fixture()
def sandbox(tmp_path, monkeypatch):
(tmp_path / "docs").mkdir()
(tmp_path / "docs" / "a.txt").write_text("alpha line\nbeta line\ngamma line\n")
(tmp_path / "docs" / "b.py").write_text("def one():\n return 1\n")
monkeypatch.chdir(tmp_path)
return tmp_path
def _oracle(tool_name, arguments):
from rune.tool_executor import execute_tool
old = os.environ.get("RUNE_TOOL_EXECUTOR_ENGINE")
os.environ["RUNE_TOOL_EXECUTOR_ENGINE"] = "python"
inner = _GoDelegatingExecutor(execute_tool)
try:
return inner(tool_name, arguments)
finally:
if old is None:
os.environ.pop("RUNE_TOOL_EXECUTOR_ENGINE", None)
else:
os.environ["RUNE_TOOL_EXECUTOR_ENGINE"] = old
def _facade(tool_name, arguments):
return _GoDelegatingExecutor(lambda n, a: (_ for _ in ()).throw(
AssertionError("inner executor must not be reached for read-only ops"))
)(tool_name, arguments)
CASES = [
("read_file", {"path": "docs/a.txt"}),
("read_file", {"path": "docs/a.txt", "offset": 2, "limit": 1}),
("list_directory", {"path": "docs"}),
("list_directory", {"path": "docs", "pattern": "*.txt"}),
("glob_files", {"pattern": "docs/*.py"}),
("glob_files", {"pattern": "docs/*"}),
("grep_files", {"pattern": "line", "path": "docs"}),
("grep_files", {"pattern": "line", "path": "docs", "include": "*.txt"}),
("read_file", {"path": "docs/missing.txt"}),
("list_directory", {"path": "docs/missing-dir"}),
("grep_files", {"pattern": "zzz-no-match", "path": "docs"}),
]
@pytest.mark.parametrize("tool_name,arguments", CASES)
def test_go_delegation_matches_python_oracle_envelope(sandbox, tool_name, arguments):
assert _jbytes(_facade(tool_name, dict(arguments))) == _jbytes(
_oracle(tool_name, dict(arguments))
)
def _jbytes(value):
return json.dumps(value, sort_keys=True, ensure_ascii=True)
def test_default_engine_is_go():
os.environ.pop("RUNE_TOOL_EXECUTOR_ENGINE", None)
assert _lte_go_engine_enabled() is True
def test_python_escape_hatch_reaches_inner_executor(sandbox):
from rune.tool_executor import execute_tool
os.environ["RUNE_TOOL_EXECUTOR_ENGINE"] = "python"
try:
seen = []
def inner(name, args):
seen.append(name)
return {"success": True, "result": "oracle"}
out = _GoDelegatingExecutor(inner)("read_file", {"path": "docs/a.txt"})
finally:
os.environ.pop("RUNE_TOOL_EXECUTOR_ENGINE", None)
assert seen == ["read_file"]
assert out == {"success": True, "result": "oracle"}
def test_fail_loud_when_engine_binary_missing(tmp_path, monkeypatch):
monkeypatch.setattr(
"rune.local_tool_runtime._lte_go_engine_bin",
lambda: str(tmp_path / "nope"),
)
with pytest.raises(RuntimeError, match="go engine binary missing"):
_go_execute_tool("read_file", {"path": "whatever"})
def test_run_command_never_delegates_even_in_go_mode():
# run_command is mutating — the approval door owns every dispatch.
assert "run_command" not in _GO_EXECUTOR_OPS
seen = []
def inner(name, args):
seen.append(name)
return {"success": True, "result": "authority path"}
os.environ.pop("RUNE_TOOL_EXECUTOR_ENGINE", None)
out = _GoDelegatingExecutor(inner)("run_command", {"command": "echo hi"})
assert seen == ["run_command"]
assert out == {"success": True, "result": "authority path"}
def test_runtime_wraps_executor_and_keeps_attribute_seam():
from rune.local_tool_runtime import LocalToolRuntime
class Transport:
def supports_tools(self, model):
return False
def supports_thinking(self, model):
return False
def executor(name, args):
return {"success": True, "result": "inner"}
runtime = LocalToolRuntime(
transport=Transport(), executor=executor, model="qwen3:8b"
)
assert isinstance(runtime.executor, _GoDelegatingExecutor)
assert getattr(runtime.executor, "_uses_tool_authority", "sentinel") == "sentinel"
assert _lte_go_engine_bin().endswith(os.path.join("src", "go", "tool-executor", "tool-executor"))
+96 -11
View File
@@ -20,6 +20,16 @@
//
// B4: a long answer never breaks the bubble — the growing-bubble
// model replaces the fixed-shape card; answers stay left-aligned.
//
// THE NEVER-ANIMATE LAW (D54 #2571 item 1): the chat pane holds the
// reading content, so it NEVER animates — no looping motion, no
// blinking, no reflow-on-append. Stream append is APPEND-ONLY: a new
// message adds lines at the tail and must not change a single
// already-rendered line of the completed blocks above it (asserted by
// TestAppendNeverReflowsCompletedBlocks; the stay-put scroll law in
// scroll.go rides the same contract). The activity strip is the
// face's one animating region, gated behind motionAllowed
// (tui/lanes/activity.go).
package chatpane
import (
@@ -27,8 +37,58 @@ import (
"strings"
"charm.land/lipgloss/v2"
"rune.dev/rune/tui/themes"
)
// THE SPACING LAW (D54 #2571 item 3): exactly ONE blank line between
// blocks — the layout constant blockGapBlankLines, pure layout (no
// theme dependency). Block TYPES never merge: the parser keeps a
// list a list and a paragraph a paragraph (mdBlocks), and the
// renderer never abuts two block regions — a blank line always sits
// between them (COGA 4.4.10 white spacing; one idea, one block, one
// gap).
const blockGapBlankLines = 1
// THE SUMMARY-FIRST LAW (D54 #2571 item 4): an answer whose block
// count exceeds summaryBlockThreshold opens with a ONE-LINE
// plain-english summary box — what the answer contains, counted
// literally — before any block (COGA 4.4.8; the generalization of
// the "code (n lines)" collapse hint). Literal wording only: no
// idiom, no figure of speech.
const summaryBlockThreshold = 8
// summaryLine composes the one-line plain-english summary for a
// block stream ("Summary: 12 blocks — 9 paragraphs, 2 lists, 1
// table.").
func summaryLine(blocks []mdBlock) string {
counts := map[string]int{}
var order []string
for _, b := range blocks {
name := b.kind
if name == "list" {
if b.ordered {
name = "steps"
} else {
name = "lists"
}
} else if name == "para" {
name = "paragraphs"
} else {
name = name + "s"
}
if counts[name] == 0 {
order = append(order, name)
}
counts[name]++
}
parts := make([]string, 0, len(order))
for _, name := range order {
parts = append(parts, fmt.Sprintf("%d %s", counts[name], name))
}
return fmt.Sprintf("Summary: %d blocks — %s. The blocks follow.", len(blocks), strings.Join(parts, ", "))
}
// mdBlock is one parsed presentation block of an answer's prose.
type mdBlock struct {
kind string // "para" | "list" | "table" | "heading"
@@ -196,6 +256,17 @@ func listMarkerNumber(line string) int {
// law's one-stamp-per-message shape).
func (p *Pane) answerPresentation(m Message, styled bool, ts string, w int, right bool) []string {
var out []string
segs := mdBlocks(m.Content)
// SUMMARY-FIRST (#2571 item 4): long answers open with the one-
// line plain-english summary box, before any block.
if len(segs) > summaryBlockThreshold {
sum := summaryLine(segs)
if styled {
sum = dimStyle.Render(sum)
}
out = append(out, boxAround([]string{sum}, w, styled, narrationBorderStyle)...)
out = append(out, make([]string, blockGapBlankLines)...)
}
var run []mdBlock
flush := func() {
if len(run) == 0 {
@@ -217,23 +288,24 @@ func (p *Pane) answerPresentation(m Message, styled bool, ts string, w int, righ
}
out = append(out, p.bubbleBoxLines(styled, assistantBorderStyle, assistantStyle,
runeStyle, Diamond+" ", body, ts, w, right)...)
out = append(out, make([]string, blockGapBlankLines)...)
ts = "" // one stamp per message
run = nil
}
for _, seg := range mdBlocks(m.Content) {
for _, seg := range segs {
switch seg.kind {
case "table":
flush()
out = append(out, tableLines(seg.rows, w, styled, roleBorderStyle(m.Role))...)
out = append(out, "")
out = append(out, make([]string, blockGapBlankLines)...)
case "heading":
flush()
out = append(out, headingBoxLines(seg.text, w, styled, assistantStyle, roleBorderStyle(m.Role))...)
out = append(out, "")
out = append(out, make([]string, blockGapBlankLines)...)
case "list":
flush()
out = append(out, listBoxLines(seg, w, styled, assistantStyle, roleBorderStyle(m.Role))...)
out = append(out, "")
out = append(out, make([]string, blockGapBlankLines)...)
default:
run = append(run, seg)
}
@@ -266,7 +338,15 @@ func tableLines(rows [][]string, w int, styled bool, border lipgloss.Style) []st
}
}
}
total := w - 4 // the │ pads + edge columns
// the row budget: body row = 1 border + Σ(wc+2) + (cols-1)
// separators + 1 border = Σw + 3*cols + 1 — so Σw ≤ w-3*cols-1.
total := w - 3*cols - 1
if total < cols*3 {
// the pane is too narrow for a 3-wide-minimum table: the
// block drops honestly rather than overflowing the pane (the
// D53 format law — no rendered line ever exceeds the width)
return nil
}
if sum := sumInt(widths); sum > total {
scale := float64(total) / float64(sum)
for c := range widths {
@@ -302,7 +382,11 @@ func tableLines(rows [][]string, w int, styled bool, border lipgloss.Style) []st
}
out = append(out, emit(edge("┌", "┬", "┐")))
for i, r := range rows {
var line string
// the row is ONE leading │, the cells separated by single │
// columns, ONE trailing │ — the same geometry as the ┌/┬/┐
// edge rows (the D53 format law: box rows and box edges share
// one width, the corner misalignment defect never returns).
cells := make([]string, cols)
for c := 0; c < cols; c++ {
v := cell(r, c)
if styled {
@@ -312,9 +396,10 @@ func tableLines(rows [][]string, w int, styled bool, border lipgloss.Style) []st
v = assistantStyle.Render(v)
}
}
line += "│ " + v + " "
cells[c] = " " + v + " "
}
out = append(out, emit(line+"│"))
line := "│" + strings.Join(cells, "│") + "│"
out = append(out, emit(line))
if i == 0 {
out = append(out, emit(edge("├", "┼", "┤")))
}
@@ -423,10 +508,10 @@ func boxAround(rows []string, w int, styled bool, border lipgloss.Style) []strin
func semantic_stripANSI(s string) string { return stripANSI(s) }
var assistantHeaderStyle = lipgloss.NewStyle().
Background(lipgloss.Color("#1E1E28")).Foreground(lipgloss.Color("#7EE787")).Bold(true)
Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#7EE787")).Bold(true)
// assistantRailStyle is the rail's speaker-coloured bar (B2.3).
var assistantRailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF79C6"))
var assistantRailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleSpeaker)))
// roleBorderStyle is the SPEAKER's outline colour (B2.1/2.2: the
// bubble outline and the table border both carry the speaker's
@@ -445,4 +530,4 @@ func roleBorderStyle(role string) lipgloss.Style {
}
// narrationBorderStyle is the narration family's dim outline.
var narrationBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#78788A"))
var narrationBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleMetaDim)))
+5 -2
View File
@@ -174,8 +174,11 @@ func TestLongAnswerNeverBreaksBubble(t *testing.T) {
bottoms++
}
}
if tops != 1 || bottoms != 1 {
t.Fatalf("a long answer must NEVER break the bubble (#2530a): %d tops, %d bottoms", tops, bottoms)
// D54 summary-first: the 20-block answer opens with its one-line
// summary box (a second top), but the BUBBLE itself is never
// broken — exactly one content bubble beyond the summary.
if tops != 2 || bottoms != 2 {
t.Fatalf("a long answer must NEVER break the bubble (#2530a; the D54 summary box rides beside it): %d tops, %d bottoms", tops, bottoms)
}
}
+100 -15
View File
@@ -34,8 +34,10 @@ package chatpane
import (
"fmt"
"math/rand"
"os"
"rune.dev/rune/tui/themes"
"strconv"
"strings"
"time"
@@ -105,7 +107,7 @@ type Style struct {
// DefaultStyle is the D41 amalgamation.
func DefaultStyle() Style {
return Style{Bubbles: true, Vocab: Vocab, Accent: "#FF79C6"}
return Style{Bubbles: true, Vocab: Vocab, Accent: themes.RoleHex(themes.RoleSpeaker)}
}
// ── D43: the direction law + the timestamp law ────────────────────────
@@ -177,6 +179,11 @@ type Message struct {
Content string
Ts string
Importance int
// streaming marks the progressive-render bubble (Gitea #2556):
// ONE pending assistant bubble whose content is replaced as the
// model's bytes arrive; the durable final Append replaces it —
// never a duplicate.
streaming bool
}
// Pane is the chat lane's bounded transcript.
@@ -200,6 +207,10 @@ type Pane struct {
cycle []rune
shuffles int64
// scrollState is the scrollback law's display state (scroll.go).
// streamOpen/streamIdx mark the ONE progressive-render bubble
// (Gitea #2556) while a streamed turn is in flight.
streamOpen bool
streamIdx int
scrollState
// scrollFade is the scrollbar layer's fade countdown (scroll.go's
// scrollbar layer): visible-on-activity, transparent-at-rest.
@@ -214,7 +225,43 @@ type Pane struct {
}
// New returns an empty pane in the default (amalgamation) style.
// ApplyCalm selects the presentation mode (D54 #2571 item 5): vivid
// is the DEFAULT; calm mutes the chroma AT THE TOKEN LAYER (one file:
// tui/themes/roles.go) and re-resolves this package's style sites
// from the role tokens.
func ApplyCalm(on bool) {
themes.SetCalm(on)
refreshRoleStyles()
}
// refreshRoleStyles re-resolves the package-level role styles from
// the themes tokens (they are package vars, resolved once at init —
// a presentation flip must re-resolve them).
func refreshRoleStyles() {
bg := lipgloss.Color(themes.RoleHex(themes.RoleBackground))
dim := lipgloss.Color(themes.RoleHex(themes.RoleMetaDim))
userStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#7FD4FF"))
assistantStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#7EE787"))
systemStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#A0A0B2"))
confirmStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#E3B341"))
researchStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#58A6FF"))
errorStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#F87171"))
toolStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#B8B85E"))
dimStyle = lipgloss.NewStyle().Background(bg).Foreground(dim)
runeStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#C4A0FF"))
tsStyle = lipgloss.NewStyle().Background(bg).Foreground(dim)
accentLiveStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleSpeaker)))
userBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF9ED8"))
assistantBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleSpeaker)))
confirmBorderStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color(themes.RoleHex(themes.RoleSpeaker)))
assistantHeaderStyle = lipgloss.NewStyle().
Background(bg).Foreground(lipgloss.Color("#7EE787")).Bold(true)
assistantRailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleSpeaker)))
narrationBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleMetaDim)))
}
func New() *Pane {
refreshRoleStyles()
p := &Pane{seen: map[string]bool{}, open: map[string]bool{}, expanded: map[string]bool{}}
p.SetStyle(DefaultStyle())
return p
@@ -247,6 +294,34 @@ func (p *Pane) rebuildCycle() {
// Len reports the retained message count.
func (p *Pane) Len() int { return len(p.msgs) }
// UpsertStream installs or extends the progressive-render assistant
// bubble (Gitea #2556): the model's bytes render AS THEY ARRIVE — the
// bubble's content is replaced with the running total, the transcript
// grows no duplicates. It reports whether the transcript changed.
func (p *Pane) UpsertStream(content string) bool {
if p.streamOpen && p.streamIdx >= 0 && p.streamIdx < len(p.msgs) {
p.msgs[p.streamIdx].Content = content
return true
}
p.msgs = append(p.msgs, Message{Role: RoleAssistant, Content: content, streaming: true})
p.streamIdx = len(p.msgs) - 1
p.streamOpen = true
return true
}
// DropStream retires the streaming bubble without a durable answer
// (the dispatch failed): the partial render goes with the honest
// error bubble the caller appends. Reports whether one was open.
func (p *Pane) DropStream() bool {
if !p.streamOpen || p.streamIdx < 0 || p.streamIdx >= len(p.msgs) {
return false
}
p.msgs = append(p.msgs[:p.streamIdx], p.msgs[p.streamIdx+1:]...)
p.streamOpen = false
p.streamIdx = 0
return true
}
// Messages returns a copy of the retained transcript, oldest first.
func (p *Pane) Messages() []Message {
return append([]Message(nil), p.msgs...)
@@ -264,6 +339,16 @@ func (p *Pane) Messages() []Message {
// thinking stream per message). It reports whether the transcript
// changed.
func (p *Pane) Append(m Message) bool {
// the streaming bubble's durable adoption (Gitea #2556): the
// final assistant append REPLACES the progressive-render bubble —
// one fact, never a duplicate.
if m.Role == RoleAssistant && m.ID == "" && p.streamOpen {
p.msgs[p.streamIdx].Content = m.Content
p.msgs[p.streamIdx].Ts = m.Ts
p.msgs[p.streamIdx].streaming = false
p.streamOpen = false
return true
}
key := "id:" + m.ID
if m.ID == "" {
key = "rc:" + m.Role + "\x00" + m.Content
@@ -679,7 +764,7 @@ func (p *Pane) spin() string {
func (p *Pane) accentPhases() []lipgloss.Style {
hex := strings.TrimPrefix(p.style.Accent, "#")
if hex == "" {
hex = "FF79C6"
hex = strings.TrimPrefix(themes.RoleHex(themes.RoleSpeaker), "#")
}
v, err := strconv.ParseUint(hex, 16, 32)
if err != nil || len(hex) != 6 {
@@ -958,35 +1043,35 @@ var (
// assistant effectively invisible). Families kept: cyan=you,
// green=rune/results (D35), grey=system, gold=confirm,
// blue=research, red=errors, olive=tool.
userStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1E1E28")).Foreground(lipgloss.Color("#7FD4FF"))
assistantStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1E1E28")).Foreground(lipgloss.Color("#7EE787"))
systemStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1E1E28")).Foreground(lipgloss.Color("#A0A0B2"))
confirmStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1E1E28")).Foreground(lipgloss.Color("#E3B341"))
researchStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1E1E28")).Foreground(lipgloss.Color("#58A6FF"))
errorStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1E1E28")).Foreground(lipgloss.Color("#F87171"))
toolStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1E1E28")).Foreground(lipgloss.Color("#B8B85E"))
dimStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1E1E28")).Foreground(lipgloss.Color("#78788A"))
userStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#7FD4FF"))
assistantStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#7EE787"))
systemStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#A0A0B2"))
confirmStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#E3B341"))
researchStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#58A6FF"))
errorStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#F87171"))
toolStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#B8B85E"))
dimStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color(themes.RoleHex(themes.RoleMetaDim)))
// runeStyle is Rune's identity marker (D35): the runic glyph rides
// the existing purple accent family (135 — the focused-border tone).
runeStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1E1E28")).Foreground(lipgloss.Color("#C4A0FF"))
runeStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#C4A0FF"))
// tsStyle is the dim timestamp beside each message (D35; the D43
// addendum's emphasis pass: Faint on top of the floor-legal dim
// tone — the terminal's own de-emphasis keeps the stamp unobtrusive
// while the floor-legal tone keeps it legible when attended to).
tsStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1E1E28")).Foreground(lipgloss.Color("#78788A")).Faint(true)
tsStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color(themes.RoleHex(themes.RoleMetaDim)))
// accentLiveStyle is the default accent (D41 pink #FF79C6, 6.9:1)
// — the breathing marker's full-brightness phase.
accentLiveStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF79C6"))
accentLiveStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleSpeaker)))
// D43.9 bubble borders: SOLID (the rounded glyphs are solid) and
// the PINK FAMILY — the same family as the composer's lit outline;
// identity keeps speaking by side and content color (D35). Both
// tones ≥4.5:1 on the base (the readability floor).
userBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF9ED8"))
assistantBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF79C6"))
assistantBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleSpeaker)))
// confirmBorderStyle: the D43.9 law — ALL boxes carry the solid
// PINK FAMILY outline (the earlier gold was the outline-color
// defect); the confirm's warning text keeps its gold tone inside.
confirmBorderStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1E1E28")).Foreground(lipgloss.Color("#FF79C6"))
confirmBorderStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color(themes.RoleHex(themes.RoleSpeaker)))
)
// stampLine renders one text line with its timestamp in the FAR-LEFT
+2 -2
View File
@@ -73,8 +73,8 @@ func TestUserLeftAssistantRight(t *testing.T) {
for len(lines) > 0 && strings.TrimRight(lines[0], " ") == "" {
lines = lines[1:]
}
if len(lines) != 6 { // 3 bubble rows each
t.Fatalf("lines = %d, want 6 (two rounded bubbles):\n%v", len(lines), lines)
if len(lines) != 7 { // 3 bubble rows each + the D54 spacing law's trailing block gap
t.Fatalf("lines = %d, want 7 (two rounded bubbles + one block gap):\n%v", len(lines), lines)
}
if got := lines[1]; !strings.HasPrefix(got, "│ left") {
t.Errorf("user must hug the LEFT (D43.4 LTR), bare content, got %q", got)
+119
View File
@@ -0,0 +1,119 @@
package chatpane
// d53_longanswer_test.go — the LONG-ANSWER FORMAT BREAK wave (Gitea
// #2568 item 1, red-first): a LONG multi-block answer (20+ blocks,
// mixed types) driven through the render path at narrow widths must
// hold format everywhere. The synthesis law it asserts (research/
// factory-nuerodivergent-presentation): CHUNKED, CONSISTENT GEOMETRY
// — every rendered line fits the pane width (no clipping, no
// overflow), every box is well-formed (its top edge, its rows and its
// bottom edge agree on one width), the summary-first box opens the
// answer at ANY length, and appending later blocks never reflows a
// completed block above (the never-reflow law's long-answer leg).
import (
"fmt"
"strings"
"testing"
"rune.dev/rune/tui/semantic"
)
// longAnswer builds a 24-block mixed answer: prose, lists, a table,
// headings — the shapes the parser produces for a real long turn.
func longAnswer() string {
var b strings.Builder
for i := 0; i < 6; i++ {
fmt.Fprintf(&b, "## Section %d\n\n", i)
fmt.Fprintf(&b, "Paragraph %d walks through the reasoning at length, carrying enough words to wrap several times at a narrow pane width so the wrap boundary math is exercised.\n\n", i)
fmt.Fprintf(&b, "- item alpha %d with detail\n", i)
fmt.Fprintf(&b, "- item beta %d with more detail\n\n", i)
if i == 2 {
b.WriteString("| col one | col two | col three |\n| --- | --- | --- |\n| r1a | r1b | r1c |\n| r2a | r2b | r2c |\n\n")
}
}
return b.String()
}
// lineWidths measures each rendered line's display width.
func lineWidths(lines []string) []int {
out := make([]int, len(lines))
for i, l := range lines {
out[i] = len([]rune(semantic.StripANSI(l)))
}
return out
}
// boxEdgesCount checks every box row's width matches its top edge:
// the box's corner glyphs delimit the box; a row that runs past its
// top edge's width is a broken box (the corner misalignment the owner
// sees as format breaking on long answers).
func boxTopWidths(lines []string) map[int]int {
w := map[int]int{}
for i, l := range lines {
plain := semantic.StripANSI(l)
if strings.HasPrefix(plain, "╭") && strings.HasSuffix(plain, "╮") {
w[i] = len([]rune(plain))
}
}
return w
}
func TestLongAnswerHoldsFormatAtNarrowWidths(t *testing.T) {
p := New()
p.Append(Message{ID: "a1", Role: RoleAssistant, Content: longAnswer()})
for _, w := range []int{24, 28, 32, 40, 60, 80, 100} {
lines := p.answerPresentation(p.msgs[0], false, "", w, false)
for i, lw := range lineWidths(lines) {
if lw > w {
t.Fatalf("width %d: rendered line %d is %d columns (%q)", w, i, lw, lines[i])
}
}
// box integrity: for every box top at line i, the box's rows
// and its bottom edge must not run past the top edge's width.
tops := boxTopWidths(lines)
for i := range tops {
if i+1 >= len(lines) {
continue
}
tw := tops[i]
for j := i + 1; j < len(lines); j++ {
plain := semantic.StripANSI(lines[j])
if strings.HasPrefix(plain, "╰") && strings.HasSuffix(plain, "╯") {
if len([]rune(plain)) != tw {
t.Fatalf("width %d: box bottom (line %d) is %d cols, top %d", w, j, len([]rune(plain)), tw)
}
break
}
if strings.HasPrefix(plain, "╭") && strings.HasSuffix(plain, "╮") {
break // a new box opened: the previous one ended
}
}
}
}
}
func TestLongAnswerSummaryFirstAtAnyLength(t *testing.T) {
p := New()
p.Append(Message{ID: "a1", Role: RoleAssistant, Content: longAnswer()})
lines := p.answerPresentation(p.msgs[0], false, "", 60, false)
head := strings.Join(lines[:min(len(lines), 3)], "\n")
if !strings.Contains(head, "Summary:") {
t.Fatalf("a 20+ block answer must open with the summary box, got %q", head)
}
}
func TestLongAnswerNoReflowOnAppend(t *testing.T) {
p := New()
p.Append(Message{ID: "a1", Role: RoleAssistant, Content: longAnswer()})
before := p.answerPresentation(p.msgs[0], false, "", 60, false)
p.Append(Message{ID: "a2", Role: RoleAssistant, Content: "A short later answer."})
after := p.answerPresentation(p.msgs[0], false, "", 60, false)
if len(before) == 0 || len(after) < len(before) {
t.Fatalf("append must not shrink the completed blocks")
}
for i := range before {
if before[i] != after[i] {
t.Fatalf("completed block line %d reflowed on append:\nbefore %q\nafter %q", i, before[i], after[i])
}
}
}
+43
View File
@@ -0,0 +1,43 @@
package chatpane
import "testing"
// D54 item 1 (Gitea #2571): THE NEVER-ANIMATE LAW of the chat pane —
// the pane holds the reading content, so it never animates. The
// append-only assertion: appending a message must NOT change a single
// already-rendered line of the completed blocks above it (stream
// append never reflows completed blocks; the top-anchored stay-put
// scroll law in scroll.go rides the same contract).
func TestAppendNeverReflowsCompletedBlocks(t *testing.T) {
p := New()
p.Append(Message{Role: RoleAssistant, Content: "First answer, a complete paragraph of prose.\n\n- one point\n- another point"})
p.Append(Message{Role: RoleUser, Content: "A user reply."})
w := 60
before, _, _ := p.renderLines(w, true)
p.Append(Message{Role: RoleAssistant, Content: "Second answer with entirely different content.\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n```go\ncode\n```"})
after, _, _ := p.renderLines(w, true)
if len(after) <= len(before) {
t.Fatalf("append must grow the transcript: %d <= %d", len(after), len(before))
}
for i := 0; i < len(before); i++ {
// completed blocks keep their exact rendered lines; the tail
// of the stream may show newly appended content only.
if before[i] != after[i] {
t.Fatalf("append reflowed completed block line %d — the never-animate law forbids it:\nbefore: %q\nafter: %q", i, before[i], after[i])
}
}
p2 := New()
p2.Append(Message{Role: RoleAssistant, Content: "First answer, a complete paragraph of prose.\n\n- one point\n- another point"})
p2.Append(Message{Role: RoleUser, Content: "A user reply."})
plainBefore, _, _ := p2.renderLines(w, false)
p2.Append(Message{Role: RoleAssistant, Content: "Second answer with entirely different content."})
plainAfter, _, _ := p2.renderLines(w, false)
for i := 0; i < len(plainBefore); i++ {
if plainBefore[i] != plainAfter[i] {
t.Fatalf("plain-path append reflowed completed block line %d: %q vs %q", i, plainBefore[i], plainAfter[i])
}
}
}
+64
View File
@@ -0,0 +1,64 @@
package chatpane
import (
"strings"
"testing"
)
// D54 item 3 (Gitea #2571): THE SPACING LAW — exactly ONE blank line
// between blocks; block types never merge (a list is a list, a
// paragraph a paragraph; the parser never joins them; the renderer
// never abuts their boxes).
func TestOneBlankLineBetweenBlocks(t *testing.T) {
p := New()
out := p.answerPresentation(Message{Role: RoleAssistant, Content: "Intro paragraph before the list.\n\n- first item\n- second item\n\n| h1 | h2 |\n| --- | --- |\n| a | b |\n\nClosing paragraph after the table."}, false, "", 60, false)
// no two box borders ever abut, and never two blanks in a row
for i := 1; i < len(out); i++ {
a, b := strings.TrimSpace(out[i-1]), strings.TrimSpace(out[i])
if a == "" && b == "" {
t.Fatalf("two blank lines adjacent at %d — the spacing law allows exactly one:\n%q", i, strings.Join(out, "\n"))
}
if (strings.HasPrefix(a, "╭") && strings.HasPrefix(b, "╭")) ||
(strings.HasPrefix(a, "┌") && strings.HasPrefix(b, "┌")) {
t.Fatalf("two block boxes abut at %d — blocks never merge:\n%q", i, strings.Join(out, "\n"))
}
}
// and between every pair of block regions there is a blank line
joined := strings.Join(out, "\n")
for _, sep := range []string{"┘\n╭", "╯\n╭", "╯\n┌", "┘\n┌"} {
if strings.Contains(joined, sep) {
t.Fatalf("blocks abut without the one-blank-line gap (%q):\n%s", sep, joined)
}
}
}
func TestBlockTypesNeverMerge(t *testing.T) {
blocks := mdBlocks("- alpha\n- beta\nplain paragraph text\n- gamma\n- delta")
var sawList, sawPara bool
for _, b := range blocks {
switch b.kind {
case "list":
sawList = true
for _, it := range b.items {
if it == "plain paragraph text" {
t.Fatal("a paragraph merged into a list block — types never merge")
}
}
case "para":
sawPara = true
if strings.Contains(b.text, "alpha") || strings.Contains(b.text, "delta") {
t.Fatal("a list merged into the paragraph block — types never merge")
}
}
}
if !sawList || !sawPara {
t.Fatalf("expected distinct list and para blocks, got %+v", blocks)
}
}
func TestBlockGapIsPureLayoutConstant(t *testing.T) {
if blockGapBlankLines != 1 {
t.Fatalf("the spacing law fixes ONE blank line between blocks, got %d", blockGapBlankLines)
}
}
+51
View File
@@ -0,0 +1,51 @@
package chatpane
import (
"strings"
"testing"
)
// D54 item 4 (Gitea #2571): SUMMARY-FIRST — an answer longer than the
// threshold opens with a ONE-LINE plain-english summary box: what the
// answer contains, counted literally, before the blocks (COGA 4.4.8;
// the generalize of the "code (n lines)" collapse).
func TestLongAnswerOpensWithSummaryBox(t *testing.T) {
var b strings.Builder
for i := 0; i < 9; i++ {
b.WriteString("Paragraph block of prose the reader has not read yet.\n\n")
}
p := New()
out := p.answerPresentation(Message{Role: RoleAssistant, Content: b.String()}, false, "", 70, false)
joined := strings.Join(out, "\n")
if !strings.Contains(joined, "Summary:") {
t.Fatalf("a 9-block answer must open with a one-line plain-english summary:\n%s", joined)
}
// the summary rides FIRST — before the first content box
if out[0] != "╭"[:1]+"" {
if !strings.HasPrefix(out[0], "╭") {
t.Fatalf("the summary box must be the first block, got %q", out[0])
}
}
if !strings.Contains(out[1], "Summary:") {
t.Fatalf("summary must be one line, got %q", out[1])
}
// literal wording: the count is in the line
if !strings.Contains(out[1], "9") {
t.Fatalf("the summary must count the blocks literally, got %q", out[1])
}
}
func TestShortAnswerHasNoSummary(t *testing.T) {
p := New()
out := p.answerPresentation(Message{Role: RoleAssistant, Content: "One short paragraph."}, false, "", 70, false)
if strings.Contains(strings.Join(out, "\n"), "Summary:") {
t.Fatalf("a 1-block answer must not carry a summary box:\n%s", strings.Join(out, "\n"))
}
}
func TestSummaryThresholdConstant(t *testing.T) {
if summaryBlockThreshold != 8 {
t.Fatalf("the summary threshold is tuned to 8 blocks, got %d", summaryBlockThreshold)
}
}
+35 -13
View File
@@ -23,6 +23,10 @@ package chatpane
import (
"fmt"
"strings"
"charm.land/lipgloss/v2"
"rune.dev/rune/tui/themes"
)
// scrollState is the pane's scroll display state (embedded in Pane).
@@ -284,11 +288,32 @@ func (p *Pane) ScrollbarVisible() bool {
// transcript. Returns "" when the layer is not visible (transparent at
// rest).
func (p *Pane) ScrollbarRow(w int) string {
if !p.ScrollbarVisible() {
rows := p.ScrollbarRows()
if rows == nil {
return ""
}
out := ""
for _, r := range rows {
if r == "" {
out += " "
continue
}
out += "▐"
}
return out
}
// ScrollbarRows renders the scrollbar layer as ONE string per pane row
// (the D53 #2568 repair): the thumb's rows carry the RoleScroll token
// colour (the D54 colour law — the bar is scheme-matched yet distinct,
// a role of its own), every other row is "" (no overlay). Nil when the
// layer is not visible.
func (p *Pane) ScrollbarRows() []string {
if !p.ScrollbarVisible() {
return nil
}
if p.scrollState.lastLines <= 0 || p.scrollState.lastRows <= 0 {
return ""
return nil
}
total := max(p.scrollState.lastLines, 1)
visible := max(p.scrollState.lastRows-1, 1)
@@ -302,16 +327,13 @@ func (p *Pane) ScrollbarRow(w int) string {
maxOff := max(total-visible, 1)
pos = int(float64(p.scrollState.lastRows-thumb) * (float64(p.scrollState.offset) / float64(maxOff)))
}
row := make([]rune, p.scrollState.lastRows)
for i := range row {
row[i] = ' '
rows := make([]string, p.scrollState.lastRows)
for i := pos; i < pos+thumb && i < len(rows); i++ {
rows[i] = scrollThumbStyle.Render("▐")
}
for i := pos; i < pos+thumb && i < len(row); i++ {
row[i] = '▐'
}
out := string(row)
if w > 0 && len(out) > w {
out = out[:w]
}
return out
return rows
}
// scrollThumbStyle is the thumb's token colour: RoleScroll, defined
// once in themes (the D54 law — one hex per role).
var scrollThumbStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleScroll)))
+58
View File
@@ -0,0 +1,58 @@
package chatpane
// stream_render_test.go — the progressive-render bubble's frozen law
// (Gitea #2556, red-first): one streaming bubble grows with the
// running total, the durable final append REPLACES it (never a
// duplicate), and a failed dispatch retires it.
import "testing"
func TestUpsertStreamGrowsOneBubble(t *testing.T) {
p := New()
if !p.UpsertStream("hel") {
t.Fatal("first upsert reported no change")
}
p.UpsertStream("hello")
if p.Len() != 1 {
t.Fatalf("transcript = %d messages, want ONE streaming bubble", p.Len())
}
if p.Messages()[0].Content != "hello" {
t.Fatalf("content = %q", p.Messages()[0].Content)
}
}
func TestFinalAppendAdoptsStreamBubble(t *testing.T) {
p := New()
p.UpsertStream("hel")
p.UpsertStream("hello wo")
ok := p.Append(Message{Role: RoleAssistant, Content: "hello world", Ts: "t"})
if !ok {
t.Fatal("final append reported no change")
}
if p.Len() != 1 {
t.Fatalf("transcript = %d messages, want the adopted ONE", p.Len())
}
m := p.Messages()[0]
if m.Content != "hello world" || m.Ts != "t" || m.streaming {
t.Fatalf("adopted bubble = %+v", m)
}
}
func TestDropStreamRetiresPartial(t *testing.T) {
p := New()
p.UpsertStream("par")
if !p.DropStream() {
t.Fatal("drop reported no change")
}
if p.Len() != 0 {
t.Fatalf("transcript = %d, want empty", p.Len())
}
if p.DropStream() {
t.Fatal("second drop reported change")
}
// a fresh stream can open after the drop
p.UpsertStream("new")
if p.Len() != 1 {
t.Fatalf("reopen = %d messages", p.Len())
}
}
+154
View File
@@ -0,0 +1,154 @@
package main
// d50_pty_test.go — the D50-residual PTY proofs (#2560): the REAL face
// binary on a REAL pseudo-terminal (the boot_smoke method) proves the
// two owner-critical behaviors:
//
// 1. ENTER DISPATCH (D50 residual 3): with the tasks pane focused and
// the tree region OPEN (the /tree road — the click road's keyboard
// parity), typing a work demand and pressing enter DISPATCHES it
// through the task-queue admission surface (the honest transport
// receipt renders) — the region never steals enter.
// 2. PIN PERSISTENCE (D50 residual 4): a pin recorded in the
// hot-state store (tasks_pins.json) before boot renders in the
// reserved pinned area after a REAL boot — the pins survive.
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"syscall"
"testing"
"time"
"rune.dev/rune/tui/semantic"
)
func TestD50PtyEnterDispatchAndPinPersistence(t *testing.T) {
if testing.Short() {
t.Skip("boots the real face (build + pty); skipped in -short")
}
if runtime.GOOS != "linux" {
t.Skipf("pty boot method is linux (/dev/ptmx); not %s", runtime.GOOS)
}
if _, err := exec.LookPath("go"); err != nil {
t.Skip("no go toolchain in PATH — cannot build the face")
}
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("cannot locate module root")
}
tuiRoot := filepath.Dir(filepath.Dir(filepath.Dir(thisFile)))
repoRoot := filepath.Dir(tuiRoot)
bin := filepath.Join(t.TempDir(), "rune-tui")
build := exec.Command("go", "build", "-o", bin, "./cmd/rune-tui")
build.Dir = tuiRoot
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("face build failed: %v\n%s", err, out)
}
master, slave, err := openPty()
if err != nil {
t.Fatalf("pty: %v", err)
}
defer master.Close()
sandbox := t.TempDir()
// the pre-written pin record (the hot-state store's boot leg): a
// pin that must render after the REAL boot.
pin := `{"pins":[{"id":"pin_1","task_id":"task_1","summary":"keep the reflow squash test green","ts":"2026-10-22T12:00:00Z"}]}` + "\n"
if err := os.WriteFile(filepath.Join(sandbox, "tasks_pins.json"), []byte(pin), 0o600); err != nil {
t.Fatalf("pin record: %v", err)
}
if err := saveProviderSelection(sandbox, providerSelection{
Provider: "local", Model: "test-model", SelectedAt: "2026-10-19T00:00:00Z"}); err != nil {
t.Fatalf("provider record: %v", err)
}
cmd := exec.Command(bin)
cmd.Stdin, cmd.Stdout, cmd.Stderr = slave, slave, slave
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 0}
cmd.Env = append(os.Environ(),
"RUNE_LIB_REPO="+repoRoot,
"RUNE_HOME="+sandbox,
"RUNE_MEMORY_DIR="+filepath.Join(sandbox, "memory"),
"TERM=xterm-256color",
)
if err := cmd.Start(); err != nil {
slave.Close()
t.Fatalf("face start: %v", err)
}
slave.Close()
exited := make(chan error, 1)
go func() { exited <- cmd.Wait() }()
var seen strings.Builder
buf := make([]byte, 4096)
waitFor := func(markers []string, budget time.Duration) {
deadline := time.Now().Add(budget)
for time.Now().Before(deadline) {
plainNow := semantic.StripANSI(seen.String())
found := false
for _, marker := range markers {
if strings.Contains(plainNow, marker) {
found = true
break
}
}
if found {
return
}
select {
case err := <-exited:
t.Fatalf("face exited (rc=%v) waiting for %v — output:\n%.2000s",
err, markers, semantic.StripANSI(seen.String()))
default:
}
if err := master.SetReadDeadline(time.Now().Add(1 * time.Second)); err != nil {
t.Fatalf("pty read deadline: %v", err)
}
n, err := master.Read(buf)
if n > 0 {
seen.Write(buf[:n])
}
if err != nil && !os.IsTimeout(err) {
t.Fatalf("pty read: %v — output:\n%.2000s", err,
semantic.StripANSI(seen.String()))
}
}
_ = cmd.Process.Kill()
<-exited
t.Fatalf("never saw any of %v within %v — output:\n%.2000s",
markers, budget, semantic.StripANSI(seen.String()))
}
waitFor([]string{"┌"}, 240*time.Second)
// 2. PIN PERSISTENCE (boot leg): open the tasks pane's region via
// the /tree road — the pre-written pin must render.
if _, err := master.Write([]byte("/tree\r")); err != nil {
t.Fatalf("/tree write: %v", err)
}
waitFor([]string{"pinned (1)"}, 60*time.Second)
plain := semantic.StripANSI(seen.String())
if !strings.Contains(plain, "keep the reflow squash test green") {
t.Fatalf("the pin's plain-english summary must render:\n%.2000s", plain)
}
// 1. ENTER DISPATCH under the OPEN region: focus tasks, type the
// demand, enter. The admission surface is real (the library seam);
// its honest verdict — delegated, queued, or the honest transport
// refusal — must land. Silence would be the stolen enter.
if _, err := master.Write([]byte("2")); err != nil {
t.Fatalf("focus write: %v", err)
}
time.Sleep(2 * time.Second)
waitFor([]string{"task entry"}, 30*time.Second)
if _, err := master.Write([]byte("wire the stream\r")); err != nil {
t.Fatalf("demand write: %v", err)
}
waitFor([]string{"work queued (", "work started:", "work demand not admitted", "no task-queue surface on this seam"}, 60*time.Second)
_ = cmd.Process.Kill()
<-exited
}
+136
View File
@@ -0,0 +1,136 @@
package main
// d52_picker_window_test.go — the D52 model-picker WINDOW law (#2567,
// owner spec): clicking the info bar's model token opens a SEPARATE
// CENTERED window that is fully selectable and searchable, split
// CLOUD and LOCAL sections — type-to-filter, up/down through results,
// enter selects, the sections labelled. Keyboard parity: ctrl+m keeps
// working; the window road is the click. NO crossfire: a model-token
// click never opens the activity (thinking) window, and a + click
// never opens the picker.
import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"rune.dev/rune/tui/runtime/lib"
"rune.dev/rune/tui/semantic"
)
func pickerFixture(t *testing.T) model {
t.Helper()
m := sized(t, initialModel(), 80, 24)
m.prov = &providerSelection{Provider: "local", Model: "qwen3.6-27b"}
m.provOpts = []lib.ModelRow{
{ID: "gpt-4o-mini", OwnedBy: "openai"},
{ID: "claude-sonnet-4", OwnedBy: "anthropic"},
{ID: "qwen2.5:7b", OwnedBy: "ollama"},
{ID: "llava:7b", OwnedBy: "ollama"},
}
return m
}
// TestPickerWindowSplitsCloudAndLocal — the picker's rendered rows
// carry the CLOUD and LOCAL section labels, the cloud rows first in
// the cloud section, locals under LOCAL, and the selection cursor
// walks BOTH sections as one list.
func TestPickerWindowSplitsCloudAndLocal(t *testing.T) {
m := pickerFixture(t)
m.openPicker()
rows := m.face.MenuSnapshot()
joined := strings.Join(rows, "\n")
cloudIdx := strings.Index(joined, "CLOUD")
localIdx := strings.Index(joined, "LOCAL")
if cloudIdx < 0 || localIdx < 0 {
t.Fatalf("the picker window must carry CLOUD and LOCAL sections:\n%s", joined)
}
if cloudIdx > localIdx {
t.Fatal("CLOUD section renders before LOCAL")
}
gpt := strings.Index(joined, "gpt-4o-mini")
qwen := strings.Index(joined, "qwen2.5:7b")
if !(cloudIdx < gpt && gpt < localIdx && localIdx < qwen) {
t.Fatalf("cloud rows must sit in the CLOUD section, locals in LOCAL:\n%s", joined)
}
}
// TestPickerWindowFilterWalksEnterChooses — the window is fully
// selectable and searchable: typing filters across both sections,
// up/down walks the filtered rows, enter records the pick and closes.
func TestPickerWindowFilterWalksEnterChooses(t *testing.T) {
m := pickerFixture(t)
m.openPicker()
// type to filter
next, _ := m.Update(tea.KeyPressMsg{Code: 'q'})
m2 := next.(model)
rows := m2.pickerRows()
if len(rows) == 0 || !strings.Contains(rows[0].ID, "qwen") {
t.Fatalf("typing must filter the rows, got %+v", rows)
}
// enter chooses the filtered row
pick := rows[m2.pickerSel]
next, _ = m2.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
m3 := next.(model)
if m3.pickerOpen {
t.Fatal("enter must close the picker window")
}
if m3.prov == nil || m3.prov.Model != pick.ID {
t.Fatalf("enter must record the pick, got %+v", m3.prov)
}
}
// TestModelTokenClickNoCrossfire — the D52 dispatch law: a click on
// the model token opens the PICKER and NEVER the activity window; a
// click on the + affordance opens the ACTIVITY window and never the
// picker. Both roads, both windows, one assertion each way.
func TestModelTokenClickNoCrossfire(t *testing.T) {
m := pickerFixture(t)
x0, w := modelTokenXRange(m)
y := m.face.Snapshot().H
next, _ := m.Update(tea.MouseClickMsg{X: x0 + w/2, Y: y})
m2 := next.(model)
if !m2.pickerOpen {
t.Fatal("the model-token click must open the picker window")
}
if m2.face.ActivityWindowOpen() {
t.Fatal("CROSSFIRE: the model-token click must NOT open the activity (thinking) window")
}
// the + road: the face's + region click toggles the activity
// window and must not open the picker
m3 := pickerFixture(t)
fr := m3.face.Render()
var reg semantic.Region
for _, r := range fr.Regions {
if r.ID == "mouse.activity.plus" {
reg = r
}
}
next, _ = m3.Update(tea.MouseClickMsg{X: reg.X, Y: reg.Y})
m4 := next.(model)
if !m4.face.ActivityWindowOpen() {
t.Fatal("the + click must open the activity window")
}
if m4.pickerOpen {
t.Fatal("CROSSFIRE: the + click must NOT open the picker")
}
}
// TestCtrlMParity — keyboard parity: ctrl+m still opens the picker
// window (the window road is the click; ctrl+m keeps working).
func TestCtrlMParity(t *testing.T) {
m := pickerFixture(t)
next, _ := m.Update(tea.KeyPressMsg{Code: 'm', Mod: tea.ModCtrl})
m2 := next.(model)
if !m2.pickerOpen && !m2.menuOpen {
t.Fatal("ctrl+m must keep opening the model chooser")
}
// its rendered chooser carries the split sections too
m3 := pickerFixture(t)
m3.openPicker()
joined := strings.Join(m3.face.MenuSnapshot(), "\n")
if !strings.Contains(joined, "CLOUD") || !strings.Contains(joined, "LOCAL") {
t.Fatalf("the ctrl+m road shares the split window:\n%s", joined)
}
}
+74
View File
@@ -0,0 +1,74 @@
package main
// d53_agents_shell_test.go — the SUB-AGENTS wave's shell leg (Gitea
// #2568 item 7): keyboard parity ('w' opens the centered window when
// agents are in flight, the window is modal: esc/up/down/enter,
// any other key closes) and the flight-free no-op.
import (
"strings"
"testing"
"rune.dev/rune/tui/lanes"
)
func withAgentsInFlight(m model) model {
m.face.SetWaveAgents(2, []lanes.AgentView{
{ID: "w1", Title: "land the scrollbar wave", State: "BUILD",
Detail: []string{"journal: admit green"}},
{ID: "w2", Title: "polish the composer hints", State: "VERIFY"},
})
return m
}
func TestCtrlWOpensAgentsWindowInFlight(t *testing.T) {
m := withAgentsInFlight(initialModel())
nx, _ := m.Update(ctrlKey("w"))
if !nx.(model).face.AgentsWindowOpen() {
t.Fatal("ctrl+w must open the agents window while agents are in flight")
}
}
func TestCtrlWNoopWithoutFlight(t *testing.T) {
m := initialModel()
nx, _ := m.Update(ctrlKey("w"))
if nx.(model).face.AgentsWindowOpen() {
t.Fatal("ctrl+w without a flight must not open any window")
}
}
func TestAgentsWindowModalKeys(t *testing.T) {
m := withAgentsInFlight(initialModel())
nx, _ := m.Update(ctrlKey("w"))
m2 := nx.(model)
// down walks the cursor
nx, _ = m2.Update(keyPress("down"))
m2 = nx.(model)
// esc closes
nx, _ = m2.Update(keyPress("esc"))
m2 = nx.(model)
if m2.face.AgentsWindowOpen() {
t.Fatal("esc must close the agents window")
}
// any other key closes the modal too
nx, _ = withAgentsInFlight(m).Update(keyPress("w"))
m2 = nx.(model)
nx, _ = m2.Update(keyPress("x"))
m2 = nx.(model)
if m2.face.AgentsWindowOpen() {
t.Fatal("any other key must close the modal window")
}
}
func TestAgentsWindowOverlayCentersInFrame(t *testing.T) {
m := withAgentsInFlight(initialModel())
nx, _ := m.Update(ctrlKey("w"))
m2 := nx.(model)
v := m2.View()
content := v.Content
if !strings.Contains(content, "sub agents") || !strings.Contains(content, "land the scrollbar wave") {
t.Fatalf("the rendered frame must carry the centered agents window")
}
if !strings.Contains(content, "journal: admit green") {
t.Fatalf("the selected agent's detail must render")
}
}
+52
View File
@@ -0,0 +1,52 @@
package main
// d53_quithint_test.go — the QUIT WARNING (Gitea #2568 item 3,
// red-first): after the FIRST ctrl+c of the D46 exit gesture a soft
// hint "press ctrl+c again to quit" rides near the shift+enter key
// hint (the composer's hint line). The hint disappears when the arm
// window times out or ANY other key arrives. The SECOND ctrl+c never
// shows it — that press quits.
import (
"strings"
"testing"
)
const wantQuitHint = "press ctrl+c again to quit"
func composerHintOf(m model) string {
return m.face.Hint()
}
func TestFirstCtrlCShowsQuitHint(t *testing.T) {
nx, _ := initialModel().Update(ctrlKey("c"))
got := composerHintOf(nx.(model))
if !strings.Contains(got, wantQuitHint) {
t.Fatalf("after the first ctrl+c the composer hint must carry the quit warning, got %q", got)
}
}
func TestQuitHintClearsOnTimeout(t *testing.T) {
nx, _ := initialModel().Update(ctrlKey("c"))
// the arm window's own reset message (the 1s tea.Tick payload)
nx, _ = nx.Update(ctrlCArmReset{})
got := composerHintOf(nx.(model))
if strings.Contains(got, wantQuitHint) {
t.Fatalf("the quit hint must disappear when the arm window times out, got %q", got)
}
}
func TestQuitHintClearsOnAnyOtherKey(t *testing.T) {
nx, _ := initialModel().Update(ctrlKey("c"))
nx, _ = nx.Update(keyPress("x"))
got := composerHintOf(nx.(model))
if strings.Contains(got, wantQuitHint) {
t.Fatalf("the quit hint must disappear on any other key, got %q", got)
}
}
func TestSecondCtrlCQuitsWithoutHint(t *testing.T) {
nx, _ := initialModel().Update(ctrlKey("c"))
if !quits(t, nx.(model), ctrlKey("c")) {
t.Fatal("the second ctrl+c must quit (the D46 gesture holds)")
}
}
+81
View File
@@ -0,0 +1,81 @@
package main
// d53_reflow_test.go — the DYNAMIC REFLOW wave (Gitea #2568 item 4,
// red-first): driving a resize event through the face chain must
// re-wrap every bubble and box at the new width — the squash test: NO
// rendered line may exceed the new width and NO stale width may
// survive (a line wider than the pane, or a box edge left at the old
// geometry, is a clip).
import (
"fmt"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"rune.dev/rune/tui/chatpane"
"rune.dev/rune/tui/semantic"
)
// frameLinesWidths renders the model's face and reports any line
// wider than w.
func overwideLines(t *testing.T, m model, w int) []string {
t.Helper()
f := m.face.Render()
var bad []string
for i, ln := range strings.Split(f.Content, "\n") {
if n := len([]rune(semantic.StripANSI(ln))); n > w {
bad = append(bad, fmt.Sprintf("line %d is %d cols: %q", i, n, ln[:min(60, len(ln))]))
}
}
return bad
}
func TestWindowSquashReflowsEveryBlock(t *testing.T) {
m := initialModel()
long := ""
for i := 0; i < 10; i++ {
long += fmt.Sprintf("## Heading %d\n\nParagraph %d with plenty of words to wrap several times at any pane width the squash test picks.\n\n- bullet one %d\n- bullet two %d\n\n", i, i, i, i)
}
m.face.AppendChat(chatpane.Message{ID: "a1", Role: chatpane.RoleAssistant, Content: long})
m.face.Resize(120, 30)
if bad := overwideLines(t, m, 120); len(bad) > 0 {
t.Fatalf("at width 120: %v", bad)
}
// the squash: drive the resize event through the face chain
nx, _ := m.Update(tea.WindowSizeMsg{Width: 44, Height: 20})
m2 := nx.(model)
if bad := overwideLines(t, m2, 44); len(bad) > 0 {
t.Fatalf("after squash to 44: %v", bad)
}
// and back out: the geometry must follow the new width again
nx, _ = m2.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
m3 := nx.(model)
if bad := overwideLines(t, m3, 100); len(bad) > 0 {
t.Fatalf("after regrow to 100: %v", bad)
}
}
func TestWindowSquashKeepsScrollStateLegal(t *testing.T) {
m := initialModel()
for i := 0; i < 40; i++ {
m.face.AppendChat(chatpane.Message{ID: fmt.Sprintf("a%d", i), Role: chatpane.RoleAssistant,
Content: fmt.Sprintf("body line of transcript prose %d", i)})
}
m.face.Resize(120, 30)
m.face.Render() // establish the scroll geometry (the last-render state)
m.face.ScrollChatUp(10)
if got := m.face.ChatScrollOffset(); got != 10 {
t.Fatalf("offset must survive the pre-squash scroll, got %d", got)
}
nx, _ := m.Update(tea.WindowSizeMsg{Width: 40, Height: 16})
m2 := nx.(model)
// the reflow changes line counts; the offset must normalize to a
// legal window (never past the oldest held line, never negative)
if off := m2.face.ChatScrollOffset(); off < 0 {
t.Fatalf("offset went negative after squash: %d", off)
}
if bad := overwideLines(t, m2, 40); len(bad) > 0 {
t.Fatalf("after squash: %v", bad)
}
}
+64
View File
@@ -0,0 +1,64 @@
package main
import (
"os"
"regexp"
"strings"
"testing"
)
// D54 item 6 (Gitea #2571): THE LITERAL-LANGUAGE AUDIT — face strings
// (affordances, notices, placeholders, hints) use literal language:
// no idioms, no figures of speech. This frozen audit scans the TUI's
// user-facing string sites for the idioms the audit found and
// rewrote:
//
// BEFORE: "consent enqueue in flight — wait for its receipt"
// AFTER: "consent enqueue being processed now — wait for its receipt"
// BEFORE: "one completion in flight — phase 1 sends one at a time"
// AFTER: "one completion being processed now — ..."
// BEFORE: "approval resolution in flight — wait for its receipt"
// AFTER: "approval resolution being processed now — ..."
// BEFORE: "the engine answered without a verdict" (legal metaphor)
// AFTER: "the engine answered without a clear result"
// BEFORE: "↑/↓ walk · enter choose" ("walk" a list)
// AFTER: "↑/↓ move · enter choose"
// BEFORE: "(none in flight)" (the + window's empty sections)
// AFTER: "(none now)"
// BEFORE: "state %s · %02d:%02d in flight" (the wave detail line)
// AFTER: "state %s · running %02d:%02d"
//
// Kept (term of art, not idiom): "seam" (the wiring seam — the
// synthesis itself cites "no task seam wired — work demand not sent"
// as good literal style), "brief" (the work record's name), "card"
// (the approval component's name).
func TestFaceStringsAreLiteral(t *testing.T) {
banned := []string{"in flight", "without a verdict", "walk · enter"}
sites := []string{
"main.go",
"../../lanes/activity.go",
"../../chatpane/chatpane.go",
"../../chatpane/blocks.go",
}
strLit := regexp.MustCompile(`"(?:[^"\\]|\\.)*"`)
for _, site := range sites {
b, err := os.ReadFile(site)
if err != nil {
t.Fatalf("read %s: %v", site, err)
}
for lineNo, line := range strings.Split(string(b), "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "//") {
continue // comments are not face
}
for _, m := range strLit.FindAllString(line, -1) {
low := strings.ToLower(m)
for _, phrase := range banned {
if strings.Contains(low, phrase) {
t.Fatalf("%s:%d: idiom %q in a face string — rewrite literally (%q)", site, lineNo+1, phrase, m)
}
}
}
}
}
}
+92
View File
@@ -0,0 +1,92 @@
package main
import (
"fmt"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"rune.dev/rune/tui/runtime/lib"
"rune.dev/rune/tui/taskqueue"
)
// D50 residual 3 (#2560, owner ruling): the tasks pane's row-selection/
// navigation mode is REMOVED — the walker (up/down/j/k over tree rows,
// the cursor, the row boxes) is gone, and the tree region is a VIEW
// that never owns the keys. Enter on tasks-pane focus starts direct
// task execution (D44 doctrine) — EVEN after a click-focus that opened
// the tree region.
func TestD50TasksFocusEnterDispatchesAfterClickFocus(t *testing.T) {
configuredHome(t)
bootCompleter = &queueSeamFake{}
defer func() { bootCompleter = nil }()
m := sized(t, initialModel(), 110, 30)
m2, _ := m.Update(keyPress("2"))
m = m2.(model)
// the click-focus road: the tree region shows (the pane click /
// palette road opened it). The walker is gone — the region is a
// view, and it must never own the keyboard.
m.face.SetTreeOpen(true)
if m.composerActive() != true {
t.Fatal("the tree region is a view now (no selectable rows): it must not own the keyboard — the composer stays active")
}
for _, r := range "wire the stream" {
next, _ := m.Update(keyPress(string(r)))
m = next.(model)
}
if got := m.taskInput.Text(); got != "wire the stream" {
t.Fatalf("typing under the open tree region must reach the shared box, got %q", m.taskInput.Text())
}
m2, cmd := m.Update(enterPress())
m = m2.(model)
if cmd == nil {
t.Fatal("enter with tasks focus must dispatch the work demand immediately, even with the tree region open")
}
var msg tea.Msg
for _, mm := range runBatch(cmd) {
msg = mm
}
if _, ok := msg.(taskDemandMsg); !ok {
t.Fatalf("the cmd must answer a taskDemandMsg, got %T", msg)
}
m2, _ = m.Update(msg)
m = m2.(model)
if got := m.face.QueueNotice(); !strings.Contains(got, "work queued (") {
t.Fatalf("the honest queue receipt missing, got %q", got)
}
}
// up/down under tasks focus no longer walk rows (the walker is
// removed) — they scroll the tasks panel's own scroll window.
func TestD50TasksArrowsScrollNotWalk(t *testing.T) {
configuredHome(t)
m := sized(t, initialModel(), 110, 30)
m2, _ := m.Update(keyPress("2"))
m = m2.(model)
m.face.SetTreeOpen(true)
// seed panel overflow so the scroll window has room (40 fresh rows)
queue := make([]taskqueue.Entry, 0, 40)
for i := 0; i < 40; i++ {
queue = append(queue, taskqueue.Entry{EntryID: fmt.Sprintf("q%02d", i),
Text: fmt.Sprintf("demand %02d", i),
QueuedAt: time.Now().Add(-time.Duration(i) * time.Minute)})
}
m.face.SetLiveWork(nil, queue, time.Now())
m.face.Render()
before := m.face.TasksScrollOffset()
m2, _ = m.Update(keyPress("up"))
m = m2.(model)
if m.face.TasksScrollOffset() == before {
t.Fatal("up under tasks focus must scroll the tasks panel (the row walker is removed)")
}
m2, _ = m.Update(keyPress("down"))
m = m2.(model)
if m.face.TasksScrollOffset() != before {
t.Fatalf("down must return the scroll window to rest, got %d want %d", m.face.TasksScrollOffset(), before)
}
}
var _ = lib.TaskEnqueueRequest{}
+13
View File
@@ -191,3 +191,16 @@ func TestOSCDetectionDenylist(t *testing.T) {
t.Error("KONSOLE_VERSION must stay a carrier fast path")
}
}
// D53 #2568 item 5: the context readout carries the usage percentage
// in brackets, computed from the existing used/limit pair.
func TestCtxPercent(t *testing.T) {
for _, c := range []struct {
used, limit int64
want int
}{{4200, 32768, 12}, {16384, 32768, 50}, {32768, 32768, 100}, {40000, 32768, 100}, {0, 32768, 0}, {100, 0, 0}} {
if got := ctxPercent(c.used, c.limit); got != c.want {
t.Errorf("ctxPercent(%d,%d) = %d, want %d", c.used, c.limit, got, c.want)
}
}
}
+406 -53
View File
@@ -157,16 +157,19 @@ type model struct {
// is the dichotomy build wave). The box renders the FOCUSED
// machine's view; typed keys keep routing by focus exactly as
// before (composerActive: only chat focus types).
composer *nav.Input3
taskInput *nav.Input3
km *nav.KeyMap
disp *laneconc.Dispatcher
chatBusy bool
lastCtrlC bool // the exit-gesture arm: second ctrl+c quits (D46)
chatSt string // "" (never sent) | sending | waiting | received | error
pendingSent string // the in-flight text (the waiting leg's match)
spin int // the animation frame (the glyph cycle's index)
blink int // the composer cursor's blink phase (D43.14)
composer *nav.Input3
taskInput *nav.Input3
km *nav.KeyMap
disp *laneconc.Dispatcher
chatBusy bool
lastCtrlC bool // the exit-gesture arm: second ctrl+c quits (D46)
chatSt string // "" (never sent) | sending | waiting | received | error
// chatStreamBuf is the running total of the streamed turn's text
// (Gitea #2556): each delta renders into the progressive bubble.
chatStreamBuf string
pendingSent string // the in-flight text (the waiting leg's match)
spin int // the animation frame (the glyph cycle's index)
blink int // the composer cursor's blink phase (D43.14)
// turnStart feeds the identity line's mm:ss turn duration (D41:
// durations — how long things take — stay visible while a turn
// flies).
@@ -188,6 +191,7 @@ type model struct {
styleIdx int
tickArmed bool
reducedMotion bool
calm bool
// THE SCAFFOLDING STATUS LINE layer's shell state (D43 addendum 5):
// the walk frame (advanced on the shared ~500ms heartbeat) and the
@@ -281,6 +285,14 @@ type model struct {
// (selection.go) — anchor/head/pane. Shell-owned display state,
// never face state.
sel selState
// hintWord is the slash word the cursor is parked on (a new word
// resets the selection to the first row).
hintWord string
// hintSel is the '/'-command submenu's selection cursor (D50 item
// 5, #2555-7): up/down move it over the matching hint rows, enter
// runs the highlighted command. 0 rests on the first row.
hintSel int
}
func initialModel() model {
@@ -318,6 +330,14 @@ func initialModel() model {
// style as the default variant.
m.gaugesView = gauges.New(time.Now().UnixNano())
m.reducedMotion = os.Getenv("RUNE_REDUCED_MOTION") != ""
// D54 item 5 (#2571): the sensory setting — VIVID is the DEFAULT
// (the owner's ruling); calm mutes the chroma at the token layer
// and closes the activity wave's motion gate. The settings store
// owns the key (themes.sensory); the env seam RUNE_SENSORY=calm
// is the boot-time carrier, same shape as RUNE_REDUCED_MOTION.
m.calm = os.Getenv("RUNE_SENSORY") == "calm"
chatpane.ApplyCalm(m.calm)
lanes.ApplyCalm(m.calm)
if root := lib.DefaultRepoRoot(); root != "" {
m.fwave = factorywave.New(root, factorywave.DefaultEvery).Start()
m.syncWaves()
@@ -334,6 +354,9 @@ func initialModel() model {
// absent or corrupt record answers the old TUI's default 8; a
// deeper record deepens the bridge's poll window with it.
m.bootDepth = loadHistoryDepth(m.home)
// the PINNED WORK hot-state record (D50 residual 4): the pins
// survive the boot — the record loads beside the history depth.
m.face.SetPins(loadPins(m.home))
if m.rt != nil {
m.rt.SetChatDepth(m.bootDepth)
}
@@ -357,6 +380,22 @@ type chatCompletionMsg struct {
text string
}
// chatStreamStartMsg opens the progressive-render leg of a dispatch
// (Gitea #2556): deltas arrive over the channel while the completion
// goroutine runs; the report lands through done when the turn settles.
type chatStreamStartMsg struct {
deltas <-chan string
done <-chan laneconc.LaneReport
text string
}
// chatStreamDeltaMsg is one streamed text chunk: the running total
// renders into the chat pane's progressive bubble immediately.
type chatStreamDeltaMsg struct {
start chatStreamStartMsg
delta string
}
// approvalReceiptMsg is one answered approval decision (the K20
// pattern's resolve leg): the Update thread armed the decision, the
// goroutine POSTed /v1/approvals/resolve off-thread, this message
@@ -478,21 +517,55 @@ func dispatchProviderClass(m model) string {
}
// chatDispatch runs one composer send through the lane spine off the
// Update thread (the K20 pattern): the ctx carries the WHOLE-TURN
// budget for the provider class (D47 defect 3 — a dispatched
// completion runs the core's tool loop, up to ToolLoopIterations model
// calls; #2536: locals get the extended class budget, cloud the
// shorter one). No face mutation happens here; the goroutine only
// touches the seam.
// Update thread (the K20 pattern) — STREAMING (Gitea #2556, the
// streaming root-cause wave): the dispatch rides stream=true, the
// answer's bytes surface through the lane dispatcher's delta sink, and
// the Update thread renders each chunk as it lands. The streamed turn
// retires the flat whole-turn budgets: the ctx binds the 30-minute
// backstop only — the CORE's inter-chunk idle timeout is the real
// deadline (no bytes = genuinely dead). No face mutation happens in
// the goroutines; they only touch the seam.
func chatDispatch(m model, text string) tea.Cmd {
deltas := make(chan string, 256)
done := make(chan laneconc.LaneReport, 1)
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), lib.TurnBudgetFor(dispatchProviderClass(m)))
ctx, cancel := context.WithTimeout(context.Background(), streamDispatchBackstop)
defer cancel()
rep, _ := m.disp.Dispatch(ctx, laneconc.Chat, text)
return chatCompletionMsg{rep: rep, text: text}
m.disp.SetDeltaSink(func(lane laneconc.Lane, s string) {
if lane == laneconc.Chat {
select {
case deltas <- s:
default: // the render thread keeps up; never block the stream
}
}
})
go func() {
defer close(deltas)
rep, _ := m.disp.Dispatch(ctx, laneconc.Chat, text)
done <- rep
}()
return chatStreamStartMsg{deltas: deltas, done: done, text: text}
}
}
// awaitStream is the streaming receive loop as tea commands: each
// delta returns its message and re-arms; the closing report lands as
// the classic chatCompletionMsg.
func awaitStream(start chatStreamStartMsg) tea.Cmd {
return func() tea.Msg {
delta, ok := <-start.deltas
if ok {
return chatStreamDeltaMsg{start: start, delta: delta}
}
return chatCompletionMsg{rep: <-start.done, text: start.text}
}
}
// streamDispatchBackstop mirrors the core's StreamTurnBackstop: the
// TUI's dispatch ctx on the streamed path is a runaway guard only —
// the inter-chunk idle timeout lives at the core (Gitea #2556).
const streamDispatchBackstop = 30 * time.Minute
// composerActive reports whether the composer owns the next key: no
// chrome boundary (tree region, promote region, rail takeover, modal)
// owns the keys. D36 owner fix 2026-10-19: the shared box is the SINGLE
@@ -560,6 +633,23 @@ type ctrlCArmReset struct{}
const spinPeriod = 500 * time.Millisecond
// quitHint is the D53 quit warning (Gitea #2568 item 3): the soft
// hint the FIRST ctrl+c installs near the shift+enter line — literal
// wording, no idiom (the D54 literal-language law). Cleared by the
// arm timeout or any other key; the SECOND press quits and never
// shows it.
const quitHint = "press ctrl+c again to quit"
// restoreComposerHint puts the live key hint back after the quit
// warning clears (the hint stays live across rebinds — the spec's
// discoverability law).
func (m *model) restoreComposerHint() {
if m.composer == nil {
return
}
m.face.SetComposer(composerView(m.composer), m.composer.Placeholder(), m.composer.Hint(m.km))
}
// spinTick schedules the next shared-tick frame (the thinking walk,
// the gauges shimmer, the wave marker, and the composer cursor's
// blink all ride this one heartbeat — D41).
@@ -664,7 +754,7 @@ func (m *model) applyTaskDemand(msg taskDemandMsg) {
m.face.SetQueueNotice(fmt.Sprintf("work queued (#%d): %s", msg.res.Position, msg.dem))
default:
m.face.AppendChat(chatpane.Message{Role: chatpane.RoleError,
Content: "work demand neither delegated nor queued — the engine answered without a verdict"})
Content: "work demand neither delegated nor queued — the engine answered without a clear result"})
}
}
@@ -679,7 +769,7 @@ func (m *model) consentAnswer() tea.Cmd {
}
if m.consentBusy {
m.face.AppendChat(chatpane.Message{Role: chatpane.RoleSystem,
Content: "consent enqueue in flight — wait for its receipt"})
Content: "consent enqueue being processed now — wait for its receipt"})
return nil
}
b, ok := m.consentHub.Answer()
@@ -762,7 +852,7 @@ func (m *model) applyConsentEnqueue(msg consentEnqueueMsg) {
msg.res.Position, msg.brief.SnapshotID, refs, msg.brief.BriefText))
default:
m.face.AppendChat(chatpane.Message{Role: chatpane.RoleError,
Content: "consented work neither delegated nor queued — the engine answered without a verdict"})
Content: "consented work neither delegated nor queued — the engine answered without a clear result"})
}
}
@@ -799,7 +889,7 @@ func (m *model) dispatchSend(sent string) tea.Cmd {
if m.chatBusy {
m.face.DropChatThinking()
m.face.AppendChat(chatpane.Message{Role: chatpane.RoleError,
Content: "one completion in flight — phase 1 sends one at a time; wait for the reply"})
Content: "one completion being processed now — phase 1 sends one at a time; wait for the reply"})
return nil
}
m.chatBusy = true
@@ -825,6 +915,7 @@ func (m *model) applyChatReport(msg chatCompletionMsg) {
if msg.rep.Err != nil {
m.chatSt = "error"
m.face.DropChatThinking()
m.face.DropStreamChat() // the partial render goes with the honest error
m.face.AppendChat(chatpane.Message{Role: chatpane.RoleError,
Content: fmt.Sprintf("dispatch failed: %v", msg.rep.Err)})
m.syncActivity() // the turn ended: the pane rests (idle)
@@ -902,6 +993,12 @@ func (m *model) syncActivity() {
}
}
m.face.SetActivity(sig, m.spin)
// MOTION LAW (D54 #2571 item 1): the activity pane's wave runs
// only when motion is allowed — reduced motion closes the gate
// (calm mode closes it too; D54 item 5). The chat pane never
// animates; at most ONE region of the face animates per frame
// (the law is written at the Update loop's heartbeat).
m.face.SetActivityMotionAllowed(m.activityMotionAllowed())
label := ""
if st := m.face.ActivityState(); st != lanes.ActivityIdle {
label = string(st)
@@ -1217,27 +1314,59 @@ func (m model) pickerRows() []lib.ModelRow {
}
// refreshPicker installs the picker's overlay lines (view-only above
// the composer — the band slot the menu used).
// the composer — the band slot the menu used). The D52 window law
// (#2567): the centered picker is split into labelled CLOUD and LOCAL
// sections — cloud rows first, locals under LOCAL — one walkable list
// over both.
func (m *model) refreshPicker() {
rows := m.pickerRows()
if m.pickerSel >= len(rows) {
m.pickerSel = max(len(rows)-1, 0)
}
var out []string
out = append(out, fmt.Sprintf("pick a model · type to filter (%d) · ↑/↓ walk · enter choose · esc keeps your text", len(rows)))
out = append(out, fmt.Sprintf("pick a model · type to filter (%d) · ↑/↓ move · enter choose · esc keeps your text", len(rows)))
if len(rows) == 0 {
out = append(out, " no models match — backspace to widen")
}
for i, r := range rows {
// the walkable index counts ALL rows (both sections) so ↑/↓/enter
// behave exactly as before — the sections are presentation.
walked := 0
cloud, local := splitRowsCloudLocal(rows)
out = append(out, "CLOUD")
for _, r := range cloud {
mark := " "
if i == m.pickerSel {
if walked == m.pickerSel {
mark = "> "
}
out = append(out, mark+fmt.Sprintf("%d) %s", i+1, r.ID))
out = append(out, mark+fmt.Sprintf("%d) %s", walked+1, r.ID))
walked++
}
out = append(out, "LOCAL")
for _, r := range local {
mark := " "
if walked == m.pickerSel {
mark = "> "
}
out = append(out, mark+fmt.Sprintf("%d) %s", walked+1, r.ID))
walked++
}
m.face.SetMenu(out)
}
// splitRowsCloudLocal partitions the picker rows into the cloud
// provider family and the local binding family (the D52 window law's
// two labelled sections).
func splitRowsCloudLocal(rows []lib.ModelRow) (cloud, local []lib.ModelRow) {
for _, r := range rows {
if isCloudProvider(r.OwnedBy) {
cloud = append(cloud, r)
} else {
local = append(local, r)
}
}
return cloud, local
}
// pickerKeys handles the keys while the picker holds them: typing
// filters, arrows walk, enter chooses, esc dismisses-keeps-draft,
// digits jump. ok=false lets the key fall through (the composer types
@@ -1527,15 +1656,21 @@ func slashMatches(text string) []int {
// slashHintLines renders the '/'-command overlay above the input box:
// the filtered candidates with the top match marked, or the honest
// no-match line.
func slashHintLines(text string) []string {
func slashHintLines(text string, sel int) []string {
matches := slashMatches(text)
if len(matches) == 0 {
return []string{"no matching command — backspace to edit"}
}
if sel < 0 {
sel = 0
}
if sel >= len(matches) {
sel = len(matches) - 1
}
var out []string
for j, i := range matches {
mark := " "
if j == 0 {
if j == sel {
mark = "> "
}
out = append(out, fmt.Sprintf("%s%s — %s", mark, slashVocabulary[i].slash, slashVocabulary[i].desc))
@@ -1543,6 +1678,23 @@ func slashHintLines(text string) []string {
return out
}
// slashHintSelected returns the vocabulary id under the submenu's
// selection cursor for the typed slash text ("" when nothing matches).
func (m *model) slashHintSelected(text string) string {
matches := slashMatches(strings.SplitN(text, " ", 2)[0])
if len(matches) == 0 {
return ""
}
sel := m.hintSel
if sel < 0 {
sel = 0
}
if sel >= len(matches) {
sel = len(matches) - 1
}
return slashVocabulary[matches[sel]].id
}
// runSlash executes one '/'-leading send: an exact or unique-prefix
// match runs through the SAME executor as the palette; anything else
// restores the text for editing and says so in the overlay (the send
@@ -1721,9 +1873,9 @@ func (m *model) refreshOverlay() {
default:
m.face.SetMenu(nil)
}
text := m.composer.Text()
text := m.focusedInput().Text()
if !m.paletteOpen() && !m.menuOpen && strings.HasPrefix(text, "/") {
m.face.SetHints(slashHintLines(strings.SplitN(text, " ", 2)[0]))
m.face.SetHints(slashHintLines(strings.SplitN(text, " ", 2)[0], m.hintSel))
return
}
m.face.SetHints(nil)
@@ -1808,7 +1960,7 @@ func (m *model) resolveApproval(approved bool) tea.Cmd {
}
if m.apprvBusy {
m.face.AppendChat(chatpane.Message{Role: chatpane.RoleSystem,
Content: "approval resolution in flight — wait for its receipt"})
Content: "approval resolution being processed now — wait for its receipt"})
return nil
}
if bootResolver == nil {
@@ -1880,7 +2032,23 @@ func (m *model) closeApprovalCard() {
m.refreshOverlay()
}
// activityMotionAllowed reports whether the activity wave may run
// (the D54 MOTION LAW gate, #2571 item 1): reduced motion closes it;
// calm mode closes it too (D54 item 5).
func (m model) activityMotionAllowed() bool {
return !m.reducedMotion && !m.calm
}
func (m model) Init() tea.Cmd {
// THE ONE-ANIMATING-REGION LAW (D54 #2571 item 1, documented at
// the face chain's Update loop, which owns the frame tick): at
// most ONE region of the face animates per frame. The activity
// strip is that region while it waves (gated by
// activityMotionAllowed); when the scaffold line animates it
// holds the region and the other animations rest (the
// arbitration bit, canon P3). The chat pane NEVER animates —
// its law is written at tui/chatpane/blocks.go and asserted
// append-only in the scroll/blocks tests.
if m.reducedMotion {
return nil // reduced motion: the cursor rests lit, no heartbeat
}
@@ -1978,9 +2146,21 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
return m, nil
case chatStreamStartMsg:
// the streamed turn opens: the progressive bubble installs and
// the receive loop arms (Gitea #2556).
m.face.StreamChat("")
return m, awaitStream(msg)
case chatStreamDeltaMsg:
// the answer renders AS IT ARRIVES: the running total replaces
// the streaming bubble's content.
m.chatStreamBuf += msg.delta
m.face.StreamChat(m.chatStreamBuf)
return m, awaitStream(msg.start)
case chatCompletionMsg:
// the lane spine answered a chat dispatch: the reply (or the
// honest error) lands in the transcript; the state clears.
m.chatStreamBuf = ""
m.applyChatReport(msg)
return m, nil
case taskDemandMsg:
@@ -2012,6 +2192,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.refreshComposer()
}
return m, nil
case ctrlCArmReset:
// THE ARM TIMEOUT (D46 + D53 #2568 item 3): the exit window
// closed — the gesture disarms and the quit warning clears.
// (This case must live at the switch top level: the message is
// not a KeyPressMsg, and the in-case check the D46 code carried
// was unreachable — the warning never cleared on timeout.)
if m.lastCtrlC {
nm := m
nm.lastCtrlC = false
nm.restoreComposerHint()
return nm, nil
}
return m, nil
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" {
// THE EXIT GESTURE (owner ruling D46 addendum): the FIRST
@@ -2024,24 +2217,22 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
nm.lastCtrlC = true
if m.composer != nil {
nm.composer.SetText("")
nm.face.SetComposer(composerView(nm.composer), nm.composer.Placeholder(), nm.composer.Hint(nm.km))
// THE QUIT WARNING (D53 #2568 item 3): the first press
// swaps the key hint for the soft quit warning near the
// shift+enter line; any other key or the timeout
// restores it. The second press never shows it — that
// press quits.
nm.face.SetComposer(composerView(nm.composer), nm.composer.Placeholder(), quitHint)
}
return nm, tea.Tick(time.Second, func(time.Time) tea.Msg {
return ctrlCArmReset{}
})
}
if _, ok := any(msg).(ctrlCArmReset); ok {
if m.lastCtrlC {
nm := m
nm.lastCtrlC = false
return nm, nil
}
return m, nil
}
{ // any non-ctrl+c key disarms the double-press window
if m.lastCtrlC {
nm := m
nm.lastCtrlC = false
nm.restoreComposerHint()
m = nm
}
}
@@ -2056,6 +2247,30 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
}
// THE SUB-AGENTS WINDOW (D53 #2568 item 7): keyboard parity
// with the section's click road. ctrl+w opens the centered
// window when agents are in flight (a CONTROL key — plain
// letters always type, the q-law); while it is open it is MODAL —
// esc closes, up/down walk the rows, enter toggles the
// selected agent's detail — and every other key closes it
// (a modal that traps typing is a trap).
if m.face.AgentsWindowOpen() {
switch msg.String() {
case "esc":
m.face.CloseAgentsWindow()
case "up":
m.face.AgentsSelMove(-1)
case "down":
m.face.AgentsSelMove(1)
default:
m.face.CloseAgentsWindow()
}
return m, nil
}
if msg.String() == "ctrl+w" && m.face.WaveAgentsCount() > 0 {
m.face.ToggleAgentsWindow()
return m, nil
}
// THE ACTIVITY + WINDOW (A6, keyboard parity per the
// tabs/windows canon): ctrl+a toggles the centered turn
// window; esc closes it while it holds the keyboard. A modal
@@ -2090,6 +2305,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.face.SetSteerOpen(true)
return m, nil
}
// THE PIN VERB's keyboard parity (D50 residual 4): ctrl+b pins
// the top live work block into the tasks pane's reserved pinned
// area, persisted through the hot-state store (tasks_pins.json).
// A control key per the D36 letter law, reachable while composing.
if msg.String() == "ctrl+b" {
if _, ok := m.face.PinTopWork(); ok {
if err := savePins(m.home, m.face.Pins()); err != nil {
m.face.AppendChat(chatpane.Message{Role: chatpane.RoleError,
Content: "pin not persisted: " + err.Error()})
}
}
return m, nil
}
// ctrl+g raises/lowers the GAUGES window (D41: progress is a
// callable overlay — never embedded in the task pane); esc
// closes it (handled before anything else can claim the key)
@@ -2277,6 +2505,48 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
}
// THE '/'-COMMAND SUBMENU (D50 item 5, #2555-7): while the
// slash hints show (a '/'-leading draft, no other overlay),
// up/down move the selection over the matching rows and
// enter runs the HIGHLIGHTED command — the palette's
// ↑/↓/enter law, carried to the inline '/' road.
if !strings.HasPrefix(m.focusedInput().Text(), "/") {
m.hintSel = 0
} else if word := strings.SplitN(m.focusedInput().Text(), " ", 2)[0]; word != m.hintWord {
m.hintWord = word
m.hintSel = 0
} else if m.hintSel > 0 && len(slashMatches(word)) == 0 {
m.hintSel = 0
}
if !m.paletteOpen() && !m.menuOpen &&
strings.HasPrefix(m.focusedInput().Text(), "/") &&
len(slashMatches(strings.SplitN(m.focusedInput().Text(), " ", 2)[0])) > 0 {
switch msg.String() {
case "up":
m.hintSel--
if m.hintSel < 0 {
m.hintSel = 0
}
m.refreshOverlay()
return m, nil
case "down":
if n := len(slashMatches(strings.SplitN(m.focusedInput().Text(), " ", 2)[0])); m.hintSel < n-1 {
m.hintSel++
}
m.refreshOverlay()
return m, nil
case "enter":
if id := m.slashHintSelected(m.focusedInput().Text()); id != "" {
arg := ""
if _, rest, ok := strings.Cut(m.focusedInput().Text(), " "); ok {
arg = rest
}
m.focusedInput().Clear()
m.refreshOverlay()
return m, m.runCommand(id, arg)
}
}
}
// SCROLLBACK (#2535, D50-1: each pane scrolls INDIVIDUALLY
// with its own scroll state): ctrl+up/ctrl+down walk the
// FOCUSED pane's scroll window line-wise, pgup/pgdown
@@ -2322,8 +2592,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if k, ok := inputEditKey(msg.String()); ok {
if m.face.Snapshot().Focus == semantic.FocusTasks &&
(msg.String() == "up" || msg.String() == "down") {
// the tree's arrows (the tree region swallows or
// walks them; ChromeOpen gates that)
// D50 residual 3: the row walker is removed — the
// arrows scroll the tasks panel's own scroll window
// (#2535) instead of walking rows.
if msg.String() == "up" {
m.face.TasksScrollUp(1)
} else {
m.face.TasksScrollDown(1)
}
m.face.TouchScroll()
return m, nil
} else {
m.focusedInput().Input(k, m.km)
m.refreshComposer()
@@ -2339,7 +2617,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// (both gated above this block), so only the letter
// case reaches here. Other chain-owned chrome keys
// (rail r, promote p/s/u, tree t, ...) route the chain.
if key.String() != "y" && chainOwnsKey(m, key) {
if key.String() != "y" && chainOwnsKey(m, key) &&
!(m.composerActive() && composerOwnsRune(m, key)) {
// chain-owned chrome key: fall through to the chain
} else {
m.focusedInput().Input(key, m.km)
@@ -2582,6 +2861,12 @@ func (m model) View() tea.View {
if m.face.ActivityWindowOpen() {
content = m.overlayActivityWindow(content)
}
// THE SUB-AGENTS WINDOW (D53 #2568 item 7): the centered overlay
// listing all in-flight agents, the selected row's detail beneath
// (the D52 picker window pattern; the + window's centered shape).
if m.face.AgentsWindowOpen() {
content = m.overlayAgentsWindow(content)
}
// the #2532 drag highlight overlays the frame while the drag is
// in flight (selection.go — shell display state, never a pane).
content = m.overlaySelection(content)
@@ -2628,10 +2913,12 @@ func semverOf(v string) string {
return v
}
// ctxToken renders the context-window readout (D43.12): "context 4.2k/32k"
// — the engine's used tokens against the model's registry limit. Owner
// ruling 2026-10-19: the full word, not the abbreviation. An engine that
// has not reported (offline face, unknown model) renders nothing — the
// ctxToken renders the context-window readout (D43.12): "context
// 4.2k/32k (13%)" — the engine's used tokens against the model's
// registry limit, with the usage percentage in brackets (D53 #2568
// item 5, computed from the existing used/limit pair). Owner ruling
// 2026-10-19: the full word, not the abbreviation. An engine that has
// not reported (offline face, unknown model) renders nothing — the
// bar never guesses.
func ctxToken(m model) string {
used, limit := int64(0), int64(0)
@@ -2641,7 +2928,22 @@ func ctxToken(m model) string {
if used <= 0 || limit <= 0 {
return ""
}
return fmt.Sprintf(" · context %s/%s", compactTokens(used), compactTokens(limit))
return fmt.Sprintf(" · context %s/%s (%d%%)", compactTokens(used), compactTokens(limit), ctxPercent(used, limit))
}
// ctxPercent is the usage percentage: integer 0-100, floor-rounded.
func ctxPercent(used, limit int64) int {
if limit <= 0 {
return 0
}
p := used * 100 / limit
if p < 0 {
return 0
}
if p > 100 {
return 100
}
return int(p)
}
// versionToken renders the info bar's version segment (D43 addendum 6
@@ -3034,6 +3336,46 @@ func (m model) overlayActivityWindow(content string) string {
return strings.Join(lines, "\n")
}
// overlayAgentsWindow draws THE SUB-AGENTS WINDOW (D53 #2568 item 7)
// CENTERED over the face: all in-flight agents, the selected row's
// thought process / recent activity beneath — the D52 picker window
// pattern (centered, sections, walkable) on the + window's shape.
func (m model) overlayAgentsWindow(content string) string {
lines := strings.Split(content, "\n")
w := 0
for _, l := range lines {
if n := len([]rune(semantic.StripANSI(l))); n > w {
w = n
}
}
ww := min(72, max(w-8, 20))
rows := m.face.AgentsWindowLines(ww)
if len(rows) == 0 {
return content
}
title := " sub agents · esc closes "
box := make([]string, 0, len(rows)+2)
topRow := "╭" + title + strings.Repeat("─", max(ww+2-len([]rune(title)), 0)) + "╮"
box = append(box, topRow)
for _, r := range rows {
r = cutDisplay(r, ww)
pad := max(ww-len([]rune(semantic.StripANSI(r))), 0)
box = append(box, "│ "+r+strings.Repeat(" ", pad)+" │")
}
box = append(box, "╰"+strings.Repeat("─", ww+2)+"╯")
bh := len(box)
y0 := max((len(lines)-1-bh)/2, 1)
left := max((w-(ww+4))/2, 0)
for i, row := range box {
y := y0 + i
if y < 0 || y >= len(lines) {
continue
}
lines[y] = cutDisplay(lines[y], left) + padDisplay(row, left+displayWidth(row), w)
}
return strings.Join(lines, "\n")
}
// clipActivity keeps the first n display columns of a window row,
// closing any SGR run the cut interrupts.
func clipActivity(line string, n int) string { return cutDisplay(line, n) }
@@ -3135,6 +3477,17 @@ func (m *model) syncWaves() {
Count: fl.Count,
Rows: waveRows(lib.DefaultRepoRoot(), fl),
})
// THE SUB-AGENTS SECTION (D53 #2568 item 7): the SAME flight
// observation installs the activity pane's "sub agents being
// processed now" section — the section can never disagree with
// the rail marker or the wave tab (one poller, one number). The
// rows carry the journal + observations detail waveRows derived.
rows := waveRows(lib.DefaultRepoRoot(), fl)
avs := make([]lanes.AgentView, 0, len(rows))
for _, r := range rows {
avs = append(avs, lanes.AgentView{ID: r.ID, Title: r.Title, State: r.State, Detail: r.Detail})
}
m.face.SetWaveAgents(fl.Count, avs)
}
// waveRows derives one row per in-flight wave from the durable
@@ -3193,7 +3546,7 @@ func waveDetail(w factory.Wave, events []factory.JournalEvent, obs map[string][]
var out []string
if t, err := time.Parse("2006-01-02T15:04:05Z", w.CreatedAt); err == nil {
d := time.Since(t).Round(time.Second)
out = append(out, fmt.Sprintf("state %s · %02d:%02d in flight", w.Phase,
out = append(out, fmt.Sprintf("state %s · running %02d:%02d", w.Phase,
int(d.Minutes()), int(d.Seconds())%60))
} else {
out = append(out, "state "+w.Phase)
@@ -3288,8 +3641,8 @@ var styleVariants = []struct {
style chatpane.Style
}{
{"amalgamation", chatpane.DefaultStyle()},
{"quiet typeset", chatpane.Style{Bubbles: false, Vocab: chatpane.Vocab, Accent: "#FF79C6"}},
{"full futhark", chatpane.Style{Bubbles: true, Vocab: chatpane.VocabFull(), Accent: "#FF79C6"}},
{"quiet typeset", chatpane.Style{Bubbles: false, Vocab: chatpane.Vocab, Accent: themes.RoleHex(themes.RoleSpeaker)}},
{"full futhark", chatpane.Style{Bubbles: true, Vocab: chatpane.VocabFull(), Accent: themes.RoleHex(themes.RoleSpeaker)}},
}
// applyStyle installs variant i on the transcript.
+56 -22
View File
@@ -22,6 +22,7 @@ import (
"rune.dev/rune/tui/chatpane"
"rune.dev/rune/tui/runtime/lib"
"rune.dev/rune/tui/semantic"
"rune.dev/rune/tui/tasks"
)
func keyPress(s string) tea.KeyPressMsg {
@@ -52,6 +53,15 @@ func sized(t *testing.T, m model, w, h int) model {
return next.(model)
}
// labSeeded swaps the shell's production face for the LAB-seeded one
// (D52 supersession, #2566): production boots EMPTY now, so the
// selection-law proofs that drive the canonical three-box substrate
// ask for the seed explicitly — the seeds stay confined to the lab.
func labSeeded(m model) model {
m.face = tasks.NewSeeded(80, 24)
return m
}
// quits reports whether sending msg to the model produces a quit command.
func quits(t *testing.T, m model, msg tea.Msg) bool {
t.Helper()
@@ -243,7 +253,9 @@ func TestTabSwitchesLaneFocus(t *testing.T) {
// on the LAST task row (reverse ring entry), further presses walk the
// ring, and the wrap returns to the chat pane.
func TestShiftTabCyclesBoxes(t *testing.T) {
m := sized(t, initialModel(), 80, 24)
// D52 supersession (#2566): the box-selection law drives the
// canonical seed substrate — the lab seed is explicit here.
m := labSeeded(sized(t, initialModel(), 80, 24))
next, _ := m.Update(shiftTabPress())
st := next.(model).face.Snapshot()
if st.Focus != semantic.FocusTasks || st.Sel != 2 {
@@ -272,7 +284,8 @@ func TestShiftTabCyclesBoxes(t *testing.T) {
// through the face chain's own regions and selects that row; typing
// then lands on the lit row (y approves the clicked task).
func TestMouseClickSelectsBox(t *testing.T) {
m := sized(t, initialModel(), 80, 24)
// D52 supersession (#2566): the seed substrate is explicit (lab).
m := labSeeded(sized(t, initialModel(), 80, 24))
reg := semantic.Region{}
for _, r := range m.face.Render().Regions {
if r.ID == "mouse.nav.jump.2" {
@@ -302,24 +315,16 @@ func TestMouseClickSelectsBox(t *testing.T) {
if st = mm.face.Snapshot(); st.Tasks[2].Approved {
t.Fatalf("y typed a letter (no card open) — it must not approve: %+v", st.Tasks)
}
// empty the composer — the keys hand back to the tree (the
// letter-stealing law's other half: an emptied composer is not
// input territory, so the arrows can walk the cursor)
// D50 residual 3 (owner ruling): the row walker is REMOVED — the
// arrows no longer walk tree rows; the semantic selection stands.
next, _ = next.(model).Update(tea.KeyPressMsg{Code: tea.KeyBackspace})
mm = next.(model)
if got := mm.taskInput.Text(); got != "" {
t.Fatalf("backspace did not empty the task composer: %q", got)
}
// arrows move the tree cursor — REAL arrow keys (the synthetic-
// spelling law: the old keyPress("down") spelled Code='d' with
// Text "down" and its tail branch asserted nothing at all)
next, _ = mm.Update(arrowPress(tea.KeyUp))
if st = next.(model).face.Snapshot(); st.Sel != 1 {
t.Fatalf("up arrow: sel=%d, want 1 (the tree cursor walks back)", st.Sel)
}
next, _ = next.(model).Update(arrowPress(tea.KeyDown))
if st = next.(model).face.Snapshot(); st.Sel != 2 {
t.Fatalf("down arrow: sel=%d, want 2 (the tree cursor walks forward)", st.Sel)
t.Fatalf("up arrow: sel=%d, want 2 (the row walker is removed; selection stands)", st.Sel)
}
}
@@ -386,7 +391,9 @@ func TestRailOpensTakeoverAndRestoresLanes(t *testing.T) {
// once the region is closed. (Setup: the composer owns typed runes on
// the chat pane, so p runs from the tasks focus — 2.)
func TestPromoteRegionWalk(t *testing.T) {
m := sized(t, initialModel(), 80, 24)
// D52 supersession (#2566): the hold/queue walk drives the seed
// substrate — the lab seed is explicit here.
m := labSeeded(sized(t, initialModel(), 80, 24))
m2, _ := m.Update(keyPress("2"))
// D36 owner fix: p is a letter now — the region opens
// programmatically (the shell's ctrl+p is the palette, frozen D34).
@@ -775,7 +782,7 @@ func TestComposerOneInFlight(t *testing.T) {
if cmd2 != nil {
t.Fatal("second send during flight must NOT dispatch")
}
if txt := chatBubbleText(m); !strings.Contains(txt, "one completion in flight") {
if txt := chatBubbleText(m); !strings.Contains(txt, "one completion being processed now") {
t.Fatalf("busy bubble missing:\n%s", txt)
}
}
@@ -830,13 +837,24 @@ func TestComposerStaysOffChromeKeys(t *testing.T) {
t.Fatalf("t must type into the shared box (D36 owner fix), got %q", got)
}
m.face.SetTreeOpen(true)
// D50 residual 3: the tree region is a VIEW (the walker is removed)
// — it never owns the keys, the composer stays active under it.
if m.face.ChromeOpen() {
t.Fatal("the tree region must not count as chrome (D50: no selectable rows, nothing owns the keys)")
}
if !m.composerActive() {
t.Fatal("the composer must stay active under the open tree region")
}
// genuine chrome still owns the keys: the rail takeover.
m.face.SetTakeoverOpen(true)
if !m.face.ChromeOpen() {
t.Fatal("tree region must be open after SetTreeOpen(true)")
t.Fatal("the rail takeover must still count as chrome")
}
if m.composerActive() {
t.Fatal("composer must not own keys while chrome is open")
}
next, cmd := m.Update(keyPress("h")) // a plain rune during chrome
m.face.SetTakeoverOpen(false)
next, cmd := m.Update(keyPress("h")) // a plain rune with the view region open
m = next.(model)
if cmd != nil {
t.Fatal("no dispatch may leave chrome-owned keys")
@@ -1552,7 +1570,22 @@ func runBatch(cmd tea.Cmd) []tea.Msg {
case nil:
return nil
default:
return []tea.Msg{msg}
out := []tea.Msg{msg}
// the streaming dispatch leg (Gitea #2556): the start message
// opens the receive loop — the batch settles it to the closing
// report so the classic completion-scanning assertions hold.
if start, ok := msg.(chatStreamStartMsg); ok {
for {
next := awaitStream(start)()
out = append(out, next)
if cm, ok := next.(chatCompletionMsg); ok {
_ = cm
break
}
start = next.(chatStreamDeltaMsg).start
}
}
return out
}
}
@@ -2042,14 +2075,15 @@ func TestTypingRoutesWithFocus(t *testing.T) {
m = next.(model)
sel := m.face.Snapshot().Sel
// D36 owner fix 2026-10-19: j/k are LETTERS now (they type into the
// shared box in both focuses); the tree cursor moves with the
// ARROW keys exclusively.
// shared box in both focuses). D50 residual 3: the arrows no longer
// walk tree rows (the walker is removed) — the semantic selection
// stands and the arrows scroll the tasks panel instead.
for _, poke := range []rune{tea.KeyDown, tea.KeyDown} {
next, _ := m.Update(arrowPress(poke))
m = next.(model)
}
if got := m.face.Snapshot().Sel; got != sel+2 {
t.Fatalf("tasks focus: arrows moved sel %d -> %d, want %d (the tree walks on the arrows)", sel, got, sel+2)
if got := m.face.Snapshot().Sel; got != sel {
t.Fatalf("tasks focus: arrows moved sel %d -> %d, want %d (the row walker is removed)", sel, got, sel)
}
if got := m.composer.Text(); got != "hi" {
t.Fatalf("tasks-focus keys must not disturb the chat draft, got %q", got)
+55
View File
@@ -0,0 +1,55 @@
// pins.go — the PINNED WORK hot-state record (D50 residual 4, #2560):
// what the owner pinned, persisted across boots. RUNE_HOME law, the
// history-depth record's exact pattern: one JSON document at the state
// home's root (tasks_pins.json beside history_depth.json), written
// atomically (write-beside, rename over). The record never guesses —
// absent or corrupt answers an empty pin set.
package main
import (
"encoding/json"
"os"
"path/filepath"
"rune.dev/rune/tui/tasks"
)
func pinsPath(home string) string {
return filepath.Join(home, "tasks_pins.json")
}
// loadPins returns the persisted pins — absent or corrupt is empty,
// never a guess.
func loadPins(home string) []tasks.Pin {
raw, err := os.ReadFile(pinsPath(home))
if err != nil {
return nil
}
var doc struct {
Pins []tasks.Pin `json:"pins"`
}
if json.Unmarshal(raw, &doc) != nil {
return nil
}
return doc.Pins
}
// savePins persists the pin set atomically (write-beside, rename
// over — the depth-record pattern).
func savePins(home string, pins []tasks.Pin) error {
if err := os.MkdirAll(home, 0o755); err != nil {
return err
}
raw, err := json.MarshalIndent(struct {
Pins []tasks.Pin `json:"pins"`
}{pins}, "", " ")
if err != nil {
return err
}
dst := pinsPath(home)
tmp := dst + ".tmp"
if err := os.WriteFile(tmp, append(raw, '\n'), 0o600); err != nil {
return err
}
return os.Rename(tmp, dst)
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"rune.dev/rune/tui/semantic"
"rune.dev/rune/tui/taskqueue"
"rune.dev/rune/tui/tasks"
)
// D50 residual 4 (#2560): PINNED WORK — the pin verb's keyboard parity
// is ctrl+b (the control-key family: ctrl+q steers the queue, ctrl+g
// raises gauges, ctrl+b pins the top live work block). Pins persist
// across boots through the hot-state store (tasks_pins.json beside
// history_depth.json — the depth record's exact pattern).
func TestD50PinKeyboardParityPersistsAcrossBoots(t *testing.T) {
configuredHome(t)
home := os.Getenv("RUNE_HOME")
bootCompleter = &queueSeamFake{}
defer func() { bootCompleter = nil }()
m := sized(t, initialModel(), 110, 30)
// live work to pin: one running block
blocks := tasks.DeriveWorkBlocks(tasks.LiveInput{
Tasks: []tasks.LiveTask{{ID: "task_1", Title: "migrate the store", Status: "running",
Updated: time.Now().UTC().Format(time.RFC3339)}},
})
m.face.SetLiveWork(blocks, []taskqueue.Entry{{EntryID: "q1", Text: "x",
QueuedAt: time.Now()}}, time.Now())
if len(blocks) != 1 {
t.Fatalf("one live block expected, got %+v", blocks)
}
m2, _ := m.Update(tea.KeyPressMsg{Code: 'b', Mod: tea.ModCtrl})
m = m2.(model)
if n := len(m.face.Pins()); n != 1 {
t.Fatalf("ctrl+b must pin the top live work block, got %d pins", n)
}
raw, err := os.ReadFile(filepath.Join(home, "tasks_pins.json"))
if err != nil {
t.Fatalf("the pin must persist through the hot-state store: %v", err)
}
if !strings.Contains(string(raw), "migrate the store") {
t.Fatalf("the persisted record must carry the plain-english summary: %s", raw)
}
// THE BOOT LEG: a fresh model loads the pins — they survive the boot
m3 := sized(t, initialModel(), 110, 30)
if n := len(m3.face.Pins()); n != 1 {
t.Fatalf("pins must persist across boots, got %d", n)
}
// the pinned area lives in the tasks pane's region view (opened by
// the click road / palette); open it and read the pane.
m3.face.SetTreeOpen(true)
v := semantic.StripANSI(m3.face.Render().Content)
if !strings.Contains(v, "pinned (1)") {
t.Fatalf("the fresh boot's tasks pane must render the pinned area:\n%s", v)
}
}
+53
View File
@@ -0,0 +1,53 @@
package main
import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
)
// D50 item 5 (Gitea #2555 item 7): the '/'-command submenu is
// SELECTABLE — while the slash hints show, up/down move the selection
// over the matching rows and enter runs the HIGHLIGHTED command (not
// just an exact or unique prefix). The palette's ↑/↓/enter law, carried
// to the inline '/' road.
func TestD50SlashHintsSelectableUpDownEnter(t *testing.T) {
configuredHome(t)
bootCompleter = &fakeChatCompleter{answer: "ok"}
defer func() { bootCompleter = nil }()
m := sized(t, initialModel(), 110, 30)
m2, _ := m.Update(keyPress("/"))
m = m2.(model)
hints := slashHintLines("/", m.hintSel)
if len(hints) < 3 {
t.Fatalf("the bare slash must list the vocabulary, got %d rows", len(hints))
}
// down once: the selection moves OFF the first row
m2, _ = m.Update(arrowPress(tea.KeyDown))
m = m2.(model)
marked := 0
for _, ln := range slashHintLines("/", m.hintSel) {
if strings.HasPrefix(ln, "> ") {
marked++
}
}
if marked != 1 {
t.Fatalf("exactly one hint row must carry the selection mark, got %d", marked)
}
if !strings.Contains(slashHintLines("/", m.hintSel)[1], "> ") {
t.Fatalf("down must move the mark to the second row:\n%s", strings.Join(slashHintLines("/", m.hintSel), "\n"))
}
// enter runs the HIGHLIGHTED command (/history, whose no-arg leg
// reports the boot depth in the transcript)
m2, _ = m.Update(enterPress())
m = m2.(model)
txt := chatBubbleText(m)
if !strings.Contains(txt, "history depth:") {
t.Fatalf("enter must run the highlighted hint command (/history):\n%s", txt)
}
if got := m.composer.Text(); got != "" {
t.Fatalf("a run hint command clears the slash text, got %q", got)
}
}
+9
View File
@@ -235,6 +235,15 @@ func New(w, h int) *Inputs { return newInputs(semantic.ModeRich, w, h) }
// NewLinear returns the linear-projection inputs face (zero-ANSI law).
func NewLinear(w, h int) *Inputs { return newInputs(semantic.ModeLinear, w, h) }
// NewSeeded is the LAB harness's constructor: the canonical seeds
// under the integrations chrome (D52 seed-substrate law, #2566 —
// production faces boot empty; a test that needs the seed asks for it
// explicitly here).
func NewSeeded(w, h int) *Inputs {
return &Inputs{inner: tasks.NewSeeded(w, h), mode: semantic.ModeRich,
connected: map[string]Scope{}, blocked: map[string]Scope{}}
}
func newInputs(mode string, w, h int) *Inputs {
inner := tasks.New(w, h)
if mode == semantic.ModeLinear {
+13 -6
View File
@@ -20,7 +20,8 @@ var _ semantic.Face = (*Inputs)(nil)
// state so divergence-hiding mutants cannot land on zero values).
func seededInputs(t *testing.T) *Inputs {
t.Helper()
f := New(80, 24)
// D52 supersession (#2566): the seed substrate is explicit (lab).
f := NewSeeded(80, 24)
if a, _ := f.Apply(semantic.Rune('2')); a != "focus.tasks" {
t.Fatalf("seed key 2 produced %q", a)
}
@@ -32,7 +33,9 @@ func seededInputs(t *testing.T) *Inputs {
func seededTasksFace(t *testing.T) *tasks.Tasks {
t.Helper()
f := tasks.New(80, 24)
// D52 supersession (#2566): production New boots EMPTY — the
// delegation oracle needs the canonical seed on both sides.
f := tasks.NewSeeded(80, 24)
f.Apply(semantic.Rune('2'))
f.Apply(semantic.Rune('j'))
return f
@@ -141,16 +144,20 @@ func TestInputsModalOutranksChrome(t *testing.T) {
}
}
// TestInputsInnerRegionOutranksChrome: an open inner region (task tree)
// swallows i — the inputs chrome never bypasses an inner boundary.
// TestInputsInnerRegionOutranksChrome (re-pinned D50 residual 3): the
// task tree region is a VIEW now — it does not own keys, so the inputs
// chrome routes exactly as with the tree closed. Genuine inner chrome
// (the promote region) still outranks the inputs layer.
func TestInputsInnerRegionOutranksChrome(t *testing.T) {
f := seededInputs(t)
f.inner.SetTreeOpen(true) // D36 owner fix: t is a letter now
if !f.inner.Open() {
t.Fatal("the tree must be open programmatically")
}
if a, _ := f.Apply(semantic.Rune('i')); a != tasks.TasksSwallow {
t.Errorf("i during open tree produced %q, want %q", a, tasks.TasksSwallow)
a, _ := f.Apply(semantic.Rune('i'))
ref, _ := f.inner.Apply(semantic.Rune('i'))
if a != ref {
t.Errorf("i during open tree produced %q, want the delegated %q (the tree region is a view)", a, ref)
}
if f.Open() {
t.Error("view opened through an open inner region")
+50 -2
View File
@@ -39,6 +39,9 @@ import (
// the I3 library seam's dispatch half satisfies the dispatcher
var _ Completer = (*lib.Repo)(nil)
// the streaming leg too (Gitea #2556)
var _ StreamCompleter = (*lib.Repo)(nil)
// Lane names the two permanent lanes of the dual-lane interface.
type Lane string
@@ -202,6 +205,14 @@ type Completer interface {
Complete(ctx context.Context, req lib.Completion) (lib.CompletionResult, error)
}
// StreamCompleter is the streaming leg of the seam (Gitea #2556):
// satisfied by *lib.Repo's CompleteStream. A dispatcher whose
// completer streams uses it so the answer's bytes surface AS THEY
// ARRIVE; fakes that don't implement it keep the non-streaming path.
type StreamCompleter interface {
CompleteStream(ctx context.Context, req lib.Completion, onDelta func(string)) (lib.CompletionResult, error)
}
// LaneReport is one lane's dispatch outcome: the seam's result, the
// shared-memory write that committed it, or the honest error (one
// lane's failure never disturbs the other lane).
@@ -223,6 +234,29 @@ type Dispatcher struct {
comp Completer
model string
shared *Shared
// deltaSinkMu guards deltaSinkFn: the TUI installs the streaming
// delta sink just before dispatch (the composer's goroutine).
deltaSinkMu sync.Mutex
deltaSinkFn func(lane Lane, text string)
}
// SetDeltaSink installs the streaming delta sink (Gitea #2556): each
// lane's streamed text delta lands here AS IT ARRIVES. nil clears it
// (the non-streaming fallback callers).
func (d *Dispatcher) SetDeltaSink(fn func(lane Lane, text string)) {
d.deltaSinkMu.Lock()
defer d.deltaSinkMu.Unlock()
d.deltaSinkFn = fn
}
func (d *Dispatcher) deltaSink(lane Lane) func(string) {
d.deltaSinkMu.Lock()
defer d.deltaSinkMu.Unlock()
if d.deltaSinkFn == nil {
return nil
}
fn := d.deltaSinkFn
return func(text string) { fn(lane, text) }
}
// New returns a dispatcher over the seam (model "" = the core's own
@@ -271,12 +305,26 @@ func (d *Dispatcher) Dispatch(ctx context.Context, lane Lane, input string) (Lan
// dispatchLane runs one lane's model call and commits its result. A
// failed dispatch commits nothing and reports its error honestly.
// The streaming leg (Gitea #2556) forwards the answer's text deltas
// to the installed sink (never nil) as they arrive; the non-streaming
// completer keeps the classic path.
func (d *Dispatcher) dispatchLane(ctx context.Context, lane Lane, input string) LaneReport {
res, err := d.comp.Complete(ctx, lib.Completion{
req := lib.Completion{
Model: d.model,
Channel: string(lane),
Messages: []lib.CompletionMessage{{Role: "user", Content: input}},
})
}
var res lib.CompletionResult
var err error
if sink := d.deltaSink(lane); sink != nil {
if sc, ok := d.comp.(StreamCompleter); ok {
res, err = sc.CompleteStream(ctx, req, sink)
} else {
res, err = d.comp.Complete(ctx, req)
}
} else {
res, err = d.comp.Complete(ctx, req)
}
if err != nil {
return LaneReport{Err: err}
}
+69
View File
@@ -0,0 +1,69 @@
package laneconc
// stream_dispatch_test.go — the lane dispatcher's streaming leg
// (Gitea #2556, red-first): a StreamCompleter's deltas flow to the
// installed sink as they arrive; a completer without the streaming
// interface keeps the non-streaming path; no sink = the classic path.
import (
"context"
"errors"
"testing"
"rune.dev/rune/tui/runtime/lib"
)
type fakeStreamRepo struct {
err error
}
func (f *fakeStreamRepo) Complete(ctx context.Context, req lib.Completion) (lib.CompletionResult, error) {
return lib.CompletionResult{ID: "id", Content: "whole answer"}, f.err
}
func (f *fakeStreamRepo) CompleteStream(ctx context.Context, req lib.Completion, onDelta func(string)) (lib.CompletionResult, error) {
if onDelta != nil {
onDelta("he")
onDelta("llo")
}
return lib.CompletionResult{ID: "id", Content: "hello"}, f.err
}
func TestDispatcherStreamsDeltasToSink(t *testing.T) {
d := New(&fakeStreamRepo{})
var got []string
d.SetDeltaSink(func(lane Lane, text string) { got = append(got, string(lane)+":"+text) })
rep, err := d.Dispatch(context.Background(), Chat, "hi")
if err != nil {
t.Fatal(err)
}
if rep.Err != nil {
t.Fatal(rep.Err)
}
if len(got) != 2 || got[0] != "chat:he" || got[1] != "chat:llo" {
t.Fatalf("deltas = %v", got)
}
if rep.Result.Content != "hello" {
t.Fatalf("content = %q", rep.Result.Content)
}
}
func TestDispatcherWithoutSinkKeepsClassicPath(t *testing.T) {
d := New(&fakeStreamRepo{})
rep, err := d.Dispatch(context.Background(), Task, "hi")
if err != nil || rep.Err != nil {
t.Fatal(err, rep.Err)
}
if rep.Result.Content != "whole answer" {
t.Fatalf("content = %q — the classic path must hold", rep.Result.Content)
}
}
func TestDispatcherStreamErrorReportsHonest(t *testing.T) {
d := New(&fakeStreamRepo{err: errors.New("wire down")})
d.SetDeltaSink(func(Lane, string) {})
rep, _ := d.Dispatch(context.Background(), Chat, "hi")
if rep.Err == nil {
t.Fatal("want the honest error")
}
}
+153 -26
View File
@@ -33,6 +33,7 @@ import (
"charm.land/lipgloss/v2"
"rune.dev/rune/tui/chatpane"
"rune.dev/rune/tui/themes"
)
// ActivityState is one activity the pane can name. Exactly the spec's
@@ -108,11 +109,11 @@ func DeriveActivity(sig ActivitySignal) ActivityState {
// activityColors is the per-activity palette (A2): each activity its
// OWN colour, every tone a floor-legal family colour on the base.
var activityColors = map[ActivityState]string{
ActivityIdle: "#78788A", // the dim family — the pane rests
ActivityThinking: "#C4A0FF", // rune's purple family
ActivityCoding: "#7EE787", // the assistant green family
ActivityPlanning: "#E3B341", // the gold family
ActivityResearching: "#58A6FF", // the blue research family
ActivityIdle: themes.RoleHex(themes.RoleMetaDim), // the dim family — the pane rests
ActivityThinking: "#C4A0FF", // rune's purple family
ActivityCoding: "#7EE787", // the assistant green family
ActivityPlanning: "#E3B341", // the gold family
ActivityResearching: "#58A6FF", // the blue research family
}
// activityWord names the state in the pane (A1's labels verbatim).
@@ -138,15 +139,29 @@ type ActivityPane struct {
open bool
turn ActivityTurn
reduced bool
// motionAllowed is the D54 MOTION LAW gate (#2571 item 1): every
// render frame of this pane passes through it. When it is false
// the strip is frame-frozen — no wave, no rainbow advance, no
// walking runes — the same rest idle already takes (A5). The
// chat pane never animates; this pane is the face's ONE
// animating region.
motionAllowed bool
}
func newActivityPane() *ActivityPane {
return &ActivityPane{
state: ActivityIdle,
seed: rand.New(rand.NewSource(time.Now().UnixNano())).Int63(),
state: ActivityIdle,
seed: rand.New(rand.NewSource(time.Now().UnixNano())).Int63(),
motionAllowed: true,
}
}
// SetMotionAllowed opens (true) or closes (false) the motion gate.
func (a *ActivityPane) SetMotionAllowed(on bool) { a.motionAllowed = on }
// MotionAllowed reports the gate's state.
func (a *ActivityPane) MotionAllowed() bool { return a.motionAllowed }
// SetSignal folds one real-signal reading: the state derives, and the
// turn-active bit drives the chat pane's live-thinking suppression
// (the D43 addendum 6 handoff — the pane declutters to conversation
@@ -185,6 +200,12 @@ func (a *ActivityPane) WindowOpen() bool { return a.open }
// activityStripWidth reserves the + affordance's right-edge columns.
const activityStripWidth = 5
// activityPlusWidth is the + affordance's click-region columns — the
// last 3 columns of the strip (mirrors lanes.render's
// activityPlusWidth; the region and the render MUST agree, the D52
// click-mapping law).
const activityPlusWidth = 3
// Render renders the strip's one row at width w: the runic marks
// (walking, per-rune coloured, while active; ABSENT at idle per the
// owner boot ruling), the activity word (rainbow while active, dim at
@@ -207,6 +228,12 @@ func (a *ActivityPane) Render(w int, styled bool) string {
if styled {
if a.state == ActivityIdle {
wordOut = activityIdleStyle.Render(word)
} else if !a.motionAllowed {
// MOTION LAW (#2571 item 1): the gate is closed — the
// word rests in its per-activity colour, frame-frozen.
for _, r := range word {
wordOut += activityStyle(a.state).Render(string(r))
}
} else {
for _, run := range a.snakeWord(word) {
wordOut += run
@@ -219,8 +246,13 @@ func (a *ActivityPane) Render(w int, styled bool) string {
line = line + " " + marks
}
line += strings.Repeat(" ", max(inner-len([]rune(stripANSI(line))), 0))
plus := activityPlusStyle.Render("+")
return line + strings.Repeat(" ", max(activityStripWidth-len(plus)-2, 0)) + plus
// D52 fix (#2566 item 3): pad by the + GLYPH's display width
// (1 column), never the styled string's byte length — the SGR
// bytes once pushed the glyph 3 columns left of its declared
// click region (mouse.activity.plus covers the last
// activityPlusWidth columns) and the owner's click missed.
return line + strings.Repeat(" ", max(activityPlusWidth-1, 0)) +
activityPlusStyle.Render("+")
}
line := word
if a.state != ActivityIdle {
@@ -230,29 +262,102 @@ func (a *ActivityPane) Render(w int, styled bool) string {
return line + strings.Repeat(" ", activityStripWidth-2) + " +"
}
// snakeWord renders the rainbow word with a SNAKE-LIKE SWEEP (owner
// live ruling 2026-10-22): a brightness crest travels from the word's
// first letter through its last and BACK — the t to the g and home —
// while every character keeps its own hue on the wheel. The frame
// advances the sweep one cell per tick.
// snakeWord renders the word with the owner's WAVE motion (D52, #2566
// item 2 — replaces the snake sweep): the first letter rises up and
// settles, then the second rises and settles, ... sequentially to the
// last letter, then the wave REVERSES back to the start, looping. The
// rise is smooth (framesPerLetter graded steps, not a hop). Every
// letter keeps its own hue on the wheel (A3), the per-activity
// palette holds the resting letters' tone, and the MOVING letter
// carries a vibrant rainbow hue that advances with the frame.
func (a *ActivityPane) snakeWord(word string) []string {
runes := []rune(word)
n := len(runes)
sweep := a.frame % max(2*n-2, 1)
out := make([]string, 0, n)
var out []string
for i, r := range runes {
dist := sweep - i
if dist < 0 {
dist = -dist
o := a.waveOffset(n, a.frame, i)
if a.state == ActivityIdle {
// A5: idle rests dim — no wave, no rainbow (the Render path
// styles idle with activityIdleStyle; snakeWord stays honest
// when called directly).
out = append(out, activityIdleStyle.Render(string(r)))
continue
}
baseHue := (i*36 + a.frame*12) % 360
hue := baseHue
sat := 0.85
light := 0.62
if o > 0 {
// the moving letter: vibrant rainbow on the crest — higher
// saturation, a hue that sweeps with the frame, brighter at
// the peak (the vibrancy ruling). Idle NEVER waves.
hue = (baseHue + a.frame*24) % 360
sat = 1.0
light = 0.68 + 0.06*float64(o)
}
l := 0.62 + 0.1*float64(min(dist, 3))
hue := (i*36 + a.frame*12) % 360
out = append(out, lipgloss.NewStyle().
Foreground(lipgloss.Color(hslHex(hue, 0.85, l))).Render(string(r)))
Foreground(lipgloss.Color(hslHex(hue, sat, light))).Render(string(r)))
}
return out
}
// wavePeak is the moving letter's top offset (display rows above the
// baseline the renderer lifts the glyph by — one row on a one-row
// strip, so the visible physics are the styled brightness crest the
// offset drives; the pane's own geometry stays one row).
const wavePeak = 3
// waveFramesPerLetter — more frames for a smooth rise and settle
// (D52 ruling): the graded ramp is 4 frames per letter.
const waveFramesPerLetter = 4
// framesPerLetter reports the wave's frames-per-letter step (test
// seam + the shell's pacing reference).
func (a *ActivityPane) framesPerLetter() int { return waveFramesPerLetter }
// waveOffset returns letter i's vertical offset (0..wavePeak) at
// frame f: a triangular crest of waveFramesPerLetter frames walking
// the letters 0..n-1 and BACK (2n-3 .. 1), looping (the reverse wave).
func (a *ActivityPane) waveOffset(n, f, i int) int {
if n <= 0 {
return 0
}
steps := 2*n - 2 // the crest positions: forward 0..n-1, back n-2..1
if steps <= 0 {
steps = 1
}
pos := (f / waveFramesPerLetter) % steps
// the crest is at letter pos; letter i is lit only at its own
// crest frame (the sequential rise-settle law: letters before the
// crest have SETTLED, letters after have not risen).
if pos != i {
return 0
}
// the smooth ramp within the crest's frames: 1,2,3,2 (rise, hold
// the peak, settle — graded, not a hop)
switch k := f % waveFramesPerLetter; {
case k == 0:
return 1
case k == 1:
return 2
case k == 2:
return wavePeak
default:
return 2
}
}
// waveCycleLen reports the wave's full loop length in frames (test
// seam): one crest frame-set per position over the forward+reverse
// walk.
func (a *ActivityPane) waveCycleLen(n int) int {
steps := 2*n - 2
if steps <= 0 {
steps = 1
}
return steps * waveFramesPerLetter
}
// marksRun renders the walking runic marks (A2: the runic animations
// are KEPT): glyphs from the one shared vocabulary, each coloured
// within the activity's palette (per-rune randomisation, deterministic
@@ -263,6 +368,10 @@ func (a *ActivityPane) marksRun(room int) string {
fit := max(min((room-16)/2, 3), 1)
n := 1 + a.frame%fit
restart := a.frame / fit
if !a.motionAllowed {
// MOTION LAW: the runes rest — one mark, frame-frozen.
n, restart = 1, 0
}
palette := activityPalette(a.state)
var b strings.Builder
for i := 0; i < n; i++ {
@@ -305,7 +414,7 @@ func (a *ActivityPane) WindowLines(w int) []string {
out = append(out, activitySectionStyle.Render(title))
}
if len(body) == 0 {
out = append(out, " "+activityIdleStyle.Render(clip("(none in flight)", inner-2)))
out = append(out, " "+activityIdleStyle.Render(clip("(none now)", inner-2)))
} else {
out = append(out, body...)
}
@@ -385,11 +494,29 @@ func activityStyle(s ActivityState) lipgloss.Style {
}
var (
activityIdleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#78788A"))
activityPlusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF79C6"))
activitySectionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#C4A0FF"))
activityIdleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleStateIdle)))
activityPlusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleSpeaker)))
activitySectionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleStateActivity)))
)
// ApplyCalm selects the activity layer's presentation mode (D54
// #2571 item 5): the styles re-resolve from the themes role tokens.
func ApplyCalm(on bool) {
themes.SetCalm(on)
refreshRoleStyles()
}
// refreshRoleStyles re-resolves the package-level role styles from
// the themes tokens (package vars resolve once at init; a
// presentation flip must re-resolve them).
func refreshRoleStyles() {
activityColors[ActivityIdle] = themes.RoleHex(themes.RoleStateIdle)
activityIdleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleStateIdle)))
composerHintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleHintPink)))
activityPlusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleSpeaker)))
activitySectionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleStateActivity)))
}
// wrapANSI word-wraps one text run at w (the wrap law, word
// boundaries; a lone long word hard-wraps).
func wrapANSI(text string, w int) []string {
+1 -1
View File
@@ -97,7 +97,7 @@ func TestActivityStripReflectsState(t *testing.T) {
func chatVocabRunes() []rune { return []rune("ᚠᚢᚦᚨᚱᚲᛉᛏᛒᛗ⍜⏃⌬☾✇") }
func TestActivityRainbowWordActive(t *testing.T) {
a := &ActivityPane{state: ActivityCoding, frame: 3}
a := &ActivityPane{state: ActivityCoding, frame: 3, motionAllowed: true}
row := a.Render(80, true)
// the rainbow word (A3): the word's characters carry per-character
// colour runs — multiple distinct SGR hues on one row
+88
View File
@@ -0,0 +1,88 @@
package lanes
// d52_clickmap_test.go — the D52 click-region mapping law (#2566 item
// 3, #2567): every region a face declares must lie ON the rendered
// glyph it names. The + affordance's live defect: Render pads the
// strip with the ANSI-STYLED + string's BYTE length (8 SGR bytes), so
// the glyph landed 3 columns left of its declared region — the owner
// clicked the visible + and missed. The law here is the debuggable
// assertion: for every region, StripANSI of the row the region sits
// on carries the region's marker INSIDE the region's columns.
import (
"strings"
"testing"
"rune.dev/rune/tui/semantic"
)
// assertRegionOnGlyph renders the face, walks every declared region,
// and reports which rendered column each region's marker actually
// sits at (the click→region mapping, testable without the flaky PTY
// input leg). Returns a diagnostic map for the failing assertion.
func regionGlyphColumns(l *Lanes) map[string]string {
fr := l.Render()
lines := strings.Split(semantic.StripANSI(fr.Content), "\n")
out := map[string]string{}
for _, r := range fr.Regions {
if r.Y < 0 || r.Y >= len(lines) {
out[r.ID] = "REGION OFF-SCREEN"
continue
}
row := lines[r.Y]
end := min(r.X+r.W, len(row))
seg := ""
if r.X < len(row) && end > r.X {
seg = row[r.X:end]
}
out[r.ID] = strings.TrimSpace(seg)
}
return out
}
// TestPlusRegionLandsOnTheGlyph — the + region's columns must contain
// a rendered '+' (the owner's click target).
func TestPlusRegionLandsOnTheGlyph(t *testing.T) {
for _, size := range [][2]int{{80, 24}, {120, 30}, {60, 20}} {
l := New(size[0], size[1])
l.SetComposer([]string{}, "type in chat", "hint")
cols := regionGlyphColumns(l)
seg, ok := cols["mouse.activity.plus"]
if !ok {
t.Fatalf("no + region declared at %dx%d", size[0], size[1])
}
if !strings.Contains(seg, "+") {
t.Fatalf("the + region (%dx%d) must lie ON the rendered '+', its columns carry %q — the region and the glyph diverged",
size[0], size[1], seg)
}
}
}
// TestPlusClickTogglesAtTheGlyphColumn — the click at the GLYPH's
// rendered column (not the region's declared-left edge) toggles the
// window: the exact miss the owner reported.
func TestPlusClickTogglesAtTheGlyphColumn(t *testing.T) {
l := New(80, 24)
l.SetComposer([]string{}, "type in chat", "hint")
fr := l.Render()
row := -1
for y, ln := range strings.Split(semantic.StripANSI(fr.Content), "\n") {
if strings.Contains(ln, "idle") {
row = y
}
}
if row < 0 {
t.Fatal("no activity row rendered")
}
plain := strings.Split(semantic.StripANSI(fr.Content), "\n")[row]
glyphX := strings.Index(plain, "+")
if glyphX < 0 {
t.Fatal("no + glyph on the strip row")
}
if a := l.Click(glyphX, row); a != semantic.ActionID("mouse.activity.plus") {
t.Fatalf("clicking the VISIBLE + at column %d must toggle the window, got %q", glyphX, a)
}
if !l.ActivityWindowOpen() {
t.Fatal("the glyph-column click must OPEN the window")
}
}
+157
View File
@@ -0,0 +1,157 @@
package lanes
// d52_wave_test.go — the owner's WAVE ruling (D52, #2566 item 2,
// replacing the snake sweep): the activity word animates per-letter as
// a WAVE — the first letter rises up and settles, then the second
// rises and settles, ... sequentially to the last letter, then the
// wave REVERSES back to the start, looping. More frames (a smooth
// rise/settle, not a 2-step hop) and more vibrant colour while in the
// thinking/activity state: brighter saturation plus the rainbow sweep
// on the MOVING letter (the per-activity palette stays, D48 rulings).
// Layer Law: own state, own tests; the frame timing respects the
// arbitration law (canon P3) — the shell already rests the other
// animations while the activity pane moves.
import (
"fmt"
"strings"
"testing"
"rune.dev/rune/tui/semantic"
"rune.dev/rune/tui/themes"
)
// letterOffsets extracts the per-letter VERTICAL offset phase from the
// rendered word's row structure. The pane renders the moving letter's
// offset as a modifier marker (an SGR italic/reverse run is display
// noise on a one-row strip — the wave's physics live in the pane's
// offsets method, which Render consumes; the test pins the METHOD's
// offsets over time and the render's consumption of them).
func TestWaveOffsetsRiseSettleReverse(t *testing.T) {
a := &ActivityPane{state: ActivityThinking}
word := activityWord(ActivityThinking) // "thinking" — 8 letters, g last
n := len([]rune(word))
cycle := a.waveCycleLen(n)
// helper: offset of letter i at frame f
off := func(f, i int) int { return a.waveOffset(n, f, i) }
// PHASE 1 — the sequential rise: at the frame the crest reaches
// letter i, letter i reaches its PEAK within the crest's frames
// (the graded ramp tops at wavePeak); letters j < i have SETTLED
// (back to 0) and letters j > i have not risen yet.
for i := 0; i < n; i++ {
crest := i*a.framesPerLetter() + 2 // the peak frame of the ramp
if off(crest, i) != wavePeak {
t.Fatalf("letter %d must be at peak %d at its crest frame %d, got %d",
i, wavePeak, crest, off(crest, i))
}
for j := 0; j < i; j++ {
if off(crest, j) != 0 {
t.Fatalf("letter %d must have settled to 0 by letter %d's crest (frame %d), got %d",
j, i, crest, off(crest, j))
}
}
for j := i + 1; j < n; j++ {
if off(crest, j) != 0 {
t.Fatalf("letter %d must not have risen at letter %d's crest (frame %d), got %d",
j, i, crest, off(crest, j))
}
}
}
// PHASE 2 — the reverse: after the last letter settles, the crest
// walks BACK (n-2, n-3, ... 0), then the cycle restarts.
lastCrest := (n-1)*a.framesPerLetter() + 2
first := (2*n-2)*a.framesPerLetter() + 2 // the reverse crest on letter 0
if off(first, 0) != wavePeak {
t.Fatalf("the reverse must crest letter 0 at frame %d, got %d", first, off(first, 0))
}
if off(lastCrest, n-1) != wavePeak {
t.Fatal("the forward crest reaches the last letter")
}
// the cycle loops: frame 0 and frame cycle render the same offsets
for i := 0; i < n; i++ {
if off(0, i) != off(cycle, i) {
t.Fatalf("the wave must loop: letter %d differs at frame 0 vs cycle %d", i, cycle)
}
}
}
// TestWaveSmoothRise — more frames: the rise is a graded ramp through
// intermediate offsets, not a 2-step hop.
func TestWaveSmoothRise(t *testing.T) {
a := &ActivityPane{state: ActivityThinking}
n := len([]rune(activityWord(ActivityThinking)))
if a.framesPerLetter() < 3 {
t.Fatalf("the rise must be smooth (multiple frames per letter), got %d frames/letter", a.framesPerLetter())
}
// letter 0's journey passes through an intermediate height
seen := map[int]bool{}
for f := 0; f < a.framesPerLetter(); f++ {
seen[a.waveOffset(n, f, 0)] = true
}
if len(seen) < 3 {
t.Fatalf("letter 0's rise must pass through >=3 distinct offsets, saw %v", seen)
}
}
// TestWaveVibrantThinking — the thinking/activity state is VIBRANT:
// higher saturation than the resting palette tone, and the MOVING
// letter carries a rainbow hue that advances with the frame (the
// per-activity palette keeps the hue family for the resting letters).
func TestWaveVibrantThinking(t *testing.T) {
a := &ActivityPane{state: ActivityThinking, frame: 2}
runs := a.snakeWord("thinking")
joined := strings.Join(runs, "")
if got := strings.Count(joined, "\x1b[38;2"); got < 2 {
t.Fatalf("the active word must stay multi-colour (the D48 palette + rainbow mover): %d runs", got)
}
// the moving letter's hue differs from the resting letter's hue at
// the same frame (the rainbow sweep rides the crest)
mover := 1 * a.framesPerLetter()
restA := &ActivityPane{state: ActivityThinking, frame: mover}
runsA := restA.snakeWord("thinking")
hue := func(runs []string, i int) string {
s := semantic.StripANSI(runs[i])
_ = s
return runs[i]
}
if hue(runsA, 1) == hue(runsA, 0) {
t.Fatal("the moving letter must carry its own rainbow hue (distinct from the resting letter)")
}
// vibrancy: the moving letter's saturation exceeds the base
// palette tone's (the parseHex SGR carry shows a brighter colour
// than the activity palette's floor tone)
if !strings.Contains(runsA[1], "\x1b[38;2") {
t.Fatal("the moving letter must be true-colour styled")
}
// the frame advances the mover's colour (animated, not frozen)
restB := &ActivityPane{state: ActivityThinking, frame: mover + 1}
runsB := restB.snakeWord("thinking")
if runsA[1] == runsB[1] {
t.Fatal("the moving letter's rainbow hue must advance with the frame")
}
}
// TestWaveIdleRests — idle keeps the dim rest (A5) — no wave, no
// rainbow, no motion.
func TestWaveIdleRests(t *testing.T) {
a := &ActivityPane{state: ActivityIdle, frame: 5}
row := a.Render(80, true)
// the word rests in its state tone; the D53 #2568 item 6 owner
// ruling SUPERSEDES the D52 dim rest: idle renders in the
// RoleStateIdle yellow token, frame-frozen (no wave, no rainbow).
idle := themes.RoleHex(themes.RoleStateIdle)
var r, g, b int
fmt.Sscanf(idle, "#%02x%02x%02x", &r, &g, &b)
want := fmt.Sprintf("\x1b[38;2;%d;%d;%dm", r, g, b)
if !strings.Contains(row, want) {
t.Fatalf("idle's word must rest in the RoleStateIdle token:\n%s", row)
}
for _, run := range a.snakeWord("idle") {
if strings.Count(run, "\x1b[38;2") > 1 || !strings.Contains(run, want[2:]) {
t.Fatalf("idle's word runs must rest in the idle token, saw %q", run)
}
}
}
+126
View File
@@ -0,0 +1,126 @@
package lanes
// d53_agents.go — the SUB-AGENTS wave (Gitea #2568 item 7): the
// activity pane gains a "sub agents being processed now" section
// rendered ONLY while agents are in flight (the shell's syncWaves
// flight observation — the same poller the rail's waves marker reads),
// clickable AND keyboard-openable (click + keyboard parity), opening
// a CENTERED window listing all in-flight agents. Each agent row is
// inspectable: the window shows the selected agent's thought
// process / recent activity — the factory journal + observations
// detail the shell already derives. Render-what-exists honesty: a
// row with no recorded process shows the shell's honest gap lines.
//
// The seam is plain-value (rail's flightView precedent): lanes cannot
// import tui/tasks (tasks → rail → lanes is structural), so the rows
// arrive as AgentView values. Display state only — never semantic
// state, never the fingerprint, rides Clone.
import "fmt"
// AgentView is one in-flight sub-agent as the shell observed it.
type AgentView struct {
ID string
Title string
State string
Detail []string // thought process / recent activity lines
}
// agentsSection is the section's literal heading (the D54
// literal-language law — no idiom, no figure of speech).
const agentsSection = "sub agents being processed now"
// SetWaveAgents installs one flight observation (copied, never
// aliased). count is the poller's own number; rows the per-agent
// views. count <= 0 clears the section.
func (l *Lanes) SetWaveAgents(count int, rows []AgentView) {
if count <= 0 {
l.agents = nil
return
}
l.agentsCount = count
l.agents = append([]AgentView(nil), rows...)
}
// WaveAgentsCount reports the installed flight count (0 = no flight).
func (l *Lanes) WaveAgentsCount() int {
if l.agents == nil {
return 0
}
return l.agentsCount
}
// WaveAgents returns a copy of the installed per-agent views.
func (l *Lanes) WaveAgents() []AgentView {
return append([]AgentView(nil), l.agents...)
}
// agentsWindow state: the centered window's open bit, the cursor, and
// which rows show their detail.
func (l *Lanes) ToggleAgentsWindow() bool {
if l.WaveAgentsCount() == 0 {
return false // no flight, no window (the section never lies)
}
l.agentsOpen = !l.agentsOpen
return true
}
// CloseAgentsWindow dismisses the window (esc, any key).
func (l *Lanes) CloseAgentsWindow() { l.agentsOpen = false }
// AgentsWindowOpen reports whether the centered window is showing.
func (l *Lanes) AgentsWindowOpen() bool { return l.agentsOpen }
// AgentsSelMove moves the window's cursor (keyboard parity leg).
func (l *Lanes) AgentsSelMove(d int) {
n := len(l.agents)
if n == 0 {
return
}
l.agentsSel += d
if l.agentsSel < 0 {
l.agentsSel = 0
}
if l.agentsSel >= n {
l.agentsSel = n - 1
}
}
// AgentsSelected returns the cursor's agent (ok=false when none).
func (l *Lanes) AgentsSelected() (AgentView, bool) {
if l.agentsSel < 0 || l.agentsSel >= len(l.agents) {
return AgentView{}, false
}
return l.agents[l.agentsSel], true
}
// AgentsWindowLines renders the centered window's content rows at w:
// the section heading, one row per in-flight agent (title + state),
// and the SELECTED agent's detail lines beneath — the thought
// process / recent activity the shell derived. The D52 picker window
// pattern (centered, sections, walkable) is the precedent.
func (l *Lanes) AgentsWindowLines(w int) []string {
if !l.agentsOpen || len(l.agents) == 0 {
return nil
}
var out []string
out = append(out, agentsSection+fmt.Sprintf(": %d", len(l.agents)))
for i, a := range l.agents {
cursor := " "
if i == l.agentsSel {
cursor = "> "
}
out = append(out, fmt.Sprintf("%s%s — %s", cursor, a.Title, a.State))
}
if sel, ok := l.AgentsSelected(); ok && len(sel.Detail) > 0 {
out = append(out, "")
out = append(out, "recent activity:")
out = append(out, sel.Detail...)
}
// clip honestly at w (the window clips, the terminal never wraps)
for i, ln := range out {
if len([]rune(ln)) > w {
out[i] = string([]rune(ln)[:w])
}
}
return out
}
+105
View File
@@ -0,0 +1,105 @@
package lanes
// d53_agents_test.go — the SUB-AGENTS wave (Gitea #2568 item 7,
// red-first): the activity pane's "sub agents being processed now"
// section renders ONLY while agents are in flight; the section row is
// clickable and opens the CENTERED agents window listing all
// in-flight agents; the selected row is inspectable (its detail
// lines — the shell's journal/observations derivation — render
// beneath).
import (
"strings"
"testing"
)
func agentsTestLanes(t *testing.T) *Lanes {
t.Helper()
l := composerLanes(t)
l.SetWaveAgents(2, []AgentView{
{ID: "w1", Title: "land the scrollbar wave", State: "BUILD",
Detail: []string{"state BUILD · running 00:04", "journal: admit green", "obs: tests red then green"}},
{ID: "w2", Title: "polish the composer hints", State: "VERIFY (blocked_on nick)"},
})
return l
}
func TestAgentsSectionOnlyInFlight(t *testing.T) {
l := composerLanes(t)
f := l.Render()
if strings.Contains(f.Content, agentsSection) {
t.Fatal("the section must not render with no agents in flight")
}
l = agentsTestLanes(t)
f = l.Render()
if !strings.Contains(f.Content, agentsSection) {
t.Fatal("the section must render while agents are in flight")
}
if !strings.Contains(f.Content, "sub agents being processed now: 2") {
t.Fatalf("the section must carry the count, got:\n%s", f.Content)
}
// landing the last wave removes the section (chrome restored)
l.SetWaveAgents(0, nil)
f = l.Render()
if strings.Contains(f.Content, agentsSection) {
t.Fatal("the section must disappear when the flight lands")
}
}
func TestAgentsSectionClickOpensCenteredWindow(t *testing.T) {
l := agentsTestLanes(t)
f := l.Render()
var reg *struct{ X, Y, W, H int }
for _, r := range f.Regions {
if r.ID == "mouse.activity.agents" {
rr := r
reg = &struct{ X, Y, W, H int }{rr.X, rr.Y, rr.W, rr.H}
}
}
if reg == nil {
t.Fatal("the section row must carry the mouse.activity.agents click region")
}
a := l.Click(reg.X+1, reg.Y)
if a != "mouse.activity.agents" {
t.Fatalf("clicking the section must route the agents action, got %q", a)
}
if !l.AgentsWindowOpen() {
t.Fatal("the click must open the agents window")
}
}
func TestAgentsWindowListsAndInspects(t *testing.T) {
l := agentsTestLanes(t)
l.ToggleAgentsWindow()
lines := l.AgentsWindowLines(72)
joined := strings.Join(lines, "\n")
for _, want := range []string{"land the scrollbar wave", "BUILD", "polish the composer hints", "VERIFY"} {
if !strings.Contains(joined, want) {
t.Fatalf("the window must list every in-flight agent, missing %q in:\n%s", want, joined)
}
}
// the selected row's detail (thought process / recent activity)
if !strings.Contains(joined, "journal: admit green") {
t.Fatalf("the selected agent's detail must render in the window:\n%s", joined)
}
// walking selects the other agent
l.AgentsSelMove(1)
joined = strings.Join(l.AgentsWindowLines(72), "\n")
if strings.Contains(joined, "journal: admit green") {
t.Fatal("the detail must follow the cursor (only the selected row's detail)")
}
// esc closes
l.CloseAgentsWindow()
if l.AgentsWindowOpen() || l.AgentsWindowLines(72) != nil {
t.Fatal("closing the window dismisses it and its lines")
}
}
func TestAgentsWindowNeedsFlight(t *testing.T) {
l := composerLanes(t)
if l.ToggleAgentsWindow() {
t.Fatal("no flight, no window: the toggle must refuse")
}
if l.AgentsWindowOpen() {
t.Fatal("the window must not open without a flight")
}
}
+56
View File
@@ -0,0 +1,56 @@
package lanes
// d53_composerstyle_test.go — the COMPOSER STYLING wave (Gitea #2568
// item 6, red-first): the enter-send / shift+enter key hints render
// GHOSTED PINK (the themes RoleHintPink token — softer than the
// speaker pink, a role of its own per the D54 colour law), and the
// activity pane's idle state renders YELLOW (the RoleStateIdle token).
import (
"fmt"
"strings"
"testing"
"rune.dev/rune/tui/themes"
)
func sgrBodyOf(t *testing.T, hex string) string {
t.Helper()
var r, g, b int
if _, err := fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b); err != nil {
t.Fatalf("bad hex %q", hex)
}
return fmt.Sprintf("38;2;%d;%d;%d", r, g, b)
}
func TestComposerHintRendersGhostedPink(t *testing.T) {
l := composerLanes(t)
f := l.Render()
hint := l.Hint()
if hint == "" {
t.Fatal("the composer hint must be installed")
}
want := sgrBodyOf(t, themes.RoleHex(themes.RoleHintPink))
// the hint line rides the input box's last row: find it and check
// its tone is the hint-pink token, not the dim ghost tone.
found := false
for _, ln := range strings.Split(f.Content, "\n") {
if strings.Contains(ln, hint) {
found = true
if !strings.Contains(ln, want) {
t.Fatalf("the key hint must render in the RoleHintPink token (%s), row %q", want, ln)
}
}
}
if !found {
t.Fatalf("the hint %q must render in the frame", hint)
}
}
func TestActivityIdleRendersYellow(t *testing.T) {
l := composerLanes(t)
f := l.Render()
want := sgrBodyOf(t, themes.RoleHex(themes.RoleStateIdle))
if !strings.Contains(f.Content, want) {
t.Fatalf("the activity pane's idle state must render in the RoleStateIdle yellow token (%s)", want)
}
}
+126
View File
@@ -0,0 +1,126 @@
package lanes
// d53_scrollbar_test.go — the SCROLLBAR wave (Gitea #2568 item 2,
// red-first): the fade layer existed but no bar visibly rendered (the
// owner: "there is no scroll bar but you can scroll history"). The
// diagnosis: the overlay column splice dropped the thumb glyph on
// short styled rows — the common transcript shape. These tests freeze
// the repaired behaviour: the thumb VISIBLY renders inside the frame
// at the pane's right content column, coloured with the RoleScroll
// token (the D54 colour law — a role keeps colour-as-meaning intact),
// only while scrolling, fading at rest, for BOTH panes; and the
// wheel's step math tracks the wheel precisely (one notch = one fixed
// small step, no jumpy multi-line hops).
import (
"fmt"
"strings"
"testing"
"rune.dev/rune/tui/chatpane"
"rune.dev/rune/tui/semantic"
"rune.dev/rune/tui/themes"
)
func longTranscriptLanes(t *testing.T) *Lanes {
t.Helper()
l := composerLanes(t)
for i := 0; i < 40; i++ {
l.AppendChat(chatMessage(fmt.Sprintf("a%d", i), "assistant",
"body line of transcript prose "+fmt.Sprint(i)))
}
l.Render()
return l
}
func TestChatScrollbarThumbRendersInFrameWhileScrolling(t *testing.T) {
l := longTranscriptLanes(t)
if !l.ScrollChatUp(5) {
t.Fatal("scroll must move")
}
l.chat.TouchScroll()
f := l.Render()
lines := strings.Split(f.Content, "\n")
seen := 0
for _, ln := range lines {
if strings.Contains(ln, "▐") {
seen++
}
}
if seen == 0 {
t.Fatal("the scrollbar thumb must visibly render inside the frame while scrolling")
}
}
func TestChatScrollbarFadesAtRest(t *testing.T) {
l := longTranscriptLanes(t)
l.ScrollChatUp(5)
l.chat.TouchScroll()
for i := 0; i < chatpane.FadeTicks; i++ {
l.chat.TickScroll()
}
f := l.Render()
if strings.Contains(f.Content, "▐") {
t.Fatal("the settled scrollbar must fade to nothing (visible ONLY while actively scrolling)")
}
}
func TestChatScrollbarColourIsTheScrollRole(t *testing.T) {
l := longTranscriptLanes(t)
l.ScrollChatUp(5)
l.chat.TouchScroll()
rows := l.chat.ScrollbarRows()
joined := strings.Join(rows, "")
// lipgloss renders the hex as its true-colour SGR body; the token
// law is about the RESOLVED colour, so assert the SGR body.
var r, g, b int
fmt.Sscanf(themes.RoleHex(themes.RoleScroll), "#%02x%02x%02x", &r, &g, &b)
want := fmt.Sprintf("38;2;%d;%d;%d", r, g, b)
if !strings.Contains(joined, want) {
t.Fatalf("the thumb must carry the RoleScroll token colour (%s), got %q",
themes.RoleHex(themes.RoleScroll), joined[:min(40, len(joined))])
}
}
func TestWheelStepTracksTheWheelPrecisely(t *testing.T) {
l := longTranscriptLanes(t)
l.ScrollChatUp(3)
if got := l.chat.ScrollOffset(); got != 3 {
t.Fatalf("one wheel notch must move exactly its 3-line step, moved %d", got)
}
l.ScrollChatUp(3)
if got := l.chat.ScrollOffset(); got != 6 {
t.Fatalf("notches accumulate exactly: offset %d, want 6", got)
}
l.ScrollChatDown(6)
if got := l.chat.ScrollOffset(); got != 0 {
t.Fatalf("down notches return exactly to the tail, offset %d", got)
}
}
func TestTasksScrollbarThumbRendersInFrameWhileScrolling(t *testing.T) {
l := composerLanes(t)
for i := 0; i < 30; i++ {
l.st.Tasks = append(l.st.Tasks, semantic.Task{ID: fmt.Sprintf("t%d", i), Label: "task"})
}
l.Render()
l.TouchTaskScroll()
if !l.TaskScrollbarVisible() {
t.Fatal("the overflowing task list must arm the scrollbar layer")
}
l.TouchTaskScroll()
f := l.Render()
if !strings.Contains(f.Content, "▐") {
t.Fatal("the tasks pane's scrollbar thumb must visibly render while scrolling")
}
}
// the layer law regression: the thumb overlay never shifts content.
func TestScrollbarOverlayKeepsContentColumns(t *testing.T) {
got := overlayDisplayCol("\x1b[38;2;138;138;158mhello\x1b[m", 10, "▐")
if !strings.Contains(got, "▐") {
t.Fatalf("a short styled row must still carry the thumb glyph, got %q", got)
}
if n := len([]rune(semantic.StripANSI(got))); n != 11 {
t.Fatalf("the spliced line must be text+glyph columns (11), got %d", n)
}
}
+49
View File
@@ -0,0 +1,49 @@
package lanes
import "testing"
// D54 item 1 (Gitea #2571): MOTION LAW — the wave render path is
// gated behind one boolean, motionAllowed. When the gate is closed
// the activity strip NEVER changes between frames: no wave, no
// rainbow advance, no walking runes. Idle already rested (A5); the
// gate extends that rest to active states.
func TestMotionGateFreezesActiveStrip(t *testing.T) {
a := newActivityPane()
a.SetMotionAllowed(false)
a.SetSignal(ActivitySignal{TurnActive: true})
a.SetFrame(0)
r0 := a.Render(60, true)
a.SetFrame(37)
r37 := a.Render(60, true)
if r0 != r37 {
t.Fatalf("motion-gated strip must be frame-frozen (never-animate law):\nframe0: %q\nframe37: %q", r0, r37)
}
// the plain path too
p0 := a.Render(60, false)
a.SetFrame(41)
p41 := a.Render(60, false)
if p0 != p41 {
t.Fatalf("motion-gated plain strip must be frame-frozen: %q vs %q", p0, p41)
}
}
func TestMotionGateAllowsWaveWhenOpen(t *testing.T) {
a := newActivityPane()
a.SetMotionAllowed(true)
a.SetSignal(ActivitySignal{TurnActive: true})
a.SetFrame(0)
r0 := a.Render(60, true)
a.SetFrame(a.waveCycleLen(8))
rC := a.Render(60, true)
if r0 == rC {
t.Fatalf("with the gate open the wave must still move (owner's vivid default)")
}
}
func TestMotionGateDefaultOpen(t *testing.T) {
a := newActivityPane()
if !a.MotionAllowed() {
t.Fatalf("a fresh pane must allow motion (default vivid)")
}
}
+89 -8
View File
@@ -21,8 +21,11 @@ import (
"fmt"
"strings"
"charm.land/lipgloss/v2"
"rune.dev/rune/tui/chatpane"
"rune.dev/rune/tui/semantic"
"rune.dev/rune/tui/themes"
)
// Lanes couples one semantic State to the two-lane projection. It
@@ -44,6 +47,12 @@ type Lanes struct {
composer []string
placeholder string
hint string
// the D53 sub-agents seam (d53_agents.go): the flight observation
// the shell's syncWaves installs — display state, never semantic.
agents []AgentView
agentsCount int
agentsOpen bool
agentsSel int
// menu is the shell-injected model-picker menu (view-only lines
// rendered directly ABOVE the input box — never a transcript
// message; the owner's 2026-10-18 ruling: model selection is a
@@ -88,6 +97,15 @@ func (l *Lanes) SetActivity(sig ActivitySignal, frame int) {
}
}
// SetActivityMotionAllowed opens or closes the activity pane's MOTION
// LAW gate (D54 #2571 item 1): closed means the strip is frame-frozen.
func (l *Lanes) SetActivityMotionAllowed(on bool) {
if l.activity == nil {
l.activity = newActivityPane()
}
l.activity.SetMotionAllowed(on)
}
// SetActivityTurn installs the current turn's processing record (the +
// window's content source).
func (l *Lanes) SetActivityTurn(t ActivityTurn) {
@@ -153,6 +171,13 @@ func New(w, h int) *Lanes {
return &Lanes{st: semantic.Seed(w, h), activity: newActivityPane()}
}
// NewEmpty returns the two-lane face on the honest PRODUCTION boot
// state (D52 seed-substrate law, #2566): no lab tasks, no seeded chat
// line — the live data fills it.
func NewEmpty(w, h int) *Lanes {
return &Lanes{st: semantic.Empty(w, h), activity: newActivityPane()}
}
// NewFromState returns the two-lane face over an existing state (the
// caller's copy — clone first when sharing).
func NewFromState(s semantic.State) *Lanes {
@@ -182,6 +207,24 @@ func (l *Lanes) AppendChat(ms ...chatpane.Message) int {
return n
}
// StreamChat installs or extends the progressive-render assistant
// bubble with the running content (Gitea #2556 — the answer renders
// as its bytes arrive). Reports whether the transcript changed.
func (l *Lanes) StreamChat(content string) bool {
if l.chat == nil {
l.chat = chatpane.New()
}
return l.chat.UpsertStream(content)
}
// DropStreamChat retires the streaming bubble (a failed dispatch).
func (l *Lanes) DropStreamChat() bool {
if l.chat == nil {
return false
}
return l.chat.DropStream()
}
// ChatSnapshot returns the transcript (oldest first).
func (l *Lanes) ChatSnapshot() []chatpane.Message {
if l.chat == nil {
@@ -237,6 +280,9 @@ func (l *Lanes) SetComposer(lines []string, placeholder, hint string) {
l.hint = hint
}
// Hint returns the installed composer hint (the D53 read-back seam).
func (l *Lanes) Hint() string { return l.hint }
// SetMenu installs the model-picker menu lines (view-only, rendered
// above the composer, never appended to the transcript). Empty closes
// the menu.
@@ -245,6 +291,12 @@ func (l *Lanes) SetMenu(lines []string) {
l.card = false
}
// MenuSnapshot returns the injected menu lines (test seam — the D52
// picker-window law reads the rendered chooser through it).
func (l *Lanes) MenuSnapshot() []string {
return append([]string(nil), l.menu...)
}
// SetApprovalCard installs the T5 approval card into the overlay band
// (view-only lines above the composer — the pending approval's face,
// rendered by tui/approvals). Empty closes the card.
@@ -293,9 +345,28 @@ func (l *Lanes) TaskScrollbarVisible() bool {
// of h rows: a right-edge thumb proportional to the selection position
// over the task count. "" when not visible (transparent at rest).
func (l *Lanes) TaskScrollbarRow(h int) string {
if !l.TaskScrollbarVisible() || len(l.st.Tasks) == 0 {
rows := l.TaskScrollbarRows(h)
if rows == nil {
return ""
}
out := ""
for _, r := range rows {
if r == "" {
out += " "
continue
}
out += "▐"
}
return out
}
// TaskScrollbarRows renders the tasks pane's scrollbar layer as ONE
// string per pane row (the D53 #2568 repair): the thumb's rows carry
// the RoleScroll token colour, every other row is "" (no overlay).
func (l *Lanes) TaskScrollbarRows(h int) []string {
if !l.TaskScrollbarVisible() || len(l.st.Tasks) == 0 {
return nil
}
n := len(l.st.Tasks)
rows := max(h-4, 1)
thumb := max(rows*rows/n, 1)
@@ -303,16 +374,16 @@ func (l *Lanes) TaskScrollbarRow(h int) string {
if n > 1 {
pos = (rows - thumb) * l.st.Sel / (n - 1)
}
row := make([]rune, rows)
for i := range row {
row[i] = ' '
out := make([]string, rows)
for i := pos; i < pos+thumb && i < len(out); i++ {
out[i] = taskScrollThumbStyle.Render("▐")
}
for i := pos; i < pos+thumb && i < len(row); i++ {
row[i] = '▐'
}
return string(row)
return out
}
// taskScrollThumbStyle is the tasks thumb's token colour: RoleScroll.
var taskScrollThumbStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleScroll)))
// SetComposerCursor sets the D43.14 blinking block cursor's phase on
// the empty composer's ghost row (the shell's tick drives the blink;
// reduced motion rests the block lit).
@@ -399,6 +470,14 @@ func (l *Lanes) Click(x, y int) semantic.ActionID {
}
return ""
}
// the sub-agents section (D53 #2568 item 7): clicking it opens
// the centered agents window (the D52 picker pattern).
if r.ID == "mouse.activity.agents" {
if l.ToggleAgentsWindow() {
return semantic.ActionID(r.ID)
}
return ""
}
}
for _, r := range fr.Regions {
if r.ID == "region.chat" && inRegion(r, x, y) {
@@ -536,6 +615,8 @@ func (l *Lanes) Clone() semantic.Face {
n.card = l.card
n.hints = append([]string(nil), l.hints...)
n.cursorOn = l.cursorOn
n.agents = append([]AgentView(nil), l.agents...)
n.agentsCount, n.agentsOpen, n.agentsSel = l.agentsCount, l.agentsOpen, l.agentsSel
if l.activity != nil {
n.activity = &ActivityPane{
state: l.activity.state, frame: l.activity.frame, seed: l.activity.seed,
+67 -14
View File
@@ -9,6 +9,7 @@ import (
"rune.dev/rune/tui/approvals"
"rune.dev/rune/tui/chatpane"
"rune.dev/rune/tui/semantic"
"rune.dev/rune/tui/themes"
)
// Render projects the semantic State through the two-lane layout (D36:
@@ -88,8 +89,18 @@ func (l *Lanes) Render() semantic.Frame {
// the strip's right edge — the window's only click road.
if len(activity) > 0 {
regions = append(regions, semantic.Region{
ID: "mouse.activity.plus", X: s.W - activityPlusWidth(), Y: activityY,
W: activityPlusWidth(), H: 1, Marker: "+",
ID: "mouse.activity.plus", X: s.W - activityPlusWidth, Y: activityY,
W: activityPlusWidth, H: 1, Marker: "+",
})
}
// the sub-agents section's click region (D53 #2568 item 7): the
// section row below the strip's activity row, ONLY in flight —
// clicking opens the centered agents window (the D52 picker
// pattern); keyboard parity rides the shell's 'w' key.
if l.WaveAgentsCount() > 0 && len(activity) > 1 {
regions = append(regions, semantic.Region{
ID: "mouse.activity.agents", X: 0, Y: activityY + 1,
W: s.W, H: 1, Marker: "agents",
})
}
// thinking-bubble control regions (D35 + D43.7: the marker, the
@@ -239,25 +250,28 @@ func buildComposerLayers(cursorOn bool, placeholder, hint string, inner int) com
ghost = ghostStyle.Render(" " + placeholder)
}
c.Rows = append(c.Rows,
composerRow{Left: "│", Core: "", Right: "│"}, // the pad row above
composerRow{Left: "│", Core: ghost, Right: "│"}, // the ghost row (cursor cell + ghost)
composerRow{Left: "│", Core: "", Right: "│"}, // the pad row below
composerRow{Left: "│", Core: " " + hint, Right: "│"}, // the key hint rides the last row
composerRow{Left: "│", Core: "", Right: "│"}, // the pad row above
composerRow{Left: "│", Core: ghost, Right: "│"}, // the ghost row (cursor cell + ghost)
composerRow{Left: "│", Core: "", Right: "│"}, // the pad row below
// the key hint rides the last row, GHOSTED PINK (D53 #2568
// item 6): the RoleHintPink token — softer than the speaker
// pink, a role of its own per the D54 colour law.
composerRow{Left: "│", Core: " " + composerHintStyle.Render(hint), Right: "│"},
)
return c
}
// chatScrollbarOverlay renders the chat pane's scrollbar layer rows for
// a pane body of h rows and w columns ("" rows when the layer rests).
// a pane body of h rows ("" rows when the layer rests). The rows come
// pre-styled from the pane's ScrollbarRows (the RoleScroll token).
func (l *Lanes) chatScrollbarOverlay(h, w int) []string {
if l.chat == nil {
return nil
}
full := l.chat.ScrollbarRow(w)
if full == "" {
rows := l.chat.ScrollbarRows()
if rows == nil {
return nil
}
rows := strings.Split(full, "")
return rows[:min(len(rows), max(h-2, 0))]
}
@@ -291,10 +305,16 @@ func overlayRight(content []string, rows []string, x int) []string {
// overlayDisplayCol replaces one display column of a (possibly styled)
// line: split at the column on display coordinates, keep every ANSI
// run intact on both sides, splice the glyph between them.
// run intact on both sides, splice the glyph between them. A line
// SHORTER than x still carries the glyph at column x (the D53
// scrollbar diagnosis: the old splicer wrote the glyph into the left
// half only when a content rune sat at column x — a short styled row,
// the common transcript shape, lost the thumb entirely and the bar
// never visibly rendered).
func overlayDisplayCol(line string, x int, glyph string) string {
var left, right strings.Builder
col := 0
wrote := false
inEsc := false
var esc strings.Builder
for _, r := range line {
@@ -321,7 +341,12 @@ func overlayDisplayCol(line string, x int, glyph string) string {
if col < x {
left.WriteRune(r)
} else if col == x {
if esc.Len() > 0 {
left.WriteString(esc.String())
esc.Reset()
}
left.WriteString(glyph)
wrote = true
} else {
right.WriteRune(r)
}
@@ -330,6 +355,13 @@ func overlayDisplayCol(line string, x int, glyph string) string {
if inEsc {
left.WriteString(esc.String())
}
if !wrote {
// the line ended before column x: pad to the column, then the
// glyph (drawPane pads the tail — the glyph lands at x exactly)
pad := max(x-col, 0)
left.WriteString(strings.Repeat(" ", pad))
left.WriteString(glyph)
}
return left.String() + right.String()
}
@@ -483,6 +515,11 @@ func stripANSI(s string) string {
func taskContent(s *semantic.State, w int) []string {
inner := max(w-2, 0)
lines := []string{clip(fmt.Sprintf("TASKS %s", mark(s.Focus == semantic.FocusTasks)), inner)}
if len(s.Tasks) == 0 {
// the D51 blank-unless-goals ruling, D52 law (#2566): an empty
// substrate renders the honest idle state — never lab rows.
lines = append(lines, clip("no outstanding work", inner))
}
for i, t := range s.Tasks {
sel := " "
if s.Focus == semantic.FocusTasks && i == s.Sel {
@@ -572,6 +609,10 @@ var (
// ghostStyle is the D43.14 ghosted placeholder tone (dim on the
// base, ≥4.5:1 — the same dim family as the transcript stamps).
ghostStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#78788A"))
// composerHintStyle is the D53 ghosted-pink key-hint tone: the
// RoleHintPink token (one hex per role, themes/roles.go — softer
// than the speaker pink, a role of its own).
composerHintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleHintPink)))
// cursorStyle is the D43.14 blinking block cursor's tone: the lit
// pink, marking chat-here.
cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF79C6"))
@@ -590,11 +631,23 @@ func (l *Lanes) activityStrip(w int) []string {
return nil
}
row := l.activity.Render(w, true)
return []string{padRow(row, w)}
rows := []string{padRow(row, w)}
// THE SUB-AGENTS SECTION (D53 #2568 item 7): rendered ONLY while
// agents are in flight — the flight observation the shell's
// syncWaves installs. Clickable: its region is
// mouse.activity.agents (the section row), opening the centered
// agents window (keyboard parity: the shell's 'w' key).
if n := l.WaveAgentsCount(); n > 0 {
text := fmt.Sprintf("%s: %d", agentsSection, n)
line := activitySectionStyle.Render(text)
rows = append(rows, padRow(line, w))
}
return rows
}
// activityPlusWidth is the + affordance's click-region columns.
func activityPlusWidth() int { return 3 }
// activityPlusWidth is the + affordance's click-region columns — the
// single declaration lives in activity.go (the D52 law: the region
// and the render share one constant).
// clip clips s to w runes.
func clip(s string, w int) string {
+4
View File
@@ -202,6 +202,10 @@ func (in *Input3) browseDown() {
// exitBrowsing leaves browsing discarding the stash (any editing key).
func (in *Input3) exitBrowsing() { in.browsing = false }
// Clear empties the input (the submenu-execution leg's reset — the
// '/' road runs the highlighted command and clears the draft).
func (in *Input3) Clear() { in.setText("") }
// setText replaces the whole input with s and parks the cursor at the
// end (how a history entry or stash is shown).
func (in *Input3) setText(s string) {
+56 -6
View File
@@ -226,15 +226,35 @@ func New(w, h int) *Promote { return newPromote(semantic.ModeRich, w, h) }
func NewLinear(w, h int) *Promote { return newPromote(semantic.ModeLinear, w, h) }
func newPromote(mode string, w, h int) *Promote {
inner := rail.New(w, h)
if mode == semantic.ModeLinear {
return newPromoteSeed(mode, w, h, true)
}
// NewEmpty returns the rich-projection promote face on the honest
// PRODUCTION boot (D52 seed-substrate law, #2566): no seed
// suggestions — Offer fills the list when real suggestions arrive.
func NewEmpty(w, h int) *Promote { return newPromoteSeed(semantic.ModeRich, w, h, false) }
// NewLinearEmpty returns the linear-projection promote face on the
// honest PRODUCTION boot (zero-ANSI law, D52 seed-substrate law).
func NewLinearEmpty(w, h int) *Promote { return newPromoteSeed(semantic.ModeLinear, w, h, false) }
func newPromoteSeed(mode string, w, h int, seeded bool) *Promote {
var inner *rail.Rail
switch {
case seeded && mode == semantic.ModeLinear:
inner = rail.NewLinear(w, h)
case seeded:
inner = rail.New(w, h)
case mode == semantic.ModeLinear:
inner = rail.NewLinearEmpty(w, h)
default:
inner = rail.NewEmpty(w, h)
}
return &Promote{
inner: inner,
mode: mode,
suggestions: Rank(SeedSuggestions),
p := &Promote{inner: inner, mode: mode}
if seeded {
p.suggestions = Rank(SeedSuggestions)
}
return p
}
// Open reports whether the suggestion region is showing.
@@ -466,6 +486,9 @@ func (p *Promote) SetComposer(lines []string, placeholder, hint string) {
p.inner.SetComposer(lines, placeholder, hint)
}
// Hint returns the installed composer hint (the D53 read-back seam).
func (p *Promote) Hint() string { return p.inner.Hint() }
// SetComposerCursor sets the D43.14 blinking block cursor's phase on
// the empty composer's ghost row (the shell's tick drives the blink).
func (p *Promote) SetComposerCursor(on bool) {
@@ -487,6 +510,10 @@ func (p *Promote) SetActivity(sig lanes.ActivitySignal, frame int) {
p.inner.SetActivity(sig, frame)
}
// SetActivityMotionAllowed passes the MOTION LAW gate down the face
// chain (D54 #2571 item 1).
func (p *Promote) SetActivityMotionAllowed(on bool) { p.inner.SetActivityMotionAllowed(on) }
// SetActivityTurn installs the current turn's processing record (the +
// window's content source).
func (p *Promote) SetActivityTurn(t lanes.ActivityTurn) { p.inner.SetActivityTurn(t) }
@@ -511,6 +538,13 @@ func (p *Promote) AmendChatThinking(content string) bool {
return p.inner.AmendChatThinking(content)
}
// StreamChat passes the progressive-render bubble through (Gitea
// #2556 — the streamed turn renders as its bytes arrive).
func (p *Promote) StreamChat(content string) bool { return p.inner.StreamChat(content) }
// DropStreamChat retires the streaming bubble (a failed dispatch).
func (p *Promote) DropStreamChat() bool { return p.inner.DropStreamChat() }
// DropChatThinking removes the trailing pending thinking bubble.
func (p *Promote) DropChatThinking() bool { return p.inner.DropChatThinking() }
@@ -518,6 +552,10 @@ func (p *Promote) DropChatThinking() bool { return p.inner.DropChatThinking() }
// (view-only, above the composer — never transcript content).
func (p *Promote) SetMenu(lines []string) { p.inner.SetMenu(lines) }
// MenuSnapshot returns the shell-injected model-picker menu lines
// (test seam, the D52 picker-window law).
func (p *Promote) MenuSnapshot() []string { return p.inner.MenuSnapshot() }
// SetApprovalCard installs the T5 approval card lines in the chat
// lane's overlay band (view-only; empty closes it).
func (p *Promote) SetApprovalCard(lines []string) { p.inner.SetApprovalCard(lines) }
@@ -657,3 +695,15 @@ func (p *Promote) SetStill(still bool) { p.inner.SetStill(still) }
// ChatScrollOffset reports the chat pane's scroll offset.
func (p *Promote) ChatScrollOffset() int { return p.inner.ChatScrollOffset() }
// the D53 sub-agents seam (Gitea #2568 item 7): display-state
// passthroughs to the lanes' agents section + centered window.
func (p *Promote) SetWaveAgents(count int, rows []lanes.AgentView) {
p.inner.SetWaveAgents(count, rows)
}
func (p *Promote) WaveAgentsCount() int { return p.inner.WaveAgentsCount() }
func (p *Promote) ToggleAgentsWindow() bool { return p.inner.ToggleAgentsWindow() }
func (p *Promote) CloseAgentsWindow() { p.inner.CloseAgentsWindow() }
func (p *Promote) AgentsWindowOpen() bool { return p.inner.AgentsWindowOpen() }
func (p *Promote) AgentsSelMove(d int) { p.inner.AgentsSelMove(d) }
func (p *Promote) AgentsWindowLines(w int) []string { return p.inner.AgentsWindowLines(w) }
+40
View File
@@ -101,6 +101,18 @@ func newRail(mode string, w, h int) *Rail {
return &Rail{lanes: lanes.New(w, h), mode: mode}
}
// NewEmpty returns the rail over the honest PRODUCTION boot state
// (D52 seed-substrate law, #2566): no lab fixtures.
func NewEmpty(w, h int) *Rail { return newRailEmpty(semantic.ModeRich, w, h) }
// NewLinearEmpty returns the linear-projection rail over the honest
// PRODUCTION boot state (zero-ANSI law, D52 seed-substrate law).
func NewLinearEmpty(w, h int) *Rail { return newRailEmpty(semantic.ModeLinear, w, h) }
func newRailEmpty(mode string, w, h int) *Rail {
return &Rail{lanes: lanes.NewEmpty(w, h), mode: mode}
}
// Open reports whether the takeover box is showing.
func (r *Rail) Open() bool { return r.open }
@@ -223,6 +235,9 @@ func (r *Rail) SetComposer(lines []string, placeholder, hint string) {
r.lanes.SetComposer(lines, placeholder, hint)
}
// Hint returns the installed composer hint (the D53 read-back seam).
func (r *Rail) Hint() string { return r.lanes.Hint() }
// SetComposerCursor sets the D43.14 blinking block cursor's phase on
// the empty composer's ghost row (the shell's tick drives the blink).
func (r *Rail) SetComposerCursor(on bool) {
@@ -251,6 +266,10 @@ func (r *Rail) SetActivity(sig lanes.ActivitySignal, frame int) {
r.lanes.SetActivity(sig, frame)
}
// SetActivityMotionAllowed passes the MOTION LAW gate down to the
// activity pane (D54 #2571 item 1).
func (r *Rail) SetActivityMotionAllowed(on bool) { r.lanes.SetActivityMotionAllowed(on) }
// SetActivityTurn installs the current turn's processing record (the +
// window's content source).
func (r *Rail) SetActivityTurn(t lanes.ActivityTurn) { r.lanes.SetActivityTurn(t) }
@@ -278,6 +297,13 @@ func (r *Rail) AmendChatThinking(content string) bool {
return r.lanes.AmendChatThinking(content)
}
// StreamChat passes the progressive-render bubble through (Gitea
// #2556 — the streamed turn renders as its bytes arrive).
func (r *Rail) StreamChat(content string) bool { return r.lanes.StreamChat(content) }
// DropStreamChat retires the streaming bubble (a failed dispatch).
func (r *Rail) DropStreamChat() bool { return r.lanes.DropStreamChat() }
// DropChatThinking removes the trailing pending thinking bubble.
func (r *Rail) DropChatThinking() bool { return r.lanes.DropChatThinking() }
@@ -285,6 +311,10 @@ func (r *Rail) DropChatThinking() bool { return r.lanes.DropChatThinking() }
// (view-only, above the composer).
func (r *Rail) SetMenu(lines []string) { r.lanes.SetMenu(lines) }
// MenuSnapshot returns the shell-injected model-picker menu lines
// (test seam, the D52 picker-window law).
func (r *Rail) MenuSnapshot() []string { return r.lanes.MenuSnapshot() }
// SetApprovalCard installs the T5 approval card lines in the chat
// lane's overlay band (view-only; empty closes it).
func (r *Rail) SetApprovalCard(lines []string) { r.lanes.SetApprovalCard(lines) }
@@ -398,3 +428,13 @@ func (r *Rail) SetChatStyle(s chatpane.Style) { r.lanes.SetChatStyle(s) }
// ChatScrollOffset reports the chat pane's scroll offset.
func (r *Rail) ChatScrollOffset() int { return r.lanes.ChatScrollOffset() }
// the D53 sub-agents seam (Gitea #2568 item 7): display-state
// passthroughs to the lanes' agents section + centered window.
func (r *Rail) SetWaveAgents(count int, rows []lanes.AgentView) { r.lanes.SetWaveAgents(count, rows) }
func (r *Rail) WaveAgentsCount() int { return r.lanes.WaveAgentsCount() }
func (r *Rail) ToggleAgentsWindow() bool { return r.lanes.ToggleAgentsWindow() }
func (r *Rail) CloseAgentsWindow() { r.lanes.CloseAgentsWindow() }
func (r *Rail) AgentsWindowOpen() bool { return r.lanes.AgentsWindowOpen() }
func (r *Rail) AgentsSelMove(d int) { r.lanes.AgentsSelMove(d) }
func (r *Rail) AgentsWindowLines(w int) []string { return r.lanes.AgentsWindowLines(w) }
+68
View File
@@ -11,6 +11,7 @@ package lib
// core's envelope law accepts rune metadata beside the OpenAI fields).
import (
"bufio"
"bytes"
"context"
"encoding/json"
@@ -18,6 +19,7 @@ import (
"fmt"
"io"
"net/http"
"strings"
"time"
)
@@ -144,6 +146,72 @@ func (r *Repo) Complete(ctx context.Context, req Completion) (CompletionResult,
}, nil
}
// CompleteStream dispatches one model call with stream=true and
// surfaces the answer's text AS IT ARRIVES through onDelta (Gitea
// #2556 — the turn never sits inside one blocking call on the TUI
// leg). The returned CompletionResult carries the assembled content
// plus the terminal chunk's rune block (nothing the core knows is
// dropped). onDelta may be nil (the assembled-only caller).
func (r *Repo) CompleteStream(ctx context.Context, req Completion, onDelta func(string)) (CompletionResult, error) {
body, err := r.post(ctx, "/v1/chat/completions", completionWire{
Model: req.Model,
Channel: req.Channel,
Messages: toWireMessages(req.Messages),
Stream: true,
})
if err != nil {
return CompletionResult{}, err
}
defer body.Close()
var result CompletionResult
var content strings.Builder
sc := bufio.NewScanner(body)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
var chunk struct {
ID string `json:"id"`
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
Rune map[string]any `json:"rune"`
}
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
return CompletionResult{}, err
}
if chunk.ID != "" {
result.ID = chunk.ID
}
if len(chunk.Choices) > 0 {
ch := chunk.Choices[0]
if ch.Delta.Content != "" {
content.WriteString(ch.Delta.Content)
if onDelta != nil {
onDelta(ch.Delta.Content)
}
}
if ch.FinishReason != nil {
result.Finish = *ch.FinishReason
}
}
if chunk.Rune != nil {
result.Rune = chunk.Rune
}
}
if err := sc.Err(); err != nil {
return CompletionResult{}, err
}
result.Role = "assistant"
result.Content = content.String()
return result, nil
}
func toWireMessages(ms []CompletionMessage) []completionMessageWire {
out := make([]completionMessageWire, 0, len(ms))
for _, m := range ms {
+62
View File
@@ -0,0 +1,62 @@
package lib
// complete_stream_test.go — the TUI leg's streaming proofs (Gitea
// #2556, red-first, hermetic): CompleteStream parses the core's NDJSON
// chunk framing incrementally, surfaces every text delta AS IT
// ARRIVES, and returns the assembled content + the terminal rune
// block; the completion client carries NO whole-call timeout (the
// core's idle watch governs the streamed turn).
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestCompleteStreamDeltasAndResult(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/x-ndjson")
chunks := []string{
`{"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant","content":"hel"}}]}`,
`{"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{"content":"lo"}}]}`,
`{"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{},"finish_reason":"stop"}],"rune":{"success":true,"model":"m"}}`,
}
for i, line := range chunks {
_, _ = w.Write([]byte(line + "\n"))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
if i < len(chunks)-1 {
time.Sleep(100 * time.Millisecond)
}
}
}))
defer srv.Close()
r := &Repo{entryBase: srv.URL, compHC: &http.Client{}}
var got strings.Builder
res, err := r.CompleteStream(context.Background(), Completion{Messages: []CompletionMessage{{Role: "user", Content: "hi"}}}, func(d string) {
got.WriteString(d)
})
if err != nil {
t.Fatal(err)
}
if got.String() != "hello" || res.Content != "hello" {
t.Fatalf("deltas=%q content=%q", got.String(), res.Content)
}
if res.Finish != "stop" {
t.Fatalf("finish = %q", res.Finish)
}
if res.Rune == nil || res.Rune["success"] != true {
t.Fatalf("rune block = %v", res.Rune)
}
}
func TestCompletionClientIsUngated(t *testing.T) {
r := &Repo{entryBase: "http://127.0.0.1:8377", compHC: &http.Client{}}
if r.compHC.Timeout != 0 {
t.Fatalf("completion client timeout = %v, want 0 (the streamed turn is governed by the core's idle watch)", r.compHC.Timeout)
}
}
+8 -7
View File
@@ -152,12 +152,13 @@ type Repo struct {
run runner
entryBase string
hc *http.Client
// compHC is the DEDICATED completion client (chat-live P1 F3): the
// 10s blanket on hc fits reads, not completions — a dispatched
// completion runs the core's WHOLE tool loop (D47 defect 3: up to
// ToolLoopIterations model calls), so the client's timeout carries
// the local whole-turn ceiling; the per-class deadline rides the
// dispatch ctx the callers bind (TurnBudgetFor).
// compHC is the DEDICATED completion client (chat-live P1 F3).
// After the streaming root-cause wave (Gitea #2556) it carries NO
// whole-call Timeout: the dispatch rides stream=true and the CORE
// governs its deadlines (the inter-chunk idle timeout + the
// 30-minute backstop) — a flat client cap here would patch the
// symptom again. Non-streaming fallback callers keep their ctx
// class budget (TurnBudgetFor).
compHC *http.Client
serveCmd *exec.Cmd
closed bool
@@ -197,7 +198,7 @@ func Open(root string) (*Repo, error) {
root: root,
run: binRunner(binDir),
hc: &http.Client{Timeout: 10 * time.Second},
compHC: &http.Client{Timeout: LocalTurnBudget},
compHC: &http.Client{},
}
if err := r.startServe(binDir); err != nil {
os.RemoveAll(binDir)
+16 -8
View File
@@ -89,33 +89,39 @@ func configs() []faceConfig {
boxes: []string{"sug-1", "sug-2", "sug-3"},
},
{
name: "tasks/closed",
face: func() semantic.Face { return tasks.New(80, 24) },
name: "tasks/closed",
// D52 supersession (#2566): the box inventory drives the
// canonical seed substrate — the lab seed is explicit.
face: func() semantic.Face { return tasks.NewSeeded(80, 24) },
boxes: []string{"chat", "task-1", "task-2", "task-3"},
pending: []string{"chat"},
},
{
name: "tasks/tree",
face: func() semantic.Face {
f := tasks.New(80, 24)
f := tasks.NewSeeded(80, 24)
f.SetTreeOpen(true) // D36 owner fix: t is a letter now
return f
},
boxes: []string{"task-1", "task-1a", "task-1b", "task-2", "task-3"},
// D50 residual 3: no selectable rows anywhere — the tree
// region is a view and declares no boxes.
boxes: nil,
},
{
name: "tasks/linear-tree",
face: func() semantic.Face {
f := tasks.NewLinear(80, 24)
f.SetLiveTree(tasks.SeedTree(), "lab")
f.SetTreeOpen(true) // D36 owner fix: t is a letter now
return f
},
boxes: []string{"task-1", "task-1a", "task-1b", "task-2", "task-3"},
// D50 residual 3: no selectable rows anywhere.
boxes: nil,
},
{
name: "tasks/promote-open-inside",
face: func() semantic.Face {
f := tasks.New(80, 24)
f := tasks.NewSeeded(80, 24)
f.SetPromotionOpen(true) // D36 owner fix: p is a letter now
return f
},
@@ -124,7 +130,7 @@ func configs() []faceConfig {
{
name: "tasks/rail-takeover-inside",
face: func() semantic.Face {
f := tasks.New(80, 24)
f := tasks.NewSeeded(80, 24)
f.SetTakeoverOpen(true)
return f
},
@@ -273,7 +279,9 @@ func (f *shiftTabBlindFace) SelectedBox() string {
// chain (the face cmd/rune-tui actually boots) with shift+tab dropped
// anywhere in it must go RED, not pass silently.
func TestMutantShiftTabBlindTripsRed(t *testing.T) {
rep := semantic.CheckSelection(&shiftTabBlindFace{tasks.New(80, 24)})
// D52 supersession (#2566): the mutant proof drives the seed
// substrate — the lab seed is explicit.
rep := semantic.CheckSelection(&shiftTabBlindFace{tasks.NewSeeded(80, 24)})
if rep.OK {
t.Fatal("shift+tab-blind mutant of the shell face chain PASSED the conformance gate — the gate has no teeth")
}
+7
View File
@@ -68,6 +68,13 @@ func Seed(w, h int) State {
}
}
// Empty returns the honest PRODUCTION boot state (D52 seed-substrate
// law, #2566): no lab tasks, no seeded chat line — the face boots
// blank and the live data fills it. The lab harness keeps Seed.
func Empty(w, h int) State {
return State{W: w, H: h, Status: "RUNNING", Focus: FocusChat, Seq: -1}
}
// Clone deep-copies the slice fields so two faces never share memory.
func (s State) Clone() State {
n := s
+8
View File
@@ -32,6 +32,11 @@ const (
KeyThemesTheme = "themes.theme"
KeyThemesAnimation = "themes.animation"
KeyThemesReducedMotion = "themes.reduced_motion"
// KeyThemesSensory is the D54 sensory presentation setting
// (#2571 item 5, the NAS Vivid/Calm precedent): "vivid" (the
// DEFAULT — the owner's ruling) or "calm" (muted chroma + no
// wave motion).
KeyThemesSensory = "themes.sensory"
)
// ChannelKey is the setting key of one notification channel's toggle.
@@ -51,6 +56,7 @@ func KnownSettings() []string {
KeyThemesTheme,
KeyThemesAnimation,
KeyThemesReducedMotion,
KeyThemesSensory,
}
for _, c := range ChannelOrder {
out = append(out, ChannelKey(c))
@@ -96,6 +102,8 @@ type ThemesConfig struct {
Theme string
Animation string
ReducedMotion bool
// Sensory is the presentation mode: "vivid" (default) or "calm".
Sensory string
}
// Config is the whole live config, as the owner holds it.
+81
View File
@@ -0,0 +1,81 @@
package settings
import (
"strings"
"testing"
)
// D54 item 5 (Gitea #2571): the sensory setting is part of the closed
// address space, rendered in the themes section, and toggleable from
// the surface (NAS Vivid/Calm precedent; vivid is the default).
type stubOwner struct {
cfg Config
refus error
}
func (o *stubOwner) Get() (Config, error) { return o.cfg, nil }
func (o *stubOwner) Apply(cs []Change) error {
for _, c := range cs {
if c.Setting == KeyThemesSensory {
v, ok := c.Value.(string)
if !ok || (v != "vivid" && v != "calm") {
return errStub("sensory must be vivid or calm")
}
o.cfg.Themes.Sensory = v
}
}
return o.refus
}
type errStub string
func (e errStub) Error() string { return string(e) }
func TestSensoryKeyIsKnown(t *testing.T) {
found := false
for _, k := range KnownSettings() {
if k == KeyThemesSensory {
found = true
}
}
if !found {
t.Fatal("themes.sensory must be inside the closed address space")
}
if err := (Open(&stubOwner{})).Edit(KeyThemesSensory, "calm"); err != nil {
t.Fatalf("staging sensory=calm must be accepted, got %v", err)
}
}
func TestSensoryToggleStagesAndFlips(t *testing.T) {
o := &stubOwner{cfg: Config{Themes: ThemesConfig{Sensory: "vivid"}}}
s := Open(o)
if err := s.ToggleSensory(); err != nil {
t.Fatalf("toggle from vivid: %v", err)
}
p := s.Pending()
if len(p) != 1 || p[0].Value != "calm" {
t.Fatalf("toggle must stage calm, got %+v", p)
}
if err := s.Submit(); err != nil {
t.Fatalf("submit: %v", err)
}
if o.cfg.Themes.Sensory != "calm" {
t.Fatalf("owner must hold calm after submit, got %q", o.cfg.Themes.Sensory)
}
// and back
if err := s.ToggleSensory(); err != nil {
t.Fatalf("toggle back: %v", err)
}
if p := s.Pending(); len(p) != 1 || p[0].Value != "vivid" {
t.Fatalf("toggle must flip back to vivid, got %+v", p)
}
}
func TestSensoryRendersInThemesSection(t *testing.T) {
o := &stubOwner{cfg: Config{Themes: ThemesConfig{Sensory: "vivid"}}}
out := strings.Join(Open(o).Render(), "\n")
if !strings.Contains(out, "sensory") || !strings.Contains(out, "vivid") {
t.Fatalf("the themes section must render the sensory setting:\n%s", out)
}
}
+1
View File
@@ -30,6 +30,7 @@ func sectionLines(cfg Config) []string {
row("theme", cfg.Themes.Theme),
row("animation", cfg.Themes.Animation),
row("reduced motion", onOff(cfg.Themes.ReducedMotion)),
row("sensory", orNone(cfg.Themes.Sensory)),
)
return out
}
+35
View File
@@ -134,6 +134,41 @@ func (s *Surface) ToggleChannel(channel string) error {
return nil
}
// ToggleSensory flips the D54 sensory presentation setting
// (#2571 item 5): vivid <-> calm, staged as a draft like any other
// edit (the owner validates at Submit; vivid is the default).
func (s *Surface) ToggleSensory() error {
cfg, err := s.owner.Get()
if err != nil {
return fmt.Errorf("settings: cannot toggle sensory — owner unavailable: %s", err)
}
next := "calm"
if cfg.Themes.Sensory != "vivid" {
// live value anything but vivid (incl. empty/unset): the
// toggle flips TO vivid — vivid is the default.
next = "vivid"
}
current := cfg.Themes.Sensory
for _, d := range s.drafts {
if d.Setting == KeyThemesSensory {
if v, ok := d.Value.(string); ok {
current = v
}
}
}
if current == "vivid" {
next = "calm"
} else if current == "calm" {
next = "vivid"
}
if next == cfg.Themes.Sensory {
s.unstage(KeyThemesSensory)
return nil
}
s.stage(Change{Setting: KeyThemesSensory, Value: next})
return nil
}
// Submit hands every pending edit to the owner's Apply. On refusal the
// error is kept and rendered visibly, the drafts stay staged, and
// nothing changes. On success drafts clear and the next render shows
+2
View File
@@ -287,6 +287,7 @@ func TestRoundTripFixtures(t *testing.T) {
" theme slate",
" animation full",
" reduced motion off",
" sensory (none)",
}
if got := s.Render(); !reflect.DeepEqual(got, wantBefore) {
t.Fatalf("default render drift:\n got: %q\nwant: %q", got, wantBefore)
@@ -337,6 +338,7 @@ func TestRoundTripFixtures(t *testing.T) {
" theme dusk",
" animation off",
" reduced motion on",
" sensory (none)",
}
if got := s.Render(); !reflect.DeepEqual(got, wantAfter) {
t.Fatalf("post-submit render drift:\n got: %q\nwant: %q", got, wantAfter)
+1 -1
View File
@@ -56,4 +56,4 @@ j/k select - y consent/approve - n decline - e expand - esc back
│ │
│ │
│ │
│ │
─────────────────┘ └──────────────────────────────────────────────────────
+2 -2
View File
@@ -39,7 +39,7 @@ func TestAppendAnswerCodeRoutesToWorkStream(t *testing.T) {
if n != 1 {
t.Fatalf("first routed block appends, got %d", n)
}
rows, _ := f.treeRows(78, 40)
rows, _, _ := f.treeRows(78, 40)
joined := strings.Join(rows, "\n")
if !strings.Contains(joined, "code · go (2 lines)") {
t.Fatalf("the collapsed summary must name the language and size, got:\n%s", joined)
@@ -51,7 +51,7 @@ func TestAppendAnswerCodeRoutesToWorkStream(t *testing.T) {
if !f.ToggleWorkExpand() {
t.Fatal("expand must flip the code artifact")
}
rows, _ = f.treeRows(78, 40)
rows, _, _ = f.treeRows(78, 40)
joined = strings.Join(rows, "\n")
if !strings.Contains(joined, "fmt.Println(\"hi\")") {
t.Fatalf("expanded must show the code body, got:\n%s", joined)
-22
View File
@@ -12,8 +12,6 @@ package tasks
import (
"fmt"
"strings"
"rune.dev/rune/tui/semantic"
)
// Command verbs (mirroring the command surface's vocabulary).
@@ -89,25 +87,6 @@ func (f *Tasks) PendingCommand() string {
return f.pending.Verb + " " + f.pending.TaskID
}
// queueCommand enqueues one command for the selected task.
func (f *Tasks) queueCommand(verb string, delta int, assignee string) semantic.ActionID {
id := f.SelectedID()
if id == "" {
return actionNoop("tasks.command." + verb)
}
if f.pending != nil {
f.notice = "command in flight (" + f.pending.Verb + " " + f.pending.TaskID + ") — wait for its receipt"
return actionNoop("tasks.command." + verb + "." + id)
}
if isDerivedRow(id) {
f.notice = "not a task record — derived rows (questions, history lines, journal leaves) are never commanded"
return actionNoop("tasks.command." + verb + "." + id)
}
f.pending = &TaskCommand{Verb: verb, TaskID: id, Delta: delta, Assignee: assignee}
f.notice = verb + " " + id + " — sending…"
return semantic.ActionID("tasks.command." + verb + "." + id)
}
// ApplyReceipt feeds one runtime receipt back: an applied command
// moves the view-model through the same pure ops (the receipt is the
// authority); a refusal mutates nothing and surfaces its typed reason.
@@ -149,7 +128,6 @@ func (f *Tasks) applyConfirmed(r TaskReceipt) {
// propagation line comes from the RECEIPT (the authority).
if nt, _, err := f.tree.Reorder(id, sentDelta(sent), OriginOwner); err == nil {
f.tree = nt
f.sel = indexOf(nt.Order(), id)
}
p := Propagation{Moved: id, Above: r.Above, Running: r.Running, Effective: r.Effective, NewOrder: r.Order}
f.lastProp = &p
+17 -181
View File
@@ -1,193 +1,29 @@
// commands_test.go — the K20 command-path pins that SURVIVE the D50
// residual 3 removal (the row-selection walker is gone; the row-verb
// command suites went with it — recorded in the wave commit).
package tasks
// commands_test.go — the COMMAND protocol battery (wave K20): the
// controls enqueue real commands only on the live command path, the
// receipt applies through the pure ops, refusals mutate nothing, and
// the honest notices carry the refusal's own words.
import (
"strings"
"testing"
"rune.dev/rune/tui/semantic"
)
// liveFace builds a tree-open live face over the frozen three-task
// fixture: task_run running, task_dep + task_audit created.
func liveFace(t *testing.T) *Tasks {
t.Helper()
f := New(80, 24)
f.SetLiveTree(liveCommandTree(), "lib")
f.SetCommandPath(true)
f.SetTreeOpen(true)
return f
}
func liveCommandTree() *Tree {
in := LiveInput{
Tasks: []LiveTask{
{ID: "task_run", Title: "wire stream", Status: "running"},
{ID: "task_dep", Title: "approve migration", Status: "created"},
{ID: "task_audit", Title: "resize audit", Status: "created"},
},
}
return DeriveLive(in)
}
func (f *Tasks) selectRow(id string) {
for i, row := range f.tree.Order() {
if row == id {
f.sel = i
return
}
}
}
func TestCommandPathOffKeepsRenderOnly(t *testing.T) {
f := New(80, 24)
f.SetLiveTree(liveCommandTree(), "lib")
f.SetTreeOpen(true)
f.selectRow("task_dep")
if f.CommandPath() {
t.Fatal("no seam wired: the path must stay off")
}
a, _ := f.Apply(semantic.Named("K"))
if a != semantic.ActionID("tasks.reorder.up.task_dep") {
t.Fatalf("offline K reorders locally, got %v", a)
}
if _, sent := f.TakeCommand(); sent {
t.Fatal("no command may be sent without the path")
}
if !strings.Contains(f.footerLine(), "render-only") {
t.Fatalf("the footer must say render-only without the path, got %q", f.footerLine())
}
}
func TestReorderMidFlightSendsAndReceipts(t *testing.T) {
f := liveFace(t)
f.selectRow("task_audit")
a, _ := f.Apply(semantic.Named("K"))
if a != semantic.ActionID("tasks.command.reorder.task_audit") {
t.Fatalf("live K must send the command, got %v", a)
}
cmd, ok := f.TakeCommand()
if !ok || cmd.Verb != CmdVerbReorder || cmd.TaskID != "task_audit" || cmd.Delta != -1 {
t.Fatalf("queued command = %+v", cmd)
}
f.ApplyReceipt(TaskReceipt{
CommandID: "cmd_1", Verb: CmdVerbReorder, TaskID: "task_audit",
Applied: true, Effective: "at-running-boundary", Running: "task_run",
Above: "task_dep", Order: []string{"task_run", "task_audit", "task_dep"},
Detail: "owner moved task_audit above task_dep; takes effect at running boundary (task_run running)",
})
if f.tree.Order()[1] != "task_audit" || f.tree.Order()[2] != "task_dep" {
t.Fatalf("receipted reorder must move the view-model, got %v", f.tree.Order())
}
if f.lastProp == nil || f.lastProp.Effective != "at-running-boundary" || f.lastProp.Running != "task_run" {
t.Fatalf("the receipt's propagation is the authority, got %+v", f.lastProp)
}
if !strings.Contains(f.notice, "reorder receipted") || !strings.Contains(f.notice, "cmd_1") {
t.Fatalf("the applied receipt must carry its receipt id in the notice, got %q", f.notice)
}
}
func TestAssignToAgentReceiptApplies(t *testing.T) {
f := liveFace(t)
f.selectRow("task_dep")
a, _ := f.Apply(semantic.Named("a"))
if a != semantic.ActionID("tasks.command.assign.task_dep") {
t.Fatalf("live a must send assign, got %v", a)
}
cmd, ok := f.TakeCommand()
if !ok || cmd.Verb != CmdVerbAssign || cmd.Assignee != AgentAssignee(SeedAgent) {
t.Fatalf("queued assign = %+v", cmd)
}
f.ApplyReceipt(TaskReceipt{CommandID: "cmd_2", Verb: CmdVerbAssign, TaskID: "task_dep",
Applied: true, Detail: "task_dep assigned to agent:alpha"})
if n := f.tree.Node("task_dep"); n.Assignee != AgentAssignee(SeedAgent) {
t.Fatalf("receipted assign must set the assignee, got %q", n.Assignee)
}
}
func TestBlockedStartRefusalMutatesNothing(t *testing.T) {
f := liveFace(t)
f.selectRow("task_dep")
if _, ok := f.Apply(semantic.Named("s")); ok {
t.Fatal("quit must never fire on s")
}
cmd, ok := f.TakeCommand()
if !ok || cmd.Verb != CmdVerbStart || cmd.TaskID != "task_dep" {
t.Fatalf("queued start = %+v", cmd)
}
before := f.tree.Clone()
f.ApplyReceipt(TaskReceipt{CommandID: "cmd_3", Verb: CmdVerbStart, TaskID: "task_dep",
Refused: true, Code: "blocked",
Message: "task_dep is blocked by unfinished dependencies",
Blockers: []string{"task_audit"}})
if n := f.tree.Node("task_dep"); n.Status != before.Node("task_dep").Status {
t.Fatalf("a refusal must not mutate the tree, got %q", n.Status)
}
if !strings.Contains(f.notice, "refused (blocked)") || !strings.Contains(f.notice, "task_audit") {
t.Fatalf("blocked refusal must name the blocker, got %q", f.notice)
}
}
func TestStaleIDRefusalEchoesAndResyncs(t *testing.T) {
f := liveFace(t)
f.selectRow("task_dep")
if _, ok := f.Apply(semantic.Named("d")); ok {
t.Fatal("quit must never fire on d")
}
if _, ok := f.TakeCommand(); !ok {
t.Fatal("d must queue complete")
}
f.ApplyReceipt(TaskReceipt{CommandID: "cmd_4", Verb: CmdVerbComplete, TaskID: "task_dep",
Refused: true, Code: "stale_task_id",
Message: "unknown task_id \"task_dep\" — the state moved on; current state echoed",
Current: []LiveTask{{ID: "task_run", Title: "wire stream", Status: "running"}}})
if !strings.Contains(f.notice, "refused (stale id)") || !strings.Contains(f.notice, "current state echoed") {
t.Fatalf("stale refusal must surface the echo, got %q", f.notice)
}
}
func TestOneCommandInFlightLaw(t *testing.T) {
f := liveFace(t)
f.selectRow("task_dep")
f.Apply(semantic.Named("s"))
a, _ := f.Apply(semantic.Named("a"))
if !strings.HasSuffix(string(a), ".noop") {
t.Fatalf("a second control while a command is in flight must be a noop, got %v", a)
}
if !strings.Contains(f.notice, "command in flight") {
t.Fatalf("the in-flight law must be surfaced, got %q", f.notice)
}
cmd, ok := f.TakeCommand()
if !ok || cmd.Verb != CmdVerbStart {
t.Fatalf("the first queued command must reach the shell, got %+v ok=%v", cmd, ok)
}
if _, ok := f.TakeCommand(); ok {
t.Fatal("the rejected second control must not have queued anything")
}
}
func TestDerivedRowsAreNeverCommanded(t *testing.T) {
f := liveFace(t)
f.selectRow("ev:evt_1") // a journal leaf
f.tree.Nodes["ev:evt_1"] = &Node{ID: "ev:evt_1", evLeaf: true}
f.tree.Roots = append(f.tree.Roots, "ev:evt_1")
f.selectRow("ev:evt_1")
f.Apply(semantic.Named("s"))
if !strings.Contains(f.notice, "never commanded") {
t.Fatalf("derived rows must refuse commands, got %q", f.notice)
}
if _, ok := f.TakeCommand(); ok {
t.Fatal("no command may target a derived row")
}
}
func TestFooterNamesLiveCommands(t *testing.T) {
f := liveFace(t)
if !strings.Contains(f.footerLine(), "live commands") || !strings.Contains(f.footerLine(), "s start") {
if !strings.Contains(f.footerLine(), "live commands") || !strings.Contains(f.footerLine(), "ctrl+q steer") {
t.Fatalf("the command-path footer must name the live controls, got %q", f.footerLine())
}
}
func liveFace(t *testing.T) *Tasks {
t.Helper()
in := LiveInput{Tasks: []LiveTask{
{ID: "task_stream", Title: "wire stream", Status: "running"},
{ID: "task_dep", Title: "approve migration", Status: "created"},
{ID: "task_audit", Title: "resize audit", Status: "created"},
}}
f := New(80, 24)
f.SetLiveTree(DeriveLive(in), "lib")
f.cmdOn = true
return f
}
+83
View File
@@ -0,0 +1,83 @@
package tasks
// d52_seedleak_test.go — the D52 seed-substrate law (Gitea #2566 item
// 1): PRODUCTION faces boot EMPTY. The owner's recurring "fix the
// login timeout" phantom is the lab substrate leaking into the
// production face: the semantic seed's three lab tasks rendered as
// boot task rows, the promote seed's suggestion fixture ("fix the
// login timeout now - it blocks deploys") held behind the promotion
// region, and SeedTree's lab tree behind the tree region. The law:
//
// - tasks.New / NewLinear boot with NO task rows, NO suggestions,
// NO lab tree — an honest idle render ("no outstanding work").
// - SetLiveTree / Offer fill the faces when REAL data arrives.
// - the seeds stay confined to the lab: semantic.Seed, SeedTree,
// SeedSuggestions keep their canonical shapes (the frozen lab
// fixtures drive them explicitly).
import (
"strings"
"testing"
"rune.dev/rune/tui/promote"
"rune.dev/rune/tui/semantic"
)
// TestProductionFaceBootsEmpty — the red-first pin: a FRESH production
// face renders no lab task rows, no lab suggestion text, and names the
// honest idle state.
func TestProductionFaceBootsEmpty(t *testing.T) {
for _, f := range []*Tasks{New(80, 24), NewLinear(80, 24)} {
plain := semantic.StripANSI(f.Render().Content)
if strings.Contains(plain, "login timeout") {
t.Fatalf("production face must NOT boot the lab suggestion fixture:\n%s", plain)
}
if strings.Contains(plain, "wire stream") || strings.Contains(plain, "resize audit") ||
strings.Contains(plain, "approve migration") {
t.Fatalf("production face must NOT boot the lab task rows:\n%s", plain)
}
if !strings.Contains(plain, "no outstanding work") {
t.Fatalf("the empty face must render the honest idle state:\n%s", plain)
}
if f.Live() {
t.Fatal("the boot substrate is not live data")
}
if len(f.TreeSnapshot().Roots) != 0 {
t.Fatalf("the boot tree must be empty, roots = %v", f.TreeSnapshot().Roots)
}
}
}
// TestSeedsStayLabConfined — the seeds keep their canonical shapes and
// the production face accepts them only EXPLICITLY.
func TestSeedsStayLabConfined(t *testing.T) {
tr := SeedTree()
if len(tr.Roots) != 3 {
t.Fatalf("SeedTree stays the canonical lab seed, roots = %v", tr.Roots)
}
if len(promote.SeedSuggestions) != 3 {
t.Fatal("SeedSuggestions stays the canonical lab seed")
}
if len(semantic.Seed(80, 24).Tasks) != 3 {
t.Fatal("semantic.Seed stays the canonical lab seed")
}
// an explicit live install carries real data into the face
f := New(80, 24)
f.SetLiveTree(SeedTree(), "lab")
if !f.Live() || len(f.TreeSnapshot().Roots) != 3 {
t.Fatal("SetLiveTree must install the tree when the caller asks")
}
}
// TestPromoteBootsEmptyOffersFill — the promote face's production
// boot is suggestion-free; Offer fills it when real suggestions land.
func TestPromoteBootsEmptyOffersFill(t *testing.T) {
p := promote.NewEmpty(80, 24)
if got := p.Suggestions(); len(got) != 0 {
t.Fatalf("the production promote face must boot with no suggestions, got %d", len(got))
}
p.Offer(promote.SeedSuggestions)
if got := p.Suggestions(); len(got) != 3 {
t.Fatalf("Offer must fill the suggestion list, got %d", len(got))
}
}
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more