Skip to content

Fluent forward-ref build tier: split-type pane handles + plan()#700

Merged
tony merged 13 commits into
engine-opsfrom
tui-idea
Jul 4, 2026
Merged

Fluent forward-ref build tier: split-type pane handles + plan()#700
tony merged 13 commits into
engine-opsfrom
tui-idea

Conversation

@tony

@tony tony commented Jul 4, 2026

Copy link
Copy Markdown
Member

Summary

A fluent, forward-ref build tier over the typed-operation spine — the headless best-of-breed DX for driving tmux through libtmux.experimental. Stacked on #690 (base: engine-ops); strictly additive, mypy + ty strict, CI green across tmux 3.2a→master.

from libtmux.experimental.fluent import plan

p = plan()
pane = p.new_session("dev").window().pane()          # ForwardPaneRef (first pane)
pane.do(lambda c: c.send_keys("vim")).split() \
    .do(lambda c: c.send_keys("pytest -q"))          # forward-ref split, chained
p.run(engine)                                         # folds to a few tmux dispatches

What's delivered

The design's north star — four thin recorders over one executor — landed end-to-end.

Fluent forward-ref build tier (fluent.py, query.py): plan()PlanBuilder / SessionRef / WindowRef, over split-type handles PaneRef (concrete, from a query row) vs ForwardPaneRef (a pane an earlier op will create). Reading a pane id off a forward handle is a static type error under both mypy and ty — the attribute is structurally absent. Structural verbs (.split) return forward handles; leaf commands under .cmd record ops; .do() chains.

Fold-unless-true-blocker: .run()/.arun() default to a folding planner, collapsing a session build into a handful of ;-chained + {marked} dispatches. The chain breaks only at a true blocker — a create whose id must round-trip, or a host boundary. PlanBuilder.sleep() / .wait() record host boundaries (BoundedPlanner); LazyPlan.explain() reports why each dispatch is a boundary.

Conditional find-or-create: LazyPlan.ensure() + PlanBuilder.find_or_create_session() — the create is made conditional at execution (probe, reuse-or-create), so the plan stays pure and serializable.

Streaming: LazyPlan.astream() — a pull-based async-generator twin of aexecute over the same sans-I/O core; backpressure is intrinsic, the loop never blocks between dispatches.

Declarative fan-out + round-trip (workspace/): freeze (live server → IR), expand (variant fan-out), WorkspaceSet (N workspaces → one folded plan).

MCP frontend: explain_plan tool alongside the existing preview/execute/result-schema plan tools.

Testing

83 new tests — NamedTuple-parametrized cases, a doctest on every method over ConcreteEngine (no tmux), plus live builds against real tmux, and a narrative section on the {ref}experimental docs page with runnable examples. Full repo gate green (ruff, mypy, ty, pytest, build-docs) on each commit; CI green across 8 tmux versions.

Known follow-ups (non-blocking)

  • MCP plan tools rebuild plans via _plan_from_dicts rather than LazyPlan.from_list, so a serialized ensure (find-or-create) is not carried over MCP yet.
  • find_or_create is wired for sessions only; astream is Core-tier and minimal; a mermaid plan-graph docs directive is still to come.

tony added 2 commits July 4, 2026 12:41
why: a fluent build needs to target a pane an earlier op will create.
Splitting the concrete PaneRef into two handle types makes reading an id
off a not-yet-created pane a static type error under mypy and ty, not a
runtime surprise.

what:
- Add _PaneRefBase (shared verbs: cmd, split, do), ForwardPaneRef (no
  snapshot reads), and PaneRef (concrete: pane_id/active/pane)
- split() records the create and returns a forward handle; do() threads a
  recorder into a fluent chain
- Widen BoundPaneCommands.target to Target so a forward SlotRef flows
- Cover the split/do verbs and the concrete-vs-forward read surface
why: the split-type pane handles give forward-ref panes, but a user still
needs a fluent entry to declare a whole session tree. plan() records a
session/window/pane build into one LazyPlan that folds to a few tmux
dispatches instead of one call per operation.

what:
- Add experimental/fluent.py: plan() -> PlanBuilder, SessionRef, WindowRef
- Name-address sessions/windows (folds stay intact); reach a pane via the
  creator's captured first-pane SlotRef (a forward handle)
- run()/arun() default to MarkedPlanner; preview() is a pure argv dry-run
- Cover the build shape, an offline fold over ConcreteEngine, and a live build
@codecov

codecov Bot commented Jul 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.20183% with 97 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.74%. Comparing base (2dfa992) to head (ce0c947).

Files with missing lines Patch % Lines
src/libtmux/experimental/workspace/sets.py 65.88% 41 Missing and 17 partials ⚠️
src/libtmux/experimental/fluent.py 74.69% 19 Missing and 2 partials ⚠️
src/libtmux/experimental/workspace/freeze.py 89.58% 3 Missing and 2 partials ⚠️
src/libtmux/experimental/ops/plan.py 93.84% 2 Missing and 2 partials ⚠️
tests/experimental/contract/test_workspace_sets.py 92.50% 2 Missing and 1 partial ⚠️
src/libtmux/experimental/mcp/fastmcp_adapter.py 33.33% 2 Missing ⚠️
src/libtmux/experimental/workspace/expand.py 94.28% 1 Missing and 1 partial ⚠️
src/libtmux/experimental/query.py 95.65% 1 Missing ⚠️
tests/experimental/ops/test_plan.py 98.36% 1 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff               @@
##           engine-ops     #700      +/-   ##
==============================================
+ Coverage       74.41%   74.74%   +0.33%     
==============================================
  Files             217      223       +6     
  Lines           12724    13257     +533     
  Branches         1678     1758      +80     
==============================================
+ Hits             9468     9909     +441     
- Misses           2599     2666      +67     
- Partials          657      682      +25     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

tony added 7 commits July 4, 2026 12:53
why: analyze() and to_dict() cover the config->IR->dict round-trip, but
there was no way back from a LIVE server to declarative IR. freeze()
closes the loop -- reverse-analyze a running session into a Workspace you
can rebuild elsewhere (tmuxp `freeze`).

what:
- Add experimental/workspace/freeze.py: freeze(ServerSnapshot) -> Workspace
  (pure), plus freeze_server/afreeze_server rebuilding the whole tree from
  one list-panes -a read
- Export freeze/freeze_server/afreeze_server from the workspace package
why: building several near-identical sessions (per-app, per-env) meant
declaring each by hand and dispatching each separately. expand() fans one
workspace into named variants; WorkspaceSet folds N workspaces into one
rebased plan so the whole batch builds in a single folded run.

what:
- Add workspace/expand.py: expand(workspace, variants) renders $name /
  ${name} tokens, leaving unknown tokens intact
- Add workspace/sets.py: WorkspaceSet + compile_workspaces/build_workspaces
  /abuild_workspaces, rebasing each workspace's SlotRefs and host steps by a
  per-workspace offset into one LazyPlan
- Export expand, WorkspaceSet(+Result/Compiled), build/abuild/compile_workspaces
why: folding collapses N ops into a few dispatches, which hides per-op
structure -- a caller can't see why the chain broke where it did, and had
no way to observe a plan step by step as it ran.

what:
- LazyPlan.explain(planner): annotate each dispatch step with why it is a
  boundary (marked-fold/folded/created-id/capture/single) -- pure, no I/O
- LazyPlan.astream(engine): async-generator twin of aexecute over the same
  drive core, yielding a StepDone per bound step and a terminal PlanDone;
  pull-based, so backpressure needs no buffer and the loop never blocks
- Export StepExplanation, StepDone, PlanDone, PlanEvent
why: some builds need a real pause between dispatches -- a fixed delay, or
waiting for a pane's shell prompt before sending the next command. A host
step is a true blocker: it can't fold into a tmux chain, so the fold must
break around it.

what:
- PlanBuilder.sleep(seconds): pause after the last recorded op
- PlanBuilder.wait(pane): poll the pane's cursor until its prompt draws
- run()/arun() bound the planner (BoundedPlanner) at each host boundary and
  run the recorded step from the on_step hook, sync and async
why: an agent composing a plan over MCP could preview argv and query a
result schema, but couldn't see why a planner folds or breaks the plan
into the dispatches it does -- the fold hides that structure.

what:
- plan_tools.explain_plan(plan, planner): per-step boundary reasons
  (marked-fold/folded/created-id/capture/single), pure over a serialized plan
- Register a readonly explain_plan tool in the fastmcp adapter
- Export explain_plan from the mcp package
why: rebuilding a workspace should reuse a session/window that already
exists rather than error or duplicate it. Doing that in the pure,
serializable plan model means the "does it exist?" branch has to live at
execution time, not build time.

what:
- LazyPlan.ensure(index, probe): mark a create conditional -- the driver
  runs the probe (a read returning the object's capture format), and on
  success binds the slot to the found ids and skips the create
- Reuse the create op's own build_result to parse the probe's ids, so a
  found object binds the same self/window/pane subrefs a created one would
- Carry the probe through to_list/from_list so the conditional round-trips
- Cover found-reuses / absent-creates and the serialization round-trip
why: give the fluent builder an idempotent session entry, so re-running a
build reuses the live session instead of duplicating it -- the tmuxp
load -a shape, in the forward-ref builder.

what:
- PlanBuilder.find_or_create_session(name): record the same create as
  new_session, made conditional via LazyPlan.ensure with a display-message
  probe that captures the session's id, first window, and first pane
- Cover the recorded shape and a live idempotent double-build
tony added 2 commits July 4, 2026 14:22
why: the fluent live tests created sessions on the session-scoped shared
server and never removed them. On tmux 3.3a/3.5 under xdist, that leaked
session perturbed the phantom-reap tests, which assert an exact global
session count -- CI failed there though the local gate (newer tmux) did
not.

what:
- Kill the created session in a finally block (test_build_session_live,
  test_find_or_create_session_is_idempotent_live) via a _kill_named helper
why: the fluent forward-ref build tier had only method/module doctests;
the experimental page covered the Core ops/engines/plans but not the
declarative surface a user reaches for first.

what:
- Add a "Building fluently with plan()" section to the experimental page,
  proportional to the other sections: forward-ref handles, fold-unless-
  true-blocker, and find_or_create_session, with runnable ConcreteEngine
  doctests
@tony tony changed the title Fluent forward-ref build tier: split-type pane handles + plan() (MVP) Fluent forward-ref build tier: split-type pane handles + plan() Jul 4, 2026
tony added 2 commits July 4, 2026 15:12
why: the MCP plan tools rebuilt a serialized plan with add() +
operation_from_dict, which drops the `ensure` probe that to_list emits --
so a find-or-create plan round-tripped through MCP silently became an
unconditional create (a duplicate-session footgun).

what:
- Route _plan_from_dicts through LazyPlan.from_list, which carries the
  ensure probe, so a conditional create survives the MCP round-trip
why: the fake engine returned all three ids for any display-message, so
the ensure test asserted a pane binding while probing only #{session_id}
-- it passed on ids the probe never requested and would not catch a real
probe/capture format mismatch.

what:
- Make _FindEngine format-aware (return only the ids the probe requests)
- Probe the full capture format in test_ensure_probes_then_creates
- Add test_ensure_probe_must_match_create_capture: a session-only probe
  binds no pane subref, guarding the format contract
@tony tony merged commit d361a2b into engine-ops Jul 4, 2026
7 of 10 checks passed
@tony tony deleted the tui-idea branch July 4, 2026 20:13
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