Phase 15 — GeneratorWindow

Every major participant in LoCAL2 has a dedicated observable window except the generator — the most important participant. This phase adds GeneratorWindow and redesigns the window layout to accommodate it, making the generator a first-class visible entity. This is a prerequisite for distributed multi-instance and specialist agent work.

1 — Layout Redesign

Layout

Four lightweight tool windows (web_search, web_fetch, get_datetime, get_location) become half-height, stacked two-per-slot in a dedicated utilities column (col 4). This frees a full slot at col 2 row 0 for GeneratorWindow.

Screen layout — right 5/7 divided into 5 columns × 2 rows Each cell = panel_w × panel_h (panel_h = H/2 − tb_h) Half cells = panel_w × half_panel_h (half_panel_h = H/4 − tb_h) ┌─────────────┬─────────────┬─────────────┬─────────────┬─────────────┐ │ search_ │ search_ │ Generator │ search_ │ web_search │ ← half H │ memory │ library │ Window │ papers ├─────────────┤ │ │ │ (NEW) │ │ web_fetch │ ← half H ├─────────────┼─────────────┼─────────────┼─────────────┼─────────────┤ │ Memory │ Documents │ Conversa- │ Critic │ get_datetime│ ← half H │ Window │ Window │ tions │ Window ├─────────────┤ │ │ │ Window │ │ get_location│ ← half H └─────────────┴─────────────┴─────────────┴─────────────┴─────────────┘ col 0 col 1 col 2 col 3 col 4

_tile_windows() changes

Add a _place_half(win, col, row, half) helper alongside the existing _place(win, col, row):

half_panel_h = H // 4 - tb_h

def _place_half(win, col: int, row: int, half: int) -> None:
    # half=0: top half of the row slot; half=1: bottom half
    x = x0 + main_w + col * panel_w
    y = y0 + row * (H // 2) + half * (H // 4) + tb_h
    win.setGeometry(x, y, panel_w, half_panel_h)

Updated _TOOL_PANEL_SLOTS

Slots now carry an optional half value (0 or 1). Full-height tools keep the existing 2-tuple; half-height tools use a 3-tuple:

_TOOL_PANEL_SLOTS: dict[str, tuple] = {
    "search_memory":  (0, 0),          # full height
    "search_library": (1, 0),          # full height
    "search_papers":  (3, 0),          # full height
    "web_search":     (4, 0, 0),       # half height — top of row 0
    "web_fetch":      (4, 0, 1),       # half height — bottom of row 0
    "get_datetime":   (4, 1, 0),       # half height — top of row 1
    "get_location":   (4, 1, 1),       # half height — bottom of row 1
}

The tiling loop checks tuple length to dispatch to the right helper:

for name, win in self._tool_windows.items():
    slot = _TOOL_PANEL_SLOTS.get(name)
    if slot:
        if len(slot) == 3:
            _place_half(win, col=slot[0], row=slot[1], half=slot[2])
        else:
            _place(win, col=slot[0], row=slot[1])
Decision: GeneratorWindow placed at col 2, row 0 (static, not reactive). It is spawned at startup like CriticWindow and MemoryWindow — not reactively on a bus event. It represents the generator's identity, which is known at startup.

2 — GeneratorWindow UI

UI

New file: src/local/ui/generator_window.py

┌─ GeneratorWindow ──────────────────────────────────────────┐ │ generator-A · gemma4:e4b · temp 0.1 · ctx 128K │ ← identity bar ├────────────────────────────────────────────────────────────┤ │ ● IDLE 47,200 / 128K │ ← state + tokens │ ████████████░░░░░░░░░░░░░░░░░░░░░░░░ 37% │ ← context bar ├────────────────────────────────────────────────────────────┤ │ Tools (7) │ ← tool registry │ search_memory · web_search · web_fetch · get_datetime │ │ get_location · search_papers · search_library │ ├────────────────────────────────────────────────────────────┤ │ System prompt ▼ │ ← collapsible │ You are a helpful assistant with access to memory… │ ├────────────────────────────────────────────────────────────┤ │ Transitions │ ← log strip │ 09:14:22 IDLE → RECEIVING │ │ 09:14:22 RECEIVING → GENERATING │ │ 09:14:31 GENERATING → DISPATCHING_TOOL │ │ 09:14:32 DISPATCHING_TOOL → TOOL_RESULT │ │ 09:14:35 TOOL_RESULT → GENERATING │ │ 09:14:41 GENERATING → IDLE │ ├────────────────────────────────────────────────────────────┤ │ Peers (none detected) │ ← peer registry └────────────────────────────────────────────────────────────┘

Identity bar

Static — set once at window construction from generator config: instance_id · model · temperature · num_ctx. Uses the instance_id from config/system.yaml (new in this phase; defaults to hostname if absent).

State + context row

State badge colour-coded identically to CriticWindow's state transitions: IDLE=grey, RECEIVING=blue, GENERATING=amber, DISPATCHING_TOOL=purple, ERROR=red. Token count and fill bar mirror the sidebar ContextGauge but with numeric detail. Updates on every generator.status event.

Tool registry

Chip-style list of currently registered tool names. Updates live as tools announce on tool.schema. Count badge in section header. Clicking a tool name is a no-op for now (future: jump to that tool's window).

System prompt

Collapsible section. Read-only QTextEdit showing the active system prompt. Collapsed by default (saves vertical space). Content set once at startup from generator.status initial payload.

Transitions log

Scrolling strip of state transitions with timestamps — same pattern as CriticWindow.append_transition(). Subscribes to existing AGENT_TRANSITION subject (already published by GeneratorAgent state machine). Shows last 50 transitions.

Peers panel

Table stub: Instance ID | State | Load | Last seen. Shows "(none detected)" until agent.heartbeat events arrive. Hidden entirely if no peers ever appear. Forward-compatible with Phase 16 distributed work — no logic needed now beyond displaying what arrives on the heartbeat subject.

3 — New Bus Subject: generator.status

Bus GeneratorAgent

A new subject generator.status carries full generator state snapshots. Published on: startup, every state transition, every tool registration, and after each generation turn.

// subject: generator.status
{
  "instance_id":    "local2-macbook",
  "respondent_id":  "A",
  "model":          "gemma4:e4b",
  "temperature":    0.1,
  "num_ctx":        128000,
  "state":          "idle",           // matches GeneratorState enum lowercase
  "token_count":    47200,            // last prompt_eval_count, 0 before first turn
  "tool_names":     ["search_memory", "web_search", ...],
  "system_prompt":  "You are a helpful assistant..."
}
Decision: generator.status is separate from agent.transition. agent.transition carries state machine events (FROM → TO). generator.status carries full snapshots of all generator config and runtime state. GeneratorWindow subscribes to both: transitions for the log strip, status for everything else.

Publish points in GeneratorAgent

A helper _publish_status() builds the payload from instance fields and publishes. Called from each of the above points.

instance_id source

Read from config/system.yaml:

# config/system.yaml  (new file)
instance_id: "local2-macbook"

If the key is absent or the file doesn't exist, fall back to socket.gethostname(). This config file is also where peer/distributed config will live in Phase 16.

4 — BusLogger + MainWindow wiring

UI

Add generator_status signal to BusLogger and handle GENERATOR_STATUS subject in log_envelope():

generator_status = Signal(dict)   # full status snapshot

# in log_envelope():
if subject == GENERATOR_STATUS:
    self.generator_status.emit(raw)
    return

Subscribe to GENERATOR_STATUS in _start_bus_monitor(). Connect signal to self._generator_window.update_status(data).

GeneratorWindow is constructed at startup alongside CriticWindow and MemoryWindow, and placed at col 2, row 0 in _tile_windows().

5 — Files Changed

FileChange
config/system.yamlNew file — instance_id (defaults to hostname if absent)
src/local/protocol/subjects.pyAdd GENERATOR_STATUS
src/local/agents/generator_agent.py_publish_status() helper; publish on startup, transitions, tool register, post-generation
src/local/ui/generator_window.pyNew file — GeneratorWindow widget
src/local/ui/main_window.py_TOOL_PANEL_SLOTS updated; _place_half() helper; _tile_windows() half-height dispatch; GeneratorWindow constructed + tiled; BusLogger generator_status signal wired
tests/test_generator_agent.pyTest _publish_status() called on state transitions; test instance_id from config

6 — Out of Scope (Phase 15)

7 — Forward Compatibility Notes

instance_id in config/system.yaml is the hook for all distributed work. Every future Phase 16 component (heartbeat, peer registry, agent.request routing) reads from the same config key. Set it meaningfully now — "local2-macbook", not "instance-1".
The peers panel in GeneratorWindow will automatically show peers once Phase 16 adds agent.heartbeat publishing. No UI changes needed at that point — just the heartbeat publisher in GeneratorAgent.