symphony-code 0.0.1__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.
- coding_agent/__init__.py +20 -0
- coding_agent/_lazy.py +17 -0
- coding_agent/addons/__init__.py +35 -0
- coding_agent/addons/approvals.py +224 -0
- coding_agent/addons/compaction/__init__.py +36 -0
- coding_agent/addons/compaction/addon.py +88 -0
- coding_agent/addons/compaction/compactor.py +138 -0
- coding_agent/addons/compaction/prompts.py +20 -0
- coding_agent/addons/compaction/transcript.py +118 -0
- coding_agent/addons/evaluation/__init__.py +68 -0
- coding_agent/addons/evaluation/addon.py +199 -0
- coding_agent/addons/evaluation/evaluators.py +245 -0
- coding_agent/addons/evaluation/handlers.py +66 -0
- coding_agent/addons/evaluation/policy.py +277 -0
- coding_agent/addons/evaluation/protocol.py +192 -0
- coding_agent/addons/evaluation/state.py +284 -0
- coding_agent/addons/langfuse/__init__.py +24 -0
- coding_agent/addons/langfuse/addon.py +465 -0
- coding_agent/addons/langfuse/install.py +79 -0
- coding_agent/addons/langfuse/serialize.py +158 -0
- coding_agent/addons/learning/__init__.py +33 -0
- coding_agent/addons/learning/addon.py +181 -0
- coding_agent/addons/learning/loop.py +247 -0
- coding_agent/addons/learning/prompts.py +26 -0
- coding_agent/addons/learning/sanitize.py +64 -0
- coding_agent/addons/learning/store.py +522 -0
- coding_agent/addons/manager/__init__.py +18 -0
- coding_agent/addons/manager/bridge.py +74 -0
- coding_agent/addons/manager/discovery.py +34 -0
- coding_agent/addons/manager/report.py +33 -0
- coding_agent/addons/manager/runtime.py +76 -0
- coding_agent/addons/manager/tool_changes.py +16 -0
- coding_agent/addons/persistence/__init__.py +41 -0
- coding_agent/addons/persistence/active.py +132 -0
- coding_agent/addons/persistence/artifacts.py +110 -0
- coding_agent/addons/persistence/collection.py +166 -0
- coding_agent/addons/persistence/compatibility.py +30 -0
- coding_agent/addons/persistence/images.py +133 -0
- coding_agent/addons/persistence/jsonl.py +1195 -0
- coding_agent/addons/persistence/presentation.py +104 -0
- coding_agent/addons/plugins/__init__.py +19 -0
- coding_agent/addons/plugins/install.py +37 -0
- coding_agent/addons/plugins/manager.py +140 -0
- coding_agent/addons/plugins/models.py +50 -0
- coding_agent/addons/skills/__init__.py +28 -0
- coding_agent/addons/skills/addon.py +26 -0
- coding_agent/addons/skills/args.py +78 -0
- coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
- coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
- coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
- coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
- coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
- coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
- coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
- coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
- coding_agent/addons/skills/frontmatter.py +57 -0
- coding_agent/addons/skills/models.py +57 -0
- coding_agent/addons/skills/registry.py +109 -0
- coding_agent/addons/skills/tool.py +54 -0
- coding_agent/addons/voice/__init__.py +5 -0
- coding_agent/addons/voice/addon.py +106 -0
- coding_agent/addons/voice/credentials.py +42 -0
- coding_agent/addons/voice/playback.py +42 -0
- coding_agent/addons/voice/speak.py +287 -0
- coding_agent/addons/voice/stream.py +651 -0
- coding_agent/addons/voice/summary.py +126 -0
- coding_agent/agent.py +523 -0
- coding_agent/bench/__init__.py +1 -0
- coding_agent/bench/cli.py +200 -0
- coding_agent/bench/patch.py +78 -0
- coding_agent/commands/__init__.py +5 -0
- coding_agent/commands/catalog.py +179 -0
- coding_agent/commands/jev_argument.py +40 -0
- coding_agent/config/__init__.py +51 -0
- coding_agent/config/config.py +376 -0
- coding_agent/config/credentials.py +153 -0
- coding_agent/config/personalities.json +34 -0
- coding_agent/config/personalities.py +170 -0
- coding_agent/config/prompts.py +67 -0
- coding_agent/protocols/__init__.py +5 -0
- coding_agent/protocols/acp/README.md +36 -0
- coding_agent/protocols/acp/__init__.py +27 -0
- coding_agent/protocols/acp/mapping.py +164 -0
- coding_agent/protocols/acp/server.py +261 -0
- coding_agent/protocols/acp/transport.py +72 -0
- coding_agent/protocols/stdio/README.md +36 -0
- coding_agent/protocols/stdio/__init__.py +37 -0
- coding_agent/protocols/stdio/catalog.py +49 -0
- coding_agent/protocols/stdio/commands.py +218 -0
- coding_agent/protocols/stdio/server.py +181 -0
- coding_agent/protocols/stdio/transport.py +71 -0
- coding_agent/py.typed +0 -0
- coding_agent/resources/__init__.py +5 -0
- coding_agent/resources/usage.py +264 -0
- coding_agent/run/__init__.py +1 -0
- coding_agent/run/cli.py +99 -0
- coding_agent/run/detach.py +37 -0
- coding_agent/run/interrupted.py +67 -0
- coding_agent/run/log_sink.py +46 -0
- coding_agent/tools/__init__.py +73 -0
- coding_agent/tools/ask_user.py +61 -0
- coding_agent/tools/base.py +136 -0
- coding_agent/tools/bash.py +288 -0
- coding_agent/tools/bash_jobs.py +116 -0
- coding_agent/tools/generate_image.py +123 -0
- coding_agent/tools/memory.py +100 -0
- coding_agent/tools/patch.py +218 -0
- coding_agent/tools/read_file.py +167 -0
- coding_agent/tools/search.py +433 -0
- coding_agent/tools/web_search.py +151 -0
- coding_agent/tools/write_file.py +40 -0
- coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
- coding_agent/tui/__init__.py +22 -0
- coding_agent/tui/__main__.py +122 -0
- coding_agent/tui/app.py +456 -0
- coding_agent/tui/chrome/__init__.py +33 -0
- coding_agent/tui/chrome/footer.py +274 -0
- coding_agent/tui/chrome/topbar.py +259 -0
- coding_agent/tui/commands/__init__.py +45 -0
- coding_agent/tui/commands/catalog.py +35 -0
- coding_agent/tui/commands/manager.py +557 -0
- coding_agent/tui/composer/__init__.py +18 -0
- coding_agent/tui/composer/input.py +219 -0
- coding_agent/tui/composer/slash_menu.py +341 -0
- coding_agent/tui/composer/surface.py +330 -0
- coding_agent/tui/composer/voice.py +267 -0
- coding_agent/tui/composer/voice_mode.py +491 -0
- coding_agent/tui/runtime/__init__.py +23 -0
- coding_agent/tui/runtime/events.py +728 -0
- coding_agent/tui/runtime/sink.py +115 -0
- coding_agent/tui/runtime/state.py +145 -0
- coding_agent/tui/runtime/subagent.py +531 -0
- coding_agent/tui/runtime/turn.py +206 -0
- coding_agent/tui/screens/__init__.py +54 -0
- coding_agent/tui/screens/ask.py +69 -0
- coding_agent/tui/screens/context.py +195 -0
- coding_agent/tui/screens/dashboard.py +91 -0
- coding_agent/tui/screens/diff.py +302 -0
- coding_agent/tui/screens/extensions.py +68 -0
- coding_agent/tui/screens/file_selector.py +167 -0
- coding_agent/tui/screens/history.py +458 -0
- coding_agent/tui/screens/langfuse.py +142 -0
- coding_agent/tui/screens/learning.py +113 -0
- coding_agent/tui/screens/modal.py +131 -0
- coding_agent/tui/screens/onboard.py +455 -0
- coding_agent/tui/screens/resume.py +163 -0
- coding_agent/tui/theme/__init__.py +49 -0
- coding_agent/tui/theme/load.py +325 -0
- coding_agent/tui/theme/style.py +95 -0
- coding_agent/tui/theme/theme.toml +1687 -0
- coding_agent/tui/tools/__init__.py +53 -0
- coding_agent/tui/tools/activity.py +184 -0
- coding_agent/tui/tools/calls.py +560 -0
- coding_agent/tui/tools/code_view.py +52 -0
- coding_agent/tui/tools/images.py +250 -0
- coding_agent/tui/tools/labels.py +135 -0
- coding_agent/tui/tools/modal.py +137 -0
- coding_agent/tui/tools/partial_args.py +19 -0
- coding_agent/tui/tools/snapshots.py +382 -0
- coding_agent/tui/transcript/__init__.py +36 -0
- coding_agent/tui/transcript/messages.py +408 -0
- coding_agent/tui/transcript/process.py +504 -0
- coding_agent/tui/transcript/surface.py +345 -0
- coding_agent/tui/transcript/thinking_markdown.py +314 -0
- symphony_code-0.0.1.dist-info/METADATA +66 -0
- symphony_code-0.0.1.dist-info/RECORD +169 -0
- symphony_code-0.0.1.dist-info/WHEEL +4 -0
- symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
- symphony_code-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Footer with workspace, context utilisation, and the active keyboard hint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING, Optional
|
|
7
|
+
|
|
8
|
+
from rich.console import Console, ConsoleOptions, Group, RenderResult
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
from textual.widgets import Static
|
|
12
|
+
|
|
13
|
+
from coding_agent.tui.theme import SYMPHONY_COLORS
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from coding_agent.tui.runtime.state import RunMetrics, UiRunState
|
|
17
|
+
|
|
18
|
+
SEGMENT_SEPARATOR = " | "
|
|
19
|
+
IDLE_HINT = "esc cancel"
|
|
20
|
+
QUESTION_HINT = "↵ approve ↑↓ choose esc deny"
|
|
21
|
+
# Context stays a left-to-right block meter. These thresholds only recolor it,
|
|
22
|
+
# the same way claude-hud warns as the window fills.
|
|
23
|
+
CONTEXT_WARN = 70
|
|
24
|
+
CONTEXT_CRITICAL = 85
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def footer_hint(*, question_pending: bool) -> str:
|
|
28
|
+
"""Keyboard hint shown as the footer's last segment."""
|
|
29
|
+
return QUESTION_HINT if question_pending else IDLE_HINT
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def context_percent(metrics: RunMetrics) -> Optional[int]:
|
|
33
|
+
"""Context-window utilisation as a whole percentage, or None when unknown."""
|
|
34
|
+
utilization = metrics.utilization
|
|
35
|
+
if utilization is None and metrics.context_limit:
|
|
36
|
+
if metrics.context_left is not None:
|
|
37
|
+
utilization = 1 - (metrics.context_left / metrics.context_limit)
|
|
38
|
+
else:
|
|
39
|
+
tokens = metrics.tokens_used or metrics.cumulative_tokens or metrics.total_tokens
|
|
40
|
+
utilization = tokens / metrics.context_limit
|
|
41
|
+
if utilization is None:
|
|
42
|
+
return None
|
|
43
|
+
return round(max(0.0, min(utilization, 1.0)) * 100)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def footer_segments(state: UiRunState, *, hint: str) -> tuple[str, ...]:
|
|
47
|
+
"""Return the context label, meter, and keyboard hint in display order."""
|
|
48
|
+
metrics = state.metrics
|
|
49
|
+
percent = context_percent(metrics)
|
|
50
|
+
label = f"{percent}% context" if percent is not None else "context unknown"
|
|
51
|
+
return (label, _context_count(metrics), hint)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _context_count(metrics: RunMetrics) -> str:
|
|
55
|
+
tokens = metrics.tokens_used or metrics.total_tokens or metrics.cumulative_tokens
|
|
56
|
+
if metrics.context_limit is not None:
|
|
57
|
+
return f"{tokens:,}/{metrics.context_limit:,}"
|
|
58
|
+
return f"{tokens:,}" if tokens else "—"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def context_bar(state: UiRunState, *, width: int = 18) -> Text:
|
|
62
|
+
"""Render a heavier left-to-right context meter followed by token counts.
|
|
63
|
+
|
|
64
|
+
The meter deliberately sits between the context label and its count: the
|
|
65
|
+
label is its anchor on the left, while the count anchors the right edge of
|
|
66
|
+
this small cluster. Full block glyphs make the one-line meter read more
|
|
67
|
+
clearly than the previous thin box-drawing line.
|
|
68
|
+
"""
|
|
69
|
+
metrics = state.metrics
|
|
70
|
+
percent = context_percent(metrics) or 0
|
|
71
|
+
filled = round(max(0, min(percent, 100)) * width / 100)
|
|
72
|
+
tokens = metrics.tokens_used or metrics.total_tokens or metrics.cumulative_tokens
|
|
73
|
+
limit = metrics.context_limit
|
|
74
|
+
count = f"{tokens:,}/{limit:,}" if limit is not None else f"{tokens:,}"
|
|
75
|
+
result = Text()
|
|
76
|
+
# Usage fills from left to right. The glyph run is unchanged; only the
|
|
77
|
+
# fill color steps from accent to warning to critical as the window fills.
|
|
78
|
+
if percent >= CONTEXT_CRITICAL:
|
|
79
|
+
fill = SYMPHONY_COLORS["keyword"]
|
|
80
|
+
elif percent >= CONTEXT_WARN:
|
|
81
|
+
fill = SYMPHONY_COLORS["number"]
|
|
82
|
+
else:
|
|
83
|
+
fill = SYMPHONY_COLORS["accent"]
|
|
84
|
+
result.append("█" * filled, style=fill)
|
|
85
|
+
result.append("░" * (width - filled), style=SYMPHONY_COLORS["muted_dim"])
|
|
86
|
+
result.append(f" {count}")
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def phase_label(state: UiRunState) -> str:
|
|
91
|
+
"""Quiet left-hand activity word; empty while idle so the footer stays clean."""
|
|
92
|
+
if state.phase == "idle":
|
|
93
|
+
return ""
|
|
94
|
+
labels = {
|
|
95
|
+
"thinking": "thinking",
|
|
96
|
+
"streaming": "streaming",
|
|
97
|
+
"tool": "tool",
|
|
98
|
+
"paused": "paused",
|
|
99
|
+
}
|
|
100
|
+
return labels.get(state.phase, "working")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _phase_style(phase: str) -> str:
|
|
104
|
+
if phase == "paused":
|
|
105
|
+
return f"bold {SYMPHONY_COLORS['keyword']}"
|
|
106
|
+
if phase == "tool":
|
|
107
|
+
return f"bold {SYMPHONY_COLORS['number']}"
|
|
108
|
+
if phase == "thinking":
|
|
109
|
+
return f"bold {SYMPHONY_COLORS['function']}"
|
|
110
|
+
return f"bold {SYMPHONY_COLORS['string']}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class ResponsiveFooter:
|
|
115
|
+
"""Rich renderable which preserves useful footer content as width changes."""
|
|
116
|
+
|
|
117
|
+
state: UiRunState
|
|
118
|
+
hint: str
|
|
119
|
+
workspace: str = ""
|
|
120
|
+
|
|
121
|
+
def __rich_console__(
|
|
122
|
+
self,
|
|
123
|
+
_console: Console,
|
|
124
|
+
options: ConsoleOptions,
|
|
125
|
+
) -> RenderResult:
|
|
126
|
+
yield self._table(max(options.max_width, 1))
|
|
127
|
+
|
|
128
|
+
def _usage(self, *, meter: bool, count: bool) -> Text:
|
|
129
|
+
percent = context_percent(self.state.metrics)
|
|
130
|
+
label = f"{percent}% context" if percent is not None else "context unknown"
|
|
131
|
+
usage = Text(label, style=SYMPHONY_COLORS["foreground"], no_wrap=True)
|
|
132
|
+
if meter and percent is not None:
|
|
133
|
+
usage.append(SEGMENT_SEPARATOR, style=SYMPHONY_COLORS["edge"])
|
|
134
|
+
usage.append_text(context_bar(self.state))
|
|
135
|
+
elif count and percent is not None:
|
|
136
|
+
usage.append(" ")
|
|
137
|
+
usage.append(_context_count(self.state.metrics))
|
|
138
|
+
return usage
|
|
139
|
+
|
|
140
|
+
def _table(self, width: int) -> Table:
|
|
141
|
+
# The keyboard hint and context label are the two invariant pieces.
|
|
142
|
+
# Add the count, meter, activity and path only when each fits without
|
|
143
|
+
# forcing Rich to turn the invariant pieces into unreadable fragments.
|
|
144
|
+
label = self._usage(meter=False, count=False)
|
|
145
|
+
counted = self._usage(meter=False, count=True)
|
|
146
|
+
metered = self._usage(meter=True, count=True)
|
|
147
|
+
gap = 3
|
|
148
|
+
available = max(width - len(self.hint) - gap, 1)
|
|
149
|
+
usage = metered if len(metered.plain) <= available else counted
|
|
150
|
+
if len(usage.plain) > available:
|
|
151
|
+
usage = label
|
|
152
|
+
|
|
153
|
+
prefix = Text(no_wrap=True)
|
|
154
|
+
phase = phase_label(self.state)
|
|
155
|
+
# Phase and path are leftovers. They must not steal width from the
|
|
156
|
+
# context meter, or a live run drops the progress bar.
|
|
157
|
+
phase_cost = (len(phase) + 3) if phase else 0
|
|
158
|
+
remaining = available - len(usage.plain) - len(SEGMENT_SEPARATOR) - phase_cost
|
|
159
|
+
if phase and remaining >= 0:
|
|
160
|
+
prefix.append(phase, style=_phase_style(self.state.phase))
|
|
161
|
+
if self.workspace and remaining >= 8:
|
|
162
|
+
if prefix.plain:
|
|
163
|
+
prefix.append(SEGMENT_SEPARATOR, style=SYMPHONY_COLORS["edge"])
|
|
164
|
+
prefix.append(self.workspace, style=SYMPHONY_COLORS["muted"])
|
|
165
|
+
|
|
166
|
+
table = Table.grid(expand=True, padding=0)
|
|
167
|
+
if prefix.plain:
|
|
168
|
+
table.add_column(ratio=1, overflow="ellipsis", no_wrap=True)
|
|
169
|
+
table.add_column(no_wrap=True)
|
|
170
|
+
table.add_column(no_wrap=True)
|
|
171
|
+
else:
|
|
172
|
+
table.add_column(ratio=1, overflow="ellipsis", no_wrap=True)
|
|
173
|
+
table.add_column(justify="right", overflow="ellipsis", no_wrap=True)
|
|
174
|
+
right = Text(
|
|
175
|
+
f"{gap * ' '}{self.hint}",
|
|
176
|
+
style=SYMPHONY_COLORS["subtext"],
|
|
177
|
+
overflow="ellipsis",
|
|
178
|
+
no_wrap=True,
|
|
179
|
+
)
|
|
180
|
+
if prefix.plain:
|
|
181
|
+
separator = Text(SEGMENT_SEPARATOR, style=SYMPHONY_COLORS["edge"])
|
|
182
|
+
table.add_row(prefix, separator, usage, right)
|
|
183
|
+
else:
|
|
184
|
+
table.add_row(usage, right)
|
|
185
|
+
return table
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def render_footer(
|
|
189
|
+
state: UiRunState,
|
|
190
|
+
*,
|
|
191
|
+
hint: str,
|
|
192
|
+
workspace: str = "",
|
|
193
|
+
) -> ResponsiveFooter:
|
|
194
|
+
"""Render a footer that adapts its detail to the available terminal width."""
|
|
195
|
+
return ResponsiveFooter(state=state, hint=hint, workspace=workspace)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
DEFAULT_HINT = "Press ctrl+q to quit the app"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def update_banner(event_type: str, payload: Optional[dict] = None) -> tuple[str, str] | None:
|
|
202
|
+
"""Build the composer banner for one harness or UI event.
|
|
203
|
+
|
|
204
|
+
This is the same banner a model switch uses. ``None`` means the event
|
|
205
|
+
does not belong above the composer. The second string is the hint line.
|
|
206
|
+
"""
|
|
207
|
+
payload = payload or {}
|
|
208
|
+
if event_type == "run_failed":
|
|
209
|
+
message = str(payload.get("message") or payload.get("error") or "").strip() or "The run failed."
|
|
210
|
+
lowered = message.lower()
|
|
211
|
+
if "bad_record_mac" in lowered or "bad record mac" in lowered:
|
|
212
|
+
message = "SSL connection failed · bad record mac. Try the turn again."
|
|
213
|
+
elif "ssl" in lowered or "certificate" in lowered:
|
|
214
|
+
message = f"SSL connection failed · {message}"
|
|
215
|
+
return message, DEFAULT_HINT
|
|
216
|
+
if event_type == "run_cancelled":
|
|
217
|
+
reason = str(payload.get("reason") or "Cancelled").replace("_", " ")
|
|
218
|
+
return reason[:1].upper() + reason[1:], "esc already pressed"
|
|
219
|
+
if event_type == "run_limit_exceeded":
|
|
220
|
+
limit = payload.get("limit") or "run limit"
|
|
221
|
+
message = str(payload.get("message") or f"Harness exceeded {limit}")
|
|
222
|
+
return message, DEFAULT_HINT
|
|
223
|
+
if event_type == "cancelling":
|
|
224
|
+
return "Cancelling…", "esc already pressed"
|
|
225
|
+
if event_type in {"model_changed", "effort_changed", "mode_changed", "personality_changed", "reloaded"}:
|
|
226
|
+
text = str(payload.get("text") or "").strip()
|
|
227
|
+
return (text, DEFAULT_HINT) if text else None
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class ComposerOverlay(Static):
|
|
232
|
+
"""A short-lived banner above the composer, matching the interrupt card."""
|
|
233
|
+
|
|
234
|
+
def __init__(self) -> None:
|
|
235
|
+
super().__init__(id="composer-overlay")
|
|
236
|
+
self.title = ""
|
|
237
|
+
self.hint = DEFAULT_HINT
|
|
238
|
+
self._hide_timer: Optional[object] = None
|
|
239
|
+
self.display = False
|
|
240
|
+
|
|
241
|
+
def show(self, title: str, hint: str = DEFAULT_HINT, *, seconds: float = 3.2) -> None:
|
|
242
|
+
"""Replace the current overlay contents and restart the hide timer."""
|
|
243
|
+
self.title = title
|
|
244
|
+
self.hint = hint
|
|
245
|
+
body = Group(
|
|
246
|
+
Text(title, style=f"bold {SYMPHONY_COLORS['string']}"),
|
|
247
|
+
Text.from_markup(self._hint_markup(hint), style=SYMPHONY_COLORS["foreground"]),
|
|
248
|
+
)
|
|
249
|
+
self.update(body)
|
|
250
|
+
self.display = True
|
|
251
|
+
if self._hide_timer is not None:
|
|
252
|
+
self._hide_timer.stop()
|
|
253
|
+
self._hide_timer = self.set_timer(seconds, self.hide)
|
|
254
|
+
|
|
255
|
+
def hide(self) -> None:
|
|
256
|
+
self.display = False
|
|
257
|
+
self.title = ""
|
|
258
|
+
self.hint = DEFAULT_HINT
|
|
259
|
+
if self._hide_timer is not None:
|
|
260
|
+
self._hide_timer.stop()
|
|
261
|
+
self._hide_timer = None
|
|
262
|
+
|
|
263
|
+
@staticmethod
|
|
264
|
+
def _hint_markup(hint: str) -> str:
|
|
265
|
+
"""Emphasize the first ctrl+key chord in the hint line."""
|
|
266
|
+
lowered = hint.lower()
|
|
267
|
+
marker = "ctrl+"
|
|
268
|
+
index = lowered.find(marker)
|
|
269
|
+
if index < 0:
|
|
270
|
+
return hint
|
|
271
|
+
end = index + len(marker)
|
|
272
|
+
while end < len(hint) and hint[end].isalnum():
|
|
273
|
+
end += 1
|
|
274
|
+
return f"{hint[:index]}[b]{hint[index:end]}[/b]{hint[end:]}"
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Top bar: branch on the left, lab mark and model on the right.
|
|
2
|
+
|
|
3
|
+
The mark is a single colored glyph. A terminal image widget was tried for the
|
|
4
|
+
real SVG and rejected: assigning its image refreshes layout, and doing that
|
|
5
|
+
from resize made the whole UI relayout forever, which froze scrolling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
from rich.cells import cell_len
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
from textual import events
|
|
18
|
+
from textual.widgets import Static
|
|
19
|
+
|
|
20
|
+
from coding_agent.config.personalities import project_root
|
|
21
|
+
from coding_agent.tui.theme import SYMPHONY_COLORS
|
|
22
|
+
|
|
23
|
+
CLUSTER_GAP = " " * 4
|
|
24
|
+
BRANCH_ICON = "⎇"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def display_workspace_path(workspace: Path, home: Optional[Path] = None) -> str:
|
|
28
|
+
"""Render a workspace path with the home directory collapsed to `~`."""
|
|
29
|
+
home = (home or Path.home()).resolve()
|
|
30
|
+
workspace = workspace.resolve()
|
|
31
|
+
if workspace == home:
|
|
32
|
+
return "~"
|
|
33
|
+
try:
|
|
34
|
+
relative = workspace.relative_to(home)
|
|
35
|
+
except ValueError:
|
|
36
|
+
return str(workspace)
|
|
37
|
+
return f"~/{relative.as_posix()}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def read_git_branch(workspace: Path) -> str:
|
|
41
|
+
"""Return the checked-out branch for the repository containing `workspace`."""
|
|
42
|
+
git_dir = _locate_git_dir(workspace.resolve())
|
|
43
|
+
if git_dir is None:
|
|
44
|
+
return ""
|
|
45
|
+
try:
|
|
46
|
+
head = (git_dir / "HEAD").read_text(encoding="utf-8").strip()
|
|
47
|
+
except OSError:
|
|
48
|
+
return ""
|
|
49
|
+
if head.startswith("ref:"):
|
|
50
|
+
return head.removeprefix("ref:").strip().removeprefix("refs/heads/")
|
|
51
|
+
return head[:7]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def read_git_dirty(workspace: Path) -> bool:
|
|
55
|
+
"""True when the worktree has uncommitted changes. Never raises.
|
|
56
|
+
|
|
57
|
+
``git status`` walks the worktree, so callers on the UI thread should not
|
|
58
|
+
run this on a short interval. The top bar probes it from a thread worker.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
probe = subprocess.run(
|
|
62
|
+
["git", "-C", str(workspace), "status", "--porcelain", "--untracked-files=no"],
|
|
63
|
+
capture_output=True,
|
|
64
|
+
text=True,
|
|
65
|
+
timeout=1.5,
|
|
66
|
+
check=False,
|
|
67
|
+
)
|
|
68
|
+
except (OSError, subprocess.SubprocessError):
|
|
69
|
+
return False
|
|
70
|
+
return bool(probe.stdout.strip())
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def short_model_name(model: str) -> str:
|
|
74
|
+
"""Drop the provider prefix so the pill reads like a Claude status line."""
|
|
75
|
+
name = model.split(":", 1)[-1] if model else ""
|
|
76
|
+
return name or model
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _locate_git_dir(start: Path) -> Optional[Path]:
|
|
80
|
+
root = project_root(start)
|
|
81
|
+
if root is None:
|
|
82
|
+
return None
|
|
83
|
+
marker = root / ".git"
|
|
84
|
+
if marker.is_dir():
|
|
85
|
+
return marker
|
|
86
|
+
if not marker.is_file():
|
|
87
|
+
return None
|
|
88
|
+
try:
|
|
89
|
+
pointer = marker.read_text(encoding="utf-8").strip()
|
|
90
|
+
except OSError:
|
|
91
|
+
return None
|
|
92
|
+
if pointer.startswith("gitdir:"):
|
|
93
|
+
target = Path(pointer.removeprefix("gitdir:").strip())
|
|
94
|
+
return target if target.is_absolute() else root / target
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def topbar_text(*, branch: str = "", model: str = "") -> Text:
|
|
99
|
+
"""Render branch and model."""
|
|
100
|
+
clusters: list[str] = []
|
|
101
|
+
if branch:
|
|
102
|
+
clusters.append(f"{BRANCH_ICON} {branch}")
|
|
103
|
+
if model:
|
|
104
|
+
clusters.append(short_model_name(model))
|
|
105
|
+
return Text(CLUSTER_GAP.join(clusters), no_wrap=True)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class TopBar(Static):
|
|
109
|
+
"""Header with the branch on the left and the lab mark plus model on the right."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
112
|
+
self._branch = ""
|
|
113
|
+
self._dirty = False
|
|
114
|
+
self._model = ""
|
|
115
|
+
self._label = ""
|
|
116
|
+
self._auth = ""
|
|
117
|
+
self._workspace: Optional[Path] = None
|
|
118
|
+
self._branch_timer = None
|
|
119
|
+
self._dirty_probe = False
|
|
120
|
+
super().__init__(*args, **kwargs)
|
|
121
|
+
|
|
122
|
+
def on_mount(self) -> None:
|
|
123
|
+
"""Poll Git's HEAD so checkouts made outside the TUI appear promptly.
|
|
124
|
+
|
|
125
|
+
The branch itself is a tiny HEAD read. The dirty mark needs
|
|
126
|
+
``git status``, which is slow enough to hitch the UI, so that probe
|
|
127
|
+
runs on a thread and only every couple of seconds.
|
|
128
|
+
"""
|
|
129
|
+
self._branch_timer = self.set_interval(0.5, self._refresh_branch)
|
|
130
|
+
self._dirty_timer = self.set_interval(2.0, self._schedule_dirty_probe)
|
|
131
|
+
|
|
132
|
+
def on_unmount(self) -> None:
|
|
133
|
+
if self._branch_timer is not None:
|
|
134
|
+
self._branch_timer.stop()
|
|
135
|
+
self._branch_timer = None
|
|
136
|
+
dirty_timer = getattr(self, "_dirty_timer", None)
|
|
137
|
+
if dirty_timer is not None:
|
|
138
|
+
dirty_timer.stop()
|
|
139
|
+
self._dirty_timer = None
|
|
140
|
+
|
|
141
|
+
def _refresh_branch(self) -> None:
|
|
142
|
+
if self._workspace is None:
|
|
143
|
+
return
|
|
144
|
+
branch = read_git_branch(self._workspace)
|
|
145
|
+
if branch == self._branch:
|
|
146
|
+
return
|
|
147
|
+
self._branch = branch
|
|
148
|
+
if not branch:
|
|
149
|
+
self._dirty = False
|
|
150
|
+
self.update(self._render_row(max(self.content_size.width, 1)))
|
|
151
|
+
|
|
152
|
+
def _schedule_dirty_probe(self) -> None:
|
|
153
|
+
"""Start one background dirty check. Skip when the last one is running."""
|
|
154
|
+
if self._workspace is None or not self._branch or self._dirty_probe:
|
|
155
|
+
return
|
|
156
|
+
self._dirty_probe = True
|
|
157
|
+
self.run_worker(
|
|
158
|
+
self._probe_dirty,
|
|
159
|
+
name="git-dirty",
|
|
160
|
+
group="git-dirty",
|
|
161
|
+
exclusive=True,
|
|
162
|
+
thread=True,
|
|
163
|
+
exit_on_error=False,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def _probe_dirty(self) -> None:
|
|
167
|
+
workspace = self._workspace
|
|
168
|
+
dirty = read_git_dirty(workspace) if workspace is not None else False
|
|
169
|
+
self.app.call_from_thread(self._apply_dirty, dirty)
|
|
170
|
+
|
|
171
|
+
def _apply_dirty(self, dirty: bool) -> None:
|
|
172
|
+
self._dirty_probe = False
|
|
173
|
+
if dirty == self._dirty:
|
|
174
|
+
return
|
|
175
|
+
self._dirty = dirty
|
|
176
|
+
self.update(self._render_row(max(self.content_size.width, 1)))
|
|
177
|
+
|
|
178
|
+
def set_context(
|
|
179
|
+
self,
|
|
180
|
+
workspace: Path,
|
|
181
|
+
model: str = "",
|
|
182
|
+
*,
|
|
183
|
+
label: str = "",
|
|
184
|
+
auth: str = "",
|
|
185
|
+
) -> None:
|
|
186
|
+
self._workspace = workspace
|
|
187
|
+
branch = read_git_branch(workspace)
|
|
188
|
+
model = model or ""
|
|
189
|
+
if (
|
|
190
|
+
branch == self._branch
|
|
191
|
+
and model == self._model
|
|
192
|
+
and label == self._label
|
|
193
|
+
and auth == self._auth
|
|
194
|
+
):
|
|
195
|
+
return
|
|
196
|
+
self._branch = branch
|
|
197
|
+
self._model = model
|
|
198
|
+
self._label = label
|
|
199
|
+
self._auth = auth
|
|
200
|
+
self.update(self._render_row(max(self.content_size.width, 1)))
|
|
201
|
+
|
|
202
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
203
|
+
"""Rebudget the model whenever the terminal changes width.
|
|
204
|
+
|
|
205
|
+
This only replaces renderable text. It must not refresh layout: a
|
|
206
|
+
layout refresh from resize re-enters this handler and freezes scroll.
|
|
207
|
+
"""
|
|
208
|
+
content_width = max(event.size.width - self.styles.gutter.width, 1)
|
|
209
|
+
self.update(self._render_row(content_width))
|
|
210
|
+
|
|
211
|
+
def _branch_text(self) -> Text:
|
|
212
|
+
branch_label = self._branch
|
|
213
|
+
if self._label:
|
|
214
|
+
branch_label = (
|
|
215
|
+
f"{branch_label} › {self._label}" if branch_label else self._label
|
|
216
|
+
)
|
|
217
|
+
left = Text(no_wrap=True, overflow="ellipsis")
|
|
218
|
+
if branch_label:
|
|
219
|
+
left.append(f"{BRANCH_ICON} ", style=f"bold {SYMPHONY_COLORS['number']}")
|
|
220
|
+
left.append(branch_label, style=SYMPHONY_COLORS["foreground"])
|
|
221
|
+
if self._dirty:
|
|
222
|
+
left.append(" *", style=f"bold {SYMPHONY_COLORS['number']}")
|
|
223
|
+
return left
|
|
224
|
+
|
|
225
|
+
def _model_text(self, width: int) -> Text:
|
|
226
|
+
model = self._model
|
|
227
|
+
if width >= 48:
|
|
228
|
+
model = short_model_name(model)
|
|
229
|
+
if not model:
|
|
230
|
+
return Text()
|
|
231
|
+
return Text(model, style=f"bold {SYMPHONY_COLORS['accent']}", no_wrap=True, overflow="ellipsis")
|
|
232
|
+
|
|
233
|
+
def on_click(self, event: events.Click) -> None:
|
|
234
|
+
"""The model name is the right-hand cluster. Clicking it opens /model.
|
|
235
|
+
|
|
236
|
+
The branch occupies the left of the row, so a click past the midpoint
|
|
237
|
+
is the model. Opening the existing picker avoids a second modal.
|
|
238
|
+
"""
|
|
239
|
+
if not self._model or event.x < max(self.content_size.width // 2, 1):
|
|
240
|
+
return
|
|
241
|
+
event.stop()
|
|
242
|
+
show = getattr(self.app, "show_model_picker", None)
|
|
243
|
+
if callable(show):
|
|
244
|
+
show()
|
|
245
|
+
|
|
246
|
+
def _render_row(self, width: int) -> Table:
|
|
247
|
+
"""Text row. Width budgets keep a narrow terminal from cropping the header."""
|
|
248
|
+
row = Table.grid(expand=True, padding=0)
|
|
249
|
+
model = self._model_text(width)
|
|
250
|
+
model_width = min(max(cell_len(model.plain), 1), max(width // 2, 1))
|
|
251
|
+
row.add_column(ratio=1, overflow="ellipsis", no_wrap=True)
|
|
252
|
+
row.add_column(
|
|
253
|
+
justify="right",
|
|
254
|
+
overflow="ellipsis",
|
|
255
|
+
no_wrap=True,
|
|
256
|
+
max_width=model_width,
|
|
257
|
+
)
|
|
258
|
+
row.add_row(self._branch_text(), model)
|
|
259
|
+
return row
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Slash-command catalogs and handlers for the coding-agent TUI."""
|
|
2
|
+
|
|
3
|
+
from coding_agent.commands.catalog import (
|
|
4
|
+
EFFORT_CATALOG,
|
|
5
|
+
EFFORTS,
|
|
6
|
+
SLASH_COMMANDS,
|
|
7
|
+
EffortOption,
|
|
8
|
+
ModelOption,
|
|
9
|
+
PersonalityOption,
|
|
10
|
+
SlashCommand,
|
|
11
|
+
command_matches,
|
|
12
|
+
find_model,
|
|
13
|
+
find_personality,
|
|
14
|
+
model_matches,
|
|
15
|
+
model_options,
|
|
16
|
+
personality_matches,
|
|
17
|
+
personality_options,
|
|
18
|
+
)
|
|
19
|
+
from coding_agent.tui.commands.manager import (
|
|
20
|
+
CommandManager,
|
|
21
|
+
effort_matches,
|
|
22
|
+
effort_options_for_model,
|
|
23
|
+
model_supports_effort,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"CommandManager",
|
|
28
|
+
"EFFORT_CATALOG",
|
|
29
|
+
"EFFORTS",
|
|
30
|
+
"EffortOption",
|
|
31
|
+
"model_options",
|
|
32
|
+
"ModelOption",
|
|
33
|
+
"PersonalityOption",
|
|
34
|
+
"SLASH_COMMANDS",
|
|
35
|
+
"SlashCommand",
|
|
36
|
+
"command_matches",
|
|
37
|
+
"effort_matches",
|
|
38
|
+
"effort_options_for_model",
|
|
39
|
+
"find_model",
|
|
40
|
+
"find_personality",
|
|
41
|
+
"model_matches",
|
|
42
|
+
"model_supports_effort",
|
|
43
|
+
"personality_matches",
|
|
44
|
+
"personality_options",
|
|
45
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Moved to :mod:`coding_agent.commands.catalog`; this path re-exports it for one release."""
|
|
2
|
+
|
|
3
|
+
from coding_agent.commands.catalog import (
|
|
4
|
+
EFFORT_CATALOG,
|
|
5
|
+
EFFORTS,
|
|
6
|
+
SLASH_COMMANDS,
|
|
7
|
+
EffortOption,
|
|
8
|
+
ModelOption,
|
|
9
|
+
PersonalityOption,
|
|
10
|
+
SlashCommand,
|
|
11
|
+
command_matches,
|
|
12
|
+
find_model,
|
|
13
|
+
find_personality,
|
|
14
|
+
model_matches,
|
|
15
|
+
model_options,
|
|
16
|
+
personality_matches,
|
|
17
|
+
personality_options,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"EFFORT_CATALOG",
|
|
22
|
+
"EFFORTS",
|
|
23
|
+
"SLASH_COMMANDS",
|
|
24
|
+
"EffortOption",
|
|
25
|
+
"ModelOption",
|
|
26
|
+
"PersonalityOption",
|
|
27
|
+
"SlashCommand",
|
|
28
|
+
"command_matches",
|
|
29
|
+
"find_model",
|
|
30
|
+
"find_personality",
|
|
31
|
+
"model_matches",
|
|
32
|
+
"model_options",
|
|
33
|
+
"personality_matches",
|
|
34
|
+
"personality_options",
|
|
35
|
+
]
|