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.
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.
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)
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])
New file: src/local/ui/generator_window.py
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 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.
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).
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.
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.
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.
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..."
}
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.
_request_schemas(), publish initial status with token_count=0self._sm.transition() call_register_tool_schema() updates the registryset_token_count() is called (token_count now known)A helper _publish_status() builds the payload from instance fields and publishes. Called from each of the above points.
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.
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().
| File | Change |
|---|---|
| config/system.yaml | New file — instance_id (defaults to hostname if absent) |
| src/local/protocol/subjects.py | Add GENERATOR_STATUS |
| src/local/agents/generator_agent.py | _publish_status() helper; publish on startup, transitions, tool register, post-generation |
| src/local/ui/generator_window.py | New 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.py | Test _publish_status() called on state transitions; test instance_id from config |
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".
agent.heartbeat publishing. No UI changes needed at that point — just the
heartbeat publisher in GeneratorAgent.