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.
Files changed (156) hide show
  1. {turnloop-0.1.4 → turnloop-0.1.5}/PKG-INFO +31 -8
  2. {turnloop-0.1.4 → turnloop-0.1.5}/README.md +30 -7
  3. {turnloop-0.1.4 → turnloop-0.1.5}/pyproject.toml +1 -1
  4. turnloop-0.1.5/tests/test_tui.py +397 -0
  5. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/__init__.py +1 -1
  6. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tui/app.py +20 -3
  7. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tui/config_screen.py +20 -1
  8. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tui/mcp_screen.py +363 -344
  9. turnloop-0.1.4/tests/test_tui.py +0 -170
  10. {turnloop-0.1.4 → turnloop-0.1.5}/.github/workflows/ci.yml +0 -0
  11. {turnloop-0.1.4 → turnloop-0.1.5}/.gitignore +0 -0
  12. {turnloop-0.1.4 → turnloop-0.1.5}/.turnloop/.gitignore +0 -0
  13. {turnloop-0.1.4 → turnloop-0.1.5}/tests/conftest.py +0 -0
  14. {turnloop-0.1.4 → turnloop-0.1.5}/tests/fixtures/anthropic_tool_call.sse +0 -0
  15. {turnloop-0.1.4 → turnloop-0.1.5}/tests/fixtures/glm_tool_call.sse +0 -0
  16. {turnloop-0.1.4 → turnloop-0.1.5}/tests/fixtures/mcp_echo_server.py +0 -0
  17. {turnloop-0.1.4 → turnloop-0.1.5}/tests/test_bash.py +0 -0
  18. {turnloop-0.1.4 → turnloop-0.1.5}/tests/test_compaction.py +0 -0
  19. {turnloop-0.1.4 → turnloop-0.1.5}/tests/test_config.py +0 -0
  20. {turnloop-0.1.4 → turnloop-0.1.5}/tests/test_config_screen.py +0 -0
  21. {turnloop-0.1.4 → turnloop-0.1.5}/tests/test_configio.py +0 -0
  22. {turnloop-0.1.4 → turnloop-0.1.5}/tests/test_experiments.py +0 -0
  23. {turnloop-0.1.4 → turnloop-0.1.5}/tests/test_hooks_mcp_commands.py +0 -0
  24. {turnloop-0.1.4 → turnloop-0.1.5}/tests/test_loop.py +0 -0
  25. {turnloop-0.1.4 → turnloop-0.1.5}/tests/test_permissions.py +0 -0
  26. {turnloop-0.1.4 → turnloop-0.1.5}/tests/test_providers.py +0 -0
  27. {turnloop-0.1.4 → turnloop-0.1.5}/tests/test_tools_files.py +0 -0
  28. {turnloop-0.1.4 → turnloop-0.1.5}/tests/test_websearch.py +0 -0
  29. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/__main__.py +0 -0
  30. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/agent/__init__.py +0 -0
  31. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/agent/factory.py +0 -0
  32. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/agent/headless.py +0 -0
  33. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/agent/loop.py +0 -0
  34. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/agent/subagent.py +0 -0
  35. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/agent/system_prompt.py +0 -0
  36. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/cli.py +0 -0
  37. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/commands/__init__.py +0 -0
  38. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/commands/dispatch.py +0 -0
  39. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/commands/loader.py +0 -0
  40. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/config.py +0 -0
  41. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/configio.py +0 -0
  42. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/context/__init__.py +0 -0
  43. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/context/budget.py +0 -0
  44. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/context/compaction.py +0 -0
  45. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/context/memory.py +0 -0
  46. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/core/__init__.py +0 -0
  47. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/core/events.py +0 -0
  48. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/core/ids.py +0 -0
  49. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/core/messages.py +0 -0
  50. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/core/tokens.py +0 -0
  51. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/diagnostics.py +0 -0
  52. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/errors.py +0 -0
  53. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/__init__.py +0 -0
  54. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/configs/bakeoff.yaml +0 -0
  55. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/configs/ceiling-check.yaml +0 -0
  56. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/configs/context-glm.yaml +0 -0
  57. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/configs/context.yaml +0 -0
  58. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/configs/glm-selfhosted.yaml +0 -0
  59. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/configs/groq-free.yaml +0 -0
  60. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/configs/hard-calibration.yaml +0 -0
  61. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/configs/hard-glm.yaml +0 -0
  62. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/configs/loop.yaml +0 -0
  63. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/configs/nvidia-smoke.yaml +0 -0
  64. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/configs/reliability.yaml +0 -0
  65. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/configs/rewrite-calibration.yaml +0 -0
  66. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/configs/smoke.yaml +0 -0
  67. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/graders.py +0 -0
  68. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/report.py +0 -0
  69. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/runner.py +0 -0
  70. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/default.yaml +0 -0
  71. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/bulky/module_1.py +0 -0
  72. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/bulky/module_2.py +0 -0
  73. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/bulky/module_3.py +0 -0
  74. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/bulky/module_4.py +0 -0
  75. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/bulky/module_5.py +0 -0
  76. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/bulky/module_6.py +0 -0
  77. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/bulky/module_7.py +0 -0
  78. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/bulky/module_8.py +0 -0
  79. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/circular_import/models.py +0 -0
  80. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/circular_import/services.py +0 -0
  81. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +0 -0
  82. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/cli_flag/app.py +0 -0
  83. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +0 -0
  84. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +0 -0
  85. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +0 -0
  86. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +0 -0
  87. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/empty/.keep +0 -0
  88. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/failing_test/calc.py +0 -0
  89. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/failing_test/test_calc.py +0 -0
  90. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/generate_bulky.py +0 -0
  91. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/grep_edit/ingest.py +0 -0
  92. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/grep_edit/load.py +0 -0
  93. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/grep_edit/transform.py +0 -0
  94. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +0 -0
  95. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +0 -0
  96. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +0 -0
  97. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +0 -0
  98. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +0 -0
  99. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/needle/inventory.py +0 -0
  100. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/needle/pricing.py +0 -0
  101. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/needle/shipping.py +0 -0
  102. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +0 -0
  103. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/regression_trap/validators.py +0 -0
  104. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/rename/billing.py +0 -0
  105. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/rename/report.py +0 -0
  106. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/rename/test_billing.py +0 -0
  107. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/spec/parser.py +0 -0
  108. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/spec/test_parser.py +0 -0
  109. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +0 -0
  110. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +0 -0
  111. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/experiments/suite/fixtures/util/util.py +0 -0
  112. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/hooks/__init__.py +0 -0
  113. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/hooks/runner.py +0 -0
  114. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/mcp/__init__.py +0 -0
  115. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/mcp/adapter.py +0 -0
  116. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/mcp/client.py +0 -0
  117. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/permissions/__init__.py +0 -0
  118. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/permissions/engine.py +0 -0
  119. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/permissions/rules.py +0 -0
  120. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/providers/__init__.py +0 -0
  121. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/providers/anthropic.py +0 -0
  122. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/providers/base.py +0 -0
  123. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/providers/gemini.py +0 -0
  124. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/providers/mock.py +0 -0
  125. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/providers/openai_compat.py +0 -0
  126. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/providers/pricing.py +0 -0
  127. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/providers/registry.py +0 -0
  128. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/providers/sse.py +0 -0
  129. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/sessions/__init__.py +0 -0
  130. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/sessions/models.py +0 -0
  131. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/sessions/store.py +0 -0
  132. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/__init__.py +0 -0
  133. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/ask.py +0 -0
  134. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/base.py +0 -0
  135. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/bash.py +0 -0
  136. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/builtin.py +0 -0
  137. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/edit.py +0 -0
  138. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/glob.py +0 -0
  139. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/grep.py +0 -0
  140. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/read.py +0 -0
  141. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/runner.py +0 -0
  142. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/shell.py +0 -0
  143. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/skill.py +0 -0
  144. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/task.py +0 -0
  145. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/textio.py +0 -0
  146. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/todo.py +0 -0
  147. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/webfetch.py +0 -0
  148. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/websearch.py +0 -0
  149. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tools/write.py +0 -0
  150. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tui/__init__.py +0 -0
  151. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tui/app.tcss +0 -0
  152. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tui/bridge.py +0 -0
  153. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tui/widgets/__init__.py +0 -0
  154. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tui/widgets/permission.py +0 -0
  155. {turnloop-0.1.4 → turnloop-0.1.5}/turnloop/tui/widgets/status.py +0 -0
  156. {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.4
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, 255 tests, no network in the default test run.
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.4
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
- **The lesson, four times over in one project:** an 84-run sweep failing while printing
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. Each was caught only by looking past the summary line —
876
- and the last one only by using the thing instead of testing it.
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 # 283 tests, no network
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` | 8 | Textual snapshots |
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, 255 tests, no network in the default test run.
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.4
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
- **The lesson, four times over in one project:** an 84-run sweep failing while printing
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. Each was caught only by looking past the summary line —
847
- and the last one only by using the thing instead of testing it.
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 # 283 tests, no network
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` | 8 | Textual snapshots |
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.4"
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))
@@ -1,3 +1,3 @@
1
1
  """turnloop — a local-first agentic coding harness."""
2
2
 
3
- __version__ = "0.1.4"
3
+ __version__ = "0.1.5"
@@ -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
- await self._open_config_screen()
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
- await self._open_mcp_add_screen()
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.dismiss(None)
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: