Skip to content

agentgrep(feat[tui]): Chat layout and deductive search#89

Open
tony wants to merge 6 commits into
masterfrom
chat-layout
Open

agentgrep(feat[tui]): Chat layout and deductive search#89
tony wants to merge 6 commits into
masterfrom
chat-layout

Conversation

@tony

@tony tony commented Jun 29, 2026

Copy link
Copy Markdown
Owner

Summary

  • Add a chat TUI layout — a Claude-Code/pi-style conversation transcript where each query is a you ▸ … turn and matches stream in beneath it as result bubbles, capped per block with a count note so the narrowing reads straight down the screen (1240 → 88 → 12).
  • Add a deductive workflow — the first prompt fixes a haystack with one engine search; each later prompt narrows within it in-memory; ctrl+up widens back out and ctrl+l clears. It composes with any layout, not just chat.
  • Fix the dead Workflow.BINDINGS seam — bindings declared on a workflow were never installed on the hosting screen, so workflow-owned keys could never fire. Bindings now install on attach (removed by identity on swap), and an on_action hook routes parameterized key actions back into the Textual-free strategy object.
  • Extend the WorkflowHost surface with set_input_text (re-seed the prompt after a widen) and update_breadcrumb (repaint the refinement path); the chat layout renders both, while HUD and grep-log re-seed the input and no-op the breadcrumb so deductive narrows their views too.
  • Document the layer in ADR 0014 and list the new pair in the TUI guide.

What it looks like

┌ agentgrep · chat · deductive ─────────────────────────────┐
│ you   ▸ rust error handling                               │
│ grep  ▸ 1,240 prompts  (haystack)                         │
│ you   ▸ anyhow                                            │
│ grep  ▸ 88 prompts                                        │
│ you   ▸ thiserror                                         │
│ grep  ▸ 12 prompts                                        │
│   · 2026-06-12 codex  migrate anyhow→thiserror in cli     │
├───────────────────────────────────────────────────────── │
│ all ▸ rust error handling ▸ anyhow ▸ thiserror            │
│ ▸ _                                          ⏎ narrow ^P ⌄ │
└───────────────────────────────────────────────────────── ┘
$ agentgrep ui --layout chat --workflow deductive

Changes by area

Layouts

  • src/agentgrep/ui/layouts/chat.py: ChatLayout (full WorkflowHost over the shared grep-log non-blocking transport — gated emitter, @offload worker, @pump_only apply, chunked mounts) plus a self-contained DetailScreen modal that builds heavy bodies off the pump and bails if dismissed mid-build.
  • src/agentgrep/ui/layouts/_base.py: install/remove the active workflow's BINDINGS on every attach, and action_workflow to delegate key actions into the workflow.
  • src/agentgrep/ui/layouts/{hud,greplog}.py: set_input_text + no-op update_breadcrumb so deductive composes there without crashing.

Workflows

  • src/agentgrep/ui/workflows/deductive.py: DeductiveWorkflow with an immutable refinement-frame stack; narrows via filter_loaded, widens by popping.
  • src/agentgrep/ui/workflows/_protocol.py: Workflow.on_action and the two new WorkflowHost hooks; search.py/browse.py get no-op on_action.

Widgets

  • src/agentgrep/ui/widgets/turns.py: frozen-slots Turn value objects, a MessageTurn bubble, and a non-slotted TurnRenderer dispatching by type via singledispatchmethod (bounded — first line + compact path).
  • src/agentgrep/ui/widgets/transcript.py: ConversationLog, an append-only VerticalScroll(layout: stream) that mounts turns and never recomposes.
  • src/agentgrep/ui/widgets/breadcrumb.py: RefinementBreadcrumb for the narrowing path.

Registry & docs

  • src/agentgrep/ui/registry.py: register the chat layout and deductive workflow.
  • ADR 0014 + the TUI guide entry.

Design decisions

  • In-memory narrowing, not a per-turn re-query: the engine seam (SearchInvoker.run) only accepts a SearchQuery, not a set of record IDs, so narrowing filters the already-loaded haystack with a composed AND (a single refinement passes through verbatim; two or more are paren-grouped clauses). This is the correct "fixed haystack" semantics and the cheaper non-blocking choice. It routes through filter_loaded only, so a future disk re-grep escape hatch is a drop-in — compose every frame including the base and call run_search — with no data-model change. Accepted gap: in-memory narrowing can't see records beyond the haystack's initial limit (the re-grep hatch is the planned mitigation).
  • Narrowing supersedes an in-flight haystack search: a refinement bumps the search generation, so a still-streaming haystack's late batches are dropped (NB-10) rather than mounting unfiltered into the narrowed block; the refinement runs over whatever loaded so far.
  • Widen appends a wider block rather than unmounting: preserves the append-only "build each finished turn once, never re-render" discipline that keeps a long transcript non-blocking.
  • Renderer split from the value object: a frozen=True, slots=True turn has no __dict__, so derived state lives on the non-slotted TurnRenderer. Renders are bounded (sliced before line-splitting); the detail modal builds heavy bodies off the pump.
  • New ADR rather than a registry instance: this grows the WorkflowHost protocol surface and adds the on_action routing seam (a contract future workflows depend on), so it's recorded as ADR 0014 extending the reusable-widget and pluggable-layout ADRs and leaving the non-blocking invariants unchanged.

Verification

The new layout and workflow are registered:

$ rg -n "_load_chat|_load_deductive" src/agentgrep/ui/registry.py

The transcript is append-only — it must never recompose:

$ rg -n "recompose" src/agentgrep/ui/widgets/transcript.py

JSON parsing stays confined to the bounded detail-body builder:

$ rg -n "json\.(loads|dumps)" src/agentgrep/ui/layouts/chat.py

Workflow bindings are installed and routed:

$ rg -n "make_bindings|action_workflow|on_action" src/agentgrep/ui/layouts/_base.py

Test plan

  • uv run ruff check . and uv run ruff format . — clean
  • uv run ty check — clean (full tree, including tests)
  • uv run pytest — full suite green
  • test_ui_deductive.py — narrowing matrix against a recording host, the composed-filter form (single verbatim vs AND-grouped), and an end-to-end narrow over real records through the engine seam, plus ctrl+up routing on the chat layout
  • test_ui_chat.py — streaming mounts result turns, filter appends a narrowed block, the per-block cap bounds mounted turns, detail opens on a focused result
  • test_ui_pluggable.py — binding install/route/remove end-to-end and the F2/F3 cycle through the third layout/workflow
  • test_ui_widgets.pyTurnRenderer dispatch + bounded result render
  • test_tui_non_blocking.py — the static guard scans the new chat/turns/transcript pump entrypoints
  • just build-docs — ADR 0014 renders and cross-references resolve
  • Recommended before merge: exercise --layout chat --workflow deductive once under AGENTGREP_TUI_WATCHDOG=1 against a large real store

@tony

tony commented Jun 29, 2026

Copy link
Copy Markdown
Owner Author

Code review

Found 1 issue:

  1. DeductiveWorkflow imports Textual at module scope (from textual.binding import Binding, used for priority=True keys), which contradicts the "Textual-free" invariant this PR states in four places: the module docstring ("Textual-free, so the strategy is unit-testable against a fake host"), ADR 0014 DN-2/DN-4 ("a workflow still imports no Textual"), and the action_workflow docstring in _base.py ("so the strategy object never imports Textual"). The other workflows import no Textual, and Workflow.BINDINGS is typed cabc.Sequence[object] to avoid the dependency. Either keep the workflow tuple-only and have _install_workflow_bindings apply priority=True, or relax the documented invariant.

import typing as t
from textual.binding import Binding
if t.TYPE_CHECKING:
from agentgrep.ui.workflows._protocol import WorkflowHost

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

tony added 6 commits July 10, 2026 19:02
why: Workflow.BINDINGS has been declared on the Workflow protocol
since ADR 0013 but was never installed on the hosting screen, so a
workflow's own keys could never fire. The upcoming deductive
workflow needs widen/clear keys, so the seam must actually wire
workflow-owned bindings and route them back to the strategy object.

what:
- Add Workflow.on_action(host, action_id) to the protocol; no-op
  (returns False) on SearchWorkflow and BrowseWorkflow.
- Install the active workflow's BINDINGS onto LayoutScreen on every
  attach (mount, swap, resume) and remove them by identity on swap,
  replacing the per-key bucket first since BindingsMap.copy() shares
  the class-level lists.
- Add LayoutScreen.action_workflow to delegate parameterized key
  actions into the active workflow (one bounded call, pump-safe).
- Cover on_action no-ops and prove end-to-end key routing + removal
  with Pilot tests (a dead binding is invisible to the static guard).
why: The explorer needed a Claude-Code/pi-style lens that reads a
search as a conversation — each query a turn, matches streaming in
beneath it — both as a layout in its own right and as the substrate
the deductive narrowing workflow will render into.

what:
- Add widgets/turns.py: frozen+slots Turn value objects
  (QueryTurn/ResultTurn/SystemTurn), a MessageTurn bubble carrying
  its value object, and a non-slotted TurnRenderer dispatching by
  type via singledispatchmethod (bounded renders — first line + path,
  never a full-body highlight).
- Add widgets/transcript.py: ConversationLog, an append-only
  VerticalScroll(layout: stream) that mounts turns and never
  recomposes, with a turn budget.
- Add layouts/chat.py: ChatLayout (full WorkflowHost over the shared
  greplog non-blocking transport; per-block result cap + count note)
  and a self-contained DetailScreen that builds heavy bodies off the
  pump. Register the chat layout and export the new widgets.
- Cover the renderer (pure), the layout streaming/filter/cap/detail
  (Pilot), and update the registry/cycle tests for the third layout.
why: The headline feature — a "deductive" search where the first
prompt fixes a haystack and each later prompt narrows within it, with
a key to widen back out. It composes with the chat layout (the
conversation reads as 1240 → 88 → 12) and any other layout.

what:
- Add workflows/deductive.py: DeductiveWorkflow holds an immutable
  refinement stack; first submit runs one engine search (the
  haystack), later submits narrow in-memory via a composed-AND
  filter_loaded, ctrl+up widens (pops + re-seeds the prompt),
  ctrl+l clears. Narrowing routes through filter_loaded only, so a
  future disk re-grep is a drop-in (compose all frames, run_search).
- Add widgets/breadcrumb.py: RefinementBreadcrumb renders the active
  path (all ▸ a ▸ b) and hides when empty.
- Extend the WorkflowHost protocol with set_input_text and
  update_breadcrumb; the chat layout renders both, while HUD and
  grep-log re-seed the input and no-op the breadcrumb (PL-6).
- Register the deductive workflow; cover the narrowing matrix against
  a recording host and prove ctrl+up routes end-to-end on the chat
  layout (binding install + on_action + breadcrumb shrink).
why: The workflow seam grew structural members across this branch —
Workflow.on_action and the WorkflowHost set_input_text /
update_breadcrumb hooks — so the pre-existing test fakes and a few
turn/renderer assertions no longer satisfied the protocols under the
full ty check (the per-step focused checks did not span the test tree).

what:
- Add on_action to the recording workflow fake and set_input_text /
  update_breadcrumb to the recording host fake.
- Narrow Turn subclass and Rich Text accesses in the chat/turn tests
  with explicit casts / isinstance guards.
- Drop two now-redundant casts in the binding tests.
…014)

why: The chat layout and deductive workflow make genuine contract
changes — the WorkflowHost surface grew, the dead Workflow.BINDINGS
were wired, and on_action was added — so they warrant their own ADR
rather than landing as new registry instances.

what:
- Add ADR 0014 (DN-1..DN-5): in-memory fixed-haystack narrowing (not
  a re-query) with the re-grep seam left open, the immutable
  refinement stack, the append-only frozen transcript + singledispatch
  renderer, the installed-bindings / on_action / host-hook seam, and
  cross-layout orthogonality; extends ADR 0012/0013, honors 0011.
- List the chat layout and deductive workflow in the TUI guide.
why: A recall-mode review of the chat/deductive work caught a
feature-breaker plus several non-blocking and lifecycle gaps the
green gate missed — the deductive tests asserted host-call shape
against a fake host but never ran the matcher, so a composed filter
that matched nothing slipped through.

what:
- Compose a single refinement verbatim instead of wrapping it in
  parens; '(python)' parsed as a literal term and matched nothing,
  so the first narrow returned zero results.
- Supersede the in-flight search generation when narrowing, so a
  still-streaming haystack's late batches are dropped instead of
  mounting unfiltered into the narrowed block (NB-10).
- Bound the result-turn render (slice before splitlines) so a large
  body never freezes the pump.
- Route a worker-thread error through the gated emitter so a
  superseded search's exception can't freeze the live status.
- Guard DetailScreen._present against the modal being dismissed
  before its off-pump body build returns.
- Drop the misleading composed-text bubble on a programmatic widen.
- Highlight detail with the active refinement terms (via the filter
  matcher), and clear the breadcrumb on run_search/reset_view.
- Add an end-to-end test that narrows over real records through the
  engine seam (the gap that let the paren bug through).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant