lcode-cli 0.32.0__tar.gz → 0.33.0__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 (169) hide show
  1. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/CHANGELOG.md +19 -1
  2. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/PKG-INFO +1 -1
  3. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/quickstart.md +1 -1
  4. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/usage.md +42 -3
  5. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/__init__.py +1 -1
  6. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/agent.py +50 -14
  7. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/permissions.py +3 -1
  8. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/repl.py +30 -4
  9. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/sessions.py +3 -1
  10. lcode_cli-0.33.0/src/lcode/steer.py +287 -0
  11. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/tools.py +4 -0
  12. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_sessions.py +2 -0
  13. lcode_cli-0.33.0/tests/test_steer.py +372 -0
  14. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/.editorconfig +0 -0
  15. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/.github/CODEOWNERS +0 -0
  16. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  17. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  18. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  19. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
  20. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  21. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/.github/dependabot.yml +0 -0
  22. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/.github/workflows/ci.yml +0 -0
  23. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/.github/workflows/docs.yml +0 -0
  24. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/.github/workflows/release.yml +0 -0
  25. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/.gitignore +0 -0
  26. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/.pre-commit-config.yaml +0 -0
  27. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/AGENTS.md +0 -0
  28. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/CODE_OF_CONDUCT.md +0 -0
  29. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/CONTRIBUTING.md +0 -0
  30. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/LICENSE +0 -0
  31. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/README.md +0 -0
  32. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/SECURITY.md +0 -0
  33. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/action.yml +0 -0
  34. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/agents.md +0 -0
  35. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/assets/demo.svg +0 -0
  36. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/assets/extra.css +0 -0
  37. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/assets/logo.svg +0 -0
  38. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/assets/social-preview.png +0 -0
  39. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/automation.md +0 -0
  40. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/bench.md +0 -0
  41. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/changelog.md +0 -0
  42. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/commands.md +0 -0
  43. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/configuration.md +0 -0
  44. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/contributing.md +0 -0
  45. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/editors.md +0 -0
  46. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/git.md +0 -0
  47. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/hooks.md +0 -0
  48. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/how-it-works.md +0 -0
  49. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/index.md +0 -0
  50. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/installation.md +0 -0
  51. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/lsp.md +0 -0
  52. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/mcp.md +0 -0
  53. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/memory.md +0 -0
  54. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/models.md +0 -0
  55. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/sandbox.md +0 -0
  56. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/search.md +0 -0
  57. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/servers.md +0 -0
  58. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/docs/troubleshooting.md +0 -0
  59. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/examples/README.md +0 -0
  60. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/examples/api/fix_tests.py +0 -0
  61. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/examples/commands/review.md +0 -0
  62. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/examples/skills/commit-message/SKILL.md +0 -0
  63. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
  64. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/install.sh +0 -0
  65. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/mkdocs.yml +0 -0
  66. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/overrides/main.html +0 -0
  67. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/pyproject.toml +0 -0
  68. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/scripts/record_demo.py +0 -0
  69. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/scripts/social-preview.html +0 -0
  70. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/__main__.py +0 -0
  71. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/acp.py +0 -0
  72. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/action.py +0 -0
  73. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/api.py +0 -0
  74. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/backends.py +0 -0
  75. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/bench.py +0 -0
  76. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/bench_backend.py +0 -0
  77. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/bench_runtime.py +0 -0
  78. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/catalog.py +0 -0
  79. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/checkpoints.py +0 -0
  80. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/checks.py +0 -0
  81. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/cli.py +0 -0
  82. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/clipboard.py +0 -0
  83. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/codesearch.py +0 -0
  84. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/config.py +0 -0
  85. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/context.py +0 -0
  86. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/extensions.py +0 -0
  87. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/frontmatter.py +0 -0
  88. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/gitflow.py +0 -0
  89. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/hardware.py +0 -0
  90. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/hooks.py +0 -0
  91. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/jobs.py +0 -0
  92. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/limits.py +0 -0
  93. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/lsp.py +0 -0
  94. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/matching.py +0 -0
  95. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/mcp/__init__.py +0 -0
  96. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/mcp/auth.py +0 -0
  97. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/mcp/catalog.py +0 -0
  98. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/mcp/catalog.toml +0 -0
  99. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/mcp/client.py +0 -0
  100. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/mcp/commands.py +0 -0
  101. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/mcp/config.py +0 -0
  102. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/mcp/http.py +0 -0
  103. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/mcp/manager.py +0 -0
  104. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/mcp/protocol.py +0 -0
  105. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/mcp/server.py +0 -0
  106. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/mcp/stdio.py +0 -0
  107. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/memory.py +0 -0
  108. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/models.toml +0 -0
  109. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/notify.py +0 -0
  110. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/ollama.py +0 -0
  111. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/planning.py +0 -0
  112. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/render.py +0 -0
  113. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/repeats.py +0 -0
  114. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/repomap.py +0 -0
  115. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/residency.py +0 -0
  116. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/review.py +0 -0
  117. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/sandbox.py +0 -0
  118. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/secretscan.py +0 -0
  119. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/servers/__init__.py +0 -0
  120. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/servers/encord.py +0 -0
  121. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/servers/valohai.py +0 -0
  122. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/servers/xai.py +0 -0
  123. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/subagents.py +0 -0
  124. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/update.py +0 -0
  125. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/vision.py +0 -0
  126. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/src/lcode/web.py +0 -0
  127. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/conftest.py +0 -0
  128. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/fake_lsp_server.py +0 -0
  129. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/fake_mcp_server.py +0 -0
  130. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_acp.py +0 -0
  131. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_action.py +0 -0
  132. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_agent.py +0 -0
  133. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_api.py +0 -0
  134. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_backends.py +0 -0
  135. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_bench.py +0 -0
  136. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_catalog.py +0 -0
  137. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_checkpoints.py +0 -0
  138. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_checks.py +0 -0
  139. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_cli.py +0 -0
  140. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_clipboard.py +0 -0
  141. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_codesearch.py +0 -0
  142. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_config.py +0 -0
  143. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_context.py +0 -0
  144. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_context_picker.py +0 -0
  145. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_extensions.py +0 -0
  146. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_gitflow.py +0 -0
  147. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_hooks.py +0 -0
  148. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_jobs.py +0 -0
  149. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_lsp.py +0 -0
  150. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_matching.py +0 -0
  151. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_mcp.py +0 -0
  152. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_memory.py +0 -0
  153. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_permissions.py +0 -0
  154. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_plan_mode.py +0 -0
  155. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_repeats.py +0 -0
  156. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_repomap.py +0 -0
  157. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_residency.py +0 -0
  158. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_review.py +0 -0
  159. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_robustness.py +0 -0
  160. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_sandbox.py +0 -0
  161. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_secretscan.py +0 -0
  162. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_servers.py +0 -0
  163. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_subagents.py +0 -0
  164. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_tools.py +0 -0
  165. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_update.py +0 -0
  166. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_vision.py +0 -0
  167. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_web.py +0 -0
  168. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/tests/test_xai.py +0 -0
  169. {lcode_cli-0.32.0 → lcode_cli-0.33.0}/uv.lock +0 -0
@@ -6,6 +6,23 @@ All notable changes to lcode are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.33.0] - 2026-10-14
10
+
11
+ ### Added
12
+
13
+ - Steer while it works. Type while a request runs and press Enter: the line goes to the model at
14
+ its next step, as a note from you, without interrupting anything. The spinner shows what you're
15
+ typing and the notes waiting. Esc pauses after the current step, so you can look, then go on with
16
+ or without a note. Notes count as your requests for summaries and the review, and anything that
17
+ never reached the model goes back into the prompt. A command that runs for a while now shows a
18
+ spinner with its elapsed time. See
19
+ [Steer while it works](https://nasser1941.github.io/lcode/usage/#steer-while-it-works).
20
+
21
+ ### Fixed
22
+
23
+ - "Where you left off" (`lcode -c`) and session titles showed lcode's own messages to the model,
24
+ such as a failed check, as if you had written them. They show your last request now.
25
+
9
26
  ## [0.32.0] - 2026-10-14
10
27
 
11
28
  ### Added
@@ -691,7 +708,8 @@ First public release.
691
708
  - `AGENTS.md` project instructions and `/init` to generate them.
692
709
  - One-line installer for Linux and macOS.
693
710
 
694
- [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.32.0...HEAD
711
+ [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.33.0...HEAD
712
+ [0.33.0]: https://github.com/nasser1941/lcode/compare/v0.32.0...v0.33.0
695
713
  [0.32.0]: https://github.com/nasser1941/lcode/compare/v0.31.0...v0.32.0
696
714
  [0.31.0]: https://github.com/nasser1941/lcode/compare/v0.30.0...v0.31.0
697
715
  [0.30.0]: https://github.com/nasser1941/lcode/compare/v0.29.0...v0.30.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.32.0
3
+ Version: 0.33.0
4
4
  Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
5
5
  Project-URL: Homepage, https://nasser1941.github.io/lcode/
6
6
  Project-URL: Documentation, https://nasser1941.github.io/lcode/
@@ -12,7 +12,7 @@ lcode
12
12
 
13
13
  ```text
14
14
  ╭──────────────────────────────────────────────╮
15
- │ lcode v0.32.0 — local coding agent │
15
+ │ lcode v0.33.0 — local coding agent │
16
16
  │ │
17
17
  │ model lcode-qwen3.6-35b │
18
18
  │ context 256K tokens │
@@ -44,6 +44,8 @@ lcode update [--check] # update lcode, and Ollama when it runs in Docker (
44
44
  | ++enter++ | Send |
45
45
  | ++esc++ ++enter++ or a trailing `\` | New line |
46
46
  | ++ctrl+c++ | Interrupt the model or a running command |
47
+ | Typing while it works | A note for the model, read at its next step ([Steer while it works](#steer-while-it-works)) |
48
+ | ++esc++ while it works | Pause after the current step |
47
49
  | ++ctrl+d++ | Quit |
48
50
  | ++shift+tab++ | Cycle permission mode: ask → plan → auto-edit → yolo |
49
51
  | ++tab++ | Complete slash commands and `@` paths |
@@ -231,9 +233,10 @@ Much of the waiting with a local model isn't the model thinking: it's loading th
231
233
  a long conversation before the first word. lcode says which, in the status line under a request:
232
234
 
233
235
  ```text
234
- ⠋ Loading qwen3.6-35b into memory… (0 tokens, 9s · Ctrl+C to stop)
235
- ⠋ Reading the conversation (18K tokens)… (0 tokens, 21s · Ctrl+C to stop)
236
- ⠋ Thinking… (312 tokens, 27s · Ctrl+C to stop)
236
+ ⠋ Loading qwen3.6-35b into memory… (0 tokens, 9s · type a note · Esc pauses · Ctrl+C stops)
237
+ ⠋ Reading the conversation (18K tokens)… (0 tokens, 21s · type a note · Esc pauses · Ctrl+C stops)
238
+ ⠋ Thinking… (312 tokens, 27s · type a note · Esc pauses · Ctrl+C stops)
239
+ ⠋ Running the command… (48s · type a note · Esc pauses · Ctrl+C stops)
237
240
  ```
238
241
 
239
242
  It prints a line only when something worth knowing happens, and nothing while the model simply
@@ -252,6 +255,42 @@ While the model loads in the background at startup, the status bar says
252
255
  part of them in system RAM. Dense ones slow down a lot, to a few tokens a second, which is why they
253
256
  get the warning.
254
257
 
258
+ ## Steer while it works
259
+
260
+ A request can take minutes with a local model. You don't have to stop it to change course: type
261
+ while it works, and press ++enter++. The line goes to the model at its next step, after the step it's
262
+ on finishes, as a note from you. Nothing is interrupted or thrown away.
263
+
264
+ ```text
265
+ ● edit_file(tally.py)
266
+ ✓ Edited tally.py
267
+ ✎ Note for the model: also add a --min-count N option that skips rare words
268
+ ```
269
+
270
+ What you type shows under the spinner, since the terminal doesn't echo it while lcode works:
271
+
272
+ ```text
273
+ ⠋ Thinking… (212 tokens, 14s · type a note · Esc pauses · Ctrl+C stops)
274
+ ✎ also add a --min-count N opt▏ Enter adds it as a note
275
+ ```
276
+
277
+ - A note typed while the model writes its final answer still counts: the model reads it before the
278
+ request ends.
279
+ - Press ++esc++ to pause after the current step, to look at what it did. Then press ++enter++ to go
280
+ on, type a note to go on with it, or press ++ctrl+c++ to stop the request. Press ++esc++ again
281
+ before the step ends if you change your mind.
282
+ - Notes count as your requests: they're kept when old messages are
283
+ [summarized](how-it-works.md#context-management), and the [review](#a-review-before-the-answer)
284
+ checks the change against them too.
285
+ - When lcode asks you something, such as a permission, the keyboard is yours again for the answer.
286
+ Keys you typed just before the question appeared are dropped, so a note you were finishing can't
287
+ answer it by accident.
288
+ - What never reached the model, such as a note typed as the request ended or after ++ctrl+c++, is
289
+ put back in the prompt.
290
+
291
+ Steering works in the interactive session on macOS and Linux. `lcode -p` and the editor
292
+ integration don't read the keyboard during a request.
293
+
255
294
  ## Notifications
256
295
 
257
296
  Local models take a while, so you'll often switch to something else during a request. lcode shows
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.32.0"
6
+ __version__ = "0.33.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
@@ -52,6 +52,7 @@ from lcode.permissions import Permissions
52
52
  from lcode.render import MarkdownStreamer
53
53
  from lcode.repeats import Repeats
54
54
  from lcode.sandbox import Sandbox, SandboxError, project_root
55
+ from lcode.steer import NOTE_INTRO, Steering
55
56
  from lcode.tools import (
56
57
  LSP_SCHEMA,
57
58
  MEMORY_SCHEMA,
@@ -224,10 +225,12 @@ class Progress:
224
225
  arrives, for example while the model writes a long file into a tool call.
225
226
  """
226
227
 
227
- def __init__(self, phase: str = "Thinking") -> None:
228
+ def __init__(self, phase: str = "Thinking", steering: Steering | None = None, tokens: bool = True) -> None:
228
229
  self.started = self.last_chunk = time.time()
229
230
  self.phase = phase
230
231
  self.chars = 0
232
+ self.steering = steering # the note being typed and the notes waiting, shown on a second line
233
+ self.tokens = tokens # False for a command running: no tokens to count
231
234
 
232
235
  def update(self, phase: str, text: str) -> None:
233
236
  self.phase, self.chars, self.last_chunk = phase, self.chars + len(text), time.time()
@@ -240,9 +243,11 @@ class Progress:
240
243
  now = time.time()
241
244
  streaming = self.phase in ("Thinking", "Writing") # loading and reading say what they are throughout
242
245
  phase = self.phase if now - self.last_chunk < 3 or not streaming else "Working"
243
- return Text.from_markup(
244
- f"[cyan]{phase}…[/] [dim]({self.chars // 4} tokens, {now - self.started:.0f}s · Ctrl+C to stop)[/]"
245
- )
246
+ tokens = f"{self.chars // 4} tokens, " if self.tokens else ""
247
+ keys = "Ctrl+C to stop" if self.steering is None else "type a note · Esc pauses · Ctrl+C stops"
248
+ line = Text.from_markup(f"[cyan]{phase}…[/] [dim]({tokens}{now - self.started:.0f}s · {keys})[/]")
249
+ below = self.steering.status() if self.steering is not None else None
250
+ return line if below is None else Text("\n").join([line, below])
246
251
 
247
252
 
248
253
  @dataclass
@@ -321,6 +326,9 @@ class Agent:
321
326
  self.read_only = False # only read-only shell commands
322
327
  self.no_changes = "" # set during a review: why nothing may change (read-only tools only)
323
328
  self.cancel: threading.Event | None = None # set from another thread to stop
329
+ self.steering: Steering | None = None # notes typed and pauses asked for during a request (lcode.repl)
330
+ self.turn_requests: list[str] = [] # the request, then the notes added to it while it ran
331
+ self.unsent = "" # typed during the last request but never sent to the model: back in the prompt (lcode.repl)
324
332
  self.on_tool = None # called with (name, arguments) before each tool runs
325
333
  self.jobs = Jobs() # background commands (shared with subagents)
326
334
  self.turn_started = 0.0 # when the running request started (time.monotonic)
@@ -749,7 +757,7 @@ class Agent:
749
757
  """Stream one model response, rendering thinking/content live. Returns the assistant message."""
750
758
  content, thinking, tool_calls, final = "", "", [], {}
751
759
  md = MarkdownStreamer(self.console)
752
- progress = Progress()
760
+ progress = Progress(steering=self.steering)
753
761
  watch = self.before_request(progress)
754
762
  status = None
755
763
  printed_thinking = False
@@ -845,15 +853,10 @@ class Agent:
845
853
  diff, changed = review.diff_of(self.tools.before, self.current_text, budget)
846
854
  if changed < review.MIN_CHANGED_LINES:
847
855
  return []
848
- request = next(
849
- (
850
- context_tools.request_text(str(m.get("content", "")))
851
- for m in reversed(self.messages)
852
- if m.get("role") == "user"
853
- and not str(m.get("content", "")).startswith(("[lcode]", sessions.SUMMARY_PREFIX))
854
- ),
855
- "",
856
- )
856
+ request = self.turn_requests[0] if self.turn_requests else ""
857
+ if len(self.turn_requests) > 1:
858
+ added = "\n".join(f"- {note}" for note in self.turn_requests[1:])
859
+ request += f"\n\nWhat the user added while you worked:\n{added}"
857
860
  try:
858
861
  with self.console.status("Reviewing the change against your request…", spinner="dots"):
859
862
  problems = review.run(self, request, answer, diff)
@@ -1069,6 +1072,7 @@ class Agent:
1069
1072
  self.turn_status = "running"
1070
1073
  note = planning.NOTE if self.planning() and self.allowed_tools is None else ""
1071
1074
  self.messages.append({"role": "user", "content": self.expand_mentions(user_text) + note})
1075
+ self.turn_requests = [user_text.strip()]
1072
1076
  self.repeats.reset()
1073
1077
  self.check_results = []
1074
1078
  self.review_results = None
@@ -1081,6 +1085,8 @@ class Agent:
1081
1085
  for step in range(self.max_steps):
1082
1086
  if self.cancel is not None and self.cancel.is_set():
1083
1087
  raise KeyboardInterrupt
1088
+ if step and self.steering is not None:
1089
+ self.steer()
1084
1090
  if step and self.autosave:
1085
1091
  self.save() # a crash or a closed terminal loses at most this step
1086
1092
  self.maybe_compact()
@@ -1126,6 +1132,8 @@ class Agent:
1126
1132
  )
1127
1133
  continue
1128
1134
  if not calls:
1135
+ if self.steering is not None and self.steering.waiting():
1136
+ continue # the user added a note while the model wrote its answer: it reads the note first
1129
1137
  check = self.project_check() if changed and not verified else None
1130
1138
  if check is not None and check_rounds < checks.MAX_ROUNDS:
1131
1139
  check_rounds += 1
@@ -1241,6 +1249,34 @@ class Agent:
1241
1249
  self.turn_status = "success"
1242
1250
  self.print_stats()
1243
1251
 
1252
+ def steer(self) -> None:
1253
+ """Between two steps: pause if the user pressed Esc, then hand the model the notes typed meanwhile."""
1254
+ steering = self.steering
1255
+ if steering is None:
1256
+ return
1257
+ if steering.take_pause():
1258
+ self.console.print(
1259
+ Text(
1260
+ "⏸ Paused. Press Enter to go on, type a note to go on with it, or press Ctrl+C to stop the request.",
1261
+ style="yellow",
1262
+ )
1263
+ )
1264
+ note = steering.ask(" ⏸ ").strip()
1265
+ if note:
1266
+ steering.add(note)
1267
+ notes = steering.take()
1268
+ if not notes:
1269
+ return
1270
+ self.turn_requests += notes
1271
+ self.messages.append({"role": "user", "content": "\n\n".join(notes) + "\n\n" + NOTE_INTRO})
1272
+ for note in notes:
1273
+ shown = note if len(note) <= 200 else note[:200] + "…"
1274
+ self.console.print(Text(f"✎ Note for the model: {shown}", style="cyan"))
1275
+
1276
+ def activity(self, phase: str):
1277
+ """A spinner with the elapsed time while something runs (a command), with the note being typed under it."""
1278
+ return self.console.status(Progress(phase, self.steering, tokens=False), spinner="dots")
1279
+
1244
1280
  def print_stats(self) -> None:
1245
1281
  pct = 100 * self.ctx_used / self.settings.context
1246
1282
  self.console.print(
@@ -198,6 +198,7 @@ class Permissions:
198
198
  self.always: set[str] = set()
199
199
  self.rules = Rules() # allow and deny lists from the settings (lcode.hooks)
200
200
  self.on_prompt = None # called with the title before lcode asks the user (notification hooks)
201
+ self.read_line: Callable[[str], str] | None = None # asks instead of input() (lcode.steer, during a request)
201
202
  # Decides instead of asking (lcode.api, lcode acp): True, False, or a message saying why not.
202
203
  self.approve: Callable[[dict], bool | str] | None = None
203
204
 
@@ -243,7 +244,8 @@ class Permissions:
243
244
  if kind == "mcp":
244
245
  scope = scope.replace(":", " › ", 1)
245
246
  try:
246
- answer = input(f" Allow? [y]es / [a]lways for '{scope}' this session / [n]o (+ optional reason): ")
247
+ question = f" Allow? [y]es / [a]lways for '{scope}' this session / [n]o (+ optional reason): "
248
+ answer = (self.read_line or input)(question)
247
249
  except EOFError:
248
250
  return False, "The user denied this action (no input available)."
249
251
  answer = answer.strip()
@@ -36,6 +36,7 @@ from lcode.checkpoints import Checkpoint, CheckpointError, Restore
36
36
  from lcode.config import PERMISSION_MODES, STATE_DIR, ConfigError, format_tokens, parse_context
37
37
  from lcode.hardware import Hardware
38
38
  from lcode.ollama import OllamaError
39
+ from lcode.steer import KeyReader, Steering
39
40
  from lcode.tools import IGNORE_DIRS
40
41
 
41
42
  COMMANDS = {
@@ -874,6 +875,7 @@ def git_command(agent: Agent, cmd: str, arg: str) -> None:
874
875
 
875
876
  def run_safely(agent: Agent, text: str) -> None:
876
877
  started = time.monotonic()
878
+ reader = start_steering(agent) if agent.interactive else None
877
879
  try:
878
880
  agent.run_turn(text)
879
881
  except KeyboardInterrupt:
@@ -881,10 +883,33 @@ def run_safely(agent: Agent, text: str) -> None:
881
883
  except OllamaError as e:
882
884
  agent.console.print(f"[red]{e}[/]")
883
885
  finally:
886
+ if reader is not None:
887
+ stop_steering(agent, reader)
884
888
  agent.save()
885
889
  agent.finished(text, time.monotonic() - started)
886
890
 
887
891
 
892
+ def start_steering(agent: Agent) -> KeyReader | None:
893
+ """Read the keyboard while the request runs: a typed line becomes a note for the model, Esc pauses."""
894
+ steering = Steering()
895
+ reader = KeyReader(steering)
896
+ if not reader.start():
897
+ return None # not a terminal lcode can read keys from (or Windows): the request runs as before
898
+ agent.steering = steering
899
+ agent.perms.read_line = steering.ask # questions take the keyboard back meanwhile
900
+ return reader
901
+
902
+
903
+ def stop_steering(agent: Agent, reader: KeyReader) -> None:
904
+ reader.stop()
905
+ agent.steering = None
906
+ agent.perms.read_line = None
907
+ unsent = reader.steering.leftover()
908
+ if unsent:
909
+ agent.unsent = unsent
910
+ agent.console.print(Text("✎ What you typed didn't reach the model; it's in the prompt below.", style="cyan"))
911
+
912
+
888
913
  def session_start(agent: Agent) -> None:
889
914
  """session_start hooks: their output is shown, and with feedback = true also given to the model."""
890
915
  for outcome in agent.hooks.run("session_start", {"cwd": str(agent.cwd), "session": agent.session_id}, agent.cwd):
@@ -1115,13 +1140,13 @@ def resume_session(agent: Agent, info: sessions.SessionInfo) -> None:
1115
1140
 
1116
1141
  def print_recap(agent: Agent) -> None:
1117
1142
  """Show the last request and the start of the last answer, so it's clear where things left off."""
1118
- last_user = next((m for m in reversed(agent.messages) if m.get("role") == "user"), None)
1143
+ requests = context_tools.user_requests(agent.messages) # the user's own words, not lcode's notes to the model
1119
1144
  last_answer = next(
1120
1145
  (m for m in reversed(agent.messages) if m.get("role") == "assistant" and m.get("content", "").strip()), None
1121
1146
  )
1122
- if not last_user:
1147
+ if not requests:
1123
1148
  return
1124
- lines = [f"[bold]You:[/] {escape(sessions.title_from([last_user]))}"]
1149
+ lines = [f"[bold]You:[/] {escape(sessions.title_from([{'role': 'user', 'content': requests[-1]}]))}"]
1125
1150
  if last_answer:
1126
1151
  answer = " ".join(last_answer["content"].split())
1127
1152
  answer = answer if len(answer) <= 300 else answer[:299].rstrip() + "…"
@@ -1153,7 +1178,8 @@ def repl(agent: Agent, prompt: str | None, hardware: Hardware, cont: bool = Fals
1153
1178
  while True:
1154
1179
  try:
1155
1180
  agent.console.print()
1156
- line = session.prompt(HTML("<ansicyan><b>❯ </b></ansicyan>")).strip()
1181
+ unsent, agent.unsent = agent.unsent, ""
1182
+ line = session.prompt(HTML("<ansicyan><b>❯ </b></ansicyan>"), default=unsent).strip()
1157
1183
  except KeyboardInterrupt:
1158
1184
  continue
1159
1185
  except EOFError:
@@ -45,7 +45,9 @@ def title_from(messages: list[dict]) -> str:
45
45
  if content.startswith(SUMMARY_PREFIX):
46
46
  compacted = True
47
47
  continue
48
- text = re.split(r"\n\n<(?:file|directory) path=", content, maxsplit=1)[0]
48
+ if content.startswith("[lcode]"):
49
+ continue # lcode's own message to the model (a failed check, a review), not the user's words
50
+ text = re.split(r"\n\n(?:<(?:file|directory|image) path=|\[lcode[:\]] )", content, maxsplit=1)[0]
49
51
  text = " ".join(text.split())
50
52
  if text:
51
53
  return text if len(text) <= TITLE_LENGTH else text[: TITLE_LENGTH - 1].rstrip() + "…"
@@ -0,0 +1,287 @@
1
+ """Steer a request while it runs: type a note for the model, or press Esc to pause after the step.
2
+
3
+ With a local model a request can take minutes. Ctrl+C throws away the step in progress; a note doesn't
4
+ interrupt anything: a line typed while lcode works is kept and handed to the model before its next step, and the
5
+ spinner line shows what's being typed and what's waiting. Esc asks lcode to stop after the current step, so you
6
+ can look at what it did and then go on, with or without a note.
7
+
8
+ `Steering` holds the state and turns keystrokes into notes; it knows nothing about terminals, so it's easy to
9
+ test. `KeyReader` reads the keys from the terminal in the background, without echoing them (that would garble
10
+ the output), and hands the keyboard back whenever lcode asks a question (`Steering.ask`).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import codecs
16
+ import contextlib
17
+ import os
18
+ import select
19
+ import sys
20
+ import threading
21
+ import time
22
+ from collections.abc import Callable, Iterator
23
+
24
+ from rich.text import Text
25
+
26
+ try:
27
+ import termios
28
+ except ImportError: # Windows: no steering, everything else works
29
+ termios = None # type: ignore[assignment]
30
+
31
+ MAX_NOTE = 4000 # characters in one note
32
+ SHOWN = 45 # characters of the line being typed shown under the spinner (the line fits 80 columns)
33
+ ESC_WAIT = 0.03 # an Esc followed by more bytes this soon is an arrow or function key, not the Esc key
34
+ TOO_QUICK = 0.4 # an Enter this soon after a question appears was meant for the note being typed
35
+ NOTE_INTRO = (
36
+ "[lcode] The user wrote this while you were working. Take it into account from here on; it doesn't mean "
37
+ "starting over unless it says so."
38
+ )
39
+
40
+
41
+ class Steering:
42
+ """Notes typed during a request, the line being typed, and whether to pause after the step."""
43
+
44
+ def __init__(self) -> None:
45
+ self.lock = threading.Lock()
46
+ self.typed = "" # the line being typed
47
+ self.notes: list[str] = [] # finished lines, waiting for the model's next step
48
+ self.pause = False # stop after the current step
49
+ self.reader: KeyReader | None = None
50
+ self.read_line: Callable[[str], str] | None = None # input() when None (looked up when asking)
51
+
52
+ # Input ------------------------------------------------------------------------------------------------
53
+
54
+ def feed(self, keys: str) -> None:
55
+ """Keystrokes as text: printable characters, Enter, Backspace, Ctrl+U/Ctrl+W, Esc and escape sequences.
56
+
57
+ A lone "\\x1b" is the Esc key (KeyReader waits a moment for the rest of a sequence before it hands one
58
+ over). Everything else that starts with it is skipped: the sequences arrow and function keys send,
59
+ Alt+key, and a terminal's answers to queries (cursor position, colours).
60
+ """
61
+ with self.lock:
62
+ i = 0
63
+ while i < len(keys):
64
+ ch = keys[i]
65
+ i += 1
66
+ if ch == "\x1b":
67
+ after = keys[i] if i < len(keys) else ""
68
+ if after == "[": # CSI: parameters, then a final byte in @..~
69
+ i += 1
70
+ while i < len(keys) and not ("@" <= keys[i] <= "~"):
71
+ i += 1
72
+ i += 1
73
+ elif after == "O": # SS3: one more character (F1-F4, keypad)
74
+ i += 2
75
+ elif after in ("]", "P", "_", "^"): # a string (OSC, DCS…), ended by BEL or ESC \
76
+ end = min(
77
+ (j for j in (keys.find("\x07", i), keys.find("\x1b\\", i)) if j >= 0), default=len(keys)
78
+ )
79
+ i = end + (2 if keys.startswith("\x1b\\", end) else 1)
80
+ elif after in ("", "\x1b"): # the Esc key (twice in a row: two presses)
81
+ self.pause = not self.pause
82
+ else:
83
+ i += 1 # Alt+key
84
+ elif ch in "\r\n":
85
+ note = self.typed.strip()
86
+ self.typed = ""
87
+ if note:
88
+ self.notes.append(note[:MAX_NOTE])
89
+ elif ch in "\x7f\x08":
90
+ self.typed = self.typed[:-1]
91
+ elif ch == "\x15": # Ctrl+U: clear the line
92
+ self.typed = ""
93
+ elif ch == "\x17": # Ctrl+W: delete the last word
94
+ self.typed = self.typed.rstrip()
95
+ self.typed = self.typed[: self.typed.rfind(" ") + 1] if " " in self.typed else ""
96
+ elif ch == "\t":
97
+ self.typed += " "
98
+ elif ch >= " " and len(self.typed) < MAX_NOTE:
99
+ self.typed += ch
100
+
101
+ # For the agent ----------------------------------------------------------------------------------------
102
+
103
+ def add(self, note: str) -> None:
104
+ with self.lock:
105
+ self.notes.append(note[:MAX_NOTE])
106
+
107
+ def waiting(self) -> bool:
108
+ with self.lock:
109
+ return bool(self.notes)
110
+
111
+ def take(self) -> list[str]:
112
+ """The notes waiting for the model (and forget them)."""
113
+ with self.lock:
114
+ notes, self.notes = self.notes, []
115
+ return notes
116
+
117
+ def take_pause(self) -> bool:
118
+ """Whether a pause was asked for (and clear the request)."""
119
+ with self.lock:
120
+ asked, self.pause = self.pause, False
121
+ return asked
122
+
123
+ def leftover(self) -> str:
124
+ """What never reached the model: notes still waiting and the unfinished line, for the next prompt."""
125
+ with self.lock:
126
+ text = "\n".join([*self.notes, self.typed.strip()]).strip()
127
+ self.notes, self.typed = [], ""
128
+ return text
129
+
130
+ def ask(self, question: str) -> str:
131
+ """Ask the user a question while the keyboard is being read for notes (permission prompts, the pause).
132
+
133
+ Keys typed before the question appeared are dropped, and an Enter pressed right as it appeared asks
134
+ once more: it was meant to finish a note, not to answer (an empty answer often means yes).
135
+ """
136
+ hold = self.reader.suspended() if self.reader is not None else contextlib.nullcontext()
137
+ with hold:
138
+ asked = time.monotonic()
139
+ answer = (self.read_line or input)(question)
140
+ if not answer.strip() and time.monotonic() - asked < TOO_QUICK:
141
+ answer = (self.read_line or input)(question)
142
+ return answer
143
+
144
+ def status(self) -> Text | None:
145
+ """The line under the spinner: what's being typed, notes waiting, a pause coming."""
146
+ with self.lock:
147
+ typed, waiting, pause = self.typed, len(self.notes), self.pause
148
+ parts: list[Text] = []
149
+ if typed:
150
+ shown = typed if len(typed) <= SHOWN else "…" + typed[-SHOWN:]
151
+ parts.append(Text.assemble(("✎ ", "cyan"), shown, ("▏", "cyan"), (" Enter adds it as a note", "dim")))
152
+ if waiting:
153
+ what = "a note" if waiting == 1 else f"{waiting} notes"
154
+ parts.append(Text(f"✎ {what} waiting for the model's next step", style="cyan"))
155
+ if pause:
156
+ parts.append(Text("⏸ Pausing after this step (Esc again to go on without pausing)", style="yellow"))
157
+ if not parts:
158
+ return None
159
+ return Text("\n").join(parts)
160
+
161
+
162
+ class KeyReader:
163
+ """Reads the keyboard in the background while a request runs, and gives the keys to a Steering.
164
+
165
+ The terminal stays in "cbreak" mode meanwhile: keys arrive one by one and aren't echoed, while Ctrl+C still
166
+ interrupts. `suspended()` hands the keyboard back, in its usual mode, for a question.
167
+ """
168
+
169
+ def __init__(self, steering: Steering, fd: int | None = None) -> None:
170
+ self.steering = steering
171
+ self.fd = fd # stdin when None
172
+ self.saved: list | None = None
173
+ self.thread: threading.Thread | None = None
174
+ self.stopping = threading.Event()
175
+ self.hold = threading.Event() # asked to stop reading for a while
176
+ self.held = threading.Event() # ...and the thread has stopped
177
+ self.resume = threading.Event()
178
+ self.decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
179
+
180
+ def start(self) -> bool:
181
+ """Start reading; False when this isn't a terminal lcode can read keys from."""
182
+ if termios is None:
183
+ return False
184
+ try:
185
+ if self.fd is None:
186
+ self.fd = sys.stdin.fileno()
187
+ if not os.isatty(self.fd):
188
+ return False
189
+ except (OSError, ValueError): # no real stdin (tests, some IDE consoles)
190
+ return False
191
+ try:
192
+ self.saved = termios.tcgetattr(self.fd)
193
+ self._cbreak()
194
+ except termios.error:
195
+ self.saved = None
196
+ return False
197
+ self.steering.reader = self
198
+ self.thread = threading.Thread(target=self._run, name="lcode-keys", daemon=True)
199
+ self.thread.start()
200
+ return True
201
+
202
+ def stop(self) -> None:
203
+ self.stopping.set()
204
+ self.resume.set()
205
+ if self.thread is not None:
206
+ self.thread.join(timeout=1)
207
+ self._restore()
208
+ self.steering.reader = None
209
+
210
+ def __enter__(self) -> KeyReader:
211
+ self.start()
212
+ return self
213
+
214
+ def __exit__(self, *exc) -> None:
215
+ self.stop()
216
+
217
+ @contextlib.contextmanager
218
+ def suspended(self) -> Iterator[None]:
219
+ """The keyboard back in its usual mode (echo, line editing) for a question, with typed-ahead keys dropped."""
220
+ if self.thread is None or not self.thread.is_alive():
221
+ yield
222
+ return
223
+ self.resume.clear()
224
+ self.hold.set()
225
+ self.held.wait(timeout=1)
226
+ self._restore()
227
+ with contextlib.suppress(termios.error):
228
+ termios.tcflush(self.fd, termios.TCIFLUSH)
229
+ try:
230
+ yield
231
+ finally:
232
+ with contextlib.suppress(termios.error):
233
+ self._cbreak()
234
+ self.hold.clear()
235
+ self.held.clear()
236
+ self.resume.set()
237
+
238
+ def _cbreak(self) -> None:
239
+ mode = termios.tcgetattr(self.fd)
240
+ mode[3] &= ~(termios.ECHO | termios.ICANON) # lflags; ISIG stays, so Ctrl+C still interrupts
241
+ mode[6][termios.VMIN] = 1
242
+ mode[6][termios.VTIME] = 0
243
+ termios.tcsetattr(self.fd, termios.TCSANOW, mode)
244
+
245
+ def _restore(self) -> None:
246
+ if self.saved is not None:
247
+ with contextlib.suppress(termios.error):
248
+ termios.tcsetattr(self.fd, termios.TCSANOW, self.saved)
249
+
250
+ def _run(self) -> None:
251
+ while not self.stopping.is_set():
252
+ if self.hold.is_set():
253
+ self.held.set()
254
+ self.resume.wait()
255
+ continue
256
+ try:
257
+ ready, _, _ = select.select([self.fd], [], [], 0.1)
258
+ if not ready or self.hold.is_set():
259
+ continue
260
+ data = os.read(self.fd, 1024)
261
+ if not data:
262
+ return # the terminal is gone
263
+ while incomplete_escape(data):
264
+ more, _, _ = select.select([self.fd], [], [], ESC_WAIT) # the rest of an escape sequence?
265
+ if not more:
266
+ break # a lone "\x1b" is then the Esc key
267
+ data += os.read(self.fd, 1024)
268
+ except (OSError, ValueError):
269
+ return
270
+ self.steering.feed(self.decoder.decode(data))
271
+
272
+
273
+ def incomplete_escape(data: bytes) -> bool:
274
+ """Whether the bytes end in the middle of an escape sequence (or with a "\\x1b" that may start one)."""
275
+ start = data.rfind(b"\x1b")
276
+ if start < 0:
277
+ return False
278
+ tail = data[start:]
279
+ if tail == b"\x1b":
280
+ return True
281
+ if tail.startswith(b"\x1b["):
282
+ return not any(0x40 <= b <= 0x7E for b in tail[2:])
283
+ if tail.startswith(b"\x1bO"):
284
+ return len(tail) < 3
285
+ if tail[1:2] in (b"]", b"P", b"_", b"^"):
286
+ return b"\x07" not in tail # (one ended by ESC \ has its last ESC there: complete)
287
+ return False