diffbutler 0.5.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.
Files changed (105) hide show
  1. diffbutler/__init__.py +1 -0
  2. diffbutler/__main__.py +6 -0
  3. diffbutler/config/__init__.py +1 -0
  4. diffbutler/config/default.toml +91 -0
  5. diffbutler/config/diagnostics.py +90 -0
  6. diffbutler/config/file.py +30 -0
  7. diffbutler/config/instance.py +55 -0
  8. diffbutler/config/settings.py +297 -0
  9. diffbutler/config/setup.py +92 -0
  10. diffbutler/config/transport.py +65 -0
  11. diffbutler/core/__init__.py +1 -0
  12. diffbutler/core/application.py +103 -0
  13. diffbutler/core/context.py +54 -0
  14. diffbutler/core/dashboard.py +393 -0
  15. diffbutler/core/doctor.py +200 -0
  16. diffbutler/core/lifecycle.py +70 -0
  17. diffbutler/core/managed.py +441 -0
  18. diffbutler/core/native.py +301 -0
  19. diffbutler/core/polling.py +118 -0
  20. diffbutler/core/process.py +64 -0
  21. diffbutler/core/repositories.py +260 -0
  22. diffbutler/core/reset.py +34 -0
  23. diffbutler/core/resources.py +50 -0
  24. diffbutler/core/review.py +331 -0
  25. diffbutler/core/setup.py +232 -0
  26. diffbutler/core/snapshot.py +70 -0
  27. diffbutler/core/sync.py +259 -0
  28. diffbutler/core/worker.py +205 -0
  29. diffbutler/core/workspaces.py +139 -0
  30. diffbutler/domain/__init__.py +1 -0
  31. diffbutler/domain/dashboard.py +63 -0
  32. diffbutler/domain/diff.py +285 -0
  33. diffbutler/domain/forge.py +123 -0
  34. diffbutler/domain/lifecycle.py +51 -0
  35. diffbutler/domain/polling.py +92 -0
  36. diffbutler/domain/process.py +29 -0
  37. diffbutler/domain/redaction.py +61 -0
  38. diffbutler/domain/resources.py +50 -0
  39. diffbutler/domain/review.py +167 -0
  40. diffbutler/domain/sync.py +196 -0
  41. diffbutler/forges/__init__.py +1 -0
  42. diffbutler/forges/base.py +39 -0
  43. diffbutler/forges/diagnostics.py +104 -0
  44. diffbutler/forges/github/__init__.py +1 -0
  45. diffbutler/forges/github/adapter.py +352 -0
  46. diffbutler/forges/github/auth.py +49 -0
  47. diffbutler/forges/github/http.py +326 -0
  48. diffbutler/forges/github/mapping.py +157 -0
  49. diffbutler/forges/github/setup.py +86 -0
  50. diffbutler/forges/registry.py +23 -0
  51. diffbutler/py.typed +0 -0
  52. diffbutler/reviewers/__init__.py +1 -0
  53. diffbutler/reviewers/base.py +101 -0
  54. diffbutler/reviewers/extraction.py +198 -0
  55. diffbutler/reviewers/native.py +238 -0
  56. diffbutler/reviewers/parsing.py +247 -0
  57. diffbutler/reviewers/presets.toml +12 -0
  58. diffbutler/reviewers/process.py +78 -0
  59. diffbutler/reviewers/prompt.py +148 -0
  60. diffbutler/reviewers/redaction.py +30 -0
  61. diffbutler/reviewers/resources.py +162 -0
  62. diffbutler/sources/__init__.py +1 -0
  63. diffbutler/sources/diagnostics.py +100 -0
  64. diffbutler/sources/fetch.py +285 -0
  65. diffbutler/sources/git.py +461 -0
  66. diffbutler/sources/remotes.py +82 -0
  67. diffbutler/store/__init__.py +1 -0
  68. diffbutler/store/base.py +88 -0
  69. diffbutler/store/dashboard.py +201 -0
  70. diffbutler/store/database.py +172 -0
  71. diffbutler/store/lifecycle.py +247 -0
  72. diffbutler/store/migrations/__init__.py +1 -0
  73. diffbutler/store/migrations/env.py +14 -0
  74. diffbutler/store/migrations/versions/0001_initial.py +147 -0
  75. diffbutler/store/migrations/versions/0002_monitoring.py +38 -0
  76. diffbutler/store/migrations/versions/0003_reviews.py +72 -0
  77. diffbutler/store/migrations/versions/0004_claims.py +22 -0
  78. diffbutler/store/migrations/versions/0005_results.py +27 -0
  79. diffbutler/store/migrations/versions/0006_anchors.py +17 -0
  80. diffbutler/store/migrations/versions/0007_lifecycle.py +36 -0
  81. diffbutler/store/migrations/versions/0008_processes.py +30 -0
  82. diffbutler/store/migrations/versions/0009_dashboard.py +43 -0
  83. diffbutler/store/migrations/versions/0010_run_reasons.py +17 -0
  84. diffbutler/store/migrations/versions/__init__.py +1 -0
  85. diffbutler/store/models.py +290 -0
  86. diffbutler/store/process.py +75 -0
  87. diffbutler/store/queue.py +289 -0
  88. diffbutler/store/review.py +583 -0
  89. diffbutler/ui/__init__.py +1 -0
  90. diffbutler/ui/cli/__init__.py +532 -0
  91. diffbutler/ui/cli/editor.py +33 -0
  92. diffbutler/ui/tui/__init__.py +1 -0
  93. diffbutler/ui/tui/_fake_agent.py +47 -0
  94. diffbutler/ui/tui/_foreground_exec.py +23 -0
  95. diffbutler/ui/tui/app.py +34 -0
  96. diffbutler/ui/tui/browse.py +119 -0
  97. diffbutler/ui/tui/browser.py +42 -0
  98. diffbutler/ui/tui/dashboard.py +926 -0
  99. diffbutler/ui/tui/foreground.py +103 -0
  100. diffbutler/ui/tui/handoff_probe.py +184 -0
  101. diffbutler-0.5.0.dist-info/METADATA +170 -0
  102. diffbutler-0.5.0.dist-info/RECORD +105 -0
  103. diffbutler-0.5.0.dist-info/WHEEL +4 -0
  104. diffbutler-0.5.0.dist-info/entry_points.txt +3 -0
  105. diffbutler-0.5.0.dist-info/licenses/LICENSE.txt +21 -0
@@ -0,0 +1,532 @@
1
+ """Typer interface to the shared core; terminal rendering stays here."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import sys
7
+ from collections.abc import Coroutine, Sequence
8
+ from enum import StrEnum
9
+ from importlib.metadata import version
10
+ from pathlib import Path
11
+ from typing import Annotated, Any
12
+
13
+ import typer
14
+ from pydantic import TypeAdapter
15
+ from sqlalchemy.exc import SQLAlchemyError
16
+
17
+ from diffbutler.config.diagnostics import configure_logging, stop_logging
18
+ from diffbutler.config.file import show_config_file
19
+ from diffbutler.config.instance import single_instance
20
+ from diffbutler.config.settings import ConfigError
21
+ from diffbutler.core.application import (
22
+ browse_configured,
23
+ follow_configured,
24
+ local_queue,
25
+ sync_configured,
26
+ )
27
+ from diffbutler.core.doctor import run_doctor
28
+ from diffbutler.core.managed import (
29
+ approved_configured,
30
+ cleanup_configured,
31
+ findings_configured,
32
+ handoff_configured,
33
+ review_configured,
34
+ )
35
+ from diffbutler.core.native import foreground_environment
36
+ from diffbutler.core.reset import reset_directories, reset_paths
37
+ from diffbutler.core.sync import PullRequestRow
38
+ from diffbutler.domain.forge import PRPage, PRQuery, PullRequest
39
+ from diffbutler.domain.lifecycle import CleanupStatus
40
+ from diffbutler.domain.review import ReviewConflict
41
+ from diffbutler.forges.base import ForgeError
42
+ from diffbutler.reviewers.native import NativeError
43
+ from diffbutler.reviewers.process import HostBootError
44
+ from diffbutler.sources.git import SourceError
45
+ from diffbutler.store.database import DatabaseError
46
+ from diffbutler.ui.cli.editor import edit_file
47
+
48
+ app = typer.Typer(
49
+ add_completion=False,
50
+ rich_markup_mode=None,
51
+ pretty_exceptions_enable=False,
52
+ context_settings={"help_option_names": ["-h", "--help"]},
53
+ help="A terminal dashboard for human-directed AI code reviews.",
54
+ epilog="Run diffbutler dashboard for the interactive queue, or history for completed cycles.",
55
+ )
56
+ ConfigOption = Annotated[Path | None, typer.Option(help="Explicit TOML configuration file")]
57
+ ForgeOption = Annotated[int, typer.Option(min=1, help="Configured forge, numbered from one")]
58
+
59
+
60
+ class LogLevel(StrEnum):
61
+ DEBUG = "debug"
62
+ INFO = "info"
63
+ WARNING = "warning"
64
+ ERROR = "error"
65
+ CRITICAL = "critical"
66
+
67
+
68
+ def run[T](operation: Coroutine[Any, Any, T]) -> T:
69
+ try:
70
+ return asyncio.run(operation)
71
+ except (
72
+ ConfigError,
73
+ DatabaseError,
74
+ ForgeError,
75
+ ReviewConflict,
76
+ NativeError,
77
+ HostBootError,
78
+ SourceError,
79
+ ) as exc:
80
+ logging.getLogger(__name__).exception("CLI operation failed")
81
+ typer.echo(f"Error: {exc}", err=True)
82
+ raise typer.Exit(1) from None
83
+ except TimeoutError:
84
+ logging.getLogger(__name__).exception("CLI operation timed out")
85
+ typer.echo("Error: operation timed out; inspect the saved run status", err=True)
86
+ raise typer.Exit(1) from None
87
+ except OSError, SQLAlchemyError:
88
+ logging.getLogger(__name__).exception("CLI storage operation failed")
89
+ typer.echo(
90
+ "Error: local storage unavailable; check permissions, space, and database integrity",
91
+ err=True,
92
+ )
93
+ raise typer.Exit(1) from None
94
+
95
+
96
+ def show_version(value: bool) -> None:
97
+ if value:
98
+ typer.echo(f"diffbutler {version('diffbutler')}")
99
+ raise typer.Exit()
100
+
101
+
102
+ @app.callback(invoke_without_command=True)
103
+ def root(
104
+ context: typer.Context,
105
+ log_level: Annotated[
106
+ LogLevel,
107
+ typer.Option(
108
+ "--log-level",
109
+ case_sensitive=False,
110
+ help="File log verbosity; debug includes redacted Git diagnostics.",
111
+ ),
112
+ ] = LogLevel.INFO,
113
+ version_flag: Annotated[
114
+ bool, typer.Option("--version", "-v", callback=show_version, is_eager=True)
115
+ ] = False,
116
+ ) -> None:
117
+ try:
118
+ context.with_resource(single_instance())
119
+ except ConfigError as exc:
120
+ typer.echo(f"Error: {exc}", err=True)
121
+ raise typer.Exit(1) from None
122
+ if context.invoked_subcommand != "reset":
123
+ try:
124
+ configure_logging(level=getattr(logging, log_level.value.upper()))
125
+ except OSError:
126
+ typer.echo(
127
+ "Warning: diagnostic log unavailable; check state directory permissions.", err=True
128
+ )
129
+ logging.getLogger(__name__).info(
130
+ "Application startup command=%s", context.invoked_subcommand or "default"
131
+ )
132
+ if context.invoked_subcommand is None:
133
+ if sys.stdin.isatty() and sys.stdout.isatty():
134
+ dashboard()
135
+ else:
136
+ typer.echo(context.get_help())
137
+
138
+
139
+ @app.command()
140
+ def config(
141
+ config: Annotated[
142
+ Path | None, typer.Option("--config", help="TOML file to show or create with defaults")
143
+ ] = None,
144
+ edit: Annotated[bool, typer.Option("--edit", help="Open the config file in $EDITOR")] = False,
145
+ ) -> None:
146
+ """Show or edit the config file, creating defaults if missing, then print its path."""
147
+ path, content = run(asyncio.to_thread(show_config_file, config))
148
+ if edit:
149
+ run(edit_file(path))
150
+ else:
151
+ typer.echo(content, nl=False)
152
+ if content and not content.endswith("\n"):
153
+ typer.echo()
154
+ typer.echo(f"\nConfig file: {path}")
155
+
156
+
157
+ @app.command()
158
+ def reset(
159
+ force: Annotated[
160
+ bool,
161
+ typer.Option(
162
+ "--force", help="Remove configuration, data, and diagnostic logs without confirmation"
163
+ ),
164
+ ] = False,
165
+ ) -> None:
166
+ """Remove standard DiffButler configuration, data, and state directories."""
167
+ paths = reset_paths()
168
+ typer.echo("This will permanently remove these DiffButler directories and their contents:")
169
+ for path in paths:
170
+ typer.echo(f" {safe(str(path))}")
171
+ if not force:
172
+ typer.confirm("Are you sure?", default=False, abort=True)
173
+ stop_logging()
174
+ run(reset_directories(paths))
175
+ typer.echo("DiffButler configuration, data, and diagnostic logs removed.")
176
+
177
+
178
+ @app.command()
179
+ def doctor(
180
+ config: ConfigOption = None,
181
+ repo: str | None = None,
182
+ offline: bool = False,
183
+ db_url: str | None = None,
184
+ no_migrate: bool = False,
185
+ ) -> None:
186
+ """Validate configuration, SQLite, and configured forge access."""
187
+ db: dict[str, object] = {}
188
+ if db_url is not None:
189
+ db["url"] = db_url
190
+ if no_migrate:
191
+ db["auto_migrate"] = False
192
+ report = run(
193
+ run_doctor(config, repo=repo, overrides={"db": db} if db else None, offline=offline)
194
+ )
195
+ for check in report.checks:
196
+ typer.echo(f"{check.status:4} {check.name:20} {check.detail}")
197
+ if not report.passed:
198
+ raise typer.Exit(1)
199
+
200
+
201
+ @app.command()
202
+ def sync(
203
+ config: ConfigOption = None,
204
+ forge: Annotated[int | None, typer.Option(min=1)] = None,
205
+ json_output: Annotated[bool, typer.Option("--json")] = False,
206
+ ) -> None:
207
+ """Persist requests and refresh followed PRs. Never starts AI reviews."""
208
+ reports = run(sync_configured(config, forge))
209
+ if json_output:
210
+ typer.echo(json.dumps([report.model_dump(mode="json") for report in reports]))
211
+ else:
212
+ for report in reports:
213
+ typer.echo(
214
+ f"{report.account.login}: {report.discovered} requested, "
215
+ f"{report.refreshed} refreshed, {report.active} active"
216
+ )
217
+ for warning in report.warnings:
218
+ typer.echo(f"Warning: {safe(warning)}", err=True)
219
+ if any(not report.complete for report in reports):
220
+ raise typer.Exit(1)
221
+
222
+
223
+ class View(StrEnum):
224
+ active = "active"
225
+ requested = "requested"
226
+ following = "following"
227
+ all = "all"
228
+
229
+
230
+ class PRState(StrEnum):
231
+ open = "open"
232
+ closed = "closed"
233
+ merged = "merged"
234
+ all = "all"
235
+
236
+
237
+ def safe(value: str) -> str:
238
+ """Do not let forge-controlled strings execute terminal escape sequences."""
239
+ return "".join(character if character.isprintable() else " " for character in value)
240
+
241
+
242
+ def print_pr(pr: PullRequest, state: str = "", account: str = "") -> None:
243
+ ref = pr.ref
244
+ typer.echo(
245
+ safe(
246
+ f"{account} {ref.namespace}/{ref.repository}#{ref.number} "
247
+ f"{state or pr.state} {pr.title} {pr.url}"
248
+ ).strip()
249
+ )
250
+
251
+
252
+ def print_row(row: PullRequestRow) -> None:
253
+ for warning in row.warnings:
254
+ typer.echo(f"Warning: {safe(warning)}", err=True)
255
+ print_pr(row.tracked.pull_request, row.tracked.state, row.account.login)
256
+ if row.tracked.sources:
257
+ typer.echo(" requested: " + safe(", ".join(row.tracked.sources)))
258
+
259
+
260
+ @app.command()
261
+ def ls(
262
+ config: ConfigOption = None,
263
+ view: View = View.active,
264
+ forge: Annotated[int | None, typer.Option(min=1)] = None,
265
+ login: str | None = None,
266
+ repo: str | None = None,
267
+ author: str | None = None,
268
+ state: PRState = PRState.open,
269
+ text: str = "",
270
+ cursor: str | None = None,
271
+ page_size: Annotated[int, typer.Option(min=1, max=100)] = 30,
272
+ json_output: Annotated[bool, typer.Option("--json")] = False,
273
+ ) -> None:
274
+ """List the local queue, or browse accessible PRs with --view all."""
275
+ if view == View.all:
276
+ if login is not None:
277
+ raise typer.BadParameter("--login filters local accounts; it requires a local view")
278
+ try:
279
+ query = PRQuery(
280
+ repository=repo, author=author, state=state.value, text=text, page_size=page_size
281
+ )
282
+ except ValueError as exc:
283
+ raise typer.BadParameter(str(exc)) from None
284
+ page = run(browse_configured(config, forge or 1, query, cursor))
285
+ if json_output:
286
+ typer.echo(TypeAdapter(PRPage).dump_json(page).decode())
287
+ else:
288
+ for pr in page.items:
289
+ print_pr(pr)
290
+ for warning in page.warnings:
291
+ typer.echo(f"Warning: {safe(warning)}", err=True)
292
+ if page.next_cursor:
293
+ typer.echo(f"Next page: --cursor {safe(page.next_cursor)}")
294
+ if not page.complete:
295
+ raise typer.Exit(1)
296
+ else:
297
+ if forge is not None:
298
+ raise typer.BadParameter("--forge selects remote browsing; use --view all")
299
+ if repo or author or text or cursor or state != PRState.open or page_size != 30:
300
+ raise typer.BadParameter("Search filters require --view all")
301
+ rows = run(local_queue(config, view.value, login))
302
+ if json_output:
303
+ typer.echo(json.dumps([row.model_dump(mode="json") for row in rows]))
304
+ else:
305
+ for row in rows:
306
+ print_row(row)
307
+
308
+
309
+ @app.command()
310
+ def follow(
311
+ pr: Annotated[str, typer.Argument(help="owner/repo#number or a PR URL")],
312
+ config: ConfigOption = None,
313
+ forge: ForgeOption = 1,
314
+ json_output: Annotated[bool, typer.Option("--json")] = False,
315
+ ) -> None:
316
+ """Follow an accessible PR without starting an AI review."""
317
+ row = run(follow_configured(config, forge, pr))
318
+ if json_output:
319
+ typer.echo(row.model_dump_json())
320
+ else:
321
+ print_row(row)
322
+
323
+
324
+ @app.command()
325
+ def review(
326
+ pr: str,
327
+ config: ConfigOption = None,
328
+ login: str | None = None,
329
+ reviewer: str | None = None,
330
+ ) -> None:
331
+ """Explicitly run a source-only native review of a synced/followed PR."""
332
+ result = run(review_configured(config, pr, login, reviewer))
333
+ typer.echo(result.model_dump_json())
334
+ if result.status == "failed":
335
+ raise typer.Exit(1)
336
+
337
+
338
+ @app.command()
339
+ def findings(pr: str, config: ConfigOption = None, login: str | None = None) -> None:
340
+ """Read saved review drafts or legacy findings, including after cleanup."""
341
+ result = run(findings_configured(config, pr, login))
342
+ typer.echo(
343
+ "No saved review result."
344
+ if result is None
345
+ else "\n".join(safe(line) for line in result.markdown.split("\n"))
346
+ if result.markdown is not None
347
+ else result.model_dump_json(exclude={"stdout", "stderr"})
348
+ )
349
+
350
+
351
+ @app.command()
352
+ def approved(pr: str, config: ConfigOption = None, login: str | None = None) -> None:
353
+ """Record your completed approval locally and clean owned resources; never post it."""
354
+ status = run(approved_configured(config, pr, login))
355
+ typer.echo("Completion recorded locally.")
356
+ print_cleanup(status)
357
+
358
+
359
+ def print_cleanup(status: CleanupStatus) -> None:
360
+ typer.echo(
361
+ f"Cleanup jobs (all history): {status.succeeded} succeeded, {status.pending} pending, "
362
+ f"{status.running} running, {status.failed} failed."
363
+ )
364
+ if status.failed:
365
+ typer.echo("Cleanup failed; resources retained for retry after backoff.", err=True)
366
+ raise typer.Exit(1)
367
+
368
+
369
+ @app.command()
370
+ def cleanup(config: ConfigOption = None) -> None:
371
+ """Retry due completion cleanup jobs without starting reviews."""
372
+ print_cleanup(run(cleanup_configured(config)))
373
+
374
+
375
+ @app.command()
376
+ def session(pr: str, config: ConfigOption = None, login: str | None = None) -> None:
377
+ """Explicitly resume the exact saved native conversation in this terminal."""
378
+ from diffbutler.ui.tui.foreground import ForegroundExitUncertain, run_foreground
379
+
380
+ async def attach() -> int:
381
+ try:
382
+ async with handoff_configured(config, pr, login) as (launch, started):
383
+ return await run_foreground(
384
+ launch.argv,
385
+ cwd=launch.cwd,
386
+ env=foreground_environment(launch),
387
+ started=started,
388
+ )
389
+ except ForegroundExitUncertain:
390
+ raise NativeError("Foreground exit is uncertain; ownership retained") from None
391
+
392
+ code = run(attach())
393
+ from diffbutler.core.application import local_store
394
+ from diffbutler.core.managed import resolve_local
395
+
396
+ async def pending() -> bool:
397
+ async with local_store(config) as (queue, _):
398
+ return (await resolve_local(queue, pr, login)).tracked.refresh_pending
399
+
400
+ if run(pending()):
401
+ typer.echo("Refresh pending; run sync to reconcile forge activity.", err=True)
402
+ if code:
403
+ raise typer.Exit(code)
404
+
405
+
406
+ def main(argv: Sequence[str] | None = None) -> None:
407
+ """Preserve callable success behavior while Typer handles usage errors."""
408
+ arguments = list(argv) if argv is not None else sys.argv[1:]
409
+ try:
410
+ app(args=arguments, prog_name="diffbutler")
411
+ except Exception:
412
+ logging.getLogger(__name__).exception("Unhandled application failure")
413
+ raise
414
+ except SystemExit as exc:
415
+ if exc.code or any(arg in {"-h", "--help", "-v", "--version"} for arg in arguments):
416
+ raise
417
+
418
+
419
+ @app.command()
420
+ def dashboard(config: ConfigOption = None, offline: bool = False) -> None:
421
+ """Open the dashboard; --offline reads local state without polling the forge."""
422
+ from diffbutler.config.settings import load_settings
423
+ from diffbutler.ui.tui.dashboard import Dashboard
424
+
425
+ async def launch() -> None:
426
+ from diffbutler.core.setup import startup_settings
427
+
428
+ async def ask(label: str, default: str) -> str:
429
+ return typer.prompt(label, default=default or None)
430
+
431
+ settings = (
432
+ await asyncio.to_thread(load_settings, config)
433
+ if offline
434
+ else await startup_settings(
435
+ config,
436
+ directory=Path.cwd(),
437
+ ask=ask if sys.stdin.isatty() and sys.stdout.isatty() else None,
438
+ progress=lambda message: typer.echo(message, err=True),
439
+ )
440
+ )
441
+ await Dashboard(config, settings, offline=offline).run_async()
442
+
443
+ run(launch())
444
+
445
+
446
+ @app.command()
447
+ def waiting(pr: str, config: ConfigOption = None, login: str | None = None) -> None:
448
+ """Explicitly wait for author changes without posting feedback."""
449
+ from diffbutler.core.dashboard import state_configured
450
+
451
+ run(state_configured(config, pr, login, waiting=True))
452
+ typer.echo("Waiting for author changes.")
453
+
454
+
455
+ @app.command(name="open")
456
+ def open_pr(pr: str, config: ConfigOption = None, login: str | None = None) -> None:
457
+ """Choose manual review and open the browser; never submit approval."""
458
+ from diffbutler.config.settings import load_settings
459
+ from diffbutler.core.dashboard import state_configured
460
+ from diffbutler.ui.tui.browser import open_browser
461
+
462
+ async def open_selected() -> None:
463
+ url = await state_configured(config, pr, login, waiting=False)
464
+ settings = await asyncio.to_thread(load_settings, config)
465
+ try:
466
+ opened = await open_browser(url, settings.ui.browser_command)
467
+ except OSError, TimeoutError:
468
+ opened = False
469
+ typer.echo(
470
+ "Manual review chosen. " + ("Browser opened." if opened else "Open: " + safe(url))
471
+ )
472
+
473
+ run(open_selected())
474
+
475
+
476
+ @app.command()
477
+ def history(
478
+ config: ConfigOption = None,
479
+ repo: str | None = None,
480
+ number: int | None = None,
481
+ reason: str | None = None,
482
+ login: str | None = None,
483
+ since: str | None = None,
484
+ until: str | None = None,
485
+ task: str | None = None,
486
+ json_output: Annotated[bool, typer.Option("--json")] = False,
487
+ ) -> None:
488
+ """Read completed cycles offline, or --task UUID for retained analysis and receipts."""
489
+ from datetime import UTC, datetime
490
+ from uuid import UUID
491
+
492
+ from diffbutler.core.dashboard import HistoryFilter, detail_configured, history_configured
493
+ from diffbutler.domain.dashboard import CycleRecord
494
+
495
+ def date(value: str | None) -> datetime | None:
496
+ if value is None:
497
+ return None
498
+ try:
499
+ parsed = datetime.fromisoformat(value)
500
+ return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed.astimezone(UTC)
501
+ except ValueError:
502
+ raise typer.BadParameter("Dates must use ISO format") from None
503
+
504
+ if task:
505
+ try:
506
+ task_id = UUID(task)
507
+ except ValueError:
508
+ raise typer.BadParameter("--task must be a cycle UUID") from None
509
+ detail = run(detail_configured(config, task_id))
510
+ typer.echo(detail.model_dump_json())
511
+ return
512
+ rows = run(
513
+ history_configured(
514
+ config,
515
+ HistoryFilter(
516
+ repository=repo,
517
+ number=number,
518
+ reason=reason,
519
+ login=login,
520
+ since=date(since),
521
+ until=date(until),
522
+ ),
523
+ )
524
+ )
525
+ if json_output:
526
+ typer.echo(TypeAdapter(tuple[CycleRecord, ...]).dump_json(rows).decode())
527
+ else:
528
+ for row in rows:
529
+ print_pr(
530
+ row.pull_request, f"cycle {row.cycle} · {row.completion_reason}", row.account.login
531
+ )
532
+ typer.echo(safe(f" {row.task_id} · {row.completed_at} · cleanup {row.cleanup_status}"))
@@ -0,0 +1,33 @@
1
+ """Launch the user's editor with direct argv execution and terminal access."""
2
+
3
+ import asyncio
4
+ import os
5
+ import shlex
6
+ from contextlib import suppress
7
+ from pathlib import Path
8
+
9
+ from diffbutler.config.settings import ConfigError
10
+
11
+
12
+ async def edit_file(path: Path) -> None:
13
+ try:
14
+ command = shlex.split(os.environ.get("EDITOR", ""))
15
+ except ValueError:
16
+ raise ConfigError("EDITOR contains invalid quoting") from None
17
+ if not command:
18
+ raise ConfigError("Set EDITOR to the editor command to use with --edit")
19
+
20
+ try:
21
+ # Inherit the terminal and environment; the path is always one argument.
22
+ process = await asyncio.create_subprocess_exec(*command, str(path))
23
+ except OSError, ValueError:
24
+ raise ConfigError("Could not launch EDITOR; check the executable and arguments") from None
25
+ try:
26
+ code = await process.wait()
27
+ finally:
28
+ if process.returncode is None:
29
+ with suppress(ProcessLookupError):
30
+ process.kill()
31
+ await process.wait()
32
+ if code:
33
+ raise ConfigError(f"Editor exited with status {code}")
@@ -0,0 +1 @@
1
+ """Terminal dashboard and foreground terminal handoff."""
@@ -0,0 +1,47 @@
1
+ """Offline interactive fixture for the handoff experiment, not a model backend."""
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+ import time
8
+ import tty
9
+ from pathlib import Path
10
+
11
+
12
+ def main() -> None:
13
+ parser = argparse.ArgumentParser()
14
+ parser.add_argument("--session", type=Path)
15
+ parser.add_argument("--ticker", action="store_true")
16
+ args = parser.parse_args()
17
+ if args.ticker:
18
+ while True:
19
+ print("BACKGROUND OUTPUT MUST NOT REACH TERMINAL", flush=True)
20
+ time.sleep(0.1)
21
+ session = Path(str(args.session))
22
+ data: dict[str, str | int] = json.loads(session.read_text())
23
+ data["resumes"] = int(data["resumes"]) + 1
24
+ session.write_text(json.dumps(data))
25
+ print(f"FAKE_AGENT_READY session={data['id']} resumes={data['resumes']}", flush=True)
26
+ print("Commands: exit, crash, raw-crash, size. Ctrl+C returns to the dashboard.", flush=True)
27
+ try:
28
+ while True:
29
+ command = input("agent> ")
30
+ if command == "exit":
31
+ return
32
+ if command == "crash":
33
+ raise SystemExit(23)
34
+ if command == "raw-crash":
35
+ tty.setraw(sys.stdin.fileno())
36
+ os._exit(24)
37
+ if command == "size":
38
+ size = os.get_terminal_size()
39
+ print(f"AGENT_SIZE {size.columns}x{size.lines}", flush=True)
40
+ continue
41
+ print(f"remembered={data['seed']}; received={command}", flush=True)
42
+ except KeyboardInterrupt, EOFError:
43
+ print("\nFAKE_AGENT_INTERRUPTED", flush=True)
44
+
45
+
46
+ if __name__ == "__main__":
47
+ main()
@@ -0,0 +1,23 @@
1
+ """Internal POSIX launch gate: do not touch the terminal until ownership is granted."""
2
+
3
+ import os
4
+ import sys
5
+
6
+
7
+ def main() -> None:
8
+ gate = int(sys.argv[1])
9
+ try:
10
+ granted = os.read(gate, 1) == b"1"
11
+ finally:
12
+ os.close(gate)
13
+ if not granted:
14
+ raise SystemExit(125)
15
+ try:
16
+ os.execvp(sys.argv[2], sys.argv[2:])
17
+ except OSError as error:
18
+ print(f"Could not launch agent: {error}", file=sys.stderr)
19
+ raise SystemExit(127) from error
20
+
21
+
22
+ if __name__ == "__main__":
23
+ main()
@@ -0,0 +1,34 @@
1
+ """Shared application defaults for DiffButler's terminal interfaces."""
2
+
3
+ from collections.abc import Iterable
4
+ from typing import Any, ClassVar
5
+
6
+ from textual.app import App, SystemCommand
7
+ from textual.binding import Binding, BindingType
8
+ from textual.screen import Screen
9
+ from textual.widgets import HelpPanel
10
+
11
+
12
+ class TerminalApp(App[None]):
13
+ """Use shared theme and command defaults for the terminal interfaces."""
14
+
15
+ BINDINGS: ClassVar[list[BindingType]] = [
16
+ Binding("escape", "hide_help_panel", "Close keys", show=False),
17
+ ]
18
+
19
+ def __init__(self) -> None:
20
+ super().__init__()
21
+ self.theme = "ansi-dark"
22
+
23
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
24
+ if action == "hide_help_panel":
25
+ return bool(self.screen.query(HelpPanel))
26
+ return super().check_action(action, parameters)
27
+
28
+ def get_system_commands(self, screen: Screen[Any]) -> Iterable[SystemCommand]:
29
+ # Textual leaves the Screen type parameter unspecified in this method.
30
+ commands = super().get_system_commands(screen) # pyright: ignore[reportUnknownMemberType]
31
+ return (command for command in commands if command.title != "Screenshot")
32
+
33
+ def action_screenshot(self, filename: str | None = None, path: str | None = None) -> None:
34
+ """Disable Textual's inherited screenshot action."""