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/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