llmcode-cli 1.0.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.
- llm_code/__init__.py +2 -0
- llm_code/analysis/__init__.py +6 -0
- llm_code/analysis/cache.py +33 -0
- llm_code/analysis/engine.py +256 -0
- llm_code/analysis/go_rules.py +114 -0
- llm_code/analysis/js_rules.py +84 -0
- llm_code/analysis/python_rules.py +311 -0
- llm_code/analysis/rules.py +140 -0
- llm_code/analysis/rust_rules.py +108 -0
- llm_code/analysis/universal_rules.py +111 -0
- llm_code/api/__init__.py +0 -0
- llm_code/api/client.py +90 -0
- llm_code/api/errors.py +73 -0
- llm_code/api/openai_compat.py +390 -0
- llm_code/api/provider.py +35 -0
- llm_code/api/sse.py +52 -0
- llm_code/api/types.py +140 -0
- llm_code/cli/__init__.py +0 -0
- llm_code/cli/commands.py +70 -0
- llm_code/cli/image.py +122 -0
- llm_code/cli/render.py +214 -0
- llm_code/cli/status_line.py +79 -0
- llm_code/cli/streaming.py +92 -0
- llm_code/cli/tui_main.py +220 -0
- llm_code/computer_use/__init__.py +11 -0
- llm_code/computer_use/app_detect.py +49 -0
- llm_code/computer_use/app_tier.py +57 -0
- llm_code/computer_use/coordinator.py +99 -0
- llm_code/computer_use/input_control.py +71 -0
- llm_code/computer_use/screenshot.py +93 -0
- llm_code/cron/__init__.py +13 -0
- llm_code/cron/parser.py +145 -0
- llm_code/cron/scheduler.py +135 -0
- llm_code/cron/storage.py +126 -0
- llm_code/enterprise/__init__.py +1 -0
- llm_code/enterprise/audit.py +59 -0
- llm_code/enterprise/auth.py +26 -0
- llm_code/enterprise/oidc.py +95 -0
- llm_code/enterprise/rbac.py +65 -0
- llm_code/harness/__init__.py +5 -0
- llm_code/harness/config.py +33 -0
- llm_code/harness/engine.py +129 -0
- llm_code/harness/guides.py +41 -0
- llm_code/harness/sensors.py +68 -0
- llm_code/harness/templates.py +84 -0
- llm_code/hida/__init__.py +1 -0
- llm_code/hida/classifier.py +187 -0
- llm_code/hida/engine.py +49 -0
- llm_code/hida/profiles.py +95 -0
- llm_code/hida/types.py +28 -0
- llm_code/ide/__init__.py +1 -0
- llm_code/ide/bridge.py +80 -0
- llm_code/ide/detector.py +76 -0
- llm_code/ide/server.py +169 -0
- llm_code/logging.py +29 -0
- llm_code/lsp/__init__.py +0 -0
- llm_code/lsp/client.py +298 -0
- llm_code/lsp/detector.py +42 -0
- llm_code/lsp/manager.py +56 -0
- llm_code/lsp/tools.py +288 -0
- llm_code/marketplace/__init__.py +0 -0
- llm_code/marketplace/builtin_registry.py +102 -0
- llm_code/marketplace/installer.py +162 -0
- llm_code/marketplace/plugin.py +78 -0
- llm_code/marketplace/registry.py +360 -0
- llm_code/mcp/__init__.py +0 -0
- llm_code/mcp/bridge.py +87 -0
- llm_code/mcp/client.py +117 -0
- llm_code/mcp/health.py +120 -0
- llm_code/mcp/manager.py +214 -0
- llm_code/mcp/oauth.py +219 -0
- llm_code/mcp/transport.py +254 -0
- llm_code/mcp/types.py +53 -0
- llm_code/remote/__init__.py +0 -0
- llm_code/remote/client.py +136 -0
- llm_code/remote/protocol.py +22 -0
- llm_code/remote/server.py +275 -0
- llm_code/remote/ssh_proxy.py +56 -0
- llm_code/runtime/__init__.py +0 -0
- llm_code/runtime/auto_commit.py +56 -0
- llm_code/runtime/auto_diagnose.py +62 -0
- llm_code/runtime/checkpoint.py +70 -0
- llm_code/runtime/checkpoint_recovery.py +142 -0
- llm_code/runtime/compaction.py +35 -0
- llm_code/runtime/compressor.py +415 -0
- llm_code/runtime/config.py +533 -0
- llm_code/runtime/context.py +49 -0
- llm_code/runtime/conversation.py +921 -0
- llm_code/runtime/cost_tracker.py +126 -0
- llm_code/runtime/dream.py +127 -0
- llm_code/runtime/file_protection.py +150 -0
- llm_code/runtime/hardware.py +85 -0
- llm_code/runtime/hooks.py +223 -0
- llm_code/runtime/indexer.py +230 -0
- llm_code/runtime/knowledge_compiler.py +232 -0
- llm_code/runtime/memory.py +132 -0
- llm_code/runtime/memory_layers.py +467 -0
- llm_code/runtime/memory_lint.py +252 -0
- llm_code/runtime/model_aliases.py +37 -0
- llm_code/runtime/ollama.py +93 -0
- llm_code/runtime/overlay.py +124 -0
- llm_code/runtime/permissions.py +200 -0
- llm_code/runtime/plan.py +45 -0
- llm_code/runtime/prompt.py +238 -0
- llm_code/runtime/repo_map.py +174 -0
- llm_code/runtime/sandbox.py +116 -0
- llm_code/runtime/session.py +268 -0
- llm_code/runtime/skill_resolver.py +61 -0
- llm_code/runtime/skills.py +133 -0
- llm_code/runtime/speculative.py +75 -0
- llm_code/runtime/streaming_executor.py +216 -0
- llm_code/runtime/telemetry.py +196 -0
- llm_code/runtime/token_budget.py +26 -0
- llm_code/runtime/vcr.py +142 -0
- llm_code/runtime/vision.py +102 -0
- llm_code/swarm/__init__.py +1 -0
- llm_code/swarm/backend_subprocess.py +108 -0
- llm_code/swarm/backend_tmux.py +103 -0
- llm_code/swarm/backend_worktree.py +306 -0
- llm_code/swarm/checkpoint.py +74 -0
- llm_code/swarm/coordinator.py +236 -0
- llm_code/swarm/mailbox.py +88 -0
- llm_code/swarm/manager.py +202 -0
- llm_code/swarm/memory_sync.py +80 -0
- llm_code/swarm/recovery.py +21 -0
- llm_code/swarm/team.py +67 -0
- llm_code/swarm/types.py +31 -0
- llm_code/task/__init__.py +16 -0
- llm_code/task/diagnostics.py +93 -0
- llm_code/task/manager.py +162 -0
- llm_code/task/types.py +112 -0
- llm_code/task/verifier.py +104 -0
- llm_code/tools/__init__.py +0 -0
- llm_code/tools/agent.py +145 -0
- llm_code/tools/agent_roles.py +82 -0
- llm_code/tools/base.py +94 -0
- llm_code/tools/bash.py +565 -0
- llm_code/tools/computer_use_tools.py +278 -0
- llm_code/tools/coordinator_tool.py +75 -0
- llm_code/tools/cron_create.py +90 -0
- llm_code/tools/cron_delete.py +49 -0
- llm_code/tools/cron_list.py +51 -0
- llm_code/tools/deferred.py +92 -0
- llm_code/tools/dump.py +116 -0
- llm_code/tools/edit_file.py +282 -0
- llm_code/tools/git_tools.py +531 -0
- llm_code/tools/glob_search.py +112 -0
- llm_code/tools/grep_search.py +144 -0
- llm_code/tools/ide_diagnostics.py +59 -0
- llm_code/tools/ide_open.py +58 -0
- llm_code/tools/ide_selection.py +52 -0
- llm_code/tools/memory_tools.py +138 -0
- llm_code/tools/multi_edit.py +143 -0
- llm_code/tools/notebook_edit.py +107 -0
- llm_code/tools/notebook_read.py +81 -0
- llm_code/tools/parsing.py +63 -0
- llm_code/tools/read_file.py +154 -0
- llm_code/tools/registry.py +58 -0
- llm_code/tools/search_backends/__init__.py +56 -0
- llm_code/tools/search_backends/brave.py +56 -0
- llm_code/tools/search_backends/duckduckgo.py +129 -0
- llm_code/tools/search_backends/searxng.py +71 -0
- llm_code/tools/search_backends/tavily.py +73 -0
- llm_code/tools/swarm_create.py +109 -0
- llm_code/tools/swarm_delete.py +95 -0
- llm_code/tools/swarm_list.py +44 -0
- llm_code/tools/swarm_message.py +109 -0
- llm_code/tools/task_close.py +79 -0
- llm_code/tools/task_plan.py +79 -0
- llm_code/tools/task_verify.py +90 -0
- llm_code/tools/tool_search.py +65 -0
- llm_code/tools/web_common.py +258 -0
- llm_code/tools/web_fetch.py +223 -0
- llm_code/tools/web_search.py +280 -0
- llm_code/tools/write_file.py +118 -0
- llm_code/tui/__init__.py +1 -0
- llm_code/tui/app.py +2432 -0
- llm_code/tui/chat_view.py +82 -0
- llm_code/tui/chat_widgets.py +309 -0
- llm_code/tui/header_bar.py +46 -0
- llm_code/tui/input_bar.py +349 -0
- llm_code/tui/keybindings.py +142 -0
- llm_code/tui/marketplace.py +210 -0
- llm_code/tui/status_bar.py +72 -0
- llm_code/tui/theme.py +96 -0
- llm_code/utils/__init__.py +0 -0
- llm_code/utils/diff.py +111 -0
- llm_code/utils/errors.py +70 -0
- llm_code/utils/hyperlink.py +73 -0
- llm_code/utils/notebook.py +179 -0
- llm_code/utils/search.py +69 -0
- llm_code/utils/text_normalize.py +28 -0
- llm_code/utils/version_check.py +62 -0
- llm_code/vim/__init__.py +4 -0
- llm_code/vim/engine.py +51 -0
- llm_code/vim/motions.py +172 -0
- llm_code/vim/operators.py +183 -0
- llm_code/vim/text_objects.py +139 -0
- llm_code/vim/transitions.py +279 -0
- llm_code/vim/types.py +68 -0
- llm_code/voice/__init__.py +1 -0
- llm_code/voice/languages.py +43 -0
- llm_code/voice/recorder.py +136 -0
- llm_code/voice/stt.py +36 -0
- llm_code/voice/stt_anthropic.py +66 -0
- llm_code/voice/stt_google.py +32 -0
- llm_code/voice/stt_whisper.py +52 -0
- llmcode_cli-1.0.0.dist-info/METADATA +524 -0
- llmcode_cli-1.0.0.dist-info/RECORD +212 -0
- llmcode_cli-1.0.0.dist-info/WHEEL +4 -0
- llmcode_cli-1.0.0.dist-info/entry_points.txt +2 -0
- llmcode_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
llm_code/api/types.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Frozen dataclass types for the LLM provider API layer."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import dataclasses
|
|
5
|
+
from typing import Union
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclasses.dataclass(frozen=True)
|
|
9
|
+
class TextBlock:
|
|
10
|
+
text: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclasses.dataclass(frozen=True)
|
|
14
|
+
class ToolUseBlock:
|
|
15
|
+
id: str
|
|
16
|
+
name: str
|
|
17
|
+
input: dict
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclasses.dataclass(frozen=True)
|
|
21
|
+
class ToolResultBlock:
|
|
22
|
+
tool_use_id: str
|
|
23
|
+
content: str
|
|
24
|
+
is_error: bool = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclasses.dataclass(frozen=True)
|
|
28
|
+
class ImageBlock:
|
|
29
|
+
media_type: str
|
|
30
|
+
data: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
ContentBlock = Union[TextBlock, ToolUseBlock, ToolResultBlock, ImageBlock]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclasses.dataclass(frozen=True)
|
|
37
|
+
class Message:
|
|
38
|
+
role: str
|
|
39
|
+
content: tuple[ContentBlock, ...]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclasses.dataclass(frozen=True)
|
|
43
|
+
class ToolDefinition:
|
|
44
|
+
name: str
|
|
45
|
+
description: str
|
|
46
|
+
input_schema: dict
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclasses.dataclass(frozen=True)
|
|
50
|
+
class MessageRequest:
|
|
51
|
+
model: str
|
|
52
|
+
messages: tuple[Message, ...]
|
|
53
|
+
system: str | None = None
|
|
54
|
+
tools: tuple[ToolDefinition, ...] = ()
|
|
55
|
+
max_tokens: int = 4096
|
|
56
|
+
temperature: float = 0.7
|
|
57
|
+
stream: bool = True
|
|
58
|
+
extra_body: dict | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclasses.dataclass(frozen=True)
|
|
62
|
+
class TokenUsage:
|
|
63
|
+
input_tokens: int
|
|
64
|
+
output_tokens: int
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclasses.dataclass(frozen=True)
|
|
68
|
+
class MessageResponse:
|
|
69
|
+
content: tuple[ContentBlock, ...]
|
|
70
|
+
usage: TokenUsage
|
|
71
|
+
stop_reason: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclasses.dataclass(frozen=True)
|
|
75
|
+
class StreamEvent:
|
|
76
|
+
"""Base class for all stream events."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclasses.dataclass(frozen=True)
|
|
80
|
+
class StreamMessageStart(StreamEvent):
|
|
81
|
+
model: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclasses.dataclass(frozen=True)
|
|
85
|
+
class StreamTextDelta(StreamEvent):
|
|
86
|
+
text: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclasses.dataclass(frozen=True)
|
|
90
|
+
class StreamToolUseStart(StreamEvent):
|
|
91
|
+
id: str
|
|
92
|
+
name: str
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclasses.dataclass(frozen=True)
|
|
96
|
+
class StreamToolUseInputDelta(StreamEvent):
|
|
97
|
+
id: str
|
|
98
|
+
partial_json: str
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclasses.dataclass(frozen=True)
|
|
102
|
+
class StreamMessageStop(StreamEvent):
|
|
103
|
+
usage: TokenUsage
|
|
104
|
+
stop_reason: str
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclasses.dataclass(frozen=True)
|
|
108
|
+
class StreamToolProgress(StreamEvent):
|
|
109
|
+
tool_name: str
|
|
110
|
+
message: str
|
|
111
|
+
percent: float | None = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclasses.dataclass(frozen=True)
|
|
115
|
+
class StreamToolExecStart(StreamEvent):
|
|
116
|
+
"""Emitted when a tool starts executing."""
|
|
117
|
+
tool_name: str
|
|
118
|
+
args_summary: str
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclasses.dataclass(frozen=True)
|
|
122
|
+
class StreamToolExecResult(StreamEvent):
|
|
123
|
+
"""Emitted when a tool finishes executing."""
|
|
124
|
+
tool_name: str
|
|
125
|
+
output: str
|
|
126
|
+
is_error: bool = False
|
|
127
|
+
metadata: dict | None = None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclasses.dataclass(frozen=True)
|
|
131
|
+
class StreamThinkingDelta(StreamEvent):
|
|
132
|
+
"""Emitted when the model produces a thinking/reasoning token."""
|
|
133
|
+
text: str
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclasses.dataclass(frozen=True)
|
|
137
|
+
class StreamPermissionRequest(StreamEvent):
|
|
138
|
+
"""Emitted when a tool requires user permission before execution."""
|
|
139
|
+
tool_name: str
|
|
140
|
+
args_preview: str
|
llm_code/cli/__init__.py
ADDED
|
File without changes
|
llm_code/cli/commands.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Slash command parsing for the CLI layer."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
KNOWN_COMMANDS = frozenset({
|
|
7
|
+
"help",
|
|
8
|
+
"clear",
|
|
9
|
+
"model",
|
|
10
|
+
"session",
|
|
11
|
+
"config",
|
|
12
|
+
"cd",
|
|
13
|
+
"image",
|
|
14
|
+
"cost",
|
|
15
|
+
"exit",
|
|
16
|
+
"quit",
|
|
17
|
+
"plugin",
|
|
18
|
+
"skill",
|
|
19
|
+
"undo",
|
|
20
|
+
"memory",
|
|
21
|
+
"index",
|
|
22
|
+
"lsp",
|
|
23
|
+
"mcp",
|
|
24
|
+
"budget",
|
|
25
|
+
"thinking",
|
|
26
|
+
"cron",
|
|
27
|
+
"vim",
|
|
28
|
+
"voice",
|
|
29
|
+
"ide",
|
|
30
|
+
"swarm",
|
|
31
|
+
"search",
|
|
32
|
+
"vcr",
|
|
33
|
+
"hida",
|
|
34
|
+
"task",
|
|
35
|
+
"checkpoint",
|
|
36
|
+
"cancel",
|
|
37
|
+
"keybind",
|
|
38
|
+
"audit",
|
|
39
|
+
"analyze",
|
|
40
|
+
"diff_check",
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class SlashCommand:
|
|
46
|
+
name: str
|
|
47
|
+
args: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_slash_command(text: str) -> SlashCommand | None:
|
|
51
|
+
"""Parse a slash command from text.
|
|
52
|
+
|
|
53
|
+
Returns a SlashCommand if the text starts with '/', otherwise None.
|
|
54
|
+
"""
|
|
55
|
+
stripped = text.strip()
|
|
56
|
+
if not stripped.startswith("/"):
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
# Strip leading slash
|
|
60
|
+
rest = stripped[1:]
|
|
61
|
+
|
|
62
|
+
# Split on first whitespace
|
|
63
|
+
parts = rest.split(None, 1)
|
|
64
|
+
if not parts:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
name = parts[0].lower()
|
|
68
|
+
args = parts[1] if len(parts) > 1 else ""
|
|
69
|
+
|
|
70
|
+
return SlashCommand(name=name, args=args)
|
llm_code/cli/image.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Image loading utilities for the CLI layer."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from llm_code.api.types import ImageBlock
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Map file extensions to MIME types
|
|
13
|
+
_EXT_TO_MEDIA_TYPE: dict[str, str] = {
|
|
14
|
+
".png": "image/png",
|
|
15
|
+
".jpg": "image/jpeg",
|
|
16
|
+
".jpeg": "image/jpeg",
|
|
17
|
+
".gif": "image/gif",
|
|
18
|
+
".webp": "image/webp",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_image_from_path(path: str) -> ImageBlock:
|
|
23
|
+
"""Load an image from a file path.
|
|
24
|
+
|
|
25
|
+
Reads the file, base64-encodes it, and detects the media type from extension.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
FileNotFoundError: If the file does not exist.
|
|
29
|
+
ValueError: If the file extension is not a supported image type.
|
|
30
|
+
"""
|
|
31
|
+
file_path = Path(path)
|
|
32
|
+
if not file_path.exists():
|
|
33
|
+
raise FileNotFoundError(f"Image file not found: {path}")
|
|
34
|
+
|
|
35
|
+
ext = file_path.suffix.lower()
|
|
36
|
+
media_type = _EXT_TO_MEDIA_TYPE.get(ext)
|
|
37
|
+
if media_type is None:
|
|
38
|
+
# Default to png for unknown extensions
|
|
39
|
+
media_type = "image/png"
|
|
40
|
+
|
|
41
|
+
raw = file_path.read_bytes()
|
|
42
|
+
encoded = base64.b64encode(raw).decode("ascii")
|
|
43
|
+
|
|
44
|
+
return ImageBlock(media_type=media_type, data=encoded)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def capture_clipboard_image() -> ImageBlock | None:
|
|
48
|
+
"""Capture an image from the clipboard.
|
|
49
|
+
|
|
50
|
+
macOS: uses pngpaste -
|
|
51
|
+
Linux: uses xclip -selection clipboard -t image/png -o
|
|
52
|
+
|
|
53
|
+
Returns None if capture is not available or fails.
|
|
54
|
+
"""
|
|
55
|
+
if sys.platform == "darwin":
|
|
56
|
+
try:
|
|
57
|
+
result = subprocess.run(
|
|
58
|
+
["pngpaste", "-"],
|
|
59
|
+
capture_output=True,
|
|
60
|
+
timeout=5,
|
|
61
|
+
)
|
|
62
|
+
if result.returncode == 0 and result.stdout:
|
|
63
|
+
encoded = base64.b64encode(result.stdout).decode("ascii")
|
|
64
|
+
return ImageBlock(media_type="image/png", data=encoded)
|
|
65
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
66
|
+
pass
|
|
67
|
+
return None
|
|
68
|
+
else:
|
|
69
|
+
# Linux
|
|
70
|
+
try:
|
|
71
|
+
result = subprocess.run(
|
|
72
|
+
["xclip", "-selection", "clipboard", "-t", "image/png", "-o"],
|
|
73
|
+
capture_output=True,
|
|
74
|
+
timeout=5,
|
|
75
|
+
)
|
|
76
|
+
if result.returncode == 0 and result.stdout:
|
|
77
|
+
encoded = base64.b64encode(result.stdout).decode("ascii")
|
|
78
|
+
return ImageBlock(media_type="image/png", data=encoded)
|
|
79
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
80
|
+
pass
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def extract_dropped_images(text: str) -> tuple[str, list[ImageBlock]]:
|
|
88
|
+
r"""Detect drag-and-dropped image file paths in user input.
|
|
89
|
+
|
|
90
|
+
Terminal drag-and-drop produces paths like:
|
|
91
|
+
/Users/adam/screenshot.png
|
|
92
|
+
'/Users/adam/my screenshot.png'
|
|
93
|
+
/Users/adam/my\ screenshot.png
|
|
94
|
+
|
|
95
|
+
Returns (cleaned_text, list_of_ImageBlocks).
|
|
96
|
+
"""
|
|
97
|
+
import shlex
|
|
98
|
+
from pathlib import Path as P
|
|
99
|
+
|
|
100
|
+
images: list[ImageBlock] = []
|
|
101
|
+
remaining_parts: list[str] = []
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
tokens = shlex.split(text)
|
|
105
|
+
except ValueError:
|
|
106
|
+
tokens = text.split()
|
|
107
|
+
|
|
108
|
+
for token in tokens:
|
|
109
|
+
token = token.strip()
|
|
110
|
+
if not token:
|
|
111
|
+
continue
|
|
112
|
+
path = P(token)
|
|
113
|
+
if path.suffix.lower() in _IMAGE_EXTENSIONS and path.is_file():
|
|
114
|
+
try:
|
|
115
|
+
img = load_image_from_path(str(path))
|
|
116
|
+
images.append(img)
|
|
117
|
+
except Exception:
|
|
118
|
+
remaining_parts.append(token)
|
|
119
|
+
else:
|
|
120
|
+
remaining_parts.append(token)
|
|
121
|
+
|
|
122
|
+
return " ".join(remaining_parts), images
|
llm_code/cli/render.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Rich-based terminal renderer for the CLI layer."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.markdown import Markdown
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.syntax import Syntax
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from llm_code.api.types import TokenUsage
|
|
14
|
+
from llm_code.tools.base import ToolResult
|
|
15
|
+
from llm_code.utils.hyperlink import auto_link, supports_hyperlinks
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# File extensions to language mappings for syntax highlighting
|
|
19
|
+
_EXT_TO_LANG: dict[str, str] = {
|
|
20
|
+
".py": "python",
|
|
21
|
+
".js": "javascript",
|
|
22
|
+
".ts": "typescript",
|
|
23
|
+
".tsx": "tsx",
|
|
24
|
+
".jsx": "jsx",
|
|
25
|
+
".go": "go",
|
|
26
|
+
".rs": "rust",
|
|
27
|
+
".java": "java",
|
|
28
|
+
".c": "c",
|
|
29
|
+
".cpp": "cpp",
|
|
30
|
+
".h": "c",
|
|
31
|
+
".hpp": "cpp",
|
|
32
|
+
".sh": "bash",
|
|
33
|
+
".bash": "bash",
|
|
34
|
+
".zsh": "bash",
|
|
35
|
+
".json": "json",
|
|
36
|
+
".yaml": "yaml",
|
|
37
|
+
".yml": "yaml",
|
|
38
|
+
".toml": "toml",
|
|
39
|
+
".md": "markdown",
|
|
40
|
+
".html": "html",
|
|
41
|
+
".css": "css",
|
|
42
|
+
".sql": "sql",
|
|
43
|
+
".rb": "ruby",
|
|
44
|
+
".php": "php",
|
|
45
|
+
".swift": "swift",
|
|
46
|
+
".kt": "kotlin",
|
|
47
|
+
".r": "r",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
SLASH_COMMANDS_HELP = [
|
|
51
|
+
("/help", "Show this help message"),
|
|
52
|
+
("/clear", "Clear the conversation history"),
|
|
53
|
+
("/model [name]", "Show or switch the current model"),
|
|
54
|
+
("/session list", "List saved sessions"),
|
|
55
|
+
("/session save", "Save the current session"),
|
|
56
|
+
("/session switch <id>", "Switch to a saved session"),
|
|
57
|
+
("/config", "Show runtime config"),
|
|
58
|
+
("/config get <key>", "Get a config value"),
|
|
59
|
+
("/config set <key> <value>", "Set a runtime config value"),
|
|
60
|
+
("/cd <path>", "Change the working directory"),
|
|
61
|
+
("/image <path>", "Attach an image from file path"),
|
|
62
|
+
("/cost", "Show token usage and estimated cost"),
|
|
63
|
+
("/plugin", "Browse plugin marketplace"),
|
|
64
|
+
("/plugin install|enable|disable|remove", "Manage plugins"),
|
|
65
|
+
("/skill", "Browse skills marketplace"),
|
|
66
|
+
("/skill install|enable|disable|remove", "Manage skills"),
|
|
67
|
+
("/undo", "Undo last file change (git checkpoint)"),
|
|
68
|
+
("/undo list", "List all checkpoints"),
|
|
69
|
+
("/memory", "List project memory entries"),
|
|
70
|
+
("/memory get|set|delete <key>", "Manage memory"),
|
|
71
|
+
("/index", "Show project index summary"),
|
|
72
|
+
("/index rebuild", "Rebuild project index"),
|
|
73
|
+
("/mcp", "List MCP servers"),
|
|
74
|
+
("/mcp search|install|remove", "MCP server marketplace"),
|
|
75
|
+
("/lsp", "Show LSP server status"),
|
|
76
|
+
("/budget <tokens>", "Set output token budget"),
|
|
77
|
+
("/exit", "Exit the application"),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TerminalRenderer:
|
|
82
|
+
"""Renders CLI output using Rich."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, console: Console | None = None) -> None:
|
|
85
|
+
self._console = console or Console()
|
|
86
|
+
|
|
87
|
+
def render_markdown(self, text: str) -> None:
|
|
88
|
+
"""Render text as Rich Markdown."""
|
|
89
|
+
if supports_hyperlinks():
|
|
90
|
+
text = auto_link(text)
|
|
91
|
+
self._console.print(Markdown(text))
|
|
92
|
+
|
|
93
|
+
def render_tool_panel(
|
|
94
|
+
self,
|
|
95
|
+
tool_name: str,
|
|
96
|
+
args: dict,
|
|
97
|
+
result: ToolResult,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Render a tool call result in a panel with syntax highlighting."""
|
|
100
|
+
status_color = "red" if result.is_error else "green"
|
|
101
|
+
status_icon = "[red]✗[/red]" if result.is_error else "[green]✓[/green]"
|
|
102
|
+
title = f"{status_icon} [bold]{tool_name}[/bold]"
|
|
103
|
+
|
|
104
|
+
# Determine content to display
|
|
105
|
+
output = result.output or ""
|
|
106
|
+
|
|
107
|
+
# Check for diff metadata first
|
|
108
|
+
if result.metadata and "diff" in result.metadata:
|
|
109
|
+
content = self._build_diff_content(args, result)
|
|
110
|
+
elif tool_name == "read_file":
|
|
111
|
+
file_path = args.get("path", "")
|
|
112
|
+
ext = Path(file_path).suffix.lower()
|
|
113
|
+
lang = _EXT_TO_LANG.get(ext, "text")
|
|
114
|
+
if output:
|
|
115
|
+
content = Syntax(output, lang, theme="monokai", line_numbers=True)
|
|
116
|
+
else:
|
|
117
|
+
content = Text(output)
|
|
118
|
+
elif tool_name == "bash":
|
|
119
|
+
content = Syntax(output, "bash", theme="monokai") if output else Text(output)
|
|
120
|
+
else:
|
|
121
|
+
if supports_hyperlinks() and output:
|
|
122
|
+
output = auto_link(output)
|
|
123
|
+
content = Text(output)
|
|
124
|
+
|
|
125
|
+
self._console.print(
|
|
126
|
+
Panel(
|
|
127
|
+
content,
|
|
128
|
+
title=title,
|
|
129
|
+
border_style=status_color,
|
|
130
|
+
expand=False,
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def _build_diff_content(self, args: dict, result: ToolResult) -> Text:
|
|
135
|
+
"""Build Rich Text with colored diff output."""
|
|
136
|
+
text = Text()
|
|
137
|
+
meta = result.metadata or {}
|
|
138
|
+
adds = meta.get("additions", 0)
|
|
139
|
+
dels = meta.get("deletions", 0)
|
|
140
|
+
|
|
141
|
+
# Header line
|
|
142
|
+
filename = Path(args.get("path", "file")).name
|
|
143
|
+
text.append(f"{filename} ", style="bold")
|
|
144
|
+
text.append(f"+{adds}", style="bold green")
|
|
145
|
+
text.append(" ")
|
|
146
|
+
text.append(f"-{dels}", style="bold red")
|
|
147
|
+
text.append("\n")
|
|
148
|
+
|
|
149
|
+
# Summary
|
|
150
|
+
text.append(result.output or "")
|
|
151
|
+
text.append("\n")
|
|
152
|
+
|
|
153
|
+
for hunk in meta.get("diff", []):
|
|
154
|
+
text.append(
|
|
155
|
+
f"@@ -{hunk['old_start']},{hunk['old_lines']} "
|
|
156
|
+
f"+{hunk['new_start']},{hunk['new_lines']} @@\n",
|
|
157
|
+
style="cyan",
|
|
158
|
+
)
|
|
159
|
+
for line in hunk.get("lines", []):
|
|
160
|
+
if line.startswith("+"):
|
|
161
|
+
text.append(line + "\n", style="green")
|
|
162
|
+
elif line.startswith("-"):
|
|
163
|
+
text.append(line + "\n", style="red")
|
|
164
|
+
else:
|
|
165
|
+
text.append(line + "\n")
|
|
166
|
+
|
|
167
|
+
return text
|
|
168
|
+
|
|
169
|
+
def render_permission_prompt(self, tool_name: str, args: dict) -> None:
|
|
170
|
+
"""Render a permission prompt for a tool call."""
|
|
171
|
+
import json
|
|
172
|
+
|
|
173
|
+
args_str = json.dumps(args, indent=2)
|
|
174
|
+
content = (
|
|
175
|
+
f"[bold yellow]Tool:[/bold yellow] {tool_name}\n"
|
|
176
|
+
f"[bold yellow]Args:[/bold yellow]\n{args_str}"
|
|
177
|
+
)
|
|
178
|
+
self._console.print(
|
|
179
|
+
Panel(
|
|
180
|
+
content,
|
|
181
|
+
title="[bold yellow]Permission Required[/bold yellow]",
|
|
182
|
+
border_style="yellow",
|
|
183
|
+
expand=False,
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
self._console.print("[bold]Allow? [y/n/a(lways)/never][/bold] ", end="")
|
|
187
|
+
|
|
188
|
+
def render_usage(self, usage: TokenUsage) -> None:
|
|
189
|
+
"""Render token usage statistics."""
|
|
190
|
+
total = usage.input_tokens + usage.output_tokens
|
|
191
|
+
self._console.print(
|
|
192
|
+
f"[dim]Tokens — input: {usage.input_tokens:,} "
|
|
193
|
+
f"output: {usage.output_tokens:,} "
|
|
194
|
+
f"total: {total:,}[/dim]"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def render_tool_progress(self, tool_name: str, message: str, percent: float | None = None) -> None:
|
|
198
|
+
"""Render an in-progress update for a running tool (overwrites current line)."""
|
|
199
|
+
if percent is not None:
|
|
200
|
+
pct = f"{percent:.0%}"
|
|
201
|
+
self._console.print(f" [dim]{tool_name}[/dim] {message} [{pct}]", end="\r")
|
|
202
|
+
else:
|
|
203
|
+
self._console.print(f" [dim]{tool_name}[/dim] {message}", end="\r")
|
|
204
|
+
|
|
205
|
+
def render_help(self) -> None:
|
|
206
|
+
"""Render a table of available slash commands."""
|
|
207
|
+
table = Table(title="Available Commands", show_header=True, header_style="bold cyan")
|
|
208
|
+
table.add_column("Command", style="bold green", no_wrap=True)
|
|
209
|
+
table.add_column("Description")
|
|
210
|
+
|
|
211
|
+
for cmd, desc in SLASH_COMMANDS_HELP:
|
|
212
|
+
table.add_row(cmd, desc)
|
|
213
|
+
|
|
214
|
+
self._console.print(table)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""CLIStatusLine — persistent bottom status bar for the print CLI."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.live import Live
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class StatusLineState:
|
|
14
|
+
model: str = ""
|
|
15
|
+
tokens: int = 0
|
|
16
|
+
cost: str = ""
|
|
17
|
+
is_streaming: bool = False
|
|
18
|
+
permission_mode: str = ""
|
|
19
|
+
context_usage: float = 0.0 # 0.0-1.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def format_status_line(state: StatusLineState) -> str:
|
|
23
|
+
"""Format status line as a pipe-separated string."""
|
|
24
|
+
parts: list[str] = []
|
|
25
|
+
if state.permission_mode and state.permission_mode != "prompt":
|
|
26
|
+
parts.append(f"[{state.permission_mode}]")
|
|
27
|
+
if state.model:
|
|
28
|
+
parts.append(state.model)
|
|
29
|
+
if state.tokens > 0:
|
|
30
|
+
parts.append(f"↓{state.tokens:,} tok")
|
|
31
|
+
if state.cost:
|
|
32
|
+
parts.append(state.cost)
|
|
33
|
+
if state.context_usage >= 0.6:
|
|
34
|
+
pct = int(state.context_usage * 100)
|
|
35
|
+
filled = int(state.context_usage * 8)
|
|
36
|
+
bar = "█" * filled + "░" * (8 - filled)
|
|
37
|
+
parts.append(f"[{bar}] {pct}%")
|
|
38
|
+
if state.is_streaming:
|
|
39
|
+
parts.append("streaming…")
|
|
40
|
+
parts.append("/help")
|
|
41
|
+
parts.append("Ctrl+D quit")
|
|
42
|
+
return " │ ".join(parts)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CLIStatusLine:
|
|
46
|
+
"""Persistent bottom status line for the print CLI using Rich Live."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, console: Console) -> None:
|
|
49
|
+
self._console = console
|
|
50
|
+
self.state = StatusLineState()
|
|
51
|
+
self._live: Live | None = None
|
|
52
|
+
|
|
53
|
+
def update(self, **kwargs: Any) -> None:
|
|
54
|
+
"""Update one or more state fields and refresh the display."""
|
|
55
|
+
for key, value in kwargs.items():
|
|
56
|
+
if hasattr(self.state, key):
|
|
57
|
+
setattr(self.state, key, value)
|
|
58
|
+
if self._live is not None:
|
|
59
|
+
self._live.update(self._render())
|
|
60
|
+
|
|
61
|
+
def _render(self) -> Text:
|
|
62
|
+
"""Render the current state as a Rich Text object."""
|
|
63
|
+
return Text(format_status_line(self.state), style="dim")
|
|
64
|
+
|
|
65
|
+
def start(self) -> None:
|
|
66
|
+
"""Begin live rendering at the bottom of the terminal."""
|
|
67
|
+
self._live = Live(
|
|
68
|
+
self._render(),
|
|
69
|
+
console=self._console,
|
|
70
|
+
refresh_per_second=4,
|
|
71
|
+
transient=True,
|
|
72
|
+
)
|
|
73
|
+
self._live.start()
|
|
74
|
+
|
|
75
|
+
def stop(self) -> None:
|
|
76
|
+
"""Stop live rendering."""
|
|
77
|
+
if self._live is not None:
|
|
78
|
+
self._live.stop()
|
|
79
|
+
self._live = None
|