Adds the failure-ticket + recovery-routing mechanism: a deterministic
gate->capability table gates whether a stage has agency to fix its own
failure, opens a FailureTicketOpenedEvent when it doesn't, and routes to
a metadata role=recovery stage (per-stage budget, cap 2, not reset by
TransitionExecuted) instead of retrying in place. Extends salvage
decisions with a RECOVER option so the review-gate judge can also route
to recovery, unifies deterministic-gate and review-gate routing through
routeToRecovery(), and has ExecutionPlanCompiler synthesize a
write-capable recovery stage + edge for freestyle plans. Read-only tools
(file_read, list_dir) are now always available on any tool-granting
stage so a recovery stage can inspect the write-less stage's failure
without flooding context via shell ls -R.
Bundles three operator-reliability guardrails (Vikunja #28/#29/#30) plus the
in-flight branch WIP they were built on top of (reasoning_content capture,
operator/project profile editor, write-jail workspaceRoot fix) — the tree is
interdependent (SessionOrchestrator references reasoningArtifactId from the WIP)
and does not compile as separable subsets, so it lands as one commit.
Guardrails:
- #28 mid-stage steering: ClientMessage.SteerSession -> GlobalStreamHandler ->
orchestrator.submitSteering, reusing SteeringNoteAddedEvent + existing context
fold (advisory, non-authoritative; invariants #3/#7). Closes the gap where
steering typed off an approval gate was silently dropped.
- #29 shell-in-file guardrail: ShellInFileContentRule (core:toolintent) blocks a
file_write whose content is a bare shell command (e.g. "mkdir -p ..."); FileWriteTool
description now advertises auto-mkdir of parent dirs. Basename-allowlist so the
extensionless case is caught; scripts/Makefiles/multiline exempt.
- #30 pt1 capability-gap detector: deterministic CapabilityGapDetector maps stage
intent -> implied ToolCapability, compares to granted tools, emits advisory
CapabilityGapDetectedEvent in FreestyleDriver.lockAndRun. Recorded, never fails
the gate and never auto-grants (invariants #3/#4/#5). Reflection rung is pt2.
Verified: ./gradlew check green (whole tree).
- editorLines now soft-wraps long logical lines at the box width (caret tracked
across wrapped rows) instead of overflowing/truncating
- alt+backspace deletes the word before the cursor (deleteWordBack)
- handle tea.PasteMsg so bracketed paste (Ctrl+V / Ctrl+Shift+V / right-click)
drops clipboard text into the focused composer; was silently dropped
- render the composer (in-session + launcher) borderless: top rule + flush text,
no side borders or trailing padding, so a terminal drag-copy yields just the
typed text instead of box chrome and whitespace
Add a cross-project task board to the TUI (T / palette): a roster fetched
from GET /tasks with vim-style nav, refresh, and an enter-to-open detail
pane (goal, criteria, paths, links, notes) built from the list payload — no
extra fetch. Server: TaskService.listAll() and a project-optional GET /tasks
(scoped with ?project=, whole board without; recent-first).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two v2-unlocked polish items now that the TUI is on bubbletea v2:
Native cursor — the composer caret was a frame-animated "▏" that forced the
redraw loop to keep ticking in insert mode. v2's View.Cursor lets the terminal
draw and blink a real cursor, so:
- editorLines/launcherInput drop a width-1 marker rune (U+E123, same cell width
as "▏") at the caret; View() locates it in the fully-composited frame via
splitCursor → (X,Y), swaps it for a space, and sets a blinking CursorBar there.
- render() (preview/golden tests) resolves the marker to the static "▏" glyph, so
screenshots are unchanged and the marker never leaks into output.
- nil cursor in normal mode hides it (vim-correct); gated on overlay==None so an
open palette/files modal (which keeps editMode==Insert) doesn't draw a cursor
behind it.
- animating() no longer forces a redraw in insert mode — the terminal blinks the
cursor itself, so an idle insert screen holds still (native text selection
survives) and burns no frames.
Window title — View.WindowTitle names the terminal tab after the active session,
falling back to the model name then "correx".
Tests: new cursor_test.go (coord extraction, mode/overlay gating, no marker leak);
updated the animating-gate test for the new insert-mode contract. Build, vet,
gofmt, full suite green; compose preview shows the static caret with no marker leak.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the Go TUI from github.com/charmbracelet/{bubbletea,lipgloss} to the v2
ecosystem at charm.land/{bubbletea,lipgloss}/v2 (Go 1.25). v2's kitty keyboard
disambiguation is on by default, so Shift+Enter now reliably inserts a newline
in the composer (Ctrl+J / Alt+Enter kept as fallbacks for non-kitty terminals).
Approach: rather than rewrite ~190 key-match sites to v2's (Code, Mod) idiom, a
small shim (internal/app/key.go) converts a v2 KeyPressMsg into the v1-shaped
keyMsg the handlers already expect, at the single Update boundary. The rest is
mechanical:
- key constants tea.Key* → shim consts; tea.KeyMsg → keyMsg.
- lipgloss.Color is now a func returning color.Color, not a type → fields/params
retyped to image/color.Color (theme/diff/overlays/view).
- v2 View() returns tea.View: render() builds the string, View() wraps it and
carries AltScreen (alt-screen is a per-frame View field now, not a program opt).
- WithWhitespaceBackground/Foreground → WithWhitespaceStyle(Style).
- preview: drop lipgloss.SetColorProfile (v2 renders truecolor by default).
Build, vet, gofmt, and the full test suite are green; preview renders truecolor
across kinds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Press d (or the "panel" palette command) in-session to cycle the right panel:
- events — the live event stream (default, unchanged)
- changes — a token-usage line (tokens + turns, summed from the transcript's
TurnMetrics) over a git-status-style list of files the session has
written, each with summed +/− from its diff. ^x still opens the full
diff.
- off — hide the panel; the output transcript takes the full width.
changesRows derives everything from data already in the model (router-turn
metrics + the tool entries' unified diffs), so no new protocol. Footer + ? help
updated; "changes" preview kind added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Enter still submits; Ctrl+J (always) and Alt+Enter (most terminals) insert a
newline at the cursor. True Shift+Enter isn't distinguishable from Enter in
bubbletea v1.3.10 (terminals send the same byte, no kitty-protocol parsing), so
these are the working stand-ins — noted in the input hint.
- editorLines() renders the buffer as styled rows with the caret at the cursor,
splitting on \n and capping at maxInputLines (last rows kept). Both the idle
launcher input and the in-session bottom bar use it and grow to fit; View()
sizes the bottom bar via inputBarHeight() instead of the fixed inputH.
- Footer gains a "^J newline" hint in insert mode (non-filter); the launcher
sub-line shows "⌥↵/^J newline". New "compose" preview kind + editor_lines_test
(split + height cap).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the idle two-panel layout (session list + welcome, where the welcome
panel duplicated the list) with an opencode-style launcher: a compact, centered
input whose lower-right shows the launch target and model (chat by default), a
hideable right rail of status + quick keys, and no session list — it's a keypress
away via R.
- Tab (or w) cycles the launch target: chat → workflows → chat (launcherWf),
shown at the input. Enter on chat starts a chat; on a workflow, StartSession
with the typed text as the brief.
- Right rail (connection, session/active counts, i/Tab/R/?/p keys) hides via the
new "rail" palette command. Drops automatically on narrow terminals.
- View() suppresses the bottom input bar on idle (the input is the launcher);
footer + ? help updated to the launcher keys (help-coverage test extended).
The old idle-panel renderers (sessionRows/welcomeRows/workflowRows) are now
unused but kept in the tree pending Phase 2 + a decision on where the session
dates should live now that the idle list is gone. Multi-line input (Ctrl+J /
Alt+Enter newline) is Phase 2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ? help and S stats modals rendered at fixed height; compositeOverlay
drops any rows past the screen, so on a 24-30 row terminal their bottom
sections — including the close hint — vanished with no way to reach them
(and the backdrop border corrupted). Recent help additions worsened it.
- Add a shared scrollable-modal renderer: helpBody()/statsBody() produce
discrete lines, renderScrollModal() windows them by m.modalScroll (clamped
to the viewport via modalBodyRows/scrollableModalMax) and pins the hint, so
the box never exceeds the screen. A "(off-end/total)" indicator shows in the
title when scrolled. Keys: ↑↓/jk line, PgUp/PgDn page, ^u/^d half, g/G ends;
any other key still dismisses help. modal_scroll_test.go (2): clamp + no-op.
- Page the artifacts viewer (v) too: PgUp/PgDn were one line at a time — now a
full body, with ^u/^d half and g/G ends via scrollArtifact().
Verified via cmd/preview: help/stats close hints now survive at h=20-28 (were
clipped below ~32/40) and show the scroll indicator; full at tall heights.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ^x fullscreen viewer (OverlayDiff) backs every tool preview — split diff
for write/edit, the command card for shell, plain lines otherwise, plus any
transcript tool-output — but only scrolled one line per keypress, making a
long diff tedious to read.
- Add paging to the viewer, matching the OUTPUT free-scroll contract: ↑↓/jk
by line, PgUp/PgDn by a full body, ^u/^d by half, g/G to the ends. New
scrollDiff() clamps to [0, diffMaxScroll] so paging past either edge lands
exactly (no dead presses). diff_scroll_test.go (2): clamp + no-op-when-fits.
- Update the modal hint to advertise paging (line / page / ends / close) and
add a "diff / preview viewer (^x)" section to the ? help overlay. Also note
the T3+ confirm-again behaviour in the approval-band help.
Approval ergonomics re-audit (BACKLOG §E): the spec'd band still holds —
single-key y/n/e for T0-T2, mandatory second-key confirm for T3+ (HighTier),
navigable queue with an i/n indicator, docked band never modal-stacked; all
covered green by approval_test.go. The scroll granularity above was the only
gap, now closed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three TUI-polish items.
- OUTPUT free-scroll: PgUp/PgDn (and ^u/^d half-page) scroll the transcript
back through history; esc snaps to tail-follow. outputScroll is clamped
against outputViewport(), which mirrors View/renderMain geometry, so the
edges land exactly (no dead presses unwinding an overshoot). routerRows
split into buildTranscriptRows (full render) + windowing so the handler
measures the same content. Tests cover the clamp + the fits-in-panel no-op.
- Help overlay: `?` (or the "help" palette command) opens a keybinding
cheat-sheet grouped by context (session / overlays / approval band) — the
non-palette keys especially. Any key dismisses it.
- Event-inspector filter: `/` filters the event list by a case-insensitive
substring of type/detail (currentEvents applies it); `c` clears, esc stops
editing then closes. Fresh per open. EVENTS side panel stays unfiltered.
go build / vet / test green; rendered help + filtered inspector via
cmd/preview (help, events-filter kinds).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surface side-effecting ("external feedback") events in the conversation
flow, opencode-style, instead of only in the EVENTS side log.
- New RouterEntry role "action" (glyph + text) appended at arrival so it
interleaves with chat turns by order. Rendered dim with an accent gutter
glyph; the EVENTS panel still carries the full stream.
- Surfaced: tool start (⏵), tool done (✓ / ✎ wrote <path> (+a −b) for a
diff), tool failed (✗), rejected (✕ blocked), approval resolved
(⌘ approved / ✕ rejected, noting "· via grant" on a grant auto-approve),
and grant create/revoke (⊞ / ⊟ · scope). ApprovalResolved names no tool
on the wire, so the tool is recovered from the pending queue before the
gate is dropped.
- Toggle: "inline actions" palette command flips actionsHidden (rows are
always recorded, gated at render, so toggling reveals history); persisted
in tui-prefs.json. prefs.go refactored to load/mutate/save so the new
field and statusbarHidden no longer clobber each other.
- demo.go: "actions" preview kind.
go build / vet / test green; rendered the flow via cmd/preview.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surface the new cross-session grant scopes in the TUI.
- Approve-always (A) now opens a scope picker instead of immediately
creating a SESSION grant: choose this session / this project / everywhere.
SESSION stays the default (A then enter/s = old behaviour); p and g
create PROJECT / GLOBAL grants. The grant + approval are sent on confirm
(esc cancels, leaving the call pending).
- New "grants" palette command + G shortcut open a standing-grants viewer
(OverlayGrants) listing the active PROJECT/GLOBAL grants — scope, tool,
permitted tiers, project path — with x/enter to revoke. The server's
RevokeGrant reply (a fresh grant.list) refreshes it in place.
- protocol.go: TypeGrantList + GrantDto; RevokeGrant/ListGrants encoders;
CreateGrant now documents the wider scopes. server.go decodes grant.list
into m.grants and treats it as a non-session global reply.
- render-matrix coverage: grant.list classified as a non-rendering query
reply (populates the overlay, not the transcript).
- demo.go: grants + grant-scope preview kinds.
go build / vet / test green; rendered both overlays via cmd/preview.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Palette: each command now shows its bare-key shortcut in a key column (and the filter
matches on it), so the palette teaches the shortcuts instead of hiding them.
Status bar: a new "status bar" palette command opens a toggle overlay to show/hide the
non-essential segments (current stage, session status, workspace path, model, last-event
clock, resource gauge); correx/connection/session-name/spinner stay. Choices persist
TUI-local in tui-prefs.json (JSON in the config dir — display state, kept out of the
shared/replayed server config) and reload on launch.
Also trims the in-session footer (drops stats/model — both reachable via `p cmds`) so it
no longer overflows + truncates `q quit` at ~100 cols after the transcript-nav additions.
Tests cover toggle→render gating and prefs persistence; verified via cmd/preview renders.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Typing `@` at a word boundary in the chat input (mid-word `@` stays literal, e.g. emails)
opens a workspace file picker: it requests file.list for the session (cached per session),
filters as you type, and enter inserts `@path ` at the cursor — back to typing. Renders as
a transparent overlay, windowed around the selection. file.list is classified non-rendering
in the render-matrix guard. Tests cover token-boundary detection, ref insertion, and filtering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ctrl+↑/↓ jumps between your sent (user) messages in the transcript: ctrl+↑ from the
bottom selects the most recent, steps to older ones (clamping); ctrl+↓ steps back and
drops to tail-follow past the last. The selected message is highlighted and scrolled
into view (routerRows tracks per-message row offsets and windows to the selection).
`y` yanks the selected message — or the latest turn when none is selected — to the
system clipboard over OSC52 (go-osc52, tmux-wrapped under $TMUX), which is SSH/Termius-
safe unlike a mouse drag. A brief "✓ copied" footer note animates out (and self-stops
the tick). esc drops the selection; switching sessions clears it. Tests cover the
user-message jump/clamp/tail and the copy-text selection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Typing `/` as the first char of a chat line opens the command palette inline
(opencode-style), instead of typing a literal slash; mid-line `/` stays literal.
Factors openPalette() (shared with the `p` key) and advertises `/ cmds` in the
insert-mode footer when the line is empty.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Input history is now a single global ring (recordInput) shared by every input surface —
free-chat, the workflow-intent line, and in-session chat — so ↑/↓ recalls anything you've
sent, including outside a session (previously per-session, so idle/intent had none).
Copy fix: the 120ms animation tick now only runs while something actually animates
(animating(): active session spinner, blinking caret, reconnect). Init no longer starts it
unconditionally; tickKick (re)starts it on demand and tickMsg stops it when idle. An idle
screen no longer auto-redraws, so a native terminal text selection survives instead of being
wiped every frame. Tests cover the ring (dedup/cap/cross-context walk) and the idle gate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- pending approvals are a navigable queue (PendingQueue/PendingIdx; ↑↓/jk, count
shown), no modal stacking; resolve removes the specific gate by request id
- y/n/e keys (approve/reject/steer); T0–T2 act on one key, T3+ requires arm+confirm
("press y again"), any other key disarms; reject/steer/auto never gated
- snapshot restore now rehydrates the full pending list, not just the first
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
R opens a navigable overlay of recent sessions (GET /sessions: short id, status,
workflow/stage, relative age); enter resumes via POST /sessions/{id}/resume, which
replays onto the existing global /stream (no WS re-dial). Fetch/resume errors surface
in the overlay, never panic. stdlib-only (ws.Client.HTTPBase derives the base URL).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surface the health subsystem (llama/event-store/disk probes) in the TUI,
mirroring the session-stats pane: ClientMessage.GetHealthChecks ->
ServerMessage.HealthChecks (health.checks, reuses HealthReport) ->
StreamQueries.healthChecks via HealthInspectionService; Go OverlayHealth on
key H + palette 'health checks', pull-based fetch, per-subject status with
degraded loud. Empty/disabled -> visible fallback (no blank/lie). Go+Kotlin
golden tests pin the wire contract field-for-field. Observability spec §4/§3.
OverlayIdeas (opened with I, global/cross-session) lists the idea board in the
artifact-viewer shell: ↑↓ to move, x/d to remove (sends DiscardIdea, optimistic
drop), esc to close. Pull-based via ListIdeas -> idea.list.
Render a workflow.proposed frame as a single-select picker with a manual-answer
slot: ↑↓/jk to choose, enter to launch, e for custom, esc to peek away. Picking
a candidate sends StartSession (and focuses the launched session); the custom
slot continues the conversation via ChatInput. Reuses the modal helpers from the
clarification view.
Render stage clarification questions as an AskUserQuestion-style form:
header chip, prompt, selectable option chips, and a free-text custom slot.
Arrow/hjkl nav, space to select, e for custom, enter to submit. Submit
guards on all-answered and sends ClarificationResponse; the parked stage
re-runs with the answers in context.
Artifact content wider than the modal was silently cut off. Lines are now
hard-wrapped at rune boundaries to the modal content width, and scroll
clamping uses the wrapped line count so the last page stays reachable.
Two operator features over the existing WebSocket protocol, plus a tui-go nav fix.
Config editor (g / palette "config"):
- core:config gains a thin registry stack: OrchestrationKnobs ([orchestration]
block), CorrexConfigWriter (round-trip TOML serializer; parseToml(write(c))==c),
ConfigHolder (AtomicReference live config), and EditableConfig (allowlist of
editable fields + patch applier; security fields are absent so they can never
be patched).
- ConfigService validates -> persists (atomic temp+move) -> swaps the holder ->
rebuilds config-derived services. Live-apply is scoped to safe seams: stage
timeout, compaction threshold (now a supplier), router knobs (routerFacade
rebuild), and personalization/project toggles all take effect on the next
session/turn; in-flight sessions keep the config they started with.
server.host/port are persisted + badged "restart required", not hot-swapped.
- Protocol: GetConfig / UpdateConfig / ConfigSnapshot; TUI overlay with
type-aware editing (bool toggle, enum cycle, inline int/string edit) and save.
Artifact viewer (v / palette "artifacts"):
- Server assembles a session's artifacts from the event log and resolves bytes
from the CAS store (StreamQueries.listArtifacts) — a replay-neutral snapshot
read. TUI overlay lists artifacts and shows scrollable content.
tui-go fix: workflow failure no longer clears selectedID (which yanked the user
to the list); listNav lands on the first session when nothing is selected
instead of wrapping to a random one.
Config is not event-sourced (it lives in TOML); editing stays outside the log.
Replace the floating approval modal (which blanked the whole UI) with an
opencode-style band that takes the input bar's slot while a gate is
pending: tool/tier/risk header above a side-by-side old|new diff, with
the session output and event stream still visible above it. The band
height adapts to the diff length.
Add a unified-diff parser that aligns removals/additions into split rows
(blank left cell for a create, blank right for a delete) and a
two-column renderer shared by the band preview and the ^x fullscreen
view. Wire plain a/r to approve/reject to match the advertised keys.
The diff overlay incremented diffScrollOffset on every down-key with no
upper bound; the render clamped only the displayed offset, so the stored
value grew unbounded and the up-key appeared dead for N presses. Clamp
the increment against a new diffMaxScroll() (len(lines) - body height),
shared with the render path via diffBodyHeight().
Rebuild the Go TUI transcript from the event stream instead of
optimistic local echoes. ChatTurnEvent now maps to ServerMessage.ChatTurn
(chat.turn); the TUI auto-vivifies sessions and renders user+router turns
from events. SessionStarted is de-special-cased into SessionAnnounced
(still carries workflowId), dropping the router.response shadow and
optimistic echoes.
Delete the unused Kotlin TUI (apps/tui) and add a kotlinx<->Go
golden-fixture test to lock the wire protocol. Gitignore server runtime
logs.
- gate session-list nav on idle so in-session arrows/jk don't move the list (5a)
- approval dismiss on esc + in-session 'a' reopens a pending approval (5b)
- auto-focus a newly started workflow session (was running invisibly)
- approval actions on Ctrl chords (^a/^r/^x) matching the modal; bare/Alt letters no longer approve; diff closes on ^x/esc
- steer hint in the approval modal; session UUID in the input bar
- env-gated debug logging (CORREX_TUI_LOG) with k.Alt in the key trace
Go TUI decodes model.changed / model.list / resource.status and renders a
status-bar VRAM/GPU%/RAM gauge plus a models overlay (m key / palette 'models'):
lists configured models with the resident one marked, enter swaps (SwapModel),
c clears the pin (ClearModelPin). All three new server messages are
non-event-bearing control/gauge frames, applied immediately like
provider.status_changed.
Server addition required for the picker: ServerMessage.ModelList (model.list,
NonEventMessage) sent once in the initial snapshot from
ManagedInferenceRouter.availableModelIds()/currentModelId() — the TUI otherwise
cannot enumerate swap targets.
Completes the 5-slice model-lifecycle feature. ./gradlew check green; go build/vet clean.
Plan: docs/plans/2026-05-31-model-lifecycle-management.md (slice 5 of 5).