devvy 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cli/ui/__init__.py ADDED
@@ -0,0 +1,55 @@
1
+ """cli.ui — public API re-exports.
2
+
3
+ All callers that do ``from cli.ui import <symbol>`` continue to work
4
+ unchanged after the module was split into sub-modules.
5
+ """
6
+
7
+ from cli.ui.rendering import (
8
+ _TERMINAL_STATES,
9
+ _STATE_COLOURS,
10
+ _fmt_dt,
11
+ _format_elapsed,
12
+ _infer_section_title,
13
+ _LOG_SECTION_PATTERNS,
14
+ _pid_alive,
15
+ _print_outcome,
16
+ console,
17
+ err_console,
18
+ pid_status_text,
19
+ print_resume_banner,
20
+ print_run_banner,
21
+ print_status_panel,
22
+ ProgressTracker,
23
+ render_logs,
24
+ state_text,
25
+ )
26
+ from cli.ui.picker import (
27
+ pick_model,
28
+ _plain_model_prompt,
29
+ _rich_picker,
30
+ )
31
+
32
+ __all__ = [
33
+ # rendering
34
+ "_TERMINAL_STATES",
35
+ "_STATE_COLOURS",
36
+ "_fmt_dt",
37
+ "_format_elapsed",
38
+ "_infer_section_title",
39
+ "_LOG_SECTION_PATTERNS",
40
+ "_pid_alive",
41
+ "_print_outcome",
42
+ "console",
43
+ "err_console",
44
+ "pid_status_text",
45
+ "print_resume_banner",
46
+ "print_run_banner",
47
+ "print_status_panel",
48
+ "ProgressTracker",
49
+ "render_logs",
50
+ "state_text",
51
+ # picker
52
+ "pick_model",
53
+ "_plain_model_prompt",
54
+ "_rich_picker",
55
+ ]
cli/ui/picker.py ADDED
@@ -0,0 +1,179 @@
1
+ """Interactive model picker for the devvy init command.
2
+
3
+ Falls back to a plain numbered prompt in non-TTY environments (e.g. CI,
4
+ CliRunner tests). In a real terminal, renders an inline arrow-key picker
5
+ using raw ANSI escape codes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+
12
+ from cli.ui.rendering import console
13
+
14
+
15
+ def pick_model(choices: list[str], default: str) -> str:
16
+ """
17
+ Interactive arrow-key model picker rendered with rich.
18
+
19
+ Falls back to a plain input() prompt if the terminal is not a TTY
20
+ or if raw-mode setup fails.
21
+
22
+ Returns the selected model string.
23
+ """
24
+ if not sys.stdin.isatty():
25
+ return _plain_model_prompt(choices, default)
26
+
27
+ try:
28
+ return _rich_picker(choices, default)
29
+ except Exception:
30
+ return _plain_model_prompt(choices, default)
31
+
32
+
33
+ def _plain_model_prompt(choices: list[str], default: str) -> str:
34
+ """Simple numbered fallback for non-TTY environments."""
35
+ console.print("\n[bold]Available models:[/bold]")
36
+ for i, c in enumerate(choices, 1):
37
+ marker = " [bold cyan]←[/bold cyan]" if c == default else ""
38
+ console.print(f" [dim]{i:2}.[/dim] {c}{marker}")
39
+ raw = input(f"\nModel number or name [{default}]: ").strip()
40
+ if not raw:
41
+ return default
42
+ if raw.isdigit():
43
+ idx = int(raw) - 1
44
+ if 0 <= idx < len(choices):
45
+ return choices[idx]
46
+ if raw in choices:
47
+ return raw
48
+ return default
49
+
50
+
51
+ def _rich_picker(choices: list[str], default: str) -> str:
52
+ """
53
+ Inline arrow-key model picker using raw terminal input.
54
+
55
+ Renders directly to the terminal using ANSI escape codes — no rich.live.Live,
56
+ which is unreliable when content height changes or approaches terminal height.
57
+
58
+ Keys: ↑/k move up, ↓/j move down, Home, End, Enter to confirm.
59
+ """
60
+ import os
61
+ import termios
62
+ import tty
63
+
64
+ selected = choices.index(default) if default in choices else 0
65
+
66
+ fd = sys.stdin.fileno()
67
+ old_settings = termios.tcgetattr(fd)
68
+
69
+ # ANSI helpers
70
+ HIDE_CURSOR = "\033[?25l"
71
+ SHOW_CURSOR = "\033[?25h"
72
+ CLEAR_LINE = "\033[2K"
73
+ UP = "\033[{n}A"
74
+ BOL = "\r" # beginning of line
75
+
76
+ def _visible_rows() -> int:
77
+ try:
78
+ term_rows = os.get_terminal_size().lines
79
+ except OSError:
80
+ term_rows = 24
81
+ # Reserve rows for: header (3 lines) + 2 indicator lines + 1 bottom blank
82
+ return min(len(choices), max(1, term_rows - 8))
83
+
84
+ def _build_lines(selected_idx: int) -> list[str]:
85
+ """Return the list of plain strings (no Rich markup) to render."""
86
+ visible = _visible_rows()
87
+ start = max(0, selected_idx - visible // 2)
88
+ end = min(len(choices), start + visible)
89
+ start = max(0, end - visible) # clamp near bottom
90
+
91
+ out: list[str] = []
92
+ out.append("")
93
+ out.append(" Select opencode model (↑/↓ to move, Enter to confirm)")
94
+ out.append("")
95
+
96
+ if start > 0:
97
+ out.append(f" ↑ {start} more above")
98
+
99
+ for idx in range(start, end):
100
+ label = choices[idx]
101
+ if idx == selected_idx:
102
+ # Bold + reverse video (works in every terminal)
103
+ out.append(f" \033[1;7m ▶ {label} \033[0m")
104
+ else:
105
+ out.append(f" \033[2m {label}\033[0m")
106
+
107
+ below = len(choices) - end
108
+ if below > 0:
109
+ out.append(f" ↓ {below} more below")
110
+
111
+ out.append("")
112
+ return out
113
+
114
+ # Track how many lines we've printed so we can erase and redraw in place.
115
+ last_line_count = 0
116
+
117
+ def _draw(selected_idx: int) -> None:
118
+ nonlocal last_line_count
119
+ lines = _build_lines(selected_idx)
120
+
121
+ out_parts: list[str] = []
122
+
123
+ # Move cursor up to the first line of the previous render, then
124
+ # overwrite every line in place.
125
+ if last_line_count > 0:
126
+ out_parts.append(UP.format(n=last_line_count - 1))
127
+
128
+ for i, line in enumerate(lines):
129
+ out_parts.append(BOL)
130
+ out_parts.append(CLEAR_LINE)
131
+ out_parts.append(line)
132
+ if i < len(lines) - 1:
133
+ out_parts.append("\n")
134
+
135
+ sys.stdout.write("".join(out_parts))
136
+ sys.stdout.flush()
137
+ last_line_count = len(lines)
138
+
139
+ try:
140
+ sys.stdout.write(HIDE_CURSOR)
141
+ sys.stdout.flush()
142
+ tty.setraw(fd)
143
+
144
+ _draw(selected)
145
+
146
+ while True:
147
+ ch = sys.stdin.read(1)
148
+
149
+ if ch in ("\r", "\n"):
150
+ break
151
+ elif ch == "\x1b":
152
+ seq = sys.stdin.read(2)
153
+ if seq == "[A": # Up arrow
154
+ selected = max(0, selected - 1)
155
+ elif seq == "[B": # Down arrow
156
+ selected = min(len(choices) - 1, selected + 1)
157
+ elif seq == "[H": # Home
158
+ selected = 0
159
+ elif seq == "[F": # End
160
+ selected = len(choices) - 1
161
+ else:
162
+ continue
163
+ elif ch in ("k", "K"):
164
+ selected = max(0, selected - 1)
165
+ elif ch in ("j", "J"):
166
+ selected = min(len(choices) - 1, selected + 1)
167
+ elif ch == "\x03": # Ctrl-C
168
+ raise KeyboardInterrupt
169
+ else:
170
+ continue
171
+
172
+ _draw(selected)
173
+
174
+ finally:
175
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
176
+ sys.stdout.write(SHOW_CURSOR + "\n")
177
+ sys.stdout.flush()
178
+
179
+ return choices[selected]
cli/ui/rendering.py ADDED
@@ -0,0 +1,350 @@
1
+ """Terminal rendering helpers — banners, progress tracking, status panels,
2
+ and log rendering. All output goes through the shared ``console`` and
3
+ ``err_console`` instances defined here.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import re
10
+ import time
11
+ from datetime import UTC, datetime
12
+ from typing import TYPE_CHECKING
13
+
14
+ from rich.console import Console
15
+ from rich.panel import Panel
16
+ from rich.rule import Rule
17
+ from rich.table import Table
18
+ from rich.text import Text
19
+
20
+ from cli.fsm import TicketState
21
+
22
+ if TYPE_CHECKING:
23
+ from cli.models import GraphContext, Ticket
24
+
25
+ # stdout console — primary output
26
+ console = Console()
27
+
28
+ # stderr console — errors / warnings only
29
+ err_console = Console(stderr=True)
30
+
31
+ # Terminal states — derived from the FSM so there's a single source of truth.
32
+ _TERMINAL_STATES = {s.value for s in TicketState if s.is_terminal}
33
+
34
+ # Human-readable labels with leading emoji/symbol handled at render time.
35
+ _STATE_COLOURS = {
36
+ "RECEIVED": "yellow",
37
+ "PREPARE_ENV": "yellow",
38
+ "PLAN": "yellow",
39
+ "IMPLEMENT": "yellow",
40
+ "VALIDATE": "yellow",
41
+ "CREATE_PR": "yellow",
42
+ "WAIT_FOR_REVIEW": "cyan",
43
+ "RESPOND_TO_REVIEW": "yellow",
44
+ "MERGED": "green",
45
+ "FAILED": "red",
46
+ }
47
+
48
+
49
+ def state_text(state: str) -> Text:
50
+ """Return a rich Text object for a state label with colour + symbol."""
51
+ colour = _STATE_COLOURS.get(state, "white")
52
+ if state == "MERGED":
53
+ symbol = "✓"
54
+ elif state == "FAILED":
55
+ symbol = "✗"
56
+ elif state == "WAIT_FOR_REVIEW":
57
+ symbol = "◷"
58
+ else:
59
+ symbol = "●"
60
+ return Text(f"{symbol} {state}", style=f"bold {colour}")
61
+
62
+
63
+ def pid_status_text(pid: int | None, state: str) -> Text:
64
+ """Return a rich Text badge describing the live/crashed/idle run status.
65
+
66
+ Logic:
67
+ - Terminal state → empty (no process badge needed).
68
+ - Non-terminal + pid set + process alive → "Running (pid <n>)"
69
+ - Non-terminal + pid set + process dead → "Crashed (pid <n>)"
70
+ - Non-terminal + pid is None → "Idle / resumable"
71
+ """
72
+ terminal_states = {s.value for s in TicketState if s.is_terminal}
73
+ if state in terminal_states:
74
+ return Text("")
75
+
76
+ if pid is not None:
77
+ alive = _pid_alive(pid)
78
+ if alive:
79
+ return Text(f"Running (pid {pid})", style="bold green")
80
+ else:
81
+ return Text(f"Crashed (pid {pid})", style="bold red")
82
+
83
+ return Text("Idle / resumable", style="dim yellow")
84
+
85
+
86
+ def _pid_alive(pid: int) -> bool:
87
+ """Return True if a process with *pid* is currently running."""
88
+ try:
89
+ os.kill(pid, 0)
90
+ return True
91
+ except ProcessLookupError:
92
+ return False
93
+ except PermissionError:
94
+ # Process exists but we don't own it — treat as alive.
95
+ return True
96
+
97
+
98
+ def print_run_banner(ticket_id: str, title: str, repo_url: str) -> None:
99
+ """Print the styled ticket start banner."""
100
+ table = Table.grid(padding=(0, 1))
101
+ table.add_column(style="bold")
102
+ table.add_column()
103
+ table.add_row("[dim]title[/dim]", title)
104
+ table.add_row("[dim]repo[/dim]", repo_url)
105
+ table.add_row("[dim]id[/dim]", f"[dim]{ticket_id}[/dim]")
106
+ console.print(
107
+ Panel(table, title="[bold]devvy[/bold]", border_style="blue", padding=(1, 2))
108
+ )
109
+
110
+
111
+ def print_resume_banner(
112
+ ticket_id: str, title: str, state: str, branch: str | None, pr_number: int | None
113
+ ) -> None:
114
+ """Print the styled resume banner."""
115
+ table = Table.grid(padding=(0, 1))
116
+ table.add_column(style="bold")
117
+ table.add_column()
118
+ table.add_row("[dim]title[/dim]", title)
119
+ table.add_row("[dim]state[/dim]", state_text(state))
120
+ table.add_row("[dim]branch[/dim]", branch or "[dim]—[/dim]")
121
+ table.add_row("[dim]pr[/dim]", str(pr_number) if pr_number else "[dim]—[/dim]")
122
+ table.add_row("[dim]id[/dim]", f"[dim]{ticket_id}[/dim]")
123
+ console.print(
124
+ Panel(
125
+ table,
126
+ title="[bold]devvy resume[/bold]",
127
+ border_style="blue",
128
+ padding=(1, 2),
129
+ )
130
+ )
131
+
132
+
133
+ class ProgressTracker:
134
+ """
135
+ Tracks FSM state transitions and renders spinner lines to the console.
136
+
137
+ Usage
138
+ -----
139
+ Create an instance, pass ``tracker.on_state_change`` as the
140
+ ``status_callback`` to ``Orchestrator.run()``, then call
141
+ ``tracker.finish()`` after the orchestrator returns.
142
+
143
+ The tracker starts an internal timer when the first state fires, then
144
+ for every subsequent state it prints a "completed" line for the previous
145
+ state (with elapsed time) and starts a new live spinner line.
146
+ """
147
+
148
+ # States we label differently while they're in-progress
149
+ _SPINNER_LABELS: dict[str, str] = {
150
+ "WAIT_FOR_REVIEW": "Waiting for PR review…",
151
+ "PLAN": "Planning…",
152
+ "IMPLEMENT": "Implementing…",
153
+ "VALIDATE": "Running validation…",
154
+ "PREPARE_ENV": "Preparing environment…",
155
+ "CREATE_PR": "Creating PR…",
156
+ "RESPOND_TO_REVIEW": "Responding to review…",
157
+ "RECEIVED": "Received…",
158
+ "MERGED": "Merging…",
159
+ "FAILED": "Failing…",
160
+ }
161
+
162
+ def __init__(self) -> None:
163
+ self._current_state: str | None = None
164
+ self._state_start: float = 0.0
165
+
166
+ def on_state_change(self, state: str) -> None:
167
+ """Called by the orchestrator when it enters a new state."""
168
+ now = time.monotonic()
169
+
170
+ # Print completed line for previous state
171
+ if self._current_state is not None:
172
+ elapsed = now - self._state_start
173
+ self._print_completed(self._current_state, elapsed)
174
+
175
+ self._current_state = state
176
+ self._state_start = now
177
+
178
+ # Print "in progress" line for the new state (non-spinner, since we're
179
+ # in a sync callback called from async context — live rendering would
180
+ # require a Live context, which we can't hold across async boundaries
181
+ # without threading). Instead we print a simple "started" line and
182
+ # let the completed line show the elapsed time when it fires next.
183
+ if state not in _TERMINAL_STATES:
184
+ colour = _STATE_COLOURS.get(state, "white")
185
+ label = self._SPINNER_LABELS.get(state, state)
186
+ console.print(
187
+ f" [bold {colour}]→[/bold {colour}] [bold]{state}[/bold] [dim]{label}[/dim]"
188
+ )
189
+
190
+ def finish(self, ticket: "Ticket") -> None:
191
+ """
192
+ Call after ``orchestrator.run()`` returns. Closes out the last
193
+ state line and prints the final outcome panel.
194
+ """
195
+ now = time.monotonic()
196
+ if (
197
+ self._current_state is not None
198
+ and self._current_state not in _TERMINAL_STATES
199
+ ):
200
+ elapsed = now - self._state_start
201
+ self._print_completed(self._current_state, elapsed)
202
+
203
+ _print_outcome(ticket)
204
+
205
+ @staticmethod
206
+ def _print_completed(state: str, elapsed: float) -> None:
207
+ colour = _STATE_COLOURS.get(state, "white")
208
+ elapsed_str = _format_elapsed(elapsed)
209
+ console.print(
210
+ f" [bold {colour}]✓[/bold {colour}] [bold]{state}[/bold]"
211
+ f" [dim]{elapsed_str}[/dim]"
212
+ )
213
+
214
+
215
+ def _format_elapsed(seconds: float) -> str:
216
+ """Format a duration in seconds as '3s' or '1m 14s'."""
217
+ s = int(seconds)
218
+ if s < 60:
219
+ return f"{s}s"
220
+ m, s = divmod(s, 60)
221
+ return f"{m}m {s}s"
222
+
223
+
224
+ def _print_outcome(ticket: "Ticket") -> None:
225
+ """Print the final merged / failed outcome panel."""
226
+ if ticket.state == "MERGED":
227
+ msg = Text("✓ PR merged successfully.", style="bold green")
228
+ console.print(Panel(msg, border_style="green", padding=(0, 2)))
229
+ else:
230
+ error = ticket.error_message or "unknown error"
231
+ msg = Text(f"✗ Failed: {error}", style="bold red")
232
+ console.print(Panel(msg, border_style="red", padding=(0, 2)))
233
+
234
+
235
+ _DATETIME_FMT = "%d %b %Y, %H:%M UTC"
236
+
237
+
238
+ def _fmt_dt(dt: datetime | None) -> str:
239
+ if dt is None:
240
+ return "—"
241
+ if dt.tzinfo is None:
242
+ dt = dt.replace(tzinfo=UTC)
243
+ return dt.strftime(_DATETIME_FMT)
244
+
245
+
246
+ def print_status_panel(ticket: "Ticket", ctx: "GraphContext | None") -> None:
247
+ """Render the ticket status as a rich Panel."""
248
+ table = Table.grid(padding=(0, 2))
249
+ table.add_column(style="bold dim", min_width=10)
250
+ table.add_column()
251
+
252
+ table.add_row("id", f"[dim]{ticket.id}[/dim]")
253
+ table.add_row("title", ticket.title)
254
+ table.add_row("state", state_text(ticket.state))
255
+
256
+ # Process liveness badge — only meaningful for non-terminal tickets.
257
+ worker_pid = ctx.worker_pid if ctx else None
258
+ process_badge = pid_status_text(worker_pid, ticket.state)
259
+ if process_badge.plain:
260
+ table.add_row("process", process_badge)
261
+
262
+ table.add_row("branch", ticket.branch_name or "[dim]—[/dim]")
263
+ table.add_row("pr", str(ctx.pr_number) if ctx and ctx.pr_number else "[dim]—[/dim]")
264
+ table.add_row("retries", str(ctx.retry_count if ctx else 0))
265
+ table.add_row("created", _fmt_dt(ticket.created_at))
266
+ table.add_row("updated", _fmt_dt(ticket.updated_at))
267
+
268
+ if ticket.error_message:
269
+ table.add_row("error", f"[bold red]{ticket.error_message}[/bold red]")
270
+
271
+ # Panel border colour reflects terminal state
272
+ border = (
273
+ "green"
274
+ if ticket.state == "MERGED"
275
+ else "red"
276
+ if ticket.state == "FAILED"
277
+ else "cyan"
278
+ if ticket.state == "WAIT_FOR_REVIEW"
279
+ else "yellow"
280
+ )
281
+ title_text = Text(f"ticket · {ticket.id[:8]}", style="bold")
282
+ console.print(Panel(table, title=title_text, border_style=border, padding=(1, 2)))
283
+
284
+
285
+ # Maps log message prefixes to a human-readable section title.
286
+ # Matched in order — first match wins.
287
+ _LOG_SECTION_PATTERNS: list[tuple[str, str]] = [
288
+ (r"Workspace:", "PREPARE_ENV"),
289
+ (r"Plan \(session", "PLAN"),
290
+ (r"Implementation:", "IMPLEMENT"),
291
+ (r"Validation:", "VALIDATE"),
292
+ (r"Self-fix:", "VALIDATE · self-fix"),
293
+ (r"PR description generated", "CREATE_PR · PR description"),
294
+ (r"PR #\d+ created:", "CREATE_PR"),
295
+ (r"Waiting for PR review", "WAIT_FOR_REVIEW"),
296
+ (r"ADO poll error:", "WAIT_FOR_REVIEW · error"),
297
+ (r"Review response:", "RESPOND_TO_REVIEW"),
298
+ (r"Posted reply to thread", "RESPOND_TO_REVIEW · reply"),
299
+ (r"Failed to post reply", "RESPOND_TO_REVIEW · error"),
300
+ (r"Ticket complete", "MERGED"),
301
+ ]
302
+
303
+
304
+ def _infer_section_title(message: str) -> str:
305
+ for pattern, title in _LOG_SECTION_PATTERNS:
306
+ if re.match(pattern, message.lstrip()):
307
+ return title
308
+ return "LOG"
309
+
310
+
311
+ def render_logs(log_text: str) -> None:
312
+ """Parse raw log blob and render as structured sections with rich rules."""
313
+ if not log_text.strip():
314
+ console.print("[dim]No logs yet.[/dim]")
315
+ return
316
+
317
+ # Split on lines that start with an ISO timestamp bracket, e.g.
318
+ # [2026-02-28T10:45:00.000000+00:00] message…
319
+ entry_re = re.compile(
320
+ r"^\[(\d{4}-\d{2}-\d{2}T[\d:.+\-]+)\] (.+)", re.DOTALL | re.MULTILINE
321
+ )
322
+
323
+ # We'll use findall for clarity.
324
+ entries = entry_re.findall(log_text)
325
+
326
+ if not entries:
327
+ # Fallback: just print the raw blob
328
+ console.print(log_text)
329
+ return
330
+
331
+ for ts_str, message in entries:
332
+ # Parse and reformat the timestamp
333
+ try:
334
+ dt = datetime.fromisoformat(ts_str)
335
+ ts_display = dt.strftime("%d %b %H:%M UTC")
336
+ except ValueError:
337
+ ts_display = ts_str
338
+
339
+ section_title = _infer_section_title(message)
340
+ colour = _STATE_COLOURS.get(section_title.split(" ")[0], "blue")
341
+
342
+ console.print(
343
+ Rule(
344
+ f"[bold {colour}]{section_title}[/bold {colour}]"
345
+ f" [dim]{ts_display}[/dim]",
346
+ style="dim",
347
+ )
348
+ )
349
+ console.print(message.strip())
350
+ console.print()