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.
Files changed (212) hide show
  1. llm_code/__init__.py +2 -0
  2. llm_code/analysis/__init__.py +6 -0
  3. llm_code/analysis/cache.py +33 -0
  4. llm_code/analysis/engine.py +256 -0
  5. llm_code/analysis/go_rules.py +114 -0
  6. llm_code/analysis/js_rules.py +84 -0
  7. llm_code/analysis/python_rules.py +311 -0
  8. llm_code/analysis/rules.py +140 -0
  9. llm_code/analysis/rust_rules.py +108 -0
  10. llm_code/analysis/universal_rules.py +111 -0
  11. llm_code/api/__init__.py +0 -0
  12. llm_code/api/client.py +90 -0
  13. llm_code/api/errors.py +73 -0
  14. llm_code/api/openai_compat.py +390 -0
  15. llm_code/api/provider.py +35 -0
  16. llm_code/api/sse.py +52 -0
  17. llm_code/api/types.py +140 -0
  18. llm_code/cli/__init__.py +0 -0
  19. llm_code/cli/commands.py +70 -0
  20. llm_code/cli/image.py +122 -0
  21. llm_code/cli/render.py +214 -0
  22. llm_code/cli/status_line.py +79 -0
  23. llm_code/cli/streaming.py +92 -0
  24. llm_code/cli/tui_main.py +220 -0
  25. llm_code/computer_use/__init__.py +11 -0
  26. llm_code/computer_use/app_detect.py +49 -0
  27. llm_code/computer_use/app_tier.py +57 -0
  28. llm_code/computer_use/coordinator.py +99 -0
  29. llm_code/computer_use/input_control.py +71 -0
  30. llm_code/computer_use/screenshot.py +93 -0
  31. llm_code/cron/__init__.py +13 -0
  32. llm_code/cron/parser.py +145 -0
  33. llm_code/cron/scheduler.py +135 -0
  34. llm_code/cron/storage.py +126 -0
  35. llm_code/enterprise/__init__.py +1 -0
  36. llm_code/enterprise/audit.py +59 -0
  37. llm_code/enterprise/auth.py +26 -0
  38. llm_code/enterprise/oidc.py +95 -0
  39. llm_code/enterprise/rbac.py +65 -0
  40. llm_code/harness/__init__.py +5 -0
  41. llm_code/harness/config.py +33 -0
  42. llm_code/harness/engine.py +129 -0
  43. llm_code/harness/guides.py +41 -0
  44. llm_code/harness/sensors.py +68 -0
  45. llm_code/harness/templates.py +84 -0
  46. llm_code/hida/__init__.py +1 -0
  47. llm_code/hida/classifier.py +187 -0
  48. llm_code/hida/engine.py +49 -0
  49. llm_code/hida/profiles.py +95 -0
  50. llm_code/hida/types.py +28 -0
  51. llm_code/ide/__init__.py +1 -0
  52. llm_code/ide/bridge.py +80 -0
  53. llm_code/ide/detector.py +76 -0
  54. llm_code/ide/server.py +169 -0
  55. llm_code/logging.py +29 -0
  56. llm_code/lsp/__init__.py +0 -0
  57. llm_code/lsp/client.py +298 -0
  58. llm_code/lsp/detector.py +42 -0
  59. llm_code/lsp/manager.py +56 -0
  60. llm_code/lsp/tools.py +288 -0
  61. llm_code/marketplace/__init__.py +0 -0
  62. llm_code/marketplace/builtin_registry.py +102 -0
  63. llm_code/marketplace/installer.py +162 -0
  64. llm_code/marketplace/plugin.py +78 -0
  65. llm_code/marketplace/registry.py +360 -0
  66. llm_code/mcp/__init__.py +0 -0
  67. llm_code/mcp/bridge.py +87 -0
  68. llm_code/mcp/client.py +117 -0
  69. llm_code/mcp/health.py +120 -0
  70. llm_code/mcp/manager.py +214 -0
  71. llm_code/mcp/oauth.py +219 -0
  72. llm_code/mcp/transport.py +254 -0
  73. llm_code/mcp/types.py +53 -0
  74. llm_code/remote/__init__.py +0 -0
  75. llm_code/remote/client.py +136 -0
  76. llm_code/remote/protocol.py +22 -0
  77. llm_code/remote/server.py +275 -0
  78. llm_code/remote/ssh_proxy.py +56 -0
  79. llm_code/runtime/__init__.py +0 -0
  80. llm_code/runtime/auto_commit.py +56 -0
  81. llm_code/runtime/auto_diagnose.py +62 -0
  82. llm_code/runtime/checkpoint.py +70 -0
  83. llm_code/runtime/checkpoint_recovery.py +142 -0
  84. llm_code/runtime/compaction.py +35 -0
  85. llm_code/runtime/compressor.py +415 -0
  86. llm_code/runtime/config.py +533 -0
  87. llm_code/runtime/context.py +49 -0
  88. llm_code/runtime/conversation.py +921 -0
  89. llm_code/runtime/cost_tracker.py +126 -0
  90. llm_code/runtime/dream.py +127 -0
  91. llm_code/runtime/file_protection.py +150 -0
  92. llm_code/runtime/hardware.py +85 -0
  93. llm_code/runtime/hooks.py +223 -0
  94. llm_code/runtime/indexer.py +230 -0
  95. llm_code/runtime/knowledge_compiler.py +232 -0
  96. llm_code/runtime/memory.py +132 -0
  97. llm_code/runtime/memory_layers.py +467 -0
  98. llm_code/runtime/memory_lint.py +252 -0
  99. llm_code/runtime/model_aliases.py +37 -0
  100. llm_code/runtime/ollama.py +93 -0
  101. llm_code/runtime/overlay.py +124 -0
  102. llm_code/runtime/permissions.py +200 -0
  103. llm_code/runtime/plan.py +45 -0
  104. llm_code/runtime/prompt.py +238 -0
  105. llm_code/runtime/repo_map.py +174 -0
  106. llm_code/runtime/sandbox.py +116 -0
  107. llm_code/runtime/session.py +268 -0
  108. llm_code/runtime/skill_resolver.py +61 -0
  109. llm_code/runtime/skills.py +133 -0
  110. llm_code/runtime/speculative.py +75 -0
  111. llm_code/runtime/streaming_executor.py +216 -0
  112. llm_code/runtime/telemetry.py +196 -0
  113. llm_code/runtime/token_budget.py +26 -0
  114. llm_code/runtime/vcr.py +142 -0
  115. llm_code/runtime/vision.py +102 -0
  116. llm_code/swarm/__init__.py +1 -0
  117. llm_code/swarm/backend_subprocess.py +108 -0
  118. llm_code/swarm/backend_tmux.py +103 -0
  119. llm_code/swarm/backend_worktree.py +306 -0
  120. llm_code/swarm/checkpoint.py +74 -0
  121. llm_code/swarm/coordinator.py +236 -0
  122. llm_code/swarm/mailbox.py +88 -0
  123. llm_code/swarm/manager.py +202 -0
  124. llm_code/swarm/memory_sync.py +80 -0
  125. llm_code/swarm/recovery.py +21 -0
  126. llm_code/swarm/team.py +67 -0
  127. llm_code/swarm/types.py +31 -0
  128. llm_code/task/__init__.py +16 -0
  129. llm_code/task/diagnostics.py +93 -0
  130. llm_code/task/manager.py +162 -0
  131. llm_code/task/types.py +112 -0
  132. llm_code/task/verifier.py +104 -0
  133. llm_code/tools/__init__.py +0 -0
  134. llm_code/tools/agent.py +145 -0
  135. llm_code/tools/agent_roles.py +82 -0
  136. llm_code/tools/base.py +94 -0
  137. llm_code/tools/bash.py +565 -0
  138. llm_code/tools/computer_use_tools.py +278 -0
  139. llm_code/tools/coordinator_tool.py +75 -0
  140. llm_code/tools/cron_create.py +90 -0
  141. llm_code/tools/cron_delete.py +49 -0
  142. llm_code/tools/cron_list.py +51 -0
  143. llm_code/tools/deferred.py +92 -0
  144. llm_code/tools/dump.py +116 -0
  145. llm_code/tools/edit_file.py +282 -0
  146. llm_code/tools/git_tools.py +531 -0
  147. llm_code/tools/glob_search.py +112 -0
  148. llm_code/tools/grep_search.py +144 -0
  149. llm_code/tools/ide_diagnostics.py +59 -0
  150. llm_code/tools/ide_open.py +58 -0
  151. llm_code/tools/ide_selection.py +52 -0
  152. llm_code/tools/memory_tools.py +138 -0
  153. llm_code/tools/multi_edit.py +143 -0
  154. llm_code/tools/notebook_edit.py +107 -0
  155. llm_code/tools/notebook_read.py +81 -0
  156. llm_code/tools/parsing.py +63 -0
  157. llm_code/tools/read_file.py +154 -0
  158. llm_code/tools/registry.py +58 -0
  159. llm_code/tools/search_backends/__init__.py +56 -0
  160. llm_code/tools/search_backends/brave.py +56 -0
  161. llm_code/tools/search_backends/duckduckgo.py +129 -0
  162. llm_code/tools/search_backends/searxng.py +71 -0
  163. llm_code/tools/search_backends/tavily.py +73 -0
  164. llm_code/tools/swarm_create.py +109 -0
  165. llm_code/tools/swarm_delete.py +95 -0
  166. llm_code/tools/swarm_list.py +44 -0
  167. llm_code/tools/swarm_message.py +109 -0
  168. llm_code/tools/task_close.py +79 -0
  169. llm_code/tools/task_plan.py +79 -0
  170. llm_code/tools/task_verify.py +90 -0
  171. llm_code/tools/tool_search.py +65 -0
  172. llm_code/tools/web_common.py +258 -0
  173. llm_code/tools/web_fetch.py +223 -0
  174. llm_code/tools/web_search.py +280 -0
  175. llm_code/tools/write_file.py +118 -0
  176. llm_code/tui/__init__.py +1 -0
  177. llm_code/tui/app.py +2432 -0
  178. llm_code/tui/chat_view.py +82 -0
  179. llm_code/tui/chat_widgets.py +309 -0
  180. llm_code/tui/header_bar.py +46 -0
  181. llm_code/tui/input_bar.py +349 -0
  182. llm_code/tui/keybindings.py +142 -0
  183. llm_code/tui/marketplace.py +210 -0
  184. llm_code/tui/status_bar.py +72 -0
  185. llm_code/tui/theme.py +96 -0
  186. llm_code/utils/__init__.py +0 -0
  187. llm_code/utils/diff.py +111 -0
  188. llm_code/utils/errors.py +70 -0
  189. llm_code/utils/hyperlink.py +73 -0
  190. llm_code/utils/notebook.py +179 -0
  191. llm_code/utils/search.py +69 -0
  192. llm_code/utils/text_normalize.py +28 -0
  193. llm_code/utils/version_check.py +62 -0
  194. llm_code/vim/__init__.py +4 -0
  195. llm_code/vim/engine.py +51 -0
  196. llm_code/vim/motions.py +172 -0
  197. llm_code/vim/operators.py +183 -0
  198. llm_code/vim/text_objects.py +139 -0
  199. llm_code/vim/transitions.py +279 -0
  200. llm_code/vim/types.py +68 -0
  201. llm_code/voice/__init__.py +1 -0
  202. llm_code/voice/languages.py +43 -0
  203. llm_code/voice/recorder.py +136 -0
  204. llm_code/voice/stt.py +36 -0
  205. llm_code/voice/stt_anthropic.py +66 -0
  206. llm_code/voice/stt_google.py +32 -0
  207. llm_code/voice/stt_whisper.py +52 -0
  208. llmcode_cli-1.0.0.dist-info/METADATA +524 -0
  209. llmcode_cli-1.0.0.dist-info/RECORD +212 -0
  210. llmcode_cli-1.0.0.dist-info/WHEEL +4 -0
  211. llmcode_cli-1.0.0.dist-info/entry_points.txt +2 -0
  212. 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
@@ -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
@@ -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))