# State Lifecycle: Undo/Redo, Reset, and State Delegation [Top](../Readme.md) | [App Controller](guide_app_controller.md) | [Discussions](guide_discussions.md) | [GUI Main](guide_gui_2.md) --- ## Overview Manual Slop's state lifecycle has three load-bearing concerns: 1. **Undo/Redo** via `HistoryManager` + `UISnapshot` — the non-provider history system 2. **Reset** via `_handle_reset_session` — the "throw away everything" flow 3. **State delegation** via `App.__getattr__`/`__setattr__` — the App is a thin proxy over the Controller This guide covers the *implementation* of all three. The design philosophy is documented in `conductor/tracks/nagent_review_20260608/nagent_takeaways_20260608.md §1` (state visibility) and `§9` (edit-the-input, not the output). > **Domain classification.** All three concerns are **Application**-domain. The Hook API (which exposes state) is also Application, but crosses into Meta-Tooling via the bridge scripts. See `guide_meta_boundary.md`. --- ## 1. Undo/Redo: `HistoryManager` + `UISnapshot` ### Why a Non-Provider History? Manual Slop's history is **not** the same as the provider-side conversation history. The provider (`ai_client._anthropic_history`, `_deepseek_history`, etc.) tracks the *exact bytes* sent to and received from the LLM. The Manual Slop history (`app.history` + `UISnapshot`) tracks the *user's UI state* — text inputs, sliders, file lists, discussions. This separation is intentional. It means: - The user can `Ctrl+Z` to undo a typo in the AI input box, even if the previous LLM call's history is preserved on the provider side. - The provider's history is the *authoritative* transcript for the LLM; the Manual Slop history is the *user's working state*. - A reset of the Manual Slop state does not clear the provider's history (and vice versa) — see §"Reset" below. This is exactly the kind of "edit the input, not the output" pattern nagent uses; see nagent takeaways §9. ### `UISnapshot` — The Serializable State `src/history.py:8 UISnapshot` is a frozen-shaped dataclass capturing the 13 user-mutable fields: | Field | Type | Source | |---|---|---| | `ai_input` | `str` | `self.ui_ai_input` | | `project_system_prompt` | `str` | `self.ui_project_system_prompt` | | `global_system_prompt` | `str` | `self.ui_global_system_prompt` | | `base_system_prompt` | `str` | `self.ui_base_system_prompt` | | `use_default_base_prompt` | `bool` | `self.ui_use_default_base_prompt` | | `temperature` | `float` | `self.temperature` | | `top_p` | `float` | `self.top_p` | | `max_tokens` | `int` | `self.max_tokens` | | `auto_add_history` | `bool` | `self.ui_auto_add_history` | | `disc_entries` | `list[dict]` | `copy.deepcopy(self.disc_entries)` | | `files` | `list[dict]` | `[f.to_dict() if hasattr(f, 'to_dict') else f for f in self.files]` | | `context_files` | `list[dict]` | `[f.to_dict() if hasattr(f, 'to_dict') else f for f in self.context_files]` | | `screenshots` | `list[str]` | `list(self.screenshots)` | `to_dict()` / `from_dict()` are explicit serializers, used by the Hook API for the `/api/session` endpoint. The dataclass is **not** auto-serialized; explicit `to_dict` is required so the schema is documented. ### `HistoryManager` — The Undo/Redo Stack `src/history.py:71 HistoryManager` is a 100-snapshot capacity stack: ```python class HistoryManager: def __init__(self, max_capacity: int = 100): ... def push(self, state: typing.Any, description: str) -> None: ... def undo(self, current_state: typing.Any, current_description: str = "Current State") -> typing.Optional[HistoryEntry]: ... def redo(self, current_state: typing.Any, current_description: str = "Current State") -> typing.Optional[HistoryEntry]: ... def jump_to_undo(self, index: int, current_state: typing.Any, current_description: str = "Before Jump") -> typing.Optional[HistoryEntry]: ... @property def can_undo(self) -> bool: ... @property def can_redo(self) -> bool: ... def get_history(self) -> typing.List[typing.Dict[str, typing.Any]]: ... ``` - `push` appends a new entry; clears the redo stack; pops the oldest if capacity exceeded. - `undo` moves the current state to the redo stack and returns the top of the undo stack. - `redo` is the inverse. - `jump_to_undo` allows time-traveling to any past snapshot, moving subsequent states to the redo stack. - `get_history` returns `[{description, timestamp}, ...]` for the History List view in the GUI. The `max_capacity=100` is the default and is sufficient for a 5-second window of rapid typing or a longer session of infrequent edits. ### The Push Trigger — `gui_2.py:1140-1170` The undo stack is *not* pushed on every keystroke. It's pushed via debounced change-detection at the start of every render frame: ```python current = self._take_snapshot() if self._last_ui_snapshot is None: self._last_ui_snapshot = current return changed = ( current.ai_input != self._last_ui_snapshot.ai_input or current.project_system_prompt != self._last_ui_snapshot.project_system_prompt or # ... 10 more field comparisons ... len(current.disc_entries) != len(self._last_ui_snapshot.disc_entries) or len(current.files) != len(self._last_ui_snapshot.files) or len(current.context_files) != len(self._last_ui_snapshot.context_files) or len(current.screenshots) != len(self._last_ui_snapshot.screenshots) ) if not changed and len(current.disc_entries) > 0: if current.disc_entries[-1].get('content') != self._last_ui_snapshot.disc_entries[-1].get('content'): changed = True if changed: self.history.push(current, description="") self._last_ui_snapshot = current ``` The change detector compares: - 7 scalar fields directly (`!=`) - 2 float fields with epsilon (`abs(...) > 1e-5`) - 4 list fields by length - The `disc_entries[-1]["content"]` separately (because streaming AI responses can change the last entry's content without changing the length) **Performance:** The check is at the start of every render frame. `copy.deepcopy(self.disc_entries)` (line 748) is the most expensive part — O(N) where N is the entry count. For a 100-entry discussion, this is microseconds. The full snapshot push only happens when a change is detected. ### The Apply Trigger — `gui_2.py:819 _apply_undo` / `_apply_redo` / `_apply_jump` Three wrapper methods invoke `_apply_snapshot(entry.state)` with the right description: - `_apply_undo(...)` — pops from undo, pushes current to redo, applies popped - `_apply_redo(...)` — pops from redo, pushes current to undo, applies popped - `_apply_jump(index, ...)` — invokes `jump_to_undo(index, current_state)`, applies the result `_apply_snapshot` is the *single* restore function (`gui_2.py:754-789`): ```python def _apply_snapshot(self, snapshot: history.UISnapshot) -> None: self._is_applying_snapshot = True try: self.ui_ai_input = snapshot.ai_input self.ui_project_system_prompt = snapshot.project_system_prompt # ... 10 more assignments ... self.disc_entries = snapshot.disc_entries # Restore files as FileItem objects from src import models self.files = [] for f in snapshot.files: if isinstance(f, dict): self.files.append(models.FileItem.from_dict(f)) else: self.files.append(models.FileItem(path=str(f))) # ... similar for context_files, screenshots ... finally: self._is_applying_snapshot = False ``` The `_is_applying_snapshot` flag is set during the restore to prevent re-pushing a new snapshot from the changes made *by* the restore itself. (Without this, pressing Ctrl+Z would push a new snapshot identical to the restored one, which would *clear the redo stack* — making Ctrl+Y a no-op.) --- ## 2. State Delegation: `App.__getattr__` / `__setattr__` ### The Pattern `App` (`src/gui_2.py:264+`) is a thin wrapper around `AppController` (`src/app_controller.py:772+`). The wrapper exists because: - The Controller is the *headless* orchestrator (no ImGui dependencies). - The App is the *render* side (ImGui calls). - Render functions take `app: App` and read state via `app.`. The simplest design would be: copy every Controller field to the App. But that's brittle (every new Controller field requires an App copy). The actual pattern uses Python's `__getattr__`/`__setattr__` for transparent delegation. ### `App.__getattr__` — Read Fallthrough `gui_2.py:666-669`: ```python def __getattr__(self, name: str) -> Any: if name == 'controller': raise AttributeError(name) return getattr(self.controller, name) ``` When `app.foo` is accessed and `foo` is not an instance attribute of `App`, Python falls through to `__getattr__`, which reads from `self.controller`. This means `app.disc_entries`, `app.history`, `app.temperature` all read from the Controller transparently. The `'controller'` exception prevents infinite recursion if the Controller is not yet set (during early `__init__`). ### `App.__setattr__` — Write Fallthrough `gui_2.py:671-675`: ```python def __setattr__(self, name: str, value: Any) -> None: if name != 'controller' and hasattr(self, 'controller') and hasattr(self.controller, name): setattr(self.controller, name, value) else: object.__setattr__(self, name, value) ``` When `app.foo = bar` is assigned and the Controller has a `foo` attribute, the write goes *through* to the Controller. Otherwise it stores on the App instance. ### Why This Matters - **No data duplication.** There is one source of truth (the Controller). The App never has its own copy of `disc_entries`, `temperature`, etc. - **No boilerplate.** New Controller fields are automatically available via the App without code changes. - **Backward-compatible.** Existing App fields (e.g. `app._is_applying_snapshot`) work because `object.__setattr__` is the fallback when the Controller doesn't have the field. ### The 4 Edge Cases (per the `guide_gui_2.md` Known Issues) 1. **`app.controller` is itself an App attribute** — it must not be delegated, hence the explicit `if name == 'controller'` guard. 2. **App instance attributes shadow Controller attributes** — the `object.__setattr__` fallback stores on the App; the next `__getattr__` call returns the App's value (because Python's normal attribute lookup finds the instance attribute first). This is sometimes intentional (e.g. `app._is_applying_snapshot` is App-local). 3. **Fields in `app._app` (not on the Controller)** are stored on the App. The `_app` field is the Controller's back-reference to the App (set at `gui_2.py:264`). 4. **Underscore-prefixed App-specific fields** like `app._mma_approval_open`, `app._pending_ask_dialog` are stored on the App because the Controller doesn't have them. They appear in the Controller's `__getattr__` mirror via `hasattr` check. ### Known Fragility `guide_gui_2.md` notes: `ui_separate_context_preview`, `ui_separate_message_panel`, `ui_separate_response_panel`, `ui_separate_tool_calls_panel`, `ui_separate_external_tools`, `ui_discussion_split_h` are NOT in the Controller's `_settable_fields`, so `__setattr__` falls through to `object.__setattr__` and stores them on the App. This is intentional for window-separator flags (they're render-only and shouldn't pollute the Controller), but it does mean they don't survive a hot-reload of `App`. --- ## 3. Reset: `_handle_reset_session` `src/app_controller.py:3286-3356 _handle_reset_session` is the **nuclear** reset, called from the "Reset Session" button in the message panel. ### What It Clears | Group | What | Why | |---|---|---| | AI client | `ai_client.reset_session()` + `clear_comms_log()` | Clears provider-side history. | | Tool stats | `_tool_log.clear()`, `_tool_stats.clear()`, `_comms_log.clear()` | Clears the in-memory activity logs. | | Discussion | `self.disc_entries.clear()`; for each discussion in project: `discussions[d_name]["history"] = []` | Empties the *current* take and all takes across all discussions. | | Files | `self.files.clear()`, `self.context_files.clear()` | Drops the FileItem lists. | | Tracks | `self.tracks.clear()` | Drops the loaded tracks. | | **Project dict (full replacement)** | `self.project = project_manager.default_project(...)` | The project is *replaced* with a fresh default, not mutated. | | Project paths | `self.project_paths = []` | Clears the recent-projects list. | | Project switch state | `_project_switch_in_progress = False` etc. | Resets the in-flight switch state machine. | | AI status | `ai_status = "session reset"`, `ai_response = ""` | Status bar update. | | UI inputs | `ui_ai_input = ""`, `ui_manual_approve = False`, `ui_auto_add_history = False` | Empties the message box. | | MMA | `active_track = None`, `active_tier = None`, `mma_status = "idle"`, `proposed_tracks = []`, `active_tickets = []`, `engines.clear()`, `mma_streams.clear()`, `_worker_status.clear()` | Drops all MMA state. | | Provider/model | `_current_provider = "gemini"`, `_current_model = "gemini-2.5-flash-lite"`, `ai_client.set_provider(...)` | Resets to defaults. | | Locks + queues | `_pending_history_adds.clear()`, `_api_event_queue.clear()`, `_pending_gui_tasks.clear()` | Drains all queues under their locks. | | Prompts | `ui_use_default_base_prompt = True`, all 3 system prompts = `''` | Resets to default base prompt. | | Persona/tool settings | `ui_active_persona = ''`, `ui_active_tool_preset = None`, `ui_active_bias_profile = None` | Drops active persona. | | Generation params | `temperature = 0.0`, `top_p = 1.0`, `max_tokens = 8192` | Defaults. | ### What It Does NOT Touch | Field | Why preserved | |---|---| | `self.active_project_path` | `_do_project_switch` writes to this path; clearing it would cause OSError on next switch and an infinite re-switch loop. The 2026-06-08 regression test `test_context_sim_live` documents this. | | `self.history` (the `HistoryManager`) | The undo stack survives a reset. Ctrl+Z after a reset can restore the pre-reset state. This may be a bug or a feature. | | The on-disk `manual_slop.toml` | The saved project TOML is not deleted or rewritten. Switching projects after reset reloads from disk. | | `self.discussion_sent_markdown` / `discussion_sent_system_prompt` | These *are* cleared (set to `""`). They're not in the preserve list. | ### The `_is_applying_snapshot` Guard During Reset `_handle_reset_session` does *not* set `self._is_applying_snapshot`. This means the change-detection logic *will* push a new snapshot to the undo stack after the reset. The snapshot will contain the *post-reset* state. To get the pre-reset state, the user must Ctrl+Z *twice* (once to push the post-reset snapshot, once to restore the pre-reset snapshot). This is a known papercut. The fix is to set `self._is_applying_snapshot = True` during the reset and clear it at the end. See `tests/test_session_logger_reset.py:test_reset_session` for the current behavior. ### The 2026-06-08 Regression `app_controller.py:3307-3312` documents a regression that was caught by `test_context_sim_live`: > The test's `client.click("btn_reset")` resets the AI session but does not reset the project (see `_handle_reset_session` at line 3244 — it clears files, context_files, disc_entries, etc. but not self.project or self.active_project_path). The fix was to *not* clear `self.active_project_path`. The project dict *is* replaced (`self.project = project_manager.default_project(...)`), but the path is preserved. This is the right behavior: the user is *resetting the session*, not *abandoning the project*. --- ## 4. State Synchronization Across Threads The state lifecycle has 4 distinct threads of access: 1. **Render thread** (60 FPS) — reads `app.` to render ImGui widgets 2. **AI response callback thread** (background) — appends to `app.disc_entries` under `_disc_entries_lock` 3. **Hook API thread** (HTTP server on `127.0.0.1:8999`) — reads via `_gettable_fields` and writes via `_predefined_callbacks` 4. **MMA worker thread(s)** — write to `_api_event_queue`, `_pending_gui_tasks`, `_pending_history_adds` The synchronization primitives: - `_disc_entries_lock: threading.Lock` — protects `disc_entries` (read by render, written by AI callback and Truncate) - `_pending_history_adds_lock` — protects the queue of pending discussion entries - `_api_event_queue_lock` — protects the Hook API event stream - `_pending_gui_tasks_lock` — protects the queue of GUI tasks scheduled from background threads - `_project_switch_lock` — protects the project-switch state machine - `ai_client._send_lock: threading.Lock` — serializes all `ai_client.send()` calls (the global lock per `guide_ai_client.md`) - `ai_client.__history_lock` — per-provider history lock (one per provider) **Invariant:** the render thread *never* blocks. It reads lock-free. All locks are acquired by the writer (AI callback, Truncate button, Hook API), held for the minimum critical section, and released before the writer returns. See `docs/reports/MUTATION_MATRIX_PHASE5.md` for the full matrix of state mutations × lock × thread. --- ## 5. State Persistence Three layers of state persistence: 1. **In-memory only** (lost on crash): `ai_client.__history`, `app.disc_entries`, `app.history` (undo stack) 2. **Project TOML** (persisted on Save / switch discussion): `project.discussion.discussions[*].history`, `project.discussion.discussions[*].context_snapshot`, `project.discussion.discussions[*].sent_markdown`, `project.discussion.discussions[*].sent_system_prompt` 3. **Config TOML** (persisted on `save_config()`): `config.disc_roles`, `config.use_default_base_system_prompt`, `config.ui_global_system_prompt`, etc. The auto-save flow is: - `_flush_to_project()` → writes project TOML - `_flush_to_config()` → writes in-memory config to `AppController.config` - `save_config()` → calls `models.save_config(config)` to write to disk These three calls are *manual* (not automatic). The user must click Save, or trigger a state change that flushes (switch discussion, branch, etc.). The reset path explicitly does *not* save (per the "What reset does NOT touch" section above). **The comms log** is its own persistence layer. `ai_client._comms_log` and `app._comms_log` are in-memory, but every entry is also written to `logs/sessions//comms.log` (JSON-L) via the `_on_comms_entry` callback. The reset clears the in-memory log; the on-disk log survives. This is intentional — the comms log is an *audit trail*, not a working state. --- ## 6. Hot Reload Integration `src/gui_2.py` is hot-reloadable. The `HotReloader` (covered in `guide_hot_reload.md`) swaps module references at runtime. The state lifecycle interacts with hot-reload in 3 ways: 1. **`HistoryManager` survives hot-reload** because it lives on the Controller, not the App. The `gui_2.py` module's functions can be reloaded without losing the undo stack. 2. **`UISnapshot` schema is the contract** — if a hot-reload changes the fields captured by `UISnapshot`, the old snapshots in the undo stack may have different shapes. The `from_dict` method handles missing fields via `.get(..., default)`, so old snapshots degrade gracefully. 3. **`_app` back-reference** is set in `App.__init__` (line 264). After a hot-reload of `gui_2.py`, the Controller's `self._app` still points to the (now reloaded) App instance. Render functions that captured `app` in closures may still hold the old App — but the App is a thin wrapper, so the new App is functionally equivalent. See `guide_hot_reload.md §"What can/cannot be safely reloaded"` for the full list. --- ## 7. Hook API Surface The state lifecycle is exposed to the Hook API via two registries on the Controller (`src/app_controller.py:296-326` in `App.__init__`): - **`_predefined_callbacks: dict[str, Callable]`** — name → function. The Hook API exposes each as a `custom_callback` action. Includes: - `save_context_preset`, `load_context_preset`, `delete_context_preset` - `set_ui_file_paths`, `set_ui_screenshot_paths` - `set_context_files_for_test`, `set_screenshots_for_test` - `_toggle_command_palette` - `get_app_debug_info`, `save_context_preset_force` - `set_ui_attr(k, v)`, `set_context_files` - `simulate_save_preset` - **`_gettable_fields: dict[str, str]`** — public name → internal field name. The Hook API exposes each as a readable state field. Includes `show_command_palette`, `app_debug_info`, and 50+ other UI/state fields. The Hook API does *not* directly expose `disc_entries` mutation; it goes through `/api/session` POST which replaces the full list. `/api/session` is the most state-relevant endpoint: - `GET /api/session` → `{"session": {"entries": [...]}}` - `POST /api/session` with `{"session": {"entries": [...]}}` → `{"status": "updated"}` See `guide_tools.md §"Hook API"` and `guide_api_hooks.md` for the full surface. --- ## 8. Tests - `tests/test_history.py` — `test_undo_redo`, `test_jump_to_undo`, `test_max_capacity`, `test_redo_cleared_on_push`, `test_push_state`, `test_initial_state` - `tests/test_history_manager.py` — `TestHistoryManager` class with: `test_snapshot_roundtrip`, `test_push_and_undo`, `test_push_clears_redo_stack`, `test_undo_and_redo`, `test_undo_no_history_returns_none`, `test_redo_no_history_returns_none`, `test_get_history_returns_descriptions`, `test_jump_to_undo` - `tests/test_session_logger_reset.py` — `test_reset_session` - `tests/test_state_inventory.py` — validates the state inventory is up-to-date - `tests/test_state_delegation.py` — validates `App.__getattr__`/`__setattr__` behavior - `tests/test_live_gui_state_sync.py` — validates that Hook API state reads are consistent with the live GUI state - `tests/test_gui_fast_render.py` — `test_render_discussion_panel_fast` (the change-detection path) - `tests/conftest.py:app_instance`, `tests/conftest.py:mock_app` — the App fixtures used by these tests --- ## 9. Known Limitations 1. **Per-edit save is not debounced to disk.** See `guide_discussions.md §"Known Limitations"` for the related issue. The fix is to hook the change-detection in `gui_2.py:1140-1170` to also call `_flush_disc_entries_to_project` after a debounce. 2. **`_handle_reset_session` pushes a new snapshot to the undo stack.** The pre-reset state is two Ctrl+Z presses away, not one. Fix: set `self._is_applying_snapshot = True` during the reset. 3. **Provider-side history and Manual Slop state can diverge.** When the user edits an entry's `content` via the discussion UI, the provider's `ai_client.__history` still has the original. This is Pitfall #4 in `conductor/tracks/nagent_review_20260608/report.md` and Decision candidate #3. 4. **Undo stack capacity is 100.** For long sessions with infrequent edits, this is plenty. For a 5-second window of rapid typing, you can fill it. Capacity is set in `app.history = HistoryManager(max_capacity=100)` in `App.__init__` and is not configurable. 5. **Hook API state writes are not undoable.** A `POST /api/session` that replaces the discussion list is a *single change*; if the change-detection logic pushes a snapshot, it's pushed as one entry. The user can Ctrl+Z to revert it, but the API caller has no way to know the change is undoable. --- ## Cross-References - **Undo/redo core:** `src/history.py:8 UISnapshot`, `src/history.py:71 HistoryManager` - **App-side wiring:** `src/gui_2.py:735 _take_snapshot`, `src/gui_2.py:754 _apply_snapshot`, `src/gui_2.py:819 _apply_undo`, `src/gui_2.py:825 _apply_redo`, `src/gui_2.py:832 _apply_jump`, `src/gui_2.py:1140-1170 change_detection`, `src/gui_2.py:666 __getattr__`, `src/gui_2.py:671 __setattr__` - **Controller-side reset:** `src/app_controller.py:3286 _handle_reset_session`, `src/app_controller.py:3357 _handle_compress_discussion` - **Hook API registries:** `src/gui_2.py:296-326` (the `_predefined_callbacks` / `_gettable_fields` assignments in `App.__init__`) - **State inventory:** `docs/reports/STATE_INVENTORY_PHASE5.md`, `docs/reports/MUTATION_MATRIX_PHASE5.md` - **Discussion integration:** `guide_discussions.md` - **Actionable patterns:** `conductor/tracks/nagent_review_20260608/nagent_takeaways_20260608.md §1` (state visibility), §9 (edit-the-input) - **Future-track candidate for stateless LLMClient:** `conductor/tracks/nagent_review_20260608/decisions.md` candidate #3