synapse-cli-agent 0.1.13__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 (131) hide show
  1. synapse/__init__.py +13 -0
  2. synapse/__main__.py +6 -0
  3. synapse/app/__init__.py +1 -0
  4. synapse/app/agent.py +492 -0
  5. synapse/app/agent_md.py +107 -0
  6. synapse/cli.py +750 -0
  7. synapse/commands/__init__.py +1 -0
  8. synapse/commands/compression.py +573 -0
  9. synapse/commands/helpers.py +22 -0
  10. synapse/commands/mcp.py +406 -0
  11. synapse/commands/model.py +173 -0
  12. synapse/commands/result.py +34 -0
  13. synapse/commands/sessions.py +443 -0
  14. synapse/commands/slash_cmds.py +521 -0
  15. synapse/commands/slash_complete.py +816 -0
  16. synapse/commands/theme.py +99 -0
  17. synapse/config.py +27 -0
  18. synapse/content/__init__.py +1 -0
  19. synapse/content/input_history.py +122 -0
  20. synapse/content/multimodal.py +733 -0
  21. synapse/content/prompts.py +249 -0
  22. synapse/content/skills_catalog.py +128 -0
  23. synapse/integrations/__init__.py +1 -0
  24. synapse/integrations/checkpoint_seed.py +281 -0
  25. synapse/integrations/codex_history.py +375 -0
  26. synapse/integrations/codex_import.py +393 -0
  27. synapse/integrations/codex_sessions.py +629 -0
  28. synapse/integrations/describe_image.py +370 -0
  29. synapse/integrations/http_clients.py +199 -0
  30. synapse/integrations/llm_openai_compat.py +90 -0
  31. synapse/integrations/llm_openai_websocket.py +187 -0
  32. synapse/integrations/mcp_client.py +646 -0
  33. synapse/integrations/vision_middleware.py +62 -0
  34. synapse/models/__init__.py +5 -0
  35. synapse/models/config.py +240 -0
  36. synapse/models/helpers.py +206 -0
  37. synapse/models/profile.py +59 -0
  38. synapse/models/registry.py +722 -0
  39. synapse/models_registry.py +7 -0
  40. synapse/observability/__init__.py +1 -0
  41. synapse/observability/startup_trace.py +127 -0
  42. synapse/runtime/__init__.py +1 -0
  43. synapse/runtime/async_runtime.py +176 -0
  44. synapse/runtime/backends.py +458 -0
  45. synapse/runtime/context_compact.py +249 -0
  46. synapse/runtime/execute_capture.py +48 -0
  47. synapse/runtime/fs_permissions.py +79 -0
  48. synapse/runtime/harness.py +57 -0
  49. synapse/runtime/hitl.py +197 -0
  50. synapse/runtime/interaction_ledger.py +82 -0
  51. synapse/runtime/middleware.py +802 -0
  52. synapse/runtime/model_request_compression_middleware.py +745 -0
  53. synapse/runtime/pathing.py +146 -0
  54. synapse/runtime/safety.py +184 -0
  55. synapse/runtime/steer.py +240 -0
  56. synapse/runtime/subagents.py +207 -0
  57. synapse/runtime/tool_ignore.py +221 -0
  58. synapse/runtime/tool_output_eval.py +118 -0
  59. synapse/runtime/tool_output_middleware.py +585 -0
  60. synapse/runtime/tool_output_usage_middleware.py +60 -0
  61. synapse/sessions/__init__.py +31 -0
  62. synapse/sessions/cancel_repair.py +208 -0
  63. synapse/sessions/session_recap.py +174 -0
  64. synapse/sessions/store.py +695 -0
  65. synapse/sessions/transcript.py +754 -0
  66. synapse/settings/__init__.py +5 -0
  67. synapse/settings/config_paths.py +184 -0
  68. synapse/settings/schema.py +464 -0
  69. synapse/tool_output/__init__.py +59 -0
  70. synapse/tool_output/detection.py +170 -0
  71. synapse/tool_output/metrics.py +32 -0
  72. synapse/tool_output/models.py +173 -0
  73. synapse/tool_output/pipeline.py +330 -0
  74. synapse/tool_output/repository.py +721 -0
  75. synapse/tool_output/transformers.py +648 -0
  76. synapse/tools/__init__.py +5 -0
  77. synapse/tools/session_tools.py +204 -0
  78. synapse/ui/__init__.py +10 -0
  79. synapse/ui/bottombar/__init__.py +73 -0
  80. synapse/ui/bottombar/components/__init__.py +143 -0
  81. synapse/ui/bottombar/components/key_hints.py +30 -0
  82. synapse/ui/bottombar/components/mcp.py +64 -0
  83. synapse/ui/bottombar/components/mode.py +24 -0
  84. synapse/ui/bottombar/components/model.py +28 -0
  85. synapse/ui/bottombar/components/thread.py +29 -0
  86. synapse/ui/bottombar/context.py +36 -0
  87. synapse/ui/bottombar/core.py +74 -0
  88. synapse/ui/dialogs/__init__.py +25 -0
  89. synapse/ui/dialogs/base.py +362 -0
  90. synapse/ui/dialogs/codex_session_list.py +84 -0
  91. synapse/ui/dialogs/compression_diagnostics.py +210 -0
  92. synapse/ui/dialogs/git_explore.py +702 -0
  93. synapse/ui/dialogs/mcp_panel.py +407 -0
  94. synapse/ui/dialogs/model_picker.py +128 -0
  95. synapse/ui/dialogs/safety_panel.py +63 -0
  96. synapse/ui/dialogs/session_list.py +98 -0
  97. synapse/ui/dialogs/theme_designer.py +863 -0
  98. synapse/ui/dialogs/theme_picker.py +113 -0
  99. synapse/ui/git_explore/__init__.py +31 -0
  100. synapse/ui/git_explore/engine.py +82 -0
  101. synapse/ui/git_explore/provider.py +242 -0
  102. synapse/ui/git_explore/unified.py +85 -0
  103. synapse/ui/rendering.py +350 -0
  104. synapse/ui/sink.py +70 -0
  105. synapse/ui/steer_widget.py +367 -0
  106. synapse/ui/stream.py +1207 -0
  107. synapse/ui/stream_events.py +421 -0
  108. synapse/ui/stream_runtime.py +252 -0
  109. synapse/ui/theme.py +1154 -0
  110. synapse/ui/timeline.py +621 -0
  111. synapse/ui/topbar/__init__.py +97 -0
  112. synapse/ui/topbar/components/__init__.py +150 -0
  113. synapse/ui/topbar/components/branch.py +41 -0
  114. synapse/ui/topbar/components/title.py +24 -0
  115. synapse/ui/topbar/components/tool_output.py +24 -0
  116. synapse/ui/topbar/components/usage.py +24 -0
  117. synapse/ui/topbar/components/workspace.py +32 -0
  118. synapse/ui/topbar/context.py +32 -0
  119. synapse/ui/topbar/core.py +979 -0
  120. synapse/ui/topbar/git_changes_popover.py +178 -0
  121. synapse/ui/topbar/git_chrome.py +475 -0
  122. synapse/ui/topbar/tool_output_popover.py +84 -0
  123. synapse/ui/topbar/widget.py +474 -0
  124. synapse/ui/tui.py +5717 -0
  125. synapse/ui/turn_rail.py +71 -0
  126. synapse/ui/user_turn.py +83 -0
  127. synapse/ui/welcome.py +261 -0
  128. synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
  129. synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
  130. synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
  131. synapse_cli_agent-0.1.13.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,979 @@
1
+ """Extensible topbar: freeform regions + components.
2
+
3
+ Regions are first-class layout slots (not limited to left/center/right).
4
+ Each region can configure:
5
+
6
+ - horizontal order
7
+ - width (fixed cells, hug content, or flex fill)
8
+ - min/max width
9
+ - align (left / center / right)
10
+ - fg / bg styles
11
+ - gap after the region
12
+ - shrink priority
13
+
14
+ Components render text into a named region and are ordered by ``order``.
15
+
16
+ Default chrome still installs three regions (``left`` / ``center`` / ``right``)
17
+ with historical behavior, painted into a single-line Textual ``Static``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import Callable, Iterable
23
+ from dataclasses import dataclass, field
24
+ from dataclasses import replace as dc_replace
25
+ from enum import StrEnum
26
+
27
+ from rich.text import Text
28
+
29
+ # Default join between sibling components in the same region.
30
+ DEFAULT_REGION_GAP = " · "
31
+ # Spaces after a default region when the next region is present.
32
+ # Theme ``top_gap`` overrides this at runtime for the live TUI topbar.
33
+ DEFAULT_COL_GAP = 3
34
+
35
+
36
+ class TopBarAlign(StrEnum):
37
+ LEFT = "left"
38
+ CENTER = "center"
39
+ RIGHT = "right"
40
+
41
+ @classmethod
42
+ def parse(cls, value: TopBarAlign | str | None) -> TopBarAlign:
43
+ if isinstance(value, cls):
44
+ return value
45
+ key = str(value or "left").strip().lower()
46
+ if key in {"middle", "mid"}:
47
+ key = "center"
48
+ try:
49
+ return cls(key)
50
+ except ValueError as exc:
51
+ allowed = ", ".join(a.value for a in cls)
52
+ raise ValueError(f"unknown topbar align {value!r}; expected one of: {allowed}") from exc
53
+
54
+
55
+ class TopBarRegion(StrEnum):
56
+ """Built-in region ids (custom region ids are plain strings)."""
57
+
58
+ LEFT = "left"
59
+ CENTER = "center"
60
+ RIGHT = "right"
61
+
62
+ @classmethod
63
+ def parse(cls, value: TopBarRegion | str) -> TopBarRegion:
64
+ """Parse a built-in region enum value (rejects freeform ids)."""
65
+ if isinstance(value, cls):
66
+ return value
67
+ key = str(value or "").strip().lower()
68
+ try:
69
+ return cls(key)
70
+ except ValueError as exc:
71
+ allowed = ", ".join(r.value for r in cls)
72
+ raise ValueError(
73
+ f"unknown topbar region {value!r}; expected one of: {allowed}"
74
+ ) from exc
75
+
76
+
77
+ def normalize_region_id(value: TopBarRegion | str | None) -> str:
78
+ """Accept built-in enum or any non-empty freeform region id."""
79
+ if isinstance(value, TopBarRegion):
80
+ return value.value
81
+ key = str(value or "").strip()
82
+ if not key:
83
+ raise ValueError("topbar region id must be non-empty")
84
+ return key
85
+
86
+
87
+ RenderFn = Callable[[], str | Text]
88
+
89
+
90
+ @dataclass(slots=True)
91
+ class TopBarRegionSpec:
92
+ """Layout + style configuration for one horizontal topbar region."""
93
+
94
+ id: str
95
+ order: int = 0
96
+ # None = hug content; int = preferred/fixed cell width (still clamped).
97
+ width: int | None = None
98
+ min_width: int = 0
99
+ max_width: int | None = None
100
+ # flex > 0 shares leftover row width (CSS flex-grow style).
101
+ flex: int = 0
102
+ align: TopBarAlign = TopBarAlign.LEFT
103
+ fg: str | None = None
104
+ bg: str | None = None
105
+ # Trailing spaces emitted after this region when a later region is shown.
106
+ gap_after: int = DEFAULT_COL_GAP
107
+ # Higher priority survives longer when the row overflows.
108
+ priority: int = 0
109
+ visible: bool = True
110
+
111
+ def style_string(self, *, fallback_fg: str | None = None) -> str | None:
112
+ fg = (self.fg if self.fg is not None else fallback_fg) or ""
113
+ bg = (self.bg or "").strip()
114
+ fg = fg.strip()
115
+ if fg and bg:
116
+ return f"{fg} on {bg}"
117
+ if bg:
118
+ return f"on {bg}"
119
+ return fg or None
120
+
121
+
122
+ @dataclass(slots=True)
123
+ class TopBarComponent:
124
+ """One renderable unit inside a named topbar region."""
125
+
126
+ id: str
127
+ region: str
128
+ render: RenderFn
129
+ order: int = 0
130
+ priority: int = 0
131
+ min_width: int = 0
132
+ gap_before: str = DEFAULT_REGION_GAP
133
+ style: str | None = None
134
+ visible: bool = True
135
+
136
+ def content(self) -> str | Text:
137
+ """Return render output as plain str or styled Rich Text."""
138
+ if not self.visible:
139
+ return ""
140
+ try:
141
+ raw = self.render()
142
+ except Exception: # noqa: BLE001
143
+ return ""
144
+ if isinstance(raw, Text):
145
+ return raw
146
+ return str(raw or "")
147
+
148
+ def text(self) -> str:
149
+ raw = self.content()
150
+ if isinstance(raw, Text):
151
+ return raw.plain
152
+ return raw
153
+
154
+
155
+ def _default_regions() -> dict[str, TopBarRegionSpec]:
156
+ # Classic chrome: left/right hug content, center flex-fills leftover.
157
+ # Theme ``top_gap`` may override gap_after at runtime.
158
+ return {
159
+ TopBarRegion.LEFT.value: TopBarRegionSpec(
160
+ id=TopBarRegion.LEFT.value,
161
+ order=10,
162
+ flex=0,
163
+ align=TopBarAlign.LEFT,
164
+ priority=40,
165
+ gap_after=DEFAULT_COL_GAP,
166
+ ),
167
+ TopBarRegion.CENTER.value: TopBarRegionSpec(
168
+ id=TopBarRegion.CENTER.value,
169
+ order=20,
170
+ flex=1,
171
+ align=TopBarAlign.CENTER,
172
+ priority=10,
173
+ min_width=4,
174
+ gap_after=DEFAULT_COL_GAP,
175
+ ),
176
+ TopBarRegion.RIGHT.value: TopBarRegionSpec(
177
+ id=TopBarRegion.RIGHT.value,
178
+ order=30,
179
+ flex=0,
180
+ align=TopBarAlign.RIGHT,
181
+ priority=50,
182
+ gap_after=0,
183
+ ),
184
+ }
185
+
186
+
187
+ @dataclass(slots=True)
188
+ class TopBarRegistry:
189
+ """Mutable registry of regions + components."""
190
+
191
+ _regions: dict[str, TopBarRegionSpec] = field(default_factory=_default_regions)
192
+ _items: dict[str, TopBarComponent] = field(default_factory=dict)
193
+
194
+ def register_region(
195
+ self,
196
+ spec: TopBarRegionSpec | str,
197
+ *,
198
+ order: int | None = None,
199
+ width: int | None = None,
200
+ min_width: int | None = None,
201
+ max_width: int | None = None,
202
+ flex: int | None = None,
203
+ align: TopBarAlign | str | None = None,
204
+ fg: str | None = None,
205
+ bg: str | None = None,
206
+ gap_after: int | None = None,
207
+ priority: int | None = None,
208
+ visible: bool | None = None,
209
+ replace: bool = True,
210
+ ) -> TopBarRegionSpec:
211
+ """Add or update a region slot."""
212
+ if isinstance(spec, TopBarRegionSpec):
213
+ region = spec
214
+ rid = normalize_region_id(region.id)
215
+ if rid != region.id:
216
+ region = dc_replace(region, id=rid)
217
+ else:
218
+ rid = normalize_region_id(spec)
219
+ region = self._regions.get(rid) or TopBarRegionSpec(id=rid, order=100)
220
+
221
+ updates: dict[str, object] = {}
222
+ if order is not None:
223
+ updates["order"] = int(order)
224
+ if width is not None:
225
+ updates["width"] = int(width)
226
+ if min_width is not None:
227
+ updates["min_width"] = max(0, int(min_width))
228
+ if max_width is not None:
229
+ updates["max_width"] = int(max_width) if max_width else None
230
+ if flex is not None:
231
+ updates["flex"] = max(0, int(flex))
232
+ if align is not None:
233
+ updates["align"] = TopBarAlign.parse(align)
234
+ if fg is not None:
235
+ updates["fg"] = fg
236
+ if bg is not None:
237
+ updates["bg"] = bg
238
+ if gap_after is not None:
239
+ updates["gap_after"] = max(0, int(gap_after))
240
+ if priority is not None:
241
+ updates["priority"] = int(priority)
242
+ if visible is not None:
243
+ updates["visible"] = bool(visible)
244
+
245
+ if updates:
246
+ region = dc_replace(region, **updates) # type: ignore[arg-type]
247
+ region = dc_replace(region, id=rid)
248
+
249
+ if not replace and rid in self._regions:
250
+ raise KeyError(f"topbar region already registered: {rid}")
251
+ self._regions[rid] = region
252
+ return region
253
+
254
+ def unregister_region(self, id: str, *, drop_components: bool = False) -> bool:
255
+ rid = str(id)
256
+ existed = self._regions.pop(rid, None) is not None
257
+ if drop_components:
258
+ for cid, comp in list(self._items.items()):
259
+ if comp.region == rid:
260
+ self._items.pop(cid, None)
261
+ return existed
262
+
263
+ def get_region(self, id: str) -> TopBarRegionSpec | None:
264
+ return self._regions.get(str(id))
265
+
266
+ def ensure_region(self, id: TopBarRegion | str) -> TopBarRegionSpec:
267
+ rid = normalize_region_id(id)
268
+ existing = self._regions.get(rid)
269
+ if existing is not None:
270
+ return existing
271
+ spec = TopBarRegionSpec(id=rid, order=100, flex=0, align=TopBarAlign.LEFT)
272
+ self._regions[rid] = spec
273
+ return spec
274
+
275
+ def regions(self, *, include_hidden: bool = False) -> list[TopBarRegionSpec]:
276
+ items = list(self._regions.values())
277
+ if not include_hidden:
278
+ items = [r for r in items if r.visible]
279
+ items.sort(key=lambda r: (r.order, r.id))
280
+ return items
281
+
282
+ def set_region_style(
283
+ self,
284
+ id: str,
285
+ *,
286
+ fg: str | None = None,
287
+ bg: str | None = None,
288
+ align: TopBarAlign | str | None = None,
289
+ width: int | None = None,
290
+ min_width: int | None = None,
291
+ max_width: int | None = None,
292
+ flex: int | None = None,
293
+ gap_after: int | None = None,
294
+ priority: int | None = None,
295
+ order: int | None = None,
296
+ visible: bool | None = None,
297
+ ) -> bool:
298
+ if self.get_region(id) is None:
299
+ return False
300
+ self.register_region(
301
+ id,
302
+ fg=fg,
303
+ bg=bg,
304
+ align=align,
305
+ width=width,
306
+ min_width=min_width,
307
+ max_width=max_width,
308
+ flex=flex,
309
+ gap_after=gap_after,
310
+ priority=priority,
311
+ order=order,
312
+ visible=visible,
313
+ replace=True,
314
+ )
315
+ return True
316
+
317
+ def register(
318
+ self,
319
+ component: TopBarComponent,
320
+ *,
321
+ replace: bool = True,
322
+ ) -> None:
323
+ cid = str(component.id or "").strip()
324
+ if not cid:
325
+ raise ValueError("topbar component id must be non-empty")
326
+ if not replace and cid in self._items:
327
+ raise KeyError(f"topbar component already registered: {cid}")
328
+ rid = normalize_region_id(component.region)
329
+ self.ensure_region(rid)
330
+ if component.id != cid or component.region != rid:
331
+ component = TopBarComponent(
332
+ id=cid,
333
+ region=rid,
334
+ render=component.render,
335
+ order=component.order,
336
+ priority=component.priority,
337
+ min_width=component.min_width,
338
+ gap_before=component.gap_before,
339
+ style=component.style,
340
+ visible=component.visible,
341
+ )
342
+ else:
343
+ component.region = rid
344
+ self._items[cid] = component
345
+
346
+ def register_fn(
347
+ self,
348
+ id: str,
349
+ render: RenderFn,
350
+ *,
351
+ region: TopBarRegion | str = TopBarRegion.RIGHT,
352
+ order: int = 100,
353
+ priority: int = 0,
354
+ min_width: int = 0,
355
+ gap_before: str = DEFAULT_REGION_GAP,
356
+ style: str | None = None,
357
+ visible: bool = True,
358
+ replace: bool = True,
359
+ ) -> TopBarComponent:
360
+ comp = TopBarComponent(
361
+ id=str(id),
362
+ region=normalize_region_id(region),
363
+ render=render,
364
+ order=int(order),
365
+ priority=int(priority),
366
+ min_width=max(0, int(min_width or 0)),
367
+ gap_before=str(gap_before),
368
+ style=style,
369
+ visible=bool(visible),
370
+ )
371
+ self.register(comp, replace=replace)
372
+ return comp
373
+
374
+ def unregister(self, id: str) -> bool:
375
+ return self._items.pop(str(id), None) is not None
376
+
377
+ def get(self, id: str) -> TopBarComponent | None:
378
+ return self._items.get(str(id))
379
+
380
+ def set_visible(self, id: str, visible: bool) -> bool:
381
+ comp = self.get(id)
382
+ if comp is None:
383
+ return False
384
+ comp.visible = bool(visible)
385
+ return True
386
+
387
+ def set_order(self, id: str, order: int) -> bool:
388
+ comp = self.get(id)
389
+ if comp is None:
390
+ return False
391
+ comp.order = int(order)
392
+ return True
393
+
394
+ def set_region(self, id: str, region: TopBarRegion | str) -> bool:
395
+ comp = self.get(id)
396
+ if comp is None:
397
+ return False
398
+ rid = normalize_region_id(region)
399
+ self.ensure_region(rid)
400
+ comp.region = rid
401
+ return True
402
+
403
+ def clear(self) -> None:
404
+ self._items.clear()
405
+ self._regions = _default_regions()
406
+
407
+ def components(
408
+ self,
409
+ region: TopBarRegion | str | None = None,
410
+ *,
411
+ include_hidden: bool = False,
412
+ ) -> list[TopBarComponent]:
413
+ items = list(self._items.values())
414
+ if region is not None:
415
+ rid = normalize_region_id(region)
416
+ items = [c for c in items if c.region == rid]
417
+ if not include_hidden:
418
+ items = [c for c in items if c.visible]
419
+ items.sort(key=lambda c: (c.order, c.id))
420
+ return items
421
+
422
+ def __len__(self) -> int:
423
+ return len(self._items)
424
+
425
+ def __contains__(self, id: object) -> bool:
426
+ return str(id) in self._items
427
+
428
+
429
+ def display_width(text: str) -> int:
430
+ """Terminal cell width (CJK / fullwidth / emoji count as 2)."""
431
+ total = 0
432
+ for ch in text or "":
433
+ o = ord(ch)
434
+ if (
435
+ 0x1100 <= o <= 0x115F
436
+ or 0x2E80 <= o <= 0xA4CF
437
+ or 0xAC00 <= o <= 0xD7A3
438
+ or 0xF900 <= o <= 0xFAFF
439
+ or 0xFE10 <= o <= 0xFE19
440
+ or 0xFE30 <= o <= 0xFE6F
441
+ or 0xFF00 <= o <= 0xFF60
442
+ or 0xFFE0 <= o <= 0xFFE6
443
+ or 0x1F300 <= o <= 0x1FAFF
444
+ ):
445
+ total += 2
446
+ else:
447
+ total += 1
448
+ return total
449
+
450
+
451
+ def truncate_to_width(text: str, max_w: int) -> str:
452
+ """Truncate ``text`` so its display width fits ``max_w`` cells."""
453
+ raw = text or ""
454
+ max_w = int(max_w or 0)
455
+ if max_w <= 0:
456
+ return ""
457
+ if display_width(raw) <= max_w:
458
+ return raw
459
+ if max_w == 1:
460
+ return "…"
461
+ out: list[str] = []
462
+ used = 0
463
+ limit = max_w - 1
464
+ for ch in raw:
465
+ cw = display_width(ch)
466
+ if used + cw > limit:
467
+ break
468
+ out.append(ch)
469
+ used += cw
470
+ return "".join(out) + "…"
471
+
472
+
473
+ def center_in_width(text: str, width: int) -> str:
474
+ """Pad ``text`` so it appears centered within ``width`` terminal cells."""
475
+ return align_in_width(text, width, TopBarAlign.CENTER)
476
+
477
+
478
+ def align_in_width(text: str, width: int, align: TopBarAlign | str) -> str:
479
+ """Pad/truncate ``text`` into ``width`` cells with the given alignment."""
480
+ align_e = TopBarAlign.parse(align)
481
+ width = max(0, int(width or 0))
482
+ if width <= 0:
483
+ return ""
484
+ body = truncate_to_width(text or "", width)
485
+ w = display_width(body)
486
+ if w >= width:
487
+ return body
488
+ pad = width - w
489
+ if align_e is TopBarAlign.RIGHT:
490
+ return (" " * pad) + body
491
+ if align_e is TopBarAlign.CENTER:
492
+ left = pad // 2
493
+ right = pad - left
494
+ return (" " * left) + body + (" " * right)
495
+ return body + (" " * pad)
496
+
497
+
498
+ def join_region_parts(parts: Iterable[tuple[str, str]]) -> str:
499
+ """Join ``(gap_before, text)`` pairs, skipping empty texts."""
500
+ chunks: list[str] = []
501
+ for gap, text in parts:
502
+ body = text or ""
503
+ if not body:
504
+ continue
505
+ if not chunks:
506
+ chunks.append(body)
507
+ else:
508
+ chunks.append(f"{gap}{body}")
509
+ return "".join(chunks)
510
+
511
+
512
+ def render_region_text(components: list[TopBarComponent]) -> str:
513
+ """Render ordered components in one region to a plain string."""
514
+ return join_region_parts((comp.gap_before, comp.text()) for comp in components)
515
+
516
+
517
+ def _compress_region_parts(components: list[TopBarComponent], budget: int) -> str:
518
+ """Fit region into ``budget`` cells by dropping/truncating low priority first."""
519
+ if budget <= 0:
520
+ return ""
521
+ snaps: list[tuple[str, str, int, int, int]] = []
522
+ for idx, comp in enumerate(components):
523
+ body = comp.text()
524
+ if not body:
525
+ continue
526
+ snaps.append(
527
+ (
528
+ comp.gap_before, body, int(comp.priority),
529
+ max(0, int(comp.min_width or 0)), idx,
530
+ )
531
+ )
532
+ if not snaps:
533
+ return ""
534
+
535
+ def joined(items: list[tuple[str, str, int, int, int]]) -> str:
536
+ return join_region_parts((g, t) for g, t, *_ in items)
537
+
538
+ text = joined(snaps)
539
+ if display_width(text) <= budget:
540
+ return text
541
+ working = list(snaps)
542
+ while len(working) > 1 and display_width(joined(working)) > budget:
543
+ drop_i = min(range(len(working)), key=lambda i: (working[i][2], -working[i][4]))
544
+ working.pop(drop_i)
545
+ text = joined(working)
546
+ if display_width(text) <= budget:
547
+ return text
548
+ return truncate_to_width(text, budget)
549
+
550
+
551
+ @dataclass(slots=True)
552
+ class PackedRegion:
553
+ """One region after width allocation."""
554
+
555
+ spec: TopBarRegionSpec
556
+ content: str
557
+ width: int
558
+ components: list[TopBarComponent] = field(default_factory=list)
559
+
560
+ def aligned_text(self) -> str:
561
+ return align_in_width(self.content, self.width, self.spec.align)
562
+
563
+
564
+ @dataclass(slots=True)
565
+ class TopBarLayout:
566
+ """Result of packing regions into one row."""
567
+
568
+ regions: list[PackedRegion]
569
+ usable: int
570
+ left: str = ""
571
+ center: str = ""
572
+ right: str = ""
573
+ pad_left: int = 0
574
+ pad_right: int = 0
575
+
576
+ def as_plain(self) -> str:
577
+ parts: list[str] = []
578
+ visible = [r for r in self.regions if r.width > 0 or r.content]
579
+ for i, reg in enumerate(visible):
580
+ parts.append(reg.aligned_text())
581
+ if i < len(visible) - 1 and reg.spec.gap_after > 0:
582
+ parts.append(" " * reg.spec.gap_after)
583
+ body = "".join(parts)
584
+ w = display_width(body)
585
+ if w < self.usable:
586
+ body = body + (" " * (self.usable - w))
587
+ elif w > self.usable:
588
+ body = truncate_to_width(body, self.usable)
589
+ return body
590
+
591
+
592
+ def _clamp_width(spec: TopBarRegionSpec, desired: int) -> int:
593
+ w = max(0, int(desired))
594
+ w = max(w, max(0, int(spec.min_width or 0)))
595
+ if spec.max_width is not None:
596
+ w = min(w, max(0, int(spec.max_width)))
597
+ return w
598
+
599
+
600
+ def pack_region_list(
601
+ items: list[tuple[TopBarRegionSpec, str, list[TopBarComponent]]],
602
+ *,
603
+ usable_width: int,
604
+ ) -> TopBarLayout:
605
+ """Pack arbitrary regions into a single non-wrapping row."""
606
+ usable = max(1, int(usable_width or 0))
607
+ active: list[tuple[TopBarRegionSpec, str, list[TopBarComponent]]] = []
608
+ for spec, content, comps in items:
609
+ if not spec.visible:
610
+ continue
611
+ content = content or ""
612
+ if not content and int(spec.flex or 0) <= 0 and spec.width is None:
613
+ continue
614
+ active.append((spec, content, comps))
615
+ if not active:
616
+ return TopBarLayout(regions=[], usable=usable)
617
+
618
+ base: list[int] = []
619
+ for spec, content, _comps in active:
620
+ natural = display_width(content)
621
+ desired = int(spec.width) if spec.width is not None else natural
622
+ if not content and spec.width is None:
623
+ desired = 0
624
+ base.append(_clamp_width(spec, desired))
625
+
626
+ def gaps_width(n: int) -> int:
627
+ if n <= 1:
628
+ return 0
629
+ return sum(max(0, int(active[i][0].gap_after or 0)) for i in range(n - 1))
630
+
631
+ widths = list(base)
632
+ gap_w = gaps_width(len(active))
633
+ remaining = usable - (sum(widths) + gap_w)
634
+
635
+ if remaining > 0:
636
+ flex_idx = [i for i, (spec, _, _) in enumerate(active) if int(spec.flex or 0) > 0]
637
+ if flex_idx:
638
+ total_flex = sum(int(active[i][0].flex) for i in flex_idx)
639
+ give = remaining
640
+ for j, i in enumerate(flex_idx):
641
+ if j == len(flex_idx) - 1:
642
+ add = give
643
+ else:
644
+ add = remaining * int(active[i][0].flex) // total_flex
645
+ give -= add
646
+ widths[i] = _clamp_width(active[i][0], widths[i] + add)
647
+ used = sum(widths) + gap_w
648
+ rest = usable - used
649
+ if rest > 0:
650
+ last = flex_idx[-1]
651
+ widths[last] = _clamp_width(active[last][0], widths[last] + rest)
652
+
653
+ overflow = (sum(widths) + gap_w) - usable
654
+ if overflow > 0:
655
+ order = sorted(
656
+ range(len(active)),
657
+ key=lambda i: (
658
+ int(active[i][0].priority),
659
+ -int(active[i][0].flex or 0),
660
+ -active[i][0].order,
661
+ ),
662
+ )
663
+ for i in order:
664
+ if overflow <= 0:
665
+ break
666
+ spec, content, _comps = active[i]
667
+ floor = max(0, int(spec.min_width or 0))
668
+ reducible = max(0, widths[i] - floor)
669
+ if reducible <= 0:
670
+ continue
671
+ cut = min(reducible, overflow)
672
+ widths[i] -= cut
673
+ overflow -= cut
674
+ if overflow > 0:
675
+ for i in order:
676
+ if overflow <= 0:
677
+ break
678
+ cut = min(widths[i], overflow)
679
+ widths[i] -= cut
680
+ overflow -= cut
681
+
682
+ packed: list[PackedRegion] = []
683
+ for (spec, content, comps), width in zip(active, widths, strict=True):
684
+ body = content
685
+ if display_width(body) > width:
686
+ body = _compress_region_parts(comps, width) if comps else truncate_to_width(body, width)
687
+ packed.append(PackedRegion(spec=spec, content=body, width=width, components=comps))
688
+
689
+ layout = TopBarLayout(regions=packed, usable=usable)
690
+ by_id = {p.spec.id: p for p in packed}
691
+ if "left" in by_id:
692
+ layout.left = by_id["left"].content
693
+ if "center" in by_id:
694
+ layout.center = by_id["center"].content
695
+ cw = display_width(by_id["center"].content)
696
+ band = by_id["center"].width
697
+ if band > cw:
698
+ layout.pad_left = (band - cw) // 2
699
+ layout.pad_right = band - cw - layout.pad_left
700
+ if "right" in by_id:
701
+ layout.right = by_id["right"].content
702
+ return layout
703
+
704
+
705
+ def pack_topbar_regions(
706
+ *,
707
+ usable_width: int,
708
+ left: str = "",
709
+ center: str = "",
710
+ right: str = "",
711
+ col_gap: int = DEFAULT_COL_GAP,
712
+ ) -> TopBarLayout:
713
+ """Legacy three-region packer (kept for tests / simple callers)."""
714
+ gap = max(0, int(col_gap or 0))
715
+ items = [
716
+ (
717
+ TopBarRegionSpec(
718
+ id="left", order=10, flex=0,
719
+ align=TopBarAlign.LEFT, priority=40, gap_after=gap,
720
+ ),
721
+ left or "",
722
+ [],
723
+ ),
724
+ (
725
+ TopBarRegionSpec(
726
+ id="center",
727
+ order=20,
728
+ flex=1,
729
+ align=TopBarAlign.CENTER,
730
+ priority=10,
731
+ min_width=4,
732
+ gap_after=gap,
733
+ ),
734
+ center or "",
735
+ [],
736
+ ),
737
+ (
738
+ TopBarRegionSpec(
739
+ id="right", order=30, flex=0,
740
+ align=TopBarAlign.RIGHT, priority=50, gap_after=0,
741
+ ),
742
+ right or "",
743
+ [],
744
+ ),
745
+ ]
746
+ return pack_region_list(items, usable_width=usable_width)
747
+
748
+
749
+ def pack_layout_from_registry(
750
+ registry: TopBarRegistry,
751
+ *,
752
+ usable_width: int,
753
+ col_gap: int | None = None,
754
+ ) -> TopBarLayout:
755
+ """Pack registry regions without styling (for hit-testing / span queries)."""
756
+ region_specs = registry.regions(include_hidden=False)
757
+ if col_gap is not None:
758
+ gap = max(0, int(col_gap))
759
+ region_specs = [
760
+ dc_replace(spec, gap_after=gap) if spec.id in {"left", "center"} else spec
761
+ for spec in region_specs
762
+ ]
763
+
764
+ items: list[tuple[TopBarRegionSpec, str, list[TopBarComponent]]] = []
765
+ for spec in region_specs:
766
+ comps = registry.components(spec.id)
767
+ items.append((spec, render_region_text(comps), comps))
768
+ return pack_region_list(items, usable_width=usable_width)
769
+
770
+
771
+ def layout_from_registry(
772
+ registry: TopBarRegistry,
773
+ *,
774
+ usable_width: int,
775
+ col_gap: int | None = None,
776
+ left_style: str = "",
777
+ center_style: str = "",
778
+ right_style: str = "",
779
+ gap_style: str = "",
780
+ default_fg: str = "",
781
+ ) -> Text:
782
+ """Render a full topbar line from a registry of regions + components."""
783
+ del gap_style
784
+ fallback = {
785
+ "left": left_style or default_fg,
786
+ "center": center_style or default_fg,
787
+ "right": right_style or default_fg,
788
+ }
789
+ packed = pack_layout_from_registry(
790
+ registry, usable_width=usable_width, col_gap=col_gap
791
+ )
792
+ return render_packed_line(packed, fallback_fg=fallback)
793
+
794
+
795
+ def locate_component_span(
796
+ registry: TopBarRegistry,
797
+ component_id: str,
798
+ *,
799
+ usable_width: int,
800
+ col_gap: int | None = None,
801
+ ) -> tuple[int, int] | None:
802
+ """Return ``(start_col, width)`` of a component within the packed topbar row.
803
+
804
+ Coordinates are content cells (0 = first cell of the usable topbar line),
805
+ matching ``Static`` content after CSS horizontal padding.
806
+ """
807
+ cid = str(component_id or "").strip()
808
+ if not cid:
809
+ return None
810
+ packed = pack_layout_from_registry(
811
+ registry, usable_width=usable_width, col_gap=col_gap
812
+ )
813
+ cursor = 0
814
+ visible = [r for r in packed.regions if r.width > 0 or r.content]
815
+ for i, reg in enumerate(visible):
816
+ natural = reg.content or ""
817
+ lead = 0
818
+ if reg.spec.align is TopBarAlign.RIGHT:
819
+ lead = max(0, reg.width - display_width(natural))
820
+ elif reg.spec.align is TopBarAlign.CENTER:
821
+ lead = max(0, (reg.width - display_width(natural)) // 2)
822
+
823
+ x = cursor + lead
824
+ first = True
825
+ for comp in reg.components:
826
+ plain = comp.text()
827
+ if not plain:
828
+ continue
829
+ if not first and comp.gap_before:
830
+ x += display_width(comp.gap_before)
831
+ w = display_width(plain)
832
+ if comp.id == cid:
833
+ return (x, w)
834
+ x += w
835
+ first = False
836
+
837
+ cursor += reg.width
838
+ if i < len(visible) - 1 and reg.spec.gap_after > 0:
839
+ cursor += int(reg.spec.gap_after or 0)
840
+ return None
841
+
842
+
843
+ def _region_bg(spec: TopBarRegionSpec) -> str:
844
+ """Normalized region background color, or empty if unset."""
845
+ return (spec.bg or "").strip()
846
+
847
+
848
+ def _with_bg(style: str | None, bg: str) -> str | None:
849
+ """Ensure a Rich style string includes ``on <bg>`` (for region color blocks)."""
850
+ bg = (bg or "").strip()
851
+ if not bg:
852
+ return style
853
+ raw = (style or "").strip()
854
+ if not raw:
855
+ return f"on {bg}"
856
+ # Already has an explicit background — keep caller choice.
857
+ if " on " in f" {raw} ":
858
+ return raw
859
+ return f"{raw} on {bg}"
860
+
861
+
862
+ def _append_chunk_with_region_bg(
863
+ line: Text,
864
+ chunk: str | Text,
865
+ *,
866
+ style: str | None,
867
+ bg: str,
868
+ ) -> None:
869
+ """Append plain/Text content, painting the region band background when set."""
870
+ if isinstance(chunk, Text):
871
+ if bg:
872
+ painted = chunk.copy()
873
+ painted.stylize(f"on {bg}")
874
+ line.append_text(painted)
875
+ else:
876
+ line.append_text(chunk)
877
+ return
878
+ line.append(str(chunk), style=_with_bg(style, bg) if bg else style)
879
+
880
+
881
+ def render_packed_line(
882
+ layout: TopBarLayout,
883
+ *,
884
+ fallback_fg: dict[str, str] | str | None = None,
885
+ ) -> Text:
886
+ """Turn a packed layout into styled Rich Text.
887
+
888
+ When a region sets ``bg``, the full allocated band (content + horizontal
889
+ pad) is painted as a solid color block. Per-component styles / pre-styled
890
+ ``Text`` keep their foregrounds; the region bg is layered underneath.
891
+ """
892
+ line = Text()
893
+ if isinstance(fallback_fg, str) or fallback_fg is None:
894
+ fb_map: dict[str, str] = {}
895
+ default_fb = fallback_fg or ""
896
+ else:
897
+ fb_map = dict(fallback_fg)
898
+ default_fb = ""
899
+
900
+ visible = [r for r in layout.regions if r.width > 0 or r.content]
901
+ for i, reg in enumerate(visible):
902
+ fb = fb_map.get(reg.spec.id, default_fb)
903
+ style = reg.spec.style_string(fallback_fg=fb or None)
904
+ bg = _region_bg(reg.spec)
905
+ body = reg.aligned_text()
906
+ natural = reg.content
907
+ # Prefer component path so branch/workspace keep their own styles, even
908
+ # when the region paints a solid background band.
909
+ if reg.components and render_region_text(reg.components) == natural:
910
+ lead = 0
911
+ if reg.spec.align is TopBarAlign.RIGHT:
912
+ lead = max(0, reg.width - display_width(natural))
913
+ elif reg.spec.align is TopBarAlign.CENTER:
914
+ lead = max(0, (reg.width - display_width(natural)) // 2)
915
+ # Always paint pad cells with the region style so bg fills the band,
916
+ # not only the glyphs (hug regions used to look like text highlight).
917
+ band_style = style or (f"on {bg}" if bg else None)
918
+ if lead > 0:
919
+ line.append(" " * lead, style=band_style)
920
+ first = True
921
+ for comp in reg.components:
922
+ chunk = comp.content()
923
+ plain = chunk.plain if isinstance(chunk, Text) else str(chunk or "")
924
+ if not plain:
925
+ continue
926
+ if not first and comp.gap_before:
927
+ line.append(comp.gap_before, style=band_style)
928
+ if isinstance(chunk, Text):
929
+ _append_chunk_with_region_bg(line, chunk, style=style, bg=bg)
930
+ else:
931
+ c_style = comp.style if comp.style is not None else style
932
+ _append_chunk_with_region_bg(line, chunk, style=c_style, bg=bg)
933
+ first = False
934
+ trail = reg.width - lead - display_width(natural)
935
+ if trail > 0:
936
+ line.append(" " * trail, style=band_style)
937
+ elif body:
938
+ line.append(body, style=style)
939
+ elif reg.width > 0 and (style or bg):
940
+ # Empty content but allocated width still paints a solid band.
941
+ band_style = style or (f"on {bg}" if bg else None)
942
+ line.append(" " * reg.width, style=band_style)
943
+
944
+ if i < len(visible) - 1 and reg.spec.gap_after > 0:
945
+ # Gaps stay unstyled so the widget CSS topbar bg shows between blocks.
946
+ line.append(" " * reg.spec.gap_after, style=None)
947
+
948
+ plain_w = display_width(line.plain)
949
+ if plain_w < layout.usable:
950
+ line.append(" " * (layout.usable - plain_w), style=None)
951
+ elif plain_w > layout.usable:
952
+ return Text(truncate_to_width(line.plain, layout.usable))
953
+ return line
954
+
955
+
956
+ __all__ = [
957
+ "DEFAULT_COL_GAP",
958
+ "DEFAULT_REGION_GAP",
959
+ "PackedRegion",
960
+ "TopBarAlign",
961
+ "TopBarComponent",
962
+ "TopBarLayout",
963
+ "TopBarRegion",
964
+ "TopBarRegionSpec",
965
+ "TopBarRegistry",
966
+ "align_in_width",
967
+ "center_in_width",
968
+ "display_width",
969
+ "join_region_parts",
970
+ "layout_from_registry",
971
+ "locate_component_span",
972
+ "normalize_region_id",
973
+ "pack_layout_from_registry",
974
+ "pack_region_list",
975
+ "pack_topbar_regions",
976
+ "render_packed_line",
977
+ "render_region_text",
978
+ "truncate_to_width",
979
+ ]