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,82 @@
1
+ # llm_code/tui/chat_view.py
2
+ """ChatScrollView — scrollable container for chat entries."""
3
+ from __future__ import annotations
4
+
5
+ from textual.containers import VerticalScroll
6
+ from textual.widget import Widget
7
+ from textual.app import RenderResult
8
+ from rich.markdown import Markdown
9
+ from rich.text import Text
10
+
11
+
12
+ class UserMessage(Widget):
13
+ """Renders a user input line: ❯ text"""
14
+
15
+ DEFAULT_CSS = "UserMessage { height: auto; margin: 1 0 0 0; }"
16
+
17
+ def __init__(self, text: str) -> None:
18
+ super().__init__()
19
+ self._text = text
20
+
21
+ def render(self) -> RenderResult:
22
+ t = Text()
23
+ t.append("❯ ", style="bold cyan")
24
+ t.append(self._text)
25
+ return t
26
+
27
+
28
+ class AssistantText(Widget):
29
+ """Renders assistant response text."""
30
+
31
+ DEFAULT_CSS = "AssistantText { height: auto; }"
32
+
33
+ def __init__(self, text: str = "") -> None:
34
+ super().__init__()
35
+ self._text = text
36
+
37
+ def append_text(self, new_text: str) -> None:
38
+ self._text += new_text
39
+ self.refresh()
40
+
41
+ def render(self) -> RenderResult:
42
+ # Use Rich Markdown for rendering if content has markdown indicators
43
+ if any(marker in self._text for marker in ("```", "**", "##", "- ")):
44
+ try:
45
+ return Markdown(self._text)
46
+ except Exception:
47
+ return Text(self._text)
48
+ return Text(self._text)
49
+
50
+
51
+ class ChatScrollView(VerticalScroll):
52
+ """Scrollable chat area that auto-scrolls to bottom on new content."""
53
+
54
+ DEFAULT_CSS = """
55
+ ChatScrollView {
56
+ height: 1fr;
57
+ padding: 0 1;
58
+ }
59
+ """
60
+
61
+ def __init__(self) -> None:
62
+ super().__init__()
63
+ self._auto_scroll = True
64
+
65
+ def on_mount(self) -> None:
66
+ self.scroll_end(animate=False)
67
+
68
+ def add_entry(self, widget: Widget) -> None:
69
+ self.mount(widget)
70
+ if self._auto_scroll:
71
+ self.scroll_end(animate=False)
72
+
73
+ def on_scroll_up(self) -> None:
74
+ self._auto_scroll = False
75
+
76
+ def pause_auto_scroll(self) -> None:
77
+ """Disable auto-scroll (e.g. when user pages up to read history)."""
78
+ self._auto_scroll = False
79
+
80
+ def resume_auto_scroll(self) -> None:
81
+ self._auto_scroll = True
82
+ self.scroll_end(animate=False)
@@ -0,0 +1,309 @@
1
+ # llm_code/tui/chat_widgets.py
2
+ """Chat entry widgets: ToolBlock, ThinkingBlock, PermissionInline, TurnSummary, SpinnerLine."""
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from textual.widget import Widget
8
+ from textual.reactive import reactive
9
+ from textual.app import RenderResult
10
+ from rich.text import Text
11
+
12
+
13
+ SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
14
+
15
+
16
+ @dataclass
17
+ class ToolBlockData:
18
+ tool_name: str
19
+ args_display: str
20
+ result: str
21
+ is_error: bool
22
+ diff_lines: list[str] = field(default_factory=list)
23
+
24
+
25
+ class ToolBlock(Widget):
26
+ """Renders a tool call — Claude Code style with diff view for edit/write."""
27
+
28
+ DEFAULT_CSS = "ToolBlock { height: auto; margin: 0 0 0 0; }"
29
+
30
+ # Map tool names to display actions
31
+ _ACTION_MAP = {
32
+ "edit_file": "Update",
33
+ "write_file": "Write",
34
+ "read_file": "Read",
35
+ "bash": "Bash",
36
+ "glob_search": "Search",
37
+ "grep_search": "Search",
38
+ "notebook_read": "Read",
39
+ "notebook_edit": "Update",
40
+ }
41
+
42
+ def __init__(self, data: ToolBlockData) -> None:
43
+ super().__init__()
44
+ self._data = data
45
+
46
+ @staticmethod
47
+ def create(
48
+ tool_name: str,
49
+ args_display: str,
50
+ result: str,
51
+ is_error: bool,
52
+ diff_lines: list[str] | None = None,
53
+ ) -> "ToolBlock":
54
+ data = ToolBlockData(
55
+ tool_name=tool_name,
56
+ args_display=args_display,
57
+ result=result,
58
+ is_error=is_error,
59
+ diff_lines=diff_lines or [],
60
+ )
61
+ return ToolBlock(data)
62
+
63
+ def _extract_file_path(self) -> str:
64
+ """Extract file path from args_display."""
65
+ d = self._data
66
+ for pattern in ("'path': '", '"path": "', "'file_path': '", '"file_path": "'):
67
+ if pattern in d.args_display:
68
+ quote = "'" if "'" in pattern[-1] else '"'
69
+ start = d.args_display.index(pattern) + len(pattern)
70
+ end = d.args_display.find(quote, start)
71
+ if end == -1:
72
+ # Truncated — use rest of string
73
+ return d.args_display[start:start + 80]
74
+ return d.args_display[start:end]
75
+ return d.args_display[:80]
76
+
77
+ def _count_diff_changes(self) -> tuple[int, int]:
78
+ """Count added and removed lines in diff."""
79
+ added = sum(1 for l in self._data.diff_lines if l.startswith("+"))
80
+ removed = sum(1 for l in self._data.diff_lines if l.startswith("-"))
81
+ return added, removed
82
+
83
+ def render_text(self) -> str:
84
+ d = self._data
85
+ action = self._ACTION_MAP.get(d.tool_name, d.tool_name)
86
+ file_path = self._extract_file_path()
87
+ icon = "✗" if d.is_error else "●"
88
+ lines = [f"{icon} {action}({file_path})"]
89
+ if d.result:
90
+ lines.append(f" └ {d.result}")
91
+ return "\n".join(lines)
92
+
93
+ def render(self) -> RenderResult:
94
+ d = self._data
95
+ text = Text()
96
+ action = self._ACTION_MAP.get(d.tool_name, d.tool_name)
97
+ file_path = self._extract_file_path()
98
+
99
+ # Header: ● Action(file_path) or ✗ Action(file_path)
100
+ if d.is_error:
101
+ text.append("✗ ", style="bold red")
102
+ else:
103
+ text.append("● ", style="bold #cc7a00")
104
+ text.append(f"{action}(", style="bold white")
105
+ text.append(file_path, style="bold white")
106
+ text.append(")", style="bold white")
107
+
108
+ # For bash: show command
109
+ if d.tool_name == "bash":
110
+ args = d.args_display
111
+ if not args.startswith("$"):
112
+ args = f"$ {args}"
113
+ text.append("\n")
114
+ text.append(f" │ {args}", style="white on #2a2a3a")
115
+
116
+ # Result summary
117
+ if d.result:
118
+ text.append("\n")
119
+ # For edit/write: show diff summary
120
+ if d.diff_lines and d.tool_name in ("edit_file", "write_file"):
121
+ added, removed = self._count_diff_changes()
122
+ parts = []
123
+ if added:
124
+ parts.append(f"Added {added} line{'s' if added != 1 else ''}")
125
+ if removed:
126
+ parts.append(f"removed {removed} line{'s' if removed != 1 else ''}")
127
+ summary = ", ".join(parts) if parts else d.result
128
+ text.append(f" └ {summary}", style="dim")
129
+ else:
130
+ icon = "✗" if d.is_error else "✓"
131
+ icon_style = "bold red" if d.is_error else "bold green"
132
+ text.append(f" {icon} ", style=icon_style)
133
+ text.append(d.result, style="dim")
134
+
135
+ # Diff lines with line numbers and colored backgrounds
136
+ for dl in d.diff_lines:
137
+ text.append("\n")
138
+ if dl.startswith("+"):
139
+ # Added line: green background
140
+ text.append(f" {dl}", style="green on #0a2e0a")
141
+ elif dl.startswith("-"):
142
+ # Removed line: red background
143
+ text.append(f" {dl}", style="red on #2e0a0a")
144
+ else:
145
+ # Context line
146
+ text.append(f" {dl}", style="dim")
147
+
148
+ return text
149
+
150
+
151
+ class ThinkingBlock(Widget):
152
+ """Collapsible thinking block: collapsed shows summary, expanded shows content."""
153
+
154
+ DEFAULT_CSS = """
155
+ ThinkingBlock { height: auto; }
156
+ """
157
+
158
+ expanded: reactive[bool] = reactive(False)
159
+
160
+ def __init__(self, content: str, elapsed: float, tokens: int) -> None:
161
+ super().__init__()
162
+ self._content = content
163
+ self._elapsed = elapsed
164
+ self._tokens = tokens
165
+
166
+ def toggle(self) -> None:
167
+ self.expanded = not self.expanded
168
+
169
+ def collapsed_text(self) -> str:
170
+ return f"💭 Thinking ({self._elapsed:.1f}s · ~{self._tokens:,} tok)"
171
+
172
+ def render(self) -> RenderResult:
173
+ text = Text()
174
+ if not self.expanded:
175
+ text.append(self.collapsed_text(), style="#cc7a00")
176
+ else:
177
+ text.append(self.collapsed_text(), style="#cc7a00")
178
+ text.append("\n")
179
+ truncated = self._content[:3000]
180
+ if len(self._content) > 3000:
181
+ truncated += f"\n… [{len(self._content):,} chars total]"
182
+ text.append(truncated, style="dim")
183
+ return text
184
+
185
+
186
+ class TurnSummary(Widget):
187
+ """Turn completion line: ✓ Done (Xs) ↑N · ↓N tok · $X.XX"""
188
+
189
+ DEFAULT_CSS = "TurnSummary { height: auto; margin: 0 0 1 0; }"
190
+
191
+ def __init__(self, text_content: str) -> None:
192
+ super().__init__()
193
+ self._text_content = text_content
194
+
195
+ @staticmethod
196
+ def create(elapsed: float, input_tokens: int, output_tokens: int, cost: str) -> "TurnSummary":
197
+ time_str = f"{elapsed:.1f}s" if elapsed < 60 else f"{elapsed / 60:.1f}m"
198
+ parts = []
199
+ if input_tokens > 0:
200
+ parts.append(f"↑{input_tokens:,}")
201
+ if output_tokens > 0:
202
+ parts.append(f"↓{output_tokens:,}")
203
+ tok_str = f" {' · '.join(parts)} tok" if parts else ""
204
+ cost_str = f" · {cost}" if cost else ""
205
+ content = f"✓ Done ({time_str}){tok_str}{cost_str}"
206
+ return TurnSummary(content)
207
+
208
+ def render_text(self) -> str:
209
+ return self._text_content
210
+
211
+ def render(self) -> RenderResult:
212
+ text = Text()
213
+ text.append("✓", style="bold green")
214
+ text.append(self._text_content[1:], style="dim")
215
+ return text
216
+
217
+
218
+ class SpinnerLine(Widget):
219
+ """Animated spinner — color changes: orange (normal) → red (>60s)."""
220
+
221
+ DEFAULT_CSS = "SpinnerLine { height: auto; }"
222
+
223
+ phase: reactive[str] = reactive("waiting")
224
+ elapsed: reactive[float] = reactive(0.0)
225
+ tokens: reactive[int] = reactive(0)
226
+ _frame: int = 0
227
+
228
+ _LABELS = {
229
+ "waiting": "Waiting for model…",
230
+ "thinking": "Puttering…",
231
+ "processing": "Processing…",
232
+ "running": "Reading {tool}…",
233
+ "streaming": "Streaming…",
234
+ }
235
+
236
+ def __init__(self, tool_name: str = "") -> None:
237
+ super().__init__()
238
+ self._tool_name = tool_name
239
+ self._detail_lines: list[str] = []
240
+
241
+ def set_detail(self, lines: list[str]) -> None:
242
+ """Set detail lines shown below the spinner (e.g. file paths)."""
243
+ self._detail_lines = lines
244
+ self.refresh()
245
+
246
+ def render_text(self) -> str:
247
+ label = self._LABELS.get(self.phase, "Working…")
248
+ if "{tool}" in label:
249
+ label = label.replace("{tool}", self._tool_name)
250
+ # Time formatting
251
+ if self.elapsed >= 60:
252
+ time_str = f"{self.elapsed / 60:.0f}m {self.elapsed % 60:.0f}s"
253
+ else:
254
+ time_str = f"{self.elapsed:.0f}s"
255
+ # Build status parts
256
+ parts = [time_str]
257
+ if self.tokens > 0:
258
+ parts.append(f"↑ {self.tokens:,} tokens")
259
+ if self.phase == "thinking":
260
+ parts.append("thinking")
261
+ meta = " · ".join(parts)
262
+ return f"{label} ({meta})"
263
+
264
+ def render(self) -> RenderResult:
265
+ # Color: orange normally, red when elapsed > 60s
266
+ color = "#cc3333" if self.elapsed > 60 else "#cc7a00"
267
+ prefix = "●" if self.phase == "running" else "*"
268
+ text = Text()
269
+ text.append(f"{prefix} ", style=f"bold {color}")
270
+ text.append(self.render_text(), style=color)
271
+ # Detail lines (e.g. tool file paths)
272
+ for line in self._detail_lines:
273
+ text.append(f"\n └ {line}", style="dim")
274
+ return text
275
+
276
+ def advance_frame(self) -> None:
277
+ self._frame += 1
278
+ self.refresh()
279
+
280
+
281
+ class PermissionInline(Widget):
282
+ """Inline permission prompt with yellow left border."""
283
+
284
+ DEFAULT_CSS = """
285
+ PermissionInline {
286
+ height: auto;
287
+ border-left: thick $warning;
288
+ padding: 0 1;
289
+ margin: 0 0 0 2;
290
+ }
291
+ """
292
+
293
+ def __init__(self, tool_name: str, args_preview: str) -> None:
294
+ super().__init__()
295
+ self._tool_name = tool_name
296
+ self._args_preview = args_preview
297
+
298
+ def render(self) -> RenderResult:
299
+ text = Text()
300
+ text.append("⚠ Allow? ", style="yellow bold")
301
+ text.append(f"{self._tool_name}: {self._args_preview[:60]}", style="dim")
302
+ text.append("\n ")
303
+ text.append("[y]", style="bold green")
304
+ text.append(" Yes ", style="dim")
305
+ text.append("[n]", style="bold red")
306
+ text.append(" No ", style="dim")
307
+ text.append("[a]", style="bold cyan")
308
+ text.append(" Always", style="dim")
309
+ return text
@@ -0,0 +1,46 @@
1
+ """HeaderBar — single-line top bar showing model, project, branch."""
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 HeaderBar(Widget):
10
+ """Single-line header: llm-code · {model} · {project} · {branch}"""
11
+
12
+ model: reactive[str] = reactive("")
13
+ project: reactive[str] = reactive("")
14
+ branch: reactive[str] = reactive("")
15
+
16
+ DEFAULT_CSS = """
17
+ HeaderBar {
18
+ dock: top;
19
+ height: 1;
20
+ background: $surface-darken-1;
21
+ color: $text-muted;
22
+ padding: 0 1;
23
+ }
24
+ """
25
+
26
+ def _format_content(self) -> str:
27
+ parts = ["llm-code"]
28
+ if self.model:
29
+ parts.append(self.model)
30
+ if self.project:
31
+ parts.append(self.project)
32
+ if self.branch:
33
+ parts.append(self.branch)
34
+ return " · ".join(parts)
35
+
36
+ def render(self) -> RenderResult:
37
+ return self._format_content()
38
+
39
+ def watch_model(self) -> None:
40
+ self.refresh()
41
+
42
+ def watch_project(self) -> None:
43
+ self.refresh()
44
+
45
+ def watch_branch(self) -> None:
46
+ self.refresh()