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,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()
|