turnloop 0.1.4__tar.gz → 0.1.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {turnloop-0.1.4 → turnloop-0.1.5}/PKG-INFO +31 -8
- {turnloop-0.1.4 → turnloop-0.1.5}/README.md +30 -7
- {turnloop-0.1.4 → turnloop-0.1.5}/pyproject.toml +1 -1
- turnloop-0.1.5/tests/test_tui.py +397 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/__init__.py +1 -1
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tui/app.py +20 -3
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tui/config_screen.py +20 -1
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tui/mcp_screen.py +363 -344
- turnloop-0.1.4/tests/test_tui.py +0 -170
- {turnloop-0.1.4 → turnloop-0.1.5}/.github/workflows/ci.yml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/.gitignore +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/.turnloop/.gitignore +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/tests/conftest.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/tests/fixtures/anthropic_tool_call.sse +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/tests/fixtures/glm_tool_call.sse +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/tests/fixtures/mcp_echo_server.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/tests/test_bash.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/tests/test_compaction.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/tests/test_config.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/tests/test_config_screen.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/tests/test_configio.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/tests/test_experiments.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/tests/test_hooks_mcp_commands.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/tests/test_loop.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/tests/test_permissions.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/tests/test_providers.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/tests/test_tools_files.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/tests/test_websearch.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/__main__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/agent/__init__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/agent/factory.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/agent/headless.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/agent/loop.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/agent/subagent.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/agent/system_prompt.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/cli.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/commands/__init__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/commands/dispatch.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/commands/loader.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/config.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/configio.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/context/__init__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/context/budget.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/context/compaction.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/context/memory.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/core/__init__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/core/events.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/core/ids.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/core/messages.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/core/tokens.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/diagnostics.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/errors.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/__init__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/configs/bakeoff.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/configs/ceiling-check.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/configs/context-glm.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/configs/context.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/configs/glm-selfhosted.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/configs/groq-free.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/configs/hard-calibration.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/configs/hard-glm.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/configs/loop.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/configs/nvidia-smoke.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/configs/reliability.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/configs/rewrite-calibration.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/configs/smoke.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/graders.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/report.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/runner.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/default.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/bulky/module_1.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/bulky/module_2.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/bulky/module_3.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/bulky/module_4.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/bulky/module_5.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/bulky/module_6.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/bulky/module_7.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/bulky/module_8.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/circular_import/models.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/circular_import/services.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/cli_flag/app.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/empty/.keep +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/failing_test/calc.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/failing_test/test_calc.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/generate_bulky.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/grep_edit/ingest.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/grep_edit/load.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/grep_edit/transform.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/needle/inventory.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/needle/pricing.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/needle/shipping.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/regression_trap/validators.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/rename/billing.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/rename/report.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/rename/test_billing.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/spec/parser.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/spec/test_parser.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/util/util.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/hooks/__init__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/hooks/runner.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/mcp/__init__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/mcp/adapter.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/mcp/client.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/permissions/__init__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/permissions/engine.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/permissions/rules.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/providers/__init__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/providers/anthropic.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/providers/base.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/providers/gemini.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/providers/mock.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/providers/openai_compat.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/providers/pricing.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/providers/registry.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/providers/sse.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/sessions/__init__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/sessions/models.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/sessions/store.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/__init__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/ask.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/base.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/bash.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/builtin.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/edit.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/glob.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/grep.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/read.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/runner.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/shell.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/skill.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/task.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/textio.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/todo.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/webfetch.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/websearch.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/write.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tui/__init__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tui/app.tcss +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tui/bridge.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tui/widgets/__init__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tui/widgets/permission.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tui/widgets/status.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tui/widgets/transcript.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: turnloop
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: A local-first agentic coding harness: streaming tool loop, permission gating, context compaction, subagents, and any LLM provider.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Pranesh-2005/turnloop
|
|
6
6
|
Author: Pranesh
|
|
@@ -49,7 +49,7 @@ tl experiment run smoke # a measurement run, offline and free
|
|
|
49
49
|
|
|
50
50
|
Four runtime dependencies: `textual`, `pydantic`, `httpx`, `pyyaml`. No vendor
|
|
51
51
|
SDKs, no agent framework — the loop is the point, so the loop is written here.
|
|
52
|
-
~18,000 lines of Python,
|
|
52
|
+
~18,000 lines of Python, 290 tests, no network in the default test run.
|
|
53
53
|
|
|
54
54
|
---
|
|
55
55
|
|
|
@@ -117,7 +117,7 @@ point.
|
|
|
117
117
|
Verify:
|
|
118
118
|
|
|
119
119
|
```bash
|
|
120
|
-
tl --version # turnloop 0.1.
|
|
120
|
+
tl --version # turnloop 0.1.5
|
|
121
121
|
tl doctor # every provider, key presence, shell, context budget
|
|
122
122
|
```
|
|
123
123
|
|
|
@@ -869,11 +869,34 @@ and through session JSONL, so a `--resume`d conversation does not hit the same w
|
|
|
869
869
|
check, and anyone who pointed 0.1.2 at Gemini got one working tool call and then a
|
|
870
870
|
400. Fixed in 0.1.3.
|
|
871
871
|
|
|
872
|
-
|
|
872
|
+
### `NoActiveWorker`: 283 green tests and a crash on the first keystroke
|
|
873
|
+
|
|
874
|
+
The config UI shipped with tests covering `/config` — they asserted that dispatching it
|
|
875
|
+
returned `open_screen="config"`. All 283 passed. Typing `/config` in the actual TUI
|
|
876
|
+
raised `NoActiveWorker: push_screen must be run from a worker`.
|
|
877
|
+
|
|
878
|
+
Textual's `push_screen_wait` blocks the calling coroutine until the screen is dismissed,
|
|
879
|
+
so it must run inside a worker; called from the message pump it would deadlock the pump
|
|
880
|
+
it is waiting on, and Textual refuses rather than hanging. `_handle_command` runs on the
|
|
881
|
+
pump. So does Textual's action dispatch, which meant the same defect existed at four
|
|
882
|
+
call sites, not the one in the traceback — including the provider form reachable from
|
|
883
|
+
`tl config --edit`, a path the crash report never touched.
|
|
884
|
+
|
|
885
|
+
The correct pattern was eleven lines away in the same file: `_permission_worker` is
|
|
886
|
+
`@work`-decorated, which is exactly why *its* `push_screen_wait` works.
|
|
887
|
+
|
|
888
|
+
Two things this cost, both worth stating. The tests verified the layer *below* the bug —
|
|
889
|
+
that the command produced the right instruction, never that the app could carry it out.
|
|
890
|
+
And **0.1.4 shipped this way**, so `pip install turnloop==0.1.4` crashes on `/config`.
|
|
891
|
+
Fixed in 0.1.5. The tests now drive a real app through Textual's `run_test()` pilot and
|
|
892
|
+
fail if the decorators are removed.
|
|
893
|
+
|
|
894
|
+
**The lesson, five times over in one project:** an 84-run sweep failing while printing
|
|
873
895
|
a tidy report of zeros; a tool returning empty with `ok: true`; a sweep sitting at
|
|
874
896
|
10/18 doing nothing for 25 minutes; an adapter passing its live test and failing on
|
|
875
|
-
the turn the test never made
|
|
876
|
-
|
|
897
|
+
the turn the test never made; a feature with green tests that crashed on its first
|
|
898
|
+
keystroke. Each was caught only by looking past the summary line — and the last two
|
|
899
|
+
only by using the thing instead of testing it.
|
|
877
900
|
|
|
878
901
|
---
|
|
879
902
|
|
|
@@ -907,7 +930,7 @@ one-line edit in a CRLF checkout does not produce a whole-file diff.
|
|
|
907
930
|
## Testing
|
|
908
931
|
|
|
909
932
|
```bash
|
|
910
|
-
pytest #
|
|
933
|
+
pytest # 290 tests, no network
|
|
911
934
|
ruff check turnloop
|
|
912
935
|
mypy turnloop
|
|
913
936
|
```
|
|
@@ -926,7 +949,7 @@ mypy turnloop
|
|
|
926
949
|
| `test_configio.py` | 15 | minimal-diff writes, atomic save, secret-bearing MCP targets, the settings deny rules |
|
|
927
950
|
| `test_config_screen.py` | 10 | provider form validation, caps derivation, env indicator leaks |
|
|
928
951
|
| `test_websearch.py` | 9 | HTML parsing, backend selection, truncation, failure modes |
|
|
929
|
-
| `test_tui.py` |
|
|
952
|
+
| `test_tui.py` | 15 | Textual snapshots, `/config` and `/mcp add` driven through a real app |
|
|
930
953
|
|
|
931
954
|
`conftest.py` monkeypatches httpx's transport to raise unless a test is marked
|
|
932
955
|
`live` — **network is off at the transport layer**, so a forgotten real call fails
|
|
@@ -20,7 +20,7 @@ tl experiment run smoke # a measurement run, offline and free
|
|
|
20
20
|
|
|
21
21
|
Four runtime dependencies: `textual`, `pydantic`, `httpx`, `pyyaml`. No vendor
|
|
22
22
|
SDKs, no agent framework — the loop is the point, so the loop is written here.
|
|
23
|
-
~18,000 lines of Python,
|
|
23
|
+
~18,000 lines of Python, 290 tests, no network in the default test run.
|
|
24
24
|
|
|
25
25
|
---
|
|
26
26
|
|
|
@@ -88,7 +88,7 @@ point.
|
|
|
88
88
|
Verify:
|
|
89
89
|
|
|
90
90
|
```bash
|
|
91
|
-
tl --version # turnloop 0.1.
|
|
91
|
+
tl --version # turnloop 0.1.5
|
|
92
92
|
tl doctor # every provider, key presence, shell, context budget
|
|
93
93
|
```
|
|
94
94
|
|
|
@@ -840,11 +840,34 @@ and through session JSONL, so a `--resume`d conversation does not hit the same w
|
|
|
840
840
|
check, and anyone who pointed 0.1.2 at Gemini got one working tool call and then a
|
|
841
841
|
400. Fixed in 0.1.3.
|
|
842
842
|
|
|
843
|
-
|
|
843
|
+
### `NoActiveWorker`: 283 green tests and a crash on the first keystroke
|
|
844
|
+
|
|
845
|
+
The config UI shipped with tests covering `/config` — they asserted that dispatching it
|
|
846
|
+
returned `open_screen="config"`. All 283 passed. Typing `/config` in the actual TUI
|
|
847
|
+
raised `NoActiveWorker: push_screen must be run from a worker`.
|
|
848
|
+
|
|
849
|
+
Textual's `push_screen_wait` blocks the calling coroutine until the screen is dismissed,
|
|
850
|
+
so it must run inside a worker; called from the message pump it would deadlock the pump
|
|
851
|
+
it is waiting on, and Textual refuses rather than hanging. `_handle_command` runs on the
|
|
852
|
+
pump. So does Textual's action dispatch, which meant the same defect existed at four
|
|
853
|
+
call sites, not the one in the traceback — including the provider form reachable from
|
|
854
|
+
`tl config --edit`, a path the crash report never touched.
|
|
855
|
+
|
|
856
|
+
The correct pattern was eleven lines away in the same file: `_permission_worker` is
|
|
857
|
+
`@work`-decorated, which is exactly why *its* `push_screen_wait` works.
|
|
858
|
+
|
|
859
|
+
Two things this cost, both worth stating. The tests verified the layer *below* the bug —
|
|
860
|
+
that the command produced the right instruction, never that the app could carry it out.
|
|
861
|
+
And **0.1.4 shipped this way**, so `pip install turnloop==0.1.4` crashes on `/config`.
|
|
862
|
+
Fixed in 0.1.5. The tests now drive a real app through Textual's `run_test()` pilot and
|
|
863
|
+
fail if the decorators are removed.
|
|
864
|
+
|
|
865
|
+
**The lesson, five times over in one project:** an 84-run sweep failing while printing
|
|
844
866
|
a tidy report of zeros; a tool returning empty with `ok: true`; a sweep sitting at
|
|
845
867
|
10/18 doing nothing for 25 minutes; an adapter passing its live test and failing on
|
|
846
|
-
the turn the test never made
|
|
847
|
-
|
|
868
|
+
the turn the test never made; a feature with green tests that crashed on its first
|
|
869
|
+
keystroke. Each was caught only by looking past the summary line — and the last two
|
|
870
|
+
only by using the thing instead of testing it.
|
|
848
871
|
|
|
849
872
|
---
|
|
850
873
|
|
|
@@ -878,7 +901,7 @@ one-line edit in a CRLF checkout does not produce a whole-file diff.
|
|
|
878
901
|
## Testing
|
|
879
902
|
|
|
880
903
|
```bash
|
|
881
|
-
pytest #
|
|
904
|
+
pytest # 290 tests, no network
|
|
882
905
|
ruff check turnloop
|
|
883
906
|
mypy turnloop
|
|
884
907
|
```
|
|
@@ -897,7 +920,7 @@ mypy turnloop
|
|
|
897
920
|
| `test_configio.py` | 15 | minimal-diff writes, atomic save, secret-bearing MCP targets, the settings deny rules |
|
|
898
921
|
| `test_config_screen.py` | 10 | provider form validation, caps derivation, env indicator leaks |
|
|
899
922
|
| `test_websearch.py` | 9 | HTML parsing, backend selection, truncation, failure modes |
|
|
900
|
-
| `test_tui.py` |
|
|
923
|
+
| `test_tui.py` | 15 | Textual snapshots, `/config` and `/mcp add` driven through a real app |
|
|
901
924
|
|
|
902
925
|
`conftest.py` monkeypatches httpx's transport to raise unless a test is marked
|
|
903
926
|
`live` — **network is off at the transport layer**, so a forgotten real call fails
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "turnloop"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.5"
|
|
8
8
|
description = "A local-first agentic coding harness: streaming tool loop, permission gating, context compaction, subagents, and any LLM provider."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""TUI tests driven through Textual's pilot.
|
|
2
|
+
|
|
3
|
+
The assertions worth having here are about the concurrency contract, not about
|
|
4
|
+
pixels: a turn runs without blocking the UI, a permission prompt reaches the modal
|
|
5
|
+
and its answer reaches the parked agent task, and typing during a turn queues
|
|
6
|
+
rather than being dropped.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from turnloop.providers.mock import MockProvider, ScriptTurn
|
|
12
|
+
from turnloop.tui.app import TurnloopApp
|
|
13
|
+
from turnloop.tui.widgets.status import StatusBar
|
|
14
|
+
from turnloop.tui.widgets.transcript import Transcript
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def app_with(settings, project, turns) -> TurnloopApp:
|
|
18
|
+
app = TurnloopApp(settings=settings, cwd=project, resume=None)
|
|
19
|
+
# Swap in a scripted provider; the loop holds the reference.
|
|
20
|
+
provider = MockProvider(mode="scripted", script=turns)
|
|
21
|
+
app.agent.provider = provider
|
|
22
|
+
app.agent.loop.provider = provider
|
|
23
|
+
return app
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def test_a_turn_streams_into_the_transcript(settings, project):
|
|
27
|
+
app = app_with(settings, project, [ScriptTurn(text="Hello from the model.")])
|
|
28
|
+
async with app.run_test() as pilot:
|
|
29
|
+
await pilot.press(*"hi")
|
|
30
|
+
await pilot.press("enter")
|
|
31
|
+
await pilot.pause()
|
|
32
|
+
for _ in range(40):
|
|
33
|
+
await pilot.pause()
|
|
34
|
+
if not app._busy:
|
|
35
|
+
break
|
|
36
|
+
rendered = _transcript_text(app)
|
|
37
|
+
assert "Hello from the model." in rendered
|
|
38
|
+
assert "› hi" in rendered
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def test_a_permission_prompt_reaches_the_modal_and_unblocks_the_agent(settings, project):
|
|
42
|
+
"""The agent task parks on ask(); the UI stays live and answers it."""
|
|
43
|
+
app = app_with(
|
|
44
|
+
settings,
|
|
45
|
+
project,
|
|
46
|
+
[
|
|
47
|
+
ScriptTurn(tools=[("Write", {"file_path": "made.txt", "content": "x"})]),
|
|
48
|
+
ScriptTurn(text="Created it."),
|
|
49
|
+
],
|
|
50
|
+
)
|
|
51
|
+
async with app.run_test() as pilot:
|
|
52
|
+
await pilot.press(*"go")
|
|
53
|
+
await pilot.press("enter")
|
|
54
|
+
|
|
55
|
+
for _ in range(60):
|
|
56
|
+
await pilot.pause()
|
|
57
|
+
if app.screen is not app.screen_stack[0]:
|
|
58
|
+
break
|
|
59
|
+
assert app.screen is not app.screen_stack[0], "the modal never appeared"
|
|
60
|
+
|
|
61
|
+
await pilot.press("y")
|
|
62
|
+
for _ in range(80):
|
|
63
|
+
await pilot.pause()
|
|
64
|
+
if not app._busy:
|
|
65
|
+
break
|
|
66
|
+
|
|
67
|
+
assert (project / "made.txt").exists()
|
|
68
|
+
assert "Created it." in _transcript_text(app)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def test_declining_a_prompt_leaves_the_file_alone(settings, project):
|
|
72
|
+
app = app_with(
|
|
73
|
+
settings,
|
|
74
|
+
project,
|
|
75
|
+
[
|
|
76
|
+
ScriptTurn(tools=[("Write", {"file_path": "nope.txt", "content": "x"})]),
|
|
77
|
+
ScriptTurn(text="Understood."),
|
|
78
|
+
],
|
|
79
|
+
)
|
|
80
|
+
async with app.run_test() as pilot:
|
|
81
|
+
await pilot.press(*"go")
|
|
82
|
+
await pilot.press("enter")
|
|
83
|
+
for _ in range(60):
|
|
84
|
+
await pilot.pause()
|
|
85
|
+
if app.screen is not app.screen_stack[0]:
|
|
86
|
+
break
|
|
87
|
+
await pilot.press("n")
|
|
88
|
+
for _ in range(80):
|
|
89
|
+
await pilot.pause()
|
|
90
|
+
if not app._busy:
|
|
91
|
+
break
|
|
92
|
+
assert not (project / "nope.txt").exists()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def test_typing_during_a_turn_queues_the_message(settings, project):
|
|
96
|
+
app = app_with(
|
|
97
|
+
settings,
|
|
98
|
+
project,
|
|
99
|
+
[ScriptTurn(text="first"), ScriptTurn(text="second")],
|
|
100
|
+
)
|
|
101
|
+
async with app.run_test() as pilot:
|
|
102
|
+
app._busy = True # simulate an in-flight turn
|
|
103
|
+
await pilot.press(*"later")
|
|
104
|
+
await pilot.press("enter")
|
|
105
|
+
await pilot.pause()
|
|
106
|
+
assert app._queued == ["later"]
|
|
107
|
+
assert "queued" in _transcript_text(app)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def test_slash_command_runs_without_reaching_the_model(settings, project):
|
|
111
|
+
app = app_with(settings, project, [ScriptTurn(text="unused")])
|
|
112
|
+
async with app.run_test() as pilot:
|
|
113
|
+
await pilot.press(*"/tools")
|
|
114
|
+
await pilot.press("enter")
|
|
115
|
+
await pilot.pause()
|
|
116
|
+
await pilot.pause()
|
|
117
|
+
assert "TodoWrite" in _transcript_text(app)
|
|
118
|
+
assert app.agent.provider.calls == 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def test_ctrl_p_cycles_permission_mode(settings, project):
|
|
122
|
+
app = app_with(settings, project, [ScriptTurn(text="x")])
|
|
123
|
+
async with app.run_test() as pilot:
|
|
124
|
+
assert app.query_one(StatusBar).mode == "default"
|
|
125
|
+
await pilot.press("ctrl+p")
|
|
126
|
+
await pilot.pause()
|
|
127
|
+
assert app.query_one(StatusBar).mode == "plan"
|
|
128
|
+
assert app.agent.permissions.mode == "plan"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def test_status_bar_reports_the_context_gauge(settings, project):
|
|
132
|
+
app = app_with(settings, project, [ScriptTurn(text="done")])
|
|
133
|
+
async with app.run_test() as pilot:
|
|
134
|
+
await pilot.press(*"hi")
|
|
135
|
+
await pilot.press("enter")
|
|
136
|
+
for _ in range(40):
|
|
137
|
+
await pilot.pause()
|
|
138
|
+
if not app._busy:
|
|
139
|
+
break
|
|
140
|
+
status = app.query_one(StatusBar)
|
|
141
|
+
assert status.context_tokens > 0
|
|
142
|
+
assert status.tokens_out > 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def test_cold_boot_panel_appears_and_clears(settings, project):
|
|
146
|
+
from turnloop.core.events import ProviderStatus, StatusUpdate
|
|
147
|
+
from turnloop.tui.widgets.status import BootPanel
|
|
148
|
+
|
|
149
|
+
app = app_with(settings, project, [ScriptTurn(text="x")])
|
|
150
|
+
async with app.run_test() as pilot:
|
|
151
|
+
boot = app.query_one("#boot", BootPanel)
|
|
152
|
+
assert not boot.display
|
|
153
|
+
|
|
154
|
+
await app.channel.send(ProviderStatus("cold boot", phase="cold_boot", elapsed_s=95))
|
|
155
|
+
await pilot.pause()
|
|
156
|
+
await pilot.pause()
|
|
157
|
+
assert boot.display
|
|
158
|
+
assert "01:35" in str(boot.render())
|
|
159
|
+
|
|
160
|
+
await app.channel.send(StatusUpdate(context_tokens=10, context_max=100))
|
|
161
|
+
await pilot.pause()
|
|
162
|
+
await pilot.pause()
|
|
163
|
+
assert not boot.display
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def test_slash_config_opens_and_dismisses_the_settings_screen(settings, project):
|
|
167
|
+
"""`/config` used to raise `NoActiveWorker` on the very first keystroke:
|
|
168
|
+
`_handle_command` runs on the message pump, and `push_screen_wait` deadlocks
|
|
169
|
+
that pump unless it runs inside a Textual worker. This drives the real app —
|
|
170
|
+
the dispatch-level test alone (`outcome.open_screen == "config"`) already
|
|
171
|
+
passed while this was broken, because nothing ever consumed the outcome.
|
|
172
|
+
"""
|
|
173
|
+
from turnloop.tui.config_screen import ConfigScreen
|
|
174
|
+
|
|
175
|
+
app = app_with(settings, project, [ScriptTurn(text="unused")])
|
|
176
|
+
async with app.run_test() as pilot:
|
|
177
|
+
base_screen = app.screen_stack[0]
|
|
178
|
+
await pilot.press(*"/config")
|
|
179
|
+
await pilot.press("enter")
|
|
180
|
+
|
|
181
|
+
for _ in range(40):
|
|
182
|
+
await pilot.pause()
|
|
183
|
+
if app.screen is not base_screen:
|
|
184
|
+
break
|
|
185
|
+
assert isinstance(app.screen, ConfigScreen), "the config screen never appeared"
|
|
186
|
+
|
|
187
|
+
await pilot.press("escape")
|
|
188
|
+
for _ in range(40):
|
|
189
|
+
await pilot.pause()
|
|
190
|
+
if app.screen is base_screen:
|
|
191
|
+
break
|
|
192
|
+
assert app.screen is base_screen, "dismissing the screen must return to the main screen"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def test_config_screen_add_provider_opens_the_provider_form(settings, project):
|
|
196
|
+
"""`ConfigScreen.action_add_provider` (key `a`) has the identical
|
|
197
|
+
`push_screen_wait`-outside-a-worker defect as `/config` itself — a
|
|
198
|
+
different call path (Textual's action dispatch, not `_handle_command`) but
|
|
199
|
+
the same bug. This confirms the standalone `tl config --edit` screen (which
|
|
200
|
+
is this exact class) does not crash on first use either.
|
|
201
|
+
"""
|
|
202
|
+
from textual.widgets import Button, DataTable
|
|
203
|
+
|
|
204
|
+
from turnloop.tui.config_screen import ConfigScreen, ProviderFormModal
|
|
205
|
+
|
|
206
|
+
app = app_with(settings, project, [ScriptTurn(text="unused")])
|
|
207
|
+
async with app.run_test() as pilot:
|
|
208
|
+
base_screen = app.screen_stack[0]
|
|
209
|
+
await pilot.press(*"/config")
|
|
210
|
+
await pilot.press("enter")
|
|
211
|
+
for _ in range(40):
|
|
212
|
+
await pilot.pause()
|
|
213
|
+
if isinstance(app.screen, ConfigScreen):
|
|
214
|
+
break
|
|
215
|
+
config_screen = app.screen
|
|
216
|
+
assert isinstance(config_screen, ConfigScreen)
|
|
217
|
+
|
|
218
|
+
config_screen.set_focus(config_screen.query_one("#providers-table", DataTable))
|
|
219
|
+
await pilot.pause()
|
|
220
|
+
await pilot.press("a")
|
|
221
|
+
|
|
222
|
+
for _ in range(40):
|
|
223
|
+
await pilot.pause()
|
|
224
|
+
if isinstance(app.screen, ProviderFormModal):
|
|
225
|
+
break
|
|
226
|
+
assert isinstance(app.screen, ProviderFormModal), "the provider form never appeared"
|
|
227
|
+
|
|
228
|
+
app.screen.query_one("#pcancel", Button).press()
|
|
229
|
+
for _ in range(40):
|
|
230
|
+
await pilot.pause()
|
|
231
|
+
if app.screen is config_screen:
|
|
232
|
+
break
|
|
233
|
+
assert app.screen is config_screen
|
|
234
|
+
|
|
235
|
+
await pilot.press("escape")
|
|
236
|
+
for _ in range(40):
|
|
237
|
+
await pilot.pause()
|
|
238
|
+
if app.screen is base_screen:
|
|
239
|
+
break
|
|
240
|
+
assert app.screen is base_screen
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
async def test_slash_mcp_add_opens_and_dismisses_the_server_form(settings, project):
|
|
244
|
+
from textual.widgets import Button
|
|
245
|
+
|
|
246
|
+
from turnloop.tui.mcp_screen import ServerFormModal
|
|
247
|
+
|
|
248
|
+
app = app_with(settings, project, [ScriptTurn(text="unused")])
|
|
249
|
+
async with app.run_test() as pilot:
|
|
250
|
+
base_screen = app.screen_stack[0]
|
|
251
|
+
await pilot.press(*"/mcp add")
|
|
252
|
+
await pilot.press("enter")
|
|
253
|
+
|
|
254
|
+
for _ in range(40):
|
|
255
|
+
await pilot.pause()
|
|
256
|
+
if app.screen is not base_screen:
|
|
257
|
+
break
|
|
258
|
+
assert isinstance(app.screen, ServerFormModal), "the mcp add form never appeared"
|
|
259
|
+
|
|
260
|
+
app.screen.query_one("#cancel", Button).press()
|
|
261
|
+
for _ in range(40):
|
|
262
|
+
await pilot.pause()
|
|
263
|
+
if app.screen is base_screen:
|
|
264
|
+
break
|
|
265
|
+
assert app.screen is base_screen
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
async def test_mcp_editor_app_action_add_opens_the_server_form():
|
|
269
|
+
"""The standalone `tl mcp` app has its own `action_add` binding, dispatched
|
|
270
|
+
through the exact same Textual action-dispatch path as `/config`'s bug —
|
|
271
|
+
confirms the fix covers `McpEditorApp` too, not just the in-TUI path."""
|
|
272
|
+
from textual.widgets import Button
|
|
273
|
+
|
|
274
|
+
from turnloop.config import default_settings
|
|
275
|
+
from turnloop.tui.mcp_screen import McpEditorApp, ServerFormModal
|
|
276
|
+
|
|
277
|
+
app = McpEditorApp(default_settings())
|
|
278
|
+
async with app.run_test() as pilot:
|
|
279
|
+
base_screen = app.screen_stack[0]
|
|
280
|
+
await pilot.press("a")
|
|
281
|
+
|
|
282
|
+
for _ in range(40):
|
|
283
|
+
await pilot.pause()
|
|
284
|
+
if app.screen is not base_screen:
|
|
285
|
+
break
|
|
286
|
+
assert isinstance(app.screen, ServerFormModal), "the mcp add form never appeared"
|
|
287
|
+
|
|
288
|
+
app.screen.query_one("#cancel", Button).press()
|
|
289
|
+
for _ in range(40):
|
|
290
|
+
await pilot.pause()
|
|
291
|
+
if app.screen is base_screen:
|
|
292
|
+
break
|
|
293
|
+
assert app.screen is base_screen
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
async def test_escape_in_provider_form_returns_to_config_screen_only(settings, project):
|
|
297
|
+
"""Escape must dismiss only the top modal (`ProviderFormModal`), landing back
|
|
298
|
+
on `ConfigScreen` — which is itself still open, not also dismissed by the
|
|
299
|
+
same keypress bubbling into its own escape binding.
|
|
300
|
+
"""
|
|
301
|
+
from textual.widgets import DataTable
|
|
302
|
+
|
|
303
|
+
from turnloop.tui.config_screen import ConfigScreen, ProviderFormModal
|
|
304
|
+
|
|
305
|
+
app = app_with(settings, project, [ScriptTurn(text="unused")])
|
|
306
|
+
async with app.run_test() as pilot:
|
|
307
|
+
base_screen = app.screen_stack[0]
|
|
308
|
+
await pilot.press(*"/config")
|
|
309
|
+
await pilot.press("enter")
|
|
310
|
+
for _ in range(40):
|
|
311
|
+
await pilot.pause()
|
|
312
|
+
if isinstance(app.screen, ConfigScreen):
|
|
313
|
+
break
|
|
314
|
+
config_screen = app.screen
|
|
315
|
+
assert isinstance(config_screen, ConfigScreen)
|
|
316
|
+
|
|
317
|
+
config_screen.set_focus(config_screen.query_one("#providers-table", DataTable))
|
|
318
|
+
await pilot.pause()
|
|
319
|
+
await pilot.press("a")
|
|
320
|
+
for _ in range(40):
|
|
321
|
+
await pilot.pause()
|
|
322
|
+
if isinstance(app.screen, ProviderFormModal):
|
|
323
|
+
break
|
|
324
|
+
assert isinstance(app.screen, ProviderFormModal), "the provider form never appeared"
|
|
325
|
+
|
|
326
|
+
await pilot.press("escape")
|
|
327
|
+
for _ in range(40):
|
|
328
|
+
await pilot.pause()
|
|
329
|
+
if app.screen is not None and not isinstance(app.screen, ProviderFormModal):
|
|
330
|
+
break
|
|
331
|
+
|
|
332
|
+
assert app.screen is config_screen, "escape must land back on ConfigScreen, still open"
|
|
333
|
+
assert app.screen is not base_screen, "a single escape must not also close ConfigScreen"
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
async def test_escape_in_mcp_editor_add_form_returns_to_the_table_without_quitting(settings, project):
|
|
337
|
+
"""`McpEditorApp` binds its own top-level escape to `action_quit_app`. Without
|
|
338
|
+
a binding on `ServerFormModal` itself, an unhandled escape bubbles straight
|
|
339
|
+
past the modal to that App binding and quits the whole standalone `tl mcp`
|
|
340
|
+
screen — a materially worse bug than "escape does nothing".
|
|
341
|
+
"""
|
|
342
|
+
from turnloop.config import default_settings
|
|
343
|
+
from turnloop.tui.mcp_screen import McpEditorApp, ServerFormModal
|
|
344
|
+
|
|
345
|
+
app = McpEditorApp(default_settings())
|
|
346
|
+
async with app.run_test() as pilot:
|
|
347
|
+
base_screen = app.screen_stack[0]
|
|
348
|
+
await pilot.press("a")
|
|
349
|
+
for _ in range(40):
|
|
350
|
+
await pilot.pause()
|
|
351
|
+
if isinstance(app.screen, ServerFormModal):
|
|
352
|
+
break
|
|
353
|
+
assert isinstance(app.screen, ServerFormModal), "the mcp add form never appeared"
|
|
354
|
+
|
|
355
|
+
await pilot.press("escape")
|
|
356
|
+
for _ in range(40):
|
|
357
|
+
await pilot.pause()
|
|
358
|
+
if not isinstance(app.screen, ServerFormModal):
|
|
359
|
+
break
|
|
360
|
+
|
|
361
|
+
assert app.is_running, "escape must close only the form, not quit the app"
|
|
362
|
+
assert app.screen is base_screen, "escape must land back on the server table"
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
async def test_escape_in_mcp_add_form_returns_to_the_main_tui_screen(settings, project):
|
|
366
|
+
"""`/mcp add` pushes `ServerFormModal` straight onto the main `TurnloopApp`
|
|
367
|
+
screen (no intermediate `McpEditorApp`) — escape there must return to the
|
|
368
|
+
running session, not exit turnloop or leave the modal stuck.
|
|
369
|
+
"""
|
|
370
|
+
from turnloop.tui.mcp_screen import ServerFormModal
|
|
371
|
+
|
|
372
|
+
app = app_with(settings, project, [ScriptTurn(text="unused")])
|
|
373
|
+
async with app.run_test() as pilot:
|
|
374
|
+
base_screen = app.screen_stack[0]
|
|
375
|
+
await pilot.press(*"/mcp add")
|
|
376
|
+
await pilot.press("enter")
|
|
377
|
+
for _ in range(40):
|
|
378
|
+
await pilot.pause()
|
|
379
|
+
if isinstance(app.screen, ServerFormModal):
|
|
380
|
+
break
|
|
381
|
+
assert isinstance(app.screen, ServerFormModal), "the mcp add form never appeared"
|
|
382
|
+
|
|
383
|
+
await pilot.press("escape")
|
|
384
|
+
for _ in range(40):
|
|
385
|
+
await pilot.pause()
|
|
386
|
+
if not isinstance(app.screen, ServerFormModal):
|
|
387
|
+
break
|
|
388
|
+
|
|
389
|
+
assert app.is_running, "escape must not quit the running session"
|
|
390
|
+
assert app.screen is base_screen, "escape must return to the main TUI screen"
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _transcript_text(app: TurnloopApp) -> str:
|
|
394
|
+
transcript = app.query_one(Transcript)
|
|
395
|
+
from textual.widgets import Static
|
|
396
|
+
|
|
397
|
+
return "\n".join(str(w.render()) for w in transcript.query(Static))
|
|
@@ -295,16 +295,32 @@ class TurnloopApp(App):
|
|
|
295
295
|
else:
|
|
296
296
|
self._start_turn(outcome.prompt)
|
|
297
297
|
if outcome.open_screen == "config":
|
|
298
|
-
|
|
298
|
+
# Fire-and-forget: `_open_config_screen` is a `@work`-decorated
|
|
299
|
+
# method, so calling it returns a Worker (not a coroutine) and
|
|
300
|
+
# runs the body — including its `push_screen_wait` — inside real
|
|
301
|
+
# worker context. Everything the result needs (the transcript
|
|
302
|
+
# note, applying the patch) already happens inside that worker;
|
|
303
|
+
# `_handle_command` itself has nothing left to do afterwards, so
|
|
304
|
+
# there is nothing here worth `.wait()`-ing on.
|
|
305
|
+
self._open_config_screen()
|
|
299
306
|
elif outcome.open_screen == "mcp_add":
|
|
300
|
-
|
|
307
|
+
self._open_mcp_add_screen()
|
|
301
308
|
|
|
302
309
|
# --- settings screens ---------------------------------------------------
|
|
303
310
|
#
|
|
304
311
|
# Reached only from `_handle_command`, which is reached only from the
|
|
305
312
|
# human's Input widget (see CommandOutcome.open_screen's docstring for the
|
|
306
313
|
# full argument). Nothing here is importable from a tool.
|
|
307
|
-
|
|
314
|
+
#
|
|
315
|
+
# Both are `@work`-decorated for the same reason `_permission_worker`
|
|
316
|
+
# (above) is: `push_screen_wait` blocks the calling coroutine while
|
|
317
|
+
# keeping the event loop alive, which requires running inside a Textual
|
|
318
|
+
# worker or it deadlocks the very message pump it is waiting on
|
|
319
|
+
# (`NoActiveWorker`). `_handle_command` runs directly on that pump, so it
|
|
320
|
+
# cannot call `push_screen_wait` itself — it has to hand the work to one
|
|
321
|
+
# of these instead.
|
|
322
|
+
|
|
323
|
+
@work(exclusive=False, thread=False, name="config-screen")
|
|
308
324
|
async def _open_config_screen(self) -> None:
|
|
309
325
|
from turnloop.tui.config_screen import ConfigScreen
|
|
310
326
|
|
|
@@ -319,6 +335,7 @@ class TurnloopApp(App):
|
|
|
319
335
|
message += f" — restart turnloop to apply: {', '.join(restart_for)}"
|
|
320
336
|
await transcript.add_note(message)
|
|
321
337
|
|
|
338
|
+
@work(exclusive=False, thread=False, name="mcp-add-screen")
|
|
322
339
|
async def _open_mcp_add_screen(self) -> None:
|
|
323
340
|
from turnloop.config import MCPServerConfig
|
|
324
341
|
from turnloop.configio import local_settings_path, write_mcp_server
|
|
@@ -22,6 +22,7 @@ import os
|
|
|
22
22
|
from pathlib import Path
|
|
23
23
|
from typing import Any
|
|
24
24
|
|
|
25
|
+
from textual import work
|
|
25
26
|
from textual.app import App, ComposeResult
|
|
26
27
|
from textual.containers import Horizontal, VerticalScroll
|
|
27
28
|
from textual.screen import ModalScreen
|
|
@@ -116,6 +117,15 @@ def build_provider_dict(existing: dict[str, Any], *, kind: str, model: str,
|
|
|
116
117
|
class ProviderFormModal(ModalScreen[dict | None]):
|
|
117
118
|
"""Add or edit one provider. Dismisses with `{"name": ..., "provider": {...}}` or None."""
|
|
118
119
|
|
|
120
|
+
# Bound here, on the modal itself, rather than left to bubble: this is the
|
|
121
|
+
# active screen while the form is open, so Textual checks its BINDINGS
|
|
122
|
+
# before anything below it in the stack (`ConfigScreen`, which also binds
|
|
123
|
+
# escape) or the App. Without this, escape here is at best a dead key and
|
|
124
|
+
# at worst reaches a parent App's own escape binding — `McpEditorApp`
|
|
125
|
+
# binds escape to quit. Consuming it here keeps the behavior identical
|
|
126
|
+
# regardless of which screen pushed this one.
|
|
127
|
+
BINDINGS = [("escape", "cancel", "Cancel")]
|
|
128
|
+
|
|
119
129
|
CSS = """
|
|
120
130
|
ProviderFormModal { align: center middle; }
|
|
121
131
|
#pform { width: 92%; max-width: 100; height: auto; max-height: 92%;
|
|
@@ -172,10 +182,13 @@ class ProviderFormModal(ModalScreen[dict | None]):
|
|
|
172
182
|
|
|
173
183
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
174
184
|
if event.button.id == "pcancel":
|
|
175
|
-
self.
|
|
185
|
+
self.action_cancel()
|
|
176
186
|
else:
|
|
177
187
|
self._save()
|
|
178
188
|
|
|
189
|
+
def action_cancel(self) -> None:
|
|
190
|
+
self.dismiss(None)
|
|
191
|
+
|
|
179
192
|
def _error(self, message: str) -> None:
|
|
180
193
|
self.query_one("#pstatus", Static).update(message)
|
|
181
194
|
|
|
@@ -313,6 +326,11 @@ class ConfigScreen(ModalScreen[tuple[Path, dict] | None]):
|
|
|
313
326
|
return None
|
|
314
327
|
return self._provider_names[table.cursor_row]
|
|
315
328
|
|
|
329
|
+
# `push_screen_wait` requires worker context or it deadlocks the message
|
|
330
|
+
# pump that key-bound actions dispatch on (`NoActiveWorker`). `@work`
|
|
331
|
+
# makes calling these return a Worker instead of running inline; Textual's
|
|
332
|
+
# action dispatch handles that fine (see `textual._callback.invoke`).
|
|
333
|
+
@work(exclusive=False, thread=False, name="provider-add")
|
|
316
334
|
async def action_add_provider(self) -> None:
|
|
317
335
|
result = await self.app.push_screen_wait(
|
|
318
336
|
ProviderFormModal("", None, set(self._providers))
|
|
@@ -321,6 +339,7 @@ class ConfigScreen(ModalScreen[tuple[Path, dict] | None]):
|
|
|
321
339
|
self._providers[result["name"]] = result["provider"]
|
|
322
340
|
self._reload_providers_table()
|
|
323
341
|
|
|
342
|
+
@work(exclusive=False, thread=False, name="provider-edit")
|
|
324
343
|
async def action_edit_provider(self) -> None:
|
|
325
344
|
name = self._selected_provider_name()
|
|
326
345
|
if not name:
|