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
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
File without changes
@@ -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