hubble-cli 4.1.2__tar.gz → 4.2.0__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.
- {hubble_cli-4.1.2/hubble_cli.egg-info → hubble_cli-4.2.0}/PKG-INFO +21 -5
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/README.md +16 -3
- hubble_cli-4.2.0/hubble/__init__.py +3 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/agent.py +4 -1
- hubble_cli-4.2.0/hubble/anthropic_provider.py +256 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/banner.py +170 -18
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/main.py +19 -7
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/onboarding.py +17 -8
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/provider.py +5 -2
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/providers.py +79 -20
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/repl.py +75 -23
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/scanner.py +36 -1
- hubble_cli-4.2.0/hubble/setup_path.py +99 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0/hubble_cli.egg-info}/PKG-INFO +21 -5
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble_cli.egg-info/SOURCES.txt +4 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble_cli.egg-info/requires.txt +1 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/pyproject.toml +4 -1
- hubble_cli-4.2.0/tests/test_anthropic_provider.py +160 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_core.py +50 -8
- hubble_cli-4.2.0/tests/test_setup_path.py +80 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_update.py +15 -0
- hubble_cli-4.1.2/hubble/__init__.py +0 -3
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/LICENSE +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/__main__.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/board.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/github.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/hooks.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/images.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/keystore.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/mcp.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/mcp_oauth.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/models.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/permissions.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/plugins.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/prompts.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/sandbox.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/session.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/settings.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/skills.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/spinner.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/subagents.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/tools.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/ui.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/update.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/web.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/worktree.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble_cli.egg-info/dependency_links.txt +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble_cli.egg-info/entry_points.txt +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble_cli.egg-info/top_level.txt +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/setup.cfg +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_agents_plugins.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_github.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_hook_events.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_hooks.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_images.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_keystore.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_mcp.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_mcp_remote.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_sandbox.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_scanner.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_skills.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_stream_json.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_task_tool.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_tools.py +0 -0
- {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_worktree.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hubble-cli
|
|
3
|
-
Version: 4.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 4.2.0
|
|
4
|
+
Summary: Agentic coding CLI for OpenAI-compatible APIs, Claude, and local LLMs
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/Hamdayrabby/hubble-cli
|
|
7
7
|
Project-URL: Repository, https://github.com/Hamdayrabby/hubble-cli
|
|
@@ -15,6 +15,8 @@ Classifier: Programming Language :: Python :: 3
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.10
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.11
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
20
|
Classifier: Topic :: Software Development
|
|
19
21
|
Classifier: Topic :: Utilities
|
|
20
22
|
Requires-Python: >=3.10
|
|
@@ -24,6 +26,7 @@ Requires-Dist: httpx>=0.25.0
|
|
|
24
26
|
Requires-Dist: rich>=13.0
|
|
25
27
|
Requires-Dist: prompt_toolkit>=3.0.40
|
|
26
28
|
Requires-Dist: keyring>=24
|
|
29
|
+
Requires-Dist: anthropic>=1.0
|
|
27
30
|
Provides-Extra: dev
|
|
28
31
|
Requires-Dist: pytest>=7; extra == "dev"
|
|
29
32
|
Dynamic: license-file
|
|
@@ -42,6 +45,14 @@ pipx install hubble-cli
|
|
|
42
45
|
```
|
|
43
46
|
(or `pip install --user hubble-cli`; the latest unreleased code: `pipx install git+https://github.com/Hamdayrabby/hubble-cli.git`)
|
|
44
47
|
|
|
48
|
+
**"hubble is not recognized" after `pip install`?** pip put the command in a folder that isn't on your
|
|
49
|
+
PATH (it warns: *"The script hubble.exe is installed in '...\Scripts' which is not on PATH"*). Either run
|
|
50
|
+
it as `python -m hubble`, or fix it once with:
|
|
51
|
+
```
|
|
52
|
+
python -m hubble --add-to-path
|
|
53
|
+
```
|
|
54
|
+
then open a new terminal and run `hubble`. (pipx avoids this: `pipx ensurepath` sets PATH up for you.)
|
|
55
|
+
|
|
45
56
|
Upgrade with `pipx upgrade hubble-cli` (or `pip install -U hubble-cli`). Hubble tells you when a new
|
|
46
57
|
version is out — it checks PyPI at most once a day; set `"update_check": false` to turn that off.
|
|
47
58
|
|
|
@@ -349,8 +360,13 @@ Allow rules never apply to chained commands (`&&`, `;`, `|`, redirects), so `she
|
|
|
349
360
|
|
|
350
361
|
## Providers (extra base URLs and API keys)
|
|
351
362
|
|
|
352
|
-
|
|
353
|
-
|
|
363
|
+
Add providers next to the built-in AIHub gateway. The `/provider add` list has presets for:
|
|
364
|
+
- **Claude (Anthropic)** — native Messages API (not an OpenAI shim): tool use, images, thinking, prompt caching
|
|
365
|
+
of the system prompt, and the real context window of each model. An `ANTHROPIC_API_KEY` in your environment is
|
|
366
|
+
picked up automatically as a provider named `anthropic` (`ANTHROPIC_BASE_URL` too, for a proxy).
|
|
367
|
+
- **OpenAI-compatible:** OpenAI, OpenRouter, Gemini, Groq, Mistral, DeepSeek, xAI (Grok), Together, Fireworks,
|
|
368
|
+
Cerebras, NVIDIA, Moonshot (Kimi), and local Ollama / LM Studio servers.
|
|
369
|
+
- **Custom URL**, either OpenAI-compatible or Anthropic-compatible (a proxy or gateway speaking `/v1/messages`).
|
|
354
370
|
|
|
355
371
|
1. Type `/provider add`, or open `/provider` and choose **+ Add a provider**.
|
|
356
372
|
2. Pick a known provider or **Custom URL...**, then paste the API key. The key is hidden as you type.
|
|
@@ -368,7 +384,7 @@ Extra providers are stored in `~/.hubble/providers.json`. Their API keys go to y
|
|
|
368
384
|
(Windows Credential Manager, macOS Keychain, or Secret Service on Linux); only where no credential store
|
|
369
385
|
exists (headless Linux, containers) are they written into that file in plain text. `/provider secure` moves
|
|
370
386
|
keys saved by older versions into the credential store.
|
|
371
|
-
Their model lists are stored in `~/.hubble/models/<name>.json`. When a model is rate limited or down, Hubble retries that request with a fallback that the current provider actually has, in this order: the fallback you picked with `/fallback`, then `fallback_model` if the provider has it, then the provider's fastest verified model, then `fallback_model` on the built-in
|
|
387
|
+
Their model lists are stored in `~/.hubble/models/<name>.json`. When a model is rate limited or down, Hubble retries that request with a fallback that the current provider actually has, in this order: the fallback you picked with `/fallback`, then `fallback_model` if the provider has it, then the provider's fastest verified model, then `fallback_model` on the built-in `aihub` provider. Sub-agents use the same route. `/fallback off` disables it for a provider.
|
|
372
388
|
|
|
373
389
|
## Project memory
|
|
374
390
|
|
|
@@ -12,6 +12,14 @@ pipx install hubble-cli
|
|
|
12
12
|
```
|
|
13
13
|
(or `pip install --user hubble-cli`; the latest unreleased code: `pipx install git+https://github.com/Hamdayrabby/hubble-cli.git`)
|
|
14
14
|
|
|
15
|
+
**"hubble is not recognized" after `pip install`?** pip put the command in a folder that isn't on your
|
|
16
|
+
PATH (it warns: *"The script hubble.exe is installed in '...\Scripts' which is not on PATH"*). Either run
|
|
17
|
+
it as `python -m hubble`, or fix it once with:
|
|
18
|
+
```
|
|
19
|
+
python -m hubble --add-to-path
|
|
20
|
+
```
|
|
21
|
+
then open a new terminal and run `hubble`. (pipx avoids this: `pipx ensurepath` sets PATH up for you.)
|
|
22
|
+
|
|
15
23
|
Upgrade with `pipx upgrade hubble-cli` (or `pip install -U hubble-cli`). Hubble tells you when a new
|
|
16
24
|
version is out — it checks PyPI at most once a day; set `"update_check": false` to turn that off.
|
|
17
25
|
|
|
@@ -319,8 +327,13 @@ Allow rules never apply to chained commands (`&&`, `;`, `|`, redirects), so `she
|
|
|
319
327
|
|
|
320
328
|
## Providers (extra base URLs and API keys)
|
|
321
329
|
|
|
322
|
-
|
|
323
|
-
|
|
330
|
+
Add providers next to the built-in AIHub gateway. The `/provider add` list has presets for:
|
|
331
|
+
- **Claude (Anthropic)** — native Messages API (not an OpenAI shim): tool use, images, thinking, prompt caching
|
|
332
|
+
of the system prompt, and the real context window of each model. An `ANTHROPIC_API_KEY` in your environment is
|
|
333
|
+
picked up automatically as a provider named `anthropic` (`ANTHROPIC_BASE_URL` too, for a proxy).
|
|
334
|
+
- **OpenAI-compatible:** OpenAI, OpenRouter, Gemini, Groq, Mistral, DeepSeek, xAI (Grok), Together, Fireworks,
|
|
335
|
+
Cerebras, NVIDIA, Moonshot (Kimi), and local Ollama / LM Studio servers.
|
|
336
|
+
- **Custom URL**, either OpenAI-compatible or Anthropic-compatible (a proxy or gateway speaking `/v1/messages`).
|
|
324
337
|
|
|
325
338
|
1. Type `/provider add`, or open `/provider` and choose **+ Add a provider**.
|
|
326
339
|
2. Pick a known provider or **Custom URL...**, then paste the API key. The key is hidden as you type.
|
|
@@ -338,7 +351,7 @@ Extra providers are stored in `~/.hubble/providers.json`. Their API keys go to y
|
|
|
338
351
|
(Windows Credential Manager, macOS Keychain, or Secret Service on Linux); only where no credential store
|
|
339
352
|
exists (headless Linux, containers) are they written into that file in plain text. `/provider secure` moves
|
|
340
353
|
keys saved by older versions into the credential store.
|
|
341
|
-
Their model lists are stored in `~/.hubble/models/<name>.json`. When a model is rate limited or down, Hubble retries that request with a fallback that the current provider actually has, in this order: the fallback you picked with `/fallback`, then `fallback_model` if the provider has it, then the provider's fastest verified model, then `fallback_model` on the built-in
|
|
354
|
+
Their model lists are stored in `~/.hubble/models/<name>.json`. When a model is rate limited or down, Hubble retries that request with a fallback that the current provider actually has, in this order: the fallback you picked with `/fallback`, then `fallback_model` if the provider has it, then the provider's fastest verified model, then `fallback_model` on the built-in `aihub` provider. Sub-agents use the same route. `/fallback off` disables it for a provider.
|
|
342
355
|
|
|
343
356
|
## Project memory
|
|
344
357
|
|
|
@@ -96,7 +96,8 @@ class Agent:
|
|
|
96
96
|
permissions: Permissions, events: Events, session: Optional[Session] = None,
|
|
97
97
|
tools: Optional[List[Tool]] = None, system_override: Optional[str] = None):
|
|
98
98
|
self.provider = provider
|
|
99
|
-
|
|
99
|
+
from hubble.providers import normalize_provider_name
|
|
100
|
+
self.provider_name: str = normalize_provider_name(settings.get("provider"))
|
|
100
101
|
self.fallback_client: Optional[OpenAICompatProvider] = provider
|
|
101
102
|
# (provider_name, model) -> (client, fallback_model) or None; set by the REPL/headless runner.
|
|
102
103
|
self.fallback_resolver = None
|
|
@@ -294,6 +295,8 @@ class Agent:
|
|
|
294
295
|
if not text and not result.tool_calls:
|
|
295
296
|
text = "(empty response)" # some backends reject empty assistant messages
|
|
296
297
|
message: Dict[str, Any] = {"role": "assistant", "content": text}
|
|
298
|
+
if result.raw_content:
|
|
299
|
+
message["_anthropic_content"] = result.raw_content
|
|
297
300
|
if result.tool_calls:
|
|
298
301
|
message["tool_calls"] = [{"id": c.id, "type": "function",
|
|
299
302
|
"function": {"name": c.name, "arguments": c.arguments}}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Native Claude provider (Anthropic Messages API) with the same interface as OpenAICompatProvider.
|
|
2
|
+
|
|
3
|
+
Hubble keeps its conversation history in OpenAI chat format; this module translates it to
|
|
4
|
+
Messages API requests (system prompt, content blocks, tool_use / tool_result, images) and the
|
|
5
|
+
streamed reply back into a TurnResult. The assistant's own content blocks (thinking blocks with
|
|
6
|
+
their signatures included) are kept on the message under `_anthropic_content` and sent back
|
|
7
|
+
unchanged on the next request, as the API expects when continuing a tool-use turn.
|
|
8
|
+
|
|
9
|
+
Uses the official `anthropic` SDK: it handles SSE parsing, retries (429 / 5xx / 529 overloaded)
|
|
10
|
+
and partial tool-input JSON.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
17
|
+
from urllib.parse import urlparse
|
|
18
|
+
|
|
19
|
+
from hubble.provider import ProviderError, ToolCall, TurnResult, parse_arguments
|
|
20
|
+
|
|
21
|
+
DEFAULT_BASE_URL = "https://api.anthropic.com"
|
|
22
|
+
# Models where the API's server-side refusal fallback is recommended (and supported).
|
|
23
|
+
FALLBACK_MODELS = ("claude-opus-5", "claude-fable-5-1", "claude-mythos-5-1")
|
|
24
|
+
FALLBACK_BETA = "server-side-fallback-2026-07-01"
|
|
25
|
+
STOP_REASONS = {"end_turn": "stop", "stop_sequence": "stop", "tool_use": "tool_calls",
|
|
26
|
+
"max_tokens": "length", "refusal": "refusal", "pause_turn": "pause_turn"}
|
|
27
|
+
ID_RX = re.compile(r"[^a-zA-Z0-9_-]")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def anthropic_base_url(url: str) -> str:
|
|
31
|
+
"""The SDK adds /v1/... itself, so drop a trailing /v1 that normalize_base_url may have added."""
|
|
32
|
+
url = (url or DEFAULT_BASE_URL).strip().rstrip("/")
|
|
33
|
+
return url[:-3] if url.endswith("/v1") else url
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _tool_id(raw: str) -> str:
|
|
37
|
+
"""Tool-use ids must match ^[a-zA-Z0-9_-]+$; ids from other providers may not."""
|
|
38
|
+
return ID_RX.sub("_", raw or "") or "toolu_x"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _image_block(url: str) -> Optional[Dict[str, Any]]:
|
|
42
|
+
m = re.match(r"^data:(image/[\w.+-]+);base64,(.+)$", url or "", re.DOTALL)
|
|
43
|
+
if m:
|
|
44
|
+
return {"type": "image", "source": {"type": "base64", "media_type": m.group(1), "data": m.group(2)}}
|
|
45
|
+
if url.startswith(("http://", "https://")):
|
|
46
|
+
return {"type": "image", "source": {"type": "url", "url": url}}
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _user_blocks(content: Any) -> List[Dict[str, Any]]:
|
|
51
|
+
if isinstance(content, str):
|
|
52
|
+
return [{"type": "text", "text": content}] if content else []
|
|
53
|
+
blocks = []
|
|
54
|
+
for part in content or []:
|
|
55
|
+
if not isinstance(part, dict):
|
|
56
|
+
continue
|
|
57
|
+
if part.get("type") == "text" and part.get("text"):
|
|
58
|
+
blocks.append({"type": "text", "text": part["text"]})
|
|
59
|
+
elif part.get("type") == "image_url":
|
|
60
|
+
img = _image_block((part.get("image_url") or {}).get("url", ""))
|
|
61
|
+
if img:
|
|
62
|
+
blocks.append(img)
|
|
63
|
+
return blocks
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def to_anthropic(messages: List[Dict[str, Any]]):
|
|
67
|
+
"""OpenAI-format history -> (system text, Messages API messages)."""
|
|
68
|
+
system_parts: List[str] = []
|
|
69
|
+
out: List[Dict[str, Any]] = []
|
|
70
|
+
|
|
71
|
+
def add(role: str, blocks: List[Dict[str, Any]]):
|
|
72
|
+
if not blocks:
|
|
73
|
+
return
|
|
74
|
+
if out and out[-1]["role"] == role:
|
|
75
|
+
out[-1]["content"].extend(blocks) # tool results of one turn share a single user message
|
|
76
|
+
else:
|
|
77
|
+
out.append({"role": role, "content": list(blocks)})
|
|
78
|
+
|
|
79
|
+
for i, m in enumerate(messages):
|
|
80
|
+
role = m.get("role")
|
|
81
|
+
if role == "system":
|
|
82
|
+
if i == 0:
|
|
83
|
+
system_parts.append(m.get("content") or "")
|
|
84
|
+
else:
|
|
85
|
+
add("user", [{"type": "text", "text": f"<system-note>\n{m.get('content') or ''}\n</system-note>"}])
|
|
86
|
+
elif role == "user":
|
|
87
|
+
add("user", _user_blocks(m.get("content")))
|
|
88
|
+
elif role == "assistant":
|
|
89
|
+
raw = m.get("_anthropic_content")
|
|
90
|
+
if raw:
|
|
91
|
+
add("assistant", [dict(b) for b in raw]) # exact blocks from Claude, thinking included
|
|
92
|
+
continue
|
|
93
|
+
blocks: List[Dict[str, Any]] = []
|
|
94
|
+
text = m.get("content") or ""
|
|
95
|
+
if isinstance(text, str) and text.strip():
|
|
96
|
+
blocks.append({"type": "text", "text": text})
|
|
97
|
+
for tc in m.get("tool_calls") or []:
|
|
98
|
+
fn = tc.get("function") or {}
|
|
99
|
+
try:
|
|
100
|
+
args = parse_arguments(fn.get("arguments") or "{}")
|
|
101
|
+
except ValueError:
|
|
102
|
+
args = {}
|
|
103
|
+
blocks.append({"type": "tool_use", "id": _tool_id(tc.get("id", "")), "name": fn.get("name", ""),
|
|
104
|
+
"input": args})
|
|
105
|
+
add("assistant", blocks or [{"type": "text", "text": "(no content)"}])
|
|
106
|
+
elif role == "tool":
|
|
107
|
+
add("user", [{"type": "tool_result", "tool_use_id": _tool_id(m.get("tool_call_id", "")),
|
|
108
|
+
"content": m.get("content") or "(no output)",
|
|
109
|
+
**({"is_error": True} if str(m.get("content", "")).startswith(("Error:", "Permission denied"))
|
|
110
|
+
else {})}])
|
|
111
|
+
if out and out[0]["role"] != "user":
|
|
112
|
+
out.insert(0, {"role": "user", "content": [{"type": "text", "text": "(conversation continues)"}]})
|
|
113
|
+
return "\n\n".join(p for p in system_parts if p), out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def to_anthropic_tools(tools: Optional[List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
|
|
117
|
+
out = []
|
|
118
|
+
for t in tools or []:
|
|
119
|
+
fn = t.get("function") or t
|
|
120
|
+
schema = fn.get("parameters") or {"type": "object", "properties": {}}
|
|
121
|
+
# Stream large tool inputs (file contents) as they are generated; Hubble validates every
|
|
122
|
+
# parsed input against the tool's schema before running it.
|
|
123
|
+
out.append({"name": fn["name"], "description": fn.get("description", "")[:4096],
|
|
124
|
+
"input_schema": schema, "eager_input_streaming": True})
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class AnthropicProvider:
|
|
129
|
+
"""Talks to Claude through the official SDK. Same methods as OpenAICompatProvider."""
|
|
130
|
+
|
|
131
|
+
kind = "anthropic"
|
|
132
|
+
|
|
133
|
+
def __init__(self, base_url: str, api_key: str, client=None, max_retries: int = 3):
|
|
134
|
+
import anthropic
|
|
135
|
+
from hubble import __version__
|
|
136
|
+
self._anthropic = anthropic
|
|
137
|
+
self.base_url = anthropic_base_url(base_url)
|
|
138
|
+
self.api_key = api_key
|
|
139
|
+
self.client = client or anthropic.Anthropic(
|
|
140
|
+
api_key=api_key, base_url=self.base_url, max_retries=max_retries, timeout=600.0,
|
|
141
|
+
default_headers={"User-Agent": f"hubble-cli/{__version__} (+https://github.com/Hamdayrabby/hubble-cli)"})
|
|
142
|
+
# Server-side fallbacks are a first-party API feature; proxies and gateways may reject them.
|
|
143
|
+
self.first_party = urlparse(self.base_url).hostname == "api.anthropic.com"
|
|
144
|
+
|
|
145
|
+
# ----- errors ---------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
def _error(self, e: Exception) -> ProviderError:
|
|
148
|
+
a = self._anthropic
|
|
149
|
+
if isinstance(e, a.APIStatusError):
|
|
150
|
+
msg = getattr(e, "message", None) or str(e)
|
|
151
|
+
return ProviderError(f"HTTP {e.status_code}: {str(msg)[:200]}", e.status_code)
|
|
152
|
+
if isinstance(e, a.APIConnectionError):
|
|
153
|
+
return ProviderError(f"{type(e).__name__}: {e}") # status None -> transient
|
|
154
|
+
return ProviderError(f"{type(e).__name__}: {e}", 400)
|
|
155
|
+
|
|
156
|
+
# ----- requests -------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def _params(self, model: str, messages, tools, max_tokens: int) -> Dict[str, Any]:
|
|
159
|
+
system, msgs = to_anthropic(messages)
|
|
160
|
+
params: Dict[str, Any] = {"model": model, "max_tokens": max_tokens, "messages": msgs}
|
|
161
|
+
if system:
|
|
162
|
+
# Cache the stable prefix (tools + system prompt) across the turns of a session.
|
|
163
|
+
params["system"] = [{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}]
|
|
164
|
+
if tools:
|
|
165
|
+
params["tools"] = to_anthropic_tools(tools)
|
|
166
|
+
# No temperature and no thinking config: current Claude models reject sampling
|
|
167
|
+
# parameters, and pick their own (adaptive) thinking by default.
|
|
168
|
+
return params
|
|
169
|
+
|
|
170
|
+
def _use_fallbacks(self, model: str) -> bool:
|
|
171
|
+
return self.first_party and any(model == m or model.startswith(m + "-") for m in FALLBACK_MODELS)
|
|
172
|
+
|
|
173
|
+
def stream(self, model: str, messages: List[Dict[str, Any]], tools: Optional[List[Dict]] = None,
|
|
174
|
+
temperature: float = 0.3, max_tokens: int = 8192,
|
|
175
|
+
on_text: Optional[Callable[[str], None]] = None,
|
|
176
|
+
on_reasoning: Optional[Callable[[str], None]] = None) -> TurnResult:
|
|
177
|
+
# Thinking counts against max_tokens and the reply is streamed (no timeout risk), so give
|
|
178
|
+
# the model room: Hubble's generic 8192 default would cut deep answers short.
|
|
179
|
+
params = self._params(model, messages, tools, max(max_tokens, 32000))
|
|
180
|
+
for attempt in range(3):
|
|
181
|
+
try:
|
|
182
|
+
return self._stream_once(model, params, on_text, on_reasoning)
|
|
183
|
+
except ValueError as e:
|
|
184
|
+
# Tool-input JSON the SDK could not parse at all (possible with eager input
|
|
185
|
+
# streaming). No tool_use id exists to answer, so re-issue the turn.
|
|
186
|
+
if attempt == 2:
|
|
187
|
+
raise ProviderError(f"Claude sent unparseable tool input: {e}", 400) from None
|
|
188
|
+
except self._anthropic.APIError as e:
|
|
189
|
+
raise self._error(e) from None
|
|
190
|
+
raise ProviderError("unreachable")
|
|
191
|
+
|
|
192
|
+
def _stream_once(self, model, params, on_text, on_reasoning) -> TurnResult:
|
|
193
|
+
start = time.time()
|
|
194
|
+
result = TurnResult()
|
|
195
|
+
if self._use_fallbacks(model):
|
|
196
|
+
ctx = self.client.beta.messages.stream(**params, betas=[FALLBACK_BETA], fallbacks="default")
|
|
197
|
+
else:
|
|
198
|
+
ctx = self.client.messages.stream(**params)
|
|
199
|
+
with ctx as stream:
|
|
200
|
+
for event in stream:
|
|
201
|
+
if event.type == "text" and event.text:
|
|
202
|
+
if not result.ttft_ms:
|
|
203
|
+
result.ttft_ms = round((time.time() - start) * 1000)
|
|
204
|
+
if on_text:
|
|
205
|
+
on_text(event.text)
|
|
206
|
+
elif event.type == "thinking" and getattr(event, "thinking", ""):
|
|
207
|
+
if on_reasoning:
|
|
208
|
+
on_reasoning(event.thinking)
|
|
209
|
+
final = stream.get_final_message()
|
|
210
|
+
data = final.to_dict()
|
|
211
|
+
blocks = data.get("content") or []
|
|
212
|
+
result.text = "".join(b.get("text", "") for b in blocks if b.get("type") == "text")
|
|
213
|
+
result.reasoning = "".join(b.get("thinking", "") for b in blocks if b.get("type") == "thinking")
|
|
214
|
+
stop = data.get("stop_reason")
|
|
215
|
+
result.finish_reason = STOP_REASONS.get(stop, stop)
|
|
216
|
+
if stop != "max_tokens":
|
|
217
|
+
# On max_tokens a tool input may be cut off mid-JSON: never run it.
|
|
218
|
+
result.tool_calls = [ToolCall(b["id"], b["name"], json.dumps(b.get("input") or {}))
|
|
219
|
+
for b in blocks if b.get("type") == "tool_use"]
|
|
220
|
+
u = data.get("usage") or {}
|
|
221
|
+
prompt = sum(int(u.get(k) or 0) for k in ("input_tokens", "cache_read_input_tokens",
|
|
222
|
+
"cache_creation_input_tokens"))
|
|
223
|
+
result.usage = {"prompt_tokens": prompt, "completion_tokens": int(u.get("output_tokens") or 0),
|
|
224
|
+
"total_tokens": prompt + int(u.get("output_tokens") or 0)}
|
|
225
|
+
if stop != "max_tokens":
|
|
226
|
+
# Echo Claude's own blocks back next turn (keeps thinking signatures intact).
|
|
227
|
+
result.raw_content = blocks
|
|
228
|
+
result.duration = time.time() - start
|
|
229
|
+
return result
|
|
230
|
+
|
|
231
|
+
def complete(self, model: str, messages: List[Dict[str, Any]], max_tokens: int = 2048,
|
|
232
|
+
temperature: float = 0.2) -> str:
|
|
233
|
+
params = self._params(model, messages, None, max_tokens)
|
|
234
|
+
try:
|
|
235
|
+
with self.client.messages.stream(**params) as stream:
|
|
236
|
+
final = stream.get_final_message()
|
|
237
|
+
except self._anthropic.APIError as e:
|
|
238
|
+
raise self._error(e) from None
|
|
239
|
+
return "".join(b.text for b in final.content if b.type == "text")
|
|
240
|
+
|
|
241
|
+
def ping(self, model: str) -> Dict[str, Any]:
|
|
242
|
+
start = time.time()
|
|
243
|
+
try:
|
|
244
|
+
text = self.complete(model, [{"role": "user", "content": "Reply OK"}], max_tokens=64)
|
|
245
|
+
return {"ok": True, "latency_ms": round((time.time() - start) * 1000), "msg": text.strip()[:60]}
|
|
246
|
+
except ProviderError as e:
|
|
247
|
+
return {"ok": False, "latency_ms": round((time.time() - start) * 1000), "msg": str(e)[:120]}
|
|
248
|
+
|
|
249
|
+
def list_models(self) -> List[Dict[str, Any]]:
|
|
250
|
+
"""[{id, context_length, max_output}] from GET /v1/models."""
|
|
251
|
+
try:
|
|
252
|
+
return [{"id": m.id, "context_length": getattr(m, "max_input_tokens", None),
|
|
253
|
+
"max_output": getattr(m, "max_tokens", None)} for m in self.client.models.list()]
|
|
254
|
+
except self._anthropic.APIError as e:
|
|
255
|
+
raise self._error(e) from None
|
|
256
|
+
|
|
@@ -299,6 +299,157 @@ def space_scene(width: int, t: float = 2.4, art: _Art = FULL) -> Text:
|
|
|
299
299
|
return _grid_text(grid).append("\n")
|
|
300
300
|
|
|
301
301
|
|
|
302
|
+
# ----- retro arcade scene (Space Invaders style) ----------------------------
|
|
303
|
+
#
|
|
304
|
+
# Also a pure function of t: invaders march in discrete steps like the 1978 cabinet, the ship
|
|
305
|
+
# glides and fires on a fixed rhythm, and which invaders are gone is replayed from the shots
|
|
306
|
+
# fired so far in the current wave, so any frame can be drawn on its own.
|
|
307
|
+
|
|
308
|
+
INVADERS = [ # (frame A, frame B, color); one row each, top to bottom
|
|
309
|
+
("▞█▚", "▚█▞", "#ff5fd7"),
|
|
310
|
+
("▛▀▜", "▙▄▟", "#00d7ff"),
|
|
311
|
+
]
|
|
312
|
+
_SHIP_ROWS = [
|
|
313
|
+
"▲",
|
|
314
|
+
"▟█▙",
|
|
315
|
+
"▗▄▄▄▟█████▙▄▄▄▖",
|
|
316
|
+
"◢████ H U B B L E ████◣",
|
|
317
|
+
"▀▀▀▜█████████▛▀▀▀",
|
|
318
|
+
"▀▼▀ ▀▼▀",
|
|
319
|
+
]
|
|
320
|
+
SHIP_W = max(len(r) for r in _SHIP_ROWS)
|
|
321
|
+
SHIP = [r.center(SHIP_W) for r in _SHIP_ROWS] # the HUBBLE starship, nose on top
|
|
322
|
+
UFO = "<=●=>"
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class _Arcade:
|
|
326
|
+
height = 5 + len(SHIP) # score/UFO row, 2 invader rows, 2 rows of open space, the ship
|
|
327
|
+
width = 44 # smallest field that still looks like a formation
|
|
328
|
+
COLS = 8 # invaders per row
|
|
329
|
+
GAP = 5 # columns from one invader to the next
|
|
330
|
+
STEP = 0.45 # seconds per march step
|
|
331
|
+
SHOT_EVERY = 0.7
|
|
332
|
+
SHOT_SPEED = 14.0 # rows per second
|
|
333
|
+
WAVE = 14.0 # seconds before a fresh wave marches in
|
|
334
|
+
|
|
335
|
+
def field(self, width: int, left: Optional[int] = None) -> Tuple[int, int]:
|
|
336
|
+
"""(left, field width) of the play area inside a grid `width` wide; centered by default."""
|
|
337
|
+
fw = max(self.width, min(width - 4, 76))
|
|
338
|
+
if left is None:
|
|
339
|
+
left = max(2, (width - fw) // 2)
|
|
340
|
+
return left, fw
|
|
341
|
+
|
|
342
|
+
def _march(self, t: float, span: int) -> int:
|
|
343
|
+
"""Formation x offset: steps right, then left, one column per STEP (triangle wave)."""
|
|
344
|
+
steps = int(t / self.STEP)
|
|
345
|
+
period = 2 * span
|
|
346
|
+
k = steps % period if period else 0
|
|
347
|
+
return k if k <= span else period - k
|
|
348
|
+
|
|
349
|
+
def _ship_x(self, t: float, fw: int) -> float:
|
|
350
|
+
"""Left column of the ship: cruises around the middle of the field."""
|
|
351
|
+
room = max(0, fw - SHIP_W)
|
|
352
|
+
return room / 2 + room / 2 * 0.85 * math.sin(t * 0.6)
|
|
353
|
+
|
|
354
|
+
def state(self, t: float, fw: int):
|
|
355
|
+
"""(offset, dead {(row, i)}, explosions [(row, col, age)], bullets [(y, x)], score)."""
|
|
356
|
+
wave_t = t % self.WAVE
|
|
357
|
+
wave_start = t - wave_t
|
|
358
|
+
formation = (self.COLS - 1) * self.GAP + 3
|
|
359
|
+
span = max(0, fw - formation)
|
|
360
|
+
dead, booms, bullets, score = set(), [], [], 0
|
|
361
|
+
n = int(wave_t / self.SHOT_EVERY)
|
|
362
|
+
for k in range(n + 1):
|
|
363
|
+
fired = wave_start + k * self.SHOT_EVERY
|
|
364
|
+
if fired > t:
|
|
365
|
+
break
|
|
366
|
+
x = int(round(self._ship_x(fired, fw))) + SHIP_W // 2 # the nose cannon
|
|
367
|
+
# Bullet leaves row 5 (ship tip) and climbs; invader rows are 1 and 2.
|
|
368
|
+
hit = None
|
|
369
|
+
for row in (1, 0): # lowest invader row first, the one a bullet meets first
|
|
370
|
+
travel = (5 - (row + 1)) / self.SHOT_SPEED
|
|
371
|
+
at = fired + travel
|
|
372
|
+
off = self._march(at, span)
|
|
373
|
+
i, rem = divmod(x - off, self.GAP)
|
|
374
|
+
if 0 <= i < self.COLS and rem < 3 and (row, i) not in dead:
|
|
375
|
+
hit = (row, i, at)
|
|
376
|
+
break
|
|
377
|
+
if hit and hit[2] <= t:
|
|
378
|
+
row, i, at = hit
|
|
379
|
+
dead.add((row, i))
|
|
380
|
+
score += 30 if row == 0 else 20
|
|
381
|
+
if t - at < 0.35:
|
|
382
|
+
booms.append((row + 1, self._march(at, span) + i * self.GAP + 1, t - at))
|
|
383
|
+
else:
|
|
384
|
+
y = 5 - (t - fired) * self.SHOT_SPEED
|
|
385
|
+
end = (hit[0] + 1) if hit else -1
|
|
386
|
+
if y > end:
|
|
387
|
+
bullets.append((int(y), x))
|
|
388
|
+
total_score = int(t / self.WAVE) * 400 + score
|
|
389
|
+
return self._march(t, span), dead, booms, bullets, total_score
|
|
390
|
+
|
|
391
|
+
def draw(self, grid, top: int, t: float, left: Optional[int] = None):
|
|
392
|
+
width = len(grid[0])
|
|
393
|
+
left, fw = self.field(width, left)
|
|
394
|
+
fw = min(fw, width - left)
|
|
395
|
+
|
|
396
|
+
def put(r, c, ch, style):
|
|
397
|
+
if 0 <= top + r < len(grid) and 0 <= left + c < min(width, left + fw):
|
|
398
|
+
grid[top + r][left + c] = (ch, style)
|
|
399
|
+
|
|
400
|
+
off, dead, booms, bullets, score = self.state(t, fw)
|
|
401
|
+
frame = int(t / self.STEP) % 2
|
|
402
|
+
for j, ch in enumerate(f"SCORE {score:05d}"):
|
|
403
|
+
put(0, j, ch, "#5fd787")
|
|
404
|
+
hi = "HI 09990"
|
|
405
|
+
for j, ch in enumerate(hi):
|
|
406
|
+
put(0, fw - len(hi) + j, ch, "#878787")
|
|
407
|
+
# Mothership crosses the top every so often (over the score, like the real thing).
|
|
408
|
+
ut = t % 9.0
|
|
409
|
+
if ut < 3.0:
|
|
410
|
+
ux = int((fw + len(UFO)) * ut / 3.0) - len(UFO)
|
|
411
|
+
for j, ch in enumerate(UFO):
|
|
412
|
+
put(0, ux + j, ch, "bold #ff5f5f" if ch == "●" and int(t * 8) % 2 else "#ff8787")
|
|
413
|
+
for row, (a, b, color) in enumerate(INVADERS):
|
|
414
|
+
sprite = a if frame == 0 else b
|
|
415
|
+
for i in range(self.COLS):
|
|
416
|
+
if (row, i) in dead:
|
|
417
|
+
continue
|
|
418
|
+
for j, ch in enumerate(sprite):
|
|
419
|
+
put(row + 1, off + i * self.GAP + j, ch, color)
|
|
420
|
+
for r, c, age in booms:
|
|
421
|
+
ch = "✶" if age < 0.12 else "✷" if age < 0.24 else "·"
|
|
422
|
+
for dc, g in ((-1, "╲"), (0, ch), (1, "╱")):
|
|
423
|
+
put(r, c + dc, g if age < 0.24 else " " if dc else ch, "bold #ffd75f")
|
|
424
|
+
for y, x in bullets:
|
|
425
|
+
if 0 <= y <= 5:
|
|
426
|
+
put(y, x, "│", "bold #ffffaf")
|
|
427
|
+
sx = int(round(self._ship_x(t, fw)))
|
|
428
|
+
flame = int(t * 10) % 3
|
|
429
|
+
name_row = next(i for i, r in enumerate(SHIP) if "H U B B L E" in r)
|
|
430
|
+
for r, line in enumerate(SHIP):
|
|
431
|
+
for j, ch in enumerate(line):
|
|
432
|
+
if ch == " " and r != name_row:
|
|
433
|
+
continue
|
|
434
|
+
if ch == "▲":
|
|
435
|
+
style = "bold #ffffff" if int(t * 6) % 2 else "bold #d7e7ff"
|
|
436
|
+
elif ch == "▼":
|
|
437
|
+
ch = ("▼", "▽", "▾")[(flame + j) % 3]
|
|
438
|
+
style = ("bold #ffd75f", "#ff8700", "#ff5f00")[(flame + j) % 3]
|
|
439
|
+
elif ch.isalpha():
|
|
440
|
+
style = f"bold {_blend(GRADIENT, ((j / SHIP_W) + t * 0.15) % 1.0)}" # glowing name
|
|
441
|
+
elif r == name_row and ch == " ":
|
|
442
|
+
style = "" # opaque inside the hull, so stars pass behind the ship
|
|
443
|
+
elif ch in "◢◣":
|
|
444
|
+
style = "#ff5f87" # wing-tip lights
|
|
445
|
+
else:
|
|
446
|
+
style = "#afc7e7" if r < name_row else "#5f87af"
|
|
447
|
+
put(5 + r, sx + j, ch, style)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
ARCADE = _Arcade()
|
|
451
|
+
|
|
452
|
+
|
|
302
453
|
# ----- composed home screen -------------------------------------------------
|
|
303
454
|
|
|
304
455
|
_MEASURE = Console(width=400, color_system="truecolor", force_terminal=True, legacy_windows=False,
|
|
@@ -440,27 +591,25 @@ def compose_grid(width: int, height: int, t: float, status: List[Text], tips: Li
|
|
|
440
591
|
"""
|
|
441
592
|
width = max(40, min(width - 1, 110))
|
|
442
593
|
height = max(height, 1)
|
|
443
|
-
# Logo sizes:
|
|
594
|
+
# Logo sizes: 1 = block letters, 0 = one-line wordmark. (2, the large pixel letters, is kept
|
|
595
|
+
# for anyone who wants it back, but the HUBBLE starship is the star of the screen now.)
|
|
444
596
|
logo_h = {2: 9, 1: 7, 0: 1} # letter rows + shadow row + tagline
|
|
445
|
-
top =
|
|
446
|
-
|
|
447
|
-
#
|
|
448
|
-
# tips, then status lines, and only then fall back to a smaller logo.
|
|
597
|
+
top = 1 if width >= 58 else 0
|
|
598
|
+
# Richest to poorest: the starship scene comes first, then the bigger logo, then tips,
|
|
599
|
+
# then status lines.
|
|
449
600
|
n_status = len(status)
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
layouts.append((cand_size, cand_art, True, n_status))
|
|
454
|
-
for cand_art in (COMPACT, None):
|
|
455
|
-
layouts.append((cand_size, cand_art, False, n_status))
|
|
456
|
-
for n in (min(2, n_status), min(1, n_status), 0):
|
|
457
|
-
layouts.append((cand_size, None, False, n))
|
|
601
|
+
shrink = [(True, n_status), (False, n_status), (False, min(2, n_status)), (False, min(1, n_status)),
|
|
602
|
+
(False, 0)]
|
|
603
|
+
layouts = [(s, a, tp, n) for a in (ARCADE, None) for s in dict.fromkeys([top, 0]) for tp, n in shrink]
|
|
458
604
|
size, art, with_tips, n_status = layouts[-1]
|
|
605
|
+
|
|
606
|
+
def art_rows(a):
|
|
607
|
+
return a.height + 1 if a is not None else 0
|
|
608
|
+
|
|
459
609
|
for cand_size, cand_art, cand_tips, cand_n in layouts:
|
|
460
|
-
if cand_art is
|
|
610
|
+
if cand_art is ARCADE and width < ARCADE.width + 4:
|
|
461
611
|
continue
|
|
462
|
-
need = logo_h[cand_size] + 1 + (cand_art
|
|
463
|
-
(1 + len(tips) if cand_tips else 0) + 1
|
|
612
|
+
need = logo_h[cand_size] + 1 + art_rows(cand_art) + cand_n + (1 + len(tips) if cand_tips else 0) + 1
|
|
464
613
|
if need <= height:
|
|
465
614
|
size, art, with_tips, n_status = cand_size, cand_art, cand_tips, cand_n
|
|
466
615
|
break
|
|
@@ -468,7 +617,7 @@ def compose_grid(width: int, height: int, t: float, status: List[Text], tips: Li
|
|
|
468
617
|
use_big = size > 0
|
|
469
618
|
logo_h = logo_h[size]
|
|
470
619
|
|
|
471
|
-
rows_total = logo_h + 1 + (art
|
|
620
|
+
rows_total = logo_h + 1 + art_rows(art) + len(status) + (1 + len(tips) if with_tips else 0) + 1
|
|
472
621
|
rows_total = min(rows_total, height) if rows_total > height else rows_total # never exceed the budget
|
|
473
622
|
grid = _new_grid(width, max(rows_total, 1))
|
|
474
623
|
_draw_stars(grid, t)
|
|
@@ -490,7 +639,10 @@ def compose_grid(width: int, height: int, t: float, status: List[Text], tips: Li
|
|
|
490
639
|
_stamp_text(grid, row, 2, Text.assemble(small_logo(), (" v" + version, "dim")))
|
|
491
640
|
row += logo_h + 1
|
|
492
641
|
|
|
493
|
-
if art is
|
|
642
|
+
if art is ARCADE:
|
|
643
|
+
ARCADE.draw(grid, row, t)
|
|
644
|
+
row += ARCADE.height + 1
|
|
645
|
+
elif isinstance(art, _Art):
|
|
494
646
|
_draw_comet(grid, row, t)
|
|
495
647
|
_draw_scene(grid, row, t, art)
|
|
496
648
|
row += art.height + 1
|