Unify the settings and workspace-editor surfaces
Status: Partially implemented — the auth source-folder validation and the shared error dialog have shipped on both surfaces; the structural unification of the two config-editing screens is open.
Problem
The global Settings screen and the workspace Editor screen edit the same config domains — auth, mounts, env/secrets, trust, and general options — but are implemented as two largely separate surfaces. The same concepts are coded twice: row models, flatten/validation, rendering, edit flows, modal stacks, footer hints, and mouse hit-testing each exist as a left/right twin. The two screens look and behave almost the same, which is exactly the failure mode: any change made to one and not mirrored to the other produces a behavior that exists on one screen and not the other.
The operator-facing goal: editing config in global settings and editing it in the workspace editor is the same thing — same look, same keys, same behavior. It must therefore be one implementation. A change in one place must propagate to the other by construction, never by remembering to copy it.
Root cause — why the architecture permits this whole class of bug
This was not a single defect; it is what the current structure guarantees. During this pull request the auth source-folder picker exposed the pattern concretely:
- Source-folder validation landed in the settings commit path; the editor commit path is a different function and stayed unvalidated, so a wrong folder (for example
~/.cargoselected for Codex) was silently accepted. - The rejection rendered differently per surface (the file browser's inline reject line versus nothing) until both were pointed at the standard error dialog.
Auth was only the instance that bit us. The same two-implementations-for-one-concept structure exists for mounts, env/secrets, trust, and general, and for the cross-cutting machinery (modal stacks, tab strip, footer, mouse). Patching each twin as it bites leaves the structure — and the next divergence — in place. The correct fix removes the condition: one shared config-surface subsystem both screens drive, so a behavior cannot exist on one screen and not the other.
The established sharing pattern proves this is a convergence, not a rewrite: the form models, pickers, mount-row display, footer-hint vocabulary, and focus model already live once in jackin-console and are consumed by both screens. The remaining duplication is the per-domain edit flow, the modal-enum packaging, and the render/flatten glue.
Scope
The whole settings ↔ editor config surface, domain by domain:
- Auth — agent auth mode + credential + source folder, across global / workspace / role layers. (First slice; validation and the shared dialog already shipped.)
- Mounts — mount rows, add/edit/destination flow, scope picker.
- Env / secrets — masked key/value rows, scope (global/workspace vs role), enter/add/delete flow.
- Trust — role-source trust (a first-class tab in settings, a confirm flow in the editor).
- General — coauthor-trailer / DCO and the workspace general options.
- Cross-cutting — the modal stack, tab strip, footer hints, mouse hit-testing, dirty/discard, and the error-display mechanism.
Full duplication map
Generic state structs live once in jackin-console; concrete instantiations live in the jackin app crate (tui/state.rs). The structural asymmetry at the root: the editor uses one unified modal enum ConsoleModal (tui/app.rs); settings splits the same modal taxonomy across three enums (GlobalMountModal, SettingsEnvModal, SettingsAuthModal) plus a screen-level error-popup field (screens/settings/model.rs).
Modal taxonomy — same concepts, 1 enum vs 3
| Modal concept | Editor (ConsoleModal) | Settings | Shared inner widget |
|---|---|---|---|
| Text input | TextInput | GlobalMountModal::Text, SettingsEnvModal::Text, SettingsAuthModal::TextInput | TextInputState |
| File browser | FileBrowser | GlobalMountModal::FileBrowser, SettingsAuthModal::SourceFolderPicker | file_browser |
| Mount-dst choice | MountDstChoice | GlobalMountModal::MountDstChoice | mount_dst_choice |
| Confirm | Confirm | GlobalMountModal::Confirm, SettingsEnvModal::Confirm | ConfirmState |
| Confirm-save / preview | ConfirmSave | GlobalMountModal::PreviewSave | save_preview |
| Error popup | ErrorPopup | screen-level SettingsState.error_popup (not a modal variant) | error_popup |
| Op (1Password) picker | OpPicker | SettingsEnvModal::OpPicker, SettingsAuthModal::OpPicker | op_picker |
| Role picker | RolePicker / RoleOverridePicker / AuthRolePicker | GlobalMountModal::RolePicker, SettingsEnvModal::RolePicker | role_picker |
| Source picker | SourcePicker / AuthSourcePicker | SettingsEnvModal::SourcePicker, SettingsAuthModal::SourcePicker | source_picker |
| Scope picker | ScopePicker | GlobalMountModal::ScopePicker, SettingsEnvModal::ScopePicker | scope_picker |
| Auth form | AuthForm | SettingsAuthModal::AuthForm | auth_panel::AuthForm |
Every modal concept settings has is also an editor ConsoleModal variant. The taxonomy is fully twinned; only the packaging differs.
Config domains — row model, flatten, render, edit flow
| Domain | Editor impl | Settings impl | Shared today |
|---|---|---|---|
| Auth | AuthRow<K> + input/auth.rs (840+ L) | SettingsAuthRow<K,M> + input/global_mounts/auth.rs (660 L) | AuthForm, AuthKind/AuthMode, auth_panel::render_form, source-folder helpers |
| Mounts | inline in pending: WorkspaceConfig; input/editor/mounts.rs | GlobalMountRow + GlobalMountsState; mount handlers in input/global_mounts.rs | mount_rows (two twin fn pairs), mount_display, mount_dst_choice |
| Env / secrets | SecretsRow + secrets_flat_rows + input/editor/secrets.rs | SettingsEnvRow + settings_env_flat_rows + env handlers in input/global_mounts.rs | editor_rows::render_secret_key_line, env-value component, pickers |
| Trust | ConfirmTarget::TrustRoleSource confirm flow | dedicated SettingsTrustState tab + handle_trust_key | role-trust confirm semantics (separate code) |
| General | inline in WorkspaceConfig; render_general_tab | SettingsGeneralState; render_general_tab (same-named twin) | tab strip, footer hints |
Largest twinned function families
Auth edit-flow (the proven slice): open_auth_form_modal / open_settings_auth_form; apply_*_to_auth_form vs apply_*_to_settings_auth_form for plain / op-picker (committed / runner / validator / failed) / source-folder; restore_auth_form_after_op_picker_cancel / restore_settings_auth_form; persist_form / persist_settings_auth_form; clear_layer / clear_settings_auth_kind. Env/secrets edit-flow: open_secrets_enter_modal / open_settings_env_enter_modal; open_secrets_add_modal / open_settings_env_add_modal; open_secrets_delete_confirm / open_settings_env_delete_confirm; mask toggles; set_pending_env_value_typed / set_settings_env_value_typed. Per-tab render twins in view/settings.rs vs the editor render in view/editor.rs: render_general_tab, render_mounts_tab, render_secrets_tab / render_env_tab, render_auth_tab, and the *_lines_for_state line builders. Flatten/row-count twins (editor/model.rs update vs settings update): auth_flat_rows / secrets_flat_rows vs settings_env_flat_rows / settings_auth_detail_row_count. Modal-stack methods copy-pasted across four structs: open_sub_modal / pop_modal_chain / clear_modal_chain on EditorState, SettingsEnvState, GlobalMountsState, plus push_auth_modal / restore_pending_auth_form on SettingsAuthState. is_dirty / discard duplicated on every per-tab state. Mouse hit-test twins in input/mouse.rs: try_select_editor_tab / try_select_settings_tab, row-select and modal-scroll twins. Footer twins in components/footer: editor_footer_items / settings_footer_items, one modal_footer_items (editor) vs three settings_*_modal_footer_items (settings).
Already shared (the established pattern to extend)
In jackin-console shared components, consumed by both screens: auth_panel (AuthForm, render_form, pickers' state constructors); mount_rows, mount_display, mount_dst_choice; editor_rows (tab strip, secret-key line, disclosure styles); footer_hints (the full shared hint vocabulary); every picker widget (op_picker, role_picker, source_picker, scope_picker, github_picker, file_browser, workdir_pick, confirm_save/save_preview, error_popup, status_popup, container_info); shared update helpers (auth_flat_rows, secrets_flat_rows, settings_env_flat_rows, change-count helpers); FocusOwner<T>; and the auth domain types (AuthKind, AuthMode). The widgets are shared; the per-screen wiring around them is not.
Key asymmetries to resolve
- Editor = one modal enum (
ConsoleModal, ~22 variants); settings = three modal enums + a screen-level error field. Same concepts, different packaging. - Editor packs mounts/general/env into one
WorkspaceConfig(pending/original); settings splits each tab into its own*Statewith its ownpending/original/modal/is_dirty/discard. - Trust is a first-class tab in settings but only a confirm flow in the editor.
- Editor auth supports mouse row-click + an
auth_expandedset; settings auth usesselected_kinddetail-rows with no row click. - Settings env scope (
Global/Role) mirrors editor secrets scope (Workspace/Role) — the same two-level model expressed as two enums and two flatteners.
Target architecture
End state: one shared config-surface subsystem that both screens drive. The two screens differ only in which layers they edit — settings edits global defaults; the editor edits workspace + role layers. That difference is data (the scope/layer set), not behavior, so it belongs behind a small trait, not in duplicated code.
Following the TUI architecture rules: the config-surface model (rows, modal stack) is terminal-interaction state and lives in jackin-console; per-domain decision logic (validation, flatten) is a pure rule in a domain/update module; rendering is a pure view over the shared model. No external work runs in any of it.
One modal enum
Collapse settings' three modal enums (and the screen-level error popup) into the editor's single unified modal taxonomy, so there is one modal stack type for both screens. The auth-modal slice is the worked example: retire SettingsAuthModal::SourceFolderPicker in favor of the shared file-browser-with-target shape, then fold the remaining settings auth/env/mount modals into the shared enum. One open_sub_modal/pop_modal_chain/clear_modal_chain implementation replaces the four copies.
One per-domain edit subsystem + a scope host trait
For each domain (auth, mounts, env, trust, general), one shared set of: row model, flatten/validation, render line-builder, and edit-flow handlers, generic over a host trait that supplies the screen-specific pieces:
pub trait ConfigSurfaceHost {
// which layers this screen edits, and how to read/write them
fn scopes(&self) -> &[ConfigScope]; // settings: [Global]; editor: [Workspace, Role(_)]
fn stash_modal(&mut self, child: ConfigModal); // one modal-stack impl, not four
fn restore_modal(&mut self);
fn show_error(&mut self, reason: String); // one standard dialog, same title both screens
// domain accessors (auth rows, mount rows, env rows) keyed by scope
}The auth slice instantiates this first (see below); mounts, env, trust, general follow the same shape. Each domain's edit flow (open_form, apply_*, validate, restore, persist, clear) becomes one generic function over H: ConfigSurfaceHost replacing the twin pair.
Auth-modal slice (first increment, concrete pattern)
The auth lifecycle is the smallest complete instance and is partly landed. It defines the shared shapes the other domains reuse:
// jackin-console: shared auth modal model (beside AuthForm)
pub enum AuthModal<SourcePickerState, OpPickerState, FileBrowserState, AuthForm, AuthFormFocus> {
Form { target: AuthFormTarget, state: Box<AuthForm>, focus: AuthFormFocus, literal_buffer: String },
CredentialSourcePicker { state: SourcePickerState },
SourceFolderBrowser { state: FileBrowserState },
OpPicker { state: Box<OpPickerState> },
}
// the validate-then-apply-or-error decision is a pure rule → domain
pub enum AuthSourceFolderOutcome { Apply(PathBuf), Reject(String) }
pub fn decide_auth_source_folder(kind: Option<AuthKind>, committed: PathBuf) -> AuthSourceFolderOutcome;decide_auth_source_folder wraps the already-shared validate_auth_source_folder (console/domain.rs) → runtime gate validate_sync_source_dir (instance/auth.rs). One handle_auth_modal<H> and one of each apply_*/open_*/restore_* replace the auth twins; the same shape generalizes to the other domains.
Infra modals that the editor shares with non-auth flows (TextInput for mount destinations and env keys; the env-value OpPicker) stay surface-owned and are reached through host openers; they are not folded into a domain modal.
Module placement
Per the TUI architecture: shared config-surface model + the ConfigModal enum live in jackin-console beside the existing shared components; pure decision/flatten logic lives in the screens' update modules (already partly there); the dispatch/apply helpers move into a shared jackin-console update module over time. The current handlers live in the app crate's input layer (input/editor.rs, input/global_mounts.rs); the move toward the jackin-console update layer is staged so each slice stays small and reviewable. This also relates to the existing Split input/editor.rs and Split app/mod items — the unification should land before or alongside those splits so the splits divide one implementation, not two.
Feasibility
Per the project's correctness-first rule, the only valid reason to stop short is a proven impossibility. None is known.
- The widgets are already shared once; the duplication is wiring. The auth slice already proved a host-trait collapse of the apply/validate/error path works.
- The two screens differ only in the layer set they edit — a data difference expressible behind
ConfigScope, not behavioral. - The state structs are already generic in
jackin-console, so embedding shared modal/row types needs no new generic machinery.
Real constraints (capability, not cost): infra modals serve non-auth flows and stay surface-owned, reached via host openers; unifying modal/row data is independent of unifying rendering, so render/footer/mouse convergence is its own increment per domain and must be proven not to regress modal sizing, footer hints, or hit-testing. The trust asymmetry (tab vs confirm flow) and the auth mouse-row-click asymmetry are genuine behavior gaps to reconcile deliberately, not silently drop. If a real block surfaces (for example a borrow/lifetime conflict in a host trait), record it here with the exact error before any symptom-layer fallback.
Parity invariants (verified by cross-surface tests, per domain)
For every domain, both screens must be identical on: open gates; modal open/stash/restore; validation accept/reject for the same input; the error dialog (same widget, same title, same dismiss target); apply semantics (set value, refocus, preserve buffers); footer hint text; Esc/cancel target; and the missing-stash path logging via the shared logger (never a silent drop). A shared test asserts editor and settings produce identical results for the same operation.
Decisions
Locked:
- One modal enum for both screens — settings' three modal enums collapse into the editor's unified taxonomy.
- Per-step commits — commit and push after each slice, suite-green and clippy-clean.
- Full collapse of the auth source-folder modal variant (the first slice).
Open (raise before the relevant slice):
- Where the shared
ConfigModal/AuthModaltypes live (leaningjackin-console, beside the shared widgets). - Whether the shared dispatch/apply helpers live in a
jackin-consoleupdate module versus the app-crate input layer, and how to stage the move. - How to reconcile the trust asymmetry (promote the editor to a trust tab, or model both through one trust-confirm flow) and the auth mouse-row-click asymmetry (add row-click to settings, or treat it as a deliberate per-screen affordance).
- Whether the editor's single-
WorkspaceConfigmodel and settings' per-tab*Statemodel converge on one scoped row model, or stay distinct behind the host trait.
Sequencing
Each slice is its own commit; full jackin / jackin-console / jackin-runtime suites green and clippy clean at every step.
Auth slice (delivers the bug fix, proves the host-trait pattern):
- Shared
decide_auth_source_folder+ConfigSurfaceHost(auth source-folder subset) +commit_auth_source_folder; wire the editor. - Wire settings to the same shared fn; delete the settings source-folder apply twin and
validate_picked_source_folder. - Settings open parity (gate on
shows_source_folder()). - Collapse
SettingsAuthModal::SourceFolderPickerinto the shared modal shape; update render/footer/mouse/tests. - Unify the auth wrong-folder UX (one dialog, one title, same dismiss); cross-surface equivalence test; update the Auth source-folder sync spec.
- Collapse the remaining auth edit-flow twins (plain credential, 1Password source, op-picker family, open/restore/persist/clear) into shared
handle_auth_modal<H>helpers.
Cross-cutting and remaining domains (each its own commit):
- One modal stack: collapse settings' three modal enums + screen-level error popup into the unified taxonomy; one
open_sub_modal/pop_modal_chain/clear_modal_chainimpl; one error-display mechanism. - Mounts domain: one row model + flatten + render + edit flow over the host trait; retire the mount twins.
- Env/secrets domain: one scoped row model (reconcile
Global/RolevsWorkspace/Role), one flatten, one edit flow. - Trust domain: reconcile tab-vs-confirm; one trust flow.
- General domain: one render + edit flow.
- Cross-cutting: one tab-strip, footer, and mouse hit-test implementation parameterized by screen; final cross-surface equivalence tests for every domain.
What this does not change
- No operator-visible behavior change beyond making the two screens identical (and reconciling the known asymmetries deliberately). Config semantics — what each field means, how it is persisted, how it is validated — are untouched.
- Infra modals keep their non-auth/non-config uses intact.
- The runtime validation gates (the actual correctness checks, e.g.
validate_sync_source_dir) are already shared and are not modified here.
Definition of done
- Every twin in the duplication map is gone; one generic, host-trait-driven implementation remains per domain and per cross-cutting concern.
- One modal enum and one modal-stack implementation for both screens; only infra modals remain surface-owned.
- The two screens differ only in their
ConfigScopeset; a change to a shared component or flow propagates to both by construction. - Cross-surface equivalence tests cover every domain (open, each apply, validate, error, cancel, persist).
- Full suites green and clippy clean at each step; the Auth source-folder sync spec updated.
Related
- Auth source-folder sync spec — the behavioral invariants the auth slice must preserve.
- Auth reliability program — the feature whose Phase 1 hardening surfaced the duplication.
- Split input/editor.rs and Split app/mod — sibling codebase-health items on the same input/modal layer; sequence the unification with these so the splits divide one implementation.