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/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
webbee/account.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class Account:
|
|
8
|
+
signed_in: bool = False
|
|
9
|
+
email: str = ""
|
|
10
|
+
nickname: str = ""
|
|
11
|
+
plan: str = ""
|
|
12
|
+
plan_status: str = ""
|
|
13
|
+
plan_renews: str = ""
|
|
14
|
+
dev_tier: str = ""
|
|
15
|
+
member_since: str = ""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _fmt_month(iso: str) -> str:
|
|
19
|
+
"""'2026-04-27T10:00:00Z' -> 'Apr 2026'; '' on any failure."""
|
|
20
|
+
try:
|
|
21
|
+
return datetime.fromisoformat(iso.replace("Z", "+00:00")).strftime("%b %Y")
|
|
22
|
+
except Exception:
|
|
23
|
+
return ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _fmt_day(iso: str) -> str:
|
|
27
|
+
"""'2026-08-01T00:00:00Z' -> '2026-08-01'; '' on any failure."""
|
|
28
|
+
try:
|
|
29
|
+
return datetime.fromisoformat(iso.replace("Z", "+00:00")).strftime("%Y-%m-%d")
|
|
30
|
+
except Exception:
|
|
31
|
+
return ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def _default_get(cfg, token: str, path: str) -> dict:
|
|
35
|
+
import httpx
|
|
36
|
+
async with httpx.AsyncClient(base_url=cfg.api_url, timeout=3.0) as c:
|
|
37
|
+
r = await c.get(path, headers={"Authorization": f"Bearer {token}"})
|
|
38
|
+
r.raise_for_status()
|
|
39
|
+
return r.json()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def fetch_account(cfg, token_provider, *, get=None) -> Account:
|
|
43
|
+
"""Best-effort account summary for the welcome screen. NEVER raises: no
|
|
44
|
+
token or a failed /v1/auth/me -> Account(signed_in=False); a failed
|
|
45
|
+
billing/developer call just omits those fields."""
|
|
46
|
+
try:
|
|
47
|
+
token = await token_provider()
|
|
48
|
+
except Exception:
|
|
49
|
+
return Account(signed_in=False)
|
|
50
|
+
|
|
51
|
+
async def getter(path: str) -> dict:
|
|
52
|
+
if get is not None:
|
|
53
|
+
return await get(path)
|
|
54
|
+
return await _default_get(cfg, token, path)
|
|
55
|
+
|
|
56
|
+
async def _try(path):
|
|
57
|
+
try:
|
|
58
|
+
return await getter(path)
|
|
59
|
+
except Exception:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
me, sub, dev = await asyncio.gather(
|
|
63
|
+
_try("/v1/auth/me"), _try("/v1/billing/subscription"), _try("/v1/developer/profile"))
|
|
64
|
+
if not me:
|
|
65
|
+
return Account(signed_in=False)
|
|
66
|
+
attrs = me.get("attributes") or {}
|
|
67
|
+
sub = sub or {}
|
|
68
|
+
dev = dev or {}
|
|
69
|
+
return Account(
|
|
70
|
+
signed_in=True,
|
|
71
|
+
email=str(me.get("email", "") or ""),
|
|
72
|
+
nickname=str(dev.get("nickname", "") or ""),
|
|
73
|
+
plan=str(sub.get("plan", "") or ""),
|
|
74
|
+
plan_status=str(sub.get("status", "") or ""),
|
|
75
|
+
plan_renews=_fmt_day(str(sub.get("expires_at", "") or "")),
|
|
76
|
+
dev_tier=str(dev.get("tier", "") or attrs.get("developer_tier", "") or ""),
|
|
77
|
+
member_since=_fmt_month(str(me.get("created_at", "") or dev.get("registered_at", "") or "")),
|
|
78
|
+
)
|
webbee/banner_art.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Webbee Code logo โ pyfiglet "big" font (generated). Rendered bee-yellow + centered by render.py.
|
|
2
|
+
WEBBEE_CODE = r"""__ __ _ _ _____ _
|
|
3
|
+
\ \ / / | | | | / ____| | |
|
|
4
|
+
\ \ /\ / /__| |__ | |__ ___ ___ | | ___ __| | ___
|
|
5
|
+
\ \/ \/ / _ \ '_ \| '_ \ / _ \/ _ \ | | / _ \ / _` |/ _ \
|
|
6
|
+
\ /\ / __/ |_) | |_) | __/ __/ | |___| (_) | (_| | __/
|
|
7
|
+
\/ \/ \___|_.__/|_.__/ \___|\___| \_____\___/ \__,_|\___|
|
|
8
|
+
|
|
9
|
+
"""
|
webbee/cli.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from webbee import __version__
|
|
6
|
+
from webbee.config import Config
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
10
|
+
p = argparse.ArgumentParser(prog="webbee", description="Webbee ๐ โ coding agent in your terminal")
|
|
11
|
+
p.add_argument("--version", action="version", version=f"webbee {__version__}")
|
|
12
|
+
p.add_argument("--mode", choices=["default", "plan", "autopilot"], default="default")
|
|
13
|
+
sub = p.add_subparsers(dest="cmd")
|
|
14
|
+
sub.add_parser("login", help="Log in to your Imperal account in the browser")
|
|
15
|
+
sub.add_parser("logout", help="Log out and remove local credentials")
|
|
16
|
+
return p
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv=None) -> None:
|
|
20
|
+
args = build_parser().parse_args(argv)
|
|
21
|
+
cfg = Config.from_env()
|
|
22
|
+
|
|
23
|
+
if args.cmd == "login":
|
|
24
|
+
from imperal_mcp import auth
|
|
25
|
+
print(f"Logged in as {auth.login(cfg)}.")
|
|
26
|
+
return
|
|
27
|
+
if args.cmd == "logout":
|
|
28
|
+
from imperal_mcp import auth
|
|
29
|
+
asyncio.run(auth.logout(cfg))
|
|
30
|
+
print("Logged out.")
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
# Default: the polished REPL. Fire a non-blocking update-check first.
|
|
34
|
+
from webbee.repl import run_repl
|
|
35
|
+
try:
|
|
36
|
+
_maybe_print_update_notice()
|
|
37
|
+
asyncio.run(run_repl(cfg, args.mode))
|
|
38
|
+
except KeyboardInterrupt:
|
|
39
|
+
# Ctrl-C during the update-check fetch, or at the read_line() prompt,
|
|
40
|
+
# unwinds here โ exit clean, no traceback. (repl.py itself now cancels
|
|
41
|
+
# a Ctrl-C mid-turn internally and returns to the prompt instead of
|
|
42
|
+
# propagating โ see run_repl.)
|
|
43
|
+
print("\nBye ๐")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _maybe_print_update_notice() -> None:
|
|
47
|
+
try:
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
import time
|
|
50
|
+
from webbee.update import check_for_update, default_fetch
|
|
51
|
+
cache = Path(os.path.expanduser("~/.cache/webbee/update.json"))
|
|
52
|
+
notice = check_for_update(__version__, cache_path=cache, now=time.time(), fetch=default_fetch)
|
|
53
|
+
if notice:
|
|
54
|
+
print(notice)
|
|
55
|
+
except Exception:
|
|
56
|
+
pass # update-check must never block or crash startup
|
webbee/commands.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
_MODES = ("default", "plan", "autopilot")
|
|
4
|
+
|
|
5
|
+
_HELP = """Commands:
|
|
6
|
+
/help show this help
|
|
7
|
+
/login sign in to your Imperal account (browser)
|
|
8
|
+
/logout sign out and remove local credentials
|
|
9
|
+
/clear clear the screen + reset session counters
|
|
10
|
+
/mode [default|plan|autopilot] consent mode (no arg โ show current)
|
|
11
|
+
/cost (=/usage) tokens + $ cost this session
|
|
12
|
+
/status cwd ยท git ยท surface ยท tokens ยท version
|
|
13
|
+
/exit (=/quit) quit"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class CommandContext:
|
|
18
|
+
mode: str
|
|
19
|
+
workspace: str
|
|
20
|
+
version: str
|
|
21
|
+
surface: str
|
|
22
|
+
logged_in: bool
|
|
23
|
+
session_tokens: int
|
|
24
|
+
session_cost: float
|
|
25
|
+
git_branch: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class SlashResult:
|
|
30
|
+
handled: bool
|
|
31
|
+
exit: bool = False
|
|
32
|
+
message: str = ""
|
|
33
|
+
action: str = ""
|
|
34
|
+
new_mode: "str | None" = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def dispatch(line: str, ctx: CommandContext) -> SlashResult:
|
|
38
|
+
"""Parse one input line. Non-slash lines return handled=False (the REPL
|
|
39
|
+
then sends them to the agent). Slash lines are fully handled here."""
|
|
40
|
+
text = line.strip()
|
|
41
|
+
if not text.startswith("/"):
|
|
42
|
+
return SlashResult(handled=False)
|
|
43
|
+
|
|
44
|
+
parts = text.split()
|
|
45
|
+
cmd, args = parts[0].lower(), parts[1:]
|
|
46
|
+
|
|
47
|
+
if cmd in ("/exit", "/quit"):
|
|
48
|
+
return SlashResult(handled=True, exit=True)
|
|
49
|
+
if cmd == "/help":
|
|
50
|
+
return SlashResult(handled=True, action="help", message=_HELP)
|
|
51
|
+
if cmd == "/login":
|
|
52
|
+
return SlashResult(handled=True, action="login")
|
|
53
|
+
if cmd == "/logout":
|
|
54
|
+
return SlashResult(handled=True, action="logout")
|
|
55
|
+
if cmd == "/clear":
|
|
56
|
+
return SlashResult(handled=True, action="clear", message="Screen cleared, counters reset.")
|
|
57
|
+
if cmd in ("/cost", "/usage"):
|
|
58
|
+
return SlashResult(handled=True, action="cost",
|
|
59
|
+
message=f"This session: {ctx.session_tokens} tokens (~${ctx.session_cost:.4f}). "
|
|
60
|
+
f"LLM turns don't spend credits.")
|
|
61
|
+
if cmd == "/status":
|
|
62
|
+
auth = "signed in" if ctx.logged_in else "not signed in (/login)"
|
|
63
|
+
msg = (f"surface: {ctx.surface} mode: {ctx.mode} {auth}\n"
|
|
64
|
+
f"cwd: {ctx.workspace} git: {ctx.git_branch}\n"
|
|
65
|
+
f"tokens: {ctx.session_tokens} (~${ctx.session_cost:.4f}) webbee v{ctx.version}")
|
|
66
|
+
return SlashResult(handled=True, action="status", message=msg)
|
|
67
|
+
if cmd == "/mode":
|
|
68
|
+
if not args:
|
|
69
|
+
return SlashResult(handled=True, action="mode", new_mode=None,
|
|
70
|
+
message=f"Current mode: {ctx.mode}. Available: {', '.join(_MODES)}.")
|
|
71
|
+
want = args[0].lower()
|
|
72
|
+
if want not in _MODES:
|
|
73
|
+
return SlashResult(handled=True, action="mode", new_mode=None,
|
|
74
|
+
message=f"Unknown mode '{want}'. Available: {', '.join(_MODES)}.")
|
|
75
|
+
return SlashResult(handled=True, action="mode", new_mode=want,
|
|
76
|
+
message=f"Mode โ {want}.")
|
|
77
|
+
return SlashResult(handled=True, message=f"Unknown command '{cmd}'. /help for the list.")
|
webbee/config.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
@dataclass(frozen=True)
|
|
5
|
+
class Config:
|
|
6
|
+
api_url: str
|
|
7
|
+
panel_url: str
|
|
8
|
+
|
|
9
|
+
@classmethod
|
|
10
|
+
def from_env(cls) -> "Config":
|
|
11
|
+
return cls(
|
|
12
|
+
api_url=os.environ.get("IMPERAL_API_URL", "https://auth.imperal.io").rstrip("/"),
|
|
13
|
+
panel_url=os.environ.get("IMPERAL_PANEL_URL", "https://panel.imperal.io").rstrip("/"),
|
|
14
|
+
)
|
webbee/events.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from typing import Protocol, runtime_checkable
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@runtime_checkable
|
|
5
|
+
class TurnSink(Protocol):
|
|
6
|
+
"""Everything a running coding turn tells the UI. The renderer (render.py)
|
|
7
|
+
implements this with Rich; tests implement it with a recording fake.
|
|
8
|
+
session.py imports ONLY this โ never Rich โ so the SSE loop stays testable."""
|
|
9
|
+
|
|
10
|
+
def tool_start(self, tool: str, args: dict) -> None: ...
|
|
11
|
+
def tool_result(self, tool: str, ok: bool, summary: str) -> None: ...
|
|
12
|
+
async def ask_consent(self, app_id: str, tool: str, args: dict) -> str: ...
|
|
13
|
+
def panel_release(self, panel_url: str, summary: str) -> None: ...
|
|
14
|
+
def progress(self, text: str) -> None: ...
|
|
15
|
+
def usage(self, tokens: int, cost_usd: float) -> None: ...
|
|
16
|
+
def plan_blocked(self, tool: str) -> None: ...
|
|
17
|
+
def user_echo(self, text: str) -> None: ...
|
webbee/render.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.markdown import Markdown
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
|
|
9
|
+
from webbee.banner_art import WEBBEE_CODE
|
|
10
|
+
|
|
11
|
+
_ICON = {"read_file": "๐", "grep": "๐", "glob": "๐๏ธ", "write_file": "โ",
|
|
12
|
+
"edit_file": "๐ง", "bash": "โก"}
|
|
13
|
+
_BEE = "yellow" # bee-yellow brand accent โ logo / ๐ / notes / busy dot ONLY
|
|
14
|
+
_ACCENT = "cyan" # interactive chrome ONLY โ live caret / mode / panel url
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _fmt_tokens(n: int) -> str:
|
|
18
|
+
"""Compact token count: 2100 -> '2.1k', 900 -> '900'."""
|
|
19
|
+
return f"{n / 1000:.1f}k" if n >= 1000 else str(int(n))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _salient_arg(args: dict) -> str:
|
|
23
|
+
"""The one human-readable arg to show for a tool/consent call (PURE) โ a
|
|
24
|
+
title/path/command/name over an opaque id; falls back to the first id-ish
|
|
25
|
+
value. Keeps the feed readable instead of dumping a raw args dict."""
|
|
26
|
+
if not isinstance(args, dict):
|
|
27
|
+
return ""
|
|
28
|
+
for k in ("title", "name", "path", "pattern", "command", "query", "q"):
|
|
29
|
+
v = args.get(k)
|
|
30
|
+
if v:
|
|
31
|
+
return str(v)
|
|
32
|
+
for k in ("id", "note_id", "folder_id", "task_id"):
|
|
33
|
+
v = args.get(k)
|
|
34
|
+
if v:
|
|
35
|
+
return str(v)
|
|
36
|
+
return ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _invalidate() -> None:
|
|
40
|
+
"""Redraw the running prompt_toolkit app if any. No-op when none is active
|
|
41
|
+
(tests / non-tty fallback) โ the fallback when no output pane is wired."""
|
|
42
|
+
try:
|
|
43
|
+
from prompt_toolkit.application import get_app_or_none
|
|
44
|
+
app = get_app_or_none()
|
|
45
|
+
if app is not None:
|
|
46
|
+
app.invalidate()
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RichSink:
|
|
52
|
+
"""Rich implementation of TurnSink. Turn output (action feed, ๐ answer,
|
|
53
|
+
footer) is rendered by the Rich Console. In the full-screen dock the Console
|
|
54
|
+
writes ANSI into the OutputPane's buffer (repl passes console=pane.console +
|
|
55
|
+
on_output=pane.notify) and the pane shows it in a scrollable region above
|
|
56
|
+
the fixed input; after each render this sink calls on_output() so the pane
|
|
57
|
+
follows the tail. The live status (busy/elapsed/tokens) is the dock toolbar,
|
|
58
|
+
fed by status(). Consent runs through the dock via an asyncio.Future
|
|
59
|
+
(resolve_consent), with a sync input fallback for non-tty.
|
|
60
|
+
|
|
61
|
+
Tests pass live_enabled=False + inject input_fn/clock and no on_output; then
|
|
62
|
+
_nudge() is a harmless no-op and consent uses the injected input_fn."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, console=None, *, live_enabled=True, input_fn=input,
|
|
65
|
+
clock=time.monotonic, on_output=None):
|
|
66
|
+
self.console = console or Console()
|
|
67
|
+
self._live_enabled = live_enabled
|
|
68
|
+
self._input = input_fn
|
|
69
|
+
self._clock = clock
|
|
70
|
+
self._on_output = on_output # pane.notify (scroll+redraw) in the dock
|
|
71
|
+
self.tokens = 0
|
|
72
|
+
self.cost_usd = 0.0
|
|
73
|
+
self.session_tokens = 0
|
|
74
|
+
self.session_cost = 0.0
|
|
75
|
+
self._tools = 0
|
|
76
|
+
self._started = None
|
|
77
|
+
self._busy = False
|
|
78
|
+
self._current = ""
|
|
79
|
+
self._pending = ("", "")
|
|
80
|
+
self._consent = None # asyncio.Future while awaiting a reply
|
|
81
|
+
self._consent_summary = ""
|
|
82
|
+
|
|
83
|
+
def _nudge(self) -> None:
|
|
84
|
+
"""After output/state changes: let the pane follow the tail + redraw."""
|
|
85
|
+
if self._on_output is not None:
|
|
86
|
+
self._on_output()
|
|
87
|
+
else:
|
|
88
|
+
_invalidate()
|
|
89
|
+
|
|
90
|
+
# ---- welcome ------------------------------------------------------------
|
|
91
|
+
def welcome(self, account, cwd: str, surface: str) -> None:
|
|
92
|
+
"""One-time launch splash: a centered WEBBEE CODE logo + imperal.io + an
|
|
93
|
+
honest account panel (who/plan/tier/member-since). Runs BEFORE the dock
|
|
94
|
+
starts. Clears the screen ONLY in the non-pane path (the full-screen
|
|
95
|
+
dock owns its own alternate screen โ clearing there would corrupt it)."""
|
|
96
|
+
if self._on_output is None:
|
|
97
|
+
self.console.clear()
|
|
98
|
+
w = self.console.width
|
|
99
|
+
|
|
100
|
+
def _center_block(text: str) -> str:
|
|
101
|
+
lines = text.splitlines()
|
|
102
|
+
bw = max((len(line) for line in lines), default=0)
|
|
103
|
+
pad = " " * max(0, (w - bw) // 2)
|
|
104
|
+
return "\n".join(pad + line for line in lines)
|
|
105
|
+
|
|
106
|
+
self.console.print()
|
|
107
|
+
self.console.print(Text(_center_block(WEBBEE_CODE), style=f"bold {_BEE}"))
|
|
108
|
+
self.console.print(Text("๐".center(w), style=f"bold {_BEE}"))
|
|
109
|
+
self.console.print(Text("ICNLI AI Cloud OS ยท Agent".center(w), style=f"bold {_ACCENT}"))
|
|
110
|
+
self.console.print(Text("ยท i m p e r a l . i o ยท".center(w), style="dim"))
|
|
111
|
+
self.console.print()
|
|
112
|
+
rows = []
|
|
113
|
+
if account.signed_in:
|
|
114
|
+
who = account.email + (f" ยท @{account.nickname}" if account.nickname else "")
|
|
115
|
+
rows.append(("Signed in as", who))
|
|
116
|
+
if account.plan:
|
|
117
|
+
plan = account.plan + (f" ยท {account.plan_status}" if account.plan_status else "")
|
|
118
|
+
plan += (f" ยท renews {account.plan_renews}" if account.plan_renews else "")
|
|
119
|
+
rows.append(("Plan", plan))
|
|
120
|
+
if account.dev_tier:
|
|
121
|
+
rows.append(("Developer", f"{account.dev_tier} tier"))
|
|
122
|
+
if account.member_since:
|
|
123
|
+
rows.append(("Member since", account.member_since))
|
|
124
|
+
else:
|
|
125
|
+
rows.append(("", "not signed in โ /login"))
|
|
126
|
+
bw = max((len(label.ljust(14) + value) for label, value in rows), default=0)
|
|
127
|
+
pad = " " * max(0, (w - bw) // 2)
|
|
128
|
+
for label, value in rows:
|
|
129
|
+
self.console.print(Text.assemble((pad + label.ljust(14), "dim"), (value, "white")))
|
|
130
|
+
self.console.print()
|
|
131
|
+
self.console.print(Text("/help ยท Ctrl-D to exit".center(w), style="dim"))
|
|
132
|
+
self.console.print()
|
|
133
|
+
self._nudge()
|
|
134
|
+
|
|
135
|
+
# ---- turn lifecycle -------------------------------------------------
|
|
136
|
+
def begin_turn(self) -> None:
|
|
137
|
+
self._tools = 0
|
|
138
|
+
self._started = self._clock()
|
|
139
|
+
self._current = ""
|
|
140
|
+
self._pending = ("", "")
|
|
141
|
+
self.tokens = 0 # per-turn live counters (usage frames are per-turn cumulative)
|
|
142
|
+
self.cost_usd = 0.0
|
|
143
|
+
self._busy = True
|
|
144
|
+
self.console.print() # breathing room between the user's message and the response
|
|
145
|
+
self._nudge()
|
|
146
|
+
|
|
147
|
+
def end_turn(self, final_text: str) -> None:
|
|
148
|
+
self._busy = False
|
|
149
|
+
if final_text:
|
|
150
|
+
self.console.print() # separation before the focus block
|
|
151
|
+
self.console.print(Text(" ๐ Webbee", style=f"bold {_BEE}"))
|
|
152
|
+
self.console.print(Markdown(final_text))
|
|
153
|
+
elapsed = self._elapsed()
|
|
154
|
+
self.session_tokens += self.tokens
|
|
155
|
+
self.session_cost += self.cost_usd
|
|
156
|
+
noun = "action" if self._tools == 1 else "actions"
|
|
157
|
+
self.console.print(Text(
|
|
158
|
+
f" {elapsed:.1f}s ยท {self._tools} {noun} ยท {_fmt_tokens(self.tokens)} tok"
|
|
159
|
+
f" ยท session {_fmt_tokens(self.session_tokens)} tok",
|
|
160
|
+
style="dim"))
|
|
161
|
+
self.console.print() # breathing room before the next prompt
|
|
162
|
+
self._nudge()
|
|
163
|
+
|
|
164
|
+
def note(self, message: str) -> None:
|
|
165
|
+
self.console.print(Text(" " + message, style=_BEE))
|
|
166
|
+
self._nudge()
|
|
167
|
+
|
|
168
|
+
def user_echo(self, text: str) -> None:
|
|
169
|
+
"""Commit the just-sent user message as its own clearly-readable line
|
|
170
|
+
with a background bar (NOT boxed like the live input) so it stands out
|
|
171
|
+
as 'what I sent' in the scrollback."""
|
|
172
|
+
self.console.print(Text.assemble(
|
|
173
|
+
(" ", ""), (" โฏ " + text + " ", "bold white on grey30")))
|
|
174
|
+
self._nudge()
|
|
175
|
+
|
|
176
|
+
def clear(self) -> None:
|
|
177
|
+
"""/clear: wipe the pane/screen + reset the session counters."""
|
|
178
|
+
self.console.clear()
|
|
179
|
+
self.tokens = 0
|
|
180
|
+
self.cost_usd = 0.0
|
|
181
|
+
self.session_tokens = 0
|
|
182
|
+
self.session_cost = 0.0
|
|
183
|
+
self._tools = 0
|
|
184
|
+
self._current = ""
|
|
185
|
+
self._nudge()
|
|
186
|
+
|
|
187
|
+
def abort(self) -> None:
|
|
188
|
+
"""Ctrl-C mid-turn: clear busy so the toolbar drops back to idle. No
|
|
189
|
+
printing โ the caller (repl.py) prints the note."""
|
|
190
|
+
self._busy = False
|
|
191
|
+
self._nudge()
|
|
192
|
+
|
|
193
|
+
# ---- TurnSink -------------------------------------------------------
|
|
194
|
+
def tool_start(self, tool: str, args: dict) -> None:
|
|
195
|
+
self._tools += 1
|
|
196
|
+
arg = args.get("path") or args.get("pattern") or args.get("command") or ""
|
|
197
|
+
self._pending = (tool, str(arg))
|
|
198
|
+
self._current = f"{tool} {str(arg)[:40]}".strip()
|
|
199
|
+
self._nudge() # the completed line is printed in tool_result
|
|
200
|
+
|
|
201
|
+
def tool_result(self, tool: str, ok: bool, summary: str) -> None:
|
|
202
|
+
# One calm dim line: icon + tool (+arg), then the โ/โ RIGHT NEXT TO the
|
|
203
|
+
# action (not pinned to the far right), then a dim summary. Only the
|
|
204
|
+
# โ/โ carries colour โ the rest recedes (dim).
|
|
205
|
+
_tool, arg = self._pending if self._pending[0] else (tool, "")
|
|
206
|
+
icon = _ICON.get(_tool, "โก")
|
|
207
|
+
mark = "โ" if ok else "โ"
|
|
208
|
+
self.console.print(Text.assemble(
|
|
209
|
+
(" " + icon + " ", "dim"),
|
|
210
|
+
(_tool, "dim"),
|
|
211
|
+
((" " + arg[:40]) if arg else "", "dim"),
|
|
212
|
+
(" ", ""),
|
|
213
|
+
(mark + " ", "green" if ok else "red"),
|
|
214
|
+
(str(summary)[:50], "dim"),
|
|
215
|
+
))
|
|
216
|
+
self._pending = ("", "")
|
|
217
|
+
self._nudge()
|
|
218
|
+
|
|
219
|
+
async def ask_consent(self, app_id: str, tool: str, args: dict) -> str:
|
|
220
|
+
"""Ask for consent and return the user's RAW reply (trimmed only) โ
|
|
221
|
+
NEVER interpret (the kernel decides, ICNLI). When the dock is running
|
|
222
|
+
the reply comes through the pinned box via an asyncio.Future (no
|
|
223
|
+
blocking input on the event loop); otherwise fall back to the injected
|
|
224
|
+
sync reader (tests / non-tty)."""
|
|
225
|
+
label = f"{app_id}ยท{tool}" if app_id else tool
|
|
226
|
+
sal = _salient_arg(args)
|
|
227
|
+
self.console.print(Text.assemble((" ? approve ", "yellow"), (label, "dim"),
|
|
228
|
+
((" " + sal[:60]) if sal else "", "dim")))
|
|
229
|
+
fut = self._arm_consent(label, sal)
|
|
230
|
+
if fut is None: # non-tty / no running app
|
|
231
|
+
raw = self._input(" ")
|
|
232
|
+
else:
|
|
233
|
+
self._nudge()
|
|
234
|
+
raw = await fut
|
|
235
|
+
self._consent = None
|
|
236
|
+
self._consent_summary = ""
|
|
237
|
+
raw = (raw or "").strip()
|
|
238
|
+
self.console.print(Text(" โณ " + raw, style="dim")) # quiet echo of the reply
|
|
239
|
+
self._nudge()
|
|
240
|
+
return raw
|
|
241
|
+
|
|
242
|
+
def panel_release(self, panel_url: str, summary: str) -> None:
|
|
243
|
+
body = Text.assemble(
|
|
244
|
+
(summary + "\n\n" if summary else "", "white"),
|
|
245
|
+
("Approve it in your browser:\n", "white"),
|
|
246
|
+
(f" {panel_url}\n", f"bold {_ACCENT}"),
|
|
247
|
+
("Then ask again โ you weren't charged.", "dim"),
|
|
248
|
+
)
|
|
249
|
+
self.console.print(Panel(body, title="๐ณ This costs money", border_style="magenta"))
|
|
250
|
+
self._nudge()
|
|
251
|
+
|
|
252
|
+
def progress(self, text: str) -> None:
|
|
253
|
+
if text:
|
|
254
|
+
self.console.print(Text(" " + text, style="dim italic"))
|
|
255
|
+
self._nudge()
|
|
256
|
+
|
|
257
|
+
def plan_blocked(self, tool: str) -> None:
|
|
258
|
+
"""Plan mode auto-declines writes/destructive. Tell the user WHY and how
|
|
259
|
+
to allow it (Shift+Tab). Autopilot and default never reach this."""
|
|
260
|
+
self.console.print(Text.assemble(
|
|
261
|
+
(" โ plan mode", _BEE),
|
|
262
|
+
(f" โ {tool} blocked. " if tool else " โ action blocked. ", "dim"),
|
|
263
|
+
("Press Shift+Tab to switch to default or autopilot to allow it.", "dim")))
|
|
264
|
+
self._nudge()
|
|
265
|
+
|
|
266
|
+
def usage(self, tokens: int, cost_usd: float) -> None:
|
|
267
|
+
# Cumulative frame โ trust the server's running totals verbatim.
|
|
268
|
+
self.tokens = tokens
|
|
269
|
+
self.cost_usd = cost_usd
|
|
270
|
+
self._nudge()
|
|
271
|
+
|
|
272
|
+
# ---- dock bridge (read by tui.run_session's toolbar + Enter binding) ---
|
|
273
|
+
def status(self) -> dict:
|
|
274
|
+
"""Live state for the dock toolbar. The bottom counter shows the SESSION
|
|
275
|
+
TOTAL (not per-turn): the running session total, plus the in-flight
|
|
276
|
+
turn's spend while busy (so it grows live), with no double-count at idle
|
|
277
|
+
(end_turn has already folded the finished turn into the session)."""
|
|
278
|
+
return {"busy": self._busy, "current": self._current,
|
|
279
|
+
"elapsed": self._elapsed(), "tools": self._tools,
|
|
280
|
+
"tokens": self.session_tokens + (self.tokens if self._busy else 0),
|
|
281
|
+
"cost": self.session_cost + (self.cost_usd if self._busy else 0.0),
|
|
282
|
+
"consent": self.consent_pending()}
|
|
283
|
+
|
|
284
|
+
def is_busy(self) -> bool:
|
|
285
|
+
return self._busy
|
|
286
|
+
|
|
287
|
+
def consent_pending(self) -> bool:
|
|
288
|
+
return self._consent is not None and not self._consent.done()
|
|
289
|
+
|
|
290
|
+
def resolve_consent(self, raw: str) -> None:
|
|
291
|
+
"""Called by the dock's Enter binding when a consent reply is awaited โ
|
|
292
|
+
hands the RAW reply verbatim to the awaiting ask_consent (ICNLI)."""
|
|
293
|
+
if self.consent_pending():
|
|
294
|
+
self._consent.set_result(raw)
|
|
295
|
+
|
|
296
|
+
# ---- internals ------------------------------------------------------
|
|
297
|
+
def _arm_consent(self, label: str, summary: str):
|
|
298
|
+
"""Create the consent Future iff a dock is running; else None (caller
|
|
299
|
+
falls back to sync input)."""
|
|
300
|
+
try:
|
|
301
|
+
from prompt_toolkit.application import get_app_or_none
|
|
302
|
+
if get_app_or_none() is None:
|
|
303
|
+
return None
|
|
304
|
+
self._consent = asyncio.get_running_loop().create_future()
|
|
305
|
+
self._consent_summary = summary or label
|
|
306
|
+
return self._consent
|
|
307
|
+
except Exception:
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
def _elapsed(self) -> float:
|
|
311
|
+
if self._started is None:
|
|
312
|
+
return 0.0
|
|
313
|
+
return self._clock() - self._started
|