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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""StatusBar — persistent bottom line with model, tokens, cost, hints."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from textual.reactive import reactive
|
|
5
|
+
from textual.widget import Widget
|
|
6
|
+
from textual.app import RenderResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StatusBar(Widget):
|
|
10
|
+
"""Bottom status: model │ ↓tokens tok │ $cost │ streaming… │ /help │ Ctrl+D quit"""
|
|
11
|
+
|
|
12
|
+
model: reactive[str] = reactive("")
|
|
13
|
+
tokens: reactive[int] = reactive(0)
|
|
14
|
+
cost: reactive[str] = reactive("")
|
|
15
|
+
is_streaming: reactive[bool] = reactive(False)
|
|
16
|
+
vim_mode: reactive[str] = reactive("") # "" | "NORMAL" | "INSERT"
|
|
17
|
+
is_local: reactive[bool] = reactive(False)
|
|
18
|
+
plan_mode: reactive[str] = reactive("") # "" | "PLAN"
|
|
19
|
+
|
|
20
|
+
DEFAULT_CSS = """
|
|
21
|
+
StatusBar {
|
|
22
|
+
dock: bottom;
|
|
23
|
+
height: 1;
|
|
24
|
+
background: $surface-darken-1;
|
|
25
|
+
color: $text-muted;
|
|
26
|
+
padding: 0 1;
|
|
27
|
+
}
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def _format_content(self) -> str:
|
|
31
|
+
parts: list[str] = []
|
|
32
|
+
if self.plan_mode:
|
|
33
|
+
parts.append(self.plan_mode)
|
|
34
|
+
if self.vim_mode:
|
|
35
|
+
parts.append(f"-- {self.vim_mode} --")
|
|
36
|
+
if self.model:
|
|
37
|
+
parts.append(self.model)
|
|
38
|
+
if self.tokens > 0:
|
|
39
|
+
parts.append(f"↓{self.tokens:,} tok")
|
|
40
|
+
if self.is_local:
|
|
41
|
+
parts.append("free")
|
|
42
|
+
elif self.cost:
|
|
43
|
+
parts.append(self.cost)
|
|
44
|
+
if self.is_streaming:
|
|
45
|
+
parts.append("streaming…")
|
|
46
|
+
parts.append("/help")
|
|
47
|
+
parts.append("Ctrl+D quit")
|
|
48
|
+
return " │ ".join(parts)
|
|
49
|
+
|
|
50
|
+
def render(self) -> RenderResult:
|
|
51
|
+
return self._format_content()
|
|
52
|
+
|
|
53
|
+
def watch_model(self) -> None:
|
|
54
|
+
self.refresh()
|
|
55
|
+
|
|
56
|
+
def watch_tokens(self) -> None:
|
|
57
|
+
self.refresh()
|
|
58
|
+
|
|
59
|
+
def watch_cost(self) -> None:
|
|
60
|
+
self.refresh()
|
|
61
|
+
|
|
62
|
+
def watch_is_streaming(self) -> None:
|
|
63
|
+
self.refresh()
|
|
64
|
+
|
|
65
|
+
def watch_vim_mode(self) -> None:
|
|
66
|
+
self.refresh()
|
|
67
|
+
|
|
68
|
+
def watch_is_local(self) -> None:
|
|
69
|
+
self.refresh()
|
|
70
|
+
|
|
71
|
+
def watch_plan_mode(self) -> None:
|
|
72
|
+
self.refresh()
|
llm_code/tui/theme.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Color constants and Textual CSS for the fullscreen TUI."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
# Semantic color map — values are Rich/Textual style strings
|
|
5
|
+
COLORS: dict[str, str] = {
|
|
6
|
+
"prompt": "bold cyan",
|
|
7
|
+
"tool_name": "bold cyan",
|
|
8
|
+
"tool_line": "dim",
|
|
9
|
+
"tool_args": "dim",
|
|
10
|
+
"success": "bold green",
|
|
11
|
+
"error": "bold red",
|
|
12
|
+
"diff_add": "green",
|
|
13
|
+
"diff_del": "red",
|
|
14
|
+
"thinking": "#cc7a00",
|
|
15
|
+
"warning": "yellow",
|
|
16
|
+
"spinner": "blue",
|
|
17
|
+
"dim": "dim",
|
|
18
|
+
"bash_cmd": "white on #2a2a3a",
|
|
19
|
+
"agent": "bold cyan",
|
|
20
|
+
"shortcut_key": "bold",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# Textual CSS applied to the App
|
|
24
|
+
APP_CSS = """
|
|
25
|
+
Screen {
|
|
26
|
+
layout: vertical;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#header-bar {
|
|
30
|
+
dock: top;
|
|
31
|
+
height: 1;
|
|
32
|
+
background: $surface-darken-1;
|
|
33
|
+
color: $text-muted;
|
|
34
|
+
padding: 0 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#chat-view {
|
|
38
|
+
height: 1fr;
|
|
39
|
+
overflow-y: auto;
|
|
40
|
+
padding: 0 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#input-bar {
|
|
44
|
+
dock: bottom;
|
|
45
|
+
height: auto;
|
|
46
|
+
min-height: 1;
|
|
47
|
+
max-height: 8;
|
|
48
|
+
padding: 0 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#status-bar {
|
|
52
|
+
dock: bottom;
|
|
53
|
+
height: 1;
|
|
54
|
+
background: $surface-darken-1;
|
|
55
|
+
color: $text-muted;
|
|
56
|
+
padding: 0 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.tool-block {
|
|
60
|
+
margin: 0 0 0 2;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.thinking-collapsed {
|
|
64
|
+
color: $text-muted;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.thinking-expanded {
|
|
68
|
+
color: $text-muted;
|
|
69
|
+
border: round $accent;
|
|
70
|
+
padding: 0 1;
|
|
71
|
+
max-height: 20;
|
|
72
|
+
overflow-y: auto;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.permission-inline {
|
|
76
|
+
border-left: thick $warning;
|
|
77
|
+
padding: 0 1;
|
|
78
|
+
margin: 0 0 0 2;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.turn-summary {
|
|
82
|
+
margin: 0 0 1 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.spinner-line {
|
|
86
|
+
color: $accent;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.user-message {
|
|
90
|
+
margin: 1 0 0 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.assistant-text {
|
|
94
|
+
margin: 0 0 1 0;
|
|
95
|
+
}
|
|
96
|
+
"""
|
|
File without changes
|
llm_code/utils/diff.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Unified diff generation for file edits."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import dataclasses
|
|
5
|
+
import difflib
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclasses.dataclass(frozen=True)
|
|
10
|
+
class DiffHunk:
|
|
11
|
+
"""A single hunk from a unified diff."""
|
|
12
|
+
|
|
13
|
+
old_start: int
|
|
14
|
+
old_lines: int
|
|
15
|
+
new_start: int
|
|
16
|
+
new_lines: int
|
|
17
|
+
lines: tuple[str, ...]
|
|
18
|
+
|
|
19
|
+
def to_dict(self) -> dict:
|
|
20
|
+
return {
|
|
21
|
+
"old_start": self.old_start,
|
|
22
|
+
"old_lines": self.old_lines,
|
|
23
|
+
"new_start": self.new_start,
|
|
24
|
+
"new_lines": self.new_lines,
|
|
25
|
+
"lines": list(self.lines),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_HUNK_HEADER = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
|
30
|
+
|
|
31
|
+
MAX_DIFF_LINES = 500
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def generate_diff(
|
|
35
|
+
old: str,
|
|
36
|
+
new: str,
|
|
37
|
+
filename: str,
|
|
38
|
+
context: int = 3,
|
|
39
|
+
) -> list[DiffHunk]:
|
|
40
|
+
"""Generate structured diff hunks from old and new file content.
|
|
41
|
+
|
|
42
|
+
Uses difflib.unified_diff with the given context (default 3).
|
|
43
|
+
Truncates total output lines at MAX_DIFF_LINES.
|
|
44
|
+
"""
|
|
45
|
+
if old == new:
|
|
46
|
+
return []
|
|
47
|
+
|
|
48
|
+
diff_lines = list(
|
|
49
|
+
difflib.unified_diff(
|
|
50
|
+
old.splitlines(keepends=True),
|
|
51
|
+
new.splitlines(keepends=True),
|
|
52
|
+
fromfile=f"a/{filename}",
|
|
53
|
+
tofile=f"b/{filename}",
|
|
54
|
+
n=context,
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
hunks: list[DiffHunk] = []
|
|
59
|
+
current_lines: list[str] = []
|
|
60
|
+
old_start = new_start = old_count = new_count = 0
|
|
61
|
+
total_lines = 0
|
|
62
|
+
|
|
63
|
+
for raw_line in diff_lines:
|
|
64
|
+
# Skip the --- and +++ header lines
|
|
65
|
+
if raw_line.startswith("---") or raw_line.startswith("+++"):
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
m = _HUNK_HEADER.match(raw_line)
|
|
69
|
+
if m:
|
|
70
|
+
# Flush previous hunk
|
|
71
|
+
if current_lines:
|
|
72
|
+
hunks.append(DiffHunk(
|
|
73
|
+
old_start=old_start,
|
|
74
|
+
old_lines=old_count,
|
|
75
|
+
new_start=new_start,
|
|
76
|
+
new_lines=new_count,
|
|
77
|
+
lines=tuple(current_lines),
|
|
78
|
+
))
|
|
79
|
+
old_start = int(m.group(1))
|
|
80
|
+
old_count = int(m.group(2)) if m.group(2) else 1
|
|
81
|
+
new_start = int(m.group(3))
|
|
82
|
+
new_count = int(m.group(4)) if m.group(4) else 1
|
|
83
|
+
current_lines = []
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
if total_lines >= MAX_DIFF_LINES:
|
|
87
|
+
break
|
|
88
|
+
|
|
89
|
+
# Normalize: strip trailing newline, keep prefix (+/-/space)
|
|
90
|
+
stripped = raw_line.rstrip("\n\r")
|
|
91
|
+
current_lines.append(stripped)
|
|
92
|
+
total_lines += 1
|
|
93
|
+
|
|
94
|
+
# Flush final hunk
|
|
95
|
+
if current_lines:
|
|
96
|
+
hunks.append(DiffHunk(
|
|
97
|
+
old_start=old_start,
|
|
98
|
+
old_lines=old_count,
|
|
99
|
+
new_start=new_start,
|
|
100
|
+
new_lines=new_count,
|
|
101
|
+
lines=tuple(current_lines),
|
|
102
|
+
))
|
|
103
|
+
|
|
104
|
+
return hunks
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def count_changes(hunks: list[DiffHunk]) -> tuple[int, int]:
|
|
108
|
+
"""Count total additions and deletions across all hunks."""
|
|
109
|
+
adds = sum(1 for h in hunks for line in h.lines if line.startswith("+"))
|
|
110
|
+
dels = sum(1 for h in hunks for line in h.lines if line.startswith("-"))
|
|
111
|
+
return adds, dels
|
llm_code/utils/errors.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Human-friendly error message formatting for tool execute() methods."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def friendly_error(error: Exception, context: str = "") -> str:
|
|
9
|
+
"""Return a user-friendly error message for the given exception.
|
|
10
|
+
|
|
11
|
+
Parameters
|
|
12
|
+
----------
|
|
13
|
+
error:
|
|
14
|
+
The exception to format.
|
|
15
|
+
context:
|
|
16
|
+
Optional extra context (e.g. the file path being operated on).
|
|
17
|
+
"""
|
|
18
|
+
prefix = f"[{context}] " if context else ""
|
|
19
|
+
|
|
20
|
+
if isinstance(error, FileNotFoundError):
|
|
21
|
+
path = error.filename or str(error)
|
|
22
|
+
return f"{prefix}File not found: {path}. Check the working directory with /cd"
|
|
23
|
+
|
|
24
|
+
if isinstance(error, PermissionError):
|
|
25
|
+
path = error.filename or str(error)
|
|
26
|
+
return (
|
|
27
|
+
f"{prefix}Permission denied: {path}. "
|
|
28
|
+
"The file may be read-only or owned by another user"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if isinstance(error, json.JSONDecodeError):
|
|
32
|
+
return (
|
|
33
|
+
f"{prefix}Invalid JSON at line {error.lineno}: {error.msg}"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if isinstance(error, subprocess.TimeoutExpired):
|
|
37
|
+
timeout = error.timeout
|
|
38
|
+
return (
|
|
39
|
+
f"{prefix}Command timed out after {timeout}s. "
|
|
40
|
+
"Try increasing timeout or simplifying the command"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if isinstance(error, ConnectionError):
|
|
44
|
+
# Try to extract target from the message
|
|
45
|
+
target = str(error).split("'")[1] if "'" in str(error) else str(error)
|
|
46
|
+
return (
|
|
47
|
+
f"{prefix}Connection failed: {target}. Check if the server is running"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return f"{prefix}Error: {type(error).__name__}: {error}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def suggest_fix(error: Exception) -> str | None:
|
|
54
|
+
"""Return an actionable suggestion for the given exception, or None."""
|
|
55
|
+
if isinstance(error, FileNotFoundError):
|
|
56
|
+
return "Use /cd to navigate to the correct directory, or verify the file path."
|
|
57
|
+
|
|
58
|
+
if isinstance(error, PermissionError):
|
|
59
|
+
return "Try running with elevated permissions, or check file ownership with `ls -la`."
|
|
60
|
+
|
|
61
|
+
if isinstance(error, json.JSONDecodeError):
|
|
62
|
+
return f"Check JSON syntax around line {error.lineno}. Common issues: trailing commas, missing quotes."
|
|
63
|
+
|
|
64
|
+
if isinstance(error, subprocess.TimeoutExpired):
|
|
65
|
+
return "Increase the timeout parameter, or break the command into smaller steps."
|
|
66
|
+
|
|
67
|
+
if isinstance(error, ConnectionError):
|
|
68
|
+
return "Verify the server is running and the URL/port is correct."
|
|
69
|
+
|
|
70
|
+
return None
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""OSC8 terminal hyperlink utilities."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
# Regex to detect http/https URLs; excludes trailing punctuation like . ) > " <
|
|
8
|
+
_URL_RE = re.compile(r"https?://[^\s)<>\"]+")
|
|
9
|
+
|
|
10
|
+
# Terminals (TERM_PROGRAM values) known to support OSC8 hyperlinks
|
|
11
|
+
_SUPPORTED_TERM_PROGRAMS: frozenset[str] = frozenset({"iTerm.app", "WezTerm"})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def make_hyperlink(url: str, text: str | None = None) -> str:
|
|
15
|
+
"""Return an OSC8 hyperlink escape sequence.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
url: The target URL.
|
|
19
|
+
text: Display text; defaults to *url* when ``None`` or empty.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
A string containing the OSC8 escape sequences so terminals that
|
|
23
|
+
support them render a clickable hyperlink.
|
|
24
|
+
"""
|
|
25
|
+
display = text if text else url
|
|
26
|
+
return f"\033]8;;{url}\033\\{display}\033]8;;\033\\"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def auto_link(text: str) -> str:
|
|
30
|
+
"""Detect URLs in *text* and wrap each one in an OSC8 hyperlink.
|
|
31
|
+
|
|
32
|
+
Only ``http://`` and ``https://`` URLs are matched. Trailing
|
|
33
|
+
punctuation characters that are unlikely to be part of the URL
|
|
34
|
+
(i.e. ``.``, ``,``, ``)``, ``]``) are excluded from the link target
|
|
35
|
+
so that sentences such as "See https://example.com." render
|
|
36
|
+
correctly.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
text: Plain text that may contain URLs.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Text with any detected URLs replaced by OSC8 hyperlink sequences.
|
|
43
|
+
"""
|
|
44
|
+
_TRAILING_PUNCT = frozenset(".),]")
|
|
45
|
+
|
|
46
|
+
def _replace(match: re.Match) -> str: # type: ignore[type-arg]
|
|
47
|
+
url = match.group(0)
|
|
48
|
+
# Strip trailing punctuation that is very likely not part of the URL.
|
|
49
|
+
while url and url[-1] in _TRAILING_PUNCT:
|
|
50
|
+
url = url[:-1]
|
|
51
|
+
suffix = match.group(0)[len(url):]
|
|
52
|
+
return make_hyperlink(url) + suffix
|
|
53
|
+
|
|
54
|
+
return _URL_RE.sub(_replace, text)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def supports_hyperlinks() -> bool:
|
|
58
|
+
"""Return ``True`` when the current terminal is known to support OSC8.
|
|
59
|
+
|
|
60
|
+
Checks the following environment variables (in order):
|
|
61
|
+
|
|
62
|
+
* ``TERM_PROGRAM`` — ``iTerm.app`` or ``WezTerm``
|
|
63
|
+
* ``WT_SESSION`` — set by Windows Terminal
|
|
64
|
+
* ``VTE_VERSION`` — set by VTE-based terminals (GNOME Terminal, etc.)
|
|
65
|
+
"""
|
|
66
|
+
term_program = os.environ.get("TERM_PROGRAM", "")
|
|
67
|
+
if term_program in _SUPPORTED_TERM_PROGRAMS:
|
|
68
|
+
return True
|
|
69
|
+
if os.environ.get("WT_SESSION"):
|
|
70
|
+
return True
|
|
71
|
+
if os.environ.get("VTE_VERSION"):
|
|
72
|
+
return True
|
|
73
|
+
return False
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Notebook utility — parse, format, edit, and validate Jupyter notebooks."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import copy
|
|
5
|
+
import dataclasses
|
|
6
|
+
import uuid
|
|
7
|
+
|
|
8
|
+
_OUTPUT_TRUNCATION_LIMIT = 10 * 1024 # 10 KB
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclasses.dataclass(frozen=True)
|
|
12
|
+
class NotebookCell:
|
|
13
|
+
index: int
|
|
14
|
+
cell_type: str
|
|
15
|
+
source: str
|
|
16
|
+
execution_count: int | None
|
|
17
|
+
output_text: str
|
|
18
|
+
images: tuple[dict, ...]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _extract_output(outputs: list[dict]) -> tuple[str, list[dict]]:
|
|
22
|
+
"""Extract text and images from a list of cell outputs."""
|
|
23
|
+
text_parts: list[str] = []
|
|
24
|
+
images: list[dict] = []
|
|
25
|
+
|
|
26
|
+
for output in outputs:
|
|
27
|
+
output_type = output.get("output_type", "")
|
|
28
|
+
|
|
29
|
+
if output_type == "stream":
|
|
30
|
+
text = output.get("text", "")
|
|
31
|
+
if isinstance(text, list):
|
|
32
|
+
text = "".join(text)
|
|
33
|
+
text_parts.append(text)
|
|
34
|
+
|
|
35
|
+
elif output_type in ("execute_result", "display_data"):
|
|
36
|
+
data = output.get("data", {})
|
|
37
|
+
# Collect images
|
|
38
|
+
for media_type in ("image/png", "image/jpeg"):
|
|
39
|
+
if media_type in data:
|
|
40
|
+
images.append({"media_type": media_type, "data": data[media_type]})
|
|
41
|
+
# Prefer plain text representation
|
|
42
|
+
if "text/plain" in data:
|
|
43
|
+
text = data["text/plain"]
|
|
44
|
+
if isinstance(text, list):
|
|
45
|
+
text = "".join(text)
|
|
46
|
+
text_parts.append(text)
|
|
47
|
+
|
|
48
|
+
elif output_type == "error":
|
|
49
|
+
ename = output.get("ename", "Error")
|
|
50
|
+
evalue = output.get("evalue", "")
|
|
51
|
+
text_parts.append(f"{ename}: {evalue}")
|
|
52
|
+
|
|
53
|
+
combined = "\n".join(text_parts)
|
|
54
|
+
if len(combined) > _OUTPUT_TRUNCATION_LIMIT:
|
|
55
|
+
combined = combined[:_OUTPUT_TRUNCATION_LIMIT] + "\n... [truncated]"
|
|
56
|
+
|
|
57
|
+
return combined, images
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_notebook(data: dict) -> list[NotebookCell]:
|
|
61
|
+
"""Parse a notebook dict into a list of NotebookCell objects."""
|
|
62
|
+
cells = data.get("cells", [])
|
|
63
|
+
result: list[NotebookCell] = []
|
|
64
|
+
|
|
65
|
+
for index, cell in enumerate(cells):
|
|
66
|
+
cell_type = cell.get("cell_type", "code")
|
|
67
|
+
source = cell.get("source", "")
|
|
68
|
+
if isinstance(source, list):
|
|
69
|
+
source = "".join(source)
|
|
70
|
+
|
|
71
|
+
execution_count = cell.get("execution_count") if cell_type == "code" else None
|
|
72
|
+
outputs = cell.get("outputs", []) if cell_type == "code" else []
|
|
73
|
+
output_text, images = _extract_output(outputs)
|
|
74
|
+
|
|
75
|
+
result.append(NotebookCell(
|
|
76
|
+
index=index,
|
|
77
|
+
cell_type=cell_type,
|
|
78
|
+
source=source,
|
|
79
|
+
execution_count=execution_count,
|
|
80
|
+
output_text=output_text,
|
|
81
|
+
images=tuple(images),
|
|
82
|
+
))
|
|
83
|
+
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def format_cells(cells: list[NotebookCell]) -> str:
|
|
88
|
+
"""Format a list of NotebookCell objects into a human-readable string."""
|
|
89
|
+
if not cells:
|
|
90
|
+
return ""
|
|
91
|
+
|
|
92
|
+
parts: list[str] = []
|
|
93
|
+
for cell in cells:
|
|
94
|
+
exec_info = f" (exec {cell.execution_count})" if cell.execution_count is not None else ""
|
|
95
|
+
header = f"Cell {cell.index} [{cell.cell_type}]{exec_info}"
|
|
96
|
+
body = cell.source
|
|
97
|
+
|
|
98
|
+
section = f"{header}\n{body}"
|
|
99
|
+
if cell.output_text:
|
|
100
|
+
section += f"\nOutput:\n{cell.output_text}"
|
|
101
|
+
|
|
102
|
+
parts.append(section)
|
|
103
|
+
|
|
104
|
+
return "\n\n".join(parts)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def validate_notebook(data: dict) -> bool:
|
|
108
|
+
"""Return True if data is a valid notebook (nbformat >= 4 and cells is a list)."""
|
|
109
|
+
if not isinstance(data, dict):
|
|
110
|
+
return False
|
|
111
|
+
nbformat = data.get("nbformat")
|
|
112
|
+
if not isinstance(nbformat, int) or nbformat < 4:
|
|
113
|
+
return False
|
|
114
|
+
cells = data.get("cells")
|
|
115
|
+
if not isinstance(cells, list):
|
|
116
|
+
return False
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _generate_cell_id() -> str:
|
|
121
|
+
"""Generate a short cell ID compatible with nbformat >= 4.5."""
|
|
122
|
+
return uuid.uuid4().hex[:8]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def edit_notebook(
|
|
126
|
+
data: dict,
|
|
127
|
+
command: str,
|
|
128
|
+
cell_index: int,
|
|
129
|
+
source: str | None = None,
|
|
130
|
+
cell_type: str | None = None,
|
|
131
|
+
) -> dict:
|
|
132
|
+
"""Return a new notebook dict with the specified edit applied.
|
|
133
|
+
|
|
134
|
+
Commands:
|
|
135
|
+
replace — replace cell at cell_index with new source (and optionally cell_type)
|
|
136
|
+
insert — insert a new cell before cell_index
|
|
137
|
+
delete — remove the cell at cell_index
|
|
138
|
+
"""
|
|
139
|
+
result = copy.deepcopy(data)
|
|
140
|
+
cells: list[dict] = result["cells"]
|
|
141
|
+
nbformat_minor: int = data.get("nbformat_minor", 0)
|
|
142
|
+
needs_id = nbformat_minor >= 5
|
|
143
|
+
|
|
144
|
+
if command == "replace":
|
|
145
|
+
if cell_index < 0 or cell_index >= len(cells):
|
|
146
|
+
raise IndexError(f"Cell index {cell_index} out of range (0..{len(cells) - 1})")
|
|
147
|
+
cell = cells[cell_index]
|
|
148
|
+
cell["source"] = source if source is not None else cell["source"]
|
|
149
|
+
if cell_type is not None:
|
|
150
|
+
cell["cell_type"] = cell_type
|
|
151
|
+
# Reset execution metadata when replacing
|
|
152
|
+
if cell.get("cell_type") == "code" and "outputs" not in cell:
|
|
153
|
+
cell["outputs"] = []
|
|
154
|
+
|
|
155
|
+
elif command == "insert":
|
|
156
|
+
if cell_index < 0 or cell_index > len(cells):
|
|
157
|
+
raise IndexError(f"Insert index {cell_index} out of range (0..{len(cells)})")
|
|
158
|
+
resolved_type = cell_type or "code"
|
|
159
|
+
new_cell: dict = {
|
|
160
|
+
"cell_type": resolved_type,
|
|
161
|
+
"metadata": {},
|
|
162
|
+
"source": source or "",
|
|
163
|
+
}
|
|
164
|
+
if needs_id:
|
|
165
|
+
new_cell["id"] = _generate_cell_id()
|
|
166
|
+
if resolved_type == "code":
|
|
167
|
+
new_cell["execution_count"] = None
|
|
168
|
+
new_cell["outputs"] = []
|
|
169
|
+
cells.insert(cell_index, new_cell)
|
|
170
|
+
|
|
171
|
+
elif command == "delete":
|
|
172
|
+
if cell_index < 0 or cell_index >= len(cells):
|
|
173
|
+
raise IndexError(f"Cell index {cell_index} out of range (0..{len(cells) - 1})")
|
|
174
|
+
cells.pop(cell_index)
|
|
175
|
+
|
|
176
|
+
else:
|
|
177
|
+
raise ValueError(f"Unknown notebook edit command: {command!r}. Use replace, insert, or delete.")
|
|
178
|
+
|
|
179
|
+
return result
|
llm_code/utils/search.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Search utilities for llm-code conversation history."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import dataclasses
|
|
5
|
+
import re
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from llm_code.api.types import Message
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclasses.dataclass(frozen=True)
|
|
13
|
+
class SearchResult:
|
|
14
|
+
"""A single search match within conversation messages."""
|
|
15
|
+
|
|
16
|
+
message_index: int
|
|
17
|
+
line_number: int
|
|
18
|
+
line_text: str
|
|
19
|
+
match_start: int
|
|
20
|
+
match_end: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def search_messages(
|
|
24
|
+
messages: list[Message],
|
|
25
|
+
query: str,
|
|
26
|
+
case_sensitive: bool = False,
|
|
27
|
+
) -> list[SearchResult]:
|
|
28
|
+
"""Search through TextBlock content in messages for the given query.
|
|
29
|
+
|
|
30
|
+
Only TextBlock.text is searched; ToolUseBlock, ToolResultBlock, and
|
|
31
|
+
ImageBlock content are ignored.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
messages: List of Message objects from a session.
|
|
35
|
+
query: The string to search for.
|
|
36
|
+
case_sensitive: When False (default), search is case-insensitive.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
A list of SearchResult instances, one per match, in order of
|
|
40
|
+
appearance (message index, then line number, then match position).
|
|
41
|
+
"""
|
|
42
|
+
if not query:
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
from llm_code.api.types import TextBlock
|
|
46
|
+
|
|
47
|
+
flags = 0 if case_sensitive else re.IGNORECASE
|
|
48
|
+
pattern = re.compile(re.escape(query), flags)
|
|
49
|
+
|
|
50
|
+
results: list[SearchResult] = []
|
|
51
|
+
|
|
52
|
+
for msg_idx, message in enumerate(messages):
|
|
53
|
+
for block in message.content:
|
|
54
|
+
if not isinstance(block, TextBlock):
|
|
55
|
+
continue
|
|
56
|
+
text = block.text
|
|
57
|
+
for line_idx, line in enumerate(text.splitlines(), start=1):
|
|
58
|
+
for match in pattern.finditer(line):
|
|
59
|
+
results.append(
|
|
60
|
+
SearchResult(
|
|
61
|
+
message_index=msg_idx,
|
|
62
|
+
line_number=line_idx,
|
|
63
|
+
line_text=line,
|
|
64
|
+
match_start=match.start(),
|
|
65
|
+
match_end=match.end(),
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return results
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Text normalization utilities for fuzzy matching."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
_QUOTE_TABLE = str.maketrans(
|
|
6
|
+
{
|
|
7
|
+
"\u2018": "'", # LEFT SINGLE QUOTATION MARK
|
|
8
|
+
"\u2019": "'", # RIGHT SINGLE QUOTATION MARK
|
|
9
|
+
"\u201c": '"', # LEFT DOUBLE QUOTATION MARK
|
|
10
|
+
"\u201d": '"', # RIGHT DOUBLE QUOTATION MARK
|
|
11
|
+
"\u02bc": "'", # MODIFIER LETTER APOSTROPHE
|
|
12
|
+
}
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def normalize_quotes(text: str) -> str:
|
|
17
|
+
"""Convert curly/smart quotes and modifier apostrophes to straight quotes."""
|
|
18
|
+
return text.translate(_QUOTE_TABLE)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def strip_trailing_whitespace(text: str) -> str:
|
|
22
|
+
"""Remove trailing spaces and tabs from each line."""
|
|
23
|
+
return "\n".join(line.rstrip(" \t") for line in text.split("\n"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def normalize_for_match(text: str) -> str:
|
|
27
|
+
"""Apply quote normalization and trailing-whitespace stripping."""
|
|
28
|
+
return strip_trailing_whitespace(normalize_quotes(text))
|