feat(tui): replace Textual with a Go/Bubble Tea interface - #941
Conversation
Greptile SummaryReplaces the Textual interface with a Go/Bubble Tea sidecar architecture.
Confidence Score: 5/5The PR appears safe to merge because no eligible unresolved failure remains in this follow-up review. No blocking failure remains. Important Files Changed
Reviews (3): Last reviewed commit: "build: drop the ruff exemption for a mod..." | Re-trigger Greptile |
Resolves conflicts with the lazy-import startup (usestrix#920) and mounted local targets (usestrix#958): drops the redundant --mount plumbing (local directories are now always mounted), keeps main.py model imports lazy, and routes the TUI /mount command through mountable-dir validation.
The Go/Bubble Tea TUI is now the only interactive interface. Deletes the Textual app, its renderers and stylesheet, drops the textual dependency, and removes the PR-added ci.yml workflow. The shared Textual-free TuiLiveView projection stays for the Go backend and viewer.
Keep only the Go sidecar wheel build, sidecar presence verification, and wheel artifact upload in the release workflow.
First-principles layout: the Go/Bubble Tea source (cmd/, internal/, go.mod) moves from top-level tui-go/ into strix/interface/tui/, the sidecar launcher becomes strix.interface.tui.runtime (was go_tui.py), and tui_backend becomes strix.interface.tui.backend. Go rendering is split out of the 1.9k-line render.go into an internal/render package with one file per tool renderer (terminal, file edit, report, dependency, notes, todo, agents graph, proxy, simple tools, registry), mirroring the old Python renderers layout. Wheel builds exclude the Go source; the compiled sidecar still ships as strix/bin/strix-tui.
ad52570 to
03c6ab5
Compare
respond_to_user, wait_for_agents, list_reports, and get_report had dedicated renderers in the old Textual UI but fell through to the generic dump in Go (the agent-graph switch matched the nonexistent wait_for_message tool). Also drops the unused width parameter from render.Chat/render.Tool and adds table-driven coverage for every dispatch case.
Target resolution, run preparation, model preflight, and start telemetry move from strix/interface/main.py into strix/interface/scan_setup.py, so the TUI runtime no longer imports private helpers from the CLI entry point (and the main -> interactive -> runtime -> main import cycle is gone). clone_repository now raises ValueError instead of printing a panel and calling sys.exit, so SystemExit is no longer used as control flow in the setup path; main() renders the error panel itself.
…unts state setup.select_provider, setup.save_api_key, and setup.add_custom_provider now all return _provider_record() (the same shape as providers.list), and the Go model folds them in through one applyProviderRecord helper. The controller's parallel mounts list is gone: /mount just adds the directory as an ordinary local target, matching the CLI's semantics, so snapshots no longer carry mounts/mount_count.
parse_arguments now establishes the full startup-state schema (needs_setup, setup_invalid_provider/guidance, targets_info, local_sources, diff_scope, run_name) in one place, so the TUI controller and runtime read attributes directly instead of probing with getattr defaults. main()'s interleaved 'if not setup_mode' blocks are folded into _detect_provider_setup_need() and _bootstrap_scan(), leaving a single linear flow.
Parses the Go protocol declaration shipped in the tree and asserts the version and capability list match the Python backend's constants, so the duplicated wire declarations cannot drift silently.
model.go (3.6k lines) is now model.go (types/Model/Update loop entry), update.go (key/mouse/modal input), wire.go (protocol envelope and collection handling), picker.go, setup.go (setup wizard commands and views), view.go (main/chat/sidebar/stats views), agents.go, and vulnerabilities.go. Pure file moves; no logic changes.
- tui/backend/projection.py: wire-safe state/collection projections, provider record serializer, and size limits, out of controller.py - tui/sidecar.py: sidecar process launch, credential-scrubbed child environment, and socket authentication, out of runtime.py - interface/cli_args.py: argument parsing and resume-state loading, out of main.py - interface/environment.py: environment validation and Docker image management, out of main.py - config/provider_detection.py: pure credential/environment detection predicates, out of config/providers.py Pure moves plus import updates; no behavior changes.
Sidecar resolution is now deterministic: a source checkout with Go runs the TUI via 'go run'; otherwise the packaged wheel binary is used.
…v-binary lookup The hidden --tui-protocol-smoke flag, Go --handshake-smoke mode, and ProtocolSmoke client path existed only for the release smoke checks that were removed from the release workflow. STRIX_TUI_LOG debug redirection and the build/sidecar dev-binary lookup in binary_command were optional escape hatches; sidecar resolution is now: go run in a source checkout, else the packaged wheel binary.
Wheels are platform wheels with the compiled sidecar; the universal sidecar-free wheel path and its STRIX_REQUIRE_TUI_SIDECAR gate are gone, so a missing Go toolchain fails the build instead of shipping an install without an interactive interface. Editable installs still need no Go. The hook now lives in build_hooks/tui_sidecar.py, and the redundant tui-run make target is removed.
Interactive launches now open the Bubble Tea UI first and run the model preflight, run preparation and telemetry behind a visible 'Preparing scan' state, so the agent loop can never start before the interface is up. A rejected saved key drops the live session into setup instead of exiting. Also moves the wheel build hook next to the other build scripts.
Dragging in the chat trace highlights text (reverse video) and copies the plain-text selection to the clipboard on release, tmux-style, since terminal mouse capture prevents native selection. Selection coordinates are anchored to content lines so scrolling keeps the highlight in place.
The prompt from the start screen, --instruction and --instruction-file all reach the agent folded into its task, which the transcript does not show, so the user's own words never appeared anywhere. They now open it as their first message. The text is captured before prepare_run prefixes the diff-scope preamble onto args.instruction, so the message is what was typed rather than the assembled task, and it is persisted in the run record. Reading it back in hydrate_from_run_dir covers a live scan, a resumed one, and the viewer from one place: it is stamped with the run's start so it sorts ahead of replayed history, and held until the root agent exists, since a fresh scan has none yet. Repeated agent syncs do not repeat it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Go TUI does not use the base projection directly: the backend subclass overrides upsert_agent to report whether anything changed, and reimplements it rather than calling back. Hooking the opening message there meant it never ran, so nothing appeared. It is posted from flush_user_instruction instead, called where the agent graph is refreshed - the resume and viewer path after hydration adds the agents, and the live path from the runtime's periodic sync, which folds the result into its changed flag so the event is pushed. The test now runs against the subclass the TUI actually uses, which is what would have caught this. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Quitting ran two beacons with a 10 second requests timeout each, and that timeout covers connect and read separately, so an endpoint blackholed by a firewall stalled the exit in connect for far longer than anyone waits. Both now share a short (connect, read) cap: against a blackholed address each call gives up in 2s instead of 10. Because they reach the network on the way out, a second Ctrl-C landed inside requests and escaped the finally block as a traceback. They are now best-effort: an interrupt there abandons them and exits cleanly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
It belongs in its own change rather than riding along with the interface work. Out go the /provider and /model commands, the provider, model, API-key and custom-provider pickers, and the seven commands behind them (providers.list, models.list, setup.select_provider, setup.save_api_key, setup.disconnect_provider, setup.add_custom_provider, setup.select_model) along with their protocol types, the paged-models capability, and the model listing paging. strix/config keeps everything it gained: the scan bootstrap still runs its model preflight and still reports provider auth failures, and the launch screen still shows the model it will use. Configuring one is back to STRIX_LLM or the config file, which is what the no-model hint now says. The scan-mode picker stays, since it configures the scan rather than the LLM. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
These are the fresh-run controls: /mode, /budget, /turns, /scope, /mount, /target-list and /prompt-file, with their backend commands and the setup-run-controls capability. Every one has a CLI flag, so the run is configured on the command line and the start screen is left to do one thing: take a prompt or a target and launch. /mode was the last thing that opened a picker, so the picker goes with it - its modes, search input, view, mouse handling and the model fields behind them. optionWindow moves to setup.go, which still needs it for the command menu. What remains under a slash is interaction rather than configuration: /target, /prompt, /clear, /start, /help, /quit. Docs that described the removed commands, the model and provider pickers, the custom-provider flow and the paged model listing are updated to point at the flags and STRIX_LLM instead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The interactive provider and model setup is gone, so the machinery behind it goes too. strix/config/providers.py and strix/config/provider_detection.py are deleted, and config/__init__.py, config/loader.py and config/models.py return to what they were: load_settings + apply_config_override + persist_current, a credential-mirroring StrixProvider, and LLM_API_KEY as the documented key. core/runner.py, llm/compaction.py and report/dedupe.py go back to passing settings.llm.extra_headers instead of per-route headers. Two call sites needed rewiring rather than reverting. preflight_model_connection keeps verifying the configured route before a named-target scan, but through StrixProvider() with no credential overrides, and an ordinary exception now propagates instead of being classified as a credential rejection. The startup gate that diverted a launch into setup whenever a provider looked unconfigured is removed - with nothing in setup able to fix that, it only produced a screen the user could not leave. validate_environment reports missing LLM_API_KEY and LLM_API_BASE again. The provider snapshot field, the setup_invalid_provider/setup_guidance argument pair and the messages they produced are gone, as are the providers.list and models.list result names the Go client still special-cased. Kept, because neither belongs to the provider work: the codex.py fix that closes its HTTP response, and repr=False on the credential settings fields so keys stay out of reprs and tracebacks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The two contributing guides still told people to export OPENAI_API_KEY. That came from the provider work, which is gone, so they go back to LLM_API_KEY like every other page. Also drop two start-screen asides that were left behind in the provider and CLI pages; the flags and STRIX_LLM already say what those pages need to say. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The page documents flags. What the start screen does and how sidecar activation fails are not flags, and nothing on the page refers to them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Installing from a wheel is what the page covers, and that needs no Go. The contributing guide already tells source checkouts what they need. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Nothing links to it, and the protocol it describes is defined in the code the two sides share. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The agent tree drew a per-row gutter accent for the selected node, dimmed labels by depth, and used ⏷/⏵ for the toggles. The stylesheet it was ported from asks for exactly that, but those selectors - .tree--node, .tree--node-label, .tree--node.-selected - are not Textual Tree component classes, so they never matched anything. The tree on screen is Textual's own: one label color, dim guides, a filled block cursor behind the label, and ▼/▶ toggles that a leaf does not get at all. ⏷ and ⏵ are also missing from most terminal fonts and render as tofu. Verified against Textual 6.2.1 by rendering both. Scrollbars took two columns - a gap plus a │ track - and a #737373 thumb, so a scrolling panel gained a bright rule down its edge. They now take one column with a blank track and the thumb color each panel asks for: #1a1a1a in the trace, #404040 in the tree, #333333 in the findings list. The confirm dialogs came out two columns too wide, because Width() sets the content box and the border is added outside it. Their buttons were also centered as a pair and only padded while focused, so moving the choice shifted the row. Both buttons now hold fixed columns and only repaint a background, which is what a Textual Button grid does. Same two fixes for the mount prompt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pressing enter with nothing that can launch - no model configured, no target - logged the attempt and its error again every time, so four tries filled all six rows the launch column keeps with two alternating lines. Snapshot messages were already deduplicated on the way in; command errors were not, and they are what a misconfiguration produces. A line that is already in the log now moves to the end rather than being appended a second time. Repeating an attempt leaves the log as it was, a distinct message still lands after it, and the log still reads oldest first. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every one of them was already reachable without a slash. submitSetupPrompt reads free text the way a coding agent does: tokens that look like a target are added as targets, the rest becomes the instruction, and enter launches. So /target, /prompt and /start restated what typing already does, /clear had nothing to clear - a submit launches immediately, so targets never accumulate - /quit duplicated ctrl+c, and /help was a no-op that pointed at the menu listing it. Gone with them: the command table, the live menu above the composer and its keyboard selection, the menu's fit rules and the surface-row helper it needed, and setup.clear_targets on the backend, which nothing else called. A leading slash is now ordinary prompt text, so "/etc/passwd is world readable" reads as a path plus an instruction instead of an unknown command. The composer placeholder and the key hint row no longer advertise commands. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Focusing the agent tree or the findings list dropped their border to #1a1a1a, which is near-black, so the panel outline vanished at the moment it became the active one. That came from the stylesheet's Tree:focus rule, but a type-plus- pseudo selector loses to the #agents_tree id selector that sets #333333, so the rule never applied and the border never changed. Both panels now hold #333333 whatever has focus. The chat and the composer keep their green focus borders, which are set on ids and do apply. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tab reaches four panels and only two of them said so. The chat and the composer take a green border when focused; the agent tree and the findings list took nothing, because the stylesheet's only rule for the tree asked for near-black and lost to an id selector anyway. Both now use the same green as the other two, so whichever panel is active looks active. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review of the 21 new commits (
|
The kitty graphics probe asked for a 500ms budget through os.Stdin.SetReadDeadline, which a terminal descriptor does not support: it is blocking, so the runtime never registers it with the poller and the call fails with "file type does not support deadline". The error was discarded and the read became unbounded, so a terminal that ignored the query stalled the TUI before the handshake and before the alt screen - nothing on screen, nothing to press but ctrl-c. Verified on a pty: the old code never returns, the new code gives up in 500ms. The wait is now poll(2), which does work on a blocking descriptor. Windows has no console equivalent worth carrying and no terminal there implements the protocol, so it reports no support instead. The reply is also drained before cooked mode returns. The DA1 trails a kitty answer, so recognizing the answer left the DA1 in the input buffer for the shell to echo, which is what printed "^[[?62;52;c". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The query puts the terminal in raw mode, and raw mode clears ISIG, so ctrl-c is delivered as an ETX byte rather than a signal. The read consumed it as terminal noise and kept waiting, which is why ctrl-c did nothing while startup was stuck here: there was no signal to receive. ETX now ends the wait, and the interrupt is raised once the terminal is restored, so the process exits the way the user asked. Confirmed on a pty with a controlling terminal: the byte is recognized and the process dies of SIGINT instead of running on. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The frozen spec still collected textual data files, listed textual hidden imports and packaged .tcss stylesheets that no longer exist, and named config modules that were renamed. Surface the underlying launch error in the CLI panel instead of a fixed wheel/Go message, and trim the README install notes about the sidecar.
Resolve conflicts after main's large refactor: - usestrix#725: adopt main's --mount design (usestrix#958); drop branch's default-mount changes in utils.py/main.py and the orphaned test_fast_local_import.py. - TUI: accept main's replacement of the Python/Textual TUI with the Go/Bubble Tea implementation (usestrix#941); drop the obsolete batched event-pump fix (app.py, event_pump.py, test_event_pump.py). - Keep and reconcile the self-contained HTML report feature onto main's refactored report layer (writer.py/state.py/settings.py). - Keep the viewer 'Past Runs without email verification' change; auth.is_verified reconciled with main's reworked viewer/auth.py.
Summary
Replaces the Textual interactive interface with a Go/Bubble Tea TUI that runs as a sidecar process, and makes it the only interactive interface — no fallback, no opt-out env var.
The split is: Python owns the scan (orchestration, controller state, protocol projection), Go owns the screen (rendering, input, selection, clipboard, images). They talk over a socketpair with a versioned JSON protocol:
All TUI code lives under
strix/interface/tui/:backend/(controller, projection, server, live view),sidecar.py+runtime.py(process lifecycle), and the Go module (cmd/strix-tui,internal/app,internal/protocol,internal/render).Notable behavior beyond the port:
targets_info). Modeled on opencode's home screen.exec_command,write_stdin,view_request,repeat_request,view_sitemap_entry,apply_patch) to a 10-line preview with click-to-expand; everything else always renders in full.view_imagerenders actual pixels through the kitty graphics protocol using Unicode placeholders so it survives Bubble Tea's scrolling viewport. Support is decided by a runtime handshake (1×1a=qquery bounded by DA1), the same one viuer and yazi use — no env allowlist, and no low-fidelity block-art fallback in terminals that can't do it. The local viewer renders the same payload as an<img>.ls *.py *.go,a * b, andsnake_caseno longer render as italics.Packaging: every wheel and the frozen build bundle a platform
strix-tui; a source checkout runs the sidecar from source withgo run.Two fixes worth calling out because they are invisible in the diff's shape:
os.Stdin.SetReadDeadline, which returnsfile type does not support deadlineon a tty — a terminal that never answered the query hung the sidecar forever and the backend failed the handshake, so the packaged binary showed only a generic "setup unavailable" panel. The read now goes through a non-blocking fd bounded by the deadline (POSIX) / is only attempted when deadlines work (Windows), and the CLI surfaces the real launch error instead of a generic one.exec_startnever closes the urllib3 responses behind sandbox terminal streams; their finalizers hit the torn-down socket at interpreter exit and printedValueError: I/O operation on closed filespam. A narrowly-scopedsys.unraisablehookdrops exactly that case and delegates everything else.Verification
uv run pytest(764 passed),ruff check,mypy strix, pre-commit across all filesgofmt,go vet ./...,go test ./...; cross-compiled forwindowsanddarwinscripts/build.shand confirmed the packagedstrix-1.4.1-linux-x86_64boots into the start screen (this is what caught the detection hang above)Link to Devin session: https://app.devin.ai/sessions/477b26e9b6e24ca48f260b7a71ed87f1
Requested by: @0xallam