webbee 0.3.9__tar.gz → 0.3.11__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 (85) hide show
  1. {webbee-0.3.9 → webbee-0.3.11}/CHANGELOG.md +12 -0
  2. {webbee-0.3.9 → webbee-0.3.11}/PKG-INFO +1 -1
  3. {webbee-0.3.9 → webbee-0.3.11}/pyproject.toml +1 -1
  4. webbee-0.3.11/src/webbee/__init__.py +1 -0
  5. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/repl.py +10 -7
  6. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/session.py +6 -1
  7. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/steer.py +11 -6
  8. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/tui.py +90 -21
  9. {webbee-0.3.9 → webbee-0.3.11}/tests/test_repl.py +7 -4
  10. {webbee-0.3.9 → webbee-0.3.11}/tests/test_session.py +21 -0
  11. {webbee-0.3.9 → webbee-0.3.11}/tests/test_steer.py +51 -7
  12. {webbee-0.3.9 → webbee-0.3.11}/tests/test_tui.py +219 -0
  13. webbee-0.3.9/src/webbee/__init__.py +0 -1
  14. {webbee-0.3.9 → webbee-0.3.11}/.github/workflows/publish.yml +0 -0
  15. {webbee-0.3.9 → webbee-0.3.11}/.gitignore +0 -0
  16. {webbee-0.3.9 → webbee-0.3.11}/LICENSE +0 -0
  17. {webbee-0.3.9 → webbee-0.3.11}/README.md +0 -0
  18. {webbee-0.3.9 → webbee-0.3.11}/install.sh +0 -0
  19. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/account.py +0 -0
  20. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/banner_art.py +0 -0
  21. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/boot.py +0 -0
  22. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/checkpoints.py +0 -0
  23. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/cli.py +0 -0
  24. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/clipboard.py +0 -0
  25. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/coding_context.py +0 -0
  26. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/commands.py +0 -0
  27. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/config.py +0 -0
  28. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/consent.py +0 -0
  29. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/details.py +0 -0
  30. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/events.py +0 -0
  31. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/frames.py +0 -0
  32. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/intel/__init__.py +0 -0
  33. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/intel/chunker.py +0 -0
  34. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/intel/embed.py +0 -0
  35. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/intel/graph.py +0 -0
  36. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/intel/indexer.py +0 -0
  37. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/intel/models.py +0 -0
  38. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/intel/query.py +0 -0
  39. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/intel/service.py +0 -0
  40. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/intel/store.py +0 -0
  41. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/intel/vectors.py +0 -0
  42. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/intel/watch.py +0 -0
  43. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/output_pane.py +0 -0
  44. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/remote.py +0 -0
  45. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/render.py +0 -0
  46. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/repo.py +0 -0
  47. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/sessions.py +0 -0
  48. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/stream.py +0 -0
  49. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/thread.py +0 -0
  50. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/tokens.py +0 -0
  51. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/tools.py +0 -0
  52. {webbee-0.3.9 → webbee-0.3.11}/src/webbee/update.py +0 -0
  53. {webbee-0.3.9 → webbee-0.3.11}/tests/__init__.py +0 -0
  54. {webbee-0.3.9 → webbee-0.3.11}/tests/test_account.py +0 -0
  55. {webbee-0.3.9 → webbee-0.3.11}/tests/test_checkpoints.py +0 -0
  56. {webbee-0.3.9 → webbee-0.3.11}/tests/test_cli.py +0 -0
  57. {webbee-0.3.9 → webbee-0.3.11}/tests/test_clipboard.py +0 -0
  58. {webbee-0.3.9 → webbee-0.3.11}/tests/test_commands.py +0 -0
  59. {webbee-0.3.9 → webbee-0.3.11}/tests/test_config.py +0 -0
  60. {webbee-0.3.9 → webbee-0.3.11}/tests/test_cpc_contract_stable.py +0 -0
  61. {webbee-0.3.9 → webbee-0.3.11}/tests/test_details.py +0 -0
  62. {webbee-0.3.9 → webbee-0.3.11}/tests/test_events.py +0 -0
  63. {webbee-0.3.9 → webbee-0.3.11}/tests/test_freeze_fix.py +0 -0
  64. {webbee-0.3.9 → webbee-0.3.11}/tests/test_intel_chunker.py +0 -0
  65. {webbee-0.3.9 → webbee-0.3.11}/tests/test_intel_embed.py +0 -0
  66. {webbee-0.3.9 → webbee-0.3.11}/tests/test_intel_graph.py +0 -0
  67. {webbee-0.3.9 → webbee-0.3.11}/tests/test_intel_indexer.py +0 -0
  68. {webbee-0.3.9 → webbee-0.3.11}/tests/test_intel_no_numpy.py +0 -0
  69. {webbee-0.3.9 → webbee-0.3.11}/tests/test_intel_query.py +0 -0
  70. {webbee-0.3.9 → webbee-0.3.11}/tests/test_intel_service.py +0 -0
  71. {webbee-0.3.9 → webbee-0.3.11}/tests/test_intel_store.py +0 -0
  72. {webbee-0.3.9 → webbee-0.3.11}/tests/test_intel_vectors.py +0 -0
  73. {webbee-0.3.9 → webbee-0.3.11}/tests/test_intel_watch.py +0 -0
  74. {webbee-0.3.9 → webbee-0.3.11}/tests/test_marathon.py +0 -0
  75. {webbee-0.3.9 → webbee-0.3.11}/tests/test_packaging.py +0 -0
  76. {webbee-0.3.9 → webbee-0.3.11}/tests/test_render.py +0 -0
  77. {webbee-0.3.9 → webbee-0.3.11}/tests/test_repo.py +0 -0
  78. {webbee-0.3.9 → webbee-0.3.11}/tests/test_sessions.py +0 -0
  79. {webbee-0.3.9 → webbee-0.3.11}/tests/test_stream.py +0 -0
  80. {webbee-0.3.9 → webbee-0.3.11}/tests/test_thread.py +0 -0
  81. {webbee-0.3.9 → webbee-0.3.11}/tests/test_tokens.py +0 -0
  82. {webbee-0.3.9 → webbee-0.3.11}/tests/test_tools.py +0 -0
  83. {webbee-0.3.9 → webbee-0.3.11}/tests/test_tui_hardening.py +0 -0
  84. {webbee-0.3.9 → webbee-0.3.11}/tests/test_update.py +0 -0
  85. {webbee-0.3.9 → webbee-0.3.11}/tests/test_version.py +0 -0
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.11
4
+
5
+ - Type-ahead queue: send follow-ups while Webbee is working — they queue and
6
+ run after the current turn; the toolbar shows how many are waiting.
7
+ - Up-arrow recalls your last message to edit and resend.
8
+
9
+ ## 0.3.10
10
+
11
+ - Remote instructions picked up by an idle terminal now carry a dedup id, so
12
+ an at-least-once delivery can never run — or bill — the same instruction
13
+ twice.
14
+
3
15
  ## 0.3.9
4
16
 
5
17
  - Internal restructure: the session engine was split into focused modules
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.9
3
+ Version: 0.3.11
4
4
  Summary: Webbee 🐝 — the Imperal Cloud coding agent in your terminal
5
5
  Project-URL: Homepage, https://imperal.io
6
6
  Project-URL: Documentation, https://docs.imperal.io
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webbee"
3
- version = "0.3.9"
3
+ version = "0.3.11"
4
4
  description = "Webbee 🐝 — the Imperal Cloud coding agent in your terminal"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1 @@
1
+ __version__ = "0.3.11"
@@ -214,14 +214,16 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
214
214
  await _run_turn(line)
215
215
  return "continue"
216
216
 
217
- async def _run_turn(line: str, surface: str = "") -> None:
217
+ async def _run_turn(line: str, surface: str = "", steer_iid: str = "") -> None:
218
218
  """ONE agent turn -- the SAME path for a typed line and an idle-steer
219
219
  pickup (liveness v2 §B), which threads the queued item's origin
220
- `surface` into the turn so the kernel stamps provenance start-path.
221
- Only the echo differs at the call sites: user_echo for a typed line,
222
- foreign_turn for a remote one."""
220
+ `surface` (provenance) and dedup `steer_iid` (kernel dedup ring) into
221
+ the turn start-path. Only the echo differs at the call sites:
222
+ user_echo for a typed line, foreign_turn for a remote one."""
223
223
  _sink.begin_turn()
224
224
  kw = {"surface": surface} if surface else {}
225
+ if steer_iid:
226
+ kw["steer_iid"] = steer_iid
225
227
  try:
226
228
  text = await agent.run(line, _sink, marathon=not once,
227
229
  goal=(line if not once else ""), **kw)
@@ -236,11 +238,12 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
236
238
  return
237
239
  _sink.end_turn(text)
238
240
 
239
- async def _steer_submit(text: str, surface: str) -> None:
241
+ async def _steer_submit(text: str, surface: str, steer_iid: str = "") -> None:
240
242
  """webbee.steer hands a drained remote instruction here: render it as
241
- the remote user's own line, then run it as a normal turn."""
243
+ the remote user's own line, then run it as a normal turn (carrying the
244
+ item's dedup iid so the kernel can drop an at-least-once twin)."""
242
245
  _sink.foreign_turn(surface, "user", text)
243
- await _run_turn(text, surface=surface)
246
+ await _run_turn(text, surface=surface, steer_iid=steer_iid)
244
247
 
245
248
  def _cancel_background() -> None:
246
249
  for _t in (watcher_task, steer_task):
@@ -45,7 +45,7 @@ class AgentSession:
45
45
  return {"Authorization": f"Bearer {token}"}
46
46
 
47
47
  async def run(self, task: str, sink, *, marathon: bool = False, goal: str = "",
48
- surface: str = "") -> str:
48
+ surface: str = "", steer_iid: str = "") -> str:
49
49
  import httpx
50
50
 
51
51
  from webbee.tools import LocalToolExecutor
@@ -72,6 +72,11 @@ class AgentSession:
72
72
  # [surface] tags). Additive-only -- a typed turn's body is
73
73
  # byte-identical to before.
74
74
  body["surface"] = surface
75
+ if steer_iid:
76
+ # steer-iid-dedup: a pickup also carries the queued item's dedup id
77
+ # so the kernel's ring can drop an at-least-once twin. Same
78
+ # additive-only contract -- a typed turn has none, key omitted.
79
+ body["steer_iid"] = steer_iid
75
80
  if marathon:
76
81
  body["marathon"] = True
77
82
  body["goal"] = goal
@@ -58,8 +58,9 @@ async def poll_idle_steer(cfg, token_provider, *, workspace: str, is_busy,
58
58
  live_session_id=lambda: "",
59
59
  interval: float = _POLL_INTERVAL) -> None:
60
60
  """Run forever (until cancelled): every ~`interval`s of idle time, drain
61
- the pending-steer queue and hand the FIRST item to `submit(text, surface)`
62
- -- the repl's normal turn path. Seams (all injected by the repl wiring):
61
+ the pending-steer queue and hand the FIRST item to
62
+ `submit(text, surface, steer_iid)` -- the repl's normal turn path. Seams
63
+ (all injected by the repl wiring):
63
64
  * is_busy() -- sync; True while a turn is running. Checked BEFORE
64
65
  the destructive fetch (never drain mid-turn) and
65
66
  AGAIN right before submit (a locally-typed line
@@ -67,9 +68,12 @@ async def poll_idle_steer(cfg, token_provider, *, workspace: str, is_busy,
67
68
  backlog instead of being lost).
68
69
  * live_session_id()-- sync; the agent's gateway-issued session id once a
69
70
  turn has run ("" before). Wins over derivation.
70
- * submit(text, surface) -- async; renders the remote line and runs the
71
- turn. Runs INSIDE this task, so polling is
72
- naturally paused for the turn's whole duration."""
71
+ * submit(text, surface, steer_iid) -- async; renders the remote line and
72
+ runs the turn. Runs INSIDE this task, so polling
73
+ is naturally paused for the turn's whole duration.
74
+ steer_iid = the queued item's dedup id ("" on an
75
+ older gateway), threaded into the turn POST so the
76
+ kernel's dedup ring can drop an at-least-once twin."""
73
77
  from webbee.thread import fetch_pending_steer
74
78
  derived = ""
75
79
  backlog: deque = deque()
@@ -97,7 +101,8 @@ async def poll_idle_steer(cfg, token_provider, *, workspace: str, is_busy,
97
101
  if is_busy():
98
102
  backlog.appendleft(item) # a local line won the race -- defer, don't drop
99
103
  continue
100
- await submit(text, str(item.get("surface") or "telegram"))
104
+ await submit(text, str(item.get("surface") or "telegram"),
105
+ str(item.get("iid") or ""))
101
106
  if _cancel_absorbed():
102
107
  raise asyncio.CancelledError
103
108
  except asyncio.CancelledError:
@@ -2,11 +2,15 @@
2
2
  output_pane.py) fills the top; a bordered input box + toolbar are pinned at
3
3
  the very bottom and never move while the output scrolls (mouse wheel /
4
4
  PageUp). `run_session` also drives step-navigation (Up/Down + Enter) over the
5
- pinned box when the input is empty and no turn is running. Pure helpers
6
- (next_mode/build_toolbar) are unit-tested; the Application is TTY/headless-
7
- smoke verified. Grounded in prompt_toolkit 3.0.52."""
5
+ pinned box when the input is empty and no turn is running; in every other
6
+ state Up/Down recall submitted lines (readline-style), and Enter while a turn
7
+ runs QUEUES the line (Claude-Code type-ahead: it runs after the current
8
+ turn). Pure helpers (next_mode/build_toolbar/the *_action functions) are
9
+ unit-tested; the Application is TTY/headless-smoke verified. Grounded in
10
+ prompt_toolkit 3.0.52."""
8
11
  import asyncio
9
12
  import re
13
+ from collections import deque
10
14
 
11
15
  from webbee.output_pane import OutputPane # noqa: F401 — re-exported (webbee.tui.OutputPane)
12
16
  from webbee.render import _fmt_tokens
@@ -64,13 +68,16 @@ def next_mode(mode: str) -> str:
64
68
 
65
69
  def build_toolbar(mode: str, tokens: int, credits: int, *, busy: bool = False,
66
70
  current: str = "", elapsed: float = 0.0, tools: int = 0,
67
- consent: bool = False) -> list:
71
+ consent: bool = False, queued: int = 0) -> list:
68
72
  """The status line under the pinned input box, as prompt_toolkit formatted
69
73
  text (per-segment styled). Three states: consent (awaiting a reply), busy
70
74
  (a turn is running — an ANIMATED coloured spinner + the current action in
71
75
  accent, so it pops, not grey), and idle (mode value coloured PER MODE —
72
76
  default cyan / plan purple / autopilot yellow — + SESSION spend + the
73
- Shift + TAB hint). Style classes are defined in run_session's Style."""
77
+ Shift + TAB hint). `queued` = type-ahead lines waiting to run after the
78
+ current turn; when >0 a subtle `⋯N queued` segment shows in the busy AND
79
+ idle states. Style classes are defined in run_session's Style."""
80
+ q = f" · ⋯{queued} queued" if queued else ""
74
81
  if consent:
75
82
  return [("class:tb.consent", " approve? type y / n / a reply · Enter to send")]
76
83
  if busy:
@@ -79,13 +86,13 @@ def build_toolbar(mode: str, tokens: int, credits: int, *, busy: bool = False,
79
86
  if current:
80
87
  frags += [("class:tb.dim", " · "), ("class:tb.action", current)]
81
88
  frags.append(("class:tb.dim",
82
- f" · {elapsed:.0f}s · {tools} · {_fmt_tokens(tokens)} tok"
89
+ f" · {elapsed:.0f}s · {tools} · {_fmt_tokens(tokens)} tok{q}"
83
90
  f" · Esc/Ctrl-C to stop"))
84
91
  return frags
85
92
  return [("class:tb.dim", " mode: "),
86
93
  (f"class:tb.mode.{mode}", mode),
87
94
  ("class:tb.dim",
88
- f" · {_fmt_tokens(tokens)} tok · {_fmt_tokens(credits)} credits · Shift + TAB: switch mode")]
95
+ f" · {_fmt_tokens(tokens)} tok · {_fmt_tokens(credits)} credits{q} · Shift + TAB: switch mode")]
89
96
 
90
97
 
91
98
  def _escape_action(sel: dict, turn: dict, is_busy, stop_turn, event, buf=None) -> None:
@@ -122,15 +129,67 @@ def _interrupt_action(turn: dict, is_busy, stop_turn, event) -> None:
122
129
  t.cancel() # cancel the running turn; dock survives
123
130
 
124
131
 
132
+ def _submit_line(text: str, buf, pending, busy: bool, start) -> str:
133
+ """Route ONE non-empty submitted line (dependency-injected, same testing
134
+ philosophy as _escape_action). Records it into the buffer's history first
135
+ (up-arrow recall), then: busy → QUEUE it (Claude-Code type-ahead — the
136
+ line is never erased or dropped; it runs after the current turn); idle →
137
+ `start(text)` (today's normal submit). Returns "queued" | "started"."""
138
+ buf.history.append_string(text)
139
+ if busy:
140
+ pending.append(text)
141
+ return "queued"
142
+ start(text)
143
+ return "started"
144
+
145
+
146
+ def _drain_pending(pending, start) -> bool:
147
+ """Turn-completion drain: pop the OLDEST queued type-ahead line and hand
148
+ it to `start` — the SAME path a typed line takes, so a persistent
149
+ marathon receives it as a `new_task` into the running session. ONE item
150
+ per completion; the rest stay queued FIFO (each finished turn drains the
151
+ next). Returns True when a drain happened."""
152
+ if not pending:
153
+ return False
154
+ start(pending.popleft())
155
+ return True
156
+
157
+
158
+ def _arrow_up_action(event, buf, sel: dict, n: int, busy: bool) -> None:
159
+ """Up key. Step-navigation EXACTLY as before when steps exist + input is
160
+ empty + idle; in EVERY other state (busy, or text present, or no steps)
161
+ recall an older submitted line into the buffer (readline-style)."""
162
+ if n and not buf.text and not busy:
163
+ sel["i"] = (n - 1) if sel["i"] is None else max(0, sel["i"] - 1)
164
+ event.app.invalidate()
165
+ return
166
+ buf.history_backward()
167
+ event.app.invalidate()
168
+
169
+
170
+ def _arrow_down_action(event, buf, sel: dict, n: int, busy: bool) -> None:
171
+ """Down key — mirror of _arrow_up_action: step-nav on the same exact
172
+ gate, otherwise cycle history forward."""
173
+ if n and not buf.text and not busy:
174
+ sel["i"] = 0 if sel["i"] is None else min(n - 1, sel["i"] + 1)
175
+ event.app.invalidate()
176
+ return
177
+ buf.history_forward()
178
+ event.app.invalidate()
179
+
180
+
125
181
  async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
126
182
  is_busy, consent_pending, resolve_consent, steps_nav=None,
127
183
  stop_turn=None) -> bool:
128
184
  """The full-screen dock: `pane` fills the top (scrollable), a bordered input
129
185
  box + toolbar are FIXED at the bottom. Enter either resolves a pending
130
186
  consent reply (ICNLI: raw verbatim) or starts a turn as a BACKGROUND task
131
- (the box stays fixed during it). When `steps_nav` is given and the input is
132
- empty and no turn is running, Up/Down move a step selection (toolbar shows
133
- `step k/N`) and Enter expands it via `steps_nav["expand"]`; Esc clears it.
187
+ (the box stays fixed during it); while a turn runs, Enter QUEUES the line
188
+ (type-ahead it starts right after the current turn; the toolbar shows
189
+ `⋯N queued`). When `steps_nav` is given and the input is empty and no turn
190
+ is running, Up/Down move a step selection (toolbar shows `step k/N`) and
191
+ Enter expands it via `steps_nav["expand"]`; Esc clears it. In every other
192
+ state Up/Down recall submitted lines (readline-style history).
134
193
  Returns True on clean exit; False if prompt_toolkit is unavailable (caller
135
194
  uses the plain fallback loop)."""
136
195
  try:
@@ -147,12 +206,24 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
147
206
 
148
207
  buf = Buffer(multiline=False)
149
208
  turn = {"task": None}
209
+ pending: deque = deque() # type-ahead queue: lines submitted while a turn runs
210
+
211
+ def _start_turn(text):
212
+ turn["task"] = get_app().create_background_task(_run_turn(text))
150
213
 
151
214
  async def _run_turn(text):
215
+ done = False
152
216
  try:
153
217
  await on_line(text)
218
+ done = True
154
219
  finally:
155
220
  turn["task"] = None
221
+ if done:
222
+ # Type-ahead drain: the turn finished normally (an Esc/Ctrl-C
223
+ # stop counts — repl._run_turn absorbs the cancel) → submit the
224
+ # oldest queued line through the SAME path a typed line takes.
225
+ # A propagating exception (dock teardown) leaves the queue be.
226
+ _drain_pending(pending, _start_turn)
156
227
  get_app().invalidate()
157
228
 
158
229
  kb = KeyBindings()
@@ -178,9 +249,12 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
178
249
  idx, sel["i"] = sel["i"], None
179
250
  event.app.create_background_task(steps_nav["expand"](idx))
180
251
  return
181
- if _busy_live() or not text.strip():
252
+ if not text.strip():
182
253
  return
183
- turn["task"] = event.app.create_background_task(_run_turn(text))
254
+ # Non-empty line: record for up-arrow recall, then queue-while-busy
255
+ # (type-ahead — runs after the current turn) or start a turn now.
256
+ if _submit_line(text, buf, pending, _busy_live(), _start_turn) == "queued":
257
+ event.app.invalidate() # toolbar shows the new depth
184
258
 
185
259
  @kb.add("s-tab")
186
260
  def _cycle(event):
@@ -206,17 +280,11 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
206
280
 
207
281
  @kb.add("up")
208
282
  def _step_up(event):
209
- n = _nav_count()
210
- if n and not buf.text and not _busy_live():
211
- sel["i"] = (n - 1) if sel["i"] is None else max(0, sel["i"] - 1)
212
- event.app.invalidate()
283
+ _arrow_up_action(event, buf, sel, _nav_count(), _busy_live())
213
284
 
214
285
  @kb.add("down")
215
286
  def _step_down(event):
216
- n = _nav_count()
217
- if n and not buf.text and not _busy_live():
218
- sel["i"] = 0 if sel["i"] is None else min(n - 1, sel["i"] + 1)
219
- event.app.invalidate()
287
+ _arrow_down_action(event, buf, sel, _nav_count(), _busy_live())
220
288
 
221
289
  @kb.add("escape")
222
290
  def _step_clear(event):
@@ -239,7 +307,8 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
239
307
  st = status()
240
308
  return build_toolbar(mode_getter(), st["tokens"], st["credits"], busy=st["busy"],
241
309
  current=st["current"], elapsed=st["elapsed"],
242
- tools=st["tools"], consent=st["consent"])
310
+ tools=st["tools"], consent=st["consent"],
311
+ queued=len(pending))
243
312
 
244
313
  # Dynamic height: EXACTLY the rows the wrapped input needs (1→10), so the box
245
314
  # grows as you type and shrinks back — never a fixed huge block. Enter still
@@ -590,10 +590,11 @@ class SurfaceAgent(FakeAgent):
590
590
  with a typed turn (the plain fallback read_line is sync)."""
591
591
  session_id = "marathon-user-1-rab12cd34ef56"
592
592
 
593
- async def run(self, task, sink, *, marathon=False, goal="", surface=""):
593
+ async def run(self, task, sink, *, marathon=False, goal="", surface="",
594
+ steer_iid=""):
594
595
  self.tasks.append(task)
595
596
  self.runs.append({"task": task, "marathon": marathon, "goal": goal,
596
- "surface": surface})
597
+ "surface": surface, "steer_iid": steer_iid})
597
598
  await asyncio.sleep(0)
598
599
  return f"answer:{task}"
599
600
 
@@ -606,7 +607,7 @@ def test_steer_pickup_renders_remote_line_and_runs_tagged_turn(monkeypatch):
606
607
  marathon=True, live_session_id=lambda: "", **kw):
607
608
  captured["marathon"] = marathon
608
609
  captured["live_sid"] = live_session_id()
609
- await submit("push the fix", "telegram")
610
+ await submit("push the fix", "telegram", "iid-42")
610
611
 
611
612
  monkeypatch.setattr(SP, "poll_idle_steer", spy_poller)
612
613
 
@@ -617,9 +618,11 @@ def test_steer_pickup_renders_remote_line_and_runs_tagged_turn(monkeypatch):
617
618
  sink, agent = _run(read_line=_lines("hello", "again", "/exit"), agent=agent)
618
619
  # the remote user's line renders tagged with its origin surface...
619
620
  assert ("telegram", "user", "push the fix") in getattr(sink, "foreign", [])
620
- # ...and the SAME turn path ran it, threading surface into the turn kwargs
621
+ # ...and the SAME turn path ran it, threading surface + the item's dedup
622
+ # iid into the turn kwargs (steer-iid-dedup pickup path)
621
623
  steer_runs = [r for r in agent.runs if r["task"] == "push the fix"]
622
624
  assert steer_runs and steer_runs[0]["surface"] == "telegram"
625
+ assert steer_runs[0]["steer_iid"] == "iid-42"
623
626
  assert steer_runs[0]["marathon"] is True # normal marathon turn
624
627
  # a picked-up turn ends like any other -- end_turn fired, dock leaves busy
625
628
  assert "answer:push the fix" in sink.turns
@@ -961,3 +961,24 @@ def test_run_omits_surface_key_for_plain_typed_turns(monkeypatch):
961
961
  # to before -- no `surface` key at all.
962
962
  body = _run_turn_capture_body(monkeypatch)
963
963
  assert "surface" not in body
964
+
965
+
966
+ def test_run_threads_steer_iid_into_session_post_body(monkeypatch):
967
+ # steer-iid-dedup pickup path: a picked-up remote instruction carries the
968
+ # queue entry's dedup id so the kernel ring can drop an at-least-once twin.
969
+ body = _run_turn_capture_body(monkeypatch, surface="telegram", steer_iid="iid-42")
970
+ assert body["steer_iid"] == "iid-42"
971
+ assert body["surface"] == "telegram"
972
+
973
+
974
+ def test_run_omits_steer_iid_key_for_plain_typed_turns(monkeypatch):
975
+ # A typed turn has no dedup id -- key omitted, body byte-identical to today.
976
+ body = _run_turn_capture_body(monkeypatch)
977
+ assert "steer_iid" not in body
978
+
979
+
980
+ def test_run_omits_steer_iid_key_when_pickup_item_had_none(monkeypatch):
981
+ # Older gateway: /pending-steer items without `iid` -> "" -> key omitted.
982
+ body = _run_turn_capture_body(monkeypatch, surface="telegram", steer_iid="")
983
+ assert "steer_iid" not in body
984
+ assert body["surface"] == "telegram"
@@ -85,7 +85,7 @@ def test_poll_submits_first_item_with_surface(monkeypatch):
85
85
  import webbee.thread as TH
86
86
  monkeypatch.setattr(TH, "fetch_pending_steer", fake_fetch)
87
87
 
88
- async def submit(text, surface):
88
+ async def submit(text, surface, steer_iid=""):
89
89
  submitted.append((text, surface))
90
90
 
91
91
  _drive(SP.poll_idle_steer(_Cfg(), _tp, workspace=".", is_busy=lambda: False,
@@ -97,6 +97,50 @@ def test_poll_submits_first_item_with_surface(monkeypatch):
97
97
  assert polled[0] == "marathon-user-1-rlive"
98
98
 
99
99
 
100
+ def test_poll_passes_item_iid_to_submit(monkeypatch):
101
+ # steer-iid-dedup pickup path: /pending-steer items carry the queue entry's
102
+ # `iid`; the poller must hand it to submit so the turn POST carries
103
+ # steer_iid and the kernel's dedup ring can drop an at-least-once twin.
104
+ submitted = []
105
+
106
+ async def fake_fetch(cfg, tp, session_id):
107
+ return [{"text": "push the fix", "surface": "telegram", "ts": 1,
108
+ "iid": "iid-42"}]
109
+
110
+ import webbee.thread as TH
111
+ monkeypatch.setattr(TH, "fetch_pending_steer", fake_fetch)
112
+
113
+ async def submit(text, surface, steer_iid=""):
114
+ submitted.append((text, surface, steer_iid))
115
+
116
+ _drive(SP.poll_idle_steer(_Cfg(), _tp, workspace=".", is_busy=lambda: False,
117
+ submit=submit, live_session_id=lambda: "marathon-u-r1",
118
+ interval=0.01),
119
+ until=lambda: submitted)
120
+ assert submitted[0] == ("push the fix", "telegram", "iid-42")
121
+
122
+
123
+ def test_poll_item_without_iid_submits_empty_iid(monkeypatch):
124
+ # An older gateway's items carry no `iid` -- submit gets "" (the turn POST
125
+ # then omits steer_iid entirely), never a crash.
126
+ submitted = []
127
+
128
+ async def fake_fetch(cfg, tp, session_id):
129
+ return [{"text": "resume", "surface": "telegram", "ts": 2}]
130
+
131
+ import webbee.thread as TH
132
+ monkeypatch.setattr(TH, "fetch_pending_steer", fake_fetch)
133
+
134
+ async def submit(text, surface, steer_iid=""):
135
+ submitted.append((text, surface, steer_iid))
136
+
137
+ _drive(SP.poll_idle_steer(_Cfg(), _tp, workspace=".", is_busy=lambda: False,
138
+ submit=submit, live_session_id=lambda: "marathon-u-r1",
139
+ interval=0.01),
140
+ until=lambda: submitted)
141
+ assert submitted[0] == ("resume", "telegram", "")
142
+
143
+
100
144
  def test_poll_no_items_never_submits(monkeypatch):
101
145
  polled = []
102
146
  submitted = []
@@ -108,7 +152,7 @@ def test_poll_no_items_never_submits(monkeypatch):
108
152
  import webbee.thread as TH
109
153
  monkeypatch.setattr(TH, "fetch_pending_steer", fake_fetch)
110
154
 
111
- async def submit(text, surface):
155
+ async def submit(text, surface, steer_iid=""):
112
156
  submitted.append((text, surface))
113
157
 
114
158
  _drive(SP.poll_idle_steer(_Cfg(), _tp, workspace=".", is_busy=lambda: False,
@@ -132,7 +176,7 @@ def test_poll_silent_on_network_error_and_recovers(monkeypatch):
132
176
  import webbee.thread as TH
133
177
  monkeypatch.setattr(TH, "fetch_pending_steer", flaky_fetch)
134
178
 
135
- async def submit(text, surface):
179
+ async def submit(text, surface, steer_iid=""):
136
180
  submitted.append((text, surface))
137
181
 
138
182
  # No exception escapes the poller; the tick after the blip succeeds.
@@ -156,7 +200,7 @@ def test_poll_never_fetches_while_busy(monkeypatch):
156
200
  import webbee.thread as TH
157
201
  monkeypatch.setattr(TH, "fetch_pending_steer", fake_fetch)
158
202
 
159
- async def submit(text, surface):
203
+ async def submit(text, surface, steer_iid=""):
160
204
  submitted.append((text, surface))
161
205
 
162
206
  ticks = {"n": 0}
@@ -192,7 +236,7 @@ def test_multi_item_drain_runs_in_order_one_per_tick_nothing_lost(monkeypatch):
192
236
  import webbee.thread as TH
193
237
  monkeypatch.setattr(TH, "fetch_pending_steer", fake_fetch)
194
238
 
195
- async def submit(text, surface):
239
+ async def submit(text, surface, steer_iid=""):
196
240
  submitted.append((text, surface))
197
241
 
198
242
  # The gateway drain returns each item exactly ONCE -- items 2..n of a batch
@@ -221,7 +265,7 @@ def test_item_deferred_not_lost_when_turn_starts_mid_tick(monkeypatch):
221
265
  def is_busy():
222
266
  return busy_answers.pop(0) if busy_answers else False
223
267
 
224
- async def submit(text, surface):
268
+ async def submit(text, surface, steer_iid=""):
225
269
  submitted.append((text, surface))
226
270
 
227
271
  _drive(SP.poll_idle_steer(_Cfg(), _tp, workspace=".", is_busy=is_busy,
@@ -245,7 +289,7 @@ def test_poller_exits_when_submitted_turn_absorbs_the_cancel(monkeypatch):
245
289
  import webbee.thread as TH
246
290
  monkeypatch.setattr(TH, "fetch_pending_steer", fake_fetch)
247
291
 
248
- async def swallowing_submit(text, surface):
292
+ async def swallowing_submit(text, surface, steer_iid=""):
249
293
  in_submit.set()
250
294
  try:
251
295
  await asyncio.sleep(3600)
@@ -412,3 +412,222 @@ def test_real_escape_with_clean_buffer_still_stops_the_turn():
412
412
  turn = {"task": task}
413
413
  _escape_action({"i": None}, turn, lambda: True, None, event, buf=_FakeBuf("draft reply"))
414
414
  assert task.cancelled == [1] # normal Esc behavior unchanged
415
+
416
+
417
+ # ── Unit Q (0.3.11): type-ahead queue + up-arrow history recall ───────────────
418
+ # Enter while a turn runs used to ERASE the typed line (buf.reset() before the
419
+ # busy gate). Now it queues (Claude-Code type-ahead): the line runs after the
420
+ # current turn, the toolbar shows the depth, and up-arrow recalls submitted
421
+ # lines to edit/resend. Handlers delegate to module-level *_action helpers —
422
+ # same DI testing philosophy as _escape_action/_interrupt_action above.
423
+
424
+ from collections import deque # noqa: E402
425
+
426
+
427
+ class _RecBuf:
428
+ """Buffer stand-in for _submit_line: records history appends."""
429
+ def __init__(self, text=""):
430
+ self.text = text
431
+ class _H:
432
+ def __init__(self): self.items = []
433
+ def append_string(self, s): self.items.append(s)
434
+ self.history = _H()
435
+
436
+
437
+ def test_enter_while_busy_queues_line_not_erased():
438
+ from webbee.tui import _submit_line
439
+ buf = _RecBuf()
440
+ pending = deque()
441
+ started = []
442
+ assert _submit_line("deploy the fix", buf, pending, True, started.append) == "queued"
443
+ assert list(pending) == ["deploy the fix"] # preserved, NOT erased
444
+ assert started == [] # and NOT run mid-turn
445
+ assert buf.history.items == ["deploy the fix"] # recallable via up-arrow
446
+
447
+
448
+ def test_submit_while_idle_starts_turn_immediately():
449
+ # Idle + non-empty = today's normal submit, byte-identical behavior.
450
+ from webbee.tui import _submit_line
451
+ buf = _RecBuf()
452
+ pending = deque()
453
+ started = []
454
+ assert _submit_line("hello", buf, pending, False, started.append) == "started"
455
+ assert started == ["hello"] and not pending
456
+ assert buf.history.items == ["hello"] # typed turns are recallable too
457
+
458
+
459
+ def test_toolbar_shows_queue_depth_busy_and_idle():
460
+ busy = _txt(build_toolbar("default", 0, 0, busy=True, elapsed=1.0, queued=2))
461
+ assert "⋯2 queued" in busy and "working" in busy
462
+ idle = _txt(build_toolbar("default", 0, 0, queued=1))
463
+ assert "⋯1 queued" in idle and "Shift + TAB" in idle
464
+
465
+
466
+ def test_toolbar_hides_queue_segment_when_empty():
467
+ assert "queued" not in _txt(build_toolbar("default", 0, 0, busy=True, elapsed=1.0))
468
+ assert "queued" not in _txt(build_toolbar("default", 0, 0))
469
+
470
+
471
+ def test_turn_completion_drains_oldest_queued_to_submit_path():
472
+ from webbee.tui import _drain_pending
473
+ started = []
474
+ pending = deque(["first", "second"])
475
+ assert _drain_pending(pending, started.append) is True
476
+ assert started == ["first"] # OLDEST goes out…
477
+ assert list(pending) == ["second"] # …one per completion, rest stay
478
+
479
+
480
+ def test_drain_with_empty_queue_is_a_noop():
481
+ from webbee.tui import _drain_pending
482
+ started = []
483
+ assert _drain_pending(deque(), started.append) is False
484
+ assert started == []
485
+
486
+
487
+ def test_fifo_order_across_multiple_queued():
488
+ from webbee.tui import _drain_pending, _submit_line
489
+ buf = _RecBuf()
490
+ pending = deque()
491
+ started = []
492
+ for t in ("a", "b", "c"):
493
+ _submit_line(t, buf, pending, True, started.append)
494
+ assert started == [] # nothing runs mid-turn
495
+ while _drain_pending(pending, started.append): # successive turn completions
496
+ pass
497
+ assert started == ["a", "b", "c"] # strict FIFO
498
+
499
+
500
+ def test_up_arrow_recalls_last_submitted_line():
501
+ from prompt_toolkit.buffer import Buffer
502
+
503
+ from webbee.tui import _arrow_up_action, _submit_line
504
+
505
+ async def scenario():
506
+ buf = Buffer(multiline=False)
507
+ _submit_line("fix the flaky test", buf, deque(), True, lambda t: None)
508
+ buf.load_history_if_not_yet_loaded() # BufferControl does this each repaint
509
+ await buf._load_history_task # loader fills the working lines
510
+ event = _FakeEvent()
511
+ sel = {"i": None}
512
+ _arrow_up_action(event, buf, sel, 0, True) # busy → history, never step-nav
513
+ assert buf.text == "fix the flaky test"
514
+ assert sel["i"] is None # step selection untouched
515
+ asyncio.run(scenario())
516
+
517
+
518
+ def test_down_arrow_cycles_history_forward():
519
+ from prompt_toolkit.buffer import Buffer
520
+
521
+ from webbee.tui import _arrow_down_action, _arrow_up_action
522
+
523
+ async def scenario():
524
+ buf = Buffer(multiline=False)
525
+ buf.history.append_string("older")
526
+ buf.history.append_string("newer")
527
+ buf.load_history_if_not_yet_loaded()
528
+ await buf._load_history_task
529
+ event = _FakeEvent()
530
+ _arrow_up_action(event, buf, {"i": None}, 0, True)
531
+ _arrow_up_action(event, buf, {"i": None}, 0, True)
532
+ assert buf.text == "older"
533
+ _arrow_down_action(event, buf, {"i": None}, 0, True)
534
+ assert buf.text == "newer"
535
+ asyncio.run(scenario())
536
+
537
+
538
+ def test_up_arrow_with_text_present_pulls_history_not_steps():
539
+ # The step-nav gate requires EMPTY input — with a draft in the box the
540
+ # arrow edits history even when idle with steps present.
541
+ from prompt_toolkit.buffer import Buffer
542
+
543
+ from webbee.tui import _arrow_up_action
544
+
545
+ async def scenario():
546
+ buf = Buffer(multiline=False)
547
+ buf.history.append_string("submitted earlier")
548
+ buf.text = "draf"
549
+ buf.load_history_if_not_yet_loaded()
550
+ await buf._load_history_task
551
+ event = _FakeEvent()
552
+ sel = {"i": None}
553
+ _arrow_up_action(event, buf, sel, 3, False) # idle + steps, but text present
554
+ assert buf.text == "submitted earlier"
555
+ assert sel["i"] is None # no step selection created
556
+ asyncio.run(scenario())
557
+
558
+
559
+ def test_up_arrow_during_step_nav_still_navigates_steps():
560
+ # Idle + empty input + steps present = EXACTLY today's step-navigation.
561
+ from prompt_toolkit.buffer import Buffer
562
+
563
+ from webbee.tui import _arrow_down_action, _arrow_up_action
564
+
565
+ buf = Buffer(multiline=False) # empty input, no history touch
566
+ sel = {"i": None}
567
+ event = _FakeEvent()
568
+ _arrow_up_action(event, buf, sel, 3, False)
569
+ assert sel["i"] == 2 and buf.text == "" # selection moves, no history pull
570
+ _arrow_up_action(event, buf, sel, 3, False)
571
+ assert sel["i"] == 1
572
+ _arrow_down_action(event, buf, sel, 3, False)
573
+ assert sel["i"] == 2
574
+ assert event.app.invalidated is True
575
+
576
+
577
+ def test_dock_end_to_end_type_ahead_queues_then_drains_fifo():
578
+ # Drive the REAL Application (pipe input + dummy output): Enter while a
579
+ # turn runs queues the lines instead of erasing them, the drain in
580
+ # _run_turn's finally submits them one per turn-completion, FIFO, through
581
+ # the SAME on_line path a typed line takes; Ctrl-D exits when idle.
582
+ import time
583
+
584
+ from prompt_toolkit.application import create_app_session
585
+ from prompt_toolkit.input import create_pipe_input
586
+ from prompt_toolkit.output import DummyOutput
587
+
588
+ from webbee import tui
589
+
590
+ async def _until(pred, timeout=5.0):
591
+ t0 = time.time()
592
+ while not pred():
593
+ assert time.time() - t0 < timeout, "timed out"
594
+ await asyncio.sleep(0.01)
595
+
596
+ async def scenario():
597
+ pane = tui.OutputPane(width=80)
598
+ ran = []
599
+ gate = asyncio.Event()
600
+ busy = {"v": False}
601
+
602
+ async def on_line(text):
603
+ busy["v"] = True
604
+ ran.append(text)
605
+ await gate.wait()
606
+ busy["v"] = False
607
+
608
+ def status():
609
+ return {"tokens": 0, "credits": 0, "busy": busy["v"], "current": "",
610
+ "elapsed": 0.0, "tools": 0, "consent": False}
611
+
612
+ with create_pipe_input() as pipe:
613
+ with create_app_session(input=pipe, output=DummyOutput()):
614
+ task = asyncio.create_task(tui.run_session(
615
+ pane=pane, on_line=on_line, mode_getter=lambda: "default",
616
+ on_cycle=lambda: None, status=status,
617
+ is_busy=lambda: busy["v"],
618
+ consent_pending=lambda: False, resolve_consent=lambda t: None))
619
+ await asyncio.sleep(0.05)
620
+ pipe.send_text("first\r")
621
+ await _until(lambda: ran == ["first"]) # turn 1 is running
622
+ pipe.send_text("second\r") # typed WHILE busy → queue
623
+ pipe.send_text("third\r")
624
+ await asyncio.sleep(0.1)
625
+ assert ran == ["first"] # nothing runs mid-turn
626
+ gate.set() # let the turns finish
627
+ await _until(lambda: ran == ["first", "second", "third"]) # FIFO
628
+ await _until(lambda: not busy["v"])
629
+ pipe.send_text("\x04") # Ctrl-D exit (idle)
630
+ ok = await asyncio.wait_for(task, 5)
631
+ assert ok is True
632
+
633
+ asyncio.run(scenario())
@@ -1 +0,0 @@
1
- __version__ = "0.3.9"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes