conductor(plan): UI Polish track - 5 phases, design spec + impl plan
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
# UI Polish Track — Design Spec
|
||||
|
||||
**Date:** 2026-06-03
|
||||
**Status:** Approved for implementation
|
||||
**Author:** Tier 2 Tech Lead
|
||||
**Scope:** Five discrete UI quality-of-life fixes identified during user review of the GUI.
|
||||
**Related tracks:** `gui_2_cleanup_20260513`, `selectable_ui_text_20260308`, `context_comp_decouple_20260510`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
User review surfaced five independent UI defects / quality issues that have been outstanding for some time and that previous agent attempts have not been able to fully resolve. This spec decomposes them into five phases of a single track. Each phase is independently shippable and produces visible improvement.
|
||||
|
||||
| # | Severity | Issue | Prior attempts |
|
||||
|---|----------|-------|----------------|
|
||||
| 1 | High | Markdown tables in `imgui_md` are rendered as run-on text — column boundaries lost, headers indistinguishable from body rows. | Multiple agents reported attempted fixes; the underlying limitation is that `imgui-bundle`'s `imgui_md` does not implement GFM tables. |
|
||||
| 2 | High | The `Keep Pairs` numeric input next to `Truncate` in the discussion panel is clipped to 80 px — single-digit values are visibly truncated. | One-line width fix. |
|
||||
| 3 | High | The `Refresh Registry` button in Log Management instantiates a new `LogRegistry` but does not call `.load_registry()`, so the displayed table never reflects on-disk state. | One-line fix. |
|
||||
| 4 | Medium | Operations Hub has no per-vendor session state view (quota, context-window usage, last-error class). Existing "Usage Analytics" tab shows historical aggregates only. | New panel. |
|
||||
| 5 | Medium | Files & Media > Files shows a flat sorted table; the user wants directory-grouped collapsible tree nodes matching the Context Composition visual style. | Refactor mirroring `render_context_files_table` pattern. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture Overview
|
||||
|
||||
All five phases target the rendering layer only. No new infrastructure, no threading changes, no provider API changes. The track stays inside the ImGui immediate-mode model and the existing `markdown_helper.py` / `aggregate.py` / `log_registry.py` modules.
|
||||
|
||||
### 2.1 Phase Boundaries
|
||||
|
||||
```
|
||||
Phase 1 ─ Markdown table pre-processor
|
||||
Phase 2 ─ Discussion Truncate / Keep Pairs layout fix
|
||||
Phase 3 ─ Log Management refresh bug fix
|
||||
Phase 4 ─ Operations Hub > Vendor State panel
|
||||
Phase 5 ─ Files & Media > Files directory tree
|
||||
```
|
||||
|
||||
Each phase is one track-level task with its own Red/Green/Commit cycle. Phase 1 is the only one with non-trivial complexity (it introduces a new sub-module). Phases 2, 3, 5 are surgical. Phase 4 adds a new sub-component.
|
||||
|
||||
### 2.2 Shared Conventions
|
||||
|
||||
- All Python code uses 1-space indentation (per `conductor/code_styleguides/python.md`).
|
||||
- No new comments in source files (per `AGENTS.md`).
|
||||
- All new render functions live at module level in `src/gui_2.py` and follow the `(app: App) -> None` signature, with a thin `_render_vendor_state(self)` wrapper on `App` if they need to be hot-reload-able.
|
||||
- SDM dependency tags required on all new public functions.
|
||||
- Tests live in `tests/test_<module>.py` and follow the existing `live_gui` fixture pattern when they need real GUI state.
|
||||
- Branch coverage target: ≥ 80 % for new code; full regression for the touched panel.
|
||||
|
||||
---
|
||||
|
||||
## 3. Per-Phase Design
|
||||
|
||||
### Phase 1 — Markdown Table Pre-Processor
|
||||
|
||||
**Problem:** `imgui-bundle`'s `imgui_md` (vendored as `src/imgui_md`) does not implement GFM table syntax. Lines like:
|
||||
|
||||
```
|
||||
| Name | Type |
|
||||
|-------|------|
|
||||
| foo | int |
|
||||
```
|
||||
|
||||
render as a single run of `|` characters with no column alignment, no header/body distinction, and no border. The user has reported this as a recurring frustration for months.
|
||||
|
||||
**Why previous fixes failed:** Attempts to monkey-patch `imgui_md` or to convert tables to ASCII pre-formatted blocks lose interactivity (links inside cells, syntax highlighting of code cells) and look bad in high-density themes like NERV.
|
||||
|
||||
**Approach:** Insert a GFM-table interceptor into `MarkdownRenderer.render()` that runs *before* `imgui_md.render()`. The interceptor:
|
||||
|
||||
1. Detects table blocks via the GFM signature: a line of `|`-delimited cells followed by a separator line containing only `|`, `-`, `:`, and spaces.
|
||||
2. Computes the natural width of each column by measuring rendered text (using `imgui.calc_text_size`) — this is what makes it "data-oriented" rather than string-length-based.
|
||||
3. Renders the table via `imgui.begin_table()` with one column per logical column, headers via `imgui.table_headers_row()`, and body rows via `imgui.table_next_row()` / `imgui.table_set_column_index()`.
|
||||
4. Recursively delegates any markdown *inside* cell content (links, emphasis, inline code) to `imgui_md.render()` per cell.
|
||||
5. Falls back to the existing `imgui_md.render()` pass if a block is detected as table-shaped but fails sanity checks (e.g. zero columns, separator with no body).
|
||||
|
||||
**Module boundary:** New module `src/markdown_table.py` exports a single function `render_markdown_tables(text: str) -> str` that returns the text with tables replaced by an inert placeholder (`\x00TABLE_<idx>\x00`), plus a list of table specs. The actual rendering stays inside `MarkdownRenderer` because it needs an ImGui context.
|
||||
|
||||
**Files touched:**
|
||||
- New: `src/markdown_table.py` — pure parser, no ImGui imports.
|
||||
- Modify: `src/markdown_helper.py` — `MarkdownRenderer.render` intercepts and dispatches.
|
||||
- New tests: `tests/test_markdown_table.py` (parser unit tests), `tests/test_markdown_table_render.py` (live_gui render tests).
|
||||
|
||||
**Safety:** The interceptor only activates when a block matches the GFM table shape; everything else passes through unchanged. The placeholder scheme is robust to nested `imgui_md` quirks because we substitute placeholder *after* `imgui_md` has finished its pass.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- A representative 4-column, 3-row table renders with aligned borders, bold header, and proper column widths.
|
||||
- Tables inside code fences (```` ``` ````) are NOT touched.
|
||||
- Inline code / links inside cells still render correctly.
|
||||
- Performance: rendering a 5-table, 50-row document is < 16 ms (one frame budget).
|
||||
- Existing markdown tests still pass.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Truncate / Keep Pairs Input Width
|
||||
|
||||
**Problem:** `src/gui_2.py:3829`:
|
||||
|
||||
```python
|
||||
imgui.text("Keep Pairs:"); imgui.same_line(); imgui.set_next_item_width(80)
|
||||
ch, app.ui_disc_truncate_pairs = imgui.input_int("##trunc_pairs", app.ui_disc_truncate_pairs, 1)
|
||||
```
|
||||
|
||||
The width of 80 px is too narrow for a 2–3 digit number. Image evidence: the digit `2` is partially cut off on the right border.
|
||||
|
||||
**Fix:** Increase `set_next_item_width` from 80 to 140 (matches the width of the adjacent `Truncate` button + spacing). Additionally, switch from `imgui.input_int` to `imgui.drag_int` for the same field — this gives the user the `+/-` stepper buttons inline without needing the `same_line` button pair, and prevents the digit-clipping behavior of `input_int` when the value approaches the field width.
|
||||
|
||||
**Files touched:**
|
||||
- Modify: `src/gui_2.py` — single line at 3829 plus the surrounding `same_line()` chain.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- 3-digit values (`999`) render fully inside the input.
|
||||
- The `Truncate` button remains clickable and aligned on the same row.
|
||||
- `ui_disc_truncate_pairs` still floors at 1.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Log Management `Refresh Registry` Bug
|
||||
|
||||
**Problem:** `src/gui_2.py:1675`:
|
||||
|
||||
```python
|
||||
if imgui.button("Refresh Registry"):
|
||||
app._log_registry = log_registry.LogRegistry(str(paths.get_logs_dir() / "log_registry.toml"))
|
||||
```
|
||||
|
||||
The new `LogRegistry` instance is constructed (which opens the file) but `.load_registry()` is never called, so `app._log_registry.data` stays empty. The user sees the same stale table.
|
||||
|
||||
**Fix:** Two acceptable shapes:
|
||||
|
||||
**A — In-place reload (preferred):**
|
||||
```python
|
||||
if imgui.button("Refresh Registry"):
|
||||
if app._log_registry is not None:
|
||||
app._log_registry.load_registry()
|
||||
```
|
||||
|
||||
**B — Re-instantiate + load (defensive):**
|
||||
```python
|
||||
if imgui.button("Refresh Registry"):
|
||||
app._log_registry = log_registry.LogRegistry(str(paths.get_logs_dir() / "log_registry.toml"))
|
||||
app._log_registry.load_registry()
|
||||
```
|
||||
|
||||
We pick **A** because it preserves any in-memory state (e.g. a pending `update_session_metadata` call) and is what the user likely meant. We add `load_registry` to `LogRegistry`'s public API (it already exists privately at lines 75-103 — we just stop reading the wrong attribute).
|
||||
|
||||
**Files touched:**
|
||||
- Modify: `src/gui_2.py` — single line at 1675.
|
||||
- New test: `tests/test_log_management_refresh.py` — drives a temp registry, calls the button via live_gui or via direct function call, asserts table count increases.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Pressing the button updates the visible table when the on-disk TOML has changed.
|
||||
- No regression to existing log_management tests.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — Operations Hub > Vendor State Panel
|
||||
|
||||
**Problem:** The current Operations Hub has tabs for Comms / Tool Calls / Usage Analytics / External Tools / Workspace Layouts. There is no at-a-glance view of "what is the current vendor's session state?" — things like:
|
||||
- Current provider + model.
|
||||
- Context-window utilization (used / limit, percentage bar).
|
||||
- Cache hit rate for the active session.
|
||||
- Last error class (if any).
|
||||
- Quota state (when the vendor exposes it; Anthropic and Gemini CLI expose different signals).
|
||||
|
||||
**Approach:** Add a new tab `Vendor State` between `Usage Analytics` and `External Tools`. The tab shows a single high-density `imgui.begin_table()` with one row per tracked metric. The metrics are pulled from a new module-level helper `get_vendor_state(app: App) -> list[VendorMetric]` that aggregates from:
|
||||
- `app.current_provider`, `app.current_model` (from `models.PROVIDERS`).
|
||||
- `app.controller.token_tracker` for used / limit / cache stats.
|
||||
- `app.controller.last_error` for error class.
|
||||
- A new `app.controller.vendor_quota` (lazy-loaded dict) populated by `ai_client` when the provider returns a quota-bearing response.
|
||||
|
||||
**Component shape:**
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class VendorMetric:
|
||||
key: str # e.g. "context_window"
|
||||
label: str # e.g. "Context Window"
|
||||
value: str # e.g. "78,234 / 200,000 (39%)"
|
||||
state: str # "ok" | "warn" | "error" | "info"
|
||||
tooltip: str # long-form explanation, shown on hover
|
||||
```
|
||||
|
||||
The new tab is a thin renderer:
|
||||
```python
|
||||
def render_vendor_state(app: App) -> None:
|
||||
metrics = get_vendor_state(app)
|
||||
if imgui.begin_table("vendor_state", 3, imgui.TableFlags_.row_bg | imgui.TableFlags_.borders):
|
||||
imgui.table_setup_column("Metric", imgui.TableColumnFlags_.width_fixed, 180)
|
||||
imgui.table_setup_column("Value", imgui.TableColumnFlags_.width_stretch)
|
||||
imgui.table_setup_column("State", imgui.TableColumnFlags_.width_fixed, 60)
|
||||
imgui.table_headers_row()
|
||||
for m in metrics:
|
||||
...
|
||||
if imgui.is_item_hovered(): imgui.set_tooltip(m.tooltip)
|
||||
```
|
||||
|
||||
**Files touched:**
|
||||
- New: `src/vendor_state.py` — pure aggregator, no ImGui imports.
|
||||
- Modify: `src/gui_2.py` — add `render_vendor_state`, new tab in `render_operations_hub`.
|
||||
- Modify: `src/app_controller.py` — add `vendor_quota: dict[str, Any] = field(default_factory=dict)` and a `set_vendor_quota(provider, payload)` callback.
|
||||
- Modify: `src/ai_client.py` — call `set_vendor_quota` on quota-bearing responses (Anthropic `usage` blocks, Gemini `metadata.tokenInfo`, DeepSeek rate-limit headers).
|
||||
- New tests: `tests/test_vendor_state.py` (aggregator logic), `tests/test_vendor_state_render.py` (live_gui rendering).
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Tab appears and is the default-open tab when Operations Hub is opened.
|
||||
- All four metric categories (provider/model, context window, cache, last error, quota) render with stable keys.
|
||||
- Missing data renders as `—` (em dash), not as a crash.
|
||||
- No regression in `live_gui` test suite.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5 — Files & Media > Files Directory Tree
|
||||
|
||||
**Problem:** `src/gui_2.py:2689` `render_files_and_media` renders `app.files` as a flat 3-column table (Act / Path / Status), sorted alphabetically. This is hard to scan for a user with 50+ files. The user wants the directory-grouped, collapsible tree node style used in `render_context_files_table` (lines 3111-3260).
|
||||
|
||||
**Approach:** Reuse the existing `aggregate.group_files_by_dir()` helper. For each directory key returned, render a `tree_node_ex(..., default_open)` with the directory name as the label, then iterate the files under it as the leaf rows. The 3-column layout (Act / Path / Status) is preserved at the leaf level.
|
||||
|
||||
**Component shape:**
|
||||
```python
|
||||
def render_files_and_media(app: App) -> None:
|
||||
if imgui.collapsing_header("Files", imgui.TreeNodeFlags_.default_open):
|
||||
with imscope.group():
|
||||
grouped = aggregate.group_files_by_dir(app.files)
|
||||
for dir_name, g_files in sorted(grouped.items()):
|
||||
with imscope.tree_node_ex(f"{dir_name}##files_dir", imgui.TreeNodeFlags_.default_open) as is_open:
|
||||
if is_open:
|
||||
# ... existing per-file row logic, with all `i` indices scoped to the directory
|
||||
# ... existing "Add Files to Inventory" button
|
||||
```
|
||||
|
||||
**Files touched:**
|
||||
- Modify: `src/gui_2.py:2689-2750` — wrap the inner per-file loop in a directory group loop.
|
||||
- New test: `tests/test_files_and_media_tree.py` — asserts that two files in different directories render with the directory labels visible, and that `tree_node` open/closed state is preserved across frames.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Two files in the same directory are grouped under one collapsible node.
|
||||
- One-file "directories" still render (no special-case).
|
||||
- The Act / Path / Status columns still function (Add, Remove, Active / Cached / disabled).
|
||||
- No regression in `tests/test_gui_fast_render.py::test_render_files_and_media_fast`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cross-Cutting Risks & Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Phase 1 markdown change regresses existing markdown rendering (communications, log previews, discussion responses). | All `live_gui` tests for those panels must pass before merging Phase 1. Add a snapshot test that hashes a fixed multi-table markdown input. |
|
||||
| Phase 4 `vendor_quota` thread-safety — providers fire callbacks on background threads. | The new `set_vendor_quota` is a pure field write under the controller's existing lock; `get_vendor_state` reads under the same lock. Document in SDM tag. |
|
||||
| Phase 5 changes the row identity for the `+` and `x` buttons — indices must be globally unique across all directories, not per-directory. | The current code uses `i = enumerate(app.files)`; we preserve the original global index for button IDs by computing it via `app.files.index(f_item)` once per iteration. |
|
||||
| ImGui scope mismatches introduced by the new `tree_node_ex` blocks. | Run `scripts/check_imgui_scopes.py` after each phase. |
|
||||
| Plan exceeds one track's worth of work; some phases might be deferred. | Each phase is independently shippable and self-contained; we will checkpoint after each phase rather than at the end. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing Strategy
|
||||
|
||||
- **Unit tests** for every new module (`markdown_table`, `vendor_state`) — pure parser/aggregator logic, no ImGui context needed.
|
||||
- **live_gui tests** for any change that touches `gui_2.py` render functions. Use the session-scoped `live_gui` fixture from `tests/conftest.py`.
|
||||
- **Regression** — full targeted batch of `tests/test_gui_fast_render.py`, `tests/test_log_management_ui.py`, `tests/test_markdown_*.py`, `tests/test_discussion_hub_*.py` after each phase.
|
||||
- **No new `unittest.mock.patch`** on core infrastructure (per the Structural Testing Contract).
|
||||
|
||||
---
|
||||
|
||||
## 6. Rollout
|
||||
|
||||
Phases are checkpointed independently:
|
||||
|
||||
```
|
||||
Phase 1 ─ checkpoint ─ Phase 2 ─ checkpoint ─ Phase 3 ─ checkpoint ─ Phase 4 ─ checkpoint ─ Phase 5 ─ final
|
||||
```
|
||||
|
||||
Each checkpoint:
|
||||
1. Run targeted test batch.
|
||||
2. Spawn live_gui in headless mode and confirm a smoke screenshot of the touched panel.
|
||||
3. Attach a git note with the verification report.
|
||||
4. Update `conductor/tracks.md` with the phase SHA.
|
||||
|
||||
If a phase is approved out-of-order, the others remain independently executable. There are no inter-phase dependencies (Phase 1, 2, 3, 5 do not depend on Phase 4; Phase 4 does not depend on any of them).
|
||||
Reference in New Issue
Block a user