ResearchOperator Surface

Unifying the Settings and Editor Config Surfaces: Duplication Map and Target Architecture

Status: Design rationale and target architecture behind Unify the settings and workspace-editor surfaces. The auth source-folder validation and shared error dialog described here have shipped; the modal-enum collapse, host-trait, and per-domain convergence below are proposed target shape, not yet implemented. This page keeps the duplication inventory and architecture reasoning out of the roadmap item's day-to-day tracking.

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. The auth source-folder picker exposed the pattern concretely:

  • Source-folder validation landed in the settings commit path; the editor commit path was a different function and stayed unvalidated, so a wrong folder (for example ~/.cargo selected 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 (as of the auth slice landing)

Generic state structs live once in jackin-console; concrete instantiations live in the jackin app crate (crates/jackin/src/console/tui.rs). The structural asymmetry at the root: the editor uses one unified modal enum ConsoleModal (crates/jackin-console/src/tui/model/modal.rs); settings splits the same modal taxonomy across three enums (GlobalMountModal, SettingsEnvModal, SettingsAuthModal) plus a screen-level error-popup field (crates/jackin-console/src/tui/screens/settings/model.rs).

Modal conceptEditor (ConsoleModal)SettingsShared inner widget
Text inputTextInputGlobalMountModal::Text, SettingsEnvModal::Text, SettingsAuthModal::TextInputTextInputState
File browserFileBrowserGlobalMountModal::FileBrowser, SettingsAuthModal::SourceFolderPickerfile_browser
Mount-dst choiceMountDstChoiceGlobalMountModal::MountDstChoicemount_dst_choice
ConfirmConfirmGlobalMountModal::Confirm, SettingsEnvModal::ConfirmConfirmState
Confirm-save / previewConfirmSaveGlobalMountModal::PreviewSavesave_preview
Error popupErrorPopupscreen-level SettingsState.error_popup (not a modal variant)error_popup
Op (1Password) pickerOpPickerSettingsEnvModal::OpPicker, SettingsAuthModal::OpPickerop_picker
Role pickerRolePicker / RoleOverridePicker / AuthRolePickerGlobalMountModal::RolePicker, SettingsEnvModal::RolePickerrole_picker
Source pickerSourcePicker / AuthSourcePickerSettingsEnvModal::SourcePicker, SettingsAuthModal::SourcePickersource_picker
Scope pickerScopePickerGlobalMountModal::ScopePicker, SettingsEnvModal::ScopePickerscope_picker
Auth formAuthFormSettingsAuthModal::AuthFormauth_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

DomainEditor implSettings implShared today
AuthAuthRow<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
Mountsinline in pending: WorkspaceConfig; input/editor/mounts.rsGlobalMountRow + GlobalMountsState; mount handlers in input/global_mounts.rsmount_rows (two twin fn pairs), mount_display, mount_dst_choice
Env / secretsSecretsRow + secrets_flat_rows + input/editor/secrets.rsSettingsEnvRow + settings_env_flat_rows + env handlers in input/global_mounts.rseditor_rows::render_secret_key_line, env-value component, pickers
TrustConfirmTarget::TrustRoleSource confirm flowdedicated SettingsTrustState tab + handle_trust_keyrole-trust confirm semantics (separate code)
Generalinline in WorkspaceConfig; render_general_tabSettingsGeneralState; 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 crates/jackin-console/src/tui/screens/settings/view.rs vs the editor render in crates/jackin-console/src/tui/screens/editor/view.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 (crates/jackin-console/src/tui/screens/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 crates/jackin-console/src/tui/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

  1. Editor = one modal enum (ConsoleModal, ~22 variants); settings = three modal enums + a screen-level error field. Same concepts, different packaging.
  2. Editor packs mounts/general/env into one WorkspaceConfig (pending/original); settings splits each tab into its own *State with its own pending/original/modal/is_dirty/discard.
  3. Trust is a first-class tab in settings but only a confirm flow in the editor.
  4. Editor auth supports mouse row-click + an auth_expanded set; settings auth uses selected_kind detail-rows with no row click.
  5. 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
}

Neither ConfigSurfaceHost nor ConfigScope/ConfigModal exist in source yet — this is proposed shape, not landed code. The auth slice instantiates the pattern first (see below); mounts, env, trust, general are expected to follow the same shape once the trait exists. Each domain's edit flow (open_form, apply_*, validate, restore, persist, clear) is intended to become 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 defines the shared shapes the other domains would reuse. Only the plain validation function (validate_auth_source_folder, shared via ManagerEffect::ApplyFileBrowserOutcome) has landed; the typed decision enum and host trait below are still proposed:

// jackin-console: shared auth modal model (beside AuthForm) — proposed, not yet landed
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 as a pure rule → domain — proposed, not yet landed
pub enum AuthSourceFolderOutcome { Apply(PathBuf), Reject(String) }
pub fn decide_auth_source_folder(kind: Option<AuthKind>, committed: PathBuf) -> AuthSourceFolderOutcome;

decide_auth_source_folder would wrap the already-shared validate_auth_source_folder (crates/jackin/src/console.rs) → runtime gate validate_sync_source_dir (crates/jackin-instance/src/auth.rs). The plan: one handle_auth_modal<H> and one of each apply_*/open_*/restore_* would replace the auth twins, with the same shape generalizing 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) are meant to stay surface-owned and reached through host openers; they would not fold into a domain modal.

Module placement

Per the TUI architecture: the shared config-surface model + a ConfigModal enum would live in jackin-console beside the existing shared components; pure decision/flatten logic lives in the screens' update modules (already partly there); dispatch/apply helpers would move into a shared jackin-console update module over time. The current handlers live in the app crate's input layer (crates/jackin-console/src/tui/input/editor.rs, crates/jackin-console/src/tui/input/global_mounts.rs); any move toward a jackin-console update layer should stage so each slice stays small and reviewable. This also relates to the Split input/editor.rs item; the app command-dispatch split has already landed there, so the remaining sequencing concern is keeping the editor/settings split from duplicating one implementation.

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's plain shared-validation-function step already proves a collapse of the validate/error path is possible without new generic machinery.
  • 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 would need no new generic machinery.

Real constraints (capability, not cost): infra modals serve non-auth flows and should 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), it should be recorded with the exact error before any symptom-layer fallback.

Parity invariants (to be 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). The plan is a shared test that 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 is the intended first structural slice.

Open (raise before the relevant slice):

  • Where the shared ConfigModal / AuthModal types live (leaning jackin-console, beside the shared widgets).
  • Whether the shared dispatch/apply helpers live in a jackin-console update 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-WorkspaceConfig model and settings' per-tab *State model converge on one scoped row model, or stay distinct behind the host trait.

On this page