From 61c8b4c8ea2fd66cf86912f9a29eccd43d287231 Mon Sep 17 00:00:00 2001 From: oso95 Date: Thu, 12 Mar 2026 08:59:40 -0400 Subject: [PATCH 01/10] docs: add workspace module split plan --- .../workspace-module-split-plan.md | 389 ++++++++++++++++++ 1 file changed, 389 insertions(+) create mode 100644 docs/architecture/workspace-module-split-plan.md diff --git a/docs/architecture/workspace-module-split-plan.md b/docs/architecture/workspace-module-split-plan.md new file mode 100644 index 0000000..9959a6a --- /dev/null +++ b/docs/architecture/workspace-module-split-plan.md @@ -0,0 +1,389 @@ +# Workspace Module Split Plan + +## Status + +Draft only. Local planning document for a follow-up maintainability refactor after the UI-thread offload work. This document is intentionally scoped as a no-behavior-change module split and dependency cleanup. It is not committed. + +## Purpose + +The UI-thread offload refactor is functionally complete, but the workspace layer now has several oversized files that are difficult to review and risky to extend. The next step is to split those files into smaller modules without changing behavior. + +This plan exists to keep that work separate from the completed architectural refactor. The goals here are maintainability, reviewability, and dependency hygiene, not new product behavior. + +## Primary Hotspots + +Current line counts in `crates/codirigent-ui/src/workspace`: + +- `impl_output_polling.rs`: 2647 lines +- `gpui.rs`: 2500 lines +- `impl_session_lifecycle.rs`: 1388 lines +- `settings_panels.rs`: 1448 lines +- `task_board_render.rs`: 1360 lines +- `drawer_render.rs`: 1234 lines + +This plan focuses first on: + +1. `crates/codirigent-ui/src/workspace/impl_output_polling.rs` +2. `crates/codirigent-ui/src/workspace/gpui.rs` + +These two files are the highest-value split targets because they mix too many responsibilities and sit on the hottest integration boundaries. + +## Why This Is Separate From The Offload Plan + +The offload plan changed ownership boundaries and thread responsibilities. That work is complete enough to review as a functional unit. + +This plan is different: + +- It must be no-behavior-change. +- It will mostly move code, not redesign logic. +- It should preserve current public module paths where possible. +- It should be easy to review commit by commit. + +Mixing this work into the earlier plan would blur architectural changes with structural ones and make rollback harder. + +## Objectives + +1. Reduce file size and responsibility sprawl in the workspace layer. +2. Make it obvious where output flow, status reconciliation, UI reducers, event handling, and render-adjacent logic live. +3. Keep module dependencies directional and predictable. +4. Preserve current behavior, current tests, and current feature gates. +5. Avoid introducing new cross-platform assumptions, new `unwrap()` usage, or new warnings. + +## Non-Goals + +This refactor must not: + +- change session status behavior +- change output polling cadence +- change render behavior +- change task assignment behavior +- change file-tree behavior +- change startup/restore behavior +- move code across crates +- redesign the terminal runtime + +If a change affects behavior, it belongs in a different plan. + +## Constraints + +1. Keep `workspace/mod.rs` stable if possible. +2. Prefer internal submodules under existing module roots before renaming public modules. +3. Keep `gpui-full` feature gating correct for every new module. +4. Keep test discovery and test names stable where feasible. +5. Preserve branch hygiene: + - no new production `unwrap()` + - no new warnings + - no Unix-only path assumptions in touched production code + +## Target Shape + +### `impl_output_polling.rs` + +Keep `workspace/mod.rs` unchanged with `mod impl_output_polling;`. + +Use `impl_output_polling.rs` as a thin root module that owns shared types and re-exports internal helpers from submodules in `crates/codirigent-ui/src/workspace/impl_output_polling/`. + +Proposed internal split: + +- `output_runtime.rs` + - `poll_output()` + - dispatch scheduling + - prepared output apply + - terminal runtime handoff + - OSC 7 / OSC 133 extraction + +- `status_reconcile.rs` + - `sync_session_status()` + - cached-status reconciliation + - session-status side effects + - notifications and event-bus transitions tied to status changes + +- `cli_pollers.rs` + - JSONL readers + - rollout readers + - CLI metadata update application + - background polling entry points + +- `hook_signals.rs` + - hook-signal scan + - hook-signal apply + - run-epoch helpers + +- `git_refresh.rs` + - background git refresh scheduling + - apply helpers + - cwd/git cache sync helpers + +- `terminal_input.rs` + - deferred enter handling + - VTE response forwarding + - compaction input follow-up helpers + +- `tests.rs` + - optional follow-up if test density keeps the root too large + +Rules: + +- Shared helper functions should stay close to the submodule that owns them. +- The root module should only keep cross-cutting types/constants that are genuinely shared by multiple submodules. +- Do not move business logic into one new giant replacement module. + +### `gpui.rs` + +Keep `workspace/mod.rs` unchanged with `pub mod gpui;`. + +Keep `gpui.rs` as the root module that owns: + +- `WorkspaceView` +- constructor wiring +- core trait impls (`Render`, `Focusable`, IME-related impls) +- any shared root-level constants that are used widely enough to justify staying at the top + +Move implementation clusters into `crates/codirigent-ui/src/workspace/gpui/` submodules. + +Proposed internal split: + +- `derived_state.rs` + - task-board reducer helpers + - header sync helpers + - empty-cell sync helpers + - mutation-driven derived-state refresh entry points + +- `ui_events.rs` + - `process_ui_events()` + - `process_top_bar_events()` + - `process_icon_rail_events()` + +- `layout_sync.rs` + - layout switching helpers + - session focus helpers + - drag/swap follow-up helpers + - terminal dimension / resize coordination + +- `session_metadata.rs` + - lightweight session metadata helpers such as project-name/task-title derivation + +- `tests.rs` + - optional only if root test module becomes noisy + +Rules: + +- `Render::render()` should remain easy to scan and mostly orchestration-only. +- Do not bury trait impls deep enough that `WorkspaceView` becomes hard to understand. +- Avoid circular helper dependencies between `derived_state`, `layout_sync`, and `ui_events`. + +## Secondary Candidates + +These are not phase-one split targets, but they should be reviewed after the primary split: + +- `impl_session_lifecycle.rs` +- `settings_panels.rs` +- `task_board_render.rs` +- `drawer_render.rs` + +They should not be pulled into the first branch unless the primary split exposes an obvious dependency problem that requires them to move. + +## Dependency Rules + +The split should make dependencies clearer, not more tangled. + +Allowed direction: + +- `gpui` root -> `gpui::*` helpers +- `impl_output_polling` root -> `impl_output_polling::*` helpers +- narrow helper modules -> `types`, `status_engine`, `output_dispatcher`, `project_state`, existing workspace utilities + +Avoid: + +- helper modules calling back into sibling modules in both directions +- shared “misc” modules +- moving state ownership into helper modules +- duplicating logic just to avoid imports + +If two submodules need the same logic, either: + +1. keep it in the root module, or +2. extract a clearly named shared helper + +## Size Targets + +Soft targets after the split: + +- no primary workspace implementation file over 900 lines +- target most new implementation modules to land between 250 and 700 lines +- the root `gpui.rs` and `impl_output_polling.rs` files should become orchestration layers, not logic dumps + +These are maintainability targets, not hard rules. + +## Delivery Strategy + +### Phase A: Scaffolding + +Goals: + +- create target submodule directories +- move only imports, helper declarations, and `mod` wiring where needed +- keep behavior identical + +Checks: + +- compile with no logic changes +- no public module path changes + +### Phase B: Split `impl_output_polling.rs` + +Recommended order: + +1. `git_refresh.rs` +2. `terminal_input.rs` +3. `hook_signals.rs` +4. `cli_pollers.rs` +5. `status_reconcile.rs` +6. `output_runtime.rs` + +Reason: + +- start with the least risky chunks +- leave the highest-coupling output/runtime code for last after the module pattern is proven + +Phase exit criteria: + +- root `impl_output_polling.rs` is substantially smaller and mostly orchestration-only +- no behavior changes in output/status flow + +### Phase C: Split `gpui.rs` + +Recommended order: + +1. `session_metadata.rs` +2. `derived_state.rs` +3. `ui_events.rs` +4. `layout_sync.rs` + +Reason: + +- begin with pure helpers +- move reducer logic before moving event orchestration +- leave render-adjacent layout coordination until the end + +Phase exit criteria: + +- root `gpui.rs` remains readable as the high-level workspace view entry point +- trait impls are still easy to locate + +### Phase D: Cleanup And Naming Pass + +Goals: + +- normalize module names +- remove dead helpers/imports +- consolidate any duplicated private helper logic created during the move +- confirm file sizes and dependency directions are improved + +Phase exit criteria: + +- no oversized root modules remain in the targeted area +- module names match actual responsibilities + +## Test Plan + +This refactor must prove behavior did not change. + +### Automated checks for every phase + +- existing unit tests stay green +- existing integration tests stay green +- no new warnings +- no new `unwrap()` in production paths + +### Focused regression tests + +Before and after the split, ensure coverage still exercises: + +- output dispatch prioritization +- output preparation when no terminal is attached +- hook-signal ingestion +- JSONL status ingestion +- detector maintenance apply path +- task-board reducer behavior +- layout/focus-derived header updates + +If code motion breaks test clarity, move tests with the code they validate rather than centralizing more into giant files. + +## Manual Validation + +Even though this is a no-behavior-change refactor, do the following after the final phase: + +- open the app in focus mode and verify the current offload behavior still works +- create, restore, rename, group, and close sessions +- exercise task creation, assignment, review, and completion +- verify hook-capable sessions still update status +- verify generic shell sessions still decay back to idle + +## Required Verification Gate + +Run the same gate used for the offload phases: + +```bash +cargo clean +cargo fmt --all -- --check +cargo check --workspace --all-targets --all-features +cargo build --workspace --all-features +cargo test --all --all-targets --all-features +cargo clippy --all --all-targets --all-features -- -D warnings +cargo check -p codirigent-ui --features gpui-full +``` + +Also run: + +```bash +git diff --check +``` + +## Review Strategy + +Use small commits with clear boundaries. + +Recommended commit shape: + +1. scaffolding only +2. `impl_output_polling` submodule moves +3. `gpui` submodule moves +4. cleanup and naming pass +5. doc updates if needed + +Each commit should remain reviewable without mentally reconstructing the entire workspace layer. + +## Risks + +1. Import churn can hide behavior changes. +2. Private helper moves can accidentally widen visibility. +3. Test motion can make diffs look larger than the logic change. +4. Over-splitting can create a module maze. + +Mitigations: + +- keep root modules as orchestration entry points +- prefer a few responsibility-based modules over many tiny files +- move code with minimal rewriting +- review diffs with behavior preservation as the first question + +## Success Criteria + +This plan is successful when: + +1. `impl_output_polling.rs` and `gpui.rs` are no longer oversized monoliths. +2. Reviewers can find output-flow logic, status logic, reducer logic, and UI event logic quickly. +3. The verification gate is green with `gpui-full`. +4. No behavior regressions are found in the manual validation pass. + +## Follow-On Work + +If this split succeeds cleanly, the same pattern can be applied later to: + +- `impl_session_lifecycle.rs` +- `settings_panels.rs` +- `task_board_render.rs` +- `drawer_render.rs` + +That follow-on work should be planned separately after the primary split lands. From 2cc36f7cd333497ca9ff4ff8b1c934f7db52783b Mon Sep 17 00:00:00 2001 From: oso95 Date: Thu, 12 Mar 2026 11:23:45 -0400 Subject: [PATCH 02/10] refactor: scaffold workspace split phase A --- crates/codirigent-ui/src/workspace/gpui.rs | 221 +--------- .../src/workspace/gpui/derived_state.rs | 9 + .../src/workspace/gpui/layout_sync.rs | 9 + .../src/workspace/gpui/session_metadata.rs | 8 + .../codirigent-ui/src/workspace/gpui/tests.rs | 204 +++++++++ .../src/workspace/gpui/ui_events.rs | 9 + .../src/workspace/impl_output_polling.rs | 413 +----------------- .../impl_output_polling/cli_pollers.rs | 8 + .../impl_output_polling/git_refresh.rs | 8 + .../impl_output_polling/hook_signals.rs | 8 + .../impl_output_polling/output_runtime.rs | 9 + .../impl_output_polling/status_reconcile.rs | 8 + .../impl_output_polling/terminal_input.rs | 8 + .../workspace/impl_output_polling/tests.rs | 391 +++++++++++++++++ .../workspace-module-split-plan.md | 238 ++++++++++ 15 files changed, 949 insertions(+), 602 deletions(-) create mode 100644 crates/codirigent-ui/src/workspace/gpui/derived_state.rs create mode 100644 crates/codirigent-ui/src/workspace/gpui/layout_sync.rs create mode 100644 crates/codirigent-ui/src/workspace/gpui/session_metadata.rs create mode 100644 crates/codirigent-ui/src/workspace/gpui/tests.rs create mode 100644 crates/codirigent-ui/src/workspace/gpui/ui_events.rs create mode 100644 crates/codirigent-ui/src/workspace/impl_output_polling/cli_pollers.rs create mode 100644 crates/codirigent-ui/src/workspace/impl_output_polling/git_refresh.rs create mode 100644 crates/codirigent-ui/src/workspace/impl_output_polling/hook_signals.rs create mode 100644 crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs create mode 100644 crates/codirigent-ui/src/workspace/impl_output_polling/status_reconcile.rs create mode 100644 crates/codirigent-ui/src/workspace/impl_output_polling/terminal_input.rs create mode 100644 crates/codirigent-ui/src/workspace/impl_output_polling/tests.rs diff --git a/crates/codirigent-ui/src/workspace/gpui.rs b/crates/codirigent-ui/src/workspace/gpui.rs index 4f7a176..f3d3de4 100644 --- a/crates/codirigent-ui/src/workspace/gpui.rs +++ b/crates/codirigent-ui/src/workspace/gpui.rs @@ -9,6 +9,12 @@ //! - GPUI `Render` trait implementation for drawing the UI //! - GPUI `Focusable` trait for keyboard focus management //! +//! Phase A scaffolding note: +//! - The root keeps `WorkspaceView`, constructor wiring, trait impls, and +//! orchestration entry points. +//! - Child modules under `workspace/gpui/` are created now so later phases can +//! move helper clusters without changing public module paths. +//! //! # Example //! //! ```ignore @@ -19,6 +25,14 @@ //! let workspace = WorkspaceView::new(app, cx); //! ``` +mod derived_state; +mod layout_sync; +mod session_metadata; +mod ui_events; + +// Phase A keeps the implementation in this root file. The child modules above +// are intentionally empty scaffolding until Phase C moves helper clusters. + use super::core::Workspace; use super::editor_detection::detect_monospace_fonts; use super::types::{ @@ -2292,209 +2306,4 @@ pub fn create_workspace_view( } #[cfg(test)] -mod tests { - //! GPUI View Testing Strategy - //! - //! # Why Limited Tests - //! - //! `WorkspaceView` is a GPUI view component that requires the GPUI runtime - //! for rendering and interaction. Testing GPUI views requires: - //! - GPUI test harness (`gpui::TestAppContext`) - //! - Window creation for rendering tests - //! - Focus simulation for interaction tests - //! - //! # Test Coverage Strategy - //! - //! 1. **Core Business Logic** - Fully tested in `workspace/tests.rs` (29 tests) - //! - Layout management, session handling, focus navigation - //! - Bounds calculation, cell info generation - //! - All non-GPUI logic has 100% test coverage - //! - //! 2. **GPUI Integration** - Deferred to integration tests - //! - Rendering correctness requires visual inspection or snapshot tests - //! - Action handlers require GPUI action dispatch simulation - //! - //! # Future: GPUI Test Infrastructure - //! - //! When GPUI test helpers are available, add tests for: - //! - [ ] WorkspaceView renders without panic - //! - [ ] Action handlers (NewSession, CloseSession, etc.) work correctly - //! - [ ] Focus delegation to child components - //! - [ ] Layout changes trigger re-render - - use std::collections::HashMap; - - #[test] - fn test_core_workspace_is_tested_separately() { - // Reminder: Core workspace logic has dedicated tests in workspace/tests.rs - // Run `cargo test workspace::tests` to see all 29 tests pass - use crate::workspace::Workspace; - - // Quick sanity check that we can create a workspace - let ws = Workspace::new(); - assert!(ws.sessions().is_empty()); - } - - #[test] - fn test_skip_collapsed_resize_when_current_is_usable() { - assert!(super::WorkspaceView::should_skip_collapsed_resize( - 40, 120, 40, 1 - )); - assert!(super::WorkspaceView::should_skip_collapsed_resize( - 40, 120, 1, 120 - )); - assert!(super::WorkspaceView::should_skip_collapsed_resize( - 40, 120, 1, 1 - )); - } - - #[test] - fn test_do_not_skip_collapsed_resize_if_already_collapsed() { - assert!(!super::WorkspaceView::should_skip_collapsed_resize( - 1, 1, 1, 1 - )); - assert!(!super::WorkspaceView::should_skip_collapsed_resize( - 1, 80, 1, 1 - )); - } - - #[test] - fn test_do_not_skip_non_collapsed_resize() { - assert!(!super::WorkspaceView::should_skip_collapsed_resize( - 40, 120, 30, 100 - )); - } - - #[test] - fn test_render_focus_signature_tracks_focus_in_single_layout() { - assert_eq!( - super::WorkspaceView::render_focus_signature_for_layout( - crate::layout::LayoutProfile::Single, - Some(codirigent_core::SessionId(2)), - ), - Some(codirigent_core::SessionId(2)) - ); - } - - #[test] - fn test_render_focus_signature_ignores_focus_outside_single_layout() { - assert_eq!( - super::WorkspaceView::render_focus_signature_for_layout( - crate::layout::LayoutProfile::Grid2x2, - Some(codirigent_core::SessionId(2)), - ), - None - ); - } - - #[test] - fn test_normalize_codex_execution_mode_detects_bypass_alias() { - assert_eq!( - super::WorkspaceView::normalize_codex_execution_mode("codex --yolo"), - Some(codirigent_core::CodexExecutionMode::Bypass) - ); - } - - #[test] - fn test_normalize_codex_execution_mode_detects_full_auto() { - assert_eq!( - super::WorkspaceView::normalize_codex_execution_mode("codex resume abc --full-auto"), - Some(codirigent_core::CodexExecutionMode::FullAuto) - ); - } - - #[test] - fn test_normalize_codex_execution_mode_detects_explicit_never_and_danger() { - assert_eq!( - super::WorkspaceView::normalize_codex_execution_mode( - "codex -a never -s danger-full-access" - ), - Some(codirigent_core::CodexExecutionMode::Bypass) - ); - } - - #[test] - fn test_session_project_name_prefers_git_repo_root_name() { - let mut session = codirigent_core::Session::new( - codirigent_core::SessionId(1), - "Session 1".to_string(), - std::path::PathBuf::from("/workspace/subdir"), - ); - session.git_info = Some(codirigent_core::GitRepoInfo { - repo_root: std::path::PathBuf::from("/workspace/project-root"), - branch: "main".to_string(), - dirty_count: 0, - has_staged: false, - head_sha: None, - unstaged_files: Vec::new(), - staged_files: Vec::new(), - }); - - assert_eq!( - super::session_project_name(&session), - Some("project-root".to_string()) - ); - } - - #[test] - fn test_session_project_name_falls_back_to_working_directory_name() { - let session = codirigent_core::Session::new( - codirigent_core::SessionId(1), - "Session 1".to_string(), - std::path::PathBuf::from("/workspace/focused-pane"), - ); - - assert_eq!( - super::session_project_name(&session), - Some("focused-pane".to_string()) - ); - } - - #[test] - fn test_resolved_task_title_prefers_cached_title_and_falls_back_to_id() { - let task_id = codirigent_core::TaskId::from("task-123"); - let mut titles = HashMap::new(); - titles.insert(task_id.clone(), "Review parser".to_string()); - - assert_eq!( - super::resolved_task_title(&task_id, Some(&titles)), - "Review parser".to_string() - ); - assert_eq!( - super::resolved_task_title(&codirigent_core::TaskId::from("task-456"), Some(&titles)), - "task-456".to_string() - ); - assert_eq!( - super::resolved_task_title(&task_id, None), - "task-123".to_string() - ); - } - - #[test] - fn test_keystroke_is_text_input_for_plain_printable_without_key_char() { - let event = gpui::KeyDownEvent { - keystroke: gpui::Keystroke { - modifiers: gpui::Modifiers::default(), - key: "a".to_string(), - key_char: None, - }, - is_held: false, - }; - - assert!(super::WorkspaceView::keystroke_is_text_input(&event)); - } - - #[test] - fn test_keystroke_is_not_text_input_for_named_terminal_key() { - let event = gpui::KeyDownEvent { - keystroke: gpui::Keystroke { - modifiers: gpui::Modifiers::default(), - key: "enter".to_string(), - key_char: None, - }, - is_held: false, - }; - - assert!(!super::WorkspaceView::keystroke_is_text_input(&event)); - } -} +mod tests; diff --git a/crates/codirigent-ui/src/workspace/gpui/derived_state.rs b/crates/codirigent-ui/src/workspace/gpui/derived_state.rs new file mode 100644 index 0000000..171a9db --- /dev/null +++ b/crates/codirigent-ui/src/workspace/gpui/derived_state.rs @@ -0,0 +1,9 @@ +//! Future home for derived UI state reducers and refresh helpers. +//! +//! Expected move targets in Phase C: +//! - task-board reducer helpers +//! - session-header synchronization helpers +//! - empty-cell synchronization helpers +//! - explicit derived-state refresh entry points +//! +//! Phase A scaffolding only. Logic remains in the root module until Phase C. diff --git a/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs b/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs new file mode 100644 index 0000000..cc054d3 --- /dev/null +++ b/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs @@ -0,0 +1,9 @@ +//! Future home for layout synchronization and resize helpers. +//! +//! Expected move targets in Phase C: +//! - layout switching follow-up +//! - focus/layout synchronization +//! - drag/swap follow-up helpers +//! - terminal dimension and resize coordination +//! +//! Phase A scaffolding only. Logic remains in the root module until Phase C. diff --git a/crates/codirigent-ui/src/workspace/gpui/session_metadata.rs b/crates/codirigent-ui/src/workspace/gpui/session_metadata.rs new file mode 100644 index 0000000..ce731a5 --- /dev/null +++ b/crates/codirigent-ui/src/workspace/gpui/session_metadata.rs @@ -0,0 +1,8 @@ +//! Future home for lightweight session metadata helpers. +//! +//! Expected move targets in Phase C: +//! - project-name derivation +//! - task-title resolution helpers +//! - other leaf-like session metadata helpers +//! +//! Phase A scaffolding only. Logic remains in the root module until Phase C. diff --git a/crates/codirigent-ui/src/workspace/gpui/tests.rs b/crates/codirigent-ui/src/workspace/gpui/tests.rs new file mode 100644 index 0000000..b336e8d --- /dev/null +++ b/crates/codirigent-ui/src/workspace/gpui/tests.rs @@ -0,0 +1,204 @@ +//! GPUI View Testing Strategy +//! +//! # Why Limited Tests +//! +//! `WorkspaceView` is a GPUI view component that requires the GPUI runtime +//! for rendering and interaction. Testing GPUI views requires: +//! - GPUI test harness (`gpui::TestAppContext`) +//! - Window creation for rendering tests +//! - Focus simulation for interaction tests +//! +//! # Test Coverage Strategy +//! +//! 1. **Core Business Logic** - Fully tested in `workspace/tests.rs` (29 tests) +//! - Layout management, session handling, focus navigation +//! - Bounds calculation, cell info generation +//! - All non-GPUI logic has 100% test coverage +//! +//! 2. **GPUI Integration** - Deferred to integration tests +//! - Rendering correctness requires visual inspection or snapshot tests +//! - Action handlers require GPUI action dispatch simulation +//! +//! # Future: GPUI Test Infrastructure +//! +//! When GPUI test helpers are available, add tests for: +//! - [ ] WorkspaceView renders without panic +//! - [ ] Action handlers (NewSession, CloseSession, etc.) work correctly +//! - [ ] Focus delegation to child components +//! - [ ] Layout changes trigger re-render + +use std::collections::HashMap; + +#[test] +fn test_core_workspace_is_tested_separately() { + // Reminder: Core workspace logic has dedicated tests in workspace/tests.rs + // Run `cargo test workspace::tests` to see all 29 tests pass + use crate::workspace::Workspace; + + // Quick sanity check that we can create a workspace + let ws = Workspace::new(); + assert!(ws.sessions().is_empty()); +} + +#[test] +fn test_skip_collapsed_resize_when_current_is_usable() { + assert!(super::WorkspaceView::should_skip_collapsed_resize( + 40, 120, 40, 1 + )); + assert!(super::WorkspaceView::should_skip_collapsed_resize( + 40, 120, 1, 120 + )); + assert!(super::WorkspaceView::should_skip_collapsed_resize( + 40, 120, 1, 1 + )); +} + +#[test] +fn test_do_not_skip_collapsed_resize_if_already_collapsed() { + assert!(!super::WorkspaceView::should_skip_collapsed_resize( + 1, 1, 1, 1 + )); + assert!(!super::WorkspaceView::should_skip_collapsed_resize( + 1, 80, 1, 1 + )); +} + +#[test] +fn test_do_not_skip_non_collapsed_resize() { + assert!(!super::WorkspaceView::should_skip_collapsed_resize( + 40, 120, 30, 100 + )); +} + +#[test] +fn test_render_focus_signature_tracks_focus_in_single_layout() { + assert_eq!( + super::WorkspaceView::render_focus_signature_for_layout( + crate::layout::LayoutProfile::Single, + Some(codirigent_core::SessionId(2)), + ), + Some(codirigent_core::SessionId(2)) + ); +} + +#[test] +fn test_render_focus_signature_ignores_focus_outside_single_layout() { + assert_eq!( + super::WorkspaceView::render_focus_signature_for_layout( + crate::layout::LayoutProfile::Grid2x2, + Some(codirigent_core::SessionId(2)), + ), + None + ); +} + +#[test] +fn test_normalize_codex_execution_mode_detects_bypass_alias() { + assert_eq!( + super::WorkspaceView::normalize_codex_execution_mode("codex --yolo"), + Some(codirigent_core::CodexExecutionMode::Bypass) + ); +} + +#[test] +fn test_normalize_codex_execution_mode_detects_full_auto() { + assert_eq!( + super::WorkspaceView::normalize_codex_execution_mode("codex resume abc --full-auto"), + Some(codirigent_core::CodexExecutionMode::FullAuto) + ); +} + +#[test] +fn test_normalize_codex_execution_mode_detects_explicit_never_and_danger() { + assert_eq!( + super::WorkspaceView::normalize_codex_execution_mode( + "codex -a never -s danger-full-access" + ), + Some(codirigent_core::CodexExecutionMode::Bypass) + ); +} + +#[test] +fn test_session_project_name_prefers_git_repo_root_name() { + let mut session = codirigent_core::Session::new( + codirigent_core::SessionId(1), + "Session 1".to_string(), + std::path::PathBuf::from("/workspace/subdir"), + ); + session.git_info = Some(codirigent_core::GitRepoInfo { + repo_root: std::path::PathBuf::from("/workspace/project-root"), + branch: "main".to_string(), + dirty_count: 0, + has_staged: false, + head_sha: None, + unstaged_files: Vec::new(), + staged_files: Vec::new(), + }); + + assert_eq!( + super::session_project_name(&session), + Some("project-root".to_string()) + ); +} + +#[test] +fn test_session_project_name_falls_back_to_working_directory_name() { + let session = codirigent_core::Session::new( + codirigent_core::SessionId(1), + "Session 1".to_string(), + std::path::PathBuf::from("/workspace/focused-pane"), + ); + + assert_eq!( + super::session_project_name(&session), + Some("focused-pane".to_string()) + ); +} + +#[test] +fn test_resolved_task_title_prefers_cached_title_and_falls_back_to_id() { + let task_id = codirigent_core::TaskId::from("task-123"); + let mut titles = HashMap::new(); + titles.insert(task_id.clone(), "Review parser".to_string()); + + assert_eq!( + super::resolved_task_title(&task_id, Some(&titles)), + "Review parser".to_string() + ); + assert_eq!( + super::resolved_task_title(&codirigent_core::TaskId::from("task-456"), Some(&titles)), + "task-456".to_string() + ); + assert_eq!( + super::resolved_task_title(&task_id, None), + "task-123".to_string() + ); +} + +#[test] +fn test_keystroke_is_text_input_for_plain_printable_without_key_char() { + let event = gpui::KeyDownEvent { + keystroke: gpui::Keystroke { + modifiers: gpui::Modifiers::default(), + key: "a".to_string(), + key_char: None, + }, + is_held: false, + }; + + assert!(super::WorkspaceView::keystroke_is_text_input(&event)); +} + +#[test] +fn test_keystroke_is_not_text_input_for_named_terminal_key() { + let event = gpui::KeyDownEvent { + keystroke: gpui::Keystroke { + modifiers: gpui::Modifiers::default(), + key: "enter".to_string(), + key_char: None, + }, + is_held: false, + }; + + assert!(!super::WorkspaceView::keystroke_is_text_input(&event)); +} diff --git a/crates/codirigent-ui/src/workspace/gpui/ui_events.rs b/crates/codirigent-ui/src/workspace/gpui/ui_events.rs new file mode 100644 index 0000000..6dde321 --- /dev/null +++ b/crates/codirigent-ui/src/workspace/gpui/ui_events.rs @@ -0,0 +1,9 @@ +//! Future home for GPUI event-processing helpers. +//! +//! Expected move targets in Phase C: +//! - `process_ui_events()` +//! - `process_top_bar_events()` +//! - `process_icon_rail_events()` +//! - closely related event translation helpers +//! +//! Phase A scaffolding only. Logic remains in the root module until Phase C. diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling.rs b/crates/codirigent-ui/src/workspace/impl_output_polling.rs index 0c76bc1..efa2e0c 100644 --- a/crates/codirigent-ui/src/workspace/impl_output_polling.rs +++ b/crates/codirigent-ui/src/workspace/impl_output_polling.rs @@ -8,6 +8,22 @@ //! - Polls Codex/Gemini JSONL logs (background thread, ~3s interval) //! - Manages automatic task assignment and context compaction //! - Handles clipboard preview auto-show/hide +//! +//! Phase A scaffolding note: +//! - The root keeps shared types, constants, and orchestration methods. +//! - Child modules under `workspace/impl_output_polling/` are created now so +//! later phases can move responsibility-based helper clusters without +//! changing `workspace/mod.rs`. + +mod cli_pollers; +mod git_refresh; +mod hook_signals; +mod output_runtime; +mod status_reconcile; +mod terminal_input; + +// Phase A keeps all behavior in this root file. The child modules above are +// destination files for Phase B moves only. use super::cli_helpers::clear_command; use super::cli_helpers::is_safe_cli_session_id; @@ -2249,399 +2265,4 @@ impl WorkspaceView { } #[cfg(test)] -mod tests { - use super::*; - use codirigent_core::{DefaultEventBus, ImageData, ImageFormat}; - use std::path::PathBuf; - use std::sync::{Arc, Mutex}; - use std::time::Instant; - - fn temp_fixture_path(name: &str) -> PathBuf { - std::env::temp_dir().join(name) - } - - #[test] - fn detector_maintenance_merge_dedupes_and_preserves_priority() { - let merged = merge_detector_maintenance_session_ids( - vec![SessionId(2), SessionId(1), SessionId(2)], - vec![SessionId(3), SessionId(1), SessionId(4)], - ); - - assert_eq!( - merged, - vec![SessionId(2), SessionId(1), SessionId(3), SessionId(4)] - ); - } - - #[test] - fn detector_maintenance_batch_includes_stale_cached_sessions() { - let detector = Arc::new(Mutex::new(codirigent_detector::InputDetector::new( - codirigent_detector::DetectorConfig::default(), - Arc::new(DefaultEventBus::new(16)), - ))); - let cli_readers = Arc::new(Mutex::new(super::super::types::CliReaders::new())); - let stale_id = SessionId(17); - - cli_readers - .lock() - .unwrap_or_else(|poison| poison.into_inner()) - .cached_status - .insert( - stale_id, - CachedCliStatus { - status: SessionStatus::NeedsAttention, - seen_at: Instant::now(), - source: CliStatusSource::Hook, - status_since: Instant::now(), - ttl: Duration::from_secs(30), - }, - ); - - let batch = collect_detector_maintenance_batch(&detector, &cli_readers); - assert_eq!(batch.session_ids, vec![stale_id]); - } - - fn codex_input( - session_id: u64, - working_dir: &str, - has_explicit_codex_started_at: bool, - ) -> JsonlCheckInput { - JsonlCheckInput { - session_id: SessionId(session_id), - working_dir: std::path::PathBuf::from(working_dir), - child_pid: None, - cli_type: CliType::CodexCli, - codex_session_id: None, - codex_execution_mode: None, - has_explicit_codex_started_at, - current_status: SessionStatus::Idle, - created_at_millis: 0, - } - } - - fn sig(status: &str, codirigent_session_id: Option<&str>, ts: u64) -> HookSignal { - HookSignal { - status: status.to_owned(), - cli_type: None, - cli_session_id: None, - approval_policy: None, - sandbox_policy_type: None, - codirigent_session_id: codirigent_session_id.map(str::to_owned), - ts, - } - } - - #[test] - fn hook_signal_without_codirigent_id_is_ignored() { - // Signals without codirigent_session_id come from Claude Code started - // outside Codirigent and should be silently discarded. - let signal = sig("working", None, 100); - assert!(signal.codirigent_session_id.is_none()); - } - - #[test] - fn hook_signal_with_codirigent_id_is_valid() { - let signal = sig("working", Some("42"), 100); - assert_eq!(signal.codirigent_session_id.as_deref(), Some("42")); - assert_eq!(signal.status, "working"); - } - - #[test] - fn hook_signal_codirigent_id_parses_to_session_id() { - let signal = sig("needs_attention", Some("7"), 100); - let id: u64 = signal - .codirigent_session_id - .unwrap() - .parse() - .expect("should parse"); - assert_eq!(id, 7); - } - - #[test] - fn hook_signal_invalid_codirigent_id_not_parseable() { - // Non-numeric IDs are rejected at parse time in hook signal processing. - let bad_id = "not-a-number".to_owned(); - assert!(bad_id.parse::().is_err()); - } - - #[test] - fn hook_signal_deserializes_from_json() { - let json = r#"{"status":"working","cli_session_id":"codex-session","codirigent_session_id":"3","ts":1234567890}"#; - let signal: HookSignal = serde_json::from_str(json).unwrap(); - assert_eq!(signal.status, "working"); - assert_eq!(signal.cli_session_id.as_deref(), Some("codex-session")); - assert_eq!(signal.codirigent_session_id.as_deref(), Some("3")); - assert_eq!(signal.ts, 1234567890); - } - - #[test] - fn hook_signal_deserializes_without_codirigent_id() { - // Backwards-compatible: old signal files without the field deserialize fine. - let json = r#"{"status":"idle","ts":100}"#; - let signal: HookSignal = serde_json::from_str(json).unwrap(); - assert!(signal.cli_session_id.is_none()); - assert!(signal.codirigent_session_id.is_none()); - } - - #[test] - fn hook_signal_context_infers_bypass_mode() { - assert_eq!( - codex_execution_mode_from_approval_and_sandbox( - Some("never"), - Some("danger-full-access"), - ), - Some(CodexExecutionMode::Bypass) - ); - } - - #[test] - fn hook_signal_context_infers_full_auto_mode() { - assert_eq!( - codex_execution_mode_from_approval_and_sandbox(Some("never"), Some("workspace-write"),), - Some(CodexExecutionMode::FullAuto) - ); - } - - #[test] - fn hook_signal_is_applied_when_timestamp_advances() { - let fp = hook_signal_fingerprint("working", Some(CLI_TYPE_CLAUDE), None, None); - assert!(should_apply_hook_signal(None, 100, fp)); - assert!(should_apply_hook_signal( - Some(ProcessedHookSignal { - ts: 99, - fingerprint: fp, - }), - 100, - fp, - )); - } - - #[test] - fn identical_hook_signal_is_ignored_when_timestamp_does_not_advance() { - let fp = hook_signal_fingerprint("working", Some(CLI_TYPE_CLAUDE), None, None); - assert!(!should_apply_hook_signal( - Some(ProcessedHookSignal { - ts: 100, - fingerprint: fp, - }), - 100, - fp, - )); - assert!(!should_apply_hook_signal( - Some(ProcessedHookSignal { - ts: 101, - fingerprint: fp, - }), - 100, - fp, - )); - } - - #[test] - fn changed_hook_signal_with_same_timestamp_is_still_applied() { - let old_fp = hook_signal_fingerprint("working", Some(CLI_TYPE_CLAUDE), None, None); - let new_fp = hook_signal_fingerprint("response_ready", Some(CLI_TYPE_CLAUDE), None, None); - - assert!(should_apply_hook_signal( - Some(ProcessedHookSignal { - ts: 100, - fingerprint: old_fp, - }), - 100, - new_fp, - )); - } - - #[test] - fn numeric_signal_file_id_is_not_treated_as_codex_session_id() { - assert_eq!(resolve_hook_cli_session_id("3", None, SessionId(3)), None); - } - - #[test] - fn non_numeric_signal_file_id_can_backfill_cli_session_id() { - assert_eq!( - resolve_hook_cli_session_id("codex-uuid", None, SessionId(3)), - Some("codex-uuid".to_string()) - ); - } - - #[test] - fn explicit_cli_session_id_wins_over_signal_file_id() { - assert_eq!( - resolve_hook_cli_session_id("3", Some("real-codex-id"), SessionId(3)), - Some("real-codex-id".to_string()) - ); - } - - #[test] - fn unsafe_hook_cli_session_id_is_rejected() { - assert_eq!( - resolve_hook_cli_session_id("3", Some("bad;id"), SessionId(3)), - None - ); - assert_eq!( - resolve_hook_cli_session_id("bad;id", None, SessionId(3)), - None - ); - } - - #[test] - fn ambiguous_codex_probe_is_deferred_without_explicit_start_time() { - let inputs = vec![ - codex_input(1, "C:/repo", false), - codex_input(2, "C:/repo", false), - ]; - let counts = count_codex_sessions_without_session_id_per_working_dir(&inputs); - - assert!(should_defer_ambiguous_codex_probe(&inputs[0], &counts)); - assert!(should_defer_ambiguous_codex_probe(&inputs[1], &counts)); - } - - #[test] - fn ambiguous_codex_probe_uses_timestamp_when_start_time_is_known() { - let inputs = vec![ - codex_input(1, "C:/repo", true), - codex_input(2, "C:/repo", true), - ]; - let counts = count_codex_sessions_without_session_id_per_working_dir(&inputs); - - assert!(!should_defer_ambiguous_codex_probe(&inputs[0], &counts)); - assert!(!should_defer_ambiguous_codex_probe(&inputs[1], &counts)); - } - - #[test] - fn ambiguous_codex_probe_only_defers_session_missing_start_time() { - let inputs = vec![ - codex_input(1, "C:/repo", true), - codex_input(2, "C:/repo", false), - ]; - let counts = count_codex_sessions_without_session_id_per_working_dir(&inputs); - - assert!(!should_defer_ambiguous_codex_probe(&inputs[0], &counts)); - assert!(should_defer_ambiguous_codex_probe(&inputs[1], &counts)); - } - - #[test] - fn git_refresh_updates_git_info_without_overwriting_custom_group() { - let project_path = temp_fixture_path("project"); - let mut session = Session::new(SessionId(1), "Session 1".to_string(), project_path.clone()); - session.group = Some("custom-group".to_string()); - session.color = Some("#f43f5e".to_string()); - - let git_info = Some(GitRepoInfo { - repo_root: project_path, - branch: "feature/custom-group".to_string(), - dirty_count: 2, - has_staged: false, - head_sha: Some("deadbeef".to_string()), - unstaged_files: Vec::new(), - staged_files: Vec::new(), - }); - - assert!(update_cached_session_git_info(&mut session, &git_info)); - assert_eq!(session.group.as_deref(), Some("custom-group")); - assert_eq!(session.color.as_deref(), Some("#f43f5e")); - assert_eq!(session.git_info, git_info); - } - - #[test] - fn cwd_session_update_preserves_custom_group_from_manager() { - let project_path = temp_fixture_path("project"); - let other_project_path = temp_fixture_path("other-project"); - let mut workspace_session = - Session::new(SessionId(1), "Session 1".to_string(), project_path.clone()); - workspace_session.group = Some("custom-group".to_string()); - workspace_session.color = Some("#f43f5e".to_string()); - workspace_session.git_info = Some(GitRepoInfo { - repo_root: project_path, - branch: "main".to_string(), - dirty_count: 1, - has_staged: false, - head_sha: Some("deadbeef".to_string()), - unstaged_files: Vec::new(), - staged_files: Vec::new(), - }); - - let mut manager_session = workspace_session.clone(); - manager_session.working_directory = other_project_path.clone(); - - apply_cwd_session_update_from_manager(&mut workspace_session, &manager_session); - - assert_eq!(workspace_session.working_directory, other_project_path); - assert_eq!(workspace_session.group.as_deref(), Some("custom-group")); - assert_eq!(workspace_session.color.as_deref(), Some("#f43f5e")); - assert!(workspace_session.git_info.is_none()); - } - - #[test] - fn hook_signal_cli_type_maps_to_codex() { - assert_eq!( - cli_type_from_hook_signal_name(CLI_TYPE_CODEX), - Some(CliType::CodexCli) - ); - } - - #[test] - fn hook_signal_cli_type_maps_to_claude_and_gemini() { - assert_eq!( - cli_type_from_hook_signal_name(CLI_TYPE_CLAUDE), - Some(CliType::ClaudeCode) - ); - assert_eq!( - cli_type_from_hook_signal_name(CLI_TYPE_GEMINI), - Some(CliType::GeminiCli) - ); - } - - #[test] - fn tiny_dib_preview_is_suppressed() { - let image = ImageData { - bytes: vec![0; 8 * 1024], - width: 32, - height: 32, - format: ImageFormat::Dib, - }; - - assert!(!should_show_clipboard_preview(&image)); - } - - #[test] - fn larger_dib_preview_is_allowed() { - let image = ImageData { - bytes: vec![0; 40 * 1024], - width: 320, - height: 240, - format: ImageFormat::Dib, - }; - - assert!(should_show_clipboard_preview(&image)); - } - - #[test] - fn focused_schedulable_output_is_prioritized() { - let session_ids = vec![SessionId(1), SessionId(2), SessionId(3)]; - let schedulable = HashSet::from([SessionId(2), SessionId(3)]); - - let (ready, deferred) = - prioritize_and_partition_output_sessions(session_ids, Some(SessionId(2)), |id| { - schedulable.contains(&id) - }); - - assert_eq!(ready, vec![SessionId(2), SessionId(3)]); - assert_eq!(deferred, vec![SessionId(1)]); - } - - #[test] - fn unschedulable_output_sessions_are_deferred_instead_of_dropped() { - let session_ids = vec![SessionId(1), SessionId(2), SessionId(3)]; - let schedulable = HashSet::from([SessionId(3)]); - - let (ready, deferred) = - prioritize_and_partition_output_sessions(session_ids, Some(SessionId(2)), |id| { - schedulable.contains(&id) - }); - - assert_eq!(ready, vec![SessionId(3)]); - assert_eq!(deferred, vec![SessionId(2), SessionId(1)]); - } -} +mod tests; diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/cli_pollers.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/cli_pollers.rs new file mode 100644 index 0000000..086035f --- /dev/null +++ b/crates/codirigent-ui/src/workspace/impl_output_polling/cli_pollers.rs @@ -0,0 +1,8 @@ +//! Future home for background JSONL and rollout polling helpers. +//! +//! Expected move targets in Phase B: +//! - JSONL reader entry points +//! - rollout-mode metadata readers +//! - cached CLI status apply helpers +//! +//! Phase A scaffolding only. Logic remains in the root module until Phase B. diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/git_refresh.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/git_refresh.rs new file mode 100644 index 0000000..d7f75a0 --- /dev/null +++ b/crates/codirigent-ui/src/workspace/impl_output_polling/git_refresh.rs @@ -0,0 +1,8 @@ +//! Future home for background git refresh scheduling and apply helpers. +//! +//! Expected move targets in Phase B: +//! - bulk git refresh scheduling +//! - per-session git refresh follow-up +//! - git-info apply helpers +//! +//! Phase A scaffolding only. Logic remains in the root module until Phase B. diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/hook_signals.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/hook_signals.rs new file mode 100644 index 0000000..f74addd --- /dev/null +++ b/crates/codirigent-ui/src/workspace/impl_output_polling/hook_signals.rs @@ -0,0 +1,8 @@ +//! Future home for hook-signal scanning and apply helpers. +//! +//! Expected move targets in Phase B: +//! - run-epoch helpers +//! - hook-signal file scanning +//! - hook-signal apply helpers +//! +//! Phase A scaffolding only. Logic remains in the root module until Phase B. diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs new file mode 100644 index 0000000..0d32952 --- /dev/null +++ b/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs @@ -0,0 +1,9 @@ +//! Future home for output scheduling and prepared-output application helpers. +//! +//! Expected move targets in Phase B: +//! - `poll_output()` +//! - output scheduling and dispatcher handoff +//! - prepared-output application +//! - OSC 7 / OSC 133 extraction follow-up tied to output draining +//! +//! Phase A scaffolding only. Logic remains in the root module until Phase B. diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/status_reconcile.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/status_reconcile.rs new file mode 100644 index 0000000..dacee4f --- /dev/null +++ b/crates/codirigent-ui/src/workspace/impl_output_polling/status_reconcile.rs @@ -0,0 +1,8 @@ +//! Future home for session-status reconciliation helpers. +//! +//! Expected move targets in Phase B: +//! - `sync_session_status()` +//! - cached-status reconciliation helpers +//! - task/notification/compaction side-effect helpers +//! +//! Phase A scaffolding only. Logic remains in the root module until Phase B. diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/terminal_input.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/terminal_input.rs new file mode 100644 index 0000000..aa62d10 --- /dev/null +++ b/crates/codirigent-ui/src/workspace/impl_output_polling/terminal_input.rs @@ -0,0 +1,8 @@ +//! Future home for deferred terminal input and VTE response helpers. +//! +//! Expected move targets in Phase B: +//! - deferred enter handling +//! - VTE response forwarding +//! - compaction input follow-up helpers +//! +//! Phase A scaffolding only. Logic remains in the root module until Phase B. diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/tests.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/tests.rs new file mode 100644 index 0000000..5044082 --- /dev/null +++ b/crates/codirigent-ui/src/workspace/impl_output_polling/tests.rs @@ -0,0 +1,391 @@ +use super::*; +use codirigent_core::{DefaultEventBus, ImageData, ImageFormat}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +fn temp_fixture_path(name: &str) -> PathBuf { + std::env::temp_dir().join(name) +} + +#[test] +fn detector_maintenance_merge_dedupes_and_preserves_priority() { + let merged = merge_detector_maintenance_session_ids( + vec![SessionId(2), SessionId(1), SessionId(2)], + vec![SessionId(3), SessionId(1), SessionId(4)], + ); + + assert_eq!( + merged, + vec![SessionId(2), SessionId(1), SessionId(3), SessionId(4)] + ); +} + +#[test] +fn detector_maintenance_batch_includes_stale_cached_sessions() { + let detector = Arc::new(Mutex::new(codirigent_detector::InputDetector::new( + codirigent_detector::DetectorConfig::default(), + Arc::new(DefaultEventBus::new(16)), + ))); + let cli_readers = Arc::new(Mutex::new(super::super::types::CliReaders::new())); + let stale_id = SessionId(17); + + cli_readers + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .cached_status + .insert( + stale_id, + CachedCliStatus { + status: SessionStatus::NeedsAttention, + seen_at: Instant::now(), + source: CliStatusSource::Hook, + status_since: Instant::now(), + ttl: Duration::from_secs(30), + }, + ); + + let batch = collect_detector_maintenance_batch(&detector, &cli_readers); + assert_eq!(batch.session_ids, vec![stale_id]); +} + +fn codex_input( + session_id: u64, + working_dir: &str, + has_explicit_codex_started_at: bool, +) -> JsonlCheckInput { + JsonlCheckInput { + session_id: SessionId(session_id), + working_dir: std::path::PathBuf::from(working_dir), + child_pid: None, + cli_type: CliType::CodexCli, + codex_session_id: None, + codex_execution_mode: None, + has_explicit_codex_started_at, + current_status: SessionStatus::Idle, + created_at_millis: 0, + } +} + +fn sig(status: &str, codirigent_session_id: Option<&str>, ts: u64) -> HookSignal { + HookSignal { + status: status.to_owned(), + cli_type: None, + cli_session_id: None, + approval_policy: None, + sandbox_policy_type: None, + codirigent_session_id: codirigent_session_id.map(str::to_owned), + ts, + } +} + +#[test] +fn hook_signal_without_codirigent_id_is_ignored() { + // Signals without codirigent_session_id come from Claude Code started + // outside Codirigent and should be silently discarded. + let signal = sig("working", None, 100); + assert!(signal.codirigent_session_id.is_none()); +} + +#[test] +fn hook_signal_with_codirigent_id_is_valid() { + let signal = sig("working", Some("42"), 100); + assert_eq!(signal.codirigent_session_id.as_deref(), Some("42")); + assert_eq!(signal.status, "working"); +} + +#[test] +fn hook_signal_codirigent_id_parses_to_session_id() { + let signal = sig("needs_attention", Some("7"), 100); + let id: u64 = signal + .codirigent_session_id + .unwrap() + .parse() + .expect("should parse"); + assert_eq!(id, 7); +} + +#[test] +fn hook_signal_invalid_codirigent_id_not_parseable() { + // Non-numeric IDs are rejected at parse time in hook signal processing. + let bad_id = "not-a-number".to_owned(); + assert!(bad_id.parse::().is_err()); +} + +#[test] +fn hook_signal_deserializes_from_json() { + let json = r#"{"status":"working","cli_session_id":"codex-session","codirigent_session_id":"3","ts":1234567890}"#; + let signal: HookSignal = serde_json::from_str(json).unwrap(); + assert_eq!(signal.status, "working"); + assert_eq!(signal.cli_session_id.as_deref(), Some("codex-session")); + assert_eq!(signal.codirigent_session_id.as_deref(), Some("3")); + assert_eq!(signal.ts, 1234567890); +} + +#[test] +fn hook_signal_deserializes_without_codirigent_id() { + // Backwards-compatible: old signal files without the field deserialize fine. + let json = r#"{"status":"idle","ts":100}"#; + let signal: HookSignal = serde_json::from_str(json).unwrap(); + assert!(signal.cli_session_id.is_none()); + assert!(signal.codirigent_session_id.is_none()); +} + +#[test] +fn hook_signal_context_infers_bypass_mode() { + assert_eq!( + codex_execution_mode_from_approval_and_sandbox(Some("never"), Some("danger-full-access"),), + Some(CodexExecutionMode::Bypass) + ); +} + +#[test] +fn hook_signal_context_infers_full_auto_mode() { + assert_eq!( + codex_execution_mode_from_approval_and_sandbox(Some("never"), Some("workspace-write")), + Some(CodexExecutionMode::FullAuto) + ); +} + +#[test] +fn hook_signal_is_applied_when_timestamp_advances() { + let fp = hook_signal_fingerprint("working", Some(CLI_TYPE_CLAUDE), None, None); + assert!(should_apply_hook_signal(None, 100, fp)); + assert!(should_apply_hook_signal( + Some(ProcessedHookSignal { + ts: 99, + fingerprint: fp, + }), + 100, + fp, + )); +} + +#[test] +fn identical_hook_signal_is_ignored_when_timestamp_does_not_advance() { + let fp = hook_signal_fingerprint("working", Some(CLI_TYPE_CLAUDE), None, None); + assert!(!should_apply_hook_signal( + Some(ProcessedHookSignal { + ts: 100, + fingerprint: fp, + }), + 100, + fp, + )); + assert!(!should_apply_hook_signal( + Some(ProcessedHookSignal { + ts: 101, + fingerprint: fp, + }), + 100, + fp, + )); +} + +#[test] +fn changed_hook_signal_with_same_timestamp_is_still_applied() { + let old_fp = hook_signal_fingerprint("working", Some(CLI_TYPE_CLAUDE), None, None); + let new_fp = hook_signal_fingerprint("response_ready", Some(CLI_TYPE_CLAUDE), None, None); + + assert!(should_apply_hook_signal( + Some(ProcessedHookSignal { + ts: 100, + fingerprint: old_fp, + }), + 100, + new_fp, + )); +} + +#[test] +fn numeric_signal_file_id_is_not_treated_as_codex_session_id() { + assert_eq!(resolve_hook_cli_session_id("3", None, SessionId(3)), None); +} + +#[test] +fn non_numeric_signal_file_id_can_backfill_cli_session_id() { + assert_eq!( + resolve_hook_cli_session_id("codex-uuid", None, SessionId(3)), + Some("codex-uuid".to_string()) + ); +} + +#[test] +fn explicit_cli_session_id_wins_over_signal_file_id() { + assert_eq!( + resolve_hook_cli_session_id("3", Some("real-codex-id"), SessionId(3)), + Some("real-codex-id".to_string()) + ); +} + +#[test] +fn unsafe_hook_cli_session_id_is_rejected() { + assert_eq!( + resolve_hook_cli_session_id("3", Some("bad;id"), SessionId(3)), + None + ); + assert_eq!( + resolve_hook_cli_session_id("bad;id", None, SessionId(3)), + None + ); +} + +#[test] +fn ambiguous_codex_probe_is_deferred_without_explicit_start_time() { + let inputs = vec![ + codex_input(1, "C:/repo", false), + codex_input(2, "C:/repo", false), + ]; + let counts = count_codex_sessions_without_session_id_per_working_dir(&inputs); + + assert!(should_defer_ambiguous_codex_probe(&inputs[0], &counts)); + assert!(should_defer_ambiguous_codex_probe(&inputs[1], &counts)); +} + +#[test] +fn ambiguous_codex_probe_uses_timestamp_when_start_time_is_known() { + let inputs = vec![ + codex_input(1, "C:/repo", true), + codex_input(2, "C:/repo", true), + ]; + let counts = count_codex_sessions_without_session_id_per_working_dir(&inputs); + + assert!(!should_defer_ambiguous_codex_probe(&inputs[0], &counts)); + assert!(!should_defer_ambiguous_codex_probe(&inputs[1], &counts)); +} + +#[test] +fn ambiguous_codex_probe_only_defers_session_missing_start_time() { + let inputs = vec![ + codex_input(1, "C:/repo", true), + codex_input(2, "C:/repo", false), + ]; + let counts = count_codex_sessions_without_session_id_per_working_dir(&inputs); + + assert!(!should_defer_ambiguous_codex_probe(&inputs[0], &counts)); + assert!(should_defer_ambiguous_codex_probe(&inputs[1], &counts)); +} + +#[test] +fn git_refresh_updates_git_info_without_overwriting_custom_group() { + let project_path = temp_fixture_path("project"); + let mut session = Session::new(SessionId(1), "Session 1".to_string(), project_path.clone()); + session.group = Some("custom-group".to_string()); + session.color = Some("#f43f5e".to_string()); + + let git_info = Some(GitRepoInfo { + repo_root: project_path, + branch: "feature/custom-group".to_string(), + dirty_count: 2, + has_staged: false, + head_sha: Some("deadbeef".to_string()), + unstaged_files: Vec::new(), + staged_files: Vec::new(), + }); + + assert!(update_cached_session_git_info(&mut session, &git_info)); + assert_eq!(session.group.as_deref(), Some("custom-group")); + assert_eq!(session.color.as_deref(), Some("#f43f5e")); + assert_eq!(session.git_info, git_info); +} + +#[test] +fn cwd_session_update_preserves_custom_group_from_manager() { + let project_path = temp_fixture_path("project"); + let other_project_path = temp_fixture_path("other-project"); + let mut workspace_session = + Session::new(SessionId(1), "Session 1".to_string(), project_path.clone()); + workspace_session.group = Some("custom-group".to_string()); + workspace_session.color = Some("#f43f5e".to_string()); + workspace_session.git_info = Some(GitRepoInfo { + repo_root: project_path, + branch: "main".to_string(), + dirty_count: 1, + has_staged: false, + head_sha: Some("deadbeef".to_string()), + unstaged_files: Vec::new(), + staged_files: Vec::new(), + }); + + let mut manager_session = workspace_session.clone(); + manager_session.working_directory = other_project_path.clone(); + + apply_cwd_session_update_from_manager(&mut workspace_session, &manager_session); + + assert_eq!(workspace_session.working_directory, other_project_path); + assert_eq!(workspace_session.group.as_deref(), Some("custom-group")); + assert_eq!(workspace_session.color.as_deref(), Some("#f43f5e")); + assert!(workspace_session.git_info.is_none()); +} + +#[test] +fn hook_signal_cli_type_maps_to_codex() { + assert_eq!( + cli_type_from_hook_signal_name(CLI_TYPE_CODEX), + Some(CliType::CodexCli) + ); +} + +#[test] +fn hook_signal_cli_type_maps_to_claude_and_gemini() { + assert_eq!( + cli_type_from_hook_signal_name(CLI_TYPE_CLAUDE), + Some(CliType::ClaudeCode) + ); + assert_eq!( + cli_type_from_hook_signal_name(CLI_TYPE_GEMINI), + Some(CliType::GeminiCli) + ); +} + +#[test] +fn tiny_dib_preview_is_suppressed() { + let image = ImageData { + bytes: vec![0; 8 * 1024], + width: 32, + height: 32, + format: ImageFormat::Dib, + }; + + assert!(!should_show_clipboard_preview(&image)); +} + +#[test] +fn larger_dib_preview_is_allowed() { + let image = ImageData { + bytes: vec![0; 40 * 1024], + width: 320, + height: 240, + format: ImageFormat::Dib, + }; + + assert!(should_show_clipboard_preview(&image)); +} + +#[test] +fn focused_schedulable_output_is_prioritized() { + let session_ids = vec![SessionId(1), SessionId(2), SessionId(3)]; + let schedulable = HashSet::from([SessionId(2), SessionId(3)]); + + let (ready, deferred) = + prioritize_and_partition_output_sessions(session_ids, Some(SessionId(2)), |id| { + schedulable.contains(&id) + }); + + assert_eq!(ready, vec![SessionId(2), SessionId(3)]); + assert_eq!(deferred, vec![SessionId(1)]); +} + +#[test] +fn unschedulable_output_sessions_are_deferred_instead_of_dropped() { + let session_ids = vec![SessionId(1), SessionId(2), SessionId(3)]; + let schedulable = HashSet::from([SessionId(3)]); + + let (ready, deferred) = + prioritize_and_partition_output_sessions(session_ids, Some(SessionId(2)), |id| { + schedulable.contains(&id) + }); + + assert_eq!(ready, vec![SessionId(3)]); + assert_eq!(deferred, vec![SessionId(2), SessionId(1)]); +} diff --git a/docs/architecture/workspace-module-split-plan.md b/docs/architecture/workspace-module-split-plan.md index 9959a6a..784bc27 100644 --- a/docs/architecture/workspace-module-split-plan.md +++ b/docs/architecture/workspace-module-split-plan.md @@ -230,6 +230,244 @@ Checks: - compile with no logic changes - no public module path changes +#### Phase A Scope + +Phase A is intentionally mechanical. It prepares the file layout for later moves without changing behavior, execution order, ownership, or public paths. + +Deliverables: + +- internal submodule directories exist under the two primary roots +- root modules declare the new child modules +- test modules are moved out of the root files into dedicated `tests.rs` files where useful +- the verification gate passes after every Phase A task + +Phase A must not: + +- move behavior between functions +- split logic across files in the same task that introduces the new files +- change `workspace/mod.rs` +- change any `pub` surface +- introduce new target-specific code paths + +#### Phase A Task Breakdown + +##### Task A1: Scaffold `impl_output_polling` internal modules + +Create the internal directory and child files under `crates/codirigent-ui/src/workspace/impl_output_polling/`: + +- `output_runtime.rs` +- `status_reconcile.rs` +- `cli_pollers.rs` +- `hook_signals.rs` +- `git_refresh.rs` +- `terminal_input.rs` + +Update `crates/codirigent-ui/src/workspace/impl_output_polling.rs` to declare these child modules, but keep all existing function bodies in the root file for this task. + +Expected dependency shape after Task A1: + +- `impl_output_polling.rs` remains the owner of shared types, constants, and orchestration entry points +- `output_runtime.rs` will later depend on: + - `WorkspaceView` + - `output_dispatcher` + - terminal runtime snapshot application + - `sync_session_status()` + - OSC 7 / OSC 133 extraction helpers +- `status_reconcile.rs` will later depend on: + - `WorkspaceView` + - `status_engine` + - `status_providers` + - task-manager side effects + - compaction and notification follow-up +- `cli_pollers.rs` will later depend on: + - `CliReaders` + - JSONL / rollout parsing helpers + - cached CLI status update logic + - root-owned status-apply entry points +- `hook_signals.rs` will later depend on: + - hook signal file parsing + - run-epoch helpers + - cached hook-signal application + - root-owned status-apply entry points +- `git_refresh.rs` will later depend on: + - session manager git refresh helpers + - cached git-info apply helpers + - focused-session file-tree refresh hooks +- `terminal_input.rs` will later depend on: + - deferred enter handling + - VTE response forwarding + - compaction input follow-up helpers + +Dependency rules for this task: + +- child modules may depend on the root module and existing workspace utilities +- child modules must not call each other in both directions +- any helper needed by more than one child module stays in the root until a clearly shared abstraction exists + +Verification after Task A1: + +```bash +cargo clean +cargo fmt --all -- --check +cargo check --workspace --all-targets --all-features +cargo build --workspace --all-features +cargo test --all --all-targets --all-features +cargo test -p codirigent-ui --lib --features gpui-full +cargo clippy --all --all-targets --all-features -- -D warnings +cargo check -p codirigent-ui --features gpui-full +git diff --check +``` + +##### Task A2: Scaffold `gpui` internal modules + +Create the internal directory and child files under `crates/codirigent-ui/src/workspace/gpui/`: + +- `session_metadata.rs` +- `derived_state.rs` +- `ui_events.rs` +- `layout_sync.rs` + +Update `crates/codirigent-ui/src/workspace/gpui.rs` to declare these child modules, but keep all existing function bodies in the root file for this task. + +Expected dependency shape after Task A2: + +- `gpui.rs` remains the owner of: + - `WorkspaceView` + - constructor wiring + - `Render`, `Focusable`, and IME trait impls + - root-level constants +- `session_metadata.rs` will later contain the lightest-weight helpers and should depend only on: + - session data + - task-title lookup inputs + - standard library collections/path formatting +- `derived_state.rs` will later depend on: + - `WorkspaceView` + - task-board state + - terminal header state + - empty-cell state + - `session_metadata` helpers +- `ui_events.rs` will later depend on: + - `WorkspaceView` + - top bar / icon rail / task board event sources + - root-owned mutation helpers such as layout or session actions +- `layout_sync.rs` will later depend on: + - `WorkspaceView` + - layout cache invalidation + - focus/layout transitions + - terminal dimension and resize coordination + +Dependency rules for this task: + +- `session_metadata.rs` should stay leaf-like and not depend on render/event modules +- `derived_state.rs` may use `session_metadata.rs`, but `session_metadata.rs` must not depend back on `derived_state.rs` +- `ui_events.rs` and `layout_sync.rs` should coordinate through root-owned methods on `WorkspaceView`, not through sibling-to-sibling private imports + +Verification after Task A2: + +```bash +cargo clean +cargo fmt --all -- --check +cargo check --workspace --all-targets --all-features +cargo build --workspace --all-features +cargo test --all --all-targets --all-features +cargo test -p codirigent-ui --lib --features gpui-full +cargo clippy --all --all-targets --all-features -- -D warnings +cargo check -p codirigent-ui --features gpui-full +git diff --check +``` + +##### Task A3: Externalize root test modules + +Create dedicated test files: + +- `crates/codirigent-ui/src/workspace/impl_output_polling/tests.rs` +- `crates/codirigent-ui/src/workspace/gpui/tests.rs` + +Update the bottom of the root files so they use `#[cfg(test)] mod tests;` instead of large inline test blocks. + +Testing structure after Task A3: + +- `workspace/tests.rs` remains unchanged because it covers the broader workspace module +- `gpui/tests.rs` initially receives the current root tests from `gpui.rs` with no assertion changes +- `impl_output_polling/tests.rs` initially receives the current root tests from `impl_output_polling.rs` with no assertion changes + +Planned later ownership moves after Phase A: + +- session metadata tests move from `gpui/tests.rs` into `gpui/session_metadata.rs` once the helpers move +- derived-state reducer tests move from `gpui/tests.rs` into `gpui/derived_state.rs` +- hook-signal tests move from `impl_output_polling/tests.rs` into `impl_output_polling/hook_signals.rs` +- git refresh tests move from `impl_output_polling/tests.rs` into `impl_output_polling/git_refresh.rs` +- output scheduling and prepared-output tests move from `impl_output_polling/tests.rs` into `impl_output_polling/output_runtime.rs` +- status reconciliation side-effect tests move from `impl_output_polling/tests.rs` into `impl_output_polling/status_reconcile.rs` + +Rules for test motion: + +- Phase A must keep test names and assertions stable +- tests should move with the code they validate once a later phase extracts that code +- do not centralize new tests back into the root if the extracted child module can own them cleanly + +Verification after Task A3: + +```bash +cargo clean +cargo fmt --all -- --check +cargo check --workspace --all-targets --all-features +cargo build --workspace --all-features +cargo test --all --all-targets --all-features +cargo test -p codirigent-ui --lib --features gpui-full +cargo clippy --all --all-targets --all-features -- -D warnings +cargo check -p codirigent-ui --features gpui-full +git diff --check +``` + +##### Task A4: Ownership comments and import hygiene + +This is the final scaffolding pass before logic moves begin. + +Update the roots and newly created child files so they clearly document ownership and future responsibility, while keeping code motion at zero: + +- note which responsibilities stay in the root permanently +- note which responsibilities are expected to migrate in Phase B or Phase C +- remove any unused imports introduced by the new `mod` declarations + +This task is complete when a reviewer can open either root file and understand: + +- why the child modules exist +- which clusters are scheduled to move next +- that no behavior moved yet + +Verification after Task A4: + +```bash +cargo clean +cargo fmt --all -- --check +cargo check --workspace --all-targets --all-features +cargo build --workspace --all-features +cargo test --all --all-targets --all-features +cargo test -p codirigent-ui --lib --features gpui-full +cargo clippy --all --all-targets --all-features -- -D warnings +cargo check -p codirigent-ui --features gpui-full +git diff --check +``` + +#### Phase A Cross-Platform Requirements + +Phase A is structural, but it still needs to preserve cross-platform correctness. + +Rules: + +- every new child module must remain under the same feature gate as its root +- do not add `target_os` conditionals unless the moved code already requires them +- do not introduce Unix-only filesystem literals in tests or production code +- use platform-neutral temp paths such as `std::env::temp_dir()` in any touched test code +- avoid assumptions about path separators, shell names, clipboard backends, or terminal behavior that differ between macOS and Windows +- keep macOS-specific and Windows-specific dependencies where they already live today instead of re-scattering them during the split + +Merge expectation: + +- the Phase A verification gate should pass on the active development host after every task +- before merge, the same gate should also be exercised on both macOS and Windows because this module tree includes platform-aware clipboard, terminal, and editor-detection paths + ### Phase B: Split `impl_output_polling.rs` Recommended order: From 4dd1b5118c6b15422c738074bc801d205850ff53 Mon Sep 17 00:00:00 2001 From: oso95 Date: Thu, 12 Mar 2026 14:34:04 -0400 Subject: [PATCH 03/10] refactor: split workspace output polling modules --- .../src/workspace/impl_output_polling.rs | 1187 +---------------- .../impl_output_polling/cli_pollers.rs | 544 +++++++- .../impl_output_polling/git_refresh.rs | 204 ++- .../impl_output_polling/hook_signals.rs | 713 +++++++++- .../impl_output_polling/terminal_input.rs | 87 +- .../workspace/impl_output_polling/tests.rs | 295 +--- 6 files changed, 1536 insertions(+), 1494 deletions(-) diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling.rs b/crates/codirigent-ui/src/workspace/impl_output_polling.rs index efa2e0c..60e88b3 100644 --- a/crates/codirigent-ui/src/workspace/impl_output_polling.rs +++ b/crates/codirigent-ui/src/workspace/impl_output_polling.rs @@ -26,45 +26,27 @@ mod terminal_input; // destination files for Phase B moves only. use super::cli_helpers::clear_command; -use super::cli_helpers::is_safe_cli_session_id; use super::gpui::WorkspaceView; -use super::types::{CachedCliStatus, CliStatusSource, ProcessedHookSignal}; +use super::types::CliStatusSource; use crate::terminal_runtime::TerminalRenderSnapshot; use codirigent_core::{ - hook_signals_dir, AssignmentAction, CliType, CodexExecutionMode, CodirigentEvent, EventBus, - GitRepoInfo, ProcessMonitor, Session, SessionId, SessionManager, SessionStatus, SessionUpdate, - TaskStatus, + AssignmentAction, CliType, CodirigentEvent, EventBus, ProcessMonitor, Session, SessionId, + SessionManager, SessionStatus, SessionUpdate, TaskStatus, }; -use codirigent_detector::NotificationType; -use codirigent_session::cli_detector::CliDetector; use codirigent_session::clipboard_service::{ClipboardService, DefaultClipboardService}; use codirigent_session::detect_cli_from_output; -use codirigent_session::CliSessionStatus; use gpui::Context; -use serde::Deserialize; use std::collections::hash_map::DefaultHasher; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::hash::{Hash, Hasher}; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant}; use tracing::{info, trace, warn}; -const CLI_TYPE_CLAUDE: &str = "claude"; -const CLI_TYPE_GEMINI: &str = "gemini"; -const CLI_TYPE_CODEX: &str = "codex"; - #[derive(Debug, Default, Clone, PartialEq, Eq)] struct DetectorMaintenanceBatch { session_ids: Vec, } -/// Unix timestamp (seconds) recorded at process startup, acting as a -/// per-process "run epoch". Hook signals written before this moment belong to -/// a previous Codirigent run and must be ignored, regardless of the 600-second -/// recency window, to prevent stale signals from routing to re-used session IDs. -/// -/// Eagerly initialized via `init_app_start_ts()` in `WorkspaceView::new`. -static APP_START_TS: std::sync::OnceLock = std::sync::OnceLock::new(); - /// When `CODIRIGENT_LEGACY_PIPELINE=1` is set, the event-driven output /// dispatcher and status reconciler are disabled and the legacy broad-scan /// polling path runs exclusively. This is a temporary kill switch for the @@ -99,41 +81,8 @@ fn is_shadow_status() -> bool { }) } -fn app_start_ts() -> u64 { - *APP_START_TS.get_or_init(|| { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) - }) -} - -/// Eagerly initialize the hook-signal run epoch. -/// -/// Must be called early in startup (e.g., `WorkspaceView::new`) so that -/// hook signals emitted between app launch and the first scan are not -/// incorrectly filtered as belonging to a previous run. pub(super) fn init_app_start_ts() { - let _ = app_start_ts(); -} - -fn update_cached_session_git_info(session: &mut Session, git_info: &Option) -> bool { - if session.git_info == *git_info { - return false; - } - - session.git_info = git_info.clone(); - true -} - -fn apply_cwd_session_update_from_manager( - workspace_session: &mut Session, - manager_session: &Session, -) { - workspace_session.working_directory = manager_session.working_directory.clone(); - workspace_session.group = manager_session.group.clone(); - workspace_session.color = manager_session.color.clone(); - workspace_session.git_info = None; + hook_signals::init_app_start_ts(); } fn merge_detector_maintenance_session_ids( @@ -195,82 +144,6 @@ where (ready, deferred) } -/// Session status result from a background JSONL read: (status, optional detail string). -type JsonlStatusResult = Option<(SessionStatus, Option)>; - -#[derive(Debug, Clone)] -struct JsonlCheckInput { - session_id: SessionId, - working_dir: std::path::PathBuf, - child_pid: Option, - cli_type: CliType, - codex_session_id: Option, - codex_execution_mode: Option, - has_explicit_codex_started_at: bool, - current_status: SessionStatus, - created_at_millis: i64, -} - -#[derive(Debug)] -struct JsonlCheckOutput { - session_id: SessionId, - status: JsonlStatusResult, - codex_session_id: Option, - codex_execution_mode: Option, -} - -fn codex_execution_mode_from_rollout_mode(mode: &str) -> Option { - if mode.eq_ignore_ascii_case("yolo") - || mode.eq_ignore_ascii_case("bypass") - || mode.eq_ignore_ascii_case("dangerously-bypass-approvals-and-sandbox") - || mode.eq_ignore_ascii_case("dangerously_bypass_approvals_and_sandbox") - { - Some(CodexExecutionMode::Bypass) - } else if mode.eq_ignore_ascii_case("full-auto") - || mode.eq_ignore_ascii_case("full_auto") - || mode.eq_ignore_ascii_case("fullauto") - { - Some(CodexExecutionMode::FullAuto) - } else { - None - } -} - -fn count_codex_sessions_without_session_id_per_working_dir( - inputs: &[JsonlCheckInput], -) -> HashMap { - inputs - .iter() - .filter(|input| input.cli_type == CliType::CodexCli && input.codex_session_id.is_none()) - .fold(HashMap::new(), |mut counts, input| { - *counts.entry(input.working_dir.clone()).or_default() += 1; - counts - }) -} - -fn should_defer_ambiguous_codex_probe( - input: &JsonlCheckInput, - no_id_codex_counts: &HashMap, -) -> bool { - input.cli_type == CliType::CodexCli - && input.codex_session_id.is_none() - && !input.has_explicit_codex_started_at - && no_id_codex_counts - .get(&input.working_dir) - .copied() - .unwrap_or_default() - > 1 -} - -fn cli_type_from_hook_signal_name(cli_type_name: &str) -> Option { - match cli_type_name { - CLI_TYPE_CLAUDE => Some(CliType::ClaudeCode), - CLI_TYPE_GEMINI => Some(CliType::GeminiCli), - CLI_TYPE_CODEX => Some(CliType::CodexCli), - _ => None, - } -} - #[derive(Debug)] struct PreparedSessionOutput { session_id: SessionId, @@ -315,207 +188,6 @@ fn should_show_clipboard_preview(image_data: &codirigent_core::ImageData) -> boo true } -/// Signal file written by `codirigent-hook` for each hook event. -#[derive(Deserialize)] -struct HookSignal { - status: String, - cli_type: Option, - #[serde(default)] - cli_session_id: Option, - #[serde(default)] - approval_policy: Option, - #[serde(default)] - sandbox_policy_type: Option, - /// Codirigent session ID, present only when Claude Code was spawned by Codirigent - /// (via the `CODIRIGENT_SESSION_ID` environment variable). - codirigent_session_id: Option, - ts: u64, -} - -#[derive(Debug)] -struct HookSignalUpdate { - session_id: SessionId, - signal_file_id: String, - cli_session_id: Option, - codex_execution_mode: Option, - status: String, - cli_type: Option, - ts: u64, -} - -fn codex_execution_mode_fingerprint(mode: Option) -> Option<&'static str> { - match mode { - Some(CodexExecutionMode::FullAuto) => Some("full-auto"), - Some(CodexExecutionMode::Bypass) => Some("bypass"), - None => None, - } -} - -fn hook_signal_fingerprint( - status: &str, - cli_type: Option<&str>, - cli_session_id: Option<&str>, - codex_execution_mode: Option, -) -> u64 { - let mut hasher = DefaultHasher::new(); - status.hash(&mut hasher); - cli_type.hash(&mut hasher); - cli_session_id.hash(&mut hasher); - codex_execution_mode_fingerprint(codex_execution_mode).hash(&mut hasher); - hasher.finish() -} - -fn should_apply_hook_signal( - last_seen: Option, - signal_ts: u64, - signal_fingerprint: u64, -) -> bool { - match last_seen { - Some(last_seen) if signal_ts < last_seen.ts => false, - Some(last_seen) - if signal_ts == last_seen.ts && signal_fingerprint == last_seen.fingerprint => - { - false - } - _ => true, - } -} - -fn resolve_hook_cli_session_id( - signal_file_id: &str, - explicit_cli_session_id: Option<&str>, - session_id: SessionId, -) -> Option { - if let Some(explicit_id) = explicit_cli_session_id - .map(str::trim) - .filter(|id| !id.is_empty()) - { - if is_safe_cli_session_id(explicit_id) { - return Some(explicit_id.to_owned()); - } - warn!( - session_id = session_id.0, - signal_file_id, - cli_session_id = %explicit_id, - "Ignoring unsafe CLI session ID from hook signal" - ); - return None; - } - - let fallback = signal_file_id.trim(); - if fallback.is_empty() || fallback == session_id.0.to_string() { - return None; - } - if !is_safe_cli_session_id(fallback) { - warn!( - session_id = session_id.0, - signal_file_id = fallback, - "Ignoring unsafe fallback CLI session ID from hook signal filename" - ); - return None; - } - - Some(fallback.to_owned()) -} - -fn codex_execution_mode_from_approval_and_sandbox( - approval_policy: Option<&str>, - sandbox_policy_type: Option<&str>, -) -> Option { - if !approval_policy.is_some_and(|value| value.eq_ignore_ascii_case("never")) { - return None; - } - - match sandbox_policy_type { - Some(value) if value.eq_ignore_ascii_case("danger-full-access") => { - Some(CodexExecutionMode::Bypass) - } - Some(value) - if value.eq_ignore_ascii_case("workspace-write") - || value.eq_ignore_ascii_case("workspace_write") => - { - Some(CodexExecutionMode::FullAuto) - } - _ => None, - } -} - -fn read_recent_hook_signal_updates() -> Vec { - let signals_dir = match hook_signals_dir() { - Some(d) => d, - None => return Vec::new(), - }; - - let entries = match std::fs::read_dir(&signals_dir) { - Ok(e) => e, - Err(_) => return Vec::new(), - }; - - let now_ts = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - - let mut updates = Vec::new(); - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) != Some("json") { - continue; - } - - let signal_file_id = match path.file_stem().and_then(|s| s.to_str()) { - Some(s) => s.to_owned(), - None => continue, - }; - - let content = match std::fs::read_to_string(&path) { - Ok(c) => c, - Err(_) => continue, - }; - - let signal: HookSignal = match serde_json::from_str(&content) { - Ok(s) => s, - Err(_) => continue, - }; - - if now_ts.saturating_sub(signal.ts) > 600 { - continue; - } - - // Reject signals written before this process started. Session IDs - // (1, 2, 3 …) reset on every restart, so a signal from a previous run - // that shares an ID with a newly-created session would route to the - // wrong session and corrupt its claude_session_id. - if signal.ts < app_start_ts() { - continue; - } - - let session_id = match signal - .codirigent_session_id - .as_deref() - .and_then(|id| id.parse::().ok()) - { - Some(id) => SessionId(id), - None => continue, - }; - - updates.push(HookSignalUpdate { - session_id, - signal_file_id, - cli_session_id: signal.cli_session_id, - codex_execution_mode: codex_execution_mode_from_approval_and_sandbox( - signal.approval_policy.as_deref(), - signal.sandbox_policy_type.as_deref(), - ), - status: signal.status, - cli_type: signal.cli_type, - ts: signal.ts, - }); - } - - updates -} - impl WorkspaceView { const GENERIC_SHELL_JSONL_MAX_AGE: Duration = Duration::from_secs(600); /// TTL for Codex/Gemini cached JSONL status. Shorter than hook signals because @@ -935,7 +607,7 @@ impl WorkspaceView { } if let Some(ws_session) = self.workspace.session_mut(session_id) { - apply_cwd_session_update_from_manager(ws_session, &mgr_session); + git_refresh::apply_cwd_session_update_from_manager(ws_session, &mgr_session); } if self.workspace.focused_session_id() == Some(session_id) { @@ -1173,482 +845,6 @@ impl WorkspaceView { /// /// Reads JSONL files written by Claude Code, Codex, and Gemini CLIs and /// updates the cached session status on the UI thread. - fn spawn_background_jsonl_check(&mut self, cx: &mut Context) { - let has_any_reader = self - .cli_readers - .lock() - .map(|r| r.codex.is_some() || r.gemini.is_some()) - .unwrap_or(false); - if !has_any_reader - || self.polling.last_jsonl_check.elapsed() < Self::BACKGROUND_REFRESH_INTERVAL - || self.polling.jsonl_check_in_flight - { - return; - } - self.polling.last_jsonl_check = Instant::now(); - self.polling.jsonl_check_in_flight = true; - trace!("spawn_background_jsonl_check"); - - // Collect inputs for background JSONL check from the authoritative - // SessionManager snapshot so hook-updated Codex ids/modes are visible - // immediately to the JSONL matcher. - let manager_sessions = self.with_session_manager(|manager| manager.list_sessions()); - let jsonl_inputs: Vec = manager_sessions - .into_iter() - .filter_map(|session| { - let cli_type = self - .clipboard - .clipboard_service - .get_session_cli_type(session.id); - // ClaudeCode uses hook signals exclusively — skip JSONL collection - // to avoid unnecessary PID lookup and working dir copy. - if cli_type == codirigent_core::CliType::ClaudeCode { - return None; - } - let child_pid = - self.with_session_manager(|manager| manager.get_child_pid(session.id)); - let known_codex_session_id = session - .codex_session_id - .as_ref() - .filter(|id| *id != &session.id.0.to_string()) - .cloned(); - Some(JsonlCheckInput { - session_id: session.id, - working_dir: session.working_directory, - child_pid, - cli_type, - codex_session_id: known_codex_session_id, - codex_execution_mode: session.codex_execution_mode, - has_explicit_codex_started_at: session.codex_started_at.is_some(), - current_status: self - .workspace - .session(session.id) - .map(|s| s.status) - .unwrap_or(session.status), - created_at_millis: session - .codex_started_at - .unwrap_or(session.created_at) - .timestamp_millis(), - }) - }) - .collect(); - - let no_id_codex_counts = - count_codex_sessions_without_session_id_per_working_dir(&jsonl_inputs); - - let cli_readers = self.cli_readers.clone(); - let event_bus = self.event_bus.clone(); - let max_age = Self::GENERIC_SHELL_JSONL_MAX_AGE; - - cx.spawn(async move |this: gpui::WeakEntity, cx| { - // Background: perform JSONL reads (the expensive I/O) - let results = cx - .background_executor() - .spawn(async move { - let mut out: Vec = Vec::new(); - let mut detected_types: Vec<(SessionId, codirigent_core::CliType)> = Vec::new(); - if let Ok(mut readers) = cli_readers.lock() { - for input in &jsonl_inputs { - // For GenericShell sessions, try process-tree detection. - // The detector walks the PTY's child processes looking - // for known CLI binaries (claude, gemini, codex). - // Note: don't use process-tree to REVERT ClaudeCode → GenericShell - // because detection is unreliable (returns GenericShell even when - // Claude is running). Banner detection handles initial detection. - let effective_type = - if input.cli_type == codirigent_core::CliType::GenericShell { - if let Some(pid) = input.child_pid { - let detected = readers.detector.detect_cli_type(pid); - if detected != codirigent_core::CliType::GenericShell { - info!( - session_id = ?input.session_id, - ?detected, - "Process-tree detected CLI type" - ); - detected_types.push((input.session_id, detected)); - detected - } else { - input.cli_type - } - } else { - input.cli_type - } - } else { - input.cli_type - }; - - let ambiguous_codex_probe = effective_type == CliType::CodexCli - && should_defer_ambiguous_codex_probe(input, &no_id_codex_counts); - - let ( - cli_status, - detected_codex_session_id, - detected_codex_execution_mode, - ): ( - Option, - Option, - Option, - ) = match effective_type { - codirigent_core::CliType::ClaudeCode => { - // Claude Code status is handled by hook signal files - // (spawn_background_hook_signal_check) — no JSONL reader needed here. - (None, None, None) - } - codirigent_core::CliType::CodexCli => { - if ambiguous_codex_probe { - (None, None, None) - } else { - readers - .codex - .as_mut() - .and_then(|r| { - let created_after = (input.created_at_millis >= 0) - .then_some( - UNIX_EPOCH - + Duration::from_millis( - input.created_at_millis as u64, - ), - ); - r.get_status_snapshot_if_recent( - &input.working_dir, - input.codex_session_id.as_deref(), - input.child_pid, - max_age, - created_after, - input.codex_execution_mode, - ) - }) - .map(|snapshot| { - ( - Some(snapshot.status), - snapshot.session_id, - snapshot.execution_mode.or_else(|| { - snapshot.approval_mode.as_deref().and_then( - codex_execution_mode_from_rollout_mode, - ) - }), - ) - }) - .unwrap_or((None, None, None)) - } - } - codirigent_core::CliType::GeminiCli => ( - readers.gemini.as_mut().and_then(|r| { - r.get_status_if_recent( - &input.working_dir, - input.child_pid, - max_age, - ) - }), - None, - None, - ), - codirigent_core::CliType::GenericShell => (None, None, None), - }; - let resolved = cli_status.and_then(|s| s.to_session_status()); - out.push(JsonlCheckOutput { - session_id: input.session_id, - status: resolved, - codex_session_id: detected_codex_session_id, - codex_execution_mode: input - .codex_execution_mode - .or(detected_codex_execution_mode), - }); - } - } - (out, jsonl_inputs, detected_types) - }) - .await; - - // Marshal results back to UI thread - let _ = this.update(cx, |this, cx| { - this.polling.jsonl_check_in_flight = false; - let mut any_dirty = false; - let (results, inputs, detected_types) = results; - let input_statuses: HashMap = inputs - .iter() - .map(|input| (input.session_id, input.current_status)) - .collect(); - let input_modes: HashMap> = inputs - .iter() - .map(|input| (input.session_id, input.codex_execution_mode)) - .collect(); - let cache_update_time = Instant::now(); - let mut status_sync_ids = HashSet::new(); - let mut cached_status = this.cli_readers.lock().ok(); - let mut should_save_state = false; - let mut pending_mode_updates = Vec::new(); - - // Apply process-tree CLI type detections on UI thread - for (session_id, detected_type) in &detected_types { - this.clipboard - .clipboard_service - .set_session_cli_type(*session_id, *detected_type); - if *detected_type == CliType::CodexCli { - let started_at = chrono::Utc::now(); - let manager_changed = this - .session_manager - .lock() - .ok() - .and_then(|mgr| { - mgr.with_session_state_mut(*session_id, |state| { - if state.session.codex_started_at.is_none() { - state.session.codex_started_at = Some(started_at); - true - } else { - false - } - }) - }) - .unwrap_or(false); - let workspace_changed = this - .workspace - .session_mut(*session_id) - .map(|session| { - if session.codex_started_at.is_none() { - session.codex_started_at = Some(started_at); - true - } else { - false - } - }) - .unwrap_or(false); - should_save_state |= manager_changed || workspace_changed; - } - info!( - ?session_id, - ?detected_type, - "Applied process-tree CLI type detection" - ); - } - for result in &results { - let session_id = result.session_id; - if let Some(codex_session_id) = result.codex_session_id.as_deref() { - if !is_safe_cli_session_id(codex_session_id) { - warn!( - ?session_id, - codex_session_id, - "Ignoring unsafe Codex session ID from rollout polling" - ); - continue; - } - let mut updated = false; - if let Ok(mgr) = this.session_manager.lock() { - updated |= mgr - .with_session_state_mut(session_id, |state| { - if state.session.codex_session_id.as_deref() - != Some(codex_session_id) - { - state.session.codex_session_id = - Some(codex_session_id.to_owned()); - true - } else { - false - } - }) - .unwrap_or(false); - } - if let Some(session) = this.workspace.session_mut(session_id) { - if session.codex_session_id.as_deref() != Some(codex_session_id) { - session.codex_session_id = Some(codex_session_id.to_owned()); - updated = true; - } - } - should_save_state |= updated; - } - - if let Some(mode) = result.codex_execution_mode { - let current_mode = input_modes - .get(&result.session_id) - .copied() - .flatten() - .or_else(|| { - this.workspace - .session(result.session_id) - .and_then(|session| session.codex_execution_mode) - }); - if current_mode != Some(mode) { - pending_mode_updates.push((result.session_id, mode)); - } - } - - if let Some((new_status, tool_name)) = &result.status { - // Cache the JSONL result - if let Some(readers) = cached_status.as_mut() { - // Preserve status_since if status hasn't changed - let status_since = readers - .cached_status - .get(&result.session_id) - .filter(|c| c.status == *new_status) - .map(|c| c.status_since) - .unwrap_or(cache_update_time); - readers.cached_status.insert( - result.session_id, - CachedCliStatus { - status: *new_status, - seen_at: cache_update_time, - source: CliStatusSource::Jsonl, - status_since, - ttl: Self::GENERIC_SHELL_JSONL_CACHE_TTL, - }, - ); - } - // Fire AttentionRequired on transition - if *new_status == SessionStatus::NeedsAttention { - let current_status = input_statuses.get(&result.session_id).copied(); - if current_status != Some(SessionStatus::NeedsAttention) { - event_bus.publish(CodirigentEvent::AttentionRequired { - session_id: result.session_id, - detail: tool_name.clone(), - }); - let session_name = this - .workspace - .session(result.session_id) - .map(|s| s.name.clone()) - .unwrap_or_else(|| format!("Session {}", result.session_id.0)); - let (notif_type, detail) = match tool_name.as_deref() { - Some("question") | None => { - (NotificationType::InputRequired, None) - } - Some(tool) => (NotificationType::PermissionPrompt, Some(tool)), - }; - this.notification_manager.notify( - notif_type, - result.session_id, - &session_name, - detail, - ); - } - } - status_sync_ids.insert(result.session_id); - } else { - // No JSONL result — check if detector says idle and clear stale cache - let detector_idle = this.with_detector(|detector| { - matches!( - detector.get_status(result.session_id), - Some(SessionStatus::Idle) | None - ) - }); - if detector_idle { - if let Some(readers) = cached_status.as_mut() { - let is_stale = readers - .cached_status - .get(&result.session_id) - .map(|c| { - c.source == CliStatusSource::Jsonl - && c.seen_at.elapsed() > c.ttl - }) - .unwrap_or(false); - if is_stale { - readers.cached_status.remove(&result.session_id); - status_sync_ids.insert(result.session_id); - } - } - } - } - } - drop(cached_status); - for (session_id, mode) in pending_mode_updates { - this.set_session_codex_execution_mode(session_id, Some(mode), cx); - } - if should_save_state { - this.save_state_to_disk(cx); - } - for session_id in status_sync_ids { - any_dirty |= this.sync_session_status(session_id); - } - if any_dirty { - cx.notify(); - } - }); - }) - .detach(); - } - - /// Send deferred Enter keypresses and clean up phase-2 grace periods. - /// - /// Task input is split into two PTY writes (the prompt text, then `\r`) so - /// that the CLI treats them as separate stdin events. This helper runs the - /// two-phase timing logic: - /// - /// - Phase 1: send `\r` once 100 ms have elapsed since the text was sent. - /// - Phase 2: remove the entry after a 500 ms grace period so auto-assign - /// does not consider the session available while the CLI processes the command. - fn process_deferred_enters(&mut self) { - // Collect both phases in one pass to avoid iterating pending_enters twice. - let mut need_enter: Vec = Vec::new(); - let mut expired: Vec = Vec::new(); - for (&session_id, &(when, sent)) in &self.polling.pending_enters { - if !sent && when.elapsed() >= Self::PENDING_ENTER_DELAY { - need_enter.push(session_id); - } else if sent && when.elapsed() >= Duration::from_millis(500) { - expired.push(session_id); - } - } - for session_id in need_enter { - if let Ok(mgr) = self.session_manager.lock() { - let _ = mgr.send_input(session_id, b"\r"); - } - // Flip to phase 2: keep entry for a grace period so the CLI can - // process the command before auto-assign considers this session. - self.polling - .pending_enters - .insert(session_id, (Instant::now(), true)); - } - for session_id in expired { - self.polling.pending_enters.remove(&session_id); - } - } - - /// Drain VTE PtyWrite responses (DSR, DA1, etc.) and forward them to each PTY. - /// - /// This is critical: PowerShell blocks on DSR (`\x1b[6n]`) until it gets a - /// response. Failing to forward these makes PowerShell hang at its prompt. - fn drain_vte_responses(&mut self) { - for (sid, rx) in &mut self.pty_write_receivers { - let mut buf = Vec::with_capacity(64); - while let Ok(bytes) = rx.try_recv() { - buf.extend_from_slice(&bytes); - } - if !buf.is_empty() { - if let Ok(mgr) = self.session_manager.lock() { - if let Err(e) = mgr.send_input(*sid, &buf) { - warn!(?sid, error = %e, "Failed to forward VTE PtyWrite response"); - } - } - } - } - } - - /// End compaction for sessions that have exceeded the configured timeout. - fn cleanup_compaction_timeouts(&mut self) { - let timeout_secs = self - .persistence - .compaction - .lock() - .map(|svc| svc.timeout_secs()) - .unwrap_or(120); - let timed_out: Vec = self - .cache - .compaction_start_times - .iter() - .filter(|(_, start)| start.elapsed() > Duration::from_secs(timeout_secs)) - .map(|(id, _)| *id) - .collect(); - for session_id in timed_out { - if let Ok(mut svc) = self.persistence.compaction.lock() { - svc.end_compaction(session_id); - } - self.cache.compaction_start_times.remove(&session_id); - self.event_bus - .publish(CodirigentEvent::CompactionCompleted { - session_id, - success: false, - }); - warn!(?session_id, "Compaction timed out"); - } - } - /// Reject pending task assignments whose target session became busy, /// and expire proposals older than 5 minutes. fn cleanup_stale_proposals(&mut self) { @@ -1671,118 +867,6 @@ impl WorkspaceView { } } - /// Spawn a background git-status refresh for all sessions if the last - /// refresh was more than 3 seconds ago and no refresh is in-flight. - fn schedule_background_git_refresh(&mut self, cx: &mut Context) { - if self.polling.last_git_refresh.elapsed() < Self::BACKGROUND_REFRESH_INTERVAL - || self.polling.git_refresh_in_flight - { - return; - } - self.polling.last_git_refresh = Instant::now(); - self.polling.git_refresh_in_flight = true; - let session_ids: Vec = self.workspace.sessions().iter().map(|s| s.id).collect(); - let session_manager = self.session_manager.clone(); - - cx.spawn(async move |this: gpui::WeakEntity, cx| { - let git_infos = cx - .background_executor() - .spawn(async move { - let mgr = match session_manager.lock() { - Ok(m) => m, - Err(_) => return Vec::new(), - }; - session_ids - .iter() - .map(|id| (*id, mgr.refresh_git_status(*id))) - .collect::>() - }) - .await; - - let _ = this.update(cx, |this, cx| { - this.polling.git_refresh_in_flight = false; - let mut git_changed = false; - for (id, git_info) in &git_infos { - if let Some(header) = this.terminal_headers.get_mut(id) { - let branch = git_info.as_ref().map(|info| info.branch.clone()); - let dirty_count = git_info.as_ref().map(|info| info.dirty_count); - if header.git_branch != branch || header.git_dirty_count != dirty_count { - header.git_branch = branch; - header.git_dirty_count = dirty_count; - git_changed = true; - } - } - if let Some(session) = this.workspace.session_mut(*id) { - git_changed |= update_cached_session_git_info(session, git_info); - } - } - if git_changed { - cx.notify(); - } - }); - }) - .detach(); - } - - fn spawn_session_git_refresh( - &mut self, - session_id: SessionId, - expected_cwd: std::path::PathBuf, - cx: &mut Context, - ) { - let session_manager = self.session_manager.clone(); - - cx.spawn(async move |this: gpui::WeakEntity, cx| { - let expected_cwd_for_bg = expected_cwd.clone(); - let git_info = cx - .background_executor() - .spawn(async move { - let mgr = session_manager.lock().ok()?; - let session = mgr.get_session(session_id)?; - if session.working_directory != expected_cwd_for_bg { - return None; - } - Some(( - session_id, - expected_cwd_for_bg, - mgr.refresh_git_status_fresh(session_id), - )) - }) - .await; - - let _ = this.update(cx, |this, cx| { - let Some((session_id, expected_cwd, git_info)) = git_info else { - return; - }; - if !this - .workspace - .session(session_id) - .is_some_and(|session| session.working_directory == expected_cwd) - { - return; - } - - let branch = git_info.as_ref().map(|info| info.branch.clone()); - let dirty_count = git_info.as_ref().map(|info| info.dirty_count); - let mut changed = false; - if let Some(header) = this.terminal_headers.get_mut(&session_id) { - if header.git_branch != branch || header.git_dirty_count != dirty_count { - header.git_branch = branch.clone(); - header.git_dirty_count = dirty_count; - changed = true; - } - } - if let Some(session) = this.workspace.session_mut(session_id) { - changed |= update_cached_session_git_info(session, &git_info); - } - if changed { - cx.notify(); - } - }); - }) - .detach(); - } - /// Check the clipboard for new image content and start a background /// save/thumbnail if found. Auto-dismiss the preview after 4 seconds. /// @@ -1880,263 +964,6 @@ impl WorkspaceView { false } - /// Read hook signal files on a background thread and apply them on the UI thread. - fn spawn_background_hook_signal_check(&mut self, cx: &mut Context) { - if self.polling.last_hook_signal_check.elapsed() < Duration::from_secs(1) - || self.polling.hook_signal_check_in_flight - { - return; - } - - trace!("spawn_background_hook_signal_check"); - self.polling.last_hook_signal_check = Instant::now(); - self.polling.hook_signal_check_in_flight = true; - - cx.spawn(async move |this: gpui::WeakEntity, cx| { - let updates = cx - .background_executor() - .spawn(async move { read_recent_hook_signal_updates() }) - .await; - - let _ = this.update(cx, |this, cx| { - this.polling.hook_signal_check_in_flight = false; - for update in updates { - this.apply_hook_signal_update(update, cx); - } - }); - }) - .detach(); - } - - fn apply_hook_signal_update(&mut self, update: HookSignalUpdate, cx: &mut Context) { - let HookSignalUpdate { - session_id, - signal_file_id, - cli_session_id, - codex_execution_mode, - status, - cli_type, - ts, - } = update; - - let signal_fingerprint = hook_signal_fingerprint( - &status, - cli_type.as_deref(), - cli_session_id.as_deref(), - codex_execution_mode, - ); - let last_seen = self - .polling - .last_processed_hook_signal_ts - .get(&signal_file_id) - .copied(); - if !should_apply_hook_signal(last_seen, ts, signal_fingerprint) { - return; - } - self.polling.last_processed_hook_signal_ts.insert( - signal_file_id.clone(), - ProcessedHookSignal { - ts, - fingerprint: signal_fingerprint, - }, - ); - - let mut id_changed = false; - let cli_type_name = cli_type.as_deref().unwrap_or(CLI_TYPE_CLAUDE); - if let Some(cli_type) = cli_type_from_hook_signal_name(cli_type_name) { - self.clipboard - .clipboard_service - .set_session_cli_type(session_id, cli_type); - } - let resolved_cli_session_id = - resolve_hook_cli_session_id(&signal_file_id, cli_session_id.as_deref(), session_id); - if let Some(cli_session_id) = resolved_cli_session_id.as_deref() { - match cli_type_name { - CLI_TYPE_CLAUDE => { - id_changed = self - .session_manager - .lock() - .ok() - .and_then(|mgr| { - mgr.with_session_state_mut(session_id, |state| { - let changed = state.session.claude_session_id.as_deref() - != Some(cli_session_id); - state.session.claude_session_id = Some(cli_session_id.to_owned()); - changed - }) - }) - .unwrap_or(false); - } - CLI_TYPE_GEMINI => { - id_changed = self - .session_manager - .lock() - .ok() - .and_then(|mgr| { - mgr.with_session_state_mut(session_id, |state| { - let changed = state.session.gemini_session_id.as_deref() - != Some(cli_session_id); - state.session.gemini_session_id = Some(cli_session_id.to_owned()); - changed - }) - }) - .unwrap_or(false); - } - CLI_TYPE_CODEX => { - id_changed = self - .session_manager - .lock() - .ok() - .and_then(|mgr| { - mgr.with_session_state_mut(session_id, |state| { - let changed = state.session.codex_session_id.as_deref() - != Some(cli_session_id); - state.session.codex_session_id = Some(cli_session_id.to_owned()); - changed - }) - }) - .unwrap_or(false); - if let Some(session) = self.workspace.session_mut(session_id) { - if session.codex_session_id.as_deref() != Some(cli_session_id) { - session.codex_session_id = Some(cli_session_id.to_owned()); - id_changed = true; - } - if session.codex_started_at.is_none() { - session.codex_started_at = Some(chrono::Utc::now()); - id_changed = true; - } - } - } - _ => {} - } - } - - if cli_type_name == CLI_TYPE_CODEX { - if let Some(mode) = codex_execution_mode { - self.set_session_codex_execution_mode(session_id, Some(mode), cx); - } - let started_at = chrono::Utc::now(); - let manager_changed = self - .session_manager - .lock() - .ok() - .and_then(|mgr| { - mgr.with_session_state_mut(session_id, |state| { - if state.session.codex_started_at.is_none() { - state.session.codex_started_at = Some(started_at); - true - } else { - false - } - }) - }) - .unwrap_or(false); - let workspace_changed = self - .workspace - .session_mut(session_id) - .map(|session| { - if session.codex_started_at.is_none() { - session.codex_started_at = Some(started_at); - true - } else { - false - } - }) - .unwrap_or(false); - id_changed |= manager_changed || workspace_changed; - } - - if id_changed { - self.save_state_to_disk(cx); - } - - let focused_id = self.workspace.focused_session_id(); - let is_focused = Some(session_id) == focused_id; - let prev_status = self.workspace.session(session_id).map(|s| s.status); - let new_status = match status.as_str() { - "working" => SessionStatus::Working, - "needs_attention" => SessionStatus::NeedsAttention, - "response_ready" => { - if is_focused { - SessionStatus::Idle - } else { - SessionStatus::ResponseReady - } - } - // "idle" signal from the CLI (e.g. idle_prompt notification). - // If the session was previously ResponseReady and is unfocused, - // keep ResponseReady — the user hasn't read the response yet. - _ => { - if !is_focused && prev_status == Some(SessionStatus::ResponseReady) { - SessionStatus::ResponseReady - } else { - SessionStatus::Idle - } - } - }; - - if let Ok(mut readers) = self.cli_readers.lock() { - let status_since = readers - .cached_status - .get(&session_id) - .filter(|c| c.status == new_status) - .map(|c| c.status_since) - .unwrap_or_else(Instant::now); - readers.cached_status.insert( - session_id, - CachedCliStatus { - status: new_status, - seen_at: Instant::now(), - source: CliStatusSource::Hook, - status_since, - ttl: Self::HOOK_SIGNAL_CACHE_TTL, - }, - ); - } - - let prev_status_for_notif = prev_status.unwrap_or(SessionStatus::Idle); - - if new_status == SessionStatus::NeedsAttention - && prev_status_for_notif != SessionStatus::NeedsAttention - { - self.event_bus.publish(CodirigentEvent::AttentionRequired { - session_id, - detail: None, - }); - let name = self - .workspace - .session(session_id) - .map(|s| s.name.clone()) - .unwrap_or_else(|| format!("Session {}", session_id.0)); - self.notification_manager.notify( - NotificationType::InputRequired, - session_id, - &name, - None, - ); - } - - if new_status == SessionStatus::ResponseReady - && prev_status_for_notif == SessionStatus::Working - { - let name = self - .workspace - .session(session_id) - .map(|s| s.name.clone()) - .unwrap_or_else(|| format!("Session {}", session_id.0)); - self.notification_manager.notify( - NotificationType::ResponseReady, - session_id, - &name, - None, - ); - } - - if self.sync_session_status(session_id) { - cx.notify(); - } - } - /// Try to compact a session before verification. /// Returns true if compaction was started, false if skipped. fn try_compact(&mut self, session_id: SessionId) -> bool { diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/cli_pollers.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/cli_pollers.rs index 086035f..83e5121 100644 --- a/crates/codirigent-ui/src/workspace/impl_output_polling/cli_pollers.rs +++ b/crates/codirigent-ui/src/workspace/impl_output_polling/cli_pollers.rs @@ -1,8 +1,536 @@ -//! Future home for background JSONL and rollout polling helpers. -//! -//! Expected move targets in Phase B: -//! - JSONL reader entry points -//! - rollout-mode metadata readers -//! - cached CLI status apply helpers -//! -//! Phase A scaffolding only. Logic remains in the root module until Phase B. +//! Background JSONL and rollout polling helpers. + +use super::super::cli_helpers::is_safe_cli_session_id; +use super::super::types::{CachedCliStatus, CliStatusSource}; +use super::WorkspaceView; +use codirigent_core::{ + CliType, CodexExecutionMode, CodirigentEvent, EventBus, ProcessMonitor, SessionId, + SessionManager, SessionStatus, +}; +use codirigent_detector::NotificationType; +use codirigent_session::cli_detector::CliDetector; +use codirigent_session::clipboard_service::ClipboardService; +use codirigent_session::CliSessionStatus; +use gpui::Context; +use std::collections::{HashMap, HashSet}; +use std::time::{Duration, Instant, UNIX_EPOCH}; +use tracing::{info, trace, warn}; + +/// Session status result from a background JSONL read: (status, optional detail string). +type JsonlStatusResult = Option<(SessionStatus, Option)>; + +#[derive(Debug, Clone)] +struct JsonlCheckInput { + session_id: SessionId, + working_dir: std::path::PathBuf, + child_pid: Option, + cli_type: CliType, + codex_session_id: Option, + codex_execution_mode: Option, + has_explicit_codex_started_at: bool, + current_status: SessionStatus, + created_at_millis: i64, +} + +#[derive(Debug)] +struct JsonlCheckOutput { + session_id: SessionId, + status: JsonlStatusResult, + codex_session_id: Option, + codex_execution_mode: Option, +} + +fn codex_execution_mode_from_rollout_mode(mode: &str) -> Option { + if mode.eq_ignore_ascii_case("yolo") + || mode.eq_ignore_ascii_case("bypass") + || mode.eq_ignore_ascii_case("dangerously-bypass-approvals-and-sandbox") + || mode.eq_ignore_ascii_case("dangerously_bypass_approvals_and_sandbox") + { + Some(CodexExecutionMode::Bypass) + } else if mode.eq_ignore_ascii_case("full-auto") + || mode.eq_ignore_ascii_case("full_auto") + || mode.eq_ignore_ascii_case("fullauto") + { + Some(CodexExecutionMode::FullAuto) + } else { + None + } +} + +fn count_codex_sessions_without_session_id_per_working_dir( + inputs: &[JsonlCheckInput], +) -> HashMap { + inputs + .iter() + .filter(|input| input.cli_type == CliType::CodexCli && input.codex_session_id.is_none()) + .fold(HashMap::new(), |mut counts, input| { + *counts.entry(input.working_dir.clone()).or_default() += 1; + counts + }) +} + +fn should_defer_ambiguous_codex_probe( + input: &JsonlCheckInput, + no_id_codex_counts: &HashMap, +) -> bool { + input.cli_type == CliType::CodexCli + && input.codex_session_id.is_none() + && !input.has_explicit_codex_started_at + && no_id_codex_counts + .get(&input.working_dir) + .copied() + .unwrap_or_default() + > 1 +} + +impl WorkspaceView { + pub(super) fn spawn_background_jsonl_check(&mut self, cx: &mut Context) { + let has_any_reader = self + .cli_readers + .lock() + .map(|r| r.codex.is_some() || r.gemini.is_some()) + .unwrap_or(false); + if !has_any_reader + || self.polling.last_jsonl_check.elapsed() < Self::BACKGROUND_REFRESH_INTERVAL + || self.polling.jsonl_check_in_flight + { + return; + } + self.polling.last_jsonl_check = Instant::now(); + self.polling.jsonl_check_in_flight = true; + trace!("spawn_background_jsonl_check"); + + // Collect inputs for background JSONL check from the authoritative + // SessionManager snapshot so hook-updated Codex ids/modes are visible + // immediately to the JSONL matcher. + let manager_sessions = self.with_session_manager(|manager| manager.list_sessions()); + let jsonl_inputs: Vec = manager_sessions + .into_iter() + .filter_map(|session| { + let cli_type = self + .clipboard + .clipboard_service + .get_session_cli_type(session.id); + // ClaudeCode uses hook signals exclusively; skip JSONL collection + // to avoid unnecessary PID lookup and working dir copy. + if cli_type == CliType::ClaudeCode { + return None; + } + let child_pid = + self.with_session_manager(|manager| manager.get_child_pid(session.id)); + let known_codex_session_id = session + .codex_session_id + .as_ref() + .filter(|id| *id != &session.id.0.to_string()) + .cloned(); + Some(JsonlCheckInput { + session_id: session.id, + working_dir: session.working_directory, + child_pid, + cli_type, + codex_session_id: known_codex_session_id, + codex_execution_mode: session.codex_execution_mode, + has_explicit_codex_started_at: session.codex_started_at.is_some(), + current_status: self + .workspace + .session(session.id) + .map(|s| s.status) + .unwrap_or(session.status), + created_at_millis: session + .codex_started_at + .unwrap_or(session.created_at) + .timestamp_millis(), + }) + }) + .collect(); + + let no_id_codex_counts = + count_codex_sessions_without_session_id_per_working_dir(&jsonl_inputs); + + let cli_readers = self.cli_readers.clone(); + let event_bus = self.event_bus.clone(); + let max_age = Self::GENERIC_SHELL_JSONL_MAX_AGE; + + cx.spawn(async move |this: gpui::WeakEntity, cx| { + // Background: perform JSONL reads (the expensive I/O) + let results: ( + Vec, + Vec, + Vec<(SessionId, CliType)>, + ) = cx + .background_executor() + .spawn(async move { + let mut out: Vec = Vec::new(); + let mut detected_types: Vec<(SessionId, CliType)> = Vec::new(); + if let Ok(mut readers) = cli_readers.lock() { + for input in &jsonl_inputs { + // For GenericShell sessions, try process-tree detection. + // The detector walks the PTY's child processes looking + // for known CLI binaries (claude, gemini, codex). + // Note: don't use process-tree to REVERT ClaudeCode -> GenericShell + // because detection is unreliable (returns GenericShell even when + // Claude is running). Banner detection handles initial detection. + let effective_type = if input.cli_type == CliType::GenericShell { + if let Some(pid) = input.child_pid { + let detected = readers.detector.detect_cli_type(pid); + if detected != CliType::GenericShell { + info!( + session_id = ?input.session_id, + ?detected, + "Process-tree detected CLI type" + ); + detected_types.push((input.session_id, detected)); + detected + } else { + input.cli_type + } + } else { + input.cli_type + } + } else { + input.cli_type + }; + + let ambiguous_codex_probe = effective_type == CliType::CodexCli + && should_defer_ambiguous_codex_probe(input, &no_id_codex_counts); + + let ( + cli_status, + detected_codex_session_id, + detected_codex_execution_mode, + ): ( + Option, + Option, + Option, + ) = match effective_type { + CliType::ClaudeCode => { + // Claude Code status is handled by hook signal files + // (spawn_background_hook_signal_check); no JSONL reader needed here. + (None, None, None) + } + CliType::CodexCli => { + if ambiguous_codex_probe { + (None, None, None) + } else { + readers + .codex + .as_mut() + .and_then(|r| { + let created_after = (input.created_at_millis >= 0) + .then_some( + UNIX_EPOCH + + Duration::from_millis( + input.created_at_millis as u64, + ), + ); + r.get_status_snapshot_if_recent( + &input.working_dir, + input.codex_session_id.as_deref(), + input.child_pid, + max_age, + created_after, + input.codex_execution_mode, + ) + }) + .map(|snapshot| { + ( + Some(snapshot.status), + snapshot.session_id, + snapshot.execution_mode.or_else(|| { + snapshot.approval_mode.as_deref().and_then( + codex_execution_mode_from_rollout_mode, + ) + }), + ) + }) + .unwrap_or((None, None, None)) + } + } + CliType::GeminiCli => ( + readers.gemini.as_mut().and_then(|r| { + r.get_status_if_recent( + &input.working_dir, + input.child_pid, + max_age, + ) + }), + None, + None, + ), + CliType::GenericShell => (None, None, None), + }; + let resolved = cli_status.and_then(|s| s.to_session_status()); + out.push(JsonlCheckOutput { + session_id: input.session_id, + status: resolved, + codex_session_id: detected_codex_session_id, + codex_execution_mode: input + .codex_execution_mode + .or(detected_codex_execution_mode), + }); + } + } + (out, jsonl_inputs, detected_types) + }) + .await; + + // Marshal results back to UI thread + let _ = this.update(cx, |this, cx| { + this.polling.jsonl_check_in_flight = false; + let mut any_dirty = false; + let (results, inputs, detected_types) = results; + let input_statuses: HashMap = inputs + .iter() + .map(|input| (input.session_id, input.current_status)) + .collect(); + let input_modes: HashMap> = inputs + .iter() + .map(|input| (input.session_id, input.codex_execution_mode)) + .collect(); + let cache_update_time = Instant::now(); + let mut status_sync_ids = HashSet::new(); + let mut cached_status = this.cli_readers.lock().ok(); + let mut should_save_state = false; + let mut pending_mode_updates = Vec::new(); + + // Apply process-tree CLI type detections on UI thread + for (session_id, detected_type) in &detected_types { + this.clipboard + .clipboard_service + .set_session_cli_type(*session_id, *detected_type); + if *detected_type == CliType::CodexCli { + let started_at = chrono::Utc::now(); + let manager_changed = this + .session_manager + .lock() + .ok() + .and_then(|mgr| { + mgr.with_session_state_mut(*session_id, |state| { + if state.session.codex_started_at.is_none() { + state.session.codex_started_at = Some(started_at); + true + } else { + false + } + }) + }) + .unwrap_or(false); + let workspace_changed = this + .workspace + .session_mut(*session_id) + .map(|session| { + if session.codex_started_at.is_none() { + session.codex_started_at = Some(started_at); + true + } else { + false + } + }) + .unwrap_or(false); + should_save_state |= manager_changed || workspace_changed; + } + info!( + ?session_id, + ?detected_type, + "Applied process-tree CLI type detection" + ); + } + for result in &results { + let session_id = result.session_id; + if let Some(codex_session_id) = result.codex_session_id.as_deref() { + if !is_safe_cli_session_id(codex_session_id) { + warn!( + ?session_id, + codex_session_id, + "Ignoring unsafe Codex session ID from rollout polling" + ); + continue; + } + let mut updated = false; + if let Ok(mgr) = this.session_manager.lock() { + updated |= mgr + .with_session_state_mut(session_id, |state| { + if state.session.codex_session_id.as_deref() + != Some(codex_session_id) + { + state.session.codex_session_id = + Some(codex_session_id.to_owned()); + true + } else { + false + } + }) + .unwrap_or(false); + } + if let Some(session) = this.workspace.session_mut(session_id) { + if session.codex_session_id.as_deref() != Some(codex_session_id) { + session.codex_session_id = Some(codex_session_id.to_owned()); + updated = true; + } + } + should_save_state |= updated; + } + + if let Some(mode) = result.codex_execution_mode { + let current_mode = input_modes + .get(&result.session_id) + .copied() + .flatten() + .or_else(|| { + this.workspace + .session(result.session_id) + .and_then(|session| session.codex_execution_mode) + }); + if current_mode != Some(mode) { + pending_mode_updates.push((result.session_id, mode)); + } + } + + if let Some((new_status, tool_name)) = &result.status { + if let Some(readers) = cached_status.as_mut() { + let status_since = readers + .cached_status + .get(&result.session_id) + .filter(|c| c.status == *new_status) + .map(|c| c.status_since) + .unwrap_or(cache_update_time); + readers.cached_status.insert( + result.session_id, + CachedCliStatus { + status: *new_status, + seen_at: cache_update_time, + source: CliStatusSource::Jsonl, + status_since, + ttl: Self::GENERIC_SHELL_JSONL_CACHE_TTL, + }, + ); + } + if *new_status == SessionStatus::NeedsAttention { + let current_status = input_statuses.get(&result.session_id).copied(); + if current_status != Some(SessionStatus::NeedsAttention) { + event_bus.publish(CodirigentEvent::AttentionRequired { + session_id: result.session_id, + detail: tool_name.clone(), + }); + let session_name = this + .workspace + .session(result.session_id) + .map(|s| s.name.clone()) + .unwrap_or_else(|| format!("Session {}", result.session_id.0)); + let (notif_type, detail) = match tool_name.as_deref() { + Some("question") | None => { + (NotificationType::InputRequired, None) + } + Some(tool) => (NotificationType::PermissionPrompt, Some(tool)), + }; + this.notification_manager.notify( + notif_type, + result.session_id, + &session_name, + detail, + ); + } + } + status_sync_ids.insert(result.session_id); + } else { + let detector_idle = this.with_detector(|detector| { + matches!( + detector.get_status(result.session_id), + Some(SessionStatus::Idle) | None + ) + }); + if detector_idle { + if let Some(readers) = cached_status.as_mut() { + let is_stale = readers + .cached_status + .get(&result.session_id) + .map(|c| { + c.source == CliStatusSource::Jsonl + && c.seen_at.elapsed() > c.ttl + }) + .unwrap_or(false); + if is_stale { + readers.cached_status.remove(&result.session_id); + status_sync_ids.insert(result.session_id); + } + } + } + } + } + drop(cached_status); + for (session_id, mode) in pending_mode_updates { + this.set_session_codex_execution_mode(session_id, Some(mode), cx); + } + if should_save_state { + this.save_state_to_disk(cx); + } + for session_id in status_sync_ids { + any_dirty |= this.sync_session_status(session_id); + } + if any_dirty { + cx.notify(); + } + }); + }) + .detach(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn codex_input( + session_id: u64, + working_dir: &str, + has_explicit_codex_started_at: bool, + ) -> JsonlCheckInput { + JsonlCheckInput { + session_id: SessionId(session_id), + working_dir: std::path::PathBuf::from(working_dir), + child_pid: None, + cli_type: CliType::CodexCli, + codex_session_id: None, + codex_execution_mode: None, + has_explicit_codex_started_at, + current_status: SessionStatus::Idle, + created_at_millis: 0, + } + } + + #[test] + fn ambiguous_codex_probe_is_deferred_without_explicit_start_time() { + let inputs = vec![ + codex_input(1, "C:/repo", false), + codex_input(2, "C:/repo", false), + ]; + let counts = count_codex_sessions_without_session_id_per_working_dir(&inputs); + + assert!(should_defer_ambiguous_codex_probe(&inputs[0], &counts)); + assert!(should_defer_ambiguous_codex_probe(&inputs[1], &counts)); + } + + #[test] + fn ambiguous_codex_probe_uses_timestamp_when_start_time_is_known() { + let inputs = vec![ + codex_input(1, "C:/repo", true), + codex_input(2, "C:/repo", true), + ]; + let counts = count_codex_sessions_without_session_id_per_working_dir(&inputs); + + assert!(!should_defer_ambiguous_codex_probe(&inputs[0], &counts)); + assert!(!should_defer_ambiguous_codex_probe(&inputs[1], &counts)); + } + + #[test] + fn ambiguous_codex_probe_only_defers_session_missing_start_time() { + let inputs = vec![ + codex_input(1, "C:/repo", true), + codex_input(2, "C:/repo", false), + ]; + let counts = count_codex_sessions_without_session_id_per_working_dir(&inputs); + + assert!(!should_defer_ambiguous_codex_probe(&inputs[0], &counts)); + assert!(should_defer_ambiguous_codex_probe(&inputs[1], &counts)); + } +} diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/git_refresh.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/git_refresh.rs index d7f75a0..8d7bb70 100644 --- a/crates/codirigent-ui/src/workspace/impl_output_polling/git_refresh.rs +++ b/crates/codirigent-ui/src/workspace/impl_output_polling/git_refresh.rs @@ -4,5 +4,205 @@ //! - bulk git refresh scheduling //! - per-session git refresh follow-up //! - git-info apply helpers -//! -//! Phase A scaffolding only. Logic remains in the root module until Phase B. + +use super::WorkspaceView; +use codirigent_core::{GitRepoInfo, Session, SessionId, SessionManager}; +use gpui::Context; +use std::path::PathBuf; +use std::time::Instant; + +fn update_cached_session_git_info(session: &mut Session, git_info: &Option) -> bool { + if session.git_info == *git_info { + return false; + } + + session.git_info = git_info.clone(); + true +} + +pub(super) fn apply_cwd_session_update_from_manager( + workspace_session: &mut Session, + manager_session: &Session, +) { + workspace_session.working_directory = manager_session.working_directory.clone(); + workspace_session.group = manager_session.group.clone(); + workspace_session.color = manager_session.color.clone(); + workspace_session.git_info = None; +} + +impl WorkspaceView { + /// Spawn a background git-status refresh for all sessions if the last + /// refresh was more than 3 seconds ago and no refresh is in-flight. + pub(super) fn schedule_background_git_refresh(&mut self, cx: &mut Context) { + if self.polling.last_git_refresh.elapsed() < Self::BACKGROUND_REFRESH_INTERVAL + || self.polling.git_refresh_in_flight + { + return; + } + self.polling.last_git_refresh = Instant::now(); + self.polling.git_refresh_in_flight = true; + let session_ids: Vec = self.workspace.sessions().iter().map(|s| s.id).collect(); + let session_manager = self.session_manager.clone(); + + cx.spawn(async move |this: gpui::WeakEntity, cx| { + let git_infos = cx + .background_executor() + .spawn(async move { + let mgr = match session_manager.lock() { + Ok(m) => m, + Err(_) => return Vec::new(), + }; + session_ids + .iter() + .map(|id| (*id, mgr.refresh_git_status(*id))) + .collect::>() + }) + .await; + + let _ = this.update(cx, |this, cx| { + this.polling.git_refresh_in_flight = false; + let mut git_changed = false; + for (id, git_info) in &git_infos { + if let Some(header) = this.terminal_headers.get_mut(id) { + let branch = git_info.as_ref().map(|info| info.branch.clone()); + let dirty_count = git_info.as_ref().map(|info| info.dirty_count); + if header.git_branch != branch || header.git_dirty_count != dirty_count { + header.git_branch = branch; + header.git_dirty_count = dirty_count; + git_changed = true; + } + } + if let Some(session) = this.workspace.session_mut(*id) { + git_changed |= update_cached_session_git_info(session, git_info); + } + } + if git_changed { + cx.notify(); + } + }); + }) + .detach(); + } + + pub(super) fn spawn_session_git_refresh( + &mut self, + session_id: SessionId, + expected_cwd: PathBuf, + cx: &mut Context, + ) { + let session_manager = self.session_manager.clone(); + + cx.spawn(async move |this: gpui::WeakEntity, cx| { + let expected_cwd_for_bg = expected_cwd.clone(); + let git_info = cx + .background_executor() + .spawn(async move { + let mgr = session_manager.lock().ok()?; + let session = mgr.get_session(session_id)?; + if session.working_directory != expected_cwd_for_bg { + return None; + } + Some(( + session_id, + expected_cwd_for_bg, + mgr.refresh_git_status_fresh(session_id), + )) + }) + .await; + + let _ = this.update(cx, |this, cx| { + let Some((session_id, expected_cwd, git_info)) = git_info else { + return; + }; + if !this + .workspace + .session(session_id) + .is_some_and(|session| session.working_directory == expected_cwd) + { + return; + } + + let branch = git_info.as_ref().map(|info| info.branch.clone()); + let dirty_count = git_info.as_ref().map(|info| info.dirty_count); + let mut changed = false; + if let Some(header) = this.terminal_headers.get_mut(&session_id) { + if header.git_branch != branch || header.git_dirty_count != dirty_count { + header.git_branch = branch.clone(); + header.git_dirty_count = dirty_count; + changed = true; + } + } + if let Some(session) = this.workspace.session_mut(session_id) { + changed |= update_cached_session_git_info(session, &git_info); + } + if changed { + cx.notify(); + } + }); + }) + .detach(); + } +} + +#[cfg(test)] +mod tests { + use super::{apply_cwd_session_update_from_manager, update_cached_session_git_info}; + use codirigent_core::{GitRepoInfo, Session, SessionId}; + use std::path::PathBuf; + + fn temp_fixture_path(name: &str) -> PathBuf { + std::env::temp_dir().join(name) + } + + #[test] + fn git_refresh_updates_git_info_without_overwriting_custom_group() { + let project_path = temp_fixture_path("project"); + let mut session = Session::new(SessionId(1), "Session 1".to_string(), project_path.clone()); + session.group = Some("custom-group".to_string()); + session.color = Some("#f43f5e".to_string()); + + let git_info = Some(GitRepoInfo { + repo_root: project_path, + branch: "feature/custom-group".to_string(), + dirty_count: 2, + has_staged: false, + head_sha: Some("deadbeef".to_string()), + unstaged_files: Vec::new(), + staged_files: Vec::new(), + }); + + assert!(update_cached_session_git_info(&mut session, &git_info)); + assert_eq!(session.group.as_deref(), Some("custom-group")); + assert_eq!(session.color.as_deref(), Some("#f43f5e")); + assert_eq!(session.git_info, git_info); + } + + #[test] + fn cwd_session_update_preserves_custom_group_from_manager() { + let project_path = temp_fixture_path("project"); + let other_project_path = temp_fixture_path("other-project"); + let mut workspace_session = + Session::new(SessionId(1), "Session 1".to_string(), project_path.clone()); + workspace_session.group = Some("custom-group".to_string()); + workspace_session.color = Some("#f43f5e".to_string()); + workspace_session.git_info = Some(GitRepoInfo { + repo_root: project_path, + branch: "main".to_string(), + dirty_count: 1, + has_staged: false, + head_sha: Some("deadbeef".to_string()), + unstaged_files: Vec::new(), + staged_files: Vec::new(), + }); + + let mut manager_session = workspace_session.clone(); + manager_session.working_directory = other_project_path.clone(); + + apply_cwd_session_update_from_manager(&mut workspace_session, &manager_session); + + assert_eq!(workspace_session.working_directory, other_project_path); + assert_eq!(workspace_session.group.as_deref(), Some("custom-group")); + assert_eq!(workspace_session.color.as_deref(), Some("#f43f5e")); + assert!(workspace_session.git_info.is_none()); + } +} diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/hook_signals.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/hook_signals.rs index f74addd..3a22de0 100644 --- a/crates/codirigent-ui/src/workspace/impl_output_polling/hook_signals.rs +++ b/crates/codirigent-ui/src/workspace/impl_output_polling/hook_signals.rs @@ -1,8 +1,705 @@ -//! Future home for hook-signal scanning and apply helpers. -//! -//! Expected move targets in Phase B: -//! - run-epoch helpers -//! - hook-signal file scanning -//! - hook-signal apply helpers -//! -//! Phase A scaffolding only. Logic remains in the root module until Phase B. +//! Hook-signal scanning and apply helpers for `impl_output_polling`. + +use super::super::cli_helpers::is_safe_cli_session_id; +use super::super::types::{CachedCliStatus, CliStatusSource, ProcessedHookSignal}; +use super::WorkspaceView; +use codirigent_core::{ + hook_signals_dir, CliType, CodexExecutionMode, CodirigentEvent, EventBus, SessionId, + SessionStatus, +}; +use codirigent_detector::NotificationType; +use codirigent_session::clipboard_service::ClipboardService; +use gpui::Context; +use serde::Deserialize; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tracing::{trace, warn}; + +const CLI_TYPE_CLAUDE: &str = "claude"; +const CLI_TYPE_GEMINI: &str = "gemini"; +const CLI_TYPE_CODEX: &str = "codex"; + +/// Unix timestamp (seconds) recorded at process startup, acting as a +/// per-process "run epoch". Hook signals written before this moment belong to +/// a previous Codirigent run and must be ignored, regardless of the 600-second +/// recency window, to prevent stale signals from routing to re-used session IDs. +static APP_START_TS: std::sync::OnceLock = std::sync::OnceLock::new(); + +fn app_start_ts() -> u64 { + *APP_START_TS.get_or_init(|| { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + }) +} + +/// Eagerly initialize the hook-signal run epoch. +/// +/// Must be called early in startup (e.g., `WorkspaceView::new`) so that +/// hook signals emitted between app launch and the first scan are not +/// incorrectly filtered as belonging to a previous run. +pub(super) fn init_app_start_ts() { + let _ = app_start_ts(); +} + +fn cli_type_from_hook_signal_name(cli_type_name: &str) -> Option { + match cli_type_name { + CLI_TYPE_CLAUDE => Some(CliType::ClaudeCode), + CLI_TYPE_GEMINI => Some(CliType::GeminiCli), + CLI_TYPE_CODEX => Some(CliType::CodexCli), + _ => None, + } +} + +/// Signal file written by `codirigent-hook` for each hook event. +#[derive(Deserialize)] +struct HookSignal { + status: String, + cli_type: Option, + #[serde(default)] + cli_session_id: Option, + #[serde(default)] + approval_policy: Option, + #[serde(default)] + sandbox_policy_type: Option, + /// Codirigent session ID, present only when Claude Code was spawned by Codirigent + /// (via the `CODIRIGENT_SESSION_ID` environment variable). + codirigent_session_id: Option, + ts: u64, +} + +#[derive(Debug)] +struct HookSignalUpdate { + session_id: SessionId, + signal_file_id: String, + cli_session_id: Option, + codex_execution_mode: Option, + status: String, + cli_type: Option, + ts: u64, +} + +fn codex_execution_mode_fingerprint(mode: Option) -> Option<&'static str> { + match mode { + Some(CodexExecutionMode::FullAuto) => Some("full-auto"), + Some(CodexExecutionMode::Bypass) => Some("bypass"), + None => None, + } +} + +fn hook_signal_fingerprint( + status: &str, + cli_type: Option<&str>, + cli_session_id: Option<&str>, + codex_execution_mode: Option, +) -> u64 { + let mut hasher = DefaultHasher::new(); + status.hash(&mut hasher); + cli_type.hash(&mut hasher); + cli_session_id.hash(&mut hasher); + codex_execution_mode_fingerprint(codex_execution_mode).hash(&mut hasher); + hasher.finish() +} + +fn should_apply_hook_signal( + last_seen: Option, + signal_ts: u64, + signal_fingerprint: u64, +) -> bool { + match last_seen { + Some(last_seen) if signal_ts < last_seen.ts => false, + Some(last_seen) + if signal_ts == last_seen.ts && signal_fingerprint == last_seen.fingerprint => + { + false + } + _ => true, + } +} + +fn resolve_hook_cli_session_id( + signal_file_id: &str, + explicit_cli_session_id: Option<&str>, + session_id: SessionId, +) -> Option { + if let Some(explicit_id) = explicit_cli_session_id + .map(str::trim) + .filter(|id| !id.is_empty()) + { + if is_safe_cli_session_id(explicit_id) { + return Some(explicit_id.to_owned()); + } + warn!( + session_id = session_id.0, + signal_file_id, + cli_session_id = %explicit_id, + "Ignoring unsafe CLI session ID from hook signal" + ); + return None; + } + + let fallback = signal_file_id.trim(); + if fallback.is_empty() || fallback == session_id.0.to_string() { + return None; + } + if !is_safe_cli_session_id(fallback) { + warn!( + session_id = session_id.0, + signal_file_id = fallback, + "Ignoring unsafe fallback CLI session ID from hook signal filename" + ); + return None; + } + + Some(fallback.to_owned()) +} + +fn codex_execution_mode_from_approval_and_sandbox( + approval_policy: Option<&str>, + sandbox_policy_type: Option<&str>, +) -> Option { + if !approval_policy.is_some_and(|value| value.eq_ignore_ascii_case("never")) { + return None; + } + + match sandbox_policy_type { + Some(value) if value.eq_ignore_ascii_case("danger-full-access") => { + Some(CodexExecutionMode::Bypass) + } + Some(value) + if value.eq_ignore_ascii_case("workspace-write") + || value.eq_ignore_ascii_case("workspace_write") => + { + Some(CodexExecutionMode::FullAuto) + } + _ => None, + } +} + +fn read_recent_hook_signal_updates() -> Vec { + let signals_dir = match hook_signals_dir() { + Some(d) => d, + None => return Vec::new(), + }; + + let entries = match std::fs::read_dir(&signals_dir) { + Ok(e) => e, + Err(_) => return Vec::new(), + }; + + let now_ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let mut updates = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + + let signal_file_id = match path.file_stem().and_then(|s| s.to_str()) { + Some(s) => s.to_owned(), + None => continue, + }; + + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => continue, + }; + + let signal: HookSignal = match serde_json::from_str(&content) { + Ok(s) => s, + Err(_) => continue, + }; + + if now_ts.saturating_sub(signal.ts) > 600 { + continue; + } + + // Reject signals written before this process started. Session IDs + // (1, 2, 3 ...) reset on every restart, so a signal from a previous run + // that shares an ID with a newly-created session would route to the + // wrong session and corrupt its claude_session_id. + if signal.ts < app_start_ts() { + continue; + } + + let session_id = match signal + .codirigent_session_id + .as_deref() + .and_then(|id| id.parse::().ok()) + { + Some(id) => SessionId(id), + None => continue, + }; + + updates.push(HookSignalUpdate { + session_id, + signal_file_id, + cli_session_id: signal.cli_session_id, + codex_execution_mode: codex_execution_mode_from_approval_and_sandbox( + signal.approval_policy.as_deref(), + signal.sandbox_policy_type.as_deref(), + ), + status: signal.status, + cli_type: signal.cli_type, + ts: signal.ts, + }); + } + + updates +} + +impl WorkspaceView { + /// Read hook signal files on a background thread and apply them on the UI thread. + pub(super) fn spawn_background_hook_signal_check(&mut self, cx: &mut Context) { + if self.polling.last_hook_signal_check.elapsed() < Duration::from_secs(1) + || self.polling.hook_signal_check_in_flight + { + return; + } + + trace!("spawn_background_hook_signal_check"); + self.polling.last_hook_signal_check = Instant::now(); + self.polling.hook_signal_check_in_flight = true; + + cx.spawn(async move |this: gpui::WeakEntity, cx| { + let updates = cx + .background_executor() + .spawn(async move { read_recent_hook_signal_updates() }) + .await; + + let _ = this.update(cx, |this, cx| { + this.polling.hook_signal_check_in_flight = false; + for update in updates { + this.apply_hook_signal_update(update, cx); + } + }); + }) + .detach(); + } + + fn apply_hook_signal_update(&mut self, update: HookSignalUpdate, cx: &mut Context) { + let HookSignalUpdate { + session_id, + signal_file_id, + cli_session_id, + codex_execution_mode, + status, + cli_type, + ts, + } = update; + + let signal_fingerprint = hook_signal_fingerprint( + &status, + cli_type.as_deref(), + cli_session_id.as_deref(), + codex_execution_mode, + ); + let last_seen = self + .polling + .last_processed_hook_signal_ts + .get(&signal_file_id) + .copied(); + if !should_apply_hook_signal(last_seen, ts, signal_fingerprint) { + return; + } + self.polling.last_processed_hook_signal_ts.insert( + signal_file_id.clone(), + ProcessedHookSignal { + ts, + fingerprint: signal_fingerprint, + }, + ); + + let mut id_changed = false; + let cli_type_name = cli_type.as_deref().unwrap_or(CLI_TYPE_CLAUDE); + if let Some(cli_type) = cli_type_from_hook_signal_name(cli_type_name) { + self.clipboard + .clipboard_service + .set_session_cli_type(session_id, cli_type); + } + let resolved_cli_session_id = + resolve_hook_cli_session_id(&signal_file_id, cli_session_id.as_deref(), session_id); + if let Some(cli_session_id) = resolved_cli_session_id.as_deref() { + match cli_type_name { + CLI_TYPE_CLAUDE => { + id_changed = self + .session_manager + .lock() + .ok() + .and_then(|mgr| { + mgr.with_session_state_mut(session_id, |state| { + let changed = state.session.claude_session_id.as_deref() + != Some(cli_session_id); + state.session.claude_session_id = Some(cli_session_id.to_owned()); + changed + }) + }) + .unwrap_or(false); + } + CLI_TYPE_GEMINI => { + id_changed = self + .session_manager + .lock() + .ok() + .and_then(|mgr| { + mgr.with_session_state_mut(session_id, |state| { + let changed = state.session.gemini_session_id.as_deref() + != Some(cli_session_id); + state.session.gemini_session_id = Some(cli_session_id.to_owned()); + changed + }) + }) + .unwrap_or(false); + } + CLI_TYPE_CODEX => { + id_changed = self + .session_manager + .lock() + .ok() + .and_then(|mgr| { + mgr.with_session_state_mut(session_id, |state| { + let changed = state.session.codex_session_id.as_deref() + != Some(cli_session_id); + state.session.codex_session_id = Some(cli_session_id.to_owned()); + changed + }) + }) + .unwrap_or(false); + if let Some(session) = self.workspace.session_mut(session_id) { + if session.codex_session_id.as_deref() != Some(cli_session_id) { + session.codex_session_id = Some(cli_session_id.to_owned()); + id_changed = true; + } + if session.codex_started_at.is_none() { + session.codex_started_at = Some(chrono::Utc::now()); + id_changed = true; + } + } + } + _ => {} + } + } + + if cli_type_name == CLI_TYPE_CODEX { + if let Some(mode) = codex_execution_mode { + self.set_session_codex_execution_mode(session_id, Some(mode), cx); + } + let started_at = chrono::Utc::now(); + let manager_changed = self + .session_manager + .lock() + .ok() + .and_then(|mgr| { + mgr.with_session_state_mut(session_id, |state| { + if state.session.codex_started_at.is_none() { + state.session.codex_started_at = Some(started_at); + true + } else { + false + } + }) + }) + .unwrap_or(false); + let workspace_changed = self + .workspace + .session_mut(session_id) + .map(|session| { + if session.codex_started_at.is_none() { + session.codex_started_at = Some(started_at); + true + } else { + false + } + }) + .unwrap_or(false); + id_changed |= manager_changed || workspace_changed; + } + + if id_changed { + self.save_state_to_disk(cx); + } + + let focused_id = self.workspace.focused_session_id(); + let is_focused = Some(session_id) == focused_id; + let prev_status = self.workspace.session(session_id).map(|s| s.status); + let new_status = match status.as_str() { + "working" => SessionStatus::Working, + "needs_attention" => SessionStatus::NeedsAttention, + "response_ready" => { + if is_focused { + SessionStatus::Idle + } else { + SessionStatus::ResponseReady + } + } + // "idle" signal from the CLI (e.g. idle_prompt notification). + // If the session was previously ResponseReady and is unfocused, + // keep ResponseReady; the user hasn't read the response yet. + _ => { + if !is_focused && prev_status == Some(SessionStatus::ResponseReady) { + SessionStatus::ResponseReady + } else { + SessionStatus::Idle + } + } + }; + + if let Ok(mut readers) = self.cli_readers.lock() { + let status_since = readers + .cached_status + .get(&session_id) + .filter(|c| c.status == new_status) + .map(|c| c.status_since) + .unwrap_or_else(Instant::now); + readers.cached_status.insert( + session_id, + CachedCliStatus { + status: new_status, + seen_at: Instant::now(), + source: CliStatusSource::Hook, + status_since, + ttl: Self::HOOK_SIGNAL_CACHE_TTL, + }, + ); + } + + let prev_status_for_notif = prev_status.unwrap_or(SessionStatus::Idle); + + if new_status == SessionStatus::NeedsAttention + && prev_status_for_notif != SessionStatus::NeedsAttention + { + self.event_bus.publish(CodirigentEvent::AttentionRequired { + session_id, + detail: None, + }); + let name = self + .workspace + .session(session_id) + .map(|s| s.name.clone()) + .unwrap_or_else(|| format!("Session {}", session_id.0)); + self.notification_manager.notify( + NotificationType::InputRequired, + session_id, + &name, + None, + ); + } + + if new_status == SessionStatus::ResponseReady + && prev_status_for_notif == SessionStatus::Working + { + let name = self + .workspace + .session(session_id) + .map(|s| s.name.clone()) + .unwrap_or_else(|| format!("Session {}", session_id.0)); + self.notification_manager.notify( + NotificationType::ResponseReady, + session_id, + &name, + None, + ); + } + + if self.sync_session_status(session_id) { + cx.notify(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sig(status: &str, codirigent_session_id: Option<&str>, ts: u64) -> HookSignal { + HookSignal { + status: status.to_owned(), + cli_type: None, + cli_session_id: None, + approval_policy: None, + sandbox_policy_type: None, + codirigent_session_id: codirigent_session_id.map(str::to_owned), + ts, + } + } + + #[test] + fn hook_signal_without_codirigent_id_is_ignored() { + // Signals without codirigent_session_id come from Claude Code started + // outside Codirigent and should be silently discarded. + let signal = sig("working", None, 100); + assert!(signal.codirigent_session_id.is_none()); + } + + #[test] + fn hook_signal_with_codirigent_id_is_valid() { + let signal = sig("working", Some("42"), 100); + assert_eq!(signal.codirigent_session_id.as_deref(), Some("42")); + assert_eq!(signal.status, "working"); + } + + #[test] + fn hook_signal_codirigent_id_parses_to_session_id() { + let signal = sig("needs_attention", Some("7"), 100); + let id: u64 = signal + .codirigent_session_id + .unwrap() + .parse() + .expect("should parse"); + assert_eq!(id, 7); + } + + #[test] + fn hook_signal_invalid_codirigent_id_not_parseable() { + // Non-numeric IDs are rejected at parse time in hook signal processing. + let bad_id = "not-a-number".to_owned(); + assert!(bad_id.parse::().is_err()); + } + + #[test] + fn hook_signal_deserializes_from_json() { + let json = r#"{"status":"working","cli_session_id":"codex-session","codirigent_session_id":"3","ts":1234567890}"#; + let signal: HookSignal = serde_json::from_str(json).unwrap(); + assert_eq!(signal.status, "working"); + assert_eq!(signal.cli_session_id.as_deref(), Some("codex-session")); + assert_eq!(signal.codirigent_session_id.as_deref(), Some("3")); + assert_eq!(signal.ts, 1234567890); + } + + #[test] + fn hook_signal_deserializes_without_codirigent_id() { + // Backwards-compatible: old signal files without the field deserialize fine. + let json = r#"{"status":"idle","ts":100}"#; + let signal: HookSignal = serde_json::from_str(json).unwrap(); + assert!(signal.cli_session_id.is_none()); + assert!(signal.codirigent_session_id.is_none()); + } + + #[test] + fn hook_signal_context_infers_bypass_mode() { + assert_eq!( + codex_execution_mode_from_approval_and_sandbox( + Some("never"), + Some("danger-full-access"), + ), + Some(CodexExecutionMode::Bypass) + ); + } + + #[test] + fn hook_signal_context_infers_full_auto_mode() { + assert_eq!( + codex_execution_mode_from_approval_and_sandbox(Some("never"), Some("workspace-write")), + Some(CodexExecutionMode::FullAuto) + ); + } + + #[test] + fn hook_signal_is_applied_when_timestamp_advances() { + let fp = hook_signal_fingerprint("working", Some(CLI_TYPE_CLAUDE), None, None); + assert!(should_apply_hook_signal(None, 100, fp)); + assert!(should_apply_hook_signal( + Some(ProcessedHookSignal { + ts: 99, + fingerprint: fp, + }), + 100, + fp, + )); + } + + #[test] + fn identical_hook_signal_is_ignored_when_timestamp_does_not_advance() { + let fp = hook_signal_fingerprint("working", Some(CLI_TYPE_CLAUDE), None, None); + assert!(!should_apply_hook_signal( + Some(ProcessedHookSignal { + ts: 100, + fingerprint: fp, + }), + 100, + fp, + )); + assert!(!should_apply_hook_signal( + Some(ProcessedHookSignal { + ts: 101, + fingerprint: fp, + }), + 100, + fp, + )); + } + + #[test] + fn changed_hook_signal_with_same_timestamp_is_still_applied() { + let old_fp = hook_signal_fingerprint("working", Some(CLI_TYPE_CLAUDE), None, None); + let new_fp = hook_signal_fingerprint("response_ready", Some(CLI_TYPE_CLAUDE), None, None); + + assert!(should_apply_hook_signal( + Some(ProcessedHookSignal { + ts: 100, + fingerprint: old_fp, + }), + 100, + new_fp, + )); + } + + #[test] + fn numeric_signal_file_id_is_not_treated_as_codex_session_id() { + assert_eq!(resolve_hook_cli_session_id("3", None, SessionId(3)), None); + } + + #[test] + fn non_numeric_signal_file_id_can_backfill_cli_session_id() { + assert_eq!( + resolve_hook_cli_session_id("codex-uuid", None, SessionId(3)), + Some("codex-uuid".to_string()) + ); + } + + #[test] + fn explicit_cli_session_id_wins_over_signal_file_id() { + assert_eq!( + resolve_hook_cli_session_id("3", Some("real-codex-id"), SessionId(3)), + Some("real-codex-id".to_string()) + ); + } + + #[test] + fn unsafe_hook_cli_session_id_is_rejected() { + assert_eq!( + resolve_hook_cli_session_id("3", Some("bad;id"), SessionId(3)), + None + ); + assert_eq!( + resolve_hook_cli_session_id("bad;id", None, SessionId(3)), + None + ); + } + + #[test] + fn hook_signal_cli_type_maps_to_codex() { + assert_eq!( + cli_type_from_hook_signal_name(CLI_TYPE_CODEX), + Some(CliType::CodexCli) + ); + } + + #[test] + fn hook_signal_cli_type_maps_to_claude_and_gemini() { + assert_eq!( + cli_type_from_hook_signal_name(CLI_TYPE_CLAUDE), + Some(CliType::ClaudeCode) + ); + assert_eq!( + cli_type_from_hook_signal_name(CLI_TYPE_GEMINI), + Some(CliType::GeminiCli) + ); + } +} diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/terminal_input.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/terminal_input.rs index aa62d10..2df5a1e 100644 --- a/crates/codirigent-ui/src/workspace/impl_output_polling/terminal_input.rs +++ b/crates/codirigent-ui/src/workspace/impl_output_polling/terminal_input.rs @@ -4,5 +4,88 @@ //! - deferred enter handling //! - VTE response forwarding //! - compaction input follow-up helpers -//! -//! Phase A scaffolding only. Logic remains in the root module until Phase B. + +use super::WorkspaceView; +use codirigent_core::{CodirigentEvent, EventBus, SessionId, SessionManager}; +use std::time::{Duration, Instant}; +use tracing::warn; + +impl WorkspaceView { + /// Send the deferred Enter keystrokes used for compaction and other + /// command-submission follow-up so the session only returns to the + /// available pool after the CLI has had a brief chance to process input. + pub(super) fn process_deferred_enters(&mut self) { + // Collect both phases in one pass to avoid iterating pending_enters twice. + let mut need_enter: Vec = Vec::new(); + let mut expired: Vec = Vec::new(); + for (&session_id, &(when, sent)) in &self.polling.pending_enters { + if !sent && when.elapsed() >= Self::PENDING_ENTER_DELAY { + need_enter.push(session_id); + } else if sent && when.elapsed() >= Duration::from_millis(500) { + expired.push(session_id); + } + } + for session_id in need_enter { + if let Ok(mgr) = self.session_manager.lock() { + let _ = mgr.send_input(session_id, b"\r"); + } + // Flip to phase 2: keep entry for a grace period so the CLI can + // process the command before auto-assign considers this session. + self.polling + .pending_enters + .insert(session_id, (Instant::now(), true)); + } + for session_id in expired { + self.polling.pending_enters.remove(&session_id); + } + } + + /// Drain VTE PtyWrite responses (DSR, DA1, etc.) and forward them to each PTY. + /// + /// This is critical: PowerShell blocks on DSR (`\x1b[6n]`) until it gets a + /// response. Failing to forward these makes PowerShell hang at its prompt. + pub(super) fn drain_vte_responses(&mut self) { + for (sid, rx) in &mut self.pty_write_receivers { + let mut buf = Vec::with_capacity(64); + while let Ok(bytes) = rx.try_recv() { + buf.extend_from_slice(&bytes); + } + if !buf.is_empty() { + if let Ok(mgr) = self.session_manager.lock() { + if let Err(e) = mgr.send_input(*sid, &buf) { + warn!(?sid, error = %e, "Failed to forward VTE PtyWrite response"); + } + } + } + } + } + + /// End compaction for sessions that have exceeded the configured timeout. + pub(super) fn cleanup_compaction_timeouts(&mut self) { + let timeout_secs = self + .persistence + .compaction + .lock() + .map(|svc| svc.timeout_secs()) + .unwrap_or(120); + let timed_out: Vec = self + .cache + .compaction_start_times + .iter() + .filter(|(_, start)| start.elapsed() > Duration::from_secs(timeout_secs)) + .map(|(id, _)| *id) + .collect(); + for session_id in timed_out { + if let Ok(mut svc) = self.persistence.compaction.lock() { + svc.end_compaction(session_id); + } + self.cache.compaction_start_times.remove(&session_id); + self.event_bus + .publish(CodirigentEvent::CompactionCompleted { + session_id, + success: false, + }); + warn!(?session_id, "Compaction timed out"); + } + } +} diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/tests.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/tests.rs index 5044082..0d5a3a0 100644 --- a/crates/codirigent-ui/src/workspace/impl_output_polling/tests.rs +++ b/crates/codirigent-ui/src/workspace/impl_output_polling/tests.rs @@ -1,13 +1,9 @@ use super::*; +use crate::workspace::types::CachedCliStatus; use codirigent_core::{DefaultEventBus, ImageData, ImageFormat}; -use std::path::PathBuf; use std::sync::{Arc, Mutex}; use std::time::Instant; -fn temp_fixture_path(name: &str) -> PathBuf { - std::env::temp_dir().join(name) -} - #[test] fn detector_maintenance_merge_dedupes_and_preserves_priority() { let merged = merge_detector_maintenance_session_ids( @@ -49,295 +45,6 @@ fn detector_maintenance_batch_includes_stale_cached_sessions() { assert_eq!(batch.session_ids, vec![stale_id]); } -fn codex_input( - session_id: u64, - working_dir: &str, - has_explicit_codex_started_at: bool, -) -> JsonlCheckInput { - JsonlCheckInput { - session_id: SessionId(session_id), - working_dir: std::path::PathBuf::from(working_dir), - child_pid: None, - cli_type: CliType::CodexCli, - codex_session_id: None, - codex_execution_mode: None, - has_explicit_codex_started_at, - current_status: SessionStatus::Idle, - created_at_millis: 0, - } -} - -fn sig(status: &str, codirigent_session_id: Option<&str>, ts: u64) -> HookSignal { - HookSignal { - status: status.to_owned(), - cli_type: None, - cli_session_id: None, - approval_policy: None, - sandbox_policy_type: None, - codirigent_session_id: codirigent_session_id.map(str::to_owned), - ts, - } -} - -#[test] -fn hook_signal_without_codirigent_id_is_ignored() { - // Signals without codirigent_session_id come from Claude Code started - // outside Codirigent and should be silently discarded. - let signal = sig("working", None, 100); - assert!(signal.codirigent_session_id.is_none()); -} - -#[test] -fn hook_signal_with_codirigent_id_is_valid() { - let signal = sig("working", Some("42"), 100); - assert_eq!(signal.codirigent_session_id.as_deref(), Some("42")); - assert_eq!(signal.status, "working"); -} - -#[test] -fn hook_signal_codirigent_id_parses_to_session_id() { - let signal = sig("needs_attention", Some("7"), 100); - let id: u64 = signal - .codirigent_session_id - .unwrap() - .parse() - .expect("should parse"); - assert_eq!(id, 7); -} - -#[test] -fn hook_signal_invalid_codirigent_id_not_parseable() { - // Non-numeric IDs are rejected at parse time in hook signal processing. - let bad_id = "not-a-number".to_owned(); - assert!(bad_id.parse::().is_err()); -} - -#[test] -fn hook_signal_deserializes_from_json() { - let json = r#"{"status":"working","cli_session_id":"codex-session","codirigent_session_id":"3","ts":1234567890}"#; - let signal: HookSignal = serde_json::from_str(json).unwrap(); - assert_eq!(signal.status, "working"); - assert_eq!(signal.cli_session_id.as_deref(), Some("codex-session")); - assert_eq!(signal.codirigent_session_id.as_deref(), Some("3")); - assert_eq!(signal.ts, 1234567890); -} - -#[test] -fn hook_signal_deserializes_without_codirigent_id() { - // Backwards-compatible: old signal files without the field deserialize fine. - let json = r#"{"status":"idle","ts":100}"#; - let signal: HookSignal = serde_json::from_str(json).unwrap(); - assert!(signal.cli_session_id.is_none()); - assert!(signal.codirigent_session_id.is_none()); -} - -#[test] -fn hook_signal_context_infers_bypass_mode() { - assert_eq!( - codex_execution_mode_from_approval_and_sandbox(Some("never"), Some("danger-full-access"),), - Some(CodexExecutionMode::Bypass) - ); -} - -#[test] -fn hook_signal_context_infers_full_auto_mode() { - assert_eq!( - codex_execution_mode_from_approval_and_sandbox(Some("never"), Some("workspace-write")), - Some(CodexExecutionMode::FullAuto) - ); -} - -#[test] -fn hook_signal_is_applied_when_timestamp_advances() { - let fp = hook_signal_fingerprint("working", Some(CLI_TYPE_CLAUDE), None, None); - assert!(should_apply_hook_signal(None, 100, fp)); - assert!(should_apply_hook_signal( - Some(ProcessedHookSignal { - ts: 99, - fingerprint: fp, - }), - 100, - fp, - )); -} - -#[test] -fn identical_hook_signal_is_ignored_when_timestamp_does_not_advance() { - let fp = hook_signal_fingerprint("working", Some(CLI_TYPE_CLAUDE), None, None); - assert!(!should_apply_hook_signal( - Some(ProcessedHookSignal { - ts: 100, - fingerprint: fp, - }), - 100, - fp, - )); - assert!(!should_apply_hook_signal( - Some(ProcessedHookSignal { - ts: 101, - fingerprint: fp, - }), - 100, - fp, - )); -} - -#[test] -fn changed_hook_signal_with_same_timestamp_is_still_applied() { - let old_fp = hook_signal_fingerprint("working", Some(CLI_TYPE_CLAUDE), None, None); - let new_fp = hook_signal_fingerprint("response_ready", Some(CLI_TYPE_CLAUDE), None, None); - - assert!(should_apply_hook_signal( - Some(ProcessedHookSignal { - ts: 100, - fingerprint: old_fp, - }), - 100, - new_fp, - )); -} - -#[test] -fn numeric_signal_file_id_is_not_treated_as_codex_session_id() { - assert_eq!(resolve_hook_cli_session_id("3", None, SessionId(3)), None); -} - -#[test] -fn non_numeric_signal_file_id_can_backfill_cli_session_id() { - assert_eq!( - resolve_hook_cli_session_id("codex-uuid", None, SessionId(3)), - Some("codex-uuid".to_string()) - ); -} - -#[test] -fn explicit_cli_session_id_wins_over_signal_file_id() { - assert_eq!( - resolve_hook_cli_session_id("3", Some("real-codex-id"), SessionId(3)), - Some("real-codex-id".to_string()) - ); -} - -#[test] -fn unsafe_hook_cli_session_id_is_rejected() { - assert_eq!( - resolve_hook_cli_session_id("3", Some("bad;id"), SessionId(3)), - None - ); - assert_eq!( - resolve_hook_cli_session_id("bad;id", None, SessionId(3)), - None - ); -} - -#[test] -fn ambiguous_codex_probe_is_deferred_without_explicit_start_time() { - let inputs = vec![ - codex_input(1, "C:/repo", false), - codex_input(2, "C:/repo", false), - ]; - let counts = count_codex_sessions_without_session_id_per_working_dir(&inputs); - - assert!(should_defer_ambiguous_codex_probe(&inputs[0], &counts)); - assert!(should_defer_ambiguous_codex_probe(&inputs[1], &counts)); -} - -#[test] -fn ambiguous_codex_probe_uses_timestamp_when_start_time_is_known() { - let inputs = vec![ - codex_input(1, "C:/repo", true), - codex_input(2, "C:/repo", true), - ]; - let counts = count_codex_sessions_without_session_id_per_working_dir(&inputs); - - assert!(!should_defer_ambiguous_codex_probe(&inputs[0], &counts)); - assert!(!should_defer_ambiguous_codex_probe(&inputs[1], &counts)); -} - -#[test] -fn ambiguous_codex_probe_only_defers_session_missing_start_time() { - let inputs = vec![ - codex_input(1, "C:/repo", true), - codex_input(2, "C:/repo", false), - ]; - let counts = count_codex_sessions_without_session_id_per_working_dir(&inputs); - - assert!(!should_defer_ambiguous_codex_probe(&inputs[0], &counts)); - assert!(should_defer_ambiguous_codex_probe(&inputs[1], &counts)); -} - -#[test] -fn git_refresh_updates_git_info_without_overwriting_custom_group() { - let project_path = temp_fixture_path("project"); - let mut session = Session::new(SessionId(1), "Session 1".to_string(), project_path.clone()); - session.group = Some("custom-group".to_string()); - session.color = Some("#f43f5e".to_string()); - - let git_info = Some(GitRepoInfo { - repo_root: project_path, - branch: "feature/custom-group".to_string(), - dirty_count: 2, - has_staged: false, - head_sha: Some("deadbeef".to_string()), - unstaged_files: Vec::new(), - staged_files: Vec::new(), - }); - - assert!(update_cached_session_git_info(&mut session, &git_info)); - assert_eq!(session.group.as_deref(), Some("custom-group")); - assert_eq!(session.color.as_deref(), Some("#f43f5e")); - assert_eq!(session.git_info, git_info); -} - -#[test] -fn cwd_session_update_preserves_custom_group_from_manager() { - let project_path = temp_fixture_path("project"); - let other_project_path = temp_fixture_path("other-project"); - let mut workspace_session = - Session::new(SessionId(1), "Session 1".to_string(), project_path.clone()); - workspace_session.group = Some("custom-group".to_string()); - workspace_session.color = Some("#f43f5e".to_string()); - workspace_session.git_info = Some(GitRepoInfo { - repo_root: project_path, - branch: "main".to_string(), - dirty_count: 1, - has_staged: false, - head_sha: Some("deadbeef".to_string()), - unstaged_files: Vec::new(), - staged_files: Vec::new(), - }); - - let mut manager_session = workspace_session.clone(); - manager_session.working_directory = other_project_path.clone(); - - apply_cwd_session_update_from_manager(&mut workspace_session, &manager_session); - - assert_eq!(workspace_session.working_directory, other_project_path); - assert_eq!(workspace_session.group.as_deref(), Some("custom-group")); - assert_eq!(workspace_session.color.as_deref(), Some("#f43f5e")); - assert!(workspace_session.git_info.is_none()); -} - -#[test] -fn hook_signal_cli_type_maps_to_codex() { - assert_eq!( - cli_type_from_hook_signal_name(CLI_TYPE_CODEX), - Some(CliType::CodexCli) - ); -} - -#[test] -fn hook_signal_cli_type_maps_to_claude_and_gemini() { - assert_eq!( - cli_type_from_hook_signal_name(CLI_TYPE_CLAUDE), - Some(CliType::ClaudeCode) - ); - assert_eq!( - cli_type_from_hook_signal_name(CLI_TYPE_GEMINI), - Some(CliType::GeminiCli) - ); -} - #[test] fn tiny_dib_preview_is_suppressed() { let image = ImageData { From 41a03635b23ec91d4003deaa3ac388009df007c3 Mon Sep 17 00:00:00 2001 From: oso95 Date: Thu, 12 Mar 2026 14:42:50 -0400 Subject: [PATCH 04/10] refactor: split workspace status reconciliation --- .../src/workspace/impl_output_polling.rs | 208 +---------------- .../impl_output_polling/status_reconcile.rs | 214 +++++++++++++++++- .../workspace/impl_output_polling/tests.rs | 4 +- 3 files changed, 210 insertions(+), 216 deletions(-) diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling.rs b/crates/codirigent-ui/src/workspace/impl_output_polling.rs index 60e88b3..2512f2e 100644 --- a/crates/codirigent-ui/src/workspace/impl_output_polling.rs +++ b/crates/codirigent-ui/src/workspace/impl_output_polling.rs @@ -25,13 +25,11 @@ mod terminal_input; // Phase A keeps all behavior in this root file. The child modules above are // destination files for Phase B moves only. -use super::cli_helpers::clear_command; use super::gpui::WorkspaceView; -use super::types::CliStatusSource; use crate::terminal_runtime::TerminalRenderSnapshot; use codirigent_core::{ - AssignmentAction, CliType, CodirigentEvent, EventBus, ProcessMonitor, Session, SessionId, - SessionManager, SessionStatus, SessionUpdate, TaskStatus, + AssignmentAction, CliType, CodirigentEvent, EventBus, Session, SessionId, SessionManager, + SessionUpdate, }; use codirigent_session::clipboard_service::{ClipboardService, DefaultClipboardService}; use codirigent_session::detect_cli_from_output; @@ -638,208 +636,6 @@ impl WorkspaceView { } } - /// Update session status from detector/cache state. - /// - /// Uses the status reconciler ([`super::status_engine::reconcile`]) to - /// combine detector hints with cached CLI hints, then applies side effects - /// (task transitions, compaction, auto-assign, notifications). - /// - /// Returns `true` if any UI-visible change was made that requires a repaint. - fn sync_session_status(&mut self, session_id: codirigent_core::SessionId) -> bool { - use super::status_engine::reconcile; - use super::status_providers::{HintSource, StaleAction}; - - let mut any_dirty = false; - - // Gather inputs for the reconciler - let (detector_status, idle_time) = self.with_detector(|detector| { - ( - detector.get_status(session_id), - detector.get_idle_time(session_id), - ) - }); - - // Gather cached CLI status in a single lock acquisition to ensure - // consistency between status, source, and age. - let (cached_status, cached_source, cache_age) = self - .cli_readers - .lock() - .ok() - .and_then(|mut readers| { - let cached = readers.cached_status.get(&session_id)?; - if cached.seen_at.elapsed() > cached.ttl { - readers.cached_status.remove(&session_id); - return None; - } - let source = match cached.source { - CliStatusSource::Hook => HintSource::HookSignal, - CliStatusSource::Jsonl => HintSource::Jsonl, - }; - let age = Some(cached.status_since.elapsed()); - Some((Some(cached.status), source, age)) - }) - .unwrap_or((None, HintSource::Detector, None)); - - let previous_status = self.workspace.session(session_id).map(|s| s.status); - - // Run the reconciler - let (reconciled, stale_action) = reconcile( - session_id, - detector_status, - cached_status, - cached_source, - cache_age, - previous_status, - ); - - // Shadow mode: log full reconciler input/output when status changes - if is_shadow_status() { - if let Some(ref r) = reconciled { - if r.changed { - info!( - ?session_id, - ?detector_status, - ?cached_status, - ?cached_source, - ?cache_age, - ?previous_status, - reconciled_status = ?r.status, - reconciled_source = ?r.source, - ?stale_action, - "shadow: reconciler status change" - ); - } - } - } - - // Handle stale cache action - match stale_action { - StaleAction::ClearAndRevert { - session_id: stale_id, - } => { - if let Ok(mut readers) = self.cli_readers.lock() { - readers.cached_status.remove(&stale_id); - } - self.clipboard - .clipboard_service - .set_session_cli_type(stale_id, codirigent_core::CliType::GenericShell); - info!( - ?stale_id, - "Cleared stale NeedsAttention, reverted to GenericShell" - ); - } - StaleAction::None => {} - } - - // Apply reconciled status and side effects - if let Some(reconciled) = reconciled { - let status = reconciled.status; - if self.polling.idle_poll_count % Self::STATUS_LOG_INTERVAL == 0 { - info!(?session_id, ?status, ?idle_time, "Session status poll"); - } - let old_status = self.workspace.session(session_id).map(|s| s.status); - let mut just_started_compaction = false; - if self.workspace.update_session_status(session_id, status) { - any_dirty = true; - // Sync task board with the canonical (JSONL-corrected) status - if let Some(old) = old_status { - // Check if task transitioned to Review - let task_transitioned_to_review = - if let Ok(mut task_mgr) = self.task_manager.lock() { - let tid = task_mgr.on_session_status_changed(session_id, old, status); - if let Some(ref task_id) = tid { - task_mgr - .get_task(task_id) - .is_some_and(|t| t.status == TaskStatus::Review) - } else { - false - } - } else { - false - }; - - // When task auto-transitions to Review: - // 1. Clear current_task so auto-assign can work later - // 2. Send /clear to reset context for the next task. - if task_transitioned_to_review { - // Keep the previous JSONL status during transient parse/IO misses. - if let Ok(mgr) = self.session_manager.lock() { - mgr.with_session_state_mut(session_id, |state| { - state.session.current_task = None; - }); - } - if let Some(session) = self.workspace.session_mut(session_id) { - session.current_task = None; - } - // Start context clear and reuse compaction infrastructure - let cli_type = self - .clipboard - .clipboard_service - .get_session_cli_type(session_id); - let clear_cmd = clear_command(cli_type); - if let Ok(mut svc) = self.persistence.compaction.lock() { - if svc.begin_compaction(session_id) { - if let Ok(mgr) = self.session_manager.lock() { - let _ = mgr.send_input(session_id, clear_cmd.as_bytes()); - } - self.polling - .pending_enters - .insert(session_id, (Instant::now(), false)); - self.cache - .compaction_start_times - .insert(session_id, Instant::now()); - just_started_compaction = true; - } - } - } - } - self.sync_task_derived_state(); - } - // NeedsAttention is NOT treated as idle because session is blocked - // Skip if we just started compaction and wait for /clear to finish - // Skip if a deferred Enter is pending because text hasn't been submitted yet - if matches!(status, SessionStatus::Idle) - && !just_started_compaction - && !self.polling.pending_enters.contains_key(&session_id) - { - let is_compacting = self - .persistence - .compaction - .lock() - .map(|svc| svc.is_compacting(session_id)) - .unwrap_or(false); - - if is_compacting { - // Compaction just finished and session returned to Idle - if let Ok(mut svc) = self.persistence.compaction.lock() { - svc.end_compaction(session_id); - } - self.cache.compaction_start_times.remove(&session_id); - self.event_bus - .publish(CodirigentEvent::CompactionCompleted { - session_id, - success: true, - }); - info!(?session_id, "Compaction completed successfully"); - // Fall through to try_auto_assign - } else { - // Not compacting; check if we should compact before proceeding - let has_task = self - .workspace - .session(session_id) - .is_some_and(|s| s.current_task.is_some()); - if has_task && self.try_compact(session_id) { - // Compaction started; skip auto-assign this cycle - return any_dirty; - } - } - - self.try_auto_assign(session_id); - } - } - any_dirty - } - /// Spawn a background JSONL status check for all sessions if the last check /// was more than 3 seconds ago and no check is currently in-flight. /// diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/status_reconcile.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/status_reconcile.rs index dacee4f..0b59dab 100644 --- a/crates/codirigent-ui/src/workspace/impl_output_polling/status_reconcile.rs +++ b/crates/codirigent-ui/src/workspace/impl_output_polling/status_reconcile.rs @@ -1,8 +1,206 @@ -//! Future home for session-status reconciliation helpers. -//! -//! Expected move targets in Phase B: -//! - `sync_session_status()` -//! - cached-status reconciliation helpers -//! - task/notification/compaction side-effect helpers -//! -//! Phase A scaffolding only. Logic remains in the root module until Phase B. +//! Session-status reconciliation helpers. + +use super::super::cli_helpers::clear_command; +use super::super::status_engine::reconcile; +use super::super::status_providers::{HintSource, StaleAction}; +use super::super::types::CliStatusSource; +use super::WorkspaceView; +use codirigent_core::{ + CliType, CodirigentEvent, EventBus, ProcessMonitor, SessionId, SessionManager, SessionStatus, + TaskStatus, +}; +use codirigent_session::clipboard_service::ClipboardService; +use std::time::Instant; +use tracing::info; + +impl WorkspaceView { + /// Update session status from detector/cache state. + /// + /// Uses the status reconciler ([`super::super::status_engine::reconcile`]) to + /// combine detector hints with cached CLI hints, then applies side effects + /// (task transitions, compaction, auto-assign, notifications). + /// + /// Returns `true` if any UI-visible change was made that requires a repaint. + pub(super) fn sync_session_status(&mut self, session_id: SessionId) -> bool { + let mut any_dirty = false; + + // Gather inputs for the reconciler + let (detector_status, idle_time) = self.with_detector(|detector| { + ( + detector.get_status(session_id), + detector.get_idle_time(session_id), + ) + }); + + // Gather cached CLI status in a single lock acquisition to ensure + // consistency between status, source, and age. + let (cached_status, cached_source, cache_age) = self + .cli_readers + .lock() + .ok() + .and_then(|mut readers| { + let cached = readers.cached_status.get(&session_id)?; + if cached.seen_at.elapsed() > cached.ttl { + readers.cached_status.remove(&session_id); + return None; + } + let source = match cached.source { + CliStatusSource::Hook => HintSource::HookSignal, + CliStatusSource::Jsonl => HintSource::Jsonl, + }; + let age = Some(cached.status_since.elapsed()); + Some((Some(cached.status), source, age)) + }) + .unwrap_or((None, HintSource::Detector, None)); + + let previous_status = self.workspace.session(session_id).map(|s| s.status); + + let (reconciled, stale_action) = reconcile( + session_id, + detector_status, + cached_status, + cached_source, + cache_age, + previous_status, + ); + + if super::is_shadow_status() { + if let Some(ref r) = reconciled { + if r.changed { + info!( + ?session_id, + ?detector_status, + ?cached_status, + ?cached_source, + ?cache_age, + ?previous_status, + reconciled_status = ?r.status, + reconciled_source = ?r.source, + ?stale_action, + "shadow: reconciler status change" + ); + } + } + } + + match stale_action { + StaleAction::ClearAndRevert { + session_id: stale_id, + } => { + if let Ok(mut readers) = self.cli_readers.lock() { + readers.cached_status.remove(&stale_id); + } + self.clipboard + .clipboard_service + .set_session_cli_type(stale_id, CliType::GenericShell); + info!( + ?stale_id, + "Cleared stale NeedsAttention, reverted to GenericShell" + ); + } + StaleAction::None => {} + } + + if let Some(reconciled) = reconciled { + let status = reconciled.status; + if self.polling.idle_poll_count % Self::STATUS_LOG_INTERVAL == 0 { + info!(?session_id, ?status, ?idle_time, "Session status poll"); + } + let old_status = self.workspace.session(session_id).map(|s| s.status); + let mut just_started_compaction = false; + if self.workspace.update_session_status(session_id, status) { + any_dirty = true; + if let Some(old) = old_status { + let task_transitioned_to_review = + if let Ok(mut task_mgr) = self.task_manager.lock() { + let tid = task_mgr.on_session_status_changed(session_id, old, status); + if let Some(ref task_id) = tid { + task_mgr + .get_task(task_id) + .is_some_and(|t| t.status == TaskStatus::Review) + } else { + false + } + } else { + false + }; + + // When task auto-transitions to Review: + // 1. Clear current_task so auto-assign can work later + // 2. Send /clear to reset context for the next task. + if task_transitioned_to_review { + // Keep the previous JSONL status during transient parse/IO misses. + if let Ok(mgr) = self.session_manager.lock() { + mgr.with_session_state_mut(session_id, |state| { + state.session.current_task = None; + }); + } + if let Some(session) = self.workspace.session_mut(session_id) { + session.current_task = None; + } + // Start context clear and reuse compaction infrastructure + let cli_type = self + .clipboard + .clipboard_service + .get_session_cli_type(session_id); + let clear_cmd = clear_command(cli_type); + if let Ok(mut svc) = self.persistence.compaction.lock() { + if svc.begin_compaction(session_id) { + if let Ok(mgr) = self.session_manager.lock() { + let _ = mgr.send_input(session_id, clear_cmd.as_bytes()); + } + self.polling + .pending_enters + .insert(session_id, (Instant::now(), false)); + self.cache + .compaction_start_times + .insert(session_id, Instant::now()); + just_started_compaction = true; + } + } + } + } + self.sync_task_derived_state(); + } + + // NeedsAttention is NOT treated as idle because session is blocked + // Skip if we just started compaction and wait for /clear to finish + // Skip if a deferred Enter is pending because text hasn't been submitted yet + if matches!(status, SessionStatus::Idle) + && !just_started_compaction + && !self.polling.pending_enters.contains_key(&session_id) + { + let is_compacting = self + .persistence + .compaction + .lock() + .map(|svc| svc.is_compacting(session_id)) + .unwrap_or(false); + + if is_compacting { + if let Ok(mut svc) = self.persistence.compaction.lock() { + svc.end_compaction(session_id); + } + self.cache.compaction_start_times.remove(&session_id); + self.event_bus + .publish(CodirigentEvent::CompactionCompleted { + session_id, + success: true, + }); + info!(?session_id, "Compaction completed successfully"); + } else { + let has_task = self + .workspace + .session(session_id) + .is_some_and(|s| s.current_task.is_some()); + if has_task && self.try_compact(session_id) { + return any_dirty; + } + } + + self.try_auto_assign(session_id); + } + } + any_dirty + } +} diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/tests.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/tests.rs index 0d5a3a0..edd0f84 100644 --- a/crates/codirigent-ui/src/workspace/impl_output_polling/tests.rs +++ b/crates/codirigent-ui/src/workspace/impl_output_polling/tests.rs @@ -1,6 +1,6 @@ use super::*; -use crate::workspace::types::CachedCliStatus; -use codirigent_core::{DefaultEventBus, ImageData, ImageFormat}; +use crate::workspace::types::{CachedCliStatus, CliStatusSource}; +use codirigent_core::{DefaultEventBus, ImageData, ImageFormat, SessionStatus}; use std::sync::{Arc, Mutex}; use std::time::Instant; From 8b8f67dfe6d0072402af3847307e966053b2a0a4 Mon Sep 17 00:00:00 2001 From: oso95 Date: Thu, 12 Mar 2026 15:00:17 -0400 Subject: [PATCH 05/10] refactor: split workspace output runtime --- .../src/workspace/impl_output_polling.rs | 421 +--------------- .../impl_output_polling/output_runtime.rs | 453 +++++++++++++++++- .../workspace/impl_output_polling/tests.rs | 28 -- 3 files changed, 452 insertions(+), 450 deletions(-) diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling.rs b/crates/codirigent-ui/src/workspace/impl_output_polling.rs index 2512f2e..e45e9e7 100644 --- a/crates/codirigent-ui/src/workspace/impl_output_polling.rs +++ b/crates/codirigent-ui/src/workspace/impl_output_polling.rs @@ -9,11 +9,10 @@ //! - Manages automatic task assignment and context compaction //! - Handles clipboard preview auto-show/hide //! -//! Phase A scaffolding note: -//! - The root keeps shared types, constants, and orchestration methods. -//! - Child modules under `workspace/impl_output_polling/` are created now so -//! later phases can move responsibility-based helper clusters without -//! changing `workspace/mod.rs`. +//! Split note: +//! - The root keeps shared types, constants, and maintenance orchestration. +//! - Responsibility-based polling clusters now live under +//! `workspace/impl_output_polling/` without changing `workspace/mod.rs`. mod cli_pollers; mod git_refresh; @@ -22,17 +21,13 @@ mod output_runtime; mod status_reconcile; mod terminal_input; -// Phase A keeps all behavior in this root file. The child modules above are -// destination files for Phase B moves only. +// The root still owns shared polling state, detector maintenance, clipboard +// preview, and task/compaction orchestration. Hot-path polling helpers now +// live in the child modules above. use super::gpui::WorkspaceView; -use crate::terminal_runtime::TerminalRenderSnapshot; -use codirigent_core::{ - AssignmentAction, CliType, CodirigentEvent, EventBus, Session, SessionId, SessionManager, - SessionUpdate, -}; +use codirigent_core::{AssignmentAction, CodirigentEvent, EventBus, SessionId, SessionManager}; use codirigent_session::clipboard_service::{ClipboardService, DefaultClipboardService}; -use codirigent_session::detect_cli_from_output; use gpui::Context; use std::collections::hash_map::DefaultHasher; use std::collections::HashSet; @@ -115,43 +110,6 @@ fn collect_detector_maintenance_batch( } } -fn prioritize_and_partition_output_sessions( - mut session_ids: Vec, - focused_id: Option, - mut can_schedule: F, -) -> (Vec, Vec) -where - F: FnMut(SessionId) -> bool, -{ - if let Some(focused_id) = focused_id { - if let Some(index) = session_ids.iter().position(|id| *id == focused_id) { - session_ids.swap(0, index); - } - } - - let mut ready = Vec::with_capacity(session_ids.len()); - let mut deferred = Vec::new(); - for session_id in session_ids { - if can_schedule(session_id) { - ready.push(session_id); - } else { - deferred.push(session_id); - } - } - - (ready, deferred) -} - -#[derive(Debug)] -struct PreparedSessionOutput { - session_id: SessionId, - bytes_drained: usize, - has_more: bool, - render_snapshot: Option, - detected_cli_type: Option, - cwd_session: Option, -} - enum ClipboardPreviewUpdate { NoChange, ChangedToNonImage, @@ -209,21 +167,6 @@ impl WorkspaceView { /// safety net for sessions that bypass the mpsc channel. const LEGACY_FALLBACK_INTERVAL: Duration = Duration::from_secs(1); - pub(super) fn poll_output(&mut self, cx: &mut Context) { - self.process_deferred_enters(); - self.drain_vte_responses(); - - let had_output_activity = self.schedule_output_preparation(cx); - - // Track output activity for adaptive polling - // - // Sessions that actually produced output are synchronized in - // `apply_prepared_session_output()`. Detector-based status decay stays - // on the slower maintenance cadence to avoid O(all sessions) work on - // every active 16 ms poll. - self.polling.last_poll_had_output = had_output_activity; - } - pub(super) fn poll_maintenance(&mut self, cx: &mut Context) { self.spawn_background_hook_signal_check(cx); self.spawn_background_jsonl_check(cx); @@ -288,354 +231,6 @@ impl WorkspaceView { } } - fn schedule_output_preparation(&mut self, cx: &mut Context) -> bool { - if is_legacy_pipeline() { - return self.schedule_output_preparation_legacy(cx); - } - - // Phase 1: Drain the event-driven mpsc channel into the dispatcher. - if let Some(ref mut rx) = self.update_rx { - let other_events = self.output_dispatcher.drain_updates(rx); - for event in other_events { - match event { - SessionUpdate::ChildProcessExited { session_id } => { - // PTY child exited — mark session ready so it gets a - // final output drain and status re-evaluation. - trace!( - ?session_id, - "ChildProcessExited: marking ready for final drain" - ); - self.output_dispatcher.mark_ready(session_id); - } - SessionUpdate::OutputReady { .. } => { - // Consumed by drain_updates into the ready set — should - // not appear here, but handle gracefully. - } - SessionUpdate::ShellStateChanged { session_id, .. } - | SessionUpdate::WorkingDirectoryChanged { session_id, .. } => { - // Phase-2: handled inline during output preparation - // (dual-path). Channel copies are informational only - // until phase-2 routing replaces the inline path. - trace!(?session_id, "phase-2 event received (not yet routed)"); - } - } - } - } - - // Phase 2: Low-frequency legacy safety net — drain the - // pending_output_sessions set at ~1s intervals to catch any sessions - // that bypass the mpsc channel (e.g., manual mark_output_pending - // calls). This is NOT the hot path — the dispatcher handles that. - if self.polling.last_legacy_fallback.elapsed() >= Self::LEGACY_FALLBACK_INTERVAL { - self.polling.last_legacy_fallback = Instant::now(); - let legacy_ids = - self.with_session_manager(|manager| manager.sessions_with_pending_output()); - if !legacy_ids.is_empty() { - trace!( - count = legacy_ids.len(), - "legacy fallback drain (safety net)" - ); - for id in &legacy_ids { - let was_new = self.output_dispatcher.mark_ready(*id); - // Shadow mode: log only genuinely missed events — sessions - // the mpsc channel didn't deliver to the dispatcher. - if is_shadow_status() && was_new { - info!( - ?id, - "shadow: legacy fallback discovered session not in dispatcher" - ); - } - } - } - } - - // Phase 3: Take ready sessions from the dispatcher (focused first). - let session_ids = self - .output_dispatcher - .take_ready_sessions(self.workspace.focused_session_id()); - - // Filter: only schedule sessions that have a terminal view. - // Sessions without a terminal yet (gap between create_session and - // terminals.insert) are re-queued so the next poll cycle picks them - // up, avoiding a ~1s delay waiting for the legacy fallback. - let mut schedulable = Vec::with_capacity(session_ids.len()); - for id in session_ids { - if self.terminals.contains_key(&id) { - schedulable.push(id); - } else { - self.output_dispatcher.mark_ready(id); - } - } - - let in_flight_count = self.output_dispatcher.in_flight_count(); - if !schedulable.is_empty() || in_flight_count > 0 { - trace!( - discovered_count = schedulable.len(), - in_flight_count, - "schedule_output_preparation" - ); - } - - let had_output_activity = !schedulable.is_empty() || self.output_dispatcher.has_activity(); - - for session_id in schedulable { - self.schedule_session_output_preparation(session_id, cx); - } - - had_output_activity - } - - /// Legacy output preparation path — uses the broad - /// `sessions_with_pending_output()` scan without the event-driven - /// dispatcher. Activated by `CODIRIGENT_LEGACY_PIPELINE=1`. - fn schedule_output_preparation_legacy(&mut self, cx: &mut Context) -> bool { - let session_ids = - self.with_session_manager(|manager| manager.sessions_with_pending_output()); - let (session_ids, deferred_ids) = prioritize_and_partition_output_sessions( - session_ids, - self.workspace.focused_session_id(), - |id| { - self.terminals.contains_key(&id) - && !self.polling.output_prepare_in_flight.contains(&id) - }, - ); - - if !deferred_ids.is_empty() { - self.with_session_manager(|manager| { - for session_id in deferred_ids { - manager.mark_output_pending(session_id); - } - }); - } - - let had_output_activity = - !session_ids.is_empty() || !self.polling.output_prepare_in_flight.is_empty(); - - for session_id in session_ids { - self.schedule_session_output_preparation(session_id, cx); - } - - had_output_activity - } - - fn schedule_session_output_preparation( - &mut self, - session_id: SessionId, - cx: &mut Context, - ) { - trace!(?session_id, "schedule_session_output_preparation"); - let Some(runtime) = self - .terminals - .get(&session_id) - .map(|tv| tv.runtime_handle()) - else { - trace!( - ?session_id, - "deferring output preparation until terminal runtime attaches" - ); - self.output_dispatcher.mark_ready(session_id); - self.with_session_manager(|manager| manager.mark_output_pending(session_id)); - return; - }; - - // Guard: prevent double-dispatch via the dispatcher's in-flight set. - if !self.output_dispatcher.mark_in_flight(session_id) { - return; - } - // TRANSITION: Legacy in-flight set kept in sync until - // CODIRIGENT_LEGACY_PIPELINE and schedule_output_preparation_legacy - // are removed. Both sets are always updated together. - self.polling.output_prepare_in_flight.insert(session_id); - debug_assert_eq!( - self.polling.output_prepare_in_flight.len(), - self.output_dispatcher.in_flight_count(), - "dual in-flight sets desynchronized after marking session {} in-flight", - session_id.0, - ); - - let session_manager = self.session_manager.clone(); - let detector = self.detector.clone(); - let update_tx = self.update_tx.clone(); - - cx.spawn(async move |this: gpui::WeakEntity, cx| { - let prepared = cx - .background_executor() - .spawn(async move { - let drained = { - let manager = session_manager.lock().ok()?; - manager.try_drain_output_bounded( - session_id, - Self::MAX_OUTPUT_CHUNKS_PER_POLL, - Self::MAX_OUTPUT_BYTES_PER_POLL, - ) - }?; - - let data = drained.data; - let bytes_drained = data.len(); - let render_snapshot = runtime.apply_output(&data); - let detected_cli_type = detect_cli_from_output(&data); - - { - let mut detector = detector.lock().ok()?; - detector.process_output(session_id, &data); - for event in codirigent_session::extract_osc133_events(&data) { - // DUAL-PATH: Emitted to channel for phase-2 event routing. - // Also applied directly below via set_shell_state() for correctness now. - if let Some(tx) = &update_tx { - if let Err(e) = - tx.try_send(codirigent_core::SessionUpdate::ShellStateChanged { - session_id, - state: event.clone(), - }) - { - trace!("ShellStateChanged try_send for {}: {e}", session_id.0); - } - } - detector.set_shell_state(session_id, event); - } - } - - let cwd_session = - codirigent_session::extract_osc7_path(&data).and_then(|new_cwd| { - // DUAL-PATH: Emitted to channel for phase-2 event routing. - // Also applied directly below via update_working_directory() for correctness now. - if let Some(tx) = &update_tx { - if let Err(e) = tx.try_send( - codirigent_core::SessionUpdate::WorkingDirectoryChanged { - session_id, - cwd: new_cwd.clone(), - }, - ) { - trace!( - "WorkingDirectoryChanged try_send for {}: {e}", - session_id.0 - ); - } - } - let manager = session_manager.lock().ok()?; - let changed = manager.update_working_directory(session_id, new_cwd); - if changed { - manager.get_session(session_id) - } else { - None - } - }); - - Some(PreparedSessionOutput { - session_id, - bytes_drained, - has_more: drained.has_more, - render_snapshot, - detected_cli_type, - cwd_session, - }) - }) - .await; - - let _ = this.update(cx, |this, cx| { - this.polling.output_prepare_in_flight.remove(&session_id); - this.output_dispatcher.complete_in_flight(session_id); - debug_assert_eq!( - this.polling.output_prepare_in_flight.len(), - this.output_dispatcher.in_flight_count(), - "dual in-flight sets desynchronized after completing session {}", - session_id.0, - ); - if let Some(prepared) = prepared { - this.apply_prepared_session_output(prepared, cx); - } else { - // No output to drain (e.g. ChildProcessExited with no - // trailing bytes). Still run status reconciliation so - // OSC133-driven sessions don't stick in Working after - // the PTY exits. - if this.sync_session_status(session_id) { - this.sync_session_header(session_id); - cx.notify(); - } - } - }); - }) - .detach(); - } - - fn apply_prepared_session_output( - &mut self, - prepared: PreparedSessionOutput, - cx: &mut Context, - ) { - let PreparedSessionOutput { - session_id, - bytes_drained, - has_more, - render_snapshot, - detected_cli_type, - cwd_session, - } = prepared; - trace!( - ?session_id, - bytes_drained, - has_more, - "apply_prepared_session_output" - ); - let mut any_dirty = false; - - if let Some(snapshot) = render_snapshot { - if let Some(terminal_view) = self.terminals.get_mut(&session_id) { - any_dirty |= terminal_view.apply_snapshot(snapshot); - } - } - - if let Some(cli_type) = detected_cli_type { - let current = self - .clipboard - .clipboard_service - .get_session_cli_type(session_id); - if current == codirigent_core::CliType::GenericShell { - self.clipboard - .clipboard_service - .set_session_cli_type(session_id, cli_type); - info!(?session_id, ?cli_type, "Detected CLI type from output"); - } - } - - if let Some(mgr_session) = cwd_session { - if let Some(header) = self.terminal_headers.get_mut(&session_id) { - header.git_branch = None; - header.git_dirty_count = None; - } - - if let Some(ws_session) = self.workspace.session_mut(session_id) { - git_refresh::apply_cwd_session_update_from_manager(ws_session, &mgr_session); - } - - if self.workspace.focused_session_id() == Some(session_id) { - self.sync_file_tree_to_focused_session(cx); - } - - self.spawn_session_git_refresh(session_id, mgr_session.working_directory.clone(), cx); - any_dirty = true; - } - - any_dirty |= self.sync_session_status(session_id); - - // Targeted delta: sync only this session's header instead of - // dirtying the full UI sync path for every output poll. - if any_dirty { - self.sync_session_header(session_id); - cx.notify(); - } - if has_more { - // Re-queue through the dispatcher so other sessions get fair - // scheduling in the next poll cycle (16ms), instead of immediately - // re-entering schedule_session_output_preparation which bypasses - // the dispatcher's focused-first prioritization. - self.output_dispatcher.mark_ready(session_id); - // Also mark in the legacy pending set so the legacy path picks - // it up when CODIRIGENT_LEGACY_PIPELINE=1 is active. - self.with_session_manager(|manager| manager.mark_output_pending(session_id)); - } - } - /// Spawn a background JSONL status check for all sessions if the last check /// was more than 3 seconds ago and no check is currently in-flight. /// diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs index 0d32952..f79000f 100644 --- a/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs +++ b/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs @@ -1,9 +1,444 @@ -//! Future home for output scheduling and prepared-output application helpers. -//! -//! Expected move targets in Phase B: -//! - `poll_output()` -//! - output scheduling and dispatcher handoff -//! - prepared-output application -//! - OSC 7 / OSC 133 extraction follow-up tied to output draining -//! -//! Phase A scaffolding only. Logic remains in the root module until Phase B. +//! Output scheduling and prepared-output application helpers. + +use super::WorkspaceView; +use crate::terminal_runtime::TerminalRenderSnapshot; +use codirigent_core::{CliType, SessionId, SessionManager, SessionUpdate}; +use codirigent_session::clipboard_service::ClipboardService; +use codirigent_session::detect_cli_from_output; +use gpui::Context; +use std::time::Instant; +use tracing::{info, trace}; + +#[derive(Debug)] +struct PreparedSessionOutput { + session_id: SessionId, + bytes_drained: usize, + has_more: bool, + render_snapshot: Option, + detected_cli_type: Option, + cwd_session: Option, +} + +fn prioritize_and_partition_output_sessions( + mut session_ids: Vec, + focused_id: Option, + mut can_schedule: F, +) -> (Vec, Vec) +where + F: FnMut(SessionId) -> bool, +{ + if let Some(focused_id) = focused_id { + if let Some(index) = session_ids.iter().position(|id| *id == focused_id) { + session_ids.swap(0, index); + } + } + + let mut ready = Vec::with_capacity(session_ids.len()); + let mut deferred = Vec::new(); + for session_id in session_ids { + if can_schedule(session_id) { + ready.push(session_id); + } else { + deferred.push(session_id); + } + } + + (ready, deferred) +} + +impl WorkspaceView { + pub(in crate::workspace) fn poll_output(&mut self, cx: &mut Context) { + self.process_deferred_enters(); + self.drain_vte_responses(); + + let had_output_activity = self.schedule_output_preparation(cx); + + // Track output activity for adaptive polling + // + // Sessions that actually produced output are synchronized in + // `apply_prepared_session_output()`. Detector-based status decay stays + // on the slower maintenance cadence to avoid O(all sessions) work on + // every active 16 ms poll. + self.polling.last_poll_had_output = had_output_activity; + } + + fn schedule_output_preparation(&mut self, cx: &mut Context) -> bool { + if super::is_legacy_pipeline() { + return self.schedule_output_preparation_legacy(cx); + } + + // Phase 1: Drain the event-driven mpsc channel into the dispatcher. + if let Some(ref mut rx) = self.update_rx { + let other_events = self.output_dispatcher.drain_updates(rx); + for event in other_events { + match event { + SessionUpdate::ChildProcessExited { session_id } => { + // PTY child exited; mark session ready so it gets a + // final output drain and status re-evaluation. + trace!( + ?session_id, + "ChildProcessExited: marking ready for final drain" + ); + self.output_dispatcher.mark_ready(session_id); + } + SessionUpdate::OutputReady { .. } => { + // Consumed by drain_updates into the ready set; should + // not appear here, but handle gracefully. + } + SessionUpdate::ShellStateChanged { session_id, .. } + | SessionUpdate::WorkingDirectoryChanged { session_id, .. } => { + // Phase-2: handled inline during output preparation + // (dual-path). Channel copies are informational only + // until phase-2 routing replaces the inline path. + trace!(?session_id, "phase-2 event received (not yet routed)"); + } + } + } + } + + // Phase 2: Low-frequency legacy safety net; drain the + // pending_output_sessions set at ~1s intervals to catch any sessions + // that bypass the mpsc channel (e.g., manual mark_output_pending + // calls). This is NOT the hot path; the dispatcher handles that. + if self.polling.last_legacy_fallback.elapsed() >= Self::LEGACY_FALLBACK_INTERVAL { + self.polling.last_legacy_fallback = Instant::now(); + let legacy_ids = + self.with_session_manager(|manager| manager.sessions_with_pending_output()); + if !legacy_ids.is_empty() { + trace!( + count = legacy_ids.len(), + "legacy fallback drain (safety net)" + ); + for id in &legacy_ids { + let was_new = self.output_dispatcher.mark_ready(*id); + // Shadow mode: log only genuinely missed events; sessions + // the mpsc channel didn't deliver to the dispatcher. + if super::is_shadow_status() && was_new { + info!( + ?id, + "shadow: legacy fallback discovered session not in dispatcher" + ); + } + } + } + } + + // Phase 3: Take ready sessions from the dispatcher (focused first). + let session_ids = self + .output_dispatcher + .take_ready_sessions(self.workspace.focused_session_id()); + + // Filter: only schedule sessions that have a terminal view. + // Sessions without a terminal yet (gap between create_session and + // terminals.insert) are re-queued so the next poll cycle picks them + // up, avoiding a ~1s delay waiting for the legacy fallback. + let mut schedulable = Vec::with_capacity(session_ids.len()); + for id in session_ids { + if self.terminals.contains_key(&id) { + schedulable.push(id); + } else { + self.output_dispatcher.mark_ready(id); + } + } + + let in_flight_count = self.output_dispatcher.in_flight_count(); + if !schedulable.is_empty() || in_flight_count > 0 { + trace!( + discovered_count = schedulable.len(), + in_flight_count, + "schedule_output_preparation" + ); + } + + let had_output_activity = !schedulable.is_empty() || self.output_dispatcher.has_activity(); + + for session_id in schedulable { + self.schedule_session_output_preparation(session_id, cx); + } + + had_output_activity + } + + /// Legacy output preparation path; uses the broad + /// `sessions_with_pending_output()` scan without the event-driven + /// dispatcher. Activated by `CODIRIGENT_LEGACY_PIPELINE=1`. + fn schedule_output_preparation_legacy(&mut self, cx: &mut Context) -> bool { + let session_ids = + self.with_session_manager(|manager| manager.sessions_with_pending_output()); + let (session_ids, deferred_ids) = prioritize_and_partition_output_sessions( + session_ids, + self.workspace.focused_session_id(), + |id| { + self.terminals.contains_key(&id) + && !self.polling.output_prepare_in_flight.contains(&id) + }, + ); + + if !deferred_ids.is_empty() { + self.with_session_manager(|manager| { + for session_id in deferred_ids { + manager.mark_output_pending(session_id); + } + }); + } + + let had_output_activity = + !session_ids.is_empty() || !self.polling.output_prepare_in_flight.is_empty(); + + for session_id in session_ids { + self.schedule_session_output_preparation(session_id, cx); + } + + had_output_activity + } + + fn schedule_session_output_preparation( + &mut self, + session_id: SessionId, + cx: &mut Context, + ) { + trace!(?session_id, "schedule_session_output_preparation"); + let Some(runtime) = self + .terminals + .get(&session_id) + .map(|tv| tv.runtime_handle()) + else { + trace!( + ?session_id, + "deferring output preparation until terminal runtime attaches" + ); + self.output_dispatcher.mark_ready(session_id); + self.with_session_manager(|manager| manager.mark_output_pending(session_id)); + return; + }; + + // Guard: prevent double-dispatch via the dispatcher's in-flight set. + if !self.output_dispatcher.mark_in_flight(session_id) { + return; + } + // TRANSITION: Legacy in-flight set kept in sync until + // CODIRIGENT_LEGACY_PIPELINE and schedule_output_preparation_legacy + // are removed. Both sets are always updated together. + self.polling.output_prepare_in_flight.insert(session_id); + debug_assert_eq!( + self.polling.output_prepare_in_flight.len(), + self.output_dispatcher.in_flight_count(), + "dual in-flight sets desynchronized after marking session {} in-flight", + session_id.0, + ); + + let session_manager = self.session_manager.clone(); + let detector = self.detector.clone(); + let update_tx = self.update_tx.clone(); + + cx.spawn(async move |this: gpui::WeakEntity, cx| { + let prepared = cx + .background_executor() + .spawn(async move { + let drained = { + let manager = session_manager.lock().ok()?; + manager.try_drain_output_bounded( + session_id, + Self::MAX_OUTPUT_CHUNKS_PER_POLL, + Self::MAX_OUTPUT_BYTES_PER_POLL, + ) + }?; + + let data = drained.data; + let bytes_drained = data.len(); + let render_snapshot = runtime.apply_output(&data); + let detected_cli_type = detect_cli_from_output(&data); + + { + let mut detector = detector.lock().ok()?; + detector.process_output(session_id, &data); + for event in codirigent_session::extract_osc133_events(&data) { + // DUAL-PATH: Emitted to channel for phase-2 event routing. + // Also applied directly below via set_shell_state() for correctness now. + if let Some(tx) = &update_tx { + if let Err(e) = tx.try_send(SessionUpdate::ShellStateChanged { + session_id, + state: event.clone(), + }) { + trace!("ShellStateChanged try_send for {}: {e}", session_id.0); + } + } + detector.set_shell_state(session_id, event); + } + } + + let cwd_session = + codirigent_session::extract_osc7_path(&data).and_then(|new_cwd| { + // DUAL-PATH: Emitted to channel for phase-2 event routing. + // Also applied directly below via update_working_directory() for correctness now. + if let Some(tx) = &update_tx { + if let Err(e) = + tx.try_send(SessionUpdate::WorkingDirectoryChanged { + session_id, + cwd: new_cwd.clone(), + }) + { + trace!( + "WorkingDirectoryChanged try_send for {}: {e}", + session_id.0 + ); + } + } + let manager = session_manager.lock().ok()?; + let changed = manager.update_working_directory(session_id, new_cwd); + if changed { + manager.get_session(session_id) + } else { + None + } + }); + + Some(PreparedSessionOutput { + session_id, + bytes_drained, + has_more: drained.has_more, + render_snapshot, + detected_cli_type, + cwd_session, + }) + }) + .await; + + let _ = this.update(cx, |this, cx| { + this.polling.output_prepare_in_flight.remove(&session_id); + this.output_dispatcher.complete_in_flight(session_id); + debug_assert_eq!( + this.polling.output_prepare_in_flight.len(), + this.output_dispatcher.in_flight_count(), + "dual in-flight sets desynchronized after completing session {}", + session_id.0, + ); + if let Some(prepared) = prepared { + this.apply_prepared_session_output(prepared, cx); + } else { + // No output to drain (e.g. ChildProcessExited with no + // trailing bytes). Still run status reconciliation so + // OSC133-driven sessions don't stick in Working after + // the PTY exits. + if this.sync_session_status(session_id) { + this.sync_session_header(session_id); + cx.notify(); + } + } + }); + }) + .detach(); + } + + fn apply_prepared_session_output( + &mut self, + prepared: PreparedSessionOutput, + cx: &mut Context, + ) { + let PreparedSessionOutput { + session_id, + bytes_drained, + has_more, + render_snapshot, + detected_cli_type, + cwd_session, + } = prepared; + trace!( + ?session_id, + bytes_drained, + has_more, + "apply_prepared_session_output" + ); + let mut any_dirty = false; + + if let Some(snapshot) = render_snapshot { + if let Some(terminal_view) = self.terminals.get_mut(&session_id) { + any_dirty |= terminal_view.apply_snapshot(snapshot); + } + } + + if let Some(cli_type) = detected_cli_type { + let current = self + .clipboard + .clipboard_service + .get_session_cli_type(session_id); + if current == CliType::GenericShell { + self.clipboard + .clipboard_service + .set_session_cli_type(session_id, cli_type); + info!(?session_id, ?cli_type, "Detected CLI type from output"); + } + } + + if let Some(mgr_session) = cwd_session { + if let Some(header) = self.terminal_headers.get_mut(&session_id) { + header.git_branch = None; + header.git_dirty_count = None; + } + + if let Some(ws_session) = self.workspace.session_mut(session_id) { + super::git_refresh::apply_cwd_session_update_from_manager(ws_session, &mgr_session); + } + + if self.workspace.focused_session_id() == Some(session_id) { + self.sync_file_tree_to_focused_session(cx); + } + + self.spawn_session_git_refresh(session_id, mgr_session.working_directory.clone(), cx); + any_dirty = true; + } + + any_dirty |= self.sync_session_status(session_id); + + // Targeted delta: sync only this session's header instead of + // dirtying the full UI sync path for every output poll. + if any_dirty { + self.sync_session_header(session_id); + cx.notify(); + } + if has_more { + // Re-queue through the dispatcher so other sessions get fair + // scheduling in the next poll cycle (16ms), instead of immediately + // re-entering schedule_session_output_preparation which bypasses + // the dispatcher's focused-first prioritization. + self.output_dispatcher.mark_ready(session_id); + // Also mark in the legacy pending set so the legacy path picks + // it up when CODIRIGENT_LEGACY_PIPELINE=1 is active. + self.with_session_manager(|manager| manager.mark_output_pending(session_id)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn focused_schedulable_output_is_prioritized() { + let session_ids = vec![SessionId(1), SessionId(2), SessionId(3)]; + let schedulable = HashSet::from([SessionId(2), SessionId(3)]); + + let (ready, deferred) = + prioritize_and_partition_output_sessions(session_ids, Some(SessionId(2)), |id| { + schedulable.contains(&id) + }); + + assert_eq!(ready, vec![SessionId(2), SessionId(3)]); + assert_eq!(deferred, vec![SessionId(1)]); + } + + #[test] + fn unschedulable_output_sessions_are_deferred_instead_of_dropped() { + let session_ids = vec![SessionId(1), SessionId(2), SessionId(3)]; + let schedulable = HashSet::from([SessionId(3)]); + + let (ready, deferred) = + prioritize_and_partition_output_sessions(session_ids, Some(SessionId(2)), |id| { + schedulable.contains(&id) + }); + + assert_eq!(ready, vec![SessionId(3)]); + assert_eq!(deferred, vec![SessionId(2), SessionId(1)]); + } +} diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/tests.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/tests.rs index edd0f84..f87b52f 100644 --- a/crates/codirigent-ui/src/workspace/impl_output_polling/tests.rs +++ b/crates/codirigent-ui/src/workspace/impl_output_polling/tests.rs @@ -68,31 +68,3 @@ fn larger_dib_preview_is_allowed() { assert!(should_show_clipboard_preview(&image)); } - -#[test] -fn focused_schedulable_output_is_prioritized() { - let session_ids = vec![SessionId(1), SessionId(2), SessionId(3)]; - let schedulable = HashSet::from([SessionId(2), SessionId(3)]); - - let (ready, deferred) = - prioritize_and_partition_output_sessions(session_ids, Some(SessionId(2)), |id| { - schedulable.contains(&id) - }); - - assert_eq!(ready, vec![SessionId(2), SessionId(3)]); - assert_eq!(deferred, vec![SessionId(1)]); -} - -#[test] -fn unschedulable_output_sessions_are_deferred_instead_of_dropped() { - let session_ids = vec![SessionId(1), SessionId(2), SessionId(3)]; - let schedulable = HashSet::from([SessionId(3)]); - - let (ready, deferred) = - prioritize_and_partition_output_sessions(session_ids, Some(SessionId(2)), |id| { - schedulable.contains(&id) - }); - - assert_eq!(ready, vec![SessionId(3)]); - assert_eq!(deferred, vec![SessionId(2), SessionId(1)]); -} From 0d10c59561005cf9ecb5f1f5e1b13056ad47bb9e Mon Sep 17 00:00:00 2001 From: oso95 Date: Thu, 12 Mar 2026 16:44:16 -0400 Subject: [PATCH 06/10] refactor: split workspace gpui helpers --- crates/codirigent-ui/src/workspace/gpui.rs | 275 +----------------- .../src/workspace/gpui/derived_state.rs | 265 ++++++++++++++++- .../src/workspace/gpui/session_metadata.rs | 90 +++++- .../codirigent-ui/src/workspace/gpui/tests.rs | 59 ---- .../workspace-module-split-plan.md | 18 +- 5 files changed, 360 insertions(+), 347 deletions(-) diff --git a/crates/codirigent-ui/src/workspace/gpui.rs b/crates/codirigent-ui/src/workspace/gpui.rs index f3d3de4..63480dc 100644 --- a/crates/codirigent-ui/src/workspace/gpui.rs +++ b/crates/codirigent-ui/src/workspace/gpui.rs @@ -9,11 +9,11 @@ //! - GPUI `Render` trait implementation for drawing the UI //! - GPUI `Focusable` trait for keyboard focus management //! -//! Phase A scaffolding note: +//! Split note: //! - The root keeps `WorkspaceView`, constructor wiring, trait impls, and //! orchestration entry points. -//! - Child modules under `workspace/gpui/` are created now so later phases can -//! move helper clusters without changing public module paths. +//! - Lightweight helper clusters are moving under `workspace/gpui/` without +//! changing public module paths. //! //! # Example //! @@ -30,8 +30,8 @@ mod layout_sync; mod session_metadata; mod ui_events; -// Phase A keeps the implementation in this root file. The child modules above -// are intentionally empty scaffolding until Phase C moves helper clusters. +// The root still owns `WorkspaceView`, trait impls, and high-level +// orchestration. Child modules hold lower-coupling helper clusters. use super::core::Workspace; use super::editor_detection::detect_monospace_fonts; @@ -71,26 +71,6 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use tracing::{info, warn}; -fn session_project_name(session: &codirigent_core::Session) -> Option { - session - .git_info - .as_ref() - .and_then(|git_info| git_info.repo_root.file_name()) - .or_else(|| session.working_directory.file_name()) - .and_then(|name| name.to_str()) - .map(str::to_owned) -} - -fn resolved_task_title( - task_id: &codirigent_core::TaskId, - task_titles: Option<&HashMap>, -) -> String { - task_titles - .and_then(|titles| titles.get(task_id)) - .cloned() - .unwrap_or_else(|| task_id.0.to_string()) -} - /// GPUI View wrapper for Workspace. /// /// This is the main workspace view that renders the grid of session panes. @@ -873,251 +853,6 @@ impl WorkspaceView { } } - fn task_title_for_session( - &self, - session: &codirigent_core::Session, - task_titles: Option<&HashMap>, - ) -> Option { - let task_id = session.current_task.as_ref()?; - if let Some(task_titles) = task_titles { - return Some(resolved_task_title(task_id, Some(task_titles))); - } - - if let Ok(manager) = self.task_manager.lock() { - return Some( - manager - .get_task(task_id) - .map(|task| task.title.clone()) - .unwrap_or_else(|| task_id.0.to_string()), - ); - } - - Some(task_id.0.to_string()) - } - - fn sync_task_board_state(&mut self) -> HashMap { - let Ok(manager) = self.task_manager.lock() else { - return HashMap::new(); - }; - - let mut titles = HashMap::new(); - let all_tasks = manager.list_tasks(); - let counts = - all_tasks - .iter() - .fold((0usize, 0usize, 0usize, 0usize), |(q, ip, r, d), task| { - titles.insert(task.id.clone(), task.title.clone()); - match task.status { - codirigent_core::TaskStatus::Queued - | codirigent_core::TaskStatus::Blocked => (q + 1, ip, r, d), - codirigent_core::TaskStatus::Assigned - | codirigent_core::TaskStatus::Working => (q, ip + 1, r, d), - codirigent_core::TaskStatus::Verifying - | codirigent_core::TaskStatus::Review => (q, ip, r + 1, d), - codirigent_core::TaskStatus::Done => (q, ip, r, d + 1), - } - }); - let running_items = all_tasks - .iter() - .filter(|t| { - matches!( - t.status, - codirigent_core::TaskStatus::Assigned | codirigent_core::TaskStatus::Working - ) - }) - .map(|t| self.core_task_to_ui_item(t)) - .collect(); - let queued_items = all_tasks - .iter() - .filter(|t| { - matches!( - t.status, - codirigent_core::TaskStatus::Queued | codirigent_core::TaskStatus::Blocked - ) - }) - .map(|t| self.core_task_to_ui_item(t)) - .collect(); - let review_items = all_tasks - .iter() - .filter(|t| { - matches!( - t.status, - codirigent_core::TaskStatus::Verifying | codirigent_core::TaskStatus::Review - ) - }) - .map(|t| self.core_task_to_ui_item(t)) - .collect(); - let done_items = all_tasks - .iter() - .filter(|t| t.status == codirigent_core::TaskStatus::Done) - .map(|t| self.core_task_to_ui_item(t)) - .collect(); - let config = manager.assignment().config(); - let auto_assign_mode = crate::task_board::AutoAssignMode::from_config( - config.auto_assign, - config.confirm_before_assign, - ); - let pending_assignments = manager - .assignment() - .pending_assignments() - .iter() - .map(|p| crate::task_board::PendingAssignmentSummary { - task_id: p.task_id.to_string(), - session_number: p.session_id.0, - task_title: all_tasks - .iter() - .find(|t| t.id == p.task_id) - .map(|t| t.title.clone()) - .unwrap_or_else(|| p.task_id.to_string()), - }) - .collect(); - - self.task_board - .set_task_counts(counts.0, counts.1, counts.2, counts.3); - self.task_board - .set_snapshot(crate::task_board::TaskBoardSnapshot { - running_items, - queued_items, - review_items, - done_items, - auto_assign_mode, - pending_assignments, - }); - - titles - } - - fn sync_all_session_headers( - &mut self, - task_titles: Option<&HashMap>, - ) { - let sessions = self.workspace.sessions(); - let focused_id = self.workspace.focused_session_id(); - for session in sessions { - let project_name = session_project_name(session); - let git_branch = session.git_info.as_ref().map(|gi| gi.branch.clone()); - let git_dirty_count = session.git_info.as_ref().map(|gi| gi.dirty_count); - let session_color = session - .color - .as_deref() - .map(crate::sidebar::Color::from_hex) - .unwrap_or_else(|| crate::sidebar::Color::from_hex("#6366f1")); - let task = self.task_title_for_session(session, task_titles); - if let Some(header) = self.terminal_headers.get_mut(&session.id) { - if header.session_name != session.name { - header.session_name = session.name.clone(); - } - if header.group_name != session.group { - header.group_name = session.group.clone(); - } - header.status = session.status; - header.context_usage = session.context_usage; - header.is_focused = focused_id == Some(session.id); - if header.project_name != project_name { - header.project_name = project_name; - } - if header.git_branch != git_branch { - header.git_branch = git_branch; - } - if header.git_dirty_count != git_dirty_count { - header.git_dirty_count = git_dirty_count; - } - if header.session_color != session_color { - header.session_color = session_color; - } - if header.task != task { - header.task = task; - } - } - } - } - - fn sync_empty_cells_state(&mut self) { - let (rows, cols) = self.workspace.layout_profile().dimensions(); - let occupied: Vec = self - .workspace - .sessions() - .iter() - .enumerate() - .map(|(i, _)| { - let row = i as u32 / cols; - let col = i as u32 % cols; - GridPosition { row, col } - }) - .collect(); - self.empty_cells.setup_for_grid(rows, cols, &occupied); - } - - pub(super) fn sync_layout_derived_state(&mut self) { - self.sync_all_session_headers(None); - self.sync_empty_cells_state(); - } - - pub(super) fn sync_task_derived_state(&mut self) { - let task_titles = self.sync_task_board_state(); - self.sync_all_session_headers(Some(&task_titles)); - } - - /// Synchronize all derived UI state from canonical workspace/task state. - /// - /// This must only run from explicit mutation paths, never as a render fallback. - pub(super) fn refresh_derived_ui_state(&mut self) { - let task_titles = self.sync_task_board_state(); - self.sync_all_session_headers(Some(&task_titles)); - self.sync_empty_cells_state(); - } - - /// Sync a single session's terminal header from workspace state. - /// - /// This is a targeted delta update for the common case where only one - /// session's status changed. Avoids the O(all sessions) cost of - /// `refresh_derived_ui_state()` for each output poll. - pub(super) fn sync_session_header(&mut self, session_id: SessionId) { - let Some(session) = self.workspace.session(session_id) else { - return; - }; - let focused_id = self.workspace.focused_session_id(); - let project_name = session_project_name(session); - let git_branch = session.git_info.as_ref().map(|gi| gi.branch.clone()); - let git_dirty_count = session.git_info.as_ref().map(|gi| gi.dirty_count); - let session_color = session - .color - .as_deref() - .map(crate::sidebar::Color::from_hex) - .unwrap_or_else(|| crate::sidebar::Color::from_hex("#6366f1")); - let task = self.task_title_for_session(session, None); - - if let Some(header) = self.terminal_headers.get_mut(&session_id) { - header.status = session.status; - header.context_usage = session.context_usage; - header.is_focused = focused_id == Some(session_id); - - if header.session_name != session.name { - header.session_name = session.name.clone(); - } - if header.group_name != session.group { - header.group_name = session.group.clone(); - } - - if header.project_name != project_name { - header.project_name = project_name; - } - - if header.git_branch != git_branch { - header.git_branch = git_branch; - } - if header.git_dirty_count != git_dirty_count { - header.git_dirty_count = git_dirty_count; - } - if header.session_color != session_color { - header.session_color = session_color; - } - if header.task != task { - header.task = task; - } - } - } - /// Get a terminal header for a session. pub fn get_terminal_header(&self, id: SessionId) -> Option<&TerminalHeader> { self.terminal_headers.get(&id) diff --git a/crates/codirigent-ui/src/workspace/gpui/derived_state.rs b/crates/codirigent-ui/src/workspace/gpui/derived_state.rs index 171a9db..28964a8 100644 --- a/crates/codirigent-ui/src/workspace/gpui/derived_state.rs +++ b/crates/codirigent-ui/src/workspace/gpui/derived_state.rs @@ -1,9 +1,256 @@ -//! Future home for derived UI state reducers and refresh helpers. -//! -//! Expected move targets in Phase C: -//! - task-board reducer helpers -//! - session-header synchronization helpers -//! - empty-cell synchronization helpers -//! - explicit derived-state refresh entry points -//! -//! Phase A scaffolding only. Logic remains in the root module until Phase C. +//! Derived UI state reducers and refresh helpers. + +use super::session_metadata::session_project_name; +use super::WorkspaceView; +use codirigent_core::SessionId; +use std::collections::HashMap; + +impl WorkspaceView { + fn task_title_for_session( + &self, + session: &codirigent_core::Session, + task_titles: Option<&HashMap>, + ) -> Option { + let task_id = session.current_task.as_ref()?; + if let Some(task_titles) = task_titles { + return Some(super::session_metadata::resolved_task_title( + task_id, + Some(task_titles), + )); + } + + if let Ok(manager) = self.task_manager.lock() { + return Some( + manager + .get_task(task_id) + .map(|task| task.title.clone()) + .unwrap_or_else(|| task_id.0.to_string()), + ); + } + + Some(task_id.0.to_string()) + } + + fn sync_task_board_state(&mut self) -> HashMap { + let Ok(manager) = self.task_manager.lock() else { + return HashMap::new(); + }; + + let mut titles = HashMap::new(); + let all_tasks = manager.list_tasks(); + let counts = + all_tasks + .iter() + .fold((0usize, 0usize, 0usize, 0usize), |(q, ip, r, d), task| { + titles.insert(task.id.clone(), task.title.clone()); + match task.status { + codirigent_core::TaskStatus::Queued + | codirigent_core::TaskStatus::Blocked => (q + 1, ip, r, d), + codirigent_core::TaskStatus::Assigned + | codirigent_core::TaskStatus::Working => (q, ip + 1, r, d), + codirigent_core::TaskStatus::Verifying + | codirigent_core::TaskStatus::Review => (q, ip, r + 1, d), + codirigent_core::TaskStatus::Done => (q, ip, r, d + 1), + } + }); + let running_items = all_tasks + .iter() + .filter(|t| { + matches!( + t.status, + codirigent_core::TaskStatus::Assigned | codirigent_core::TaskStatus::Working + ) + }) + .map(|t| self.core_task_to_ui_item(t)) + .collect(); + let queued_items = all_tasks + .iter() + .filter(|t| { + matches!( + t.status, + codirigent_core::TaskStatus::Queued | codirigent_core::TaskStatus::Blocked + ) + }) + .map(|t| self.core_task_to_ui_item(t)) + .collect(); + let review_items = all_tasks + .iter() + .filter(|t| { + matches!( + t.status, + codirigent_core::TaskStatus::Verifying | codirigent_core::TaskStatus::Review + ) + }) + .map(|t| self.core_task_to_ui_item(t)) + .collect(); + let done_items = all_tasks + .iter() + .filter(|t| t.status == codirigent_core::TaskStatus::Done) + .map(|t| self.core_task_to_ui_item(t)) + .collect(); + let config = manager.assignment().config(); + let auto_assign_mode = crate::task_board::AutoAssignMode::from_config( + config.auto_assign, + config.confirm_before_assign, + ); + let pending_assignments = manager + .assignment() + .pending_assignments() + .iter() + .map(|p| crate::task_board::PendingAssignmentSummary { + task_id: p.task_id.to_string(), + session_number: p.session_id.0, + task_title: all_tasks + .iter() + .find(|t| t.id == p.task_id) + .map(|t| t.title.clone()) + .unwrap_or_else(|| p.task_id.to_string()), + }) + .collect(); + + self.task_board + .set_task_counts(counts.0, counts.1, counts.2, counts.3); + self.task_board + .set_snapshot(crate::task_board::TaskBoardSnapshot { + running_items, + queued_items, + review_items, + done_items, + auto_assign_mode, + pending_assignments, + }); + + titles + } + + fn sync_all_session_headers( + &mut self, + task_titles: Option<&HashMap>, + ) { + let sessions = self.workspace.sessions(); + let focused_id = self.workspace.focused_session_id(); + for session in sessions { + let project_name = session_project_name(session); + let git_branch = session.git_info.as_ref().map(|gi| gi.branch.clone()); + let git_dirty_count = session.git_info.as_ref().map(|gi| gi.dirty_count); + let session_color = session + .color + .as_deref() + .map(crate::sidebar::Color::from_hex) + .unwrap_or_else(|| crate::sidebar::Color::from_hex("#6366f1")); + let task = self.task_title_for_session(session, task_titles); + if let Some(header) = self.terminal_headers.get_mut(&session.id) { + if header.session_name != session.name { + header.session_name = session.name.clone(); + } + if header.group_name != session.group { + header.group_name = session.group.clone(); + } + header.status = session.status; + header.context_usage = session.context_usage; + header.is_focused = focused_id == Some(session.id); + if header.project_name != project_name { + header.project_name = project_name; + } + if header.git_branch != git_branch { + header.git_branch = git_branch; + } + if header.git_dirty_count != git_dirty_count { + header.git_dirty_count = git_dirty_count; + } + if header.session_color != session_color { + header.session_color = session_color; + } + if header.task != task { + header.task = task; + } + } + } + } + + fn sync_empty_cells_state(&mut self) { + let (rows, cols) = self.workspace.layout_profile().dimensions(); + let occupied: Vec = self + .workspace + .sessions() + .iter() + .enumerate() + .map(|(i, _)| { + let row = i as u32 / cols; + let col = i as u32 % cols; + codirigent_core::GridPosition { row, col } + }) + .collect(); + self.empty_cells.setup_for_grid(rows, cols, &occupied); + } + + pub(in crate::workspace) fn sync_layout_derived_state(&mut self) { + self.sync_all_session_headers(None); + self.sync_empty_cells_state(); + } + + pub(in crate::workspace) fn sync_task_derived_state(&mut self) { + let task_titles = self.sync_task_board_state(); + self.sync_all_session_headers(Some(&task_titles)); + } + + /// Synchronize all derived UI state from canonical workspace/task state. + /// + /// This must only run from explicit mutation paths, never as a render fallback. + pub(in crate::workspace) fn refresh_derived_ui_state(&mut self) { + let task_titles = self.sync_task_board_state(); + self.sync_all_session_headers(Some(&task_titles)); + self.sync_empty_cells_state(); + } + + /// Sync a single session's terminal header from workspace state. + /// + /// This is a targeted delta update for the common case where only one + /// session's status changed. Avoids the O(all sessions) cost of + /// `refresh_derived_ui_state()` for each output poll. + pub(in crate::workspace) fn sync_session_header(&mut self, session_id: SessionId) { + let Some(session) = self.workspace.session(session_id) else { + return; + }; + let focused_id = self.workspace.focused_session_id(); + let project_name = session_project_name(session); + let git_branch = session.git_info.as_ref().map(|gi| gi.branch.clone()); + let git_dirty_count = session.git_info.as_ref().map(|gi| gi.dirty_count); + let session_color = session + .color + .as_deref() + .map(crate::sidebar::Color::from_hex) + .unwrap_or_else(|| crate::sidebar::Color::from_hex("#6366f1")); + let task = self.task_title_for_session(session, None); + + if let Some(header) = self.terminal_headers.get_mut(&session_id) { + header.status = session.status; + header.context_usage = session.context_usage; + header.is_focused = focused_id == Some(session_id); + + if header.session_name != session.name { + header.session_name = session.name.clone(); + } + if header.group_name != session.group { + header.group_name = session.group.clone(); + } + + if header.project_name != project_name { + header.project_name = project_name; + } + + if header.git_branch != git_branch { + header.git_branch = git_branch; + } + if header.git_dirty_count != git_dirty_count { + header.git_dirty_count = git_dirty_count; + } + if header.session_color != session_color { + header.session_color = session_color; + } + if header.task != task { + header.task = task; + } + } + } +} diff --git a/crates/codirigent-ui/src/workspace/gpui/session_metadata.rs b/crates/codirigent-ui/src/workspace/gpui/session_metadata.rs index ce731a5..3f99f8e 100644 --- a/crates/codirigent-ui/src/workspace/gpui/session_metadata.rs +++ b/crates/codirigent-ui/src/workspace/gpui/session_metadata.rs @@ -1,8 +1,82 @@ -//! Future home for lightweight session metadata helpers. -//! -//! Expected move targets in Phase C: -//! - project-name derivation -//! - task-title resolution helpers -//! - other leaf-like session metadata helpers -//! -//! Phase A scaffolding only. Logic remains in the root module until Phase C. +//! Lightweight session metadata helpers. + +use std::collections::HashMap; + +pub(super) fn session_project_name(session: &codirigent_core::Session) -> Option { + session + .git_info + .as_ref() + .and_then(|git_info| git_info.repo_root.file_name()) + .or_else(|| session.working_directory.file_name()) + .and_then(|name| name.to_str()) + .map(str::to_owned) +} + +pub(super) fn resolved_task_title( + task_id: &codirigent_core::TaskId, + task_titles: Option<&HashMap>, +) -> String { + task_titles + .and_then(|titles| titles.get(task_id)) + .cloned() + .unwrap_or_else(|| task_id.0.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_project_name_prefers_git_repo_root_name() { + let mut session = codirigent_core::Session::new( + codirigent_core::SessionId(1), + "Session 1".to_string(), + std::path::PathBuf::from("/workspace/subdir"), + ); + session.git_info = Some(codirigent_core::GitRepoInfo { + repo_root: std::path::PathBuf::from("/workspace/project-root"), + branch: "main".to_string(), + dirty_count: 0, + has_staged: false, + head_sha: None, + unstaged_files: Vec::new(), + staged_files: Vec::new(), + }); + + assert_eq!( + session_project_name(&session), + Some("project-root".to_string()) + ); + } + + #[test] + fn session_project_name_falls_back_to_working_directory_name() { + let session = codirigent_core::Session::new( + codirigent_core::SessionId(1), + "Session 1".to_string(), + std::path::PathBuf::from("/workspace/focused-pane"), + ); + + assert_eq!( + session_project_name(&session), + Some("focused-pane".to_string()) + ); + } + + #[test] + fn resolved_task_title_prefers_cached_title_and_falls_back_to_id() { + let task_id = codirigent_core::TaskId::from("task-123"); + let mut titles = HashMap::new(); + titles.insert(task_id.clone(), "Review parser".to_string()); + + assert_eq!( + resolved_task_title(&task_id, Some(&titles)), + "Review parser".to_string() + ); + assert_eq!( + resolved_task_title(&codirigent_core::TaskId::from("task-456"), Some(&titles)), + "task-456".to_string() + ); + assert_eq!(resolved_task_title(&task_id, None), "task-123".to_string()); + } +} diff --git a/crates/codirigent-ui/src/workspace/gpui/tests.rs b/crates/codirigent-ui/src/workspace/gpui/tests.rs index b336e8d..c2e8ef2 100644 --- a/crates/codirigent-ui/src/workspace/gpui/tests.rs +++ b/crates/codirigent-ui/src/workspace/gpui/tests.rs @@ -27,8 +27,6 @@ //! - [ ] Focus delegation to child components //! - [ ] Layout changes trigger re-render -use std::collections::HashMap; - #[test] fn test_core_workspace_is_tested_separately() { // Reminder: Core workspace logic has dedicated tests in workspace/tests.rs @@ -118,63 +116,6 @@ fn test_normalize_codex_execution_mode_detects_explicit_never_and_danger() { ); } -#[test] -fn test_session_project_name_prefers_git_repo_root_name() { - let mut session = codirigent_core::Session::new( - codirigent_core::SessionId(1), - "Session 1".to_string(), - std::path::PathBuf::from("/workspace/subdir"), - ); - session.git_info = Some(codirigent_core::GitRepoInfo { - repo_root: std::path::PathBuf::from("/workspace/project-root"), - branch: "main".to_string(), - dirty_count: 0, - has_staged: false, - head_sha: None, - unstaged_files: Vec::new(), - staged_files: Vec::new(), - }); - - assert_eq!( - super::session_project_name(&session), - Some("project-root".to_string()) - ); -} - -#[test] -fn test_session_project_name_falls_back_to_working_directory_name() { - let session = codirigent_core::Session::new( - codirigent_core::SessionId(1), - "Session 1".to_string(), - std::path::PathBuf::from("/workspace/focused-pane"), - ); - - assert_eq!( - super::session_project_name(&session), - Some("focused-pane".to_string()) - ); -} - -#[test] -fn test_resolved_task_title_prefers_cached_title_and_falls_back_to_id() { - let task_id = codirigent_core::TaskId::from("task-123"); - let mut titles = HashMap::new(); - titles.insert(task_id.clone(), "Review parser".to_string()); - - assert_eq!( - super::resolved_task_title(&task_id, Some(&titles)), - "Review parser".to_string() - ); - assert_eq!( - super::resolved_task_title(&codirigent_core::TaskId::from("task-456"), Some(&titles)), - "task-456".to_string() - ); - assert_eq!( - super::resolved_task_title(&task_id, None), - "task-123".to_string() - ); -} - #[test] fn test_keystroke_is_text_input_for_plain_printable_without_key_char() { let event = gpui::KeyDownEvent { diff --git a/docs/architecture/workspace-module-split-plan.md b/docs/architecture/workspace-module-split-plan.md index 784bc27..ba154ec 100644 --- a/docs/architecture/workspace-module-split-plan.md +++ b/docs/architecture/workspace-module-split-plan.md @@ -318,6 +318,10 @@ cargo check -p codirigent-ui --features gpui-full git diff --check ``` +Additional verification requirement: + +- inspect the task diff and confirm no new production `unwrap()` or `expect()` calls were introduced + ##### Task A2: Scaffold `gpui` internal modules Create the internal directory and child files under `crates/codirigent-ui/src/workspace/gpui/`: @@ -376,6 +380,10 @@ cargo check -p codirigent-ui --features gpui-full git diff --check ``` +Additional verification requirement: + +- inspect the task diff and confirm no new production `unwrap()` or `expect()` calls were introduced + ##### Task A3: Externalize root test modules Create dedicated test files: @@ -420,6 +428,10 @@ cargo check -p codirigent-ui --features gpui-full git diff --check ``` +Additional verification requirement: + +- inspect the task diff and confirm no new production `unwrap()` or `expect()` calls were introduced + ##### Task A4: Ownership comments and import hygiene This is the final scaffolding pass before logic moves begin. @@ -450,6 +462,10 @@ cargo check -p codirigent-ui --features gpui-full git diff --check ``` +Additional verification requirement: + +- inspect the task diff and confirm no new production `unwrap()` or `expect()` calls were introduced + #### Phase A Cross-Platform Requirements Phase A is structural, but it still needs to preserve cross-platform correctness. @@ -532,7 +548,7 @@ This refactor must prove behavior did not change. - existing unit tests stay green - existing integration tests stay green - no new warnings -- no new `unwrap()` in production paths +- no new production `unwrap()` or `expect()` paths ### Focused regression tests From ceb5ffc601a95b1a4b1497152ee7dd3512b6ea99 Mon Sep 17 00:00:00 2001 From: oso95 Date: Thu, 12 Mar 2026 16:48:50 -0400 Subject: [PATCH 07/10] refactor: split workspace gpui events --- crates/codirigent-ui/src/workspace/gpui.rs | 129 +--------------- .../src/workspace/gpui/ui_events.rs | 141 ++++++++++++++++-- 2 files changed, 135 insertions(+), 135 deletions(-) diff --git a/crates/codirigent-ui/src/workspace/gpui.rs b/crates/codirigent-ui/src/workspace/gpui.rs index 63480dc..5ac9f1c 100644 --- a/crates/codirigent-ui/src/workspace/gpui.rs +++ b/crates/codirigent-ui/src/workspace/gpui.rs @@ -41,7 +41,7 @@ use super::types::{ TERMINAL_CONTENT_PADDING, }; use crate::clipboard_preview::ClipboardPreview; -use crate::empty_session::{EmptySessionEvent, EmptySessionPool}; +use crate::empty_session::EmptySessionPool; use crate::input::{key_to_bytes, TerminalKeystroke, TerminalModifiers}; use crate::sidebar::{FileTreePanel, WorktreePanel}; use crate::task_board::TaskBoardPanel; @@ -52,8 +52,7 @@ use crate::toolbar::CustomLayoutPicker; use codirigent_core::compaction::{CompactionConfig, CompactionService}; use codirigent_core::{ CodexExecutionMode, CodirigentEvent, DefaultEventBus, EventBus, FileStorageService, - GridPosition, ProcessMonitor, SessionId, SessionManager, SessionStatus, TaskManager, - TaskManagerConfig, + ProcessMonitor, SessionId, SessionManager, SessionStatus, TaskManager, TaskManagerConfig, }; use codirigent_detector::{InputDetector, NotificationManager}; use codirigent_filetree::FileTree; @@ -69,7 +68,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -use tracing::{info, warn}; +use tracing::warn; /// GPUI View wrapper for Workspace. /// @@ -694,24 +693,6 @@ impl WorkspaceView { (file_tree, None, project_root) } - /// Check if a session should be created at the given position. - /// Returns true if this is not a duplicate click (same position within 100ms). - pub(super) fn should_create_session_at(&mut self, position: GridPosition) -> bool { - let now = Instant::now(); - - // Check if this is a duplicate click - if let Some((last_pos, last_time)) = self.selection.last_click_position { - if last_pos == position && now.duration_since(last_time) < Duration::from_millis(100) { - info!(?position, "Ignoring duplicate click within 100ms"); - return false; - } - } - - // Update last click position - self.selection.last_click_position = Some((position, now)); - true - } - fn persisted_layout_mode(&self) -> codirigent_core::LayoutMode { match self.workspace.layout_state() { crate::layout::WorkspaceLayoutState::Grid(state) => state.profile().to_mode(), @@ -858,82 +839,6 @@ impl WorkspaceView { self.terminal_headers.get(&id) } - /// Process pending events from all UI components. - /// - /// This method is called at the start of each render cycle to handle - /// any pending events from task board, empty session cells, etc. - fn process_ui_events(&mut self, cx: &mut Context) { - // Process task board events - for event in self.task_board.take_events() { - self.handle_task_board_event(event, cx); - } - - // Process empty session events - for event in self.empty_cells.take_events() { - self.handle_empty_session_event(event, cx); - } - } - - /// Process pending top bar events and translate to workspace actions. - pub(super) fn process_top_bar_events(&mut self) { - let events = self.top_bar.drain_events(); - for event in events { - match event { - crate::top_bar::TopBarEvent::LayoutSelected(layout_mode) => { - match layout_mode { - codirigent_core::LayoutMode::Grid { rows, cols } => { - let profile = match (rows, cols) { - (2, 2) => crate::layout::LayoutProfile::Grid2x2, - (4, 1) => crate::layout::LayoutProfile::Stack1x4, - (2, 3) => crate::layout::LayoutProfile::Grid2x3, - (3, 3) => crate::layout::LayoutProfile::Grid3x3, - _ => crate::layout::LayoutProfile::Custom { rows, cols }, - }; - self.workspace.set_layout(profile); - } - codirigent_core::LayoutMode::Single => { - self.workspace - .set_layout(crate::layout::LayoutProfile::Single); - } - codirigent_core::LayoutMode::SplitTree { root } => { - self.workspace.set_split_tree(root); - } - codirigent_core::LayoutMode::Custom { .. } => { - // Custom positional layouts not used from tabs - } - } - self.mark_layout_cache_dirty(); - self.sync_layout_derived_state(); - } - crate::top_bar::TopBarEvent::RightPanelToggled => { - // Will be wired in plan 05 (right task board) - } - crate::top_bar::TopBarEvent::CustomLayoutRequested => { - if self.custom_picker.is_open { - self.custom_picker.close(); - } else { - let current_tree = if self.workspace.is_split_tree_mode() { - self.workspace - .layout_state() - .as_split_tree() - .map(|s| s.tree().clone()) - } else { - None - }; - let (rows, cols) = self.workspace.layout_profile().dimensions(); - self.custom_picker.open_with_state(current_tree, rows, cols); - } - } - crate::top_bar::TopBarEvent::NewSessionRequested => { - // Future: delegate to create_session logic - } - crate::top_bar::TopBarEvent::BroadcastToggled(_) => { - // Broadcast feature removed - } - } - } - } - /// Select a session (updates drawer context and grid focus). pub(super) fn select_session_with_cx(&mut self, session_id: SessionId, cx: &mut Context) { self.selection.selected_session_id = Some(session_id); @@ -953,34 +858,6 @@ impl WorkspaceView { } } - /// Process icon rail events (drawer toggling, settings). - pub(super) fn process_icon_rail_events(&mut self) { - let events = self.icon_rail.drain_events(); - for event in events { - match event { - crate::icon_rail::IconRailEvent::DrawerToggled(panel) => { - self.drawer.set_active_panel(panel); - } - crate::icon_rail::IconRailEvent::SettingsRequested => { - self.open_settings(); - } - } - } - } - - /// Handle empty session cell events. - fn handle_empty_session_event(&mut self, event: EmptySessionEvent, cx: &mut Context) { - match event { - EmptySessionEvent::CreateSessionClicked { position } => { - info!(?position, "Create session at position"); - if self.should_create_session_at(position) { - self.create_session(cx); - } - } - } - cx.notify(); - } - /// Get a reference to the underlying workspace. /// /// Used by the render module to access workspace state. diff --git a/crates/codirigent-ui/src/workspace/gpui/ui_events.rs b/crates/codirigent-ui/src/workspace/gpui/ui_events.rs index 6dde321..2b0dda5 100644 --- a/crates/codirigent-ui/src/workspace/gpui/ui_events.rs +++ b/crates/codirigent-ui/src/workspace/gpui/ui_events.rs @@ -1,9 +1,132 @@ -//! Future home for GPUI event-processing helpers. -//! -//! Expected move targets in Phase C: -//! - `process_ui_events()` -//! - `process_top_bar_events()` -//! - `process_icon_rail_events()` -//! - closely related event translation helpers -//! -//! Phase A scaffolding only. Logic remains in the root module until Phase C. +//! GPUI event-processing helpers. + +use super::WorkspaceView; +use crate::empty_session::EmptySessionEvent; +use codirigent_core::{GridPosition, LayoutMode}; +use gpui::Context; +use std::time::{Duration, Instant}; +use tracing::info; + +impl WorkspaceView { + /// Check if a session should be created at the given position. + /// Returns true if this is not a duplicate click (same position within 100ms). + fn should_create_session_at(&mut self, position: GridPosition) -> bool { + let now = Instant::now(); + + // Check if this is a duplicate click + if let Some((last_pos, last_time)) = self.selection.last_click_position { + if last_pos == position && now.duration_since(last_time) < Duration::from_millis(100) { + info!(?position, "Ignoring duplicate click within 100ms"); + return false; + } + } + + // Update last click position + self.selection.last_click_position = Some((position, now)); + true + } + + /// Process pending events from all UI components. + /// + /// This method is called at the start of each render cycle to handle + /// any pending events from task board, empty session cells, etc. + pub(super) fn process_ui_events(&mut self, cx: &mut Context) { + // Process task board events + for event in self.task_board.take_events() { + self.handle_task_board_event(event, cx); + } + + // Process empty session events + for event in self.empty_cells.take_events() { + self.handle_empty_session_event(event, cx); + } + } + + /// Process pending top bar events and translate to workspace actions. + pub(in crate::workspace) fn process_top_bar_events(&mut self) { + let events = self.top_bar.drain_events(); + for event in events { + match event { + crate::top_bar::TopBarEvent::LayoutSelected(layout_mode) => { + match layout_mode { + LayoutMode::Grid { rows, cols } => { + let profile = match (rows, cols) { + (2, 2) => crate::layout::LayoutProfile::Grid2x2, + (4, 1) => crate::layout::LayoutProfile::Stack1x4, + (2, 3) => crate::layout::LayoutProfile::Grid2x3, + (3, 3) => crate::layout::LayoutProfile::Grid3x3, + _ => crate::layout::LayoutProfile::Custom { rows, cols }, + }; + self.workspace.set_layout(profile); + } + LayoutMode::Single => { + self.workspace + .set_layout(crate::layout::LayoutProfile::Single); + } + LayoutMode::SplitTree { root } => { + self.workspace.set_split_tree(root); + } + LayoutMode::Custom { .. } => { + // Custom positional layouts not used from tabs + } + } + self.mark_layout_cache_dirty(); + self.sync_layout_derived_state(); + } + crate::top_bar::TopBarEvent::RightPanelToggled => { + // Will be wired in plan 05 (right task board) + } + crate::top_bar::TopBarEvent::CustomLayoutRequested => { + if self.custom_picker.is_open { + self.custom_picker.close(); + } else { + let current_tree = if self.workspace.is_split_tree_mode() { + self.workspace + .layout_state() + .as_split_tree() + .map(|s| s.tree().clone()) + } else { + None + }; + let (rows, cols) = self.workspace.layout_profile().dimensions(); + self.custom_picker.open_with_state(current_tree, rows, cols); + } + } + crate::top_bar::TopBarEvent::NewSessionRequested => { + // Future: delegate to create_session logic + } + crate::top_bar::TopBarEvent::BroadcastToggled(_) => { + // Broadcast feature removed + } + } + } + } + + /// Process icon rail events (drawer toggling, settings). + pub(in crate::workspace) fn process_icon_rail_events(&mut self) { + let events = self.icon_rail.drain_events(); + for event in events { + match event { + crate::icon_rail::IconRailEvent::DrawerToggled(panel) => { + self.drawer.set_active_panel(panel); + } + crate::icon_rail::IconRailEvent::SettingsRequested => { + self.open_settings(); + } + } + } + } + + /// Handle empty session cell events. + fn handle_empty_session_event(&mut self, event: EmptySessionEvent, cx: &mut Context) { + match event { + EmptySessionEvent::CreateSessionClicked { position } => { + info!(?position, "Create session at position"); + if self.should_create_session_at(position) { + self.create_session(cx); + } + } + } + cx.notify(); + } +} From 616ba19cdcb1cc1fb2286ca26ee909b06153fadc Mon Sep 17 00:00:00 2001 From: oso95 Date: Thu, 12 Mar 2026 16:57:22 -0400 Subject: [PATCH 08/10] refactor: split workspace layout sync --- crates/codirigent-ui/src/workspace/gpui.rs | 270 +------------- .../src/workspace/gpui/layout_sync.rs | 344 +++++++++++++++++- .../codirigent-ui/src/workspace/gpui/tests.rs | 52 --- 3 files changed, 339 insertions(+), 327 deletions(-) diff --git a/crates/codirigent-ui/src/workspace/gpui.rs b/crates/codirigent-ui/src/workspace/gpui.rs index 5ac9f1c..92938b6 100644 --- a/crates/codirigent-ui/src/workspace/gpui.rs +++ b/crates/codirigent-ui/src/workspace/gpui.rs @@ -37,8 +37,7 @@ use super::core::Workspace; use super::editor_detection::detect_monospace_fonts; use super::types::{ CacheState, CliReaders, ModalState, PollingState, RenderLayoutSignature, SelectionState, - TerminalResizeSignature, CELL_BORDER_WIDTH, FONT_SIZE_BASE_DEFAULT, HEADER_HEIGHT, REM_BASE, - TERMINAL_CONTENT_PADDING, + FONT_SIZE_BASE_DEFAULT, REM_BASE, }; use crate::clipboard_preview::ClipboardPreview; use crate::empty_session::EmptySessionPool; @@ -51,8 +50,8 @@ use crate::theme::CodirigentTheme; use crate::toolbar::CustomLayoutPicker; use codirigent_core::compaction::{CompactionConfig, CompactionService}; use codirigent_core::{ - CodexExecutionMode, CodirigentEvent, DefaultEventBus, EventBus, FileStorageService, - ProcessMonitor, SessionId, SessionManager, SessionStatus, TaskManager, TaskManagerConfig, + CodexExecutionMode, DefaultEventBus, FileStorageService, ProcessMonitor, SessionId, + SessionManager, SessionStatus, TaskManager, TaskManagerConfig, }; use codirigent_detector::{InputDetector, NotificationManager}; use codirigent_filetree::FileTree; @@ -67,7 +66,7 @@ use gpui::{ use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; +use std::time::Duration; use tracing::warn; /// GPUI View wrapper for Workspace. @@ -164,19 +163,6 @@ impl WorkspaceView { /// Debounce window for persisted app-state saves. const STATE_SAVE_DEBOUNCE: Duration = Duration::from_millis(200); - /// Returns true when a computed target size is a transient collapse that - /// should be ignored to avoid 1-column/1-row PTY resizes. - fn should_skip_collapsed_resize( - current_rows: u16, - current_cols: u16, - target_rows: u16, - target_cols: u16, - ) -> bool { - let target_collapsed = target_rows <= 1 || target_cols <= 1; - let current_usable = current_rows > 1 && current_cols > 1; - target_collapsed && current_usable - } - fn session_is_shell_idle(&self, session_id: SessionId) -> bool { self.with_detector(|detector| { matches!( @@ -704,48 +690,6 @@ impl WorkspaceView { } } - /// Mark layout-derived render caches as dirty after structural changes. - pub(super) fn mark_layout_cache_dirty(&mut self) { - self.cache.render_cell_info_dirty = true; - self.cache.layout_generation = self.cache.layout_generation.saturating_add(1); - self.cache.last_resize_signature = None; - self.cache.pending_resize_signature = None; - } - - fn current_resize_signature( - &self, - cell_width: f32, - cell_height: f32, - ) -> Option { - Some(TerminalResizeSignature { - layout_generation: self.cache.layout_generation, - layout: self.cache.render_layout_signature?, - cell_width, - cell_height, - }) - } - - fn render_focus_signature(&self) -> Option { - Self::render_focus_signature_for_layout( - self.workspace.layout_profile(), - self.workspace.focused_session_id(), - ) - } - - fn render_focus_signature_for_layout( - layout_profile: crate::layout::LayoutProfile, - focused_session_id: Option, - ) -> Option { - // Only single-pane mode swaps which session is visibly rendered when focus - // changes. Multi-pane layouts already render every visible session, so - // focus changes alone should not invalidate the cell-layout cache. - if layout_profile == crate::layout::LayoutProfile::Single { - focused_session_id - } else { - None - } - } - /// Debounce persisted session/layout state writes off the UI thread. pub(super) fn save_state_to_disk(&mut self, cx: &mut Context) { self.polling.state_save_generation = self.polling.state_save_generation.saturating_add(1); @@ -802,62 +746,11 @@ impl WorkspaceView { })); } - /// Cycle to next layout. - pub fn next_layout(&mut self, cx: &mut Context) { - self.workspace.next_layout(); - self.mark_layout_cache_dirty(); - self.sync_layout_derived_state(); - self.event_bus.publish(CodirigentEvent::LayoutChanged { - mode: self.workspace.layout_profile().to_mode(), - }); - cx.notify(); - } - - /// Toggle sidebar visibility. - pub fn toggle_sidebar(&mut self, cx: &mut Context) { - self.workspace.toggle_sidebar(); - self.mark_layout_cache_dirty(); - self.sync_layout_derived_state(); - cx.notify(); - } - - /// Focus a session by number (1-9). - pub fn focus_session_number(&mut self, number: usize, cx: &mut Context) { - if self.workspace.focus_session_number(number) { - if let Some(id) = self.workspace.focused_session_id() { - self.event_bus - .publish(CodirigentEvent::SessionFocused { id }); - } - self.sync_layout_derived_state(); - self.sync_file_tree_to_focused_session(cx); - cx.notify(); - } - } - /// Get a terminal header for a session. pub fn get_terminal_header(&self, id: SessionId) -> Option<&TerminalHeader> { self.terminal_headers.get(&id) } - /// Select a session (updates drawer context and grid focus). - pub(super) fn select_session_with_cx(&mut self, session_id: SessionId, cx: &mut Context) { - self.selection.selected_session_id = Some(session_id); - self.drawer.set_selected_session(Some(session_id)); - self.workspace.focus_session(session_id); - self.sync_layout_derived_state(); - self.sync_file_tree_to_focused_session(cx); - // If the session showed ResponseReady, downgrade the cache to Idle - // immediately so the badge clears without waiting for the next poll. - if let Ok(mut readers) = self.cli_readers.lock() { - if let Some(cached) = readers.cached_status.get_mut(&session_id) { - if cached.status == codirigent_core::SessionStatus::ResponseReady { - cached.status = codirigent_core::SessionStatus::Idle; - cached.status_since = std::time::Instant::now(); - } - } - } - } - /// Get a reference to the underlying workspace. /// /// Used by the render module to access workspace state. @@ -989,75 +882,6 @@ impl WorkspaceView { &mut self.terminals } - /// Resize all terminals to fit their current grid cell bounds. - /// - /// This should be called when the window is resized or the layout changes, - /// to ensure terminals have the correct character dimensions for their pixel bounds. - /// Returns `true` if any terminal was actually resized. - fn resize_terminals_to_grid(&mut self) -> bool { - // Layout constants from types.rs: HEADER_HEIGHT, TERMINAL_CONTENT_PADDING, CELL_BORDER_WIDTH - let mut resized_any = false; - - for &info in &self.cache.render_cell_info { - if let Some(terminal_view) = self.terminals.get_mut(&info.session_id) { - // Subtract all chrome between the grid cell bounds and the - // actual terminal canvas drawing area: - // - border: .border_1() on session cell (1px each side) - // - padding: canvas prepaint offsets by TERMINAL_CONTENT_PADDING - // - header: 32px header bar above terminal content - let padding2 = TERMINAL_CONTENT_PADDING * 2.0; - let available_width = - (info.bounds.size.width - CELL_BORDER_WIDTH - padding2).max(0.0); - let available_height = - (info.bounds.size.height - CELL_BORDER_WIDTH - HEADER_HEIGHT - padding2) - .max(0.0); - - // Convert first so we can guard against transient layout collapses. - // During some intermediate layout passes, bounds briefly report near-zero - // sizes, which would otherwise force the PTY to 1 column/row and make - // output wrap vertically until the next resize event. - let (target_rows, target_cols) = - terminal_view.dimensions_from_pixels(available_width, available_height); - let current_rows = terminal_view.rows(); - let current_cols = terminal_view.cols(); - - if Self::should_skip_collapsed_resize( - current_rows, - current_cols, - target_rows, - target_cols, - ) { - continue; - } - - // Resize terminal emulator to fit the remaining space - let did_resize = terminal_view.resize_to_fit(available_width, available_height); - - if did_resize { - resized_any = true; - - // Propagate resize to actual PTY (ConPTY) so the shell - // knows the correct terminal dimensions - let rows = terminal_view.rows(); - let cols = terminal_view.cols(); - let last = self.cache.pty_sizes.get(&info.session_id); - if last != Some(&(rows, cols)) { - self.with_session_manager(|manager| { - if let Err(e) = manager.resize(info.session_id, rows, cols) { - warn!( - "Failed to resize PTY for session {}: {}", - info.session_id, e - ); - } - }); - self.cache.pty_sizes.insert(info.session_id, (rows, cols)); - } - } - } - } - resized_any - } - /// Handle keyboard input for the focused session. fn handle_key_down( &mut self, @@ -1808,92 +1632,6 @@ impl Render for WorkspaceView { } } -impl WorkspaceView { - /// Sync terminal cell dimensions with font metrics, then throttle-trigger PTY resize. - /// - /// Uses a cache keyed on font family + size so font queries only run when - /// terminal appearance settings change, not on every frame. - /// - /// Resize is debounced to ≤10/sec to prevent PTY feedback loops during - /// continuous window drag/resize. - fn sync_terminal_dimensions_and_resize(&mut self, window: &mut Window, cx: &mut Context) { - let font_family = &self.workspace.theme().terminal_font_family; - let font_size = self.workspace.theme().terminal_font_size; - let line_height = self.workspace.theme().terminal_line_height; - let (real_w, real_h) = match &self.cache.cached_cell_dims { - Some(cached) - if cached.font_family == *font_family - && (cached.font_size - font_size).abs() < 0.01 - && (cached.line_height - line_height).abs() < 0.001 => - { - (cached.cell_width, cached.cell_height) - } - _ => { - let (w, h) = crate::terminal_view::compute_cell_dimensions( - window.text_system(), - font_family, - font_size, - line_height, - ); - self.cache.cached_cell_dims = Some(super::types::CachedCellDims { - font_family: font_family.clone(), - font_size, - line_height, - cell_width: w, - cell_height: h, - }); - (w, h) - } - }; - for tv in self.terminals.values_mut() { - if !tv.dimensions_initialized() { - tv.set_cell_dimensions(real_w, real_h); - } - } - - let Some(resize_signature) = self.current_resize_signature(real_w, real_h) else { - return; - }; - if self.cache.last_resize_signature == Some(resize_signature) { - return; - } - - let now = Instant::now(); - if now.duration_since(self.polling.last_resize_time) > Duration::from_millis(100) { - self.resize_terminals_to_grid(); - self.cache.last_resize_signature = Some(resize_signature); - self.cache.pending_resize_signature = None; - self.polling.last_resize_time = now; - self.polling.pending_resize = false; - } else { - self.cache.pending_resize_signature = Some(resize_signature); - if self.polling.pending_resize { - return; - } - self.polling.pending_resize = true; - cx.spawn(async move |this: gpui::WeakEntity, cx| { - cx.background_executor() - .timer(Duration::from_millis(100)) - .await; - let _ = this.update(cx, |this, cx| { - let Some(signature) = this.cache.pending_resize_signature.take() else { - this.polling.pending_resize = false; - return; - }; - let resized = this.resize_terminals_to_grid(); - this.cache.last_resize_signature = Some(signature); - this.polling.last_resize_time = Instant::now(); - this.polling.pending_resize = false; - if resized { - cx.notify(); - } - }); - }) - .detach(); - } - } -} - /// Create a complete workspace view with all components wired up. /// /// # Arguments diff --git a/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs b/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs index cc054d3..17aab8a 100644 --- a/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs +++ b/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs @@ -1,9 +1,335 @@ -//! Future home for layout synchronization and resize helpers. -//! -//! Expected move targets in Phase C: -//! - layout switching follow-up -//! - focus/layout synchronization -//! - drag/swap follow-up helpers -//! - terminal dimension and resize coordination -//! -//! Phase A scaffolding only. Logic remains in the root module until Phase C. +//! Layout synchronization, focus transitions, and terminal resize helpers. + +use super::WorkspaceView; +use crate::workspace::types::{ + CachedCellDims, TerminalResizeSignature, CELL_BORDER_WIDTH, HEADER_HEIGHT, + TERMINAL_CONTENT_PADDING, +}; +use codirigent_core::{CodirigentEvent, EventBus, SessionId, SessionManager}; +use gpui::{Context, Window}; +use std::time::{Duration, Instant}; +use tracing::warn; + +impl WorkspaceView { + /// Returns true when a computed target size is a transient collapse that + /// should be ignored to avoid 1-column/1-row PTY resizes. + fn should_skip_collapsed_resize( + current_rows: u16, + current_cols: u16, + target_rows: u16, + target_cols: u16, + ) -> bool { + let target_collapsed = target_rows <= 1 || target_cols <= 1; + let current_usable = current_rows > 1 && current_cols > 1; + target_collapsed && current_usable + } + + /// Mark layout-derived render caches as dirty after structural changes. + pub(in crate::workspace) fn mark_layout_cache_dirty(&mut self) { + self.cache.render_cell_info_dirty = true; + self.cache.layout_generation = self.cache.layout_generation.saturating_add(1); + self.cache.last_resize_signature = None; + self.cache.pending_resize_signature = None; + } + + fn current_resize_signature( + &self, + cell_width: f32, + cell_height: f32, + ) -> Option { + Some(TerminalResizeSignature { + layout_generation: self.cache.layout_generation, + layout: self.cache.render_layout_signature?, + cell_width, + cell_height, + }) + } + + pub(super) fn render_focus_signature(&self) -> Option { + Self::render_focus_signature_for_layout( + self.workspace.layout_profile(), + self.workspace.focused_session_id(), + ) + } + + fn render_focus_signature_for_layout( + layout_profile: crate::layout::LayoutProfile, + focused_session_id: Option, + ) -> Option { + // Only single-pane mode swaps which session is visibly rendered when focus + // changes. Multi-pane layouts already render every visible session, so + // focus changes alone should not invalidate the cell-layout cache. + if layout_profile == crate::layout::LayoutProfile::Single { + focused_session_id + } else { + None + } + } + + /// Cycle to next layout. + pub fn next_layout(&mut self, cx: &mut Context) { + self.workspace.next_layout(); + self.mark_layout_cache_dirty(); + self.sync_layout_derived_state(); + self.event_bus.publish(CodirigentEvent::LayoutChanged { + mode: self.workspace.layout_profile().to_mode(), + }); + cx.notify(); + } + + /// Toggle sidebar visibility. + pub fn toggle_sidebar(&mut self, cx: &mut Context) { + self.workspace.toggle_sidebar(); + self.mark_layout_cache_dirty(); + self.sync_layout_derived_state(); + cx.notify(); + } + + /// Focus a session by number (1-9). + pub fn focus_session_number(&mut self, number: usize, cx: &mut Context) { + if self.workspace.focus_session_number(number) { + if let Some(id) = self.workspace.focused_session_id() { + self.event_bus + .publish(CodirigentEvent::SessionFocused { id }); + } + self.sync_layout_derived_state(); + self.sync_file_tree_to_focused_session(cx); + cx.notify(); + } + } + + /// Select a session (updates drawer context and grid focus). + pub(in crate::workspace) fn select_session_with_cx( + &mut self, + session_id: SessionId, + cx: &mut Context, + ) { + self.selection.selected_session_id = Some(session_id); + self.drawer.set_selected_session(Some(session_id)); + self.workspace.focus_session(session_id); + self.sync_layout_derived_state(); + self.sync_file_tree_to_focused_session(cx); + // If the session showed ResponseReady, downgrade the cache to Idle + // immediately so the badge clears without waiting for the next poll. + if let Ok(mut readers) = self.cli_readers.lock() { + if let Some(cached) = readers.cached_status.get_mut(&session_id) { + if cached.status == codirigent_core::SessionStatus::ResponseReady { + cached.status = codirigent_core::SessionStatus::Idle; + cached.status_since = Instant::now(); + } + } + } + } + + /// Resize all terminals to fit their current grid cell bounds. + /// + /// This should be called when the window is resized or the layout changes, + /// to ensure terminals have the correct character dimensions for their pixel bounds. + /// Returns `true` if any terminal was actually resized. + fn resize_terminals_to_grid(&mut self) -> bool { + // Layout constants from types.rs: HEADER_HEIGHT, TERMINAL_CONTENT_PADDING, CELL_BORDER_WIDTH + let mut resized_any = false; + + for &info in &self.cache.render_cell_info { + if let Some(terminal_view) = self.terminals.get_mut(&info.session_id) { + // Subtract all chrome between the grid cell bounds and the + // actual terminal canvas drawing area: + // - border: .border_1() on session cell (1px each side) + // - padding: canvas prepaint offsets by TERMINAL_CONTENT_PADDING + // - header: 32px header bar above terminal content + let padding2 = TERMINAL_CONTENT_PADDING * 2.0; + let available_width = + (info.bounds.size.width - CELL_BORDER_WIDTH - padding2).max(0.0); + let available_height = + (info.bounds.size.height - CELL_BORDER_WIDTH - HEADER_HEIGHT - padding2) + .max(0.0); + + // Convert first so we can guard against transient layout collapses. + // During some intermediate layout passes, bounds briefly report near-zero + // sizes, which would otherwise force the PTY to 1 column/row and make + // output wrap vertically until the next resize event. + let (target_rows, target_cols) = + terminal_view.dimensions_from_pixels(available_width, available_height); + let current_rows = terminal_view.rows(); + let current_cols = terminal_view.cols(); + + if Self::should_skip_collapsed_resize( + current_rows, + current_cols, + target_rows, + target_cols, + ) { + continue; + } + + // Resize terminal emulator to fit the remaining space + let did_resize = terminal_view.resize_to_fit(available_width, available_height); + + if did_resize { + resized_any = true; + + // Propagate resize to actual PTY (ConPTY) so the shell + // knows the correct terminal dimensions + let rows = terminal_view.rows(); + let cols = terminal_view.cols(); + let last = self.cache.pty_sizes.get(&info.session_id); + if last != Some(&(rows, cols)) { + self.with_session_manager(|manager| { + if let Err(e) = manager.resize(info.session_id, rows, cols) { + warn!( + "Failed to resize PTY for session {}: {}", + info.session_id, e + ); + } + }); + self.cache.pty_sizes.insert(info.session_id, (rows, cols)); + } + } + } + } + resized_any + } + + /// Sync terminal cell dimensions with font metrics, then throttle-trigger PTY resize. + /// + /// Uses a cache keyed on font family + size so font queries only run when + /// terminal appearance settings change, not on every frame. + /// + /// Resize is debounced to <=10/sec to prevent PTY feedback loops during + /// continuous window drag/resize. + pub(super) fn sync_terminal_dimensions_and_resize( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + let font_family = &self.workspace.theme().terminal_font_family; + let font_size = self.workspace.theme().terminal_font_size; + let line_height = self.workspace.theme().terminal_line_height; + let (real_w, real_h) = match &self.cache.cached_cell_dims { + Some(cached) + if cached.font_family == *font_family + && (cached.font_size - font_size).abs() < 0.01 + && (cached.line_height - line_height).abs() < 0.001 => + { + (cached.cell_width, cached.cell_height) + } + _ => { + let (w, h) = crate::terminal_view::compute_cell_dimensions( + window.text_system(), + font_family, + font_size, + line_height, + ); + self.cache.cached_cell_dims = Some(CachedCellDims { + font_family: font_family.clone(), + font_size, + line_height, + cell_width: w, + cell_height: h, + }); + (w, h) + } + }; + for tv in self.terminals.values_mut() { + if !tv.dimensions_initialized() { + tv.set_cell_dimensions(real_w, real_h); + } + } + + let Some(resize_signature) = self.current_resize_signature(real_w, real_h) else { + return; + }; + if self.cache.last_resize_signature == Some(resize_signature) { + return; + } + + let now = Instant::now(); + if now.duration_since(self.polling.last_resize_time) > Duration::from_millis(100) { + self.resize_terminals_to_grid(); + self.cache.last_resize_signature = Some(resize_signature); + self.cache.pending_resize_signature = None; + self.polling.last_resize_time = now; + self.polling.pending_resize = false; + } else { + self.cache.pending_resize_signature = Some(resize_signature); + if self.polling.pending_resize { + return; + } + self.polling.pending_resize = true; + cx.spawn(async move |this: gpui::WeakEntity, cx| { + cx.background_executor() + .timer(Duration::from_millis(100)) + .await; + let _ = this.update(cx, |this, cx| { + let Some(signature) = this.cache.pending_resize_signature.take() else { + this.polling.pending_resize = false; + return; + }; + let resized = this.resize_terminals_to_grid(); + this.cache.last_resize_signature = Some(signature); + this.polling.last_resize_time = Instant::now(); + this.polling.pending_resize = false; + if resized { + cx.notify(); + } + }); + }) + .detach(); + } + } +} + +#[cfg(test)] +mod tests { + #[test] + fn test_skip_collapsed_resize_when_current_is_usable() { + assert!(super::WorkspaceView::should_skip_collapsed_resize( + 40, 120, 40, 1 + )); + assert!(super::WorkspaceView::should_skip_collapsed_resize( + 40, 120, 1, 120 + )); + assert!(super::WorkspaceView::should_skip_collapsed_resize( + 40, 120, 1, 1 + )); + } + + #[test] + fn test_do_not_skip_collapsed_resize_if_already_collapsed() { + assert!(!super::WorkspaceView::should_skip_collapsed_resize( + 1, 1, 1, 1 + )); + assert!(!super::WorkspaceView::should_skip_collapsed_resize( + 1, 80, 1, 1 + )); + } + + #[test] + fn test_do_not_skip_non_collapsed_resize() { + assert!(!super::WorkspaceView::should_skip_collapsed_resize( + 40, 120, 30, 100 + )); + } + + #[test] + fn test_render_focus_signature_tracks_focus_in_single_layout() { + assert_eq!( + super::WorkspaceView::render_focus_signature_for_layout( + crate::layout::LayoutProfile::Single, + Some(codirigent_core::SessionId(2)), + ), + Some(codirigent_core::SessionId(2)) + ); + } + + #[test] + fn test_render_focus_signature_ignores_focus_outside_single_layout() { + assert_eq!( + super::WorkspaceView::render_focus_signature_for_layout( + crate::layout::LayoutProfile::Grid2x2, + Some(codirigent_core::SessionId(2)), + ), + None + ); + } +} diff --git a/crates/codirigent-ui/src/workspace/gpui/tests.rs b/crates/codirigent-ui/src/workspace/gpui/tests.rs index c2e8ef2..44c1484 100644 --- a/crates/codirigent-ui/src/workspace/gpui/tests.rs +++ b/crates/codirigent-ui/src/workspace/gpui/tests.rs @@ -38,58 +38,6 @@ fn test_core_workspace_is_tested_separately() { assert!(ws.sessions().is_empty()); } -#[test] -fn test_skip_collapsed_resize_when_current_is_usable() { - assert!(super::WorkspaceView::should_skip_collapsed_resize( - 40, 120, 40, 1 - )); - assert!(super::WorkspaceView::should_skip_collapsed_resize( - 40, 120, 1, 120 - )); - assert!(super::WorkspaceView::should_skip_collapsed_resize( - 40, 120, 1, 1 - )); -} - -#[test] -fn test_do_not_skip_collapsed_resize_if_already_collapsed() { - assert!(!super::WorkspaceView::should_skip_collapsed_resize( - 1, 1, 1, 1 - )); - assert!(!super::WorkspaceView::should_skip_collapsed_resize( - 1, 80, 1, 1 - )); -} - -#[test] -fn test_do_not_skip_non_collapsed_resize() { - assert!(!super::WorkspaceView::should_skip_collapsed_resize( - 40, 120, 30, 100 - )); -} - -#[test] -fn test_render_focus_signature_tracks_focus_in_single_layout() { - assert_eq!( - super::WorkspaceView::render_focus_signature_for_layout( - crate::layout::LayoutProfile::Single, - Some(codirigent_core::SessionId(2)), - ), - Some(codirigent_core::SessionId(2)) - ); -} - -#[test] -fn test_render_focus_signature_ignores_focus_outside_single_layout() { - assert_eq!( - super::WorkspaceView::render_focus_signature_for_layout( - crate::layout::LayoutProfile::Grid2x2, - Some(codirigent_core::SessionId(2)), - ), - None - ); -} - #[test] fn test_normalize_codex_execution_mode_detects_bypass_alias() { assert_eq!( From 3ff8c1108e168dadfcc937d69e52e08e95659006 Mon Sep 17 00:00:00 2001 From: oso95 Date: Thu, 12 Mar 2026 17:03:11 -0400 Subject: [PATCH 09/10] docs: refresh workspace module structure --- crates/codirigent-ui/src/workspace/README.md | 265 +++++------------- crates/codirigent-ui/src/workspace/gpui.rs | 2 +- .../impl_output_polling/git_refresh.rs | 7 +- .../impl_output_polling/terminal_input.rs | 7 +- 4 files changed, 70 insertions(+), 211 deletions(-) diff --git a/crates/codirigent-ui/src/workspace/README.md b/crates/codirigent-ui/src/workspace/README.md index 6fb7828..b604936 100644 --- a/crates/codirigent-ui/src/workspace/README.md +++ b/crates/codirigent-ui/src/workspace/README.md @@ -1,209 +1,78 @@ # Workspace Module -The workspace module manages the main application window with grid layout, session panes, and UI controls. - -## Architecture - -The workspace is split into two main components: - -- **`core.rs`** - Core workspace logic (layout, sessions, focus management) -- **`gpui.rs`** - GPUI view implementation and state management - -The rendering logic is further split into focused component modules for maintainability. - -## Rendering Modules - -The workspace rendering is organized into specialized modules: - -### Core Coordinator -- **`render.rs`** (2,461 lines) - Main rendering coordinator - - Terminal content rendering - - Drawer panels (sessions, files, worktrees) - - Session menus and inline UI - - Module coordination - -### Component Renderers -- **`grid_render.rs`** (729 lines) - Grid and split layouts - - Traditional NxM grid layout - - Split tree (binary tree) layout - - Session cells with terminals - - Empty cell placeholders - -- **`task_board_render.rs`** (1,334 lines) - Task management UI - - Right sidebar task board - - Task creation and editing modals - - Task cards and status sections - - Priority and status mapping - -- **`modal_render.rs`** (963 lines) - Modal dialogs - - Custom layout builder modal - - Session action modal (rename, group assign) - - Modal overlay and interactions - -- **`icon_rail_render.rs`** (174 lines) - Left sidebar - - Icon rail navigation - - Icon click handling - - Rail layout and styling - -- **`top_bar_render.rs`** (172 lines) - Top bar UI - - Session tabs - - Layout controls - - Window controls integration - -### Utilities -- **`icon_utils.rs`** (191 lines) - Icon rendering helpers - - `centered_lucide_icon()` - Centered icon wrapper - - `aligned_icon_label_row()` - Icon + label rows - - Consistent icon alignment utilities - -## Key Types - -### Core Types -- **`Workspace`** - Core workspace state and logic - - Layout management (grid, split tree, single) - - Session collection - - Focus tracking - - Bounds calculation - -- **`CellInfo`** - Information about grid cells - - Session assignment - - Cell bounds - - Visual state - -### GPUI Types -- **`WorkspaceView`** - Main GPUI view - - Rendering implementation - - Event handling - - UI state management - - Terminal views - -## Layout System - -The workspace supports three layout modes: - -1. **Grid Layout** (1x1 to 3x3) - - Traditional grid of session panes - - Fixed or flexible cell sizing - - Rendered by `grid_render.rs` - -2. **Split Tree Layout** - - Binary tree of horizontal/vertical splits - - Recursive pane subdivision - - Rendered by `grid_render.rs` - -3. **Single Layout** - - Focused single session view - - Quick switching between sessions - - Temporary overlay mode - -## Session Management - -Sessions represent terminal instances with associated state: - -- **Session Creation** - Create new sessions in grid cells -- **Session Focus** - Track and switch focused session -- **Session Grouping** - Organize sessions by color-coded groups -- **Session Persistence** - Sessions persist across layout changes - -## UI Components - -### Top Bar -- Session tabs with labels and status -- Layout switcher (grid, split, single) -- Window controls (minimize, maximize, close) - -### Icon Rail (Left Sidebar) -- Navigation icons -- Layout mode selector -- Drawer toggle - -### Drawer Panels (Left) -- **Sessions** - List of all sessions with groups -- **Files** - Git changes and file tree -- **Worktrees** - Git worktree management - -### Task Board (Right Sidebar) -- Task queue by status -- Task creation and editing -- Auto-assignment configuration -- Task actions (assign, review, complete) - -## Event Handling - -The workspace processes events through dedicated handlers: - -- **UI Events** - Button clicks, modal actions, task board events -- **Top Bar Events** - Session tab clicks, layout changes -- **Icon Rail Events** - Navigation, drawer toggle -- **Keyboard Shortcuts** - Session switching, layout changes -- **Terminal Events** - Mouse/keyboard input, scrolling +The workspace module owns the main application window: session layout, GPUI view state, rendering, polling, and workspace-scoped UI interactions. + +## Current Structure + +- `core.rs` + - Canonical workspace state and layout logic. + - Session placement, focus, bounds, and layout transitions. + +- `gpui.rs` + - Root `WorkspaceView` type. + - Constructor wiring, trait impls, render entry point, keyboard/IME handling, and high-level orchestration. + - Lower-coupling helper clusters live under `workspace/gpui/`: + - `session_metadata.rs` + - `derived_state.rs` + - `ui_events.rs` + - `layout_sync.rs` + +- `impl_output_polling.rs` + - Root polling coordinator for output/status maintenance. + - Lower-coupling helper clusters live under `workspace/impl_output_polling/`: + - `output_runtime.rs` + - `status_reconcile.rs` + - `cli_pollers.rs` + - `hook_signals.rs` + - `git_refresh.rs` + - `terminal_input.rs` + +- Rendering modules + - `render.rs`, `grid_render.rs`, `drawer_render.rs`, `task_board_render.rs`, `top_bar_render.rs`, `icon_rail_render.rs`, `modal_render.rs` + - These keep UI composition close to the components they render while relying on root-owned `WorkspaceView` state. + +## Dependency Shape + +- `Workspace` in `core.rs` stays free of GPUI concerns. +- `WorkspaceView` in `gpui.rs` is the GPUI-facing root and remains the main place to start reading the UI layer. +- `workspace/gpui/*.rs` helpers extend `WorkspaceView` without changing public module paths. +- `workspace/impl_output_polling/*.rs` helpers extend the polling root without changing public module paths. +- Sibling modules coordinate through `WorkspaceView` methods rather than importing one another's private helpers. + +## Key Responsibilities + +- Layout and focus: + - `core.rs` + - `workspace/gpui/layout_sync.rs` + +- Derived UI state: + - `workspace/gpui/derived_state.rs` + +- UI event translation: + - `workspace/gpui/ui_events.rs` + +- Session metadata helpers: + - `workspace/gpui/session_metadata.rs` + +- Output polling and runtime preparation: + - `impl_output_polling.rs` + - `workspace/impl_output_polling/output_runtime.rs` + - `workspace/impl_output_polling/cli_pollers.rs` + - `workspace/impl_output_polling/status_reconcile.rs` + - `workspace/impl_output_polling/hook_signals.rs` + - `workspace/impl_output_polling/git_refresh.rs` + - `workspace/impl_output_polling/terminal_input.rs` ## Testing -The workspace module includes comprehensive tests: +The workspace layer is verified with: -- **Layout Tests** - Grid dimensions, cell bounds -- **Session Tests** - Add/remove/focus sessions -- **Focus Tests** - Session number navigation -- **Bounds Tests** - Cell and sidebar calculations -- **Theme Tests** - Theme application and updates +- core unit tests in `workspace/tests.rs` +- module-local tests moved next to extracted helpers where practical +- full workspace and UI crate test runs via: -Run tests with: ```bash cargo test -p codirigent-ui --lib workspace:: ``` -## Rendering Pattern - -All rendering modules use a consistent pattern: - -```rust -// In each *_render.rs module -impl WorkspaceView { - pub(super) fn render_component(&mut self, cx: &mut Context) -> impl IntoElement { - // Component rendering logic - } -} -``` - -This keeps methods accessible via `self` without changing the public API. - -## Module Dependencies - -``` -workspace/ -├── core.rs # Core logic (no GPUI dependencies) -├── gpui.rs # GPUI view (depends on core) -├── render.rs # Main coordinator -│ ├── Uses: grid_render, icon_rail_render, task_board_render -│ ├── Uses: top_bar_render, modal_render -│ └── Uses: icon_utils -├── grid_render.rs # Grid/split layouts -│ └── Uses: icon_utils -├── task_board_render.rs # Task board UI -│ └── Uses: icon_utils -├── icon_rail_render.rs # Left sidebar -│ └── Uses: icon_utils -├── top_bar_render.rs # Top bar -│ └── Uses: icon_utils -├── modal_render.rs # Modal dialogs -│ └── Uses: icon_utils -└── icon_utils.rs # Shared utilities -``` - -## Performance Considerations - -- **Incremental Rendering** - Only changed components re-render -- **Terminal Throttling** - Terminal resizes throttled to ~10/sec -- **Lazy Font Detection** - Monospace fonts detected once on first render -- **Efficient Bounds** - Cell bounds calculated once per layout change - -## Future Improvements - -Potential enhancements for the workspace module: - -1. **Layout Persistence** - Save/restore layout across sessions -2. **Custom Layouts** - User-defined split configurations -3. **Session Templates** - Pre-configured session setups -4. **Enhanced Task Board** - Drag-drop task reordering -5. **Multi-Window Support** - Multiple workspace windows +For refactor verification, use the full workspace gate documented in `docs/architecture/workspace-module-split-plan.md`. diff --git a/crates/codirigent-ui/src/workspace/gpui.rs b/crates/codirigent-ui/src/workspace/gpui.rs index 92938b6..e96ec4a 100644 --- a/crates/codirigent-ui/src/workspace/gpui.rs +++ b/crates/codirigent-ui/src/workspace/gpui.rs @@ -12,7 +12,7 @@ //! Split note: //! - The root keeps `WorkspaceView`, constructor wiring, trait impls, and //! orchestration entry points. -//! - Lightweight helper clusters are moving under `workspace/gpui/` without +//! - Lower-coupling helper clusters now live under `workspace/gpui/` without //! changing public module paths. //! //! # Example diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/git_refresh.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/git_refresh.rs index 8d7bb70..069fc73 100644 --- a/crates/codirigent-ui/src/workspace/impl_output_polling/git_refresh.rs +++ b/crates/codirigent-ui/src/workspace/impl_output_polling/git_refresh.rs @@ -1,9 +1,4 @@ -//! Future home for background git refresh scheduling and apply helpers. -//! -//! Expected move targets in Phase B: -//! - bulk git refresh scheduling -//! - per-session git refresh follow-up -//! - git-info apply helpers +//! Background git refresh scheduling and apply helpers. use super::WorkspaceView; use codirigent_core::{GitRepoInfo, Session, SessionId, SessionManager}; diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/terminal_input.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/terminal_input.rs index 2df5a1e..597f878 100644 --- a/crates/codirigent-ui/src/workspace/impl_output_polling/terminal_input.rs +++ b/crates/codirigent-ui/src/workspace/impl_output_polling/terminal_input.rs @@ -1,9 +1,4 @@ -//! Future home for deferred terminal input and VTE response helpers. -//! -//! Expected move targets in Phase B: -//! - deferred enter handling -//! - VTE response forwarding -//! - compaction input follow-up helpers +//! Deferred terminal input and VTE response helpers. use super::WorkspaceView; use codirigent_core::{CodirigentEvent, EventBus, SessionId, SessionManager}; From 42a7a0566487aa1d345e9b116c6de680c6d7641b Mon Sep 17 00:00:00 2001 From: oso95 Date: Thu, 12 Mar 2026 17:48:39 -0400 Subject: [PATCH 10/10] docs: replace workspace split plans with architecture docs --- crates/codirigent-ui/src/workspace/README.md | 3 +- docs/architecture/overview.md | 10 +- .../ui-thread-offload-refactor-plan.md | 1188 ----------------- .../workspace-module-split-plan.md | 643 --------- docs/architecture/workspace/README.md | 85 ++ docs/architecture/workspace/gpui.md | 198 +++ docs/architecture/workspace/module-map.md | 262 ++++ docs/architecture/workspace/output-polling.md | 227 ++++ 8 files changed, 782 insertions(+), 1834 deletions(-) delete mode 100644 docs/architecture/ui-thread-offload-refactor-plan.md delete mode 100644 docs/architecture/workspace-module-split-plan.md create mode 100644 docs/architecture/workspace/README.md create mode 100644 docs/architecture/workspace/gpui.md create mode 100644 docs/architecture/workspace/module-map.md create mode 100644 docs/architecture/workspace/output-polling.md diff --git a/crates/codirigent-ui/src/workspace/README.md b/crates/codirigent-ui/src/workspace/README.md index b604936..268fe4f 100644 --- a/crates/codirigent-ui/src/workspace/README.md +++ b/crates/codirigent-ui/src/workspace/README.md @@ -75,4 +75,5 @@ The workspace layer is verified with: cargo test -p codirigent-ui --lib workspace:: ``` -For refactor verification, use the full workspace gate documented in `docs/architecture/workspace-module-split-plan.md`. +For durable architecture docs and lookup guidance, use +`docs/architecture/workspace/`. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 7bfa3c0..7529325 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -69,6 +69,10 @@ Codirigent is an AI Coding Agent Orchestration IDE that manages multiple AI codi - `TaskBoardPanel` - Task visualization - `SettingsPage` - Configuration UI +See also: +- `docs/architecture/workspace/` + - module map and focused guides for the split workspace layer + ### codirigent-filetree **Purpose:** File tree data structure for navigation @@ -154,5 +158,7 @@ All data stored in `.codirigent/` directory: ## Next Steps - Read [Data Flow](data-flow.md) for detailed flow diagrams -- Read [Crate Dependencies](crate-dependencies.md) for dependency graph -- Read [Event Bus](event-bus.md) for event system details +- Read [Workspace Architecture](workspace/README.md) for the `codirigent-ui` + workspace layout, render roots, and polling/status boundaries +- Read [../hook-and-status-system.md](../hook-and-status-system.md) for the + hook-signal and session-status pipeline diff --git a/docs/architecture/ui-thread-offload-refactor-plan.md b/docs/architecture/ui-thread-offload-refactor-plan.md deleted file mode 100644 index eeba061..0000000 --- a/docs/architecture/ui-thread-offload-refactor-plan.md +++ /dev/null @@ -1,1188 +0,0 @@ -# UI-Thread Offload Refactor Plan - -## Status - -Substantially complete. This document defines the planned refactor for the remaining UI-thread-bound session workflow in Codirigent, and now reflects implemented progress through Phase 5. Phase 0 instrumentation remains deferred. - -## Progress Snapshot - -| Phase | Status | Notes | -| --- | --- | --- | -| Phase 0: Instrumentation And Baseline | Pending | Planned first in the roadmap, but not yet implemented. | -| Phase 1: PTY Command Queue | Complete | Queue-backed PTY writes/resizes landed with a dedicated worker, tests, and full verification gate. | -| Phase 2: Async Session Bootstrap | Complete | Session create/restore bootstrap now runs on the background executor, with UI-side attach/finalization only. | -| Phase 3: Terminal Runtime Offload | Complete | Terminal parsing/state mutation now runs in a background runtime and the UI consumes snapshots only. | -| Phase 4: Detector Worker | Complete | Detector tick and stale cached-status collection now run on the background executor; UI only applies targeted status deltas. | -| Phase 5: Derived UI State Cleanup | Complete | Render-time fallback recomputation is removed; derived UI state is now updated from explicit mutation paths and targeted reducers. | - -### Phase 1 Completion Notes - -Implemented: - -- Added a dedicated PTY I/O worker in `crates/codirigent-session/src/session_io.rs`. -- Moved per-session write and resize operations behind a queue-backed handle in `SessionState`. -- Updated `DefaultSessionManager::send_input()` and `DefaultSessionManager::resize()` to enqueue commands instead of performing synchronous PTY I/O. -- Added worker-level tests for write ordering, contiguous resize coalescing, and shutdown behavior. -- Added a manager-level test that verifies ordered command delivery through the real PTY path. - -Additional fixes made while validating the phase: - -- Initialized the cached cursor position earlier in `TerminalView` so the all-features UI test suite remains green. -- Fixed terminal-editor detection for Windows-style paths in `editor_detection.rs`. - -Verification completed successfully with: - -```bash -cargo clean -cargo fmt --all -- --check -cargo check --workspace --all-targets --all-features -cargo build --workspace --all-features -cargo test --all --all-targets --all-features -cargo clippy --all --all-targets --all-features -- -D warnings -cargo check -p codirigent-ui --features gpui-full -``` - -### Phase 2 Completion Notes - -Implemented: - -- Added a background bootstrap path in `crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs` that performs `DefaultSessionManager::create_session()` off the UI thread and returns a structured bootstrap result. -- Refactored `WorkspaceView::create_session_inner()` so the UI thread only reserves the target slot/session number, submits the bootstrap task, and later attaches the completed session. -- Added reservation tracking in `crates/codirigent-ui/src/workspace/types.rs` for in-flight session numbers and split-tree slots so repeated create requests cannot race into duplicate names or duplicate slot claims. -- Refactored restore so saved sessions are bootstrapped on the background executor in small batches, then finalized on the UI thread with header creation, detector monitoring, resume-command replay, and layout reapplication. -- Added cleanup paths that discard a bootstrapped manager session if the workspace can no longer attach it, preventing session-manager leaks when late results cannot be placed in the workspace. -- Preserved restore responsiveness by keeping layout staging/focus updates on the UI thread while moving PTY spawn, working-directory validation, and session registration off-thread. - -Phase 2 tests added: - -- Unit test for session-number allocation that skips both existing and reserved values. -- Unit test for restore resume-command ordering across Claude, Codex, and Gemini. -- Unit test for successful bootstrap result assembly, including normalized working directory and child PID capture. -- Unit test for invalid working directory failure with no orphaned session-manager state. - -Additional fixes made while validating the phase: - -- Updated the bootstrap metadata test to compare normalized working-directory paths, matching runtime semantics. -- Scoped the macOS clipboard pasteboard probe helper to tests so the all-features warning-free gate remains green under `clippy -D warnings`. -- Replaced Unix-only `"/tmp"` usage in the new lifecycle tests and the session-create fallback path with `std::env::temp_dir()` so the Phase 2 change set does not hardcode Unix filesystem assumptions. -- Removed the remaining production-path `unwrap()` from the macOS clipboard URL-decoding helper to keep the branch aligned with the no-`unwrap()` rule for shipped code. - -Verification completed successfully with: - -```bash -cargo clean -cargo fmt --all -- --check -cargo check --workspace --all-targets --all-features -cargo build --workspace --all-features -cargo test --all --all-targets --all-features -cargo clippy --all --all-targets --all-features -- -D warnings -cargo check -p codirigent-ui --features gpui-full -``` - -Post-phase follow-up verification: - -```bash -cargo fmt --all -- --check -cargo test -p codirigent-ui --lib -cargo check -p codirigent-ui --features gpui-full -``` - -Windows-target follow-up: - -- Attempted `cargo check -p codirigent-ui --lib --tests --target x86_64-pc-windows-msvc --features gpui-full`. -- The check was blocked on this macOS host before reaching project code because the Windows target C toolchain/dependencies were unavailable (`ring`/`libz-sys` failed due to missing SDK headers and `vcpkg` setup). -- The project-side portability fixes for Phase 2 were still applied: Unix-only fallback/test paths were removed from the new lifecycle code, so the branch no longer embeds those assumptions. - -### Phase 3 Completion Notes - -Implemented: - -- Added `crates/codirigent-ui/src/terminal_runtime.rs` with a background-owned `TerminalRuntimeHandle` that owns terminal parsing/state mutation and publishes immutable `TerminalRenderSnapshot` values. -- Refactored `crates/codirigent-ui/src/terminal_view.rs` so `TerminalView` now holds the latest committed snapshot plus UI-only caches instead of a live mutable `Terminal`. -- Moved `Terminal::process_output()` off the UI thread by changing `crates/codirigent-ui/src/workspace/impl_output_polling.rs` to apply PTY bytes inside the background output-preparation step and send only render snapshots plus metadata back to the UI. -- Updated `crates/codirigent-ui/src/workspace/terminal_render.rs` to render selection as a UI overlay on top of snapshot-backed row caches, avoiding selection-triggered terminal-state work on the UI thread. -- Updated workspace call sites in `gpui.rs` and `impl_clipboard.rs` to use snapshot-backed terminal accessors (`rows`, `cols`, `mode`, `bracketed_paste_mode`) instead of reaching into a live terminal. -- Propagated theme changes into terminal runtimes in `settings_panels.rs` so snapshot colors stay aligned with the active workspace theme. - -Phase 3 tests added or updated: - -- Runtime tests for generation advancement, resize propagation, and background selection extraction in `terminal_runtime.rs`. -- Terminal view tests updated to use runtime-backed output application instead of direct terminal mutation. -- Added a stale-snapshot rejection test to verify generation guards in `TerminalView`. -- Added a scrollback-selection overlay test to verify viewport-relative selection rendering on top of snapshots. - -Verification completed successfully with: - -```bash -cargo clean -cargo fmt --all -- --check -cargo check --workspace --all-targets --all-features -cargo build --workspace --all-features -cargo test --all --all-targets --all-features -cargo clippy --all --all-targets --all-features -- -D warnings -cargo check -p codirigent-ui --features gpui-full -``` - -Targeted phase validation run before the full gate: - -```bash -cargo check -p codirigent-ui --features gpui-full -cargo test -p codirigent-ui --lib --features gpui-full -``` - -Manual Phase 3 focus-mode validation is still pending on a live app run. The automated gate is green, but the specific “single-session focus mode under sustained output” interaction still needs hands-on review. - -### Phase 4 Completion Notes - -Implemented: - -- Split detector maintenance out of the UI-side maintenance poll by adding a dedicated detector-maintenance polling loop in `crates/codirigent-ui/src/workspace/gpui.rs`. -- Added a background detector-maintenance batch collector in `crates/codirigent-ui/src/workspace/impl_output_polling.rs` that performs `detector.tick()` and stale cached-status enumeration off the UI thread. -- Added `detector_maintenance_in_flight` tracking in `crates/codirigent-ui/src/workspace/types.rs` so the new maintenance loop cannot enqueue overlapping detector jobs. -- Kept status reconciliation, header refreshes, task transitions, and notifications on the UI thread, but reduced the UI work to applying a precomputed list of affected session IDs. -- Removed detector tick work from `WorkspaceView::poll_maintenance()`, leaving hook/JSONL scans, compaction cleanup, git refresh scheduling, and clipboard preview updates on the existing maintenance loop. - -Phase 4 tests added: - -- Unit test for detector-maintenance ID merging to ensure changed detector results keep priority while duplicate session IDs are removed. -- Unit test for detector-maintenance batch collection to ensure stale cached-status sessions are still reconciled even when the detector itself reports no changes. - -Cross-platform and branch-hygiene checks performed during Phase 4: - -- The new tests use platform-neutral fixtures only and do not introduce Unix-only filesystem assumptions. -- Touched production source files remain free of new `unwrap()` calls under the repo's CI unwrap-count rule. - -Verification completed successfully with: - -```bash -cargo clean -cargo fmt --all -- --check -cargo check --workspace --all-targets --all-features -cargo build --workspace --all-features -cargo test --all --all-targets --all-features -cargo clippy --all --all-targets --all-features -- -D warnings -cargo check -p codirigent-ui --features gpui-full -``` - -Targeted phase validation run before the full gate: - -```bash -cargo fmt --all -- --check -cargo check -p codirigent-ui --features gpui-full -cargo test -p codirigent-ui --lib --all-features -``` - -### Phase 5 Completion Notes - -Implemented: - -- Removed the render-time derived-state fallback from `WorkspaceView::render()` in `crates/codirigent-ui/src/workspace/gpui.rs`, including the old `ui_sync_dirty` / `last_ui_sync` polling fields from `crates/codirigent-ui/src/workspace/types.rs`. -- Split the old `sync_ui_state()` sweep into explicit reducers in `gpui.rs`: - - `sync_task_board_state()` - - `sync_all_session_headers()` - - `sync_empty_cells_state()` - - `sync_layout_derived_state()` - - `sync_task_derived_state()` - - `refresh_derived_ui_state()` -- Expanded `sync_session_header()` so the hot path now updates `project_name`, `task`, git metadata, focus, and session color without relying on a later full recompute. -- Replaced former `mark_ui_sync_dirty()` mutation sites across the workspace implementation files with explicit reducer calls matched to the type of mutation: - - layout/focus changes use `sync_layout_derived_state()` - - task transitions use `sync_task_derived_state()` - - broad structural session/project mutations use `refresh_derived_ui_state()` -- Seeded derived UI state during `WorkspaceView::new()` so initial render starts from committed reducer output rather than waiting for a future render repair pass. - -Phase 5 tests added: - -- Unit tests for `session_project_name()` covering git-root preference and working-directory fallback. -- Unit tests for cached task-title resolution fallback behavior in the new reducer helpers. - -Branch-hygiene and portability checks performed during Phase 5: - -- Confirmed no stale references remain to `mark_ui_sync_dirty`, `ui_sync_dirty`, `last_ui_sync`, or `sync_ui_state()` in the workspace code. -- Confirmed no new production `unwrap()` calls were introduced under the repo's CI unwrap-count rule (`138 -> 138` versus `origin/main`). -- Scanned the touched Phase 5 workspace files for new Unix-only path literals and found none. - -Verification completed successfully with: - -```bash -cargo clean -cargo fmt --all -- --check -cargo check --workspace --all-targets --all-features -cargo build --workspace --all-features -cargo test --all --all-targets --all-features -cargo clippy --all --all-targets --all-features -- -D warnings -cargo check -p codirigent-ui --features gpui-full -``` - -Targeted phase validation run before the full gate: - -```bash -cargo test -p codirigent-ui gpui::tests --features gpui-full -cargo check -p codirigent-ui --features gpui-full -``` - -Manual Phase 5 validation is still pending on a live app run. The render-path fallback is removed and the automated gate is green, but the final interactive review for task-board/header freshness under real session activity still needs hands-on confirmation. - -## Problem Statement - -Focus mode with a single visible session exposes the current architecture's weakest path: - -1. PTY output is drained on a background task. -2. The prepared output is handed back to `WorkspaceView`. -3. `WorkspaceView` applies terminal output on the UI thread. -4. Rendering then rebuilds or reshapes terminal rows on the same thread. -5. The same thread is also responsible for click handling, keyboard handling, layout, and paint submission. - -Under sustained output, the UI thread becomes both the terminal state mutator and the renderer. That is the core architectural issue. Poll frequency tuning can change how often the problem appears, but it does not change ownership of the hot path. - -## Goals - -- Move the remaining terminal/session workflow off the UI thread where practical. -- Make the UI thread responsible only for event dispatch, state application, layout, and paint. -- Preserve visible behavior during the migration. -- Reduce worst-case frame time under sustained PTY output. -- Make focused single-session rendering no worse than multi-session rendering in terms of responsiveness. -- Replace render-time recomputation with mutation-driven derived UI state. - -## Non-Goals - -- Redesigning the visual terminal renderer. -- Replacing GPUI. -- Rewriting the detector heuristics from scratch. -- Extracting the terminal engine into a new crate in this first pass. -- Perfectly eliminating all background work from `WorkspaceView`; the target is to remove heavy and blocking work from the UI thread, not every mutation. - -## Scope - -This plan covers the five remaining migration items: - -1. Move terminal output application and terminal damage generation off the UI thread. -2. Move PTY writes and PTY resizes off the UI thread. -3. Move session creation and restore PTY spawn flow off the UI thread. -4. Move detector ticking and process-state polling off the UI thread. -5. Remove full `sync_ui_state()` recomputation from the render path. - -## Current Hotspots - -### 1. Terminal output apply on the UI thread - -- `crates/codirigent-ui/src/workspace/impl_output_polling.rs` - - `apply_prepared_session_output()` -- `crates/codirigent-ui/src/terminal.rs` - - `Terminal::process_output()` - -Current behavior: - -- PTY bytes are drained in the background. -- The bytes are sent back into `WorkspaceView::update(...)`. -- `Terminal::process_output()` is called synchronously on the UI thread. -- Status reconciliation and header updates then run on the same thread. - -### 2. Terminal row cache and shaping work in the render path - -- `crates/codirigent-ui/src/workspace/terminal_render.rs` - - `render_terminal_content()` -- `crates/codirigent-ui/src/terminal_view.rs` - - `render_rows()` - - `shaped_rows()` - -Current behavior: - -- Rendering can trigger row-cache rebuilds. -- Dirty rows can trigger shape rebuilds. -- In worst-case output, render cost grows with viewport size and damage size. - -### 3. PTY writes and resizes called synchronously from UI callbacks - -- `crates/codirigent-session/src/manager.rs` - - `send_input()` - - `resize()` -- Representative call sites: - - `crates/codirigent-ui/src/workspace/gpui.rs` - - `crates/codirigent-ui/src/workspace/impl_output_polling.rs` - - `crates/codirigent-ui/src/workspace/impl_task_board.rs` - -Current behavior: - -- Keyboard handlers, deferred-enter processing, VTE response forwarding, task assignment, and layout resize propagation can all call into PTY I/O synchronously. - -### 4. Session creation and restore still do PTY spawn on the UI thread - -- `crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs` - - `create_session_inner()` - - `restore_session_from_plan()` -- `crates/codirigent-session/src/manager.rs` - - `create_session()` - -Current behavior: - -- Working directory validation, shell resolution, PTY spawn, session registration, and some restore replay happen synchronously from UI mutation paths. - -### 5. Detector maintenance work still runs from the UI polling loop - -- `crates/codirigent-ui/src/workspace/impl_output_polling.rs` - - `poll_maintenance()` - - `tick_detector_statuses()` -- `crates/codirigent-detector/src/detector.rs` - - `tick()` - -Current behavior: - -- Detector ticking, stale cache sweep, and related status reconciliation are still initiated from the UI thread on the maintenance cadence. - -### 6. Full UI model recomputation still happens from `render()` - -- `crates/codirigent-ui/src/workspace/gpui.rs` - - `sync_ui_state()` - - `render()` - -Current behavior: - -- `render()` still has a fallback path that rebuilds task board snapshots and other derived UI metadata. -- This violates a clean render contract and can turn missed invalidations into visible hitches. - -## Existing Background Work To Preserve - -The following areas are already backgrounded and should stay that way: - -- File tree construction and worktree enumeration. -- App state load/save. -- Settings load/save. -- JSONL readers. -- Hook signal scanning. -- Git refresh work. -- Clipboard preview image processing. - -This refactor should not regress those paths by reintroducing UI-thread blocking. - -## Target Architecture - -The target model is event-driven, snapshot-based, and actor-owned. - -### High-Level Ownership - -- `SessionManager` - - Owns session metadata registration. - - Owns PTY bootstrap. - - Owns PTY reader and PTY command channel lifecycle. - -- `SessionIoWorker` - - Owns PTY write and resize commands after creation. - - Serializes PTY mutations. - - Coalesces resizes. - -- `TerminalRuntime` - - Owns terminal parser state for each live session. - - Consumes drained PTY bytes off the UI thread. - - Produces immutable render snapshots or deltas. - - Emits metadata updates derived from output: - - shell state - - cwd changes - - CLI detection hints - - output activity markers - -- `DetectorWorker` - - Owns detector ticks and process-state polling. - - Produces status deltas instead of requiring the UI thread to poll detector state. - -- `UiStateReducer` - - Runs on the UI thread. - - Applies already-prepared mutations. - - Maintains derived UI state incrementally. - - Never performs O(all tasks) or O(all sessions) recomputation inside `render()`. - -### Required Data Contracts - -The refactor depends on explicit message boundaries. - -#### PTY command path - -```rust -enum SessionIoCommand { - Write { bytes: Vec }, - Resize { rows: u16, cols: u16 }, - Shutdown, -} -``` - -Requirements: - -- `Write` must preserve ordering. -- `Resize` must be latest-wins when multiple resizes queue up during drag. -- Commands must be fire-and-forget from the UI thread. - -#### Terminal runtime path - -```rust -struct SessionRenderSnapshot { - session_id: SessionId, - generation: u64, - rows: Arc<[RenderRow]>, - dirty_rows: Arc<[usize]>, - cursor: Option, - viewport: ViewportSnapshot, -} - -struct SessionMetadataDelta { - session_id: SessionId, - cwd: Option, - detected_cli_type: Option, - shell_state: Option, - has_more_output: bool, -} -``` - -Requirements: - -- Snapshots must be immutable once published. -- The UI should always be able to swap to the latest snapshot without reparsing PTY bytes. -- Snapshot publication must not require holding UI state locks. - -#### Detector path - -```rust -struct SessionStatusDelta { - session_id: SessionId, - detector_status: Option, - idle_time: Option, - stale_cache_candidates: bool, -} -``` - -Requirements: - -- Detector work should return changed sessions only. -- The UI thread should apply targeted status deltas, not scan all sessions each maintenance tick. - -## Module-By-Module Change Map - -This section maps the planned refactor onto the current code layout so the implementation can be split into reviewable PRs. - -| Area | Current modules | Planned changes | Notes | -| --- | --- | --- | --- | -| PTY command queue | `crates/codirigent-session/src/manager.rs` | Add a per-session command sender, worker bootstrap, queue-backed `send_input()`, queue-backed `resize()` | First low-risk slice. | -| Session bootstrap | `crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs`, `crates/codirigent-session/src/manager.rs` | Extract blocking creation into background bootstrap job and completion messages | Needed before full terminal runtime offload. | -| Terminal runtime | `crates/codirigent-ui/src/workspace/impl_output_polling.rs`, `crates/codirigent-ui/src/terminal.rs`, `crates/codirigent-ui/src/terminal_view.rs` | Introduce background terminal runtime ownership and immutable snapshots | Main performance workstream. | -| Render path | `crates/codirigent-ui/src/workspace/terminal_render.rs`, `crates/codirigent-ui/src/terminal_view.rs` | Stop render-triggered state mutation; render committed snapshots only | Final text shaping may remain UI-owned. | -| Detector maintenance | `crates/codirigent-ui/src/workspace/impl_output_polling.rs`, `crates/codirigent-detector/src/detector.rs` | Move detector tick and stale cache sweep into a worker | Can be staged after terminal runtime work. | -| Derived UI state | `crates/codirigent-ui/src/workspace/gpui.rs` | Split `sync_ui_state()` into reducers and remove full recompute from `render()` | Final cleanup phase. | - -### Suggested New Modules - -- `crates/codirigent-ui/src/workspace/terminal_runtime.rs` - - background terminal parser ownership - - snapshot publication - - generation tracking - -- `crates/codirigent-session/src/session_io.rs` - - PTY command worker - - write ordering - - resize coalescing - -- `crates/codirigent-ui/src/workspace/session_bootstrap.rs` - - async session creation and restore bootstrap jobs - -- `crates/codirigent-ui/src/workspace/status_worker.rs` - - detector cadence and status-delta publication - -- `crates/codirigent-ui/src/workspace/ui_reducer.rs` - - derived task-board and session-summary reducers - -These names are recommendations, not requirements. The important part is separating worker-owned logic from UI-owned mutation and render logic. - -## Thread Ownership After Refactor - -### Must stay on the UI thread - -- GPUI event handling. -- Focus changes. -- Input routing decisions. -- Layout calculation. -- Final element tree creation. -- Final text shaping if GPUI text APIs remain UI-thread-bound. -- Paint submission. -- Applying already-prepared background deltas to UI-visible state. - -### Must move off the UI thread - -- PTY spawn and restore bootstrapping. -- PTY writes. -- PTY resizes. -- Terminal output parsing and terminal-state mutation. -- Terminal damage generation. -- Detector ticking and stale-status polling. -- Any session-wide or task-wide derived-state recomputation that does not require GPUI APIs. - -### Split ownership - -- Terminal rendering data preparation - - Move terminal-state mutation, damage extraction, and row materialization off-thread. - - Keep only the final GPUI text shaping step on the UI thread unless GPUI exposes a safe background shaping path. - -This split is important. We should not block this refactor on moving text shaping off-thread if the framework does not support it. - -## Workstream 1: Move Terminal Output Apply Off The UI Thread - -### Current Entry Points - -- `WorkspaceView::apply_prepared_session_output()` -- `Terminal::process_output()` -- `TerminalView::render_rows()` - -### Target End State - -- PTY bytes are parsed by a background `TerminalRuntime`. -- `WorkspaceView` receives ready-to-apply snapshots and metadata deltas. -- The UI thread no longer calls `Terminal::process_output()`. -- `TerminalView` becomes a lightweight snapshot holder plus view-specific caches. - -### Design - -Introduce one `TerminalRuntimeHandle` per session. - -Responsibilities: - -- Consume PTY bytes from the current prepared-output path. -- Apply `Terminal::process_output()` off-thread. -- Track terminal damage. -- Materialize render rows or row deltas. -- Emit `SessionRenderSnapshot` plus `SessionMetadataDelta`. - -Initial implementation should reuse the current output preparation pipeline rather than rewriting session output delivery and terminal rendering in one step. The migration should change the owner of `process_output()`, not the entire output transport in phase one. - -### Detailed Steps - -1. Add a new runtime module in `codirigent-ui` for terminal background work. -2. Move the mutable terminal parser state out of `TerminalView`. -3. Convert `TerminalView` into: - - latest committed snapshot - - font/theme settings - - UI-only caches - - cursor/IME view data -4. Replace `apply_prepared_session_output()` so it publishes drained bytes to the session runtime instead of mutating terminal state directly. -5. Add a UI-facing channel for `SessionRenderSnapshot` and `SessionMetadataDelta`. -6. Apply snapshots inside `WorkspaceView::update(...)` with no parsing work. -7. Remove `Terminal::process_output()` from all UI-thread code paths. - -### Damage Strategy - -- Use damage ranges or dirty row lists from the terminal runtime. -- Publish full snapshots only as a fallback. -- Prefer latest-snapshot-wins semantics for rendering. -- Intermediate paint requests may be dropped; terminal state must never be dropped. - -### Acceptance Criteria - -- No call to `Terminal::process_output()` from any `WorkspaceView` UI update path. -- Focused single-session mode remains interactive under sustained PTY output. -- Snapshot application on the UI thread is bounded and does not parse bytes. -- Existing session status behavior still updates correctly. - -### Risks - -- Snapshot payloads can become too large if they clone entire viewports too often. -- TerminalView may still do too much UI-side shaping work if snapshots are too raw. -- Output-derived metadata must remain ordered relative to rendered content. - -### Risk Mitigations - -- Start with dirty-row snapshots. -- Use generation counters to discard stale updates. -- Benchmark snapshot size and clone frequency before widening the contract. - -## Workstream 2: Move PTY Writes And Resizes Off The UI Thread - -### Current Entry Points - -- `DefaultSessionManager::send_input()` -- `DefaultSessionManager::resize()` -- UI call sites in keyboard handlers, deferred-enter handling, task assignment, VTE response forwarding, and resize sync. - -### Target End State - -- The UI thread enqueues PTY commands and returns immediately. -- A per-session `SessionIoWorker` owns PTY writes and resizes. -- PTY command ordering is explicit. - -### Design - -At session creation time, split PTY responsibilities: - -- PTY output reader remains background-owned. -- PTY write/resize path moves behind a `SessionIoCommand` sender. - -`SessionState` should retain: - -- session metadata -- child pid -- command sender -- output reader handle metadata - -It should no longer require a UI-thread caller to lock the manager and directly touch the PTY for every input event. - -### Detailed Steps - -1. Add a per-session command channel when the PTY is created. -2. Spawn a blocking worker that owns the PTY write/resize operations. -3. Change `send_input()` to enqueue `SessionIoCommand::Write`. -4. Change `resize()` to enqueue `SessionIoCommand::Resize`. -5. Add resize coalescing in the worker: - - collapse multiple queued resizes into the last seen size - - avoid replaying obsolete geometry during window drags -6. Audit all synchronous PTY call sites and convert them to the queue-based API. -7. Add observability: - - command queue depth - - resize collapse count - - write failures - -### Acceptance Criteria - -- No UI handler directly performs PTY I/O. -- Window drag/resize does not trigger synchronous PTY calls on the UI thread. -- Keyboard and VTE response paths remain ordered and correct. - -### Risks - -- Write ordering bugs can corrupt terminal interaction. -- Unbounded queues can hide overload until memory grows. - -### Risk Mitigations - -- Preserve per-session ordering with one queue per session. -- Consider bounded queues with explicit logging if pressure appears. -- Keep resize latest-wins while preserving write ordering. - -## Workstream 3: Move Session Creation And Restore Off The UI Thread - -### Current Entry Points - -- `WorkspaceView::create_session_inner()` -- `WorkspaceView::restore_session_from_plan()` -- `DefaultSessionManager::create_session()` - -### Target End State - -- Session creation becomes a background bootstrap job. -- The UI thread only initiates creation and applies the result. -- Restore queues multiple background bootstrap jobs without blocking render. - -### Design - -Introduce a `SessionBootstrapJob` with a completion message: - -```rust -struct SessionBootstrapResult { - session_id: SessionId, - session: Session, - child_pid: Option, - io_sender: SessionIoSender, - output_handle: SessionOutputHandle, - pending_restore_commands: Vec>, -} -``` - -The UI thread should be able to: - -- reserve a slot or pending placeholder -- kick off the job -- receive success or failure -- attach the finished session to workspace state - -### Detailed Steps - -1. Extract synchronous creation logic from `create_session_inner()` into a background bootstrap function. -2. Validate working directory and resolve shell in the bootstrap job. -3. Spawn the PTY and IO worker in the bootstrap job. -4. Return session metadata and handles to the UI thread. -5. Only after success: - - create the `TerminalRuntime` - - attach the session to the workspace - - start detector monitoring - - replay resume commands through the command queue -6. For restore: - - keep restore plans immutable - - queue one bootstrap job per saved session - - attach sessions incrementally as results arrive -7. Add a visible pending state so the UI does not look frozen during slow shell startup. - -### Acceptance Criteria - -- Creating or restoring sessions never blocks `render()` or input handlers. -- Slow shell startup or bad working directory validation does not freeze the UI. -- Restore replay preserves ordering of resume commands after the PTY is ready. - -### Risks - -- Session slot assignment can race with late-arriving bootstrap results. -- Failed creation jobs can leave orphaned placeholder state. - -### Risk Mitigations - -- Use a creation token per pending session slot. -- Only commit bootstrap results if the token still matches. -- Define explicit cleanup for failed bootstrap jobs. - -## Workstream 4: Move Detector Tick And Process-State Polling Off The UI Thread - -### Current Entry Points - -- `WorkspaceView::poll_maintenance()` -- `WorkspaceView::tick_detector_statuses()` -- `Detector::tick()` - -### Target End State - -- Detector maintenance runs independently of UI frame work. -- The UI thread receives targeted `SessionStatusDelta` messages. -- Stale cache reconciliation no longer requires a UI-driven sweep across session caches. - -### Design - -Introduce a dedicated maintenance worker that owns: - -- detector tick cadence -- stale cache review cadence -- per-session process-state updates - -The worker should emit: - -- changed detector status -- idle-time updates when needed -- stale-cache reconciliation requests or already-reconciled status results - -The cleanest version is to move both detector ticking and status reconciliation into the worker and send a final `SessionStatusPatch` to the UI thread. If that is too large for phase one, move detector ticking first and keep UI-side targeted reconcile as an intermediate state. - -### Detailed Steps - -1. Create a background maintenance task that ticks the detector at the current cadence. -2. Return changed session IDs and any needed status metadata over a channel. -3. Move stale cached-status sweep out of the UI path. -4. Decide the final ownership of `status_engine::reconcile()`: - - preferred: background worker - - acceptable intermediate step: UI applies only the changed patches -5. Keep notifications, task transitions, and UI header refreshes on the UI thread, but only as a reaction to precomputed status changes. -6. Remove detector polling from `poll_maintenance()`. - -### Acceptance Criteria - -- `poll_maintenance()` no longer performs detector tick work on the UI thread. -- Idle-to-working and working-to-idle transitions still behave correctly. -- Sessions without OSC integration still decay back to idle correctly. - -### Risks - -- Status ordering bugs can produce flicker or stale headers. -- Split ownership between detector and reconciler can create temporary inconsistency. - -### Risk Mitigations - -- Include sequence numbers or timestamps in status deltas. -- Prefer moving full reconciliation with the detector when feasible. - -## Workstream 5: Remove `sync_ui_state()` From The Render Path - -### Current Entry Points - -- `WorkspaceView::render()` -- `WorkspaceView::sync_ui_state()` - -### Target End State - -- `render()` becomes read-only with respect to derived UI state. -- Task board state, counts, pending assignments, and similar aggregates are rebuilt on mutation, not as a fallback in the hot render path. - -### Design - -Introduce a dedicated `UiDerivedState` struct that is updated incrementally from mutation sources: - -- task manager changes -- session header changes -- layout changes -- workspace session add/remove -- settings changes - -`render()` should only: - -- read the already-prepared state -- update layout-dependent caches that strictly require current window metrics -- build the element tree - -### Detailed Steps - -1. Split `sync_ui_state()` into smaller reducers: - - task-board reducer - - session-list reducer - - layout-derived reducer -2. Identify the mutation sources that currently rely on fallback sync. -3. Trigger the appropriate reducer directly from those mutation paths. -4. Replace the `render()` fallback sync with debug assertions or lightweight diagnostics. -5. Keep a temporary kill-switch during rollout in case an invalidation path is missed. -6. Once stable, remove the fallback interval entirely. - -### Acceptance Criteria - -- `render()` no longer calls a full derived-state recomputation function. -- Task board and session metadata remain correct after all existing mutation paths. -- Missed invalidations can be detected in debug builds. - -### Risks - -- Missing invalidation hooks can leave stale UI sections. -- Partial reducers can drift apart if ownership is unclear. - -### Risk Mitigations - -- Add explicit reducer tests. -- Use narrow reducer APIs with clear callers. -- Keep temporary diagnostics that compare reducer output against old full recompute in debug mode. - -## Recommended Rollout Order - -The five workstreams are coupled. The order below minimizes architectural churn. - -### Phase 0: Instrumentation And Baseline - -Before behavior changes: - -- add tracing spans around: - - output apply - - render_terminal_content - - shaped_rows - - send_input - - resize - - create_session - - tick_detector_statuses - - sync_ui_state -- capture frame time and input latency under a synthetic high-output session -- record baseline CPU use in single-session focus mode - -Deliverable: - -- reproducible before/after benchmark script or manual test recipe - -### Phase 1: PTY Command Queue - -Do Workstream 2 first. - -Why first: - -- It is self-contained. -- It removes synchronous PTY writes/resizes from multiple hot input paths. -- It provides the command infrastructure needed by async session creation and restore. - -### Phase 2: Async Session Bootstrap - -Do Workstream 3 second. - -Why second: - -- It builds on the PTY command queue. -- It removes another blocking class from UI mutation paths. -- It creates the right lifecycle hook for the terminal runtime. - -### Phase 3: Terminal Runtime Offload - -Do Workstream 1 third. - -Why third: - -- It is the biggest performance win. -- It depends on stable session bootstrap and PTY ownership boundaries. -- It changes the data flow between session output and rendering. - -### Phase 4: Detector Worker - -Do Workstream 4 fourth. - -Why fourth: - -- It is logically separate once session runtime ownership is clear. -- It simplifies maintenance polling after the terminal path is no longer UI-bound. - -### Phase 5: Derived UI State Cleanup - -Do Workstream 5 last. - -Why last: - -- It benefits from the new event boundaries created in earlier phases. -- It should be done after background result application paths are stable. - -## Per-Phase Implementation Verification - -Each phase needs its own implementation test plan. A phase is not complete when the code compiles; it is complete when the new ownership boundary is tested directly, the affected UX path is manually verified, and the full repo verification gate passes. - -### Phase 0: Instrumentation And Baseline - -Implementation checks: - -- Confirm new tracing spans compile and are emitted in debug logs. -- Verify baseline metrics can be collected for: - - frame duration - - terminal render duration - - output apply duration - - detector tick duration - -Manual checks: - -- Record a reproducible single-session focus-mode high-output scenario. -- Record a reproducible session-create / restore scenario. -- Record a reproducible window-drag / terminal-resize scenario. - -Phase exit criteria: - -- Baseline numbers are written down and can be compared after each later phase. -- There is a repeatable manual test recipe for every hot path being refactored. - -### Phase 1: PTY Command Queue - -Implementation tests: - -- Unit tests for per-session write ordering. -- Unit tests for resize coalescing with interleaved writes. -- Unit tests for worker shutdown and channel teardown. -- Unit tests for command send failure behavior after session close. - -Integration tests: - -- Session input path still reaches the PTY in order. -- Deferred-enter and VTE response forwarding still reach the PTY in order. -- Resize storms collapse to the latest geometry without dropping writes. - -Manual checks: - -- Type continuously into an active session while output is streaming. -- Drag-resize the window and verify terminal size keeps up without UI stalls. -- Trigger task assignment and confirm the queued task prompt still arrives correctly. - -Phase exit criteria: - -- No direct synchronous PTY write/resize path remains in UI handlers. -- Input ordering is preserved. -- Resize behavior is stable under rapid window drag. - -### Phase 2: Async Session Bootstrap - -Implementation tests: - -- Unit tests for successful bootstrap result assembly. -- Unit tests for invalid working directory failure. -- Unit tests for shell resolution failure and cleanup. -- Unit tests for placeholder token matching and stale result rejection. - -Integration tests: - -- Create-session path attaches a live session after background bootstrap succeeds. -- Restore-session path replays resume commands after PTY readiness. -- Failed bootstrap does not leave orphaned workspace/session state. - -Manual checks: - -- Create a new session while another session is producing heavy output. -- Restore multiple sessions and verify the UI remains responsive while they appear incrementally. -- Test a deliberately bad working directory and confirm the UI reports failure without freezing. - -Phase exit criteria: - -- Session creation and restore do not block UI interaction. -- Placeholder and cleanup behavior is correct on both success and failure paths. - -### Phase 3: Terminal Runtime Offload - -Implementation tests: - -- Unit tests for output chunk ingest into the background runtime. -- Unit tests for damage generation and dirty-row snapshot publication. -- Unit tests for snapshot generation ordering and stale-generation drop behavior. -- Unit tests for metadata extraction ordering relative to rendered content. - -Integration tests: - -- High-output sessions produce snapshots without calling `Terminal::process_output()` from the UI path. -- CLI detection, cwd updates, and shell-state updates still arrive correctly. -- Focused session and non-focused session output both render correctly. - -Manual checks: - -- Run a high-output producer in single-session focus mode and verify clicks and typing remain responsive. -- Switch focus between noisy and quiet sessions and verify no stale frame artifacts. -- Confirm terminal selection, cursor rendering, and IME behavior still work. - -Phase exit criteria: - -- PTY output parsing is off the UI thread. -- UI-side snapshot application is bounded and lightweight. -- The original freeze symptom is materially reduced or eliminated in the baseline scenario. - -### Phase 4: Detector Worker - -Implementation tests: - -- Unit tests for detector tick result publication. -- Unit tests for sessions without OSC integration returning to idle. -- Unit tests for stale cached-status sweep behavior. -- Unit tests for out-of-order or duplicate detector events. - -Integration tests: - -- Detector changes reach session headers and status caches correctly. -- Notifications and task state changes still trigger from detector-driven transitions. -- Hook-driven sessions still prefer hook status over detector fallbacks. - -Manual checks: - -- Exercise generic shell sessions that rely on detector decay. -- Exercise hook-capable sessions and confirm there is no status regression. -- Leave sessions idle long enough to verify stale-state cleanup behavior. - -Phase exit criteria: - -- `poll_maintenance()` no longer performs detector tick work on the UI thread. -- Detector-driven transitions remain correct across supported session types. - -### Phase 5: Derived UI State Cleanup - -Implementation tests: - -- Unit tests for task-board reducer updates across all task-status transitions. -- Unit tests for session-summary reducer updates on add/remove/focus/group changes. -- Unit tests for layout-derived state invalidation. -- Debug-only parity checks between reducer output and legacy full recompute while the fallback still exists. - -Integration tests: - -- Task board remains correct during rapid session and task updates. -- Session headers, counts, and pending-assignment state stay synchronized. -- No mutation path depends on `render()` to repair stale UI state. - -Manual checks: - -- Exercise task creation, assignment, verification, and completion flows. -- Rapidly switch layouts and focus while sessions update in the background. -- Verify no stale sidebar or task-board state appears after large batches of updates. - -Phase exit criteria: - -- `render()` is read-only with respect to full derived UI state. -- The fallback recompute path is removed or reduced to debug-only diagnostics. - -## Required Verification Gate After Each Phase - -Every implementation phase must end with a full local verification pass. The fast targeted tests above are not enough by themselves. - -Required commands: - -```bash -cargo clean -cargo fmt --all -- --check -cargo check --workspace --all-targets --all-features -cargo build --workspace --all-features -cargo test --all --all-targets --all-features -cargo clippy --all --all-targets --all-features -- -D warnings -``` - -Additional CI-parity check: - -```bash -cargo check -p codirigent-ui --features gpui-full -``` - -Notes: - -- `cargo fmt --all -- --check` is the formatting gate. This repo does not have a separate lint tool beyond formatting and clippy. -- `cargo clippy --all --all-targets --all-features -- -D warnings` is the local lint gate. -- `cargo clean` is required at phase completion, not just at the very end of the entire refactor. -- If local platform constraints prevent full cross-platform verification, the local gate must still pass on the active platform and CI must be allowed to validate the Windows/macOS matrix. - -## Detailed Validation Plan - -### Automated Tests - -- Session creation tests - - async bootstrap success - - invalid working directory failure - - restore replay order - -- PTY command queue tests - - write ordering - - resize coalescing - - shutdown cleanup - -- Terminal runtime tests - - output parsing produces correct row snapshots - - dirty-row updates do not require full snapshot rebuild - - stale generation snapshots are dropped - -- Detector tests - - sessions without OSC integration return to idle - - stale cached status transitions still occur - - out-of-order detector events do not regress state - -- UI reducer tests - - task-board counts update on each task status transition - - session list updates on add/remove/focus/group changes - - no render-path recomputation dependency remains - -### Manual Verification - -- Focus mode, single session, high-output producer - - clicks remain responsive - - keyboard input remains responsive - - render remains visually correct - -- Multi-session fairness - - one noisy session does not starve others - -- Window drag/resize - - no visible stalls - - terminal size catches up correctly - -- Session restore - - many-session restore does not freeze the app - -- Codex / Claude / generic shell sessions - - status transitions still behave as expected - - cwd and git refresh still update correctly - -### Instrumentation Metrics - -- UI thread frame duration percentile -- time spent applying output on UI thread -- time spent inside `render_terminal_content` -- terminal snapshot publish rate -- terminal snapshot size -- PTY command queue depth -- detector tick duration -- derived UI reducer duration - -## Migration Invariants - -These invariants must hold throughout the refactor: - -1. Terminal data ordering per session must remain correct. -2. PTY write ordering per session must remain correct. -3. A session must not accept output updates after shutdown. -4. Resize commands may collapse, but writes may not reorder around each other. -5. UI state must only display committed session snapshots. -6. Status updates must remain monotonic with respect to the latest known event timestamp or generation. - -## Open Questions - -1. Can GPUI text shaping be safely performed off-thread, or must shaped rows remain UI-owned for now? -2. Should `Terminal` remain in `codirigent-ui`, or is a later extraction into a non-UI crate desirable after this refactor? -3. Should status reconciliation move fully into the detector worker, or is a staged split acceptable long term? -4. Do we want bounded PTY command queues with backpressure, or unbounded queues with diagnostics in the first pass? -5. Should session bootstrap create a visible placeholder pane immediately, or only attach the pane after PTY creation succeeds? - -## Recommended First Implementation Slice - -The first implementation PR should not attempt all five workstreams at once. - -Recommended first slice: - -1. Add instrumentation. -2. Add the per-session PTY command queue and worker. -3. Convert `send_input()` and `resize()` to queued commands. -4. Land tests for ordering and resize coalescing. - -This slice is low-risk, directly reduces UI-thread blocking, and creates the foundation needed for the remaining four workstreams. diff --git a/docs/architecture/workspace-module-split-plan.md b/docs/architecture/workspace-module-split-plan.md deleted file mode 100644 index ba154ec..0000000 --- a/docs/architecture/workspace-module-split-plan.md +++ /dev/null @@ -1,643 +0,0 @@ -# Workspace Module Split Plan - -## Status - -Draft only. Local planning document for a follow-up maintainability refactor after the UI-thread offload work. This document is intentionally scoped as a no-behavior-change module split and dependency cleanup. It is not committed. - -## Purpose - -The UI-thread offload refactor is functionally complete, but the workspace layer now has several oversized files that are difficult to review and risky to extend. The next step is to split those files into smaller modules without changing behavior. - -This plan exists to keep that work separate from the completed architectural refactor. The goals here are maintainability, reviewability, and dependency hygiene, not new product behavior. - -## Primary Hotspots - -Current line counts in `crates/codirigent-ui/src/workspace`: - -- `impl_output_polling.rs`: 2647 lines -- `gpui.rs`: 2500 lines -- `impl_session_lifecycle.rs`: 1388 lines -- `settings_panels.rs`: 1448 lines -- `task_board_render.rs`: 1360 lines -- `drawer_render.rs`: 1234 lines - -This plan focuses first on: - -1. `crates/codirigent-ui/src/workspace/impl_output_polling.rs` -2. `crates/codirigent-ui/src/workspace/gpui.rs` - -These two files are the highest-value split targets because they mix too many responsibilities and sit on the hottest integration boundaries. - -## Why This Is Separate From The Offload Plan - -The offload plan changed ownership boundaries and thread responsibilities. That work is complete enough to review as a functional unit. - -This plan is different: - -- It must be no-behavior-change. -- It will mostly move code, not redesign logic. -- It should preserve current public module paths where possible. -- It should be easy to review commit by commit. - -Mixing this work into the earlier plan would blur architectural changes with structural ones and make rollback harder. - -## Objectives - -1. Reduce file size and responsibility sprawl in the workspace layer. -2. Make it obvious where output flow, status reconciliation, UI reducers, event handling, and render-adjacent logic live. -3. Keep module dependencies directional and predictable. -4. Preserve current behavior, current tests, and current feature gates. -5. Avoid introducing new cross-platform assumptions, new `unwrap()` usage, or new warnings. - -## Non-Goals - -This refactor must not: - -- change session status behavior -- change output polling cadence -- change render behavior -- change task assignment behavior -- change file-tree behavior -- change startup/restore behavior -- move code across crates -- redesign the terminal runtime - -If a change affects behavior, it belongs in a different plan. - -## Constraints - -1. Keep `workspace/mod.rs` stable if possible. -2. Prefer internal submodules under existing module roots before renaming public modules. -3. Keep `gpui-full` feature gating correct for every new module. -4. Keep test discovery and test names stable where feasible. -5. Preserve branch hygiene: - - no new production `unwrap()` - - no new warnings - - no Unix-only path assumptions in touched production code - -## Target Shape - -### `impl_output_polling.rs` - -Keep `workspace/mod.rs` unchanged with `mod impl_output_polling;`. - -Use `impl_output_polling.rs` as a thin root module that owns shared types and re-exports internal helpers from submodules in `crates/codirigent-ui/src/workspace/impl_output_polling/`. - -Proposed internal split: - -- `output_runtime.rs` - - `poll_output()` - - dispatch scheduling - - prepared output apply - - terminal runtime handoff - - OSC 7 / OSC 133 extraction - -- `status_reconcile.rs` - - `sync_session_status()` - - cached-status reconciliation - - session-status side effects - - notifications and event-bus transitions tied to status changes - -- `cli_pollers.rs` - - JSONL readers - - rollout readers - - CLI metadata update application - - background polling entry points - -- `hook_signals.rs` - - hook-signal scan - - hook-signal apply - - run-epoch helpers - -- `git_refresh.rs` - - background git refresh scheduling - - apply helpers - - cwd/git cache sync helpers - -- `terminal_input.rs` - - deferred enter handling - - VTE response forwarding - - compaction input follow-up helpers - -- `tests.rs` - - optional follow-up if test density keeps the root too large - -Rules: - -- Shared helper functions should stay close to the submodule that owns them. -- The root module should only keep cross-cutting types/constants that are genuinely shared by multiple submodules. -- Do not move business logic into one new giant replacement module. - -### `gpui.rs` - -Keep `workspace/mod.rs` unchanged with `pub mod gpui;`. - -Keep `gpui.rs` as the root module that owns: - -- `WorkspaceView` -- constructor wiring -- core trait impls (`Render`, `Focusable`, IME-related impls) -- any shared root-level constants that are used widely enough to justify staying at the top - -Move implementation clusters into `crates/codirigent-ui/src/workspace/gpui/` submodules. - -Proposed internal split: - -- `derived_state.rs` - - task-board reducer helpers - - header sync helpers - - empty-cell sync helpers - - mutation-driven derived-state refresh entry points - -- `ui_events.rs` - - `process_ui_events()` - - `process_top_bar_events()` - - `process_icon_rail_events()` - -- `layout_sync.rs` - - layout switching helpers - - session focus helpers - - drag/swap follow-up helpers - - terminal dimension / resize coordination - -- `session_metadata.rs` - - lightweight session metadata helpers such as project-name/task-title derivation - -- `tests.rs` - - optional only if root test module becomes noisy - -Rules: - -- `Render::render()` should remain easy to scan and mostly orchestration-only. -- Do not bury trait impls deep enough that `WorkspaceView` becomes hard to understand. -- Avoid circular helper dependencies between `derived_state`, `layout_sync`, and `ui_events`. - -## Secondary Candidates - -These are not phase-one split targets, but they should be reviewed after the primary split: - -- `impl_session_lifecycle.rs` -- `settings_panels.rs` -- `task_board_render.rs` -- `drawer_render.rs` - -They should not be pulled into the first branch unless the primary split exposes an obvious dependency problem that requires them to move. - -## Dependency Rules - -The split should make dependencies clearer, not more tangled. - -Allowed direction: - -- `gpui` root -> `gpui::*` helpers -- `impl_output_polling` root -> `impl_output_polling::*` helpers -- narrow helper modules -> `types`, `status_engine`, `output_dispatcher`, `project_state`, existing workspace utilities - -Avoid: - -- helper modules calling back into sibling modules in both directions -- shared “misc” modules -- moving state ownership into helper modules -- duplicating logic just to avoid imports - -If two submodules need the same logic, either: - -1. keep it in the root module, or -2. extract a clearly named shared helper - -## Size Targets - -Soft targets after the split: - -- no primary workspace implementation file over 900 lines -- target most new implementation modules to land between 250 and 700 lines -- the root `gpui.rs` and `impl_output_polling.rs` files should become orchestration layers, not logic dumps - -These are maintainability targets, not hard rules. - -## Delivery Strategy - -### Phase A: Scaffolding - -Goals: - -- create target submodule directories -- move only imports, helper declarations, and `mod` wiring where needed -- keep behavior identical - -Checks: - -- compile with no logic changes -- no public module path changes - -#### Phase A Scope - -Phase A is intentionally mechanical. It prepares the file layout for later moves without changing behavior, execution order, ownership, or public paths. - -Deliverables: - -- internal submodule directories exist under the two primary roots -- root modules declare the new child modules -- test modules are moved out of the root files into dedicated `tests.rs` files where useful -- the verification gate passes after every Phase A task - -Phase A must not: - -- move behavior between functions -- split logic across files in the same task that introduces the new files -- change `workspace/mod.rs` -- change any `pub` surface -- introduce new target-specific code paths - -#### Phase A Task Breakdown - -##### Task A1: Scaffold `impl_output_polling` internal modules - -Create the internal directory and child files under `crates/codirigent-ui/src/workspace/impl_output_polling/`: - -- `output_runtime.rs` -- `status_reconcile.rs` -- `cli_pollers.rs` -- `hook_signals.rs` -- `git_refresh.rs` -- `terminal_input.rs` - -Update `crates/codirigent-ui/src/workspace/impl_output_polling.rs` to declare these child modules, but keep all existing function bodies in the root file for this task. - -Expected dependency shape after Task A1: - -- `impl_output_polling.rs` remains the owner of shared types, constants, and orchestration entry points -- `output_runtime.rs` will later depend on: - - `WorkspaceView` - - `output_dispatcher` - - terminal runtime snapshot application - - `sync_session_status()` - - OSC 7 / OSC 133 extraction helpers -- `status_reconcile.rs` will later depend on: - - `WorkspaceView` - - `status_engine` - - `status_providers` - - task-manager side effects - - compaction and notification follow-up -- `cli_pollers.rs` will later depend on: - - `CliReaders` - - JSONL / rollout parsing helpers - - cached CLI status update logic - - root-owned status-apply entry points -- `hook_signals.rs` will later depend on: - - hook signal file parsing - - run-epoch helpers - - cached hook-signal application - - root-owned status-apply entry points -- `git_refresh.rs` will later depend on: - - session manager git refresh helpers - - cached git-info apply helpers - - focused-session file-tree refresh hooks -- `terminal_input.rs` will later depend on: - - deferred enter handling - - VTE response forwarding - - compaction input follow-up helpers - -Dependency rules for this task: - -- child modules may depend on the root module and existing workspace utilities -- child modules must not call each other in both directions -- any helper needed by more than one child module stays in the root until a clearly shared abstraction exists - -Verification after Task A1: - -```bash -cargo clean -cargo fmt --all -- --check -cargo check --workspace --all-targets --all-features -cargo build --workspace --all-features -cargo test --all --all-targets --all-features -cargo test -p codirigent-ui --lib --features gpui-full -cargo clippy --all --all-targets --all-features -- -D warnings -cargo check -p codirigent-ui --features gpui-full -git diff --check -``` - -Additional verification requirement: - -- inspect the task diff and confirm no new production `unwrap()` or `expect()` calls were introduced - -##### Task A2: Scaffold `gpui` internal modules - -Create the internal directory and child files under `crates/codirigent-ui/src/workspace/gpui/`: - -- `session_metadata.rs` -- `derived_state.rs` -- `ui_events.rs` -- `layout_sync.rs` - -Update `crates/codirigent-ui/src/workspace/gpui.rs` to declare these child modules, but keep all existing function bodies in the root file for this task. - -Expected dependency shape after Task A2: - -- `gpui.rs` remains the owner of: - - `WorkspaceView` - - constructor wiring - - `Render`, `Focusable`, and IME trait impls - - root-level constants -- `session_metadata.rs` will later contain the lightest-weight helpers and should depend only on: - - session data - - task-title lookup inputs - - standard library collections/path formatting -- `derived_state.rs` will later depend on: - - `WorkspaceView` - - task-board state - - terminal header state - - empty-cell state - - `session_metadata` helpers -- `ui_events.rs` will later depend on: - - `WorkspaceView` - - top bar / icon rail / task board event sources - - root-owned mutation helpers such as layout or session actions -- `layout_sync.rs` will later depend on: - - `WorkspaceView` - - layout cache invalidation - - focus/layout transitions - - terminal dimension and resize coordination - -Dependency rules for this task: - -- `session_metadata.rs` should stay leaf-like and not depend on render/event modules -- `derived_state.rs` may use `session_metadata.rs`, but `session_metadata.rs` must not depend back on `derived_state.rs` -- `ui_events.rs` and `layout_sync.rs` should coordinate through root-owned methods on `WorkspaceView`, not through sibling-to-sibling private imports - -Verification after Task A2: - -```bash -cargo clean -cargo fmt --all -- --check -cargo check --workspace --all-targets --all-features -cargo build --workspace --all-features -cargo test --all --all-targets --all-features -cargo test -p codirigent-ui --lib --features gpui-full -cargo clippy --all --all-targets --all-features -- -D warnings -cargo check -p codirigent-ui --features gpui-full -git diff --check -``` - -Additional verification requirement: - -- inspect the task diff and confirm no new production `unwrap()` or `expect()` calls were introduced - -##### Task A3: Externalize root test modules - -Create dedicated test files: - -- `crates/codirigent-ui/src/workspace/impl_output_polling/tests.rs` -- `crates/codirigent-ui/src/workspace/gpui/tests.rs` - -Update the bottom of the root files so they use `#[cfg(test)] mod tests;` instead of large inline test blocks. - -Testing structure after Task A3: - -- `workspace/tests.rs` remains unchanged because it covers the broader workspace module -- `gpui/tests.rs` initially receives the current root tests from `gpui.rs` with no assertion changes -- `impl_output_polling/tests.rs` initially receives the current root tests from `impl_output_polling.rs` with no assertion changes - -Planned later ownership moves after Phase A: - -- session metadata tests move from `gpui/tests.rs` into `gpui/session_metadata.rs` once the helpers move -- derived-state reducer tests move from `gpui/tests.rs` into `gpui/derived_state.rs` -- hook-signal tests move from `impl_output_polling/tests.rs` into `impl_output_polling/hook_signals.rs` -- git refresh tests move from `impl_output_polling/tests.rs` into `impl_output_polling/git_refresh.rs` -- output scheduling and prepared-output tests move from `impl_output_polling/tests.rs` into `impl_output_polling/output_runtime.rs` -- status reconciliation side-effect tests move from `impl_output_polling/tests.rs` into `impl_output_polling/status_reconcile.rs` - -Rules for test motion: - -- Phase A must keep test names and assertions stable -- tests should move with the code they validate once a later phase extracts that code -- do not centralize new tests back into the root if the extracted child module can own them cleanly - -Verification after Task A3: - -```bash -cargo clean -cargo fmt --all -- --check -cargo check --workspace --all-targets --all-features -cargo build --workspace --all-features -cargo test --all --all-targets --all-features -cargo test -p codirigent-ui --lib --features gpui-full -cargo clippy --all --all-targets --all-features -- -D warnings -cargo check -p codirigent-ui --features gpui-full -git diff --check -``` - -Additional verification requirement: - -- inspect the task diff and confirm no new production `unwrap()` or `expect()` calls were introduced - -##### Task A4: Ownership comments and import hygiene - -This is the final scaffolding pass before logic moves begin. - -Update the roots and newly created child files so they clearly document ownership and future responsibility, while keeping code motion at zero: - -- note which responsibilities stay in the root permanently -- note which responsibilities are expected to migrate in Phase B or Phase C -- remove any unused imports introduced by the new `mod` declarations - -This task is complete when a reviewer can open either root file and understand: - -- why the child modules exist -- which clusters are scheduled to move next -- that no behavior moved yet - -Verification after Task A4: - -```bash -cargo clean -cargo fmt --all -- --check -cargo check --workspace --all-targets --all-features -cargo build --workspace --all-features -cargo test --all --all-targets --all-features -cargo test -p codirigent-ui --lib --features gpui-full -cargo clippy --all --all-targets --all-features -- -D warnings -cargo check -p codirigent-ui --features gpui-full -git diff --check -``` - -Additional verification requirement: - -- inspect the task diff and confirm no new production `unwrap()` or `expect()` calls were introduced - -#### Phase A Cross-Platform Requirements - -Phase A is structural, but it still needs to preserve cross-platform correctness. - -Rules: - -- every new child module must remain under the same feature gate as its root -- do not add `target_os` conditionals unless the moved code already requires them -- do not introduce Unix-only filesystem literals in tests or production code -- use platform-neutral temp paths such as `std::env::temp_dir()` in any touched test code -- avoid assumptions about path separators, shell names, clipboard backends, or terminal behavior that differ between macOS and Windows -- keep macOS-specific and Windows-specific dependencies where they already live today instead of re-scattering them during the split - -Merge expectation: - -- the Phase A verification gate should pass on the active development host after every task -- before merge, the same gate should also be exercised on both macOS and Windows because this module tree includes platform-aware clipboard, terminal, and editor-detection paths - -### Phase B: Split `impl_output_polling.rs` - -Recommended order: - -1. `git_refresh.rs` -2. `terminal_input.rs` -3. `hook_signals.rs` -4. `cli_pollers.rs` -5. `status_reconcile.rs` -6. `output_runtime.rs` - -Reason: - -- start with the least risky chunks -- leave the highest-coupling output/runtime code for last after the module pattern is proven - -Phase exit criteria: - -- root `impl_output_polling.rs` is substantially smaller and mostly orchestration-only -- no behavior changes in output/status flow - -### Phase C: Split `gpui.rs` - -Recommended order: - -1. `session_metadata.rs` -2. `derived_state.rs` -3. `ui_events.rs` -4. `layout_sync.rs` - -Reason: - -- begin with pure helpers -- move reducer logic before moving event orchestration -- leave render-adjacent layout coordination until the end - -Phase exit criteria: - -- root `gpui.rs` remains readable as the high-level workspace view entry point -- trait impls are still easy to locate - -### Phase D: Cleanup And Naming Pass - -Goals: - -- normalize module names -- remove dead helpers/imports -- consolidate any duplicated private helper logic created during the move -- confirm file sizes and dependency directions are improved - -Phase exit criteria: - -- no oversized root modules remain in the targeted area -- module names match actual responsibilities - -## Test Plan - -This refactor must prove behavior did not change. - -### Automated checks for every phase - -- existing unit tests stay green -- existing integration tests stay green -- no new warnings -- no new production `unwrap()` or `expect()` paths - -### Focused regression tests - -Before and after the split, ensure coverage still exercises: - -- output dispatch prioritization -- output preparation when no terminal is attached -- hook-signal ingestion -- JSONL status ingestion -- detector maintenance apply path -- task-board reducer behavior -- layout/focus-derived header updates - -If code motion breaks test clarity, move tests with the code they validate rather than centralizing more into giant files. - -## Manual Validation - -Even though this is a no-behavior-change refactor, do the following after the final phase: - -- open the app in focus mode and verify the current offload behavior still works -- create, restore, rename, group, and close sessions -- exercise task creation, assignment, review, and completion -- verify hook-capable sessions still update status -- verify generic shell sessions still decay back to idle - -## Required Verification Gate - -Run the same gate used for the offload phases: - -```bash -cargo clean -cargo fmt --all -- --check -cargo check --workspace --all-targets --all-features -cargo build --workspace --all-features -cargo test --all --all-targets --all-features -cargo clippy --all --all-targets --all-features -- -D warnings -cargo check -p codirigent-ui --features gpui-full -``` - -Also run: - -```bash -git diff --check -``` - -## Review Strategy - -Use small commits with clear boundaries. - -Recommended commit shape: - -1. scaffolding only -2. `impl_output_polling` submodule moves -3. `gpui` submodule moves -4. cleanup and naming pass -5. doc updates if needed - -Each commit should remain reviewable without mentally reconstructing the entire workspace layer. - -## Risks - -1. Import churn can hide behavior changes. -2. Private helper moves can accidentally widen visibility. -3. Test motion can make diffs look larger than the logic change. -4. Over-splitting can create a module maze. - -Mitigations: - -- keep root modules as orchestration entry points -- prefer a few responsibility-based modules over many tiny files -- move code with minimal rewriting -- review diffs with behavior preservation as the first question - -## Success Criteria - -This plan is successful when: - -1. `impl_output_polling.rs` and `gpui.rs` are no longer oversized monoliths. -2. Reviewers can find output-flow logic, status logic, reducer logic, and UI event logic quickly. -3. The verification gate is green with `gpui-full`. -4. No behavior regressions are found in the manual validation pass. - -## Follow-On Work - -If this split succeeds cleanly, the same pattern can be applied later to: - -- `impl_session_lifecycle.rs` -- `settings_panels.rs` -- `task_board_render.rs` -- `drawer_render.rs` - -That follow-on work should be planned separately after the primary split lands. diff --git a/docs/architecture/workspace/README.md b/docs/architecture/workspace/README.md new file mode 100644 index 0000000..7014ff2 --- /dev/null +++ b/docs/architecture/workspace/README.md @@ -0,0 +1,85 @@ +# Workspace Architecture + +This directory documents the `codirigent-ui::workspace` module after the +module split. The goal is to let a future developer or coding agent answer +"where does this behavior live?" without opening every file in +`crates/codirigent-ui/src/workspace/`. + +## Read This First + +- [Module Map](module-map.md) + - Top-level ownership boundaries. + - Which files are roots, helpers, renderers, or state containers. + +- [GPUI And Rendering](gpui.md) + - `WorkspaceView`, render orchestration, UI event translation, layout sync. + +- [Output Polling And Status](output-polling.md) + - PTY output flow, status reconciliation, JSONL polling, hook signals. + +## Workspace In One Screen + +`workspace/mod.rs` exposes two conceptual roots: + +- `core.rs` + - Canonical workspace state and layout logic. + - No GPUI-specific rendering concerns. + +- `gpui.rs` + - `WorkspaceView` and the GPUI-facing shell around `Workspace`. + - Renders the UI, owns UI-scoped state, and coordinates helper modules. + +The second major internal root is: + +- `impl_output_polling.rs` + - Output polling, status refresh, detector maintenance, background checks, + and compaction/task follow-up. + +Everything else in `workspace/` either: + +- extends `WorkspaceView` with a focused behavior cluster +- renders a specific UI region +- stores grouped sub-state used by the roots above + +## Quick Lookup + +If you need to change: + +- layout switching, focus movement, terminal resize: + - [gpui.md](gpui.md) + - `gpui/layout_sync.rs` + +- task board counts, header badges, empty cell sync: + - [gpui.md](gpui.md) + - `gpui/derived_state.rs` + +- top bar, icon rail, empty-cell event translation: + - [gpui.md](gpui.md) + - `gpui/ui_events.rs` + +- PTY output draining, terminal runtime application, output scheduling: + - [output-polling.md](output-polling.md) + - `impl_output_polling/output_runtime.rs` + +- session status decisions, stale cache clearing, auto-assign/compaction follow-up: + - [output-polling.md](output-polling.md) + - `impl_output_polling/status_reconcile.rs` + +- JSONL-based Codex/Gemini status ingestion: + - [output-polling.md](output-polling.md) + - `impl_output_polling/cli_pollers.rs` + +- hook-signal ingestion: + - [output-polling.md](output-polling.md) + - `impl_output_polling/hook_signals.rs` + +## Related Docs + +- [../overview.md](../overview.md) + - Crate-level architecture and high-level system view. + +- [../../hook-and-status-system.md](../../hook-and-status-system.md) + - Lower-level hook file format and end-to-end status semantics. + +- [../../session-resume.md](../../session-resume.md) + - Session resume behavior and restore-oriented details. diff --git a/docs/architecture/workspace/gpui.md b/docs/architecture/workspace/gpui.md new file mode 100644 index 0000000..a6719dc --- /dev/null +++ b/docs/architecture/workspace/gpui.md @@ -0,0 +1,198 @@ +# GPUI And Rendering + +This document explains the `WorkspaceView` side of the workspace module. + +## What `gpui.rs` Owns + +`crates/codirigent-ui/src/workspace/gpui.rs` is still the UI root even after +the split. It owns: + +- the `WorkspaceView` type +- constructor wiring in `WorkspaceView::new` +- grouped UI state fields +- GPUI trait impls (`Render`, focus, IME/input handling) +- high-level render orchestration +- keyboard and IME behavior that still benefits from staying close to the root + +The split was about moving lower-coupling helper clusters out of the root, not +about hiding the root. + +## `WorkspaceView` State Layout + +The struct is easiest to understand in four groups: + +### Canonical services and shared backends + +- `workspace` +- `event_bus` +- `session_manager` +- `detector` +- `task_manager` + +These tie the UI to the core/session/detector layers. + +### Rendered child components + +- `top_bar` +- `icon_rail` +- `drawer` +- `task_board` +- `empty_cells` +- `terminal_headers` +- `terminals` + +These are the long-lived UI components or terminal render surfaces. + +### Grouped UI sub-state + +- `project` +- `clipboard` +- `settings` +- `persistence` +- `modals` +- `selection` +- `polling` +- `cache` + +These are intentionally split into dedicated state structs so the root does not +become a flat bag of dozens of unrelated fields. + +### Output/status plumbing shared with polling + +- `output_dispatcher` +- `update_rx` +- `update_tx` +- `cli_readers` +- `notification_manager` + +These are UI-owned because the polling system ultimately mutates visible state. + +## Child Modules Under `workspace/gpui/` + +### `session_metadata.rs` + +Small pure helpers: + +- `session_project_name()` +- `resolved_task_title()` + +This is the leaf module used by reducers when they need human-readable session +or task labels. + +### `derived_state.rs` + +Converts canonical session/task state into cached UI state: + +- task board counts and snapshots +- terminal header state +- empty-grid-cell state + +Key rule: + +- derived state is refreshed from explicit mutation paths +- it should not be rebuilt as a silent render fallback + +That rule keeps render cheap and prevents "render fixed my stale state" bugs. + +### `ui_events.rs` + +Drains component event queues and translates them into workspace mutations: + +- task board events +- empty-cell create-session clicks +- top bar layout requests +- icon rail drawer/settings requests + +This file should stay focused on translation, not business logic. + +### `layout_sync.rs` + +Owns the follow-up work after layout or selection changes: + +- mark layout caches dirty +- focus-sensitive layout signatures +- selection helpers +- terminal cell metrics +- PTY resize throttling + +This is the main place where UI layout changes meet terminal runtime behavior. + +## Render Path + +The high-level render flow is: + +1. `Render::render()` in `gpui.rs` +2. drain pending UI component events +3. update cached layout signatures and render cell info +4. synchronize terminal dimensions and schedule PTY resizes +5. delegate UI composition into render-focused modules such as: + - `render.rs` + - `grid_render.rs` + - `drawer_render.rs` + - `task_board_render.rs` + +Important design choice: + +- the root keeps the render entry point so a reader can still find the UI + lifecycle without jumping through many files first + +## Mutation Path Rules + +When a workspace mutation changes visible state, the usual follow-up pattern is: + +1. mutate canonical workspace state +2. call `mark_layout_cache_dirty()` if structure/bounds changed +3. call `sync_layout_derived_state()` or `sync_task_derived_state()` +4. run any file-tree or session-selection follow-up +5. `cx.notify()` if the UI should repaint + +Examples that follow this pattern: + +- next layout +- toggle sidebar +- focus/select session +- top bar layout selection + +## Where To Edit + +If you need to change: + +- terminal header fields: + - `gpui/derived_state.rs` + +- task board counters or task snapshot contents: + - `gpui/derived_state.rs` + +- top bar event behavior: + - `gpui/ui_events.rs` + - `top_bar_render.rs` + +- icon rail event behavior: + - `gpui/ui_events.rs` + - `icon_rail_render.rs` + +- empty-cell click behavior: + - `gpui/ui_events.rs` + +- session selection side effects: + - `gpui/layout_sync.rs` + +- resize throttling or collapsed-resize guards: + - `gpui/layout_sync.rs` + +- key handling or IME behavior: + - `gpui.rs` + - `impl_keyboard.rs` + +## Cross-Platform Notes + +The most platform-sensitive GPUI behavior in this area is terminal resize and +text input: + +- collapsed intermediate layouts must not force PTYs to `1x1` +- IME and key handling must preserve correct behavior on macOS and Windows +- terminal font metrics are cached because font-system behavior differs by + platform and is too expensive to recompute on every frame + +When changing layout or input behavior, run the full gate and then manually +check both macOS and Windows UI behavior. diff --git a/docs/architecture/workspace/module-map.md b/docs/architecture/workspace/module-map.md new file mode 100644 index 0000000..f57756b --- /dev/null +++ b/docs/architecture/workspace/module-map.md @@ -0,0 +1,262 @@ +# Workspace Module Map + +This file maps the workspace source tree to responsibilities. Use it when you +need to find the right module quickly. + +## Entry Points + +### `workspace/mod.rs` + +Owns module declarations and the public surface: + +- `pub use core::{CellInfo, Workspace}` +- `pub use gpui::WorkspaceView` behind `gpui-full` + +This file is intentionally boring. If behavior changes require touching +`workspace/mod.rs`, the change is probably architectural rather than local. + +### `core.rs` + +Canonical workspace model: + +- session placement and removal +- grid/split-tree layout state +- focus movement +- cell bounds and visible session calculation + +`core.rs` should stay free of GPUI rendering details. + +### `gpui.rs` + +Primary UI root: + +- defines `WorkspaceView` +- owns constructor wiring and grouped UI state +- keeps GPUI trait impls easy to find +- coordinates render-time orchestration + +Helper clusters that extend the root now live under `workspace/gpui/`. + +### `impl_output_polling.rs` + +Primary runtime/polling root: + +- shared polling constants and helper types +- adaptive maintenance cadence +- detector maintenance orchestration +- clipboard preview updates +- kill-switch / shadow-mode toggles for the output pipeline transition + +Helper clusters that extend the root now live under +`workspace/impl_output_polling/`. + +## `workspace/gpui/` Submodules + +### `session_metadata.rs` + +Leaf helpers used by higher-level reducers: + +- derive project names from session metadata +- resolve task titles from cached or fallback inputs + +This module should remain dependency-light. + +### `derived_state.rs` + +Mutation-driven UI reducers: + +- task board snapshot/count refresh +- terminal header synchronization +- empty-cell synchronization +- explicit derived-state refresh entry points + +This module converts canonical session/task state into UI-facing cached state. + +### `ui_events.rs` + +Component event translation: + +- task board and empty-cell event draining +- top bar events -> workspace mutations +- icon rail events -> drawer/settings actions + +This is where component-local UI events become `WorkspaceView` actions. + +### `layout_sync.rs` + +Layout and focus follow-up: + +- layout cache invalidation +- focus-sensitive layout refresh +- session selection helpers +- terminal cell metrics and PTY resize coordination + +This module is the main bridge between UI layout changes and terminal runtime +effects. + +## `workspace/impl_output_polling/` Submodules + +### `output_runtime.rs` + +Hot path for terminal output: + +- event-driven output scheduling +- focused-session prioritization +- background output preparation +- prepared output application back on the UI thread + +If output appears late or the wrong session gets priority, start here. + +### `status_reconcile.rs` + +Status application and side effects: + +- reconciles detector and cached CLI hints via `status_engine` +- clears stale cached status +- applies task-state side effects +- triggers compaction completion and auto-assign follow-up + +If status changes are correct but the UI/task side effects are wrong, start +here. + +### `cli_pollers.rs` + +Background polling for log-backed CLIs: + +- Codex/Gemini JSONL reads +- rollout / execution-mode inference +- CLI-type detection fallback +- cache updates and notifications from JSONL snapshots + +### `hook_signals.rs` + +Background polling for hook signal files: + +- signal-file scanning +- stale signal guards +- session-id resolution +- hook-derived status and CLI metadata updates + +### `git_refresh.rs` + +Background git refresh coordination: + +- bulk git refresh scheduling +- apply refreshed git info to headers and session snapshots + +### `terminal_input.rs` + +Terminal follow-up helpers: + +- deferred Enter handling +- VTE DSR/DA response forwarding +- compaction timeout cleanup + +## Other Important Workspace Modules + +### Operational `impl_*` files + +These extend `WorkspaceView` outside the two split roots: + +- `impl_session_lifecycle.rs` + - create, restore, close, bootstrap, resume + +- `impl_keyboard.rs` + - keyboard shortcuts and keybinding-driven actions + +- `impl_task_board.rs` + - task board mutations and modal/task operations + +- `impl_modals.rs` + - modal state transitions + +- `impl_file_tree.rs` + - file tree / focused-session path sync + +- `impl_clipboard.rs` + - clipboard actions and session clipboard integration + +- `impl_settings.rs` + - settings page behavior + +- `impl_action_handlers.rs` + - GPUI action callbacks that delegate into higher-level helpers + +- `impl_ui_operations.rs` + - broader UI helpers that do not fit cleanly into render or polling roots + +### Rendering files + +These mostly build UI elements rather than owning long-lived behavior: + +- `render.rs` + - top-level composition for the workspace body + +- `grid_render.rs` + - grid and split-tree cells + +- `drawer_render.rs` + - drawer panels and left-side content + +- `task_board_render.rs` + - task board UI and task cards + +- `top_bar_render.rs` + - top bar layout/profile UI + +- `icon_rail_render.rs` + - icon rail chrome and click surfaces + +- `modal_render.rs` + - modal composition + +- `terminal_render.rs` + - terminal-specific render helpers + +### State containers + +These group related state to keep `WorkspaceView` readable: + +- `clipboard_state.rs` +- `project_state.rs` +- `settings_state.rs` +- `persistence_state.rs` +- `types.rs` + +## Dependency Rules + +The current structure tries to preserve these rules: + +- `core.rs` remains the canonical layout/session model. +- `gpui.rs` stays readable as the main UI root. +- `impl_output_polling.rs` stays readable as the main polling root. +- child helper modules extend a root through `impl WorkspaceView` rather than + introducing new public entry points. +- sibling helper modules coordinate through root-owned methods on + `WorkspaceView`, not by importing one another's private helpers. + +## Where To Start Reading + +For common tasks: + +- "How is the whole workspace assembled?" + - `workspace/mod.rs` + - `gpui.rs` + - `render.rs` + +- "Why did a layout or selection change affect terminal sizing?" + - `gpui/layout_sync.rs` + - `grid_render.rs` + +- "Why is a session badge wrong?" + - `impl_output_polling/status_reconcile.rs` + - `status_engine.rs` + - `gpui/derived_state.rs` + +- "Why is output delayed or missing?" + - `impl_output_polling/output_runtime.rs` + - `output_dispatcher.rs` + +- "Why didn't a task board/header update happen?" + - `gpui/derived_state.rs` + - `impl_task_board.rs` diff --git a/docs/architecture/workspace/output-polling.md b/docs/architecture/workspace/output-polling.md new file mode 100644 index 0000000..0847a55 --- /dev/null +++ b/docs/architecture/workspace/output-polling.md @@ -0,0 +1,227 @@ +# Output Polling And Status + +This document explains the runtime side of the workspace module: PTY output, +status updates, background polling, and related side effects. + +## What `impl_output_polling.rs` Owns + +`crates/codirigent-ui/src/workspace/impl_output_polling.rs` is the polling +root. It keeps: + +- shared constants and helper types +- adaptive polling cadence +- detector maintenance orchestration +- clipboard preview maintenance +- stale proposal cleanup +- output-pipeline feature toggles (`LEGACY_PIPELINE`, `SHADOW_STATUS`) + +It delegates narrower responsibility clusters into child modules under +`workspace/impl_output_polling/`. + +## Polling Model + +There are two cadences: + +### Fast output cadence + +Used when terminals are actively producing output. + +Main responsibilities: + +- drain PTY output +- apply terminal runtime snapshots +- keep focused sessions responsive + +Key entry point: + +- `poll_output()` in `output_runtime.rs` + +### Slower maintenance cadence + +Used for work that does not need to run every active frame. + +Main responsibilities: + +- hook signal scanning +- JSONL checks +- git refresh +- detector maintenance +- clipboard preview updates +- compaction timeout cleanup + +Key entry point: + +- `poll_maintenance()` in `impl_output_polling.rs` + +## Child Modules + +### `output_runtime.rs` + +Hot-path output scheduling and application. + +Owns: + +- event-driven session readiness via `output_dispatcher` +- focused-session prioritization +- legacy fallback drain of `sessions_with_pending_output()` +- background preparation of drained PTY output +- UI-thread application of prepared output + +Start here when: + +- output is delayed +- the wrong session is prioritized +- a session without a terminal runtime gets dropped instead of retried + +### `status_reconcile.rs` + +Applies session status and all major side effects. + +Inputs: + +- detector state +- cached hook/JSONL status +- prior workspace session status + +Owns: + +- call into `status_engine::reconcile` +- expire stale cached status +- update workspace session status +- task transition side effects +- context clear / compaction follow-up +- auto-assign follow-up on returning to idle + +Start here when: + +- a status badge is wrong even though raw detector/log inputs seem correct +- status transitions do not trigger the right task or compaction behavior + +### `cli_pollers.rs` + +Background JSONL and rollout polling for Codex and Gemini. + +Owns: + +- JSONL input collection +- process-tree CLI detection fallback +- Codex session-id and execution-mode inference +- ambiguity guards when multiple Codex sessions share a working directory +- cache updates and notifications from JSONL results + +Start here when: + +- Codex/Gemini status does not update +- execution mode inference is wrong +- multiple Codex sessions in one directory interfere with one another + +### `hook_signals.rs` + +Background hook-signal ingestion. + +Owns: + +- signal-file scanning +- process-start epoch guard +- stale-signal rejection +- CLI session-id backfill from hook metadata +- hook-derived status and CLI metadata updates + +Start here when: + +- Claude Code hook updates are missing +- a hook signal is applied to the wrong session +- hook-derived `cli_session_id` or execution mode is wrong + +### `git_refresh.rs` + +Background git refresh. + +Owns: + +- refresh scheduling +- applying refreshed git info to headers and cached sessions + +### `terminal_input.rs` + +Terminal follow-up helpers not directly tied to output draining. + +Owns: + +- deferred Enter handling +- VTE response forwarding +- compaction timeout cleanup + +This is the place to look when the shell is waiting for an expected terminal +response or when post-command follow-up input timing is wrong. + +## Status Data Sources + +Status is not driven by one source. The system combines multiple hints: + +- detector state from `InputDetector` +- hook-derived status for Claude Code +- JSONL-derived status for Codex/Gemini +- stale-cache handling rules + +The actual arbitration happens in: + +- `status_engine.rs` +- `status_providers.rs` +- `impl_output_polling/status_reconcile.rs` + +Practical rule: + +- if the wrong raw data is entering the system, fix `hook_signals.rs` or + `cli_pollers.rs` +- if the raw data is correct but the chosen status is wrong, fix + `status_engine.rs` / `status_reconcile.rs` + +## Output Flow + +The normal output path is: + +1. background session runtime marks output ready +2. `SessionUpdate` events are drained into `output_dispatcher` +3. ready sessions are prioritized, focused first +4. output is prepared in the background +5. terminal runtime snapshots are applied on the UI thread +6. status/header follow-up is synchronized + +The legacy broad-scan path still exists behind `CODIRIGENT_LEGACY_PIPELINE` as +a transition fallback. + +## Cross-Platform Notes + +The polling layer has real platform sensitivity: + +- Windows terminal behavior is more sensitive to missed PTY resizes and DSR + responses. +- Hook signal and JSONL path handling must tolerate Windows and macOS path + conventions. +- Process detection and child-pid behavior can vary between platforms. + +When changing output or status behavior, validate: + +- normal interactive output +- Claude hook-driven status +- Codex/Gemini JSONL status +- compaction follow-up +- terminal response forwarding on shells that expect DSR/DA replies + +## Related Files Outside The Split + +- `output_dispatcher.rs` + - ready/in-flight session scheduling state + +- `status_engine.rs` + - pure-ish reconciliation logic + +- `status_providers.rs` + - status hint source types and stale actions + +- `types.rs` + - cached CLI status structures and shared polling types + +- `project_state.rs` + - working-directory and project-root behavior used by polling follow-up