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.
- synapse/__init__.py +13 -0
- synapse/__main__.py +6 -0
- synapse/app/__init__.py +1 -0
- synapse/app/agent.py +492 -0
- synapse/app/agent_md.py +107 -0
- synapse/cli.py +750 -0
- synapse/commands/__init__.py +1 -0
- synapse/commands/compression.py +573 -0
- synapse/commands/helpers.py +22 -0
- synapse/commands/mcp.py +406 -0
- synapse/commands/model.py +173 -0
- synapse/commands/result.py +34 -0
- synapse/commands/sessions.py +443 -0
- synapse/commands/slash_cmds.py +521 -0
- synapse/commands/slash_complete.py +816 -0
- synapse/commands/theme.py +99 -0
- synapse/config.py +27 -0
- synapse/content/__init__.py +1 -0
- synapse/content/input_history.py +122 -0
- synapse/content/multimodal.py +733 -0
- synapse/content/prompts.py +249 -0
- synapse/content/skills_catalog.py +128 -0
- synapse/integrations/__init__.py +1 -0
- synapse/integrations/checkpoint_seed.py +281 -0
- synapse/integrations/codex_history.py +375 -0
- synapse/integrations/codex_import.py +393 -0
- synapse/integrations/codex_sessions.py +629 -0
- synapse/integrations/describe_image.py +370 -0
- synapse/integrations/http_clients.py +199 -0
- synapse/integrations/llm_openai_compat.py +90 -0
- synapse/integrations/llm_openai_websocket.py +187 -0
- synapse/integrations/mcp_client.py +646 -0
- synapse/integrations/vision_middleware.py +62 -0
- synapse/models/__init__.py +5 -0
- synapse/models/config.py +240 -0
- synapse/models/helpers.py +206 -0
- synapse/models/profile.py +59 -0
- synapse/models/registry.py +722 -0
- synapse/models_registry.py +7 -0
- synapse/observability/__init__.py +1 -0
- synapse/observability/startup_trace.py +127 -0
- synapse/runtime/__init__.py +1 -0
- synapse/runtime/async_runtime.py +176 -0
- synapse/runtime/backends.py +458 -0
- synapse/runtime/context_compact.py +249 -0
- synapse/runtime/execute_capture.py +48 -0
- synapse/runtime/fs_permissions.py +79 -0
- synapse/runtime/harness.py +57 -0
- synapse/runtime/hitl.py +197 -0
- synapse/runtime/interaction_ledger.py +82 -0
- synapse/runtime/middleware.py +802 -0
- synapse/runtime/model_request_compression_middleware.py +745 -0
- synapse/runtime/pathing.py +146 -0
- synapse/runtime/safety.py +184 -0
- synapse/runtime/steer.py +240 -0
- synapse/runtime/subagents.py +207 -0
- synapse/runtime/tool_ignore.py +221 -0
- synapse/runtime/tool_output_eval.py +118 -0
- synapse/runtime/tool_output_middleware.py +585 -0
- synapse/runtime/tool_output_usage_middleware.py +60 -0
- synapse/sessions/__init__.py +31 -0
- synapse/sessions/cancel_repair.py +208 -0
- synapse/sessions/session_recap.py +174 -0
- synapse/sessions/store.py +695 -0
- synapse/sessions/transcript.py +754 -0
- synapse/settings/__init__.py +5 -0
- synapse/settings/config_paths.py +184 -0
- synapse/settings/schema.py +464 -0
- synapse/tool_output/__init__.py +59 -0
- synapse/tool_output/detection.py +170 -0
- synapse/tool_output/metrics.py +32 -0
- synapse/tool_output/models.py +173 -0
- synapse/tool_output/pipeline.py +330 -0
- synapse/tool_output/repository.py +721 -0
- synapse/tool_output/transformers.py +648 -0
- synapse/tools/__init__.py +5 -0
- synapse/tools/session_tools.py +204 -0
- synapse/ui/__init__.py +10 -0
- synapse/ui/bottombar/__init__.py +73 -0
- synapse/ui/bottombar/components/__init__.py +143 -0
- synapse/ui/bottombar/components/key_hints.py +30 -0
- synapse/ui/bottombar/components/mcp.py +64 -0
- synapse/ui/bottombar/components/mode.py +24 -0
- synapse/ui/bottombar/components/model.py +28 -0
- synapse/ui/bottombar/components/thread.py +29 -0
- synapse/ui/bottombar/context.py +36 -0
- synapse/ui/bottombar/core.py +74 -0
- synapse/ui/dialogs/__init__.py +25 -0
- synapse/ui/dialogs/base.py +362 -0
- synapse/ui/dialogs/codex_session_list.py +84 -0
- synapse/ui/dialogs/compression_diagnostics.py +210 -0
- synapse/ui/dialogs/git_explore.py +702 -0
- synapse/ui/dialogs/mcp_panel.py +407 -0
- synapse/ui/dialogs/model_picker.py +128 -0
- synapse/ui/dialogs/safety_panel.py +63 -0
- synapse/ui/dialogs/session_list.py +98 -0
- synapse/ui/dialogs/theme_designer.py +863 -0
- synapse/ui/dialogs/theme_picker.py +113 -0
- synapse/ui/git_explore/__init__.py +31 -0
- synapse/ui/git_explore/engine.py +82 -0
- synapse/ui/git_explore/provider.py +242 -0
- synapse/ui/git_explore/unified.py +85 -0
- synapse/ui/rendering.py +350 -0
- synapse/ui/sink.py +70 -0
- synapse/ui/steer_widget.py +367 -0
- synapse/ui/stream.py +1207 -0
- synapse/ui/stream_events.py +421 -0
- synapse/ui/stream_runtime.py +252 -0
- synapse/ui/theme.py +1154 -0
- synapse/ui/timeline.py +621 -0
- synapse/ui/topbar/__init__.py +97 -0
- synapse/ui/topbar/components/__init__.py +150 -0
- synapse/ui/topbar/components/branch.py +41 -0
- synapse/ui/topbar/components/title.py +24 -0
- synapse/ui/topbar/components/tool_output.py +24 -0
- synapse/ui/topbar/components/usage.py +24 -0
- synapse/ui/topbar/components/workspace.py +32 -0
- synapse/ui/topbar/context.py +32 -0
- synapse/ui/topbar/core.py +979 -0
- synapse/ui/topbar/git_changes_popover.py +178 -0
- synapse/ui/topbar/git_chrome.py +475 -0
- synapse/ui/topbar/tool_output_popover.py +84 -0
- synapse/ui/topbar/widget.py +474 -0
- synapse/ui/tui.py +5717 -0
- synapse/ui/turn_rail.py +71 -0
- synapse/ui/user_turn.py +83 -0
- synapse/ui/welcome.py +261 -0
- synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
- synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
- synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
- synapse_cli_agent-0.1.13.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Git working-tree changes hover popover."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
from textual.containers import Vertical
|
|
9
|
+
from textual.events import Click, Enter, Leave
|
|
10
|
+
from textual.widgets import Static
|
|
11
|
+
|
|
12
|
+
from synapse.ui.topbar.core import display_width
|
|
13
|
+
from synapse.ui.topbar.git_chrome import (
|
|
14
|
+
GitChangedFile,
|
|
15
|
+
format_changed_file_plain,
|
|
16
|
+
render_changed_file_row,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from synapse.ui.topbar.widget import TopBar
|
|
21
|
+
|
|
22
|
+
_MAX_ROWS = 14
|
|
23
|
+
_MIN_WIDTH = 36
|
|
24
|
+
_MAX_WIDTH = 72
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PopoverFileRow(Static):
|
|
28
|
+
"""One clickable changed-file row inside the hover popover."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, item: GitChangedFile, row_text: Text) -> None:
|
|
31
|
+
super().__init__(row_text)
|
|
32
|
+
self.item = item
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GitChangesPopover(Vertical):
|
|
36
|
+
"""Overlay list of changed files under the branch chrome."""
|
|
37
|
+
|
|
38
|
+
DEFAULT_CSS = """
|
|
39
|
+
GitChangesPopover {
|
|
40
|
+
layer: overlay;
|
|
41
|
+
width: auto;
|
|
42
|
+
height: auto;
|
|
43
|
+
max-height: 16;
|
|
44
|
+
padding: 0 1;
|
|
45
|
+
border: solid $theme-border;
|
|
46
|
+
background: $theme-bar;
|
|
47
|
+
color: $theme-fg;
|
|
48
|
+
overflow-x: hidden;
|
|
49
|
+
overflow-y: auto;
|
|
50
|
+
/* Quiet 1-cell rail: track blends into bar; thumb is muted border. */
|
|
51
|
+
scrollbar-size-vertical: 1;
|
|
52
|
+
scrollbar-size-horizontal: 0;
|
|
53
|
+
scrollbar-background: $theme-bar;
|
|
54
|
+
scrollbar-color: $theme-border;
|
|
55
|
+
scrollbar-background-hover: $theme-bar;
|
|
56
|
+
scrollbar-color-hover: $theme-muted;
|
|
57
|
+
scrollbar-background-active: $theme-bar;
|
|
58
|
+
scrollbar-color-active: $theme-dim;
|
|
59
|
+
scrollbar-corner-color: $theme-bar;
|
|
60
|
+
}
|
|
61
|
+
GitChangesPopover #git-changes-title {
|
|
62
|
+
height: 1;
|
|
63
|
+
color: $theme-dim;
|
|
64
|
+
text-style: bold;
|
|
65
|
+
}
|
|
66
|
+
GitChangesPopover #git-changes-body {
|
|
67
|
+
height: auto;
|
|
68
|
+
max-height: 14;
|
|
69
|
+
overflow-y: auto;
|
|
70
|
+
overflow-x: hidden;
|
|
71
|
+
layout: vertical;
|
|
72
|
+
scrollbar-size-vertical: 1;
|
|
73
|
+
scrollbar-size-horizontal: 0;
|
|
74
|
+
scrollbar-background: $theme-bar;
|
|
75
|
+
scrollbar-color: $theme-border;
|
|
76
|
+
scrollbar-background-hover: $theme-bar;
|
|
77
|
+
scrollbar-color-hover: $theme-muted;
|
|
78
|
+
scrollbar-background-active: $theme-bar;
|
|
79
|
+
scrollbar-color-active: $theme-dim;
|
|
80
|
+
scrollbar-corner-color: $theme-bar;
|
|
81
|
+
}
|
|
82
|
+
GitChangesPopover PopoverFileRow {
|
|
83
|
+
height: 1;
|
|
84
|
+
width: 1fr;
|
|
85
|
+
}
|
|
86
|
+
GitChangesPopover PopoverFileRow:hover {
|
|
87
|
+
text-style: bold;
|
|
88
|
+
}
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
files: list[GitChangedFile],
|
|
94
|
+
*,
|
|
95
|
+
color_clean: str = "#81c995",
|
|
96
|
+
color_dirty: str = "#f28b82",
|
|
97
|
+
color_dim: str = "#9aa0a6",
|
|
98
|
+
color_fg: str = "#e8eaed",
|
|
99
|
+
color_orange: str = "#f4b183",
|
|
100
|
+
color_added: str = "#81c995",
|
|
101
|
+
color_deleted: str = "#f28b82",
|
|
102
|
+
id: str | None = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
# Callers that mount onto Screen should pass a unique id; fixed default
|
|
105
|
+
# ids collide when Textual remove() is still draining the previous node.
|
|
106
|
+
super().__init__(id=id)
|
|
107
|
+
self.files = list(files or [])
|
|
108
|
+
self._colors = {
|
|
109
|
+
"clean": color_clean,
|
|
110
|
+
"dirty": color_dirty,
|
|
111
|
+
"dim": color_dim,
|
|
112
|
+
"fg": color_fg,
|
|
113
|
+
"orange": color_orange,
|
|
114
|
+
"added": color_added,
|
|
115
|
+
"deleted": color_deleted,
|
|
116
|
+
}
|
|
117
|
+
self._owner: TopBar | None = None
|
|
118
|
+
|
|
119
|
+
def compose(self): # type: ignore[override]
|
|
120
|
+
n = len(self.files)
|
|
121
|
+
title = f"changed files ({n}) · click to explore" if n else "no changes"
|
|
122
|
+
yield Static(title, id="git-changes-title")
|
|
123
|
+
with Vertical(id="git-changes-body"):
|
|
124
|
+
if not self.files:
|
|
125
|
+
yield Static(Text("working tree clean", style=self._colors["dim"]))
|
|
126
|
+
else:
|
|
127
|
+
shown = self.files[:_MAX_ROWS]
|
|
128
|
+
path_w = max(12, min(48, max((len(f.path) for f in shown), default=12)))
|
|
129
|
+
for item in shown:
|
|
130
|
+
row_text = render_changed_file_row(
|
|
131
|
+
item,
|
|
132
|
+
path_width=path_w,
|
|
133
|
+
color_status_m=self._colors["orange"],
|
|
134
|
+
color_status_a=self._colors["added"],
|
|
135
|
+
color_status_d=self._colors["deleted"],
|
|
136
|
+
color_status_u=self._colors["dim"],
|
|
137
|
+
color_path=self._colors["fg"],
|
|
138
|
+
color_added=self._colors["added"],
|
|
139
|
+
color_deleted=self._colors["deleted"],
|
|
140
|
+
color_muted=self._colors["dim"],
|
|
141
|
+
)
|
|
142
|
+
yield PopoverFileRow(item, row_text)
|
|
143
|
+
extra = len(self.files) - len(shown)
|
|
144
|
+
if extra > 0:
|
|
145
|
+
yield Static(Text(f"... +{extra} more", style=self._colors["dim"]))
|
|
146
|
+
|
|
147
|
+
def measure_width(self) -> int:
|
|
148
|
+
"""Preferred content width in cells."""
|
|
149
|
+
if not self.files:
|
|
150
|
+
return _MIN_WIDTH
|
|
151
|
+
plain_rows = [format_changed_file_plain(f) for f in self.files[:_MAX_ROWS]]
|
|
152
|
+
body = max((display_width(r) for r in plain_rows), default=_MIN_WIDTH)
|
|
153
|
+
title_w = display_width(f"changed files ({len(self.files)}) · click to explore")
|
|
154
|
+
# border + padding ~ 4 cells
|
|
155
|
+
return max(_MIN_WIDTH, min(_MAX_WIDTH, max(body, title_w) + 4))
|
|
156
|
+
|
|
157
|
+
def on_enter(self, event: Enter) -> None:
|
|
158
|
+
event.stop()
|
|
159
|
+
if self._owner is not None:
|
|
160
|
+
self._owner.on_popover_enter()
|
|
161
|
+
|
|
162
|
+
def on_leave(self, event: Leave) -> None:
|
|
163
|
+
event.stop()
|
|
164
|
+
if self._owner is not None:
|
|
165
|
+
self._owner.on_popover_leave()
|
|
166
|
+
|
|
167
|
+
def on_click(self, event: Click) -> None:
|
|
168
|
+
"""Click a file row (or the popover) to open Git Explore."""
|
|
169
|
+
event.stop()
|
|
170
|
+
path: str | None = None
|
|
171
|
+
widget = event.widget
|
|
172
|
+
if isinstance(widget, PopoverFileRow):
|
|
173
|
+
path = widget.item.path
|
|
174
|
+
elif self.files:
|
|
175
|
+
# Title / empty area → open explore without a focused path.
|
|
176
|
+
path = None
|
|
177
|
+
if self._owner is not None:
|
|
178
|
+
self._owner.request_explore(path)
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""Git branch chrome for the TUI topbar.
|
|
2
|
+
|
|
3
|
+
Compact status:
|
|
4
|
+
|
|
5
|
+
- clean + in sync: green branch name (no dirty mark)
|
|
6
|
+
- dirty: red branch name + red `` *`` (space before star)
|
|
7
|
+
- diff stats (when dirty): `` Nf +A -D`` (files changed, lines added, lines deleted)
|
|
8
|
+
- ahead: ``↑N`` in ahead color
|
|
9
|
+
- behind: ``↓N`` in behind color
|
|
10
|
+
|
|
11
|
+
Diff stats use ``git diff HEAD --shortstat`` (working tree + index vs HEAD).
|
|
12
|
+
|
|
13
|
+
Changed-file popover data uses ``git status --porcelain`` plus staged/unstaged
|
|
14
|
+
``numstat`` so untracked files are included and each path gets ``+A -D``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
import subprocess
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from rich.text import Text
|
|
25
|
+
|
|
26
|
+
# Cap large ahead/behind / file-count display so chrome stays short.
|
|
27
|
+
# Line add/delete counts are always shown in full (topbar + popover).
|
|
28
|
+
_COUNT_CAP = 99
|
|
29
|
+
# Popover / shortstat path caps (display only).
|
|
30
|
+
_PATH_CAP = 48
|
|
31
|
+
_FILE_LIST_CAP = 40
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class GitBranchChrome:
|
|
36
|
+
"""Local git branch snapshot for topbar rendering."""
|
|
37
|
+
|
|
38
|
+
name: str
|
|
39
|
+
dirty: bool = False
|
|
40
|
+
ahead: int | None = None # None = no upstream tracking
|
|
41
|
+
behind: int | None = None
|
|
42
|
+
files_changed: int = 0
|
|
43
|
+
lines_added: int = 0
|
|
44
|
+
lines_deleted: int = 0
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def synced(self) -> bool:
|
|
48
|
+
"""Clean working tree and not ahead/behind (or no upstream)."""
|
|
49
|
+
if self.dirty:
|
|
50
|
+
return False
|
|
51
|
+
if self.ahead is not None and int(self.ahead) > 0:
|
|
52
|
+
return False
|
|
53
|
+
if self.behind is not None and int(self.behind) > 0:
|
|
54
|
+
return False
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class GitChangedFile:
|
|
60
|
+
"""One dirty / untracked path for the branch hover popover."""
|
|
61
|
+
|
|
62
|
+
path: str
|
|
63
|
+
# Single display status: M / A / D / R / C / ? / U / T / …
|
|
64
|
+
status: str
|
|
65
|
+
lines_added: int = 0
|
|
66
|
+
lines_deleted: int = 0
|
|
67
|
+
is_untracked: bool = False
|
|
68
|
+
# Optional rename/copy source (display only).
|
|
69
|
+
source_path: str | None = None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def has_line_stats(self) -> bool:
|
|
73
|
+
return (not self.is_untracked) and (
|
|
74
|
+
self.lines_added > 0
|
|
75
|
+
or self.lines_deleted > 0
|
|
76
|
+
or self.status in {"D", "A", "M", "R", "C", "T"}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _run_git(args: list[str], *, cwd: Path, timeout: float = 0.8) -> str | None:
|
|
81
|
+
try:
|
|
82
|
+
proc = subprocess.run(
|
|
83
|
+
["git", *args],
|
|
84
|
+
cwd=str(cwd),
|
|
85
|
+
capture_output=True,
|
|
86
|
+
text=True,
|
|
87
|
+
encoding="utf-8",
|
|
88
|
+
errors="replace",
|
|
89
|
+
timeout=timeout,
|
|
90
|
+
check=False,
|
|
91
|
+
)
|
|
92
|
+
except Exception: # noqa: BLE001
|
|
93
|
+
return None
|
|
94
|
+
if proc.returncode != 0:
|
|
95
|
+
return None
|
|
96
|
+
# rstrip only: leading spaces are meaningful in porcelain XY codes.
|
|
97
|
+
return (proc.stdout or "").rstrip()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _format_count(n: int) -> str:
|
|
101
|
+
"""Cap large counts (ahead/behind/files) for compact chrome."""
|
|
102
|
+
n = max(0, int(n or 0))
|
|
103
|
+
if n > _COUNT_CAP:
|
|
104
|
+
return f"{_COUNT_CAP}+"
|
|
105
|
+
return str(n)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _format_lines(n: int) -> str:
|
|
109
|
+
"""Full line add/delete count (no 99+ cap)."""
|
|
110
|
+
return str(max(0, int(n or 0)))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _probe_diff_shortstat(cwd: Path, *, timeout: float = 0.8) -> tuple[int, int, int]:
|
|
114
|
+
"""Return ``(files_changed, lines_added, lines_deleted)`` from
|
|
115
|
+
``git diff HEAD --shortstat`` (working tree + index vs HEAD).
|
|
116
|
+
|
|
117
|
+
Returns ``(0, 0, 0)`` on failure or when there is nothing to diff
|
|
118
|
+
(e.g. no commits yet).
|
|
119
|
+
"""
|
|
120
|
+
raw = _run_git(["diff", "HEAD", "--shortstat"], cwd=cwd, timeout=timeout)
|
|
121
|
+
if not raw:
|
|
122
|
+
return 0, 0, 0
|
|
123
|
+
|
|
124
|
+
files = 0
|
|
125
|
+
inserted = 0
|
|
126
|
+
deleted = 0
|
|
127
|
+
m = re.search(r"(\d+)\s+files?\s+changed", raw)
|
|
128
|
+
if m:
|
|
129
|
+
files = int(m.group(1))
|
|
130
|
+
m = re.search(r"(\d+)\s+insertions?\(\+\)", raw)
|
|
131
|
+
if m:
|
|
132
|
+
inserted = int(m.group(1))
|
|
133
|
+
m = re.search(r"(\d+)\s+deletions?\(-\)", raw)
|
|
134
|
+
if m:
|
|
135
|
+
deleted = int(m.group(1))
|
|
136
|
+
return files, inserted, deleted
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _status_letter(xy: str, *, untracked: bool = False) -> str:
|
|
140
|
+
"""Collapse porcelain XY codes into one display letter."""
|
|
141
|
+
if untracked or xy == "??":
|
|
142
|
+
return "?"
|
|
143
|
+
if xy == "!!":
|
|
144
|
+
return "!"
|
|
145
|
+
x = (xy[:1] or " ").upper()
|
|
146
|
+
y = (xy[1:2] or " ").upper() if len(xy) > 1 else " "
|
|
147
|
+
# Prefer worktree letter when both present and differ; else staged.
|
|
148
|
+
for ch in (y, x):
|
|
149
|
+
if ch not in {" ", "?"}:
|
|
150
|
+
return ch
|
|
151
|
+
return "M"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _parse_porcelain_line(line: str) -> tuple[str, str, str | None] | None:
|
|
155
|
+
"""Return ``(xy, path, source_path)`` or None for empty/unusable lines."""
|
|
156
|
+
raw = (line or "").rstrip("\n")
|
|
157
|
+
if not raw:
|
|
158
|
+
return None
|
|
159
|
+
if raw.startswith("??") or raw.startswith("!!"):
|
|
160
|
+
path = raw[2:].lstrip()
|
|
161
|
+
if not path:
|
|
162
|
+
return None
|
|
163
|
+
return raw[:2], path, None
|
|
164
|
+
if len(raw) < 4:
|
|
165
|
+
return None
|
|
166
|
+
xy = raw[:2]
|
|
167
|
+
rest = raw[3:]
|
|
168
|
+
source: str | None = None
|
|
169
|
+
# Rename / copy: ``R old -> new`` (non -z porcelain).
|
|
170
|
+
if " -> " in rest and (xy[:1] in {"R", "C"} or xy[1:2] in {"R", "C"}):
|
|
171
|
+
left, right = rest.split(" -> ", 1)
|
|
172
|
+
source = left.strip() or None
|
|
173
|
+
path = right.strip()
|
|
174
|
+
else:
|
|
175
|
+
path = rest.strip()
|
|
176
|
+
if not path:
|
|
177
|
+
return None
|
|
178
|
+
return xy, path, source
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _parse_numstat(raw: str | None) -> dict[str, tuple[int, int]]:
|
|
182
|
+
"""Parse ``git diff --numstat`` output into path → (added, deleted)."""
|
|
183
|
+
out: dict[str, tuple[int, int]] = {}
|
|
184
|
+
if not raw:
|
|
185
|
+
return out
|
|
186
|
+
for line in raw.splitlines():
|
|
187
|
+
parts = line.split("\t")
|
|
188
|
+
if len(parts) < 3:
|
|
189
|
+
continue
|
|
190
|
+
a_s, d_s, path = parts[0], parts[1], parts[-1]
|
|
191
|
+
if not path:
|
|
192
|
+
continue
|
|
193
|
+
# Binary files show ``-`` for both counts.
|
|
194
|
+
try:
|
|
195
|
+
added = 0 if a_s == "-" else max(0, int(a_s))
|
|
196
|
+
except ValueError:
|
|
197
|
+
added = 0
|
|
198
|
+
try:
|
|
199
|
+
deleted = 0 if d_s == "-" else max(0, int(d_s))
|
|
200
|
+
except ValueError:
|
|
201
|
+
deleted = 0
|
|
202
|
+
prev = out.get(path, (0, 0))
|
|
203
|
+
out[path] = (prev[0] + added, prev[1] + deleted)
|
|
204
|
+
return out
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def probe_git_changed_files(
|
|
208
|
+
cwd: Path | str,
|
|
209
|
+
*,
|
|
210
|
+
timeout: float = 1.0,
|
|
211
|
+
limit: int = _FILE_LIST_CAP,
|
|
212
|
+
) -> list[GitChangedFile]:
|
|
213
|
+
"""List dirty / untracked files with optional line-level add/delete stats.
|
|
214
|
+
|
|
215
|
+
Sources:
|
|
216
|
+
- ``git status --porcelain=v1`` for paths + status letters (includes ``??``)
|
|
217
|
+
- ``git diff --numstat`` + ``git diff --cached --numstat`` for ``+A -D``
|
|
218
|
+
"""
|
|
219
|
+
root = Path(cwd)
|
|
220
|
+
porcelain = _run_git(["status", "--porcelain=v1"], cwd=root, timeout=timeout)
|
|
221
|
+
if porcelain is None:
|
|
222
|
+
return []
|
|
223
|
+
if not porcelain:
|
|
224
|
+
return []
|
|
225
|
+
|
|
226
|
+
unstaged = _parse_numstat(
|
|
227
|
+
_run_git(["diff", "--numstat"], cwd=root, timeout=timeout)
|
|
228
|
+
)
|
|
229
|
+
staged = _parse_numstat(
|
|
230
|
+
_run_git(["diff", "--cached", "--numstat"], cwd=root, timeout=timeout)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
items: list[GitChangedFile] = []
|
|
234
|
+
seen: set[str] = set()
|
|
235
|
+
for line in porcelain.splitlines():
|
|
236
|
+
parsed = _parse_porcelain_line(line)
|
|
237
|
+
if parsed is None:
|
|
238
|
+
continue
|
|
239
|
+
xy, path, source = parsed
|
|
240
|
+
if path in seen:
|
|
241
|
+
continue
|
|
242
|
+
seen.add(path)
|
|
243
|
+
untracked = xy == "??"
|
|
244
|
+
status = _status_letter(xy, untracked=untracked)
|
|
245
|
+
added = 0
|
|
246
|
+
deleted = 0
|
|
247
|
+
if not untracked:
|
|
248
|
+
a1, d1 = unstaged.get(path, (0, 0))
|
|
249
|
+
a2, d2 = staged.get(path, (0, 0))
|
|
250
|
+
added = a1 + a2
|
|
251
|
+
deleted = d1 + d2
|
|
252
|
+
# Renames: numstat may key the new path only; also try source.
|
|
253
|
+
if source and added == 0 and deleted == 0:
|
|
254
|
+
a1, d1 = unstaged.get(source, (0, 0))
|
|
255
|
+
a2, d2 = staged.get(source, (0, 0))
|
|
256
|
+
added = a1 + a2
|
|
257
|
+
deleted = d1 + d2
|
|
258
|
+
items.append(
|
|
259
|
+
GitChangedFile(
|
|
260
|
+
path=path,
|
|
261
|
+
status=status,
|
|
262
|
+
lines_added=added,
|
|
263
|
+
lines_deleted=deleted,
|
|
264
|
+
is_untracked=untracked,
|
|
265
|
+
source_path=source,
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
if limit > 0 and len(items) >= int(limit):
|
|
269
|
+
break
|
|
270
|
+
return items
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def format_changed_file_plain(item: GitChangedFile, *, path_width: int = _PATH_CAP) -> str:
|
|
274
|
+
"""Plain row: ``M path/to/file.py +10 -3``."""
|
|
275
|
+
status = (item.status or "M")[:1]
|
|
276
|
+
path = item.path or ""
|
|
277
|
+
if path_width > 0 and len(path) > path_width:
|
|
278
|
+
path = "…" + path[-(path_width - 1) :]
|
|
279
|
+
if item.is_untracked:
|
|
280
|
+
stats = "untracked"
|
|
281
|
+
elif item.lines_added or item.lines_deleted:
|
|
282
|
+
stats = f"+{_format_lines(item.lines_added)} -{_format_lines(item.lines_deleted)}"
|
|
283
|
+
elif status == "D":
|
|
284
|
+
stats = "deleted"
|
|
285
|
+
elif status == "A":
|
|
286
|
+
stats = "added"
|
|
287
|
+
else:
|
|
288
|
+
stats = ""
|
|
289
|
+
body = f"{status} {path}"
|
|
290
|
+
return f"{body} {stats}" if stats else body
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def render_changed_file_row(
|
|
294
|
+
item: GitChangedFile,
|
|
295
|
+
*,
|
|
296
|
+
path_width: int = _PATH_CAP,
|
|
297
|
+
color_status_m: str = "#f4b183",
|
|
298
|
+
color_status_a: str = "#81c995",
|
|
299
|
+
color_status_d: str = "#f28b82",
|
|
300
|
+
color_status_u: str = "#9aa0a6",
|
|
301
|
+
color_path: str = "#e8eaed",
|
|
302
|
+
color_added: str = "#81c995",
|
|
303
|
+
color_deleted: str = "#f28b82",
|
|
304
|
+
color_muted: str = "#9aa0a6",
|
|
305
|
+
) -> Text:
|
|
306
|
+
"""Styled popover row for one changed file."""
|
|
307
|
+
status = (item.status or "M")[:1]
|
|
308
|
+
if item.is_untracked or status == "?":
|
|
309
|
+
st_style = color_status_u
|
|
310
|
+
elif status == "A":
|
|
311
|
+
st_style = color_status_a
|
|
312
|
+
elif status == "D":
|
|
313
|
+
st_style = color_status_d
|
|
314
|
+
elif status in {"R", "C"}:
|
|
315
|
+
st_style = color_status_m
|
|
316
|
+
else:
|
|
317
|
+
st_style = color_status_m
|
|
318
|
+
|
|
319
|
+
path = item.path or ""
|
|
320
|
+
if path_width > 0 and len(path) > path_width:
|
|
321
|
+
path = "…" + path[-(path_width - 1) :]
|
|
322
|
+
|
|
323
|
+
out = Text()
|
|
324
|
+
out.append(f"{status} ", style=st_style)
|
|
325
|
+
out.append(path, style=color_path)
|
|
326
|
+
if item.is_untracked:
|
|
327
|
+
out.append(" untracked", style=color_muted)
|
|
328
|
+
elif item.lines_added or item.lines_deleted:
|
|
329
|
+
out.append(" ")
|
|
330
|
+
if item.lines_added:
|
|
331
|
+
out.append(f"+{_format_lines(item.lines_added)}", style=color_added)
|
|
332
|
+
if item.lines_deleted:
|
|
333
|
+
out.append(" ")
|
|
334
|
+
if item.lines_deleted:
|
|
335
|
+
out.append(f"-{_format_lines(item.lines_deleted)}", style=color_deleted)
|
|
336
|
+
elif status == "D":
|
|
337
|
+
out.append(" deleted", style=color_deleted)
|
|
338
|
+
elif status == "A":
|
|
339
|
+
out.append(" added", style=color_added)
|
|
340
|
+
return out
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def format_branch_chrome_plain(
|
|
344
|
+
info: GitBranchChrome | None,
|
|
345
|
+
*,
|
|
346
|
+
mark: str = "⎇",
|
|
347
|
+
) -> str:
|
|
348
|
+
"""Plain-text form for tests / fallbacks: ``⎇ main * 3f +120 -45 ↑2↓1``."""
|
|
349
|
+
if info is None or not (info.name or "").strip():
|
|
350
|
+
return ""
|
|
351
|
+
name = info.name.strip()
|
|
352
|
+
parts: list[str] = []
|
|
353
|
+
head = f"{mark} {name}".strip() if (mark or "").strip() else name
|
|
354
|
+
if info.dirty:
|
|
355
|
+
head = f"{head} *"
|
|
356
|
+
parts.append(head)
|
|
357
|
+
# Diff stats (only when there are changes).
|
|
358
|
+
if info.files_changed > 0:
|
|
359
|
+
parts.append(f"{_format_count(info.files_changed)}f")
|
|
360
|
+
if info.lines_added > 0:
|
|
361
|
+
parts.append(f"+{_format_lines(info.lines_added)}")
|
|
362
|
+
if info.lines_deleted > 0:
|
|
363
|
+
parts.append(f"-{_format_lines(info.lines_deleted)}")
|
|
364
|
+
bits = ""
|
|
365
|
+
if info.ahead is not None and int(info.ahead) > 0:
|
|
366
|
+
bits += f"↑{_format_count(int(info.ahead))}"
|
|
367
|
+
if info.behind is not None and int(info.behind) > 0:
|
|
368
|
+
bits += f"↓{_format_count(int(info.behind))}"
|
|
369
|
+
if bits:
|
|
370
|
+
parts.append(bits)
|
|
371
|
+
return " ".join(parts)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def render_branch_chrome(
|
|
375
|
+
info: GitBranchChrome | None,
|
|
376
|
+
*,
|
|
377
|
+
mark: str = "⎇",
|
|
378
|
+
color_clean: str = "#81c995",
|
|
379
|
+
color_dirty: str = "#f28b82",
|
|
380
|
+
color_ahead: str = "#8ab4f8",
|
|
381
|
+
color_behind: str = "#f4b183",
|
|
382
|
+
color_diverged: str = "#e8eaed",
|
|
383
|
+
color_files: str = "#9aa0a6",
|
|
384
|
+
color_added: str = "#81c995",
|
|
385
|
+
color_deleted: str = "#f28b82",
|
|
386
|
+
) -> Text:
|
|
387
|
+
"""Styled branch chrome for the topbar.
|
|
388
|
+
|
|
389
|
+
Color rules:
|
|
390
|
+
- fully clean/synced: green name (no star)
|
|
391
|
+
- dirty: red name + red `` *``
|
|
392
|
+
- diff stats: ``Nf`` (files count, muted), ``+A`` (added, green), ``-D`` (deleted, red)
|
|
393
|
+
- clean but diverged: neutral name; ahead blue; behind orange
|
|
394
|
+
"""
|
|
395
|
+
out = Text()
|
|
396
|
+
if info is None or not (info.name or "").strip():
|
|
397
|
+
return out
|
|
398
|
+
|
|
399
|
+
name = info.name.strip()
|
|
400
|
+
mark_s = (mark or "").strip()
|
|
401
|
+
if info.dirty:
|
|
402
|
+
name_style = color_dirty
|
|
403
|
+
elif info.synced:
|
|
404
|
+
name_style = color_clean
|
|
405
|
+
else:
|
|
406
|
+
name_style = color_diverged
|
|
407
|
+
|
|
408
|
+
if mark_s:
|
|
409
|
+
out.append(f"{mark_s} ", style=name_style)
|
|
410
|
+
out.append(name, style=name_style)
|
|
411
|
+
if info.dirty:
|
|
412
|
+
# Space before dirty mark; mark shares dirty red with the name.
|
|
413
|
+
out.append(" *", style=color_dirty)
|
|
414
|
+
|
|
415
|
+
# Diff stats: files changed + lines added / deleted (vs HEAD).
|
|
416
|
+
if info.files_changed > 0:
|
|
417
|
+
out.append(f" {_format_count(info.files_changed)}f", style=color_files)
|
|
418
|
+
if info.lines_added > 0:
|
|
419
|
+
out.append(f" +{_format_lines(info.lines_added)}", style=color_added)
|
|
420
|
+
if info.lines_deleted > 0:
|
|
421
|
+
out.append(f" -{_format_lines(info.lines_deleted)}", style=color_deleted)
|
|
422
|
+
|
|
423
|
+
bits = Text()
|
|
424
|
+
if info.ahead is not None and int(info.ahead) > 0:
|
|
425
|
+
bits.append(f"↑{_format_count(int(info.ahead))}", style=color_ahead)
|
|
426
|
+
if info.behind is not None and int(info.behind) > 0:
|
|
427
|
+
bits.append(f"↓{_format_count(int(info.behind))}", style=color_behind)
|
|
428
|
+
if bits.plain:
|
|
429
|
+
out.append(" ")
|
|
430
|
+
out.append_text(bits)
|
|
431
|
+
return out
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def probe_git_branch_chrome(cwd: Path | str, *, timeout: float = 0.8) -> GitBranchChrome | None:
|
|
435
|
+
"""Probe local branch name, dirty flag, and upstream ahead/behind."""
|
|
436
|
+
root = Path(cwd)
|
|
437
|
+
name = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=root, timeout=timeout)
|
|
438
|
+
if not name or name == "HEAD":
|
|
439
|
+
return None
|
|
440
|
+
|
|
441
|
+
dirty = False
|
|
442
|
+
porcelain = _run_git(["status", "--porcelain"], cwd=root, timeout=timeout)
|
|
443
|
+
# status returns "" when clean; None on failure → treat as not dirty.
|
|
444
|
+
if porcelain:
|
|
445
|
+
dirty = True
|
|
446
|
+
|
|
447
|
+
ahead: int | None = None
|
|
448
|
+
behind: int | None = None
|
|
449
|
+
# left = upstream-only (behind), right = HEAD-only (ahead)
|
|
450
|
+
counts = _run_git(
|
|
451
|
+
["rev-list", "--left-right", "--count", "@{upstream}...HEAD"],
|
|
452
|
+
cwd=root,
|
|
453
|
+
timeout=timeout,
|
|
454
|
+
)
|
|
455
|
+
if counts:
|
|
456
|
+
parts = counts.split()
|
|
457
|
+
if len(parts) >= 2:
|
|
458
|
+
try:
|
|
459
|
+
behind = max(0, int(parts[0]))
|
|
460
|
+
ahead = max(0, int(parts[1]))
|
|
461
|
+
except ValueError:
|
|
462
|
+
ahead = None
|
|
463
|
+
behind = None
|
|
464
|
+
|
|
465
|
+
files, inserted, deleted = _probe_diff_shortstat(root, timeout=timeout)
|
|
466
|
+
|
|
467
|
+
return GitBranchChrome(
|
|
468
|
+
name=name,
|
|
469
|
+
dirty=dirty,
|
|
470
|
+
ahead=ahead,
|
|
471
|
+
behind=behind,
|
|
472
|
+
files_changed=files,
|
|
473
|
+
lines_added=inserted,
|
|
474
|
+
lines_deleted=deleted,
|
|
475
|
+
)
|