webbee 0.1.0__py3-none-any.whl
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/__init__.py +1 -0
- webbee/account.py +78 -0
- webbee/banner_art.py +9 -0
- webbee/cli.py +56 -0
- webbee/commands.py +77 -0
- webbee/config.py +14 -0
- webbee/events.py +17 -0
- webbee/render.py +313 -0
- webbee/repl.py +157 -0
- webbee/session.py +160 -0
- webbee/tools.py +84 -0
- webbee/tui.py +217 -0
- webbee/update.py +52 -0
- webbee-0.1.0.dist-info/METADATA +86 -0
- webbee-0.1.0.dist-info/RECORD +18 -0
- webbee-0.1.0.dist-info/WHEEL +4 -0
- webbee-0.1.0.dist-info/entry_points.txt +2 -0
- webbee-0.1.0.dist-info/licenses/LICENSE +21 -0
webbee/repl.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from webbee import __version__
|
|
7
|
+
from webbee.commands import CommandContext, dispatch
|
|
8
|
+
from webbee.session import AgentSession
|
|
9
|
+
from webbee.tui import next_mode
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _git_branch(workspace: str) -> str:
|
|
13
|
+
try:
|
|
14
|
+
p = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=workspace,
|
|
15
|
+
capture_output=True, text=True, timeout=5)
|
|
16
|
+
return p.stdout.strip() if p.returncode == 0 else "-"
|
|
17
|
+
except (OSError, subprocess.SubprocessError):
|
|
18
|
+
return "-"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def run_repl(cfg, mode: str = "default", *, sink=None, read_line=input,
|
|
22
|
+
agent_factory=None, auth=None, account_fetcher=None) -> None:
|
|
23
|
+
"""Interactive coding REPL. Production (a real tty, no injected sink) runs
|
|
24
|
+
the persistent prompt_toolkit dock (`tui.run_session`): the bordered input
|
|
25
|
+
box is pinned at the bottom, turn output scrolls above it (patch_stdout →
|
|
26
|
+
native scrollback), and turns run as background tasks. Tests / non-tty use
|
|
27
|
+
the injected sync `read_line` fallback loop. Both share `_handle`."""
|
|
28
|
+
if auth is None:
|
|
29
|
+
from imperal_mcp import auth as _auth
|
|
30
|
+
auth = _auth
|
|
31
|
+
if agent_factory is None:
|
|
32
|
+
agent_factory = lambda c, tp, ws, m: AgentSession(c, tp, ws, m) # noqa: E731
|
|
33
|
+
if account_fetcher is None:
|
|
34
|
+
from webbee.account import fetch_account as account_fetcher
|
|
35
|
+
|
|
36
|
+
workspace = os.getcwd()
|
|
37
|
+
|
|
38
|
+
async def token_provider() -> str:
|
|
39
|
+
return await auth.ensure_access_token(cfg)
|
|
40
|
+
|
|
41
|
+
# Prod dock path = the default reader + a real tty + no injected sink; tests
|
|
42
|
+
# inject sink/read_line and take the plain fallback loop.
|
|
43
|
+
use_dock = sink is None and read_line is input and sys.stdin.isatty()
|
|
44
|
+
state = {"mode": mode, "logged_in": False}
|
|
45
|
+
_sink = None # assigned by _boot
|
|
46
|
+
agent = None # assigned by _boot
|
|
47
|
+
|
|
48
|
+
def _cycle() -> None:
|
|
49
|
+
state["mode"] = next_mode(state["mode"])
|
|
50
|
+
agent.mode = state["mode"]
|
|
51
|
+
|
|
52
|
+
def _ctx() -> CommandContext:
|
|
53
|
+
return CommandContext(mode=state["mode"], workspace=workspace, version=__version__,
|
|
54
|
+
surface="terminal", logged_in=state["logged_in"],
|
|
55
|
+
session_tokens=getattr(_sink, "session_tokens", 0),
|
|
56
|
+
session_cost=getattr(_sink, "session_cost", 0.0),
|
|
57
|
+
git_branch=_git_branch(workspace))
|
|
58
|
+
|
|
59
|
+
async def _handle(line: str) -> str:
|
|
60
|
+
"""Process one input line. Returns 'exit' or 'continue'."""
|
|
61
|
+
if not line.strip():
|
|
62
|
+
return "continue"
|
|
63
|
+
res = dispatch(line, _ctx())
|
|
64
|
+
if res.handled:
|
|
65
|
+
if res.exit:
|
|
66
|
+
return "exit"
|
|
67
|
+
if res.action == "login":
|
|
68
|
+
email = auth.login(cfg)
|
|
69
|
+
state["logged_in"] = True
|
|
70
|
+
_sink.note(f"Signed in as {email}.")
|
|
71
|
+
return "continue"
|
|
72
|
+
if res.action == "logout":
|
|
73
|
+
await auth.logout(cfg)
|
|
74
|
+
state["logged_in"] = False
|
|
75
|
+
_sink.note("Signed out, local credentials removed.")
|
|
76
|
+
return "continue"
|
|
77
|
+
if res.action == "clear":
|
|
78
|
+
_sink.clear()
|
|
79
|
+
_sink.note(res.message)
|
|
80
|
+
return "continue"
|
|
81
|
+
if res.action == "mode" and res.new_mode:
|
|
82
|
+
state["mode"] = res.new_mode
|
|
83
|
+
agent.mode = res.new_mode
|
|
84
|
+
if res.message:
|
|
85
|
+
_sink.note(res.message)
|
|
86
|
+
return "continue"
|
|
87
|
+
|
|
88
|
+
# A task for the agent.
|
|
89
|
+
_sink.user_echo(line)
|
|
90
|
+
_sink.begin_turn()
|
|
91
|
+
try:
|
|
92
|
+
text = await agent.run(line, _sink)
|
|
93
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
94
|
+
_sink.abort()
|
|
95
|
+
_sink.note("Interrupted.")
|
|
96
|
+
return "continue"
|
|
97
|
+
except Exception as e: # network/auth/etc — never crash the REPL
|
|
98
|
+
_sink.note(f"Error: {type(e).__name__}: {e}")
|
|
99
|
+
return "continue"
|
|
100
|
+
_sink.end_turn(text)
|
|
101
|
+
return "continue"
|
|
102
|
+
|
|
103
|
+
async def _boot(s) -> None:
|
|
104
|
+
nonlocal _sink, agent
|
|
105
|
+
_sink = s
|
|
106
|
+
account = await account_fetcher(cfg, token_provider)
|
|
107
|
+
state["logged_in"] = account.signed_in
|
|
108
|
+
_sink.welcome(account, workspace, "terminal")
|
|
109
|
+
agent = agent_factory(cfg, token_provider, workspace, state["mode"])
|
|
110
|
+
|
|
111
|
+
if use_dock:
|
|
112
|
+
ok = False
|
|
113
|
+
pane = None
|
|
114
|
+
try:
|
|
115
|
+
import shutil
|
|
116
|
+
|
|
117
|
+
from webbee import tui
|
|
118
|
+
from webbee.render import RichSink
|
|
119
|
+
width = shutil.get_terminal_size((100, 24)).columns
|
|
120
|
+
pane = tui.OutputPane(width=width)
|
|
121
|
+
await _boot(RichSink(console=pane.console, on_output=pane.notify))
|
|
122
|
+
|
|
123
|
+
async def _on_line(text: str) -> None:
|
|
124
|
+
if await _handle(text) == "exit":
|
|
125
|
+
from prompt_toolkit.application import get_app
|
|
126
|
+
get_app().exit()
|
|
127
|
+
|
|
128
|
+
ok = await tui.run_session(
|
|
129
|
+
pane=pane, on_line=_on_line, mode_getter=lambda: state["mode"],
|
|
130
|
+
on_cycle=_cycle, status=_sink.status, is_busy=_sink.is_busy,
|
|
131
|
+
consent_pending=_sink.consent_pending, resolve_consent=_sink.resolve_consent)
|
|
132
|
+
except Exception:
|
|
133
|
+
ok = False
|
|
134
|
+
if ok:
|
|
135
|
+
# the alt screen is gone — reprint the session transcript to real
|
|
136
|
+
# stdout so the conversation stays in the terminal scrollback.
|
|
137
|
+
if pane is not None:
|
|
138
|
+
sys.stdout.write(pane.dump())
|
|
139
|
+
sys.stdout.flush()
|
|
140
|
+
return
|
|
141
|
+
# dock unavailable → fall through to the plain fallback loop
|
|
142
|
+
|
|
143
|
+
# Fallback loop (tests / non-tty / dock unavailable).
|
|
144
|
+
if _sink is None:
|
|
145
|
+
if sink is None:
|
|
146
|
+
from webbee.render import RichSink
|
|
147
|
+
sink = RichSink()
|
|
148
|
+
await _boot(sink)
|
|
149
|
+
while True:
|
|
150
|
+
try:
|
|
151
|
+
line = read_line("❯ ")
|
|
152
|
+
except (EOFError, KeyboardInterrupt):
|
|
153
|
+
return
|
|
154
|
+
if line is None:
|
|
155
|
+
return
|
|
156
|
+
if await _handle(line) == "exit":
|
|
157
|
+
return
|
webbee/session.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def handle_tool_request(frame: dict, executor) -> dict:
|
|
6
|
+
"""Run a (kernel-pre-approved) local tool. The kernel gates write/bash
|
|
7
|
+
consent via confirm_request BEFORE dispatching, so a tool_request here is
|
|
8
|
+
always cleared to run."""
|
|
9
|
+
return {"req_id": frame["req_id"], "result": executor.run(frame["tool"], frame.get("args", {}))}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def handle_confirm_request(frame: dict, mode: str, ask_consent) -> dict:
|
|
13
|
+
"""ICNLI consent path. The client does NOT interpret consent words — it
|
|
14
|
+
relays the user's RAW reply; the kernel brain interprets intent
|
|
15
|
+
(safe-by-default). autopilot/plan are explicit and never prompt.
|
|
16
|
+
`ask_consent(app_id, tool, args)` is an async coroutine returning the raw
|
|
17
|
+
reply (resolved through the pinned dock, or a sync fallback reader)."""
|
|
18
|
+
req_id = frame["req_id"]
|
|
19
|
+
if mode == "autopilot":
|
|
20
|
+
return {"req_id": req_id, "result": {"approved": True}}
|
|
21
|
+
if mode == "plan":
|
|
22
|
+
return {"req_id": req_id, "result": {"approved": False, "reason": "plan_mode"}}
|
|
23
|
+
raw = await ask_consent(frame.get("app_id", ""), frame.get("tool", ""), frame.get("args", {}))
|
|
24
|
+
return {"req_id": req_id, "result": {"consent_reply": raw}}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_coding_context(workspace_root: str) -> dict:
|
|
28
|
+
"""Snapshot handed to the cloud brain: cwd (realpath), `git status -sb`
|
|
29
|
+
(empty for non-git/any error), and a bounded newline-joined file tree."""
|
|
30
|
+
cwd = os.path.realpath(workspace_root)
|
|
31
|
+
try:
|
|
32
|
+
proc = subprocess.run(
|
|
33
|
+
["git", "status", "-sb"], cwd=cwd,
|
|
34
|
+
capture_output=True, text=True, timeout=10,
|
|
35
|
+
)
|
|
36
|
+
git = proc.stdout if proc.returncode == 0 else ""
|
|
37
|
+
except (OSError, subprocess.SubprocessError):
|
|
38
|
+
git = ""
|
|
39
|
+
paths = []
|
|
40
|
+
for dirpath, dirnames, filenames in os.walk(cwd):
|
|
41
|
+
dirnames[:] = [d for d in dirnames if d != ".git" and not d.startswith(".")]
|
|
42
|
+
for fn in filenames:
|
|
43
|
+
paths.append(os.path.relpath(os.path.join(dirpath, fn), cwd))
|
|
44
|
+
if len(paths) >= 200:
|
|
45
|
+
break
|
|
46
|
+
if len(paths) >= 200:
|
|
47
|
+
break
|
|
48
|
+
return {"cwd": cwd, "git": git, "tree": "\n".join(paths)}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _summary(result: dict) -> str:
|
|
52
|
+
"""One-line summary of a tool result for the UI."""
|
|
53
|
+
content = str(result.get("content", ""))
|
|
54
|
+
first = content.strip().splitlines()[0] if content.strip() else ""
|
|
55
|
+
return first[:120]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AgentSession:
|
|
59
|
+
"""Client-side driver for one coding turn against the Imperal cloud.
|
|
60
|
+
The brain runs server-side; this is the hands — it streams kernel-
|
|
61
|
+
pre-approved tool_request frames over SSE, runs each tool locally, relays
|
|
62
|
+
confirm_request replies RAW for the brain to interpret, drives the sink
|
|
63
|
+
for live UI, and posts results back until a final frame arrives.
|
|
64
|
+
|
|
65
|
+
P1: one POST per turn (server reloads the shared webbee-terminal thread,
|
|
66
|
+
so context carries across turns). Persistent signal-based sessions are P3."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, cfg, token_provider, workspace_root: str, mode: str = "default") -> None:
|
|
69
|
+
self.cfg = cfg
|
|
70
|
+
self.token_provider = token_provider
|
|
71
|
+
self.workspace_root = workspace_root
|
|
72
|
+
self.mode = mode
|
|
73
|
+
|
|
74
|
+
async def _headers(self) -> dict:
|
|
75
|
+
token = await self.token_provider()
|
|
76
|
+
return {"Authorization": f"Bearer {token}"}
|
|
77
|
+
|
|
78
|
+
async def run(self, task: str, sink) -> str:
|
|
79
|
+
import httpx
|
|
80
|
+
from httpx_sse import aconnect_sse
|
|
81
|
+
|
|
82
|
+
from webbee.tools import LocalToolExecutor
|
|
83
|
+
from imperal_mcp.client import ImperalClient
|
|
84
|
+
|
|
85
|
+
coding_context = build_coding_context(self.workspace_root)
|
|
86
|
+
imperal_id = await ImperalClient(self.cfg, self.token_provider).whoami()
|
|
87
|
+
executor = LocalToolExecutor(self.workspace_root)
|
|
88
|
+
|
|
89
|
+
headers = await self._headers()
|
|
90
|
+
async with httpx.AsyncClient(base_url=self.cfg.api_url, timeout=60) as client:
|
|
91
|
+
resp = await client.post(
|
|
92
|
+
"/v1/agent/sessions",
|
|
93
|
+
json={"user_id": imperal_id, "task": task, "coding_context": coding_context},
|
|
94
|
+
headers=headers,
|
|
95
|
+
)
|
|
96
|
+
resp.raise_for_status()
|
|
97
|
+
session_id = resp.json()["session_id"]
|
|
98
|
+
|
|
99
|
+
seen: dict = {} # req_id -> already-posted result (at-least-once dedup)
|
|
100
|
+
headers = await self._headers()
|
|
101
|
+
async with aconnect_sse(
|
|
102
|
+
client, "GET", f"/v1/agent/sessions/{session_id}/stream", headers=headers,
|
|
103
|
+
) as event_source:
|
|
104
|
+
async for sse in event_source.aiter_sse():
|
|
105
|
+
frame = sse.json()
|
|
106
|
+
ftype = frame.get("type")
|
|
107
|
+
|
|
108
|
+
if ftype == "tool_request":
|
|
109
|
+
rid = frame.get("req_id")
|
|
110
|
+
if rid in seen:
|
|
111
|
+
out = seen[rid]
|
|
112
|
+
else:
|
|
113
|
+
sink.tool_start(frame.get("tool", ""), frame.get("args", {}))
|
|
114
|
+
out = handle_tool_request(frame, executor)
|
|
115
|
+
res = out["result"]
|
|
116
|
+
sink.tool_result(frame.get("tool", ""), bool(res.get("ok")), _summary(res))
|
|
117
|
+
seen[rid] = out
|
|
118
|
+
await self._post_result(client, session_id, out)
|
|
119
|
+
|
|
120
|
+
elif ftype == "confirm_request":
|
|
121
|
+
rid = frame.get("req_id")
|
|
122
|
+
if rid in seen:
|
|
123
|
+
out = seen[rid]
|
|
124
|
+
else:
|
|
125
|
+
if self.mode == "plan":
|
|
126
|
+
sink.plan_blocked(frame.get("tool", ""))
|
|
127
|
+
out = await handle_confirm_request(frame, self.mode, sink.ask_consent)
|
|
128
|
+
seen[rid] = out
|
|
129
|
+
await self._post_result(client, session_id, out)
|
|
130
|
+
|
|
131
|
+
elif ftype == "panel_release_required":
|
|
132
|
+
sink.panel_release(frame.get("panel_url", ""), frame.get("summary", ""))
|
|
133
|
+
|
|
134
|
+
elif ftype == "action": # R2 — ext-tool call (server-side) surfaced in the feed
|
|
135
|
+
_lbl = "·".join(x for x in (frame.get("app_id", ""), frame.get("tool", "")) if x)
|
|
136
|
+
if frame.get("phase") == "start":
|
|
137
|
+
sink.tool_start(_lbl, {})
|
|
138
|
+
else:
|
|
139
|
+
_summ = str(frame.get("summary", "") or "")
|
|
140
|
+
if _summ in ("None", "none"): # tool result had no content — clean ✓
|
|
141
|
+
_summ = ""
|
|
142
|
+
sink.tool_result(_lbl, bool(frame.get("ok")), _summ)
|
|
143
|
+
|
|
144
|
+
elif ftype == "progress": # P2 — server not emitting yet; forward-compatible
|
|
145
|
+
sink.progress(frame.get("text", ""))
|
|
146
|
+
|
|
147
|
+
elif ftype == "usage": # P2 — cumulative tokens + USD cost
|
|
148
|
+
sink.usage(
|
|
149
|
+
int(frame.get("tokens", 0) or 0),
|
|
150
|
+
float(frame.get("cost_usd", 0.0) or 0.0),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
elif ftype == "final":
|
|
154
|
+
return frame.get("text", "")
|
|
155
|
+
|
|
156
|
+
return ""
|
|
157
|
+
|
|
158
|
+
async def _post_result(self, client, session_id: str, out: dict) -> None:
|
|
159
|
+
headers = await self._headers()
|
|
160
|
+
await client.post(f"/v1/agent/sessions/{session_id}/result", json=out, headers=headers)
|
webbee/tools.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OutsideWorkspaceError(Exception):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LocalToolExecutor:
|
|
11
|
+
def __init__(self, workspace_root: str) -> None:
|
|
12
|
+
self.root = os.path.realpath(workspace_root)
|
|
13
|
+
|
|
14
|
+
def resolve_in_workspace(self, path: str) -> str:
|
|
15
|
+
full = os.path.realpath(os.path.join(self.root, path))
|
|
16
|
+
if full != self.root and not full.startswith(self.root + os.sep):
|
|
17
|
+
raise OutsideWorkspaceError(path)
|
|
18
|
+
return full
|
|
19
|
+
|
|
20
|
+
def run(self, tool: str, args: dict) -> dict:
|
|
21
|
+
try:
|
|
22
|
+
fn = getattr(self, f"_t_{tool}", None)
|
|
23
|
+
if fn is None:
|
|
24
|
+
return {"ok": False, "content": f"unknown tool: {tool}"}
|
|
25
|
+
return fn(args)
|
|
26
|
+
except OutsideWorkspaceError:
|
|
27
|
+
raise
|
|
28
|
+
except Exception as e: # surface tool errors to the brain, don't crash
|
|
29
|
+
return {"ok": False, "content": f"{type(e).__name__}: {e}"}
|
|
30
|
+
|
|
31
|
+
def _t_read_file(self, a: dict) -> dict:
|
|
32
|
+
p = self.resolve_in_workspace(a["path"])
|
|
33
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
34
|
+
return {"ok": True, "content": f.read()}
|
|
35
|
+
|
|
36
|
+
def _t_write_file(self, a: dict) -> dict:
|
|
37
|
+
p = self.resolve_in_workspace(a["path"])
|
|
38
|
+
os.makedirs(os.path.dirname(p) or self.root, exist_ok=True)
|
|
39
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
40
|
+
f.write(a.get("content", ""))
|
|
41
|
+
return {"ok": True, "content": f"wrote {a['path']}"}
|
|
42
|
+
|
|
43
|
+
def _t_edit_file(self, a: dict) -> dict:
|
|
44
|
+
p = self.resolve_in_workspace(a["path"])
|
|
45
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
46
|
+
text = f.read()
|
|
47
|
+
if a["old"] not in text:
|
|
48
|
+
return {"ok": False, "content": "old string not found"}
|
|
49
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
50
|
+
f.write(text.replace(a["old"], a["new"], 1))
|
|
51
|
+
return {"ok": True, "content": f"edited {a['path']}"}
|
|
52
|
+
|
|
53
|
+
def _t_bash(self, a: dict) -> dict:
|
|
54
|
+
proc = subprocess.run(
|
|
55
|
+
a["command"], shell=True, cwd=self.root,
|
|
56
|
+
capture_output=True, text=True, timeout=a.get("timeout", 120),
|
|
57
|
+
)
|
|
58
|
+
out = (proc.stdout or "") + (proc.stderr or "")
|
|
59
|
+
return {"ok": proc.returncode == 0, "content": out or f"(exit {proc.returncode})"}
|
|
60
|
+
|
|
61
|
+
def _t_grep(self, a: dict) -> dict:
|
|
62
|
+
pat = re.compile(a["pattern"])
|
|
63
|
+
base = self.resolve_in_workspace(a.get("path", "."))
|
|
64
|
+
hits = []
|
|
65
|
+
for dp, _dn, fns in os.walk(base):
|
|
66
|
+
if "/.git" in dp:
|
|
67
|
+
continue
|
|
68
|
+
for fn in fns:
|
|
69
|
+
fp = os.path.join(dp, fn)
|
|
70
|
+
try:
|
|
71
|
+
with open(fp, "r", encoding="utf-8") as f:
|
|
72
|
+
for i, line in enumerate(f, 1):
|
|
73
|
+
if pat.search(line):
|
|
74
|
+
rel = os.path.relpath(fp, self.root)
|
|
75
|
+
hits.append(f"{rel}:{i}:{line.rstrip()}")
|
|
76
|
+
except (UnicodeDecodeError, OSError):
|
|
77
|
+
continue
|
|
78
|
+
return {"ok": True, "content": "\n".join(hits[:200]) or "(no matches)"}
|
|
79
|
+
|
|
80
|
+
def _t_glob(self, a: dict) -> dict:
|
|
81
|
+
import glob as _g
|
|
82
|
+
base = os.path.join(self.root, a["pattern"])
|
|
83
|
+
rels = [os.path.relpath(p, self.root) for p in _g.glob(base, recursive=True)]
|
|
84
|
+
return {"ok": True, "content": "\n".join(sorted(rels)) or "(no matches)"}
|
webbee/tui.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Full-screen dock: a scrollable, colored output pane (Rich → ANSI) fills the
|
|
2
|
+
top; a bordered input box + toolbar are pinned at the very bottom and never
|
|
3
|
+
move while the output scrolls (mouse wheel / PageUp). Pure helpers
|
|
4
|
+
(next_mode/build_toolbar) are unit-tested; the Application + OutputPane are
|
|
5
|
+
TTY/headless-smoke verified. Grounded in prompt_toolkit 3.0.52."""
|
|
6
|
+
import asyncio
|
|
7
|
+
|
|
8
|
+
from webbee.render import _fmt_tokens
|
|
9
|
+
|
|
10
|
+
_MODES = ("default", "plan", "autopilot")
|
|
11
|
+
_SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" # braille frames — animated while a turn runs
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def next_mode(mode: str) -> str:
|
|
15
|
+
try:
|
|
16
|
+
return _MODES[(_MODES.index(mode) + 1) % len(_MODES)]
|
|
17
|
+
except ValueError:
|
|
18
|
+
return _MODES[0]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_toolbar(mode: str, tokens: int, cost: float, *, busy: bool = False,
|
|
22
|
+
current: str = "", elapsed: float = 0.0, tools: int = 0,
|
|
23
|
+
consent: bool = False) -> list:
|
|
24
|
+
"""The status line under the pinned input box, as prompt_toolkit formatted
|
|
25
|
+
text (per-segment styled). Three states: consent (awaiting a reply), busy
|
|
26
|
+
(a turn is running — an ANIMATED coloured spinner + the current action in
|
|
27
|
+
accent, so it pops, not grey), and idle (mode value coloured PER MODE —
|
|
28
|
+
default cyan / plan purple / autopilot yellow — + SESSION spend + the
|
|
29
|
+
Shift + TAB hint). Style classes are defined in run_session's Style."""
|
|
30
|
+
if consent:
|
|
31
|
+
return [("class:tb.consent", " approve? type y / n / a reply · Enter to send")]
|
|
32
|
+
if busy:
|
|
33
|
+
spin = _SPINNER[int(elapsed * 10) % len(_SPINNER)] # animates via the ticker
|
|
34
|
+
frags = [("class:tb.spin", f" {spin} "), ("class:tb.working", "working")]
|
|
35
|
+
if current:
|
|
36
|
+
frags += [("class:tb.dim", " · "), ("class:tb.action", current)]
|
|
37
|
+
frags.append(("class:tb.dim",
|
|
38
|
+
f" · {elapsed:.0f}s · {tools} · {_fmt_tokens(tokens)} tok"
|
|
39
|
+
f" · Ctrl-C to stop"))
|
|
40
|
+
return frags
|
|
41
|
+
return [("class:tb.dim", " mode: "),
|
|
42
|
+
(f"class:tb.mode.{mode}", mode),
|
|
43
|
+
("class:tb.dim",
|
|
44
|
+
f" · {_fmt_tokens(tokens)} tok · ${cost:.4f} · Shift + TAB: switch mode")]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class OutputPane:
|
|
48
|
+
"""A full-screen scrollable pane that shows COLORED output. Rich renders
|
|
49
|
+
into a StringIO as ANSI; a FormattedTextControl(ANSI(...)) shows it with the
|
|
50
|
+
colours intact. The `get_cursor_position == vertical_scroll` trick makes
|
|
51
|
+
Window.vertical_scroll authoritative so the mouse wheel / PageUp scroll it;
|
|
52
|
+
notify() auto-follows the tail ONLY when the user is already at the bottom
|
|
53
|
+
(so scrolling up to read history isn't yanked back down). Grounded in
|
|
54
|
+
prompt_toolkit 3.0.52 (verified in venv)."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, width: int = 100) -> None:
|
|
57
|
+
import io
|
|
58
|
+
|
|
59
|
+
from prompt_toolkit.data_structures import Point
|
|
60
|
+
from prompt_toolkit.formatted_text import ANSI
|
|
61
|
+
from prompt_toolkit.layout.containers import Window
|
|
62
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
63
|
+
from rich.console import Console
|
|
64
|
+
|
|
65
|
+
self._io = io.StringIO()
|
|
66
|
+
self.console = Console(file=self._io, force_terminal=True,
|
|
67
|
+
color_system="truecolor", width=width, highlight=False)
|
|
68
|
+
self._ANSI = ANSI
|
|
69
|
+
self._cache = (None, None) # (text, ANSI) memo — bounds re-parse cost
|
|
70
|
+
self.control = FormattedTextControl(
|
|
71
|
+
text=self._formatted, focusable=False, show_cursor=False,
|
|
72
|
+
get_cursor_position=lambda: Point(0, self.window.vertical_scroll))
|
|
73
|
+
self.window = Window(content=self.control, wrap_lines=False,
|
|
74
|
+
always_hide_cursor=True, allow_scroll_beyond_bottom=False)
|
|
75
|
+
|
|
76
|
+
def _formatted(self):
|
|
77
|
+
text = self._io.getvalue()
|
|
78
|
+
if self._cache[0] != text: # only re-parse when it changed
|
|
79
|
+
self._cache = (text, self._ANSI(text))
|
|
80
|
+
return self._cache[1]
|
|
81
|
+
|
|
82
|
+
def notify(self) -> None:
|
|
83
|
+
"""Called after each sink print: follow the tail if the user is at the
|
|
84
|
+
bottom, then redraw. If they've scrolled up, leave their scroll alone."""
|
|
85
|
+
self._trim()
|
|
86
|
+
ri = self.window.render_info
|
|
87
|
+
if ri is not None and ri.bottom_visible:
|
|
88
|
+
n = self._io.getvalue().count("\n") + 1
|
|
89
|
+
self.window.vertical_scroll = max(0, n - ri.window_height)
|
|
90
|
+
try:
|
|
91
|
+
from prompt_toolkit.application import get_app_or_none
|
|
92
|
+
app = get_app_or_none()
|
|
93
|
+
if app is not None:
|
|
94
|
+
app.invalidate()
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
def _trim(self, max_lines: int = 5000) -> None:
|
|
99
|
+
import io
|
|
100
|
+
s = self._io.getvalue()
|
|
101
|
+
if s.count("\n") > max_lines:
|
|
102
|
+
s = "\n".join(s.split("\n")[-max_lines:])
|
|
103
|
+
self._io = io.StringIO()
|
|
104
|
+
self._io.write(s)
|
|
105
|
+
self.console.file = self._io
|
|
106
|
+
|
|
107
|
+
def dump(self) -> str:
|
|
108
|
+
"""The full session transcript (ANSI). Printed to real stdout on exit so
|
|
109
|
+
the conversation survives leaving the alternate screen."""
|
|
110
|
+
return self._io.getvalue()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
|
|
114
|
+
is_busy, consent_pending, resolve_consent) -> bool:
|
|
115
|
+
"""The full-screen dock: `pane` fills the top (scrollable), a bordered input
|
|
116
|
+
box + toolbar are FIXED at the bottom. Enter either resolves a pending
|
|
117
|
+
consent reply (ICNLI: raw verbatim) or starts a turn as a BACKGROUND task
|
|
118
|
+
(the box stays fixed during it). Returns True on clean exit; False if
|
|
119
|
+
prompt_toolkit is unavailable (caller uses the plain fallback loop)."""
|
|
120
|
+
try:
|
|
121
|
+
from prompt_toolkit.application import Application, get_app
|
|
122
|
+
from prompt_toolkit.buffer import Buffer
|
|
123
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
124
|
+
from prompt_toolkit.layout import HSplit, Layout, Window
|
|
125
|
+
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
|
|
126
|
+
from prompt_toolkit.layout.processors import BeforeInput
|
|
127
|
+
from prompt_toolkit.styles import Style
|
|
128
|
+
from prompt_toolkit.widgets import Frame
|
|
129
|
+
except Exception:
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
buf = Buffer(multiline=False)
|
|
133
|
+
turn = {"task": None}
|
|
134
|
+
|
|
135
|
+
async def _run_turn(text):
|
|
136
|
+
try:
|
|
137
|
+
await on_line(text)
|
|
138
|
+
finally:
|
|
139
|
+
turn["task"] = None
|
|
140
|
+
get_app().invalidate()
|
|
141
|
+
|
|
142
|
+
kb = KeyBindings()
|
|
143
|
+
|
|
144
|
+
@kb.add("enter")
|
|
145
|
+
def _enter(event):
|
|
146
|
+
text = buf.text
|
|
147
|
+
buf.reset()
|
|
148
|
+
if consent_pending():
|
|
149
|
+
resolve_consent(text) # ICNLI: relay the raw reply verbatim
|
|
150
|
+
return
|
|
151
|
+
if is_busy() or not text.strip():
|
|
152
|
+
return
|
|
153
|
+
turn["task"] = event.app.create_background_task(_run_turn(text))
|
|
154
|
+
|
|
155
|
+
@kb.add("s-tab")
|
|
156
|
+
def _cycle(event):
|
|
157
|
+
on_cycle()
|
|
158
|
+
event.app.invalidate()
|
|
159
|
+
|
|
160
|
+
@kb.add("c-c")
|
|
161
|
+
def _interrupt(event):
|
|
162
|
+
t = turn["task"]
|
|
163
|
+
if t is not None and not t.done():
|
|
164
|
+
t.cancel() # cancel the running turn; dock survives
|
|
165
|
+
|
|
166
|
+
@kb.add("c-d")
|
|
167
|
+
def _eof(event):
|
|
168
|
+
if not is_busy():
|
|
169
|
+
event.app.exit()
|
|
170
|
+
|
|
171
|
+
@kb.add("pageup")
|
|
172
|
+
def _pgup(event):
|
|
173
|
+
pane.window.vertical_scroll = max(0, pane.window.vertical_scroll - 5)
|
|
174
|
+
|
|
175
|
+
@kb.add("pagedown")
|
|
176
|
+
def _pgdn(event):
|
|
177
|
+
pane.window.vertical_scroll += 5
|
|
178
|
+
|
|
179
|
+
def _toolbar():
|
|
180
|
+
st = status()
|
|
181
|
+
return build_toolbar(mode_getter(), st["tokens"], st["cost"], busy=st["busy"],
|
|
182
|
+
current=st["current"], elapsed=st["elapsed"],
|
|
183
|
+
tools=st["tools"], consent=st["consent"])
|
|
184
|
+
|
|
185
|
+
input_win = Window(
|
|
186
|
+
BufferControl(buffer=buf, input_processors=[BeforeInput("❯ ", style="class:prompt")]),
|
|
187
|
+
height=1)
|
|
188
|
+
toolbar = Window(FormattedTextControl(_toolbar), height=1, always_hide_cursor=True)
|
|
189
|
+
root = HSplit([pane.window, Frame(input_win), toolbar])
|
|
190
|
+
style = Style.from_dict({
|
|
191
|
+
"frame.border": "#5f5f5f", # muted grey chrome — furniture, not focus
|
|
192
|
+
"prompt": "#00afd7 bold", # cyan ❯ — the interactive accent
|
|
193
|
+
"tb.dim": "#8a8a8a", # idle chrome / secondary bits — dim
|
|
194
|
+
"tb.spin": "#e8a317 bold", # animated spinner — bee-yellow, pops
|
|
195
|
+
"tb.working": "#e8a317", # 'working' — yellow
|
|
196
|
+
"tb.action": "#00afd7", # current action — cyan
|
|
197
|
+
"tb.consent": "#e8a317 bold", # consent prompt line — yellow
|
|
198
|
+
"tb.mode.default": "#00afd7", # default — cyan
|
|
199
|
+
"tb.mode.plan": "#af87ff", # plan — purple
|
|
200
|
+
"tb.mode.autopilot": "#e8a317 bold", # autopilot — yellow (auto-approving: caution)
|
|
201
|
+
})
|
|
202
|
+
app = Application(layout=Layout(root, focused_element=input_win), key_bindings=kb,
|
|
203
|
+
full_screen=True, mouse_support=True, style=style)
|
|
204
|
+
|
|
205
|
+
async def _ticker():
|
|
206
|
+
# animate the spinner + tick the elapsed clock while a turn runs
|
|
207
|
+
while True:
|
|
208
|
+
await asyncio.sleep(0.25)
|
|
209
|
+
if is_busy():
|
|
210
|
+
app.invalidate()
|
|
211
|
+
|
|
212
|
+
tick = asyncio.ensure_future(_ticker())
|
|
213
|
+
try:
|
|
214
|
+
await app.run_async()
|
|
215
|
+
finally:
|
|
216
|
+
tick.cancel()
|
|
217
|
+
return True
|
webbee/update.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
PYPI_URL = "https://pypi.org/pypi/webbee/json"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _ver(s: str) -> tuple:
|
|
8
|
+
try:
|
|
9
|
+
return tuple(int(x) for x in s.strip().split("."))
|
|
10
|
+
except (ValueError, AttributeError):
|
|
11
|
+
return ()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def default_fetch() -> "str | None":
|
|
15
|
+
"""Fetch the latest version from PyPI. Returns None on ANY failure
|
|
16
|
+
(offline, timeout, parse) — the caller treats None as 'no update'."""
|
|
17
|
+
try:
|
|
18
|
+
import httpx
|
|
19
|
+
r = httpx.get(PYPI_URL, timeout=2.0)
|
|
20
|
+
r.raise_for_status()
|
|
21
|
+
return r.json()["info"]["version"]
|
|
22
|
+
except Exception:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def check_for_update(current: str, *, cache_path, now: float, fetch, ttl: float = 86400.0) -> "str | None":
|
|
27
|
+
"""Return a one-line upgrade notice if a newer webbee is on PyPI, else None.
|
|
28
|
+
Caches the latest-seen version for `ttl` seconds. Never raises."""
|
|
29
|
+
cache_path = Path(cache_path)
|
|
30
|
+
latest = None
|
|
31
|
+
try:
|
|
32
|
+
cached = json.loads(cache_path.read_text())
|
|
33
|
+
if now - float(cached.get("checked_at", 0)) < ttl:
|
|
34
|
+
latest = cached.get("latest")
|
|
35
|
+
except Exception:
|
|
36
|
+
latest = None
|
|
37
|
+
|
|
38
|
+
if latest is None:
|
|
39
|
+
try:
|
|
40
|
+
latest = fetch()
|
|
41
|
+
except Exception:
|
|
42
|
+
latest = None
|
|
43
|
+
if latest:
|
|
44
|
+
try:
|
|
45
|
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
cache_path.write_text(json.dumps({"latest": latest, "checked_at": now}))
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
if latest and _ver(latest) > _ver(current):
|
|
51
|
+
return f"🐝 webbee v{latest} available — upgrade: pipx upgrade webbee (or: uv tool upgrade webbee)"
|
|
52
|
+
return None
|