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.
Files changed (169) hide show
  1. coding_agent/__init__.py +20 -0
  2. coding_agent/_lazy.py +17 -0
  3. coding_agent/addons/__init__.py +35 -0
  4. coding_agent/addons/approvals.py +224 -0
  5. coding_agent/addons/compaction/__init__.py +36 -0
  6. coding_agent/addons/compaction/addon.py +88 -0
  7. coding_agent/addons/compaction/compactor.py +138 -0
  8. coding_agent/addons/compaction/prompts.py +20 -0
  9. coding_agent/addons/compaction/transcript.py +118 -0
  10. coding_agent/addons/evaluation/__init__.py +68 -0
  11. coding_agent/addons/evaluation/addon.py +199 -0
  12. coding_agent/addons/evaluation/evaluators.py +245 -0
  13. coding_agent/addons/evaluation/handlers.py +66 -0
  14. coding_agent/addons/evaluation/policy.py +277 -0
  15. coding_agent/addons/evaluation/protocol.py +192 -0
  16. coding_agent/addons/evaluation/state.py +284 -0
  17. coding_agent/addons/langfuse/__init__.py +24 -0
  18. coding_agent/addons/langfuse/addon.py +465 -0
  19. coding_agent/addons/langfuse/install.py +79 -0
  20. coding_agent/addons/langfuse/serialize.py +158 -0
  21. coding_agent/addons/learning/__init__.py +33 -0
  22. coding_agent/addons/learning/addon.py +181 -0
  23. coding_agent/addons/learning/loop.py +247 -0
  24. coding_agent/addons/learning/prompts.py +26 -0
  25. coding_agent/addons/learning/sanitize.py +64 -0
  26. coding_agent/addons/learning/store.py +522 -0
  27. coding_agent/addons/manager/__init__.py +18 -0
  28. coding_agent/addons/manager/bridge.py +74 -0
  29. coding_agent/addons/manager/discovery.py +34 -0
  30. coding_agent/addons/manager/report.py +33 -0
  31. coding_agent/addons/manager/runtime.py +76 -0
  32. coding_agent/addons/manager/tool_changes.py +16 -0
  33. coding_agent/addons/persistence/__init__.py +41 -0
  34. coding_agent/addons/persistence/active.py +132 -0
  35. coding_agent/addons/persistence/artifacts.py +110 -0
  36. coding_agent/addons/persistence/collection.py +166 -0
  37. coding_agent/addons/persistence/compatibility.py +30 -0
  38. coding_agent/addons/persistence/images.py +133 -0
  39. coding_agent/addons/persistence/jsonl.py +1195 -0
  40. coding_agent/addons/persistence/presentation.py +104 -0
  41. coding_agent/addons/plugins/__init__.py +19 -0
  42. coding_agent/addons/plugins/install.py +37 -0
  43. coding_agent/addons/plugins/manager.py +140 -0
  44. coding_agent/addons/plugins/models.py +50 -0
  45. coding_agent/addons/skills/__init__.py +28 -0
  46. coding_agent/addons/skills/addon.py +26 -0
  47. coding_agent/addons/skills/args.py +78 -0
  48. coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
  49. coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
  50. coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
  51. coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
  52. coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
  53. coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
  54. coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
  55. coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
  56. coding_agent/addons/skills/frontmatter.py +57 -0
  57. coding_agent/addons/skills/models.py +57 -0
  58. coding_agent/addons/skills/registry.py +109 -0
  59. coding_agent/addons/skills/tool.py +54 -0
  60. coding_agent/addons/voice/__init__.py +5 -0
  61. coding_agent/addons/voice/addon.py +106 -0
  62. coding_agent/addons/voice/credentials.py +42 -0
  63. coding_agent/addons/voice/playback.py +42 -0
  64. coding_agent/addons/voice/speak.py +287 -0
  65. coding_agent/addons/voice/stream.py +651 -0
  66. coding_agent/addons/voice/summary.py +126 -0
  67. coding_agent/agent.py +523 -0
  68. coding_agent/bench/__init__.py +1 -0
  69. coding_agent/bench/cli.py +200 -0
  70. coding_agent/bench/patch.py +78 -0
  71. coding_agent/commands/__init__.py +5 -0
  72. coding_agent/commands/catalog.py +179 -0
  73. coding_agent/commands/jev_argument.py +40 -0
  74. coding_agent/config/__init__.py +51 -0
  75. coding_agent/config/config.py +376 -0
  76. coding_agent/config/credentials.py +153 -0
  77. coding_agent/config/personalities.json +34 -0
  78. coding_agent/config/personalities.py +170 -0
  79. coding_agent/config/prompts.py +67 -0
  80. coding_agent/protocols/__init__.py +5 -0
  81. coding_agent/protocols/acp/README.md +36 -0
  82. coding_agent/protocols/acp/__init__.py +27 -0
  83. coding_agent/protocols/acp/mapping.py +164 -0
  84. coding_agent/protocols/acp/server.py +261 -0
  85. coding_agent/protocols/acp/transport.py +72 -0
  86. coding_agent/protocols/stdio/README.md +36 -0
  87. coding_agent/protocols/stdio/__init__.py +37 -0
  88. coding_agent/protocols/stdio/catalog.py +49 -0
  89. coding_agent/protocols/stdio/commands.py +218 -0
  90. coding_agent/protocols/stdio/server.py +181 -0
  91. coding_agent/protocols/stdio/transport.py +71 -0
  92. coding_agent/py.typed +0 -0
  93. coding_agent/resources/__init__.py +5 -0
  94. coding_agent/resources/usage.py +264 -0
  95. coding_agent/run/__init__.py +1 -0
  96. coding_agent/run/cli.py +99 -0
  97. coding_agent/run/detach.py +37 -0
  98. coding_agent/run/interrupted.py +67 -0
  99. coding_agent/run/log_sink.py +46 -0
  100. coding_agent/tools/__init__.py +73 -0
  101. coding_agent/tools/ask_user.py +61 -0
  102. coding_agent/tools/base.py +136 -0
  103. coding_agent/tools/bash.py +288 -0
  104. coding_agent/tools/bash_jobs.py +116 -0
  105. coding_agent/tools/generate_image.py +123 -0
  106. coding_agent/tools/memory.py +100 -0
  107. coding_agent/tools/patch.py +218 -0
  108. coding_agent/tools/read_file.py +167 -0
  109. coding_agent/tools/search.py +433 -0
  110. coding_agent/tools/web_search.py +151 -0
  111. coding_agent/tools/write_file.py +40 -0
  112. coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
  113. coding_agent/tui/__init__.py +22 -0
  114. coding_agent/tui/__main__.py +122 -0
  115. coding_agent/tui/app.py +456 -0
  116. coding_agent/tui/chrome/__init__.py +33 -0
  117. coding_agent/tui/chrome/footer.py +274 -0
  118. coding_agent/tui/chrome/topbar.py +259 -0
  119. coding_agent/tui/commands/__init__.py +45 -0
  120. coding_agent/tui/commands/catalog.py +35 -0
  121. coding_agent/tui/commands/manager.py +557 -0
  122. coding_agent/tui/composer/__init__.py +18 -0
  123. coding_agent/tui/composer/input.py +219 -0
  124. coding_agent/tui/composer/slash_menu.py +341 -0
  125. coding_agent/tui/composer/surface.py +330 -0
  126. coding_agent/tui/composer/voice.py +267 -0
  127. coding_agent/tui/composer/voice_mode.py +491 -0
  128. coding_agent/tui/runtime/__init__.py +23 -0
  129. coding_agent/tui/runtime/events.py +728 -0
  130. coding_agent/tui/runtime/sink.py +115 -0
  131. coding_agent/tui/runtime/state.py +145 -0
  132. coding_agent/tui/runtime/subagent.py +531 -0
  133. coding_agent/tui/runtime/turn.py +206 -0
  134. coding_agent/tui/screens/__init__.py +54 -0
  135. coding_agent/tui/screens/ask.py +69 -0
  136. coding_agent/tui/screens/context.py +195 -0
  137. coding_agent/tui/screens/dashboard.py +91 -0
  138. coding_agent/tui/screens/diff.py +302 -0
  139. coding_agent/tui/screens/extensions.py +68 -0
  140. coding_agent/tui/screens/file_selector.py +167 -0
  141. coding_agent/tui/screens/history.py +458 -0
  142. coding_agent/tui/screens/langfuse.py +142 -0
  143. coding_agent/tui/screens/learning.py +113 -0
  144. coding_agent/tui/screens/modal.py +131 -0
  145. coding_agent/tui/screens/onboard.py +455 -0
  146. coding_agent/tui/screens/resume.py +163 -0
  147. coding_agent/tui/theme/__init__.py +49 -0
  148. coding_agent/tui/theme/load.py +325 -0
  149. coding_agent/tui/theme/style.py +95 -0
  150. coding_agent/tui/theme/theme.toml +1687 -0
  151. coding_agent/tui/tools/__init__.py +53 -0
  152. coding_agent/tui/tools/activity.py +184 -0
  153. coding_agent/tui/tools/calls.py +560 -0
  154. coding_agent/tui/tools/code_view.py +52 -0
  155. coding_agent/tui/tools/images.py +250 -0
  156. coding_agent/tui/tools/labels.py +135 -0
  157. coding_agent/tui/tools/modal.py +137 -0
  158. coding_agent/tui/tools/partial_args.py +19 -0
  159. coding_agent/tui/tools/snapshots.py +382 -0
  160. coding_agent/tui/transcript/__init__.py +36 -0
  161. coding_agent/tui/transcript/messages.py +408 -0
  162. coding_agent/tui/transcript/process.py +504 -0
  163. coding_agent/tui/transcript/surface.py +345 -0
  164. coding_agent/tui/transcript/thinking_markdown.py +314 -0
  165. symphony_code-0.0.1.dist-info/METADATA +66 -0
  166. symphony_code-0.0.1.dist-info/RECORD +169 -0
  167. symphony_code-0.0.1.dist-info/WHEEL +4 -0
  168. symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
  169. 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
+ ]