23 KiB
Client-Core vs Presentation Split Plan
Status: Proposed
This document audits the current TUI/client stack and proposes a safe, incremental split between a reusable client-core layer and the ratatui/crossterm presentation layer.
The goal is to make the current single-surface client easier to maintain, while also unblocking the multi-surface direction described in MULTI_SESSION_CLIENT_ARCHITECTURE.md.
See also:
Executive Summary
Today the client stack is functionally split, but not structurally split:
src/tui/app.rsowns a very largeAppstate object with session state, transport state, input state, transient UI state, and runtime handles mixed together.src/tui/app/*.rsacts like a distributed reducer, but mutation is expressed as directimpl Appmethods instead of typed actions and reducer entrypoints.src/tui/ui.rsandsrc/tui/ui_*.rsare already mostly presentation-only, but they still depend on a very wideTuiStatetrait and a few process-global render caches.src/tui/workspace_client.rsis process-global mutable state, which is the clearest current blocker for a true client-core split and for multi-surface clients.
The safest plan is:
- Define a real
client-corestate model inside the existing crate first. - Move pure state and reducers behind that boundary without changing behavior.
- Keep ratatui rendering, overlays, markdown, mermaid, and render caches in presentation.
- Only after the boundary is clean, consider moving
client-coreinto its own crate.
Current Stack Audit
Entry points and loops
Current runtime entrypoints:
src/cli/tui_launch.rs- boots terminal runtime
- constructs
tui::App - restores session/startup hints
- calls
app.run(...)
src/tui/app/run_shell.rs- local loop:
App::run - remote loop:
App::run_remote - replay loop helpers
- local loop:
src/tui/app/local.rs- local tick handling
- terminal event handling
- bus event handling
- finish-turn bookkeeping
src/tui/app/remote.rs- remote tick and terminal event handling
- reconnect and disconnected handling
src/tui/app/remote/reconnect.rs- connect/reconnect orchestration
src/tui/app/remote/input_dispatch.rs- remote send/split submission path
src/tui/app/remote/server_events.rs- main remote event reducer today
Rendering entrypoints:
src/tui/mod.rsrender_frame(frame, state)
src/tui/ui.rsdraw(frame, app: &dyn TuiState)draw_inner(...)
src/tui/ui_prepare.rs,ui_viewport.rs,ui_messages.rs,ui_input.rs,ui_pinned.rs,ui_overlays.rs,ui_header.rs,ui_diagram_pane.rs- frame preparation and rendering
Current state root
Primary root:
src/tui/app.rspub struct AppDisplayMessageProcessingStatus- several transport and pending-operation helper structs
App currently mixes all of these concerns:
- runtime handles
provider,registry,skills,mcp_manager, debug channel
- conversation/session data
messages,session,display_messages, tool-output tracking
- composer/input state
input,cursor_pos, pasted content, pending images, queueing
- turn execution state
is_processing,status,processing_started, pending turn flags
- streaming state
streaming_text, stream buffer, thinking state, token usage, TPS tracking
- remote client/session state
- remote provider hints, session ids, server metadata, reconnect/startup state, split launch state
- workspace state
- currently not in
App, but in globalworkspace_client.rs
- currently not in
- surface-local UI state
- scroll offsets, copy selection, diagram pane focus/scroll, diff pane state, inline picker state, overlays, status notices
- config and feature toggles
- memory, swarm, diff mode, centered mode, diagram mode, auto-review, auto-judge
Current mutation surface
Mutation is spread across many impl App files:
State helpers and pseudo-reducers:
src/tui/app/state_ui.rssrc/tui/app/state_ui_runtime.rssrc/tui/app/state_ui_messages.rssrc/tui/app/state_ui_storage.rssrc/tui/app/state_ui_input_helpers.rssrc/tui/app/state_ui_maintenance.rssrc/tui/app/conversation_state.rs
Event and command handling:
src/tui/app/input.rssrc/tui/app/turn.rssrc/tui/app/local.rssrc/tui/app/remote.rssrc/tui/app/remote/input_dispatch.rssrc/tui/app/remote/server_events.rssrc/tui/app/remote/workspace.rssrc/tui/app/navigation.rssrc/tui/app/inline_interactive.rssrc/tui/app/copy_selection.rssrc/tui/app/model_context.rssrc/tui/app/auth*.rs
This is why the code already feels reducer-like, but is still tightly coupled. State transitions, runtime side effects, and redraw decisions are interleaved.
Current presentation boundary
The renderer already has a partial boundary via src/tui/mod.rs::TuiState.
That boundary is promising, but still too wide because it currently includes:
- raw domain/session access
- surface state access
- auth/config lookups
- render-specific helpers such as
render_streaming_markdown - some expensive derived computations and caching behavior
- mutable behavior like
update_cost
The result is that the trait is acting as a dump point rather than a narrow presentation model.
Concrete pain points found in code
1. App is too large and semantically mixed
The state root in src/tui/app.rs is carrying:
- domain state
- surface/controller state
- transport state
- runtime handles
- presentation-adjacent state
This prevents reuse outside the current TUI runtime.
2. No typed action/reducer boundary
The main reducers are implicit:
local.rs::handle_ticklocal.rs::handle_terminal_eventremote/server_events.rs::handle_server_eventremote/input_dispatch.rs::*state_ui_messages.rs::*conversation_state.rs::*
These should become named reducers over named state slices.
3. Workspace state is process-global
src/tui/workspace_client.rs- uses
static WORKSPACE_STATE: Mutex<Option<WorkspaceClientState>>
- uses
This is incompatible with:
- multiple client instances in one process
- test isolation without global resets
- future multi-surface clients
- a clean client-core extraction
This state must become instance-owned.
4. Render layer still relies on globals
Examples in src/tui/ui.rs:
LAST_MAX_SCROLLPINNED_PANE_TOTAL_LINES- prompt viewport animation state
- visible copy targets
These are presentation concerns, but they should become renderer-instance state, not process-global state.
5. Runtime loops and rendering are tightly interwoven
terminal.draw(|frame| crate::tui::ui::draw(frame, &self)) appears in many control-flow paths:
run_shell.rsturn.rsremote/reconnect.rsinput.rsmodel_context.rs
That makes controller extraction harder because redraw timing is coupled to mutation paths.
Proposed Split
Layer 1: client-core
Owns client behavior and state, but not ratatui rendering or terminal I/O.
Allowed in core:
- client/session/surface state
- reduction of user intents, server events, bus events, and ticks
- command parsing and routing decisions
- queueing and pending-operation state
- workspace model state
- feature toggles and mode state
- effects emitted for runtime adapters
Not allowed in core:
ratatuicrosstermevent types- direct terminal drawing
- process-global UI caches
- widget rendering
- mermaid/image/markdown rendering details
Layer 2: presentation
Owns all ratatui and render-time concerns.
Includes:
src/tui/ui.rsandsrc/tui/ui_*.rssrc/tui/info_widget*.rssrc/tui/markdown*.rssrc/tui/mermaid*.rssrc/tui/session_picker*.rssrc/tui/login_picker.rssrc/tui/account_picker*.rssrc/tui/usage_overlay.rssrc/tui/visual_debug.rs
Presentation should consume a narrow immutable snapshot or read-only trait from core.
Proposed State Types
These types should exist before any crate split. Initially they can live in a new src/client_core/ module inside the main crate.
ClientCoreState
Top-level state for one client surface.
Suggested file:
src/client_core/state/mod.rs
Suggested fields:
conversation: ConversationStatecomposer: ComposerStateturn: TurnStatestream: StreamStateremote: RemoteStateworkspace: WorkspaceStatesurface: SurfaceStatefeatures: FeatureStatenotices: NoticeState
ConversationState
Suggested files:
src/client_core/state/conversation.rs
Move in from:
src/tui/app.rssrc/tui/app/conversation_state.rssrc/tui/app/state_ui_messages.rs
Owns:
messages: Vec<Message>display_messages: Vec<DisplayMessage>display_messages_version: u64- tool output tracking
tool_call_idstool_result_idstool_output_scan_index
- provider/session conversation hydration helpers
Reducer name:
conversation_reducer
Primary responsibilities:
- append/replace/remove display messages
- replace provider transcript
- compact storage-friendly display messages
- maintain tool output tracking
ComposerState
Suggested file:
src/client_core/state/composer.rs
Move in from:
src/tui/app.rssrc/tui/app/state_ui_input_helpers.rs- pure parts of
src/tui/app/input.rs
Owns:
inputcursor_pospasted_contentspending_imagesqueued_messageshidden_queued_system_messagesinterleave_messagepending_soft_interruptspending_soft_interrupt_requestsstashed_inputqueue_modesubmit_input_on_startup- route-next-prompt flags
Reducer names:
composer_reducerqueue_reducer
Primary responsibilities:
- text editing
- queueing/interleave behavior
- restore/save reload input decisions
- turning prepared input into a high-level send intent
TurnState
Suggested file:
src/client_core/state/turn.rs
Move in from:
src/tui/app.rssrc/tui/app/local.rssrc/tui/app/tui_lifecycle.rssrc/tui/app/state_ui_maintenance.rs
Owns:
is_processingstatus: ProcessingStatusprocessing_startedpending_turnpending_queued_dispatchcancel_requestedquit_pendingpending_provider_failoversession_save_pending- background maintenance state
- current-turn reminder state
Reducer names:
turn_reducerlifecycle_reducermaintenance_reducer
Primary responsibilities:
- start/finish turn
- idle/sending/streaming/tool transitions
- failover countdown state
- maintenance banners/notices
StreamState
Suggested file:
src/client_core/state/stream.rs
Move in from:
src/tui/app.rssrc/tui/app/remote/server_events.rssrc/tui/app/misc_ui.rs
Owns:
streaming_textstream_bufferstreaming_tool_calls- token usage fields
- cache usage fields
- TPS tracking fields
- thinking/thought-line state
last_stream_activitysubagent_statusbatch_progress
Reducer names:
stream_reducerserver_event_reducer
Primary responsibilities:
- text delta/replace handling
- tool start/exec/done state
- token accounting
- thought-line handling
- stale activity tracking
RemoteState
Suggested file:
src/client_core/state/remote.rs
Move in from:
src/tui/app.rssrc/tui/app/remote/input_dispatch.rssrc/tui/app/remote/server_events.rssrc/tui/app/remote/reconnect.rssrc/tui/app/remote/queue_recovery.rs
Owns:
- remote session identity and resume state
- provider/model/server metadata
- startup/reconnect phase
- split-launch state
- pending remote message state
- rate-limit retry state
- remote resume activity snapshot
current_message_id- server sessions / client count / swarm snapshots
Reducer names:
remote_reducerserver_event_reducerremote_lifecycle_reducer
Primary responsibilities:
- reduce
ServerEventinto remote/session state - own remote reconnect-visible state
- own split/new-session routing state
- own queue recovery bookkeeping
WorkspaceState
Suggested file:
src/client_core/state/workspace.rs
Move in from:
src/tui/workspace_client.rs
Owns:
enabledmap: WorkspaceMapModelimported_server_sessionspending_split_targetpending_resume_session
Reducer names:
workspace_reducer
Primary responsibilities:
- enable/disable workspace mode
- import existing sessions
- update map after split/resume/history sync
- move focus left/right/up/down
Important rule:
- this state must become instance-owned, not global static state
SurfaceState
Suggested file:
src/client_core/state/surface.rs
Move in from:
src/tui/app.rssrc/tui/app/navigation.rssrc/tui/app/copy_selection.rssrc/tui/app/inline_interactive.rs- selected non-render code from
src/tui/app/input.rs
Owns:
scroll_offsetauto_scroll_paused- copy selection state
- diff pane focus/scroll
- diagram focus/index/scroll/ratio state
- side-panel focus state
- inline interactive/view state
- help/changelog overlay visibility and scroll
- status notices
- mouse scroll animation queue
Reducer names:
surface_reducernavigation_reduceroverlay_reducer
Note:
This is still core, not presentation. It is surface-local controller state, not render cache state.
FeatureState
Suggested file:
src/client_core/state/features.rs
Move in from:
src/tui/app.rssrc/tui/app/observe.rssrc/tui/app/split_view.rs
Owns:
- memory, swarm, autoreview, autojudge, improve mode
- diff mode
- centered mode
- diagram mode/pinning defaults
- observe mode
- split view mode
- image pinning and native scrollbar toggles
Reducer names:
feature_reducer
NoticeState
Suggested file:
src/client_core/state/notices.rs
Owns:
- transient status notices
- rate-limit/reset countdown notices
- background task wake/status notices
- startup hints / restored reload notices
Reducer names:
notice_reducer
Proposed Effects Boundary
Reducers should not directly call terminal, remote socket, or persistence APIs.
Introduce:
src/client_core/effects.rs
Suggested effect enum:
ClientEffect::SendRemoteMessage { ... }ClientEffect::ResumeRemoteSession { session_id }ClientEffect::LaunchRemoteSplitClientEffect::PersistSessionClientEffect::PersistReloadInputClientEffect::ExtractMemoriesClientEffect::StartCompactionClientEffect::RunInputShell { ... }ClientEffect::RequestQuitClientEffect::RequestRedraw
Runtime adapters in src/tui/app/local.rs, remote.rs, remote/reconnect.rs, and run_shell.rs should execute these effects.
Presentation: What Stays Put
The following should remain presentation-owned for the first split:
Core renderer
src/tui/ui.rssrc/tui/ui_prepare.rssrc/tui/ui_viewport.rssrc/tui/ui_messages.rssrc/tui/ui_input.rssrc/tui/ui_pinned.rssrc/tui/ui_overlays.rssrc/tui/ui_header.rssrc/tui/ui_diagram_pane.rssrc/tui/ui_layout.rssrc/tui/ui_status.rssrc/tui/ui_theme.rs
Rendering helpers and caches
src/tui/markdown*.rssrc/tui/mermaid*.rssrc/tui/image.rssrc/tui/visual_debug.rs- render cache structs in
ui.rs,ui_messages_cache.rs,ui_file_diff.rs,ui_pinned.rs
Widgets and overlays
src/tui/info_widget*.rssrc/tui/session_picker*.rssrc/tui/login_picker.rssrc/tui/account_picker*.rssrc/tui/usage_overlay.rs
Concrete File Mapping
Files that should become core-first
| Current file | Target module | Notes |
|---|---|---|
src/tui/app.rs |
src/client_core/state/* + thin App shell |
Split the giant App root by concern |
src/tui/app/conversation_state.rs |
src/client_core/state/conversation.rs |
Mostly state logic already |
src/tui/app/state_ui_messages.rs |
src/client_core/reducer/conversation.rs |
Clean first reducer extraction |
src/tui/app/state_ui_input_helpers.rs |
src/client_core/reducer/composer.rs |
Pure text-edit logic |
src/tui/app/state_ui.rs |
src/client_core/reducer/lifecycle.rs |
Save/restore and client focus helpers |
src/tui/app/state_ui_maintenance.rs |
src/client_core/reducer/maintenance.rs |
Notice/message state |
src/tui/app/remote/server_events.rs |
src/client_core/reducer/server_event.rs |
Highest-value reducer split |
src/tui/app/remote/queue_recovery.rs |
src/client_core/reducer/queue_recovery.rs |
Already isolated |
src/tui/app/remote/workspace.rs |
src/client_core/reducer/workspace.rs + runtime adapter |
Split commands from transport calls |
src/tui/workspace_client.rs |
src/client_core/state/workspace.rs |
Must stop being global |
src/tui/app/navigation.rs |
src/client_core/reducer/navigation.rs |
Move non-ratatui navigation state |
src/tui/app/copy_selection.rs |
src/client_core/reducer/copy_selection.rs |
Surface interaction state |
src/tui/app/inline_interactive.rs |
src/client_core/reducer/inline_ui.rs |
State transitions, not drawing |
Files that should remain presentation-first
| Current file | Keep in presentation because... |
|---|---|
src/tui/ui.rs |
main ratatui frame renderer |
src/tui/ui_prepare.rs |
render-time wrapping/caching/layout prep |
src/tui/ui_viewport.rs |
draw-time viewport calculations |
src/tui/ui_messages.rs |
ratatui message rendering |
src/tui/ui_input.rs |
input box drawing |
src/tui/ui_pinned.rs |
side-pane drawing and caches |
src/tui/info_widget*.rs |
widget composition and rendering |
src/tui/markdown*.rs |
rendering pipeline, not client behavior |
src/tui/mermaid*.rs |
rendering pipeline and image management |
src/tui/session_picker*.rs, login_picker.rs, account_picker*.rs, usage_overlay.rs |
widget state can remain presentation initially |
Recommended Reducer API
Do not start with a single mega-reducer.
Start with slice reducers and one coordinator:
reduce_tick(state, now) -> Effectsreduce_terminal_intent(state, intent) -> Effectsreduce_server_event(state, event) -> Effectsreduce_bus_event(state, event) -> Effectsreduce_workspace_action(state, action) -> Effects
Suggested types:
ClientIntent- normalized user intent, not raw crossterm keys
ExternalEvent- server event, bus event, tick, lifecycle event
ClientEffect- runtime work for adapters
This keeps crossterm and ratatui out of core.
Proposed Extraction Order
Phase 0: docs and naming
- Land this document.
- Freeze naming for the future core slices.
- Do not move code yet.
Phase 1: introduce state slices inside the current crate
Create empty or lightly-populated modules:
src/client_core/mod.rssrc/client_core/state/mod.rssrc/client_core/state/conversation.rssrc/client_core/state/composer.rssrc/client_core/state/turn.rssrc/client_core/state/stream.rssrc/client_core/state/remote.rssrc/client_core/state/workspace.rssrc/client_core/state/surface.rssrc/client_core/state/features.rssrc/client_core/state/notices.rs
Safe rule:
- move types first
- keep method bodies where they are until state compiles cleanly
Phase 2: extract the easiest pure reducers
First extractions should be the least coupled files:
state_ui_messages.rsconversation_state.rsstate_ui_input_helpers.rsremote/queue_recovery.rsstate_ui_maintenance.rs
Why first:
- mostly state mutation
- low terminal/runtime coupling
- easy to cover with unit tests
Phase 3: move workspace state into the app instance
This is the highest-leverage architectural fix.
Do this before large event-loop refactors:
- replace
workspace_client.rsglobal static state withWorkspaceStateinside app/core - keep the same commands and behavior
- adjust
remote/workspace.rsto operate on instance-owned state
Why now:
- removes the clearest multi-surface blocker
- lowers future complexity for everything else
Phase 4: extract remote event reduction
Split src/tui/app/remote/server_events.rs into:
- core reduction
- state transitions
- display-message mutations
- token and tool-call accounting
- status transitions
- runtime adapter
RemoteEventStateparsing glue- redraw policy
- transport-specific buffering
This is the single most important reducer extraction after workspace state.
Phase 5: extract normalized terminal intents
Do not put raw crossterm::Event into core.
Instead:
- keep key decoding in
local.rs,remote.rs, andinput.rs - introduce normalized intents such as:
SubmitPromptMoveCursorLeftScrollChatUpOpenSessionPickerToggleCopySelectionNavigateWorkspace(Direction)
- reduce those intents in core
Phase 6: narrow the renderer boundary
Replace the current wide TuiState dependency with either:
- a much narrower trait, or
- a
PresentationSnapshotbuilt from core state
Recommended direction:
- build a
PresentationSnapshotfrom core + presentation-owned caches
This keeps expensive derived computations out of ad hoc trait methods.
Phase 7: move runtime adapters behind effects
Once reducers return ClientEffect, update:
src/tui/app/local.rssrc/tui/app/remote.rssrc/tui/app/remote/reconnect.rssrc/tui/app/run_shell.rs
to become thin shells that:
- collect external events
- reduce them
- run returned effects
- schedule redraws
Phase 8: optional crate split
Only after ratatui/crossterm have been removed from core APIs:
- create
crates/jcode-client-core - move
src/client_core/*into the crate - keep presentation in the main crate or a future
jcode-tui-presentationcrate
Do not start with the crate split. Start with the boundary.
Testing Strategy For The Split
Each extraction phase should preserve the existing user-visible behavior.
Recommended checks:
- existing TUI tests under
src/tui/ui_testsandsrc/tui/app/tests.rs - focused reducer tests for new
client_coreslices - workspace state tests after de-globalizing
workspace_client.rs - remote
ServerEventreduction tests using captured event sequences
Recommended First PR Sequence
If this work starts immediately, the first sequence should be:
- docs only
- this plan
- type-only move
- introduce
client_core::state::workspace::WorkspaceState - no behavior change yet
- introduce
- safe behavioral move
- make workspace state instance-owned
- reducer move
- extract
state_ui_messages.rs
- extract
- reducer move
- extract
remote/server_events.rs
- extract
That order minimizes risk while unlocking the most important future architecture work.
Non-Goals For The First Split
Do not try to do these in the first wave:
- rewriting the renderer
- deleting the
TuiStateabstraction immediately - moving mermaid/markdown rendering into core
- redesigning all overlays/widgets at once
- introducing a giant Redux-style universal action enum from day one
- making independent and workspace modes separate apps
Bottom Line
The split should be:
client-core= instance-owned client state + reducers + effects- presentation = ratatui widgets, layout, drawing, render caches, visual debug
The safest first extraction is not a rendering change. It is making workspace state instance-owned and then extracting the existing pseudo-reducers, starting with display-message and remote-event reduction.