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.
- {webbee-0.3.9 → webbee-0.3.11}/CHANGELOG.md +12 -0
- {webbee-0.3.9 → webbee-0.3.11}/PKG-INFO +1 -1
- {webbee-0.3.9 → webbee-0.3.11}/pyproject.toml +1 -1
- webbee-0.3.11/src/webbee/__init__.py +1 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/repl.py +10 -7
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/session.py +6 -1
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/steer.py +11 -6
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/tui.py +90 -21
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_repl.py +7 -4
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_session.py +21 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_steer.py +51 -7
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_tui.py +219 -0
- webbee-0.3.9/src/webbee/__init__.py +0 -1
- {webbee-0.3.9 → webbee-0.3.11}/.github/workflows/publish.yml +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/.gitignore +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/LICENSE +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/README.md +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/install.sh +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/account.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/banner_art.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/boot.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/checkpoints.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/cli.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/clipboard.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/coding_context.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/commands.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/config.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/consent.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/details.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/events.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/frames.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/intel/embed.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/intel/graph.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/intel/models.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/intel/query.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/intel/service.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/intel/store.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/intel/watch.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/output_pane.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/remote.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/render.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/repo.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/sessions.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/stream.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/thread.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/tokens.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/tools.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/src/webbee/update.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/__init__.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_account.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_checkpoints.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_cli.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_clipboard.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_commands.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_config.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_details.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_events.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_freeze_fix.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_intel_chunker.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_intel_embed.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_intel_graph.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_intel_indexer.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_intel_query.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_intel_service.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_intel_store.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_intel_vectors.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_intel_watch.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_marathon.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_packaging.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_render.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_repo.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_sessions.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_stream.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_thread.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_tokens.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_tools.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_tui_hardening.py +0 -0
- {webbee-0.3.9 → webbee-0.3.11}/tests/test_update.py +0 -0
- {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
|
|
@@ -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`
|
|
221
|
-
Only the echo differs at the call sites:
|
|
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
|
|
62
|
-
-- the repl's normal turn path. Seams
|
|
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
|
|
71
|
-
turn. Runs INSIDE this task, so polling
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
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).
|
|
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)
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
252
|
+
if not text.strip():
|
|
182
253
|
return
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|