refactorai-cli 0.4.10__tar.gz → 0.4.11__tar.gz

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 (47) hide show
  1. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/PKG-INFO +2 -2
  2. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/pyproject.toml +2 -2
  3. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/__init__.py +1 -1
  4. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/commands/branch_cmds.py +3 -0
  5. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/commands/run_cmds.py +10 -0
  6. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/commands/watch_cmds.py +324 -34
  7. refactorai_cli-0.4.11/refactorai_cli/refactor_branch_store.py +189 -0
  8. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/watch_state.py +289 -13
  9. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli.egg-info/PKG-INFO +2 -2
  10. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli.egg-info/requires.txt +1 -1
  11. refactorai_cli-0.4.10/refactorai_cli/refactor_branch_store.py +0 -116
  12. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/README.md +0 -0
  13. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/auth.py +0 -0
  14. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/client.py +0 -0
  15. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/cloud_rr.py +0 -0
  16. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/commands/__init__.py +0 -0
  17. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/commands/account_cmds.py +0 -0
  18. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/commands/auth_cmds.py +0 -0
  19. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/commands/cloud_cmds.py +0 -0
  20. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/commands/engine_cmds.py +0 -0
  21. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/commands/hook_cmds.py +0 -0
  22. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/commands/model_cmds.py +0 -0
  23. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/commands/request_cmds.py +0 -0
  24. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/commands/rules_cmds.py +0 -0
  25. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/commands/runtime_cmds.py +0 -0
  26. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/commands/runtime_proxy_cmds.py +0 -0
  27. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/commands/setup_cmds.py +0 -0
  28. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/commands/toolchains_cmds.py +0 -0
  29. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/commit_queue.py +0 -0
  30. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/control_plane.py +0 -0
  31. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/credentials.py +0 -0
  32. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/dotenv_loader.py +0 -0
  33. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/git_scope.py +0 -0
  34. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/local_constitution.py +0 -0
  35. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/local_engine_runtime.py +0 -0
  36. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/local_paths.py +0 -0
  37. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/main.py +0 -0
  38. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/model_policy.py +0 -0
  39. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/review_runner.py +0 -0
  40. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/runtime_manager.py +0 -0
  41. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/settings.py +0 -0
  42. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli/setup_flow.py +0 -0
  43. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli.egg-info/SOURCES.txt +0 -0
  44. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli.egg-info/dependency_links.txt +0 -0
  45. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli.egg-info/entry_points.txt +0 -0
  46. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/refactorai_cli.egg-info/top_level.txt +0 -0
  47. {refactorai_cli-0.4.10 → refactorai_cli-0.4.11}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.4.10
3
+ Version: 0.4.11
4
4
  Summary: Local-first CLI for the refactor platform
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -8,7 +8,7 @@ Requires-Dist: typer>=0.12.0
8
8
  Requires-Dist: httpx>=0.27.0
9
9
  Requires-Dist: rich>=13.7.0
10
10
  Requires-Dist: PyYAML>=6.0.1
11
- Requires-Dist: refactorai-core>=3.2.5
11
+ Requires-Dist: refactorai-core>=3.2.6
12
12
 
13
13
  # refactorai-cli
14
14
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "refactorai-cli"
3
- version = "0.4.10"
3
+ version = "0.4.11"
4
4
  description = "Local-first CLI for the refactor platform"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -12,7 +12,7 @@ dependencies = [
12
12
  "httpx>=0.27.0",
13
13
  "rich>=13.7.0",
14
14
  "PyYAML>=6.0.1",
15
- "refactorai-core>=3.2.5",
15
+ "refactorai-core>=3.2.6",
16
16
  ]
17
17
 
18
18
  [project.scripts]
@@ -5,4 +5,4 @@ the shared `refactor_core` pipeline from a project folder while staying
5
5
  authenticated to the hosted platform via a developer key.
6
6
  """
7
7
 
8
- __version__ = "0.4.10"
8
+ __version__ = "0.4.11"
@@ -128,6 +128,9 @@ def merge_branch(
128
128
  ok, output = git_scope.merge(project_root, target, squash=squash)
129
129
  if not ok:
130
130
  return False, f"merge failed / conflicted: {output}"
131
+ # docs/43 `R30`: remember the terminal outcome so the finalize view can render
132
+ # a DONE (merged) node without re-inspecting git.
133
+ refactor_branch_store.set_outcome(project_root, target, refactor_branch_store.OUTCOME_MERGED)
131
134
  if squash:
132
135
  sha = git_scope.commit(project_root, _squash_message(project_root, target, source))
133
136
  if not sha:
@@ -3217,6 +3217,16 @@ def code(
3217
3217
  final_status = "skipped"
3218
3218
  if _final_test_enabled(project_config):
3219
3219
  final_status, _ = _run_final_test_suite(project_root, project_config)
3220
+ # docs/43 `R30`: persist the verdict so the Refactor Terminal finalize view
3221
+ # can show it without re-running the suite.
3222
+ try:
3223
+ from refactorai_cli import refactor_branch_store
3224
+
3225
+ refactor_branch_store.set_final_test(
3226
+ project_root, refactor_branch_ctx.get("branch_name", ""), final_status
3227
+ )
3228
+ except Exception:
3229
+ pass
3220
3230
  decision = _decide_finalize(
3221
3231
  project_config,
3222
3232
  finalize=finalize,
@@ -25,10 +25,15 @@ from typing import Callable
25
25
  import typer
26
26
  from rich.console import Console
27
27
 
28
- from refactorai_cli import commit_queue, git_scope, watch_state
28
+ from refactorai_cli import commit_queue, git_scope, refactor_branch_store, watch_state
29
29
  from refactorai_cli.auth import AuthError, ensure_authenticated
30
30
  from refactorai_cli.review_runner import ReviewRunner, SubprocessReviewRunner
31
- from refactorai_cli.watch_state import WatchState, render_graph
31
+ from refactorai_cli.watch_state import (
32
+ FinalizeInfo,
33
+ RRCommit,
34
+ WatchState,
35
+ render_graph,
36
+ )
32
37
 
33
38
  console = Console()
34
39
 
@@ -121,6 +126,141 @@ def _pending_for_run(
121
126
  return len(matches), lines
122
127
 
123
128
 
129
+ # --------------------------------------------------------------------------- #
130
+ # Finalize / branch-view resolution (docs/43 `R30`, Phase 1) #
131
+ # --------------------------------------------------------------------------- #
132
+
133
+ def _shas_match(a: str, b: str) -> bool:
134
+ """True if two shas refer to the same commit (tolerant of short/long forms)."""
135
+ a = str(a or "")
136
+ b = str(b or "")
137
+ if not a or not b:
138
+ return False
139
+ return a == b or a.startswith(b) or b.startswith(a)
140
+
141
+
142
+ def _resolve_refactor_branch(
143
+ project_root: Path, node
144
+ ) -> tuple[str, "refactor_branch_store.BranchMeta | None"]:
145
+ """Map a commit node to its refactor branch (docs/43 Part C).
146
+
147
+ Resolution order: (1) a branch-store entry whose ``source_sha`` matches the
148
+ node, (2) the ``refactor/<branch>/<shortsha>`` name template, (3) an RR
149
+ record carrying ``refactor_branch`` for this node's run. Returns
150
+ ``("", None)`` when nothing links the node to a refactor branch.
151
+ """
152
+ # 1) recorded source association (reversible, preferred).
153
+ for meta in refactor_branch_store.list_branches(project_root):
154
+ if meta.source_sha and _shas_match(node.sha, meta.source_sha):
155
+ return meta.branch, meta
156
+ # 2) deterministic name template from the node's branch + sha.
157
+ template = refactor_branch_store.refactor_branch_name(node.branch, node.sha)
158
+ meta = refactor_branch_store.load_branch(project_root, template)
159
+ if meta is not None or git_scope.branch_exists(project_root, template):
160
+ return template, meta
161
+ # 3) RR records created by this node's run.
162
+ branch = _branch_from_run(project_root, getattr(node, "run_id", ""))
163
+ if branch:
164
+ return branch, refactor_branch_store.load_branch(project_root, branch)
165
+ return "", None
166
+
167
+
168
+ def _branch_from_run(project_root: Path, run_id: str) -> str:
169
+ """First ``refactor_branch`` recorded on an RR created by ``run_id``."""
170
+ if not run_id:
171
+ return ""
172
+ try:
173
+ from refactor_core.refactor_requests import list_requests
174
+ except Exception:
175
+ return ""
176
+ try:
177
+ requests = list_requests(project_root)
178
+ except Exception:
179
+ return ""
180
+ for rr in requests:
181
+ if str(getattr(rr, "created_by_run", "") or "") == run_id:
182
+ branch = str(getattr(rr, "refactor_branch", "") or "")
183
+ if branch:
184
+ return branch
185
+ return ""
186
+
187
+
188
+ def _rr_commits_for_branch(
189
+ project_root: Path, branch: str, *, run_id: str = ""
190
+ ) -> list[RRCommit]:
191
+ """Per-request commits recorded on ``branch`` (newest first)."""
192
+ if not branch:
193
+ return []
194
+ try:
195
+ from refactor_core.refactor_requests import list_requests
196
+ except Exception:
197
+ return []
198
+ try:
199
+ requests = list_requests(project_root)
200
+ except Exception:
201
+ return []
202
+ out: list[RRCommit] = []
203
+ for rr in requests:
204
+ rr_branch = str(getattr(rr, "refactor_branch", "") or "")
205
+ commit_sha = str(getattr(rr, "commit_sha", "") or "")
206
+ if not commit_sha:
207
+ continue
208
+ # Prefer an exact branch match; fall back to the node's run when the RR
209
+ # predates branch tagging.
210
+ if rr_branch == branch or (not rr_branch and run_id and str(getattr(rr, "created_by_run", "") or "") == run_id):
211
+ out.append(
212
+ RRCommit(
213
+ rr_id=str(getattr(rr, "rr_id", "") or ""),
214
+ commit_sha=commit_sha,
215
+ location=str(getattr(rr, "location", "") or ""),
216
+ status=str(getattr(rr, "status", "") or ""),
217
+ reverted=bool(getattr(rr, "reverted", False)),
218
+ )
219
+ )
220
+ # ``list_requests`` is oldest-first; newest RR commit renders on top.
221
+ out.reverse()
222
+ return out
223
+
224
+
225
+ def resolve_finalize(project_root: Path, node) -> FinalizeInfo | None:
226
+ """Resolve the finalize/branch-view data for a node (docs/43 Part C/G).
227
+
228
+ Returns ``None`` when the node has no refactor branch to finalize. Fail-soft:
229
+ any store/git hiccup yields ``None`` rather than raising into the terminal.
230
+ """
231
+ try:
232
+ branch, meta = _resolve_refactor_branch(project_root, node)
233
+ except Exception:
234
+ return None
235
+ if not branch:
236
+ return None
237
+ try:
238
+ exists = git_scope.branch_exists(project_root, branch)
239
+ except Exception:
240
+ exists = False
241
+ commits = _rr_commits_for_branch(project_root, branch, run_id=getattr(node, "run_id", ""))
242
+ outcome = meta.outcome if meta else ""
243
+ # Nothing to show and nothing recorded -> treat as "no finalize".
244
+ if not commits and not exists and not outcome:
245
+ return None
246
+ return FinalizeInfo(
247
+ refactor_branch=branch,
248
+ source_branch=(meta.source_branch if meta else "") or node.branch,
249
+ source_sha=(meta.source_sha if meta else "") or node.sha,
250
+ branch_exists=exists,
251
+ outcome=outcome,
252
+ final_test=meta.final_test if meta else "",
253
+ commits=commits,
254
+ )
255
+
256
+
257
+ def refresh_finalize(project_root: Path, state: WatchState) -> None:
258
+ """Recompute finalize data for reviewed nodes so phases stay current."""
259
+ for node in state.nodes_oldest_first():
260
+ if node.state == watch_state.REVIEWED:
261
+ node.finalize = resolve_finalize(project_root, node)
262
+
263
+
124
264
  def _resolve_repo(project_root: Path | None) -> Path:
125
265
  root = git_scope.repo_root(project_root or Path.cwd())
126
266
  if not root:
@@ -184,6 +324,11 @@ def process_pending(
184
324
  (rendering happens via ``on_update``) so it is unit-testable with a fake
185
325
  runner and a temporary queue file.
186
326
  """
327
+ # Track the checked-out branch so the finalize view can mark "(current)".
328
+ try:
329
+ state.current_branch = git_scope.current_branch(project_root) or state.current_branch
330
+ except Exception:
331
+ pass
187
332
  for event in commit_queue.read_events(project_root):
188
333
  sha = str(event.get("sha") or "")
189
334
  if sha and not state.has(sha):
@@ -210,6 +355,11 @@ def process_pending(
210
355
  reviewed += 1
211
356
  if on_update:
212
357
  on_update(state)
358
+ # docs/43 Phase 1: fold current refactor-branch data into reviewed nodes so
359
+ # their phase (review/finalize/done) reflects any branch built by `[r]`.
360
+ refresh_finalize(project_root, state)
361
+ if on_update:
362
+ on_update(state)
213
363
  return reviewed
214
364
 
215
365
 
@@ -258,15 +408,25 @@ def _read_key(timeout: float) -> str:
258
408
  termios.tcsetattr(fd, termios.TCSADRAIN, old)
259
409
 
260
410
 
261
- def build_action_command(action: str, sha: str, *, executable: str = "refactor") -> list[str] | None:
262
- """Build the argv for a terminal action. Pure + testable (docs/42 Phase 4).
263
-
264
- ``code`` reuses pending requests; ``refresh`` regenerates them. Both pass
265
- ``--keep`` so resolving from the terminal only *builds* the refactor branch
266
- (per-request commits + final test) and never pops its own merge/keep/drop
267
- prompt — the terminal's ``[m]`` merge / ``[x]`` drop keys are the single,
268
- explicit place that decision is made. ``merge`` / ``drop`` operate on that
269
- already-built refactor branch (local only, never pushes).
411
+ def build_action_command(
412
+ action: str,
413
+ sha: str,
414
+ *,
415
+ executable: str = "refactor",
416
+ branch: str | None = None,
417
+ rr_id: str | None = None,
418
+ ) -> list[str] | None:
419
+ """Build the argv for a terminal action. Pure + testable (docs/42, docs/43).
420
+
421
+ Review phase (docs/41): ``code`` reuses pending requests; ``refresh``
422
+ regenerates them. Both pass ``--keep`` so resolving from the terminal only
423
+ *builds* the refactor branch and never pops its own merge/keep/drop prompt —
424
+ the finalize phase's ``[m]``/``[x]`` are the single, explicit place that
425
+ decision is made.
426
+
427
+ Finalize phase (docs/43): ``merge``/``drop`` target this commit's refactor
428
+ ``branch`` (scoped with ``--branch`` when known so a multi-commit terminal is
429
+ unambiguous); ``revert`` reverses a single ``rr_id``. All are local only.
270
430
  """
271
431
  if action == "code":
272
432
  # Commit-scoped so `[r]` resolves THIS commit's requests (docs/41 Phase 4).
@@ -279,10 +439,21 @@ def build_action_command(action: str, sha: str, *, executable: str = "refactor")
279
439
  return [executable, "diff"]
280
440
  if action == "merge":
281
441
  # `[m]` merges this commit's refactor branch into your working branch.
282
- return [executable, "branch", "merge"]
442
+ cmd = [executable, "branch", "merge"]
443
+ if branch:
444
+ cmd += ["--branch", branch]
445
+ return cmd
283
446
  if action == "drop":
284
447
  # `[x]` discards this commit's refactor branch and its commits.
285
- return [executable, "branch", "drop"]
448
+ cmd = [executable, "branch", "drop"]
449
+ if branch:
450
+ cmd += ["--branch", branch]
451
+ return cmd
452
+ if action == "revert":
453
+ # `[v]` reverses one resolved request via `git revert` (docs/42).
454
+ if not rr_id:
455
+ return None
456
+ return [executable, "request", "revert", "--id", rr_id]
286
457
  return None
287
458
 
288
459
 
@@ -304,7 +475,7 @@ def _run_json_stream(project_root: Path, state: WatchState, runner: ReviewRunner
304
475
 
305
476
  def emit(st: WatchState) -> None:
306
477
  for node in st.nodes_oldest_first():
307
- signature = f"{node.state}:{node.gate_status}:{node.rr_count}"
478
+ signature = f"{node.state}:{node.phase}:{node.gate_status}:{node.rr_count}"
308
479
  if emitted.get(node.sha) != signature:
309
480
  emitted[node.sha] = signature
310
481
  sys.stdout.write(json.dumps({"type": "node", "node": node.to_contract()}) + "\n")
@@ -317,9 +488,25 @@ def _run_json_stream(project_root: Path, state: WatchState, runner: ReviewRunner
317
488
  time.sleep(_POLL_SECONDS)
318
489
 
319
490
 
320
- def _run_action(live, project_root: Path, action: str, node, state: WatchState) -> None:
491
+ def _sync_current_branch(project_root: Path, state: WatchState) -> None:
492
+ try:
493
+ state.current_branch = git_scope.current_branch(project_root) or state.current_branch
494
+ except Exception:
495
+ pass
496
+
497
+
498
+ def _run_action(
499
+ live,
500
+ project_root: Path,
501
+ action: str,
502
+ node,
503
+ state: WatchState,
504
+ *,
505
+ rr_id: str | None = None,
506
+ ) -> None:
321
507
  """Suspend the live graph, run an interactive action, then refresh the node."""
322
- cmd = build_action_command(action, node.sha)
508
+ branch = node.finalize.refactor_branch if getattr(node, "finalize", None) else None
509
+ cmd = build_action_command(action, node.sha, branch=branch, rr_id=rr_id)
323
510
  if not cmd:
324
511
  return
325
512
  live.stop()
@@ -328,21 +515,134 @@ def _run_action(live, project_root: Path, action: str, node, state: WatchState)
328
515
  _launch_interactive(cmd, project_root)
329
516
  if action == "refresh":
330
517
  # `[f]` explicitly regenerates requests, so re-review this commit on the
331
- # next pass.
518
+ # next pass (also clears any `[b] back` override).
332
519
  state.requeue(node.sha)
333
520
  elif action == "code":
334
521
  # `[r]` is resolve/reuse. Do NOT auto re-review here (that can regenerate
335
522
  # duplicate-looking findings); instead reflect current pending requests
336
- # from the existing run in-place.
523
+ # from the existing run in-place, then fold in the refactor branch so the
524
+ # node auto-transitions to the finalize view (docs/43 FR-02).
525
+ state.forced_review.discard(node.sha)
337
526
  node_after = state.get(node.sha)
338
527
  if node_after and node_after.run_id:
339
528
  pending_count, preview = _pending_for_run(project_root, node_after.run_id)
340
529
  node_after.rr_count = pending_count
341
530
  node_after.rr_preview = preview
531
+ _sync_current_branch(project_root, state)
532
+ refresh_finalize(project_root, state)
533
+ elif action in ("merge", "drop", "revert"):
534
+ # Finalize actions (docs/43). `merge_branch` records the merged outcome
535
+ # itself; a completed drop leaves no branch, so tombstone the dropped
536
+ # outcome here so the node collapses to a DONE (dropped) row.
537
+ if action == "drop" and branch and not git_scope.branch_exists(project_root, branch):
538
+ refactor_branch_store.set_outcome(project_root, branch, refactor_branch_store.OUTCOME_DROPPED)
539
+ _sync_current_branch(project_root, state)
540
+ refresh_finalize(project_root, state)
541
+ console.print("\n[dim]watch: returning to the Refactor Terminal...[/dim]")
542
+ live.start()
543
+
544
+
545
+ def _run_revert_action(live, project_root: Path, node, state: WatchState) -> None:
546
+ """`[v]`: pick one resolved request and reverse it via `refactor request revert`."""
547
+ fin = getattr(node, "finalize", None)
548
+ commits = [c for c in (fin.commits if fin else []) if not c.reverted]
549
+ if not commits:
550
+ console.print("[dim]watch: no revertible requests on this refactor branch.[/dim]")
551
+ return
552
+ live.stop()
553
+ console.print("\n[bold bright_cyan]watch:[/bold bright_cyan] revert a refactor request "
554
+ f"on [cyan]{fin.refactor_branch}[/cyan]\n")
555
+ for idx, commit in enumerate(commits, start=1):
556
+ console.print(
557
+ f" [bold]{idx}[/bold]. {commit.location or '(unknown)'} "
558
+ f"[dim](rr {commit.rr_id[:7]}, {(commit.commit_sha or '')[:8]})[/dim]"
559
+ )
560
+ try:
561
+ raw = typer.prompt("Number to revert (blank to cancel)", default="")
562
+ except (typer.Abort, EOFError, KeyboardInterrupt):
563
+ raw = ""
564
+ choice = str(raw).strip()
565
+ if choice:
566
+ try:
567
+ idx = int(choice)
568
+ except ValueError:
569
+ idx = 0
570
+ if 1 <= idx <= len(commits):
571
+ rr_id = commits[idx - 1].rr_id
572
+ _launch_interactive(
573
+ build_action_command("revert", node.sha, rr_id=rr_id), project_root
574
+ )
575
+ refresh_finalize(project_root, state)
576
+ else:
577
+ console.print("[dim]watch: nothing reverted (out of range).[/dim]")
578
+ else:
579
+ console.print("[dim]watch: revert cancelled.[/dim]")
342
580
  console.print("\n[dim]watch: returning to the Refactor Terminal...[/dim]")
343
581
  live.start()
344
582
 
345
583
 
584
+ def _dispatch_key(live, project_root: Path, key: str, state: WatchState) -> None:
585
+ """Route a hotkey to the right action for the selected node's phase (docs/43).
586
+
587
+ Finalize phase offers ``[m]/[x]/[d]/[v]/[b]`` (review keys are hidden);
588
+ review/none/done offers ``[r]/[f]/[d]`` (and ``[b]`` toggles back to a
589
+ finalize node the user pressed ``[b]`` on).
590
+ """
591
+ node = state.selected()
592
+ if node is None:
593
+ return
594
+
595
+ if state.show_finalize(node):
596
+ # --- finalize phase: merge / drop / diff / revert / back -------------- #
597
+ if key == "m":
598
+ _run_action(live, project_root, "merge", node, state)
599
+ elif key == "x":
600
+ _run_action(live, project_root, "drop", node, state)
601
+ elif key == "d":
602
+ _run_action(live, project_root, "diff", node, state)
603
+ elif key == "v":
604
+ _run_revert_action(live, project_root, node, state)
605
+ elif key == "b":
606
+ # Show the review view for this commit so `[f]` fresh review is possible.
607
+ state.forced_review.add(node.sha)
608
+ elif key in ("r", "f"):
609
+ console.print(
610
+ "[dim]watch: this commit is resolved; press [b] to go back and "
611
+ "re-review, or [m] to merge.[/dim]"
612
+ )
613
+ return
614
+
615
+ # --- review / none / done phase ----------------------------------------- #
616
+ if key == "b" and node.sha in state.forced_review:
617
+ # Toggle back to the finalize view for a node we forced into review.
618
+ state.forced_review.discard(node.sha)
619
+ return
620
+ if key in ("r", "f", "d"):
621
+ can_review = node.phase == watch_state.PHASE_REVIEW or node.sha in state.forced_review
622
+ if key == "d":
623
+ if node.state == watch_state.REVIEWED:
624
+ _run_action(live, project_root, "diff", node, state)
625
+ return
626
+ if not can_review:
627
+ console.print(
628
+ f"[dim]watch: nothing to review on {node.short_sha} "
629
+ f"(phase: {node.phase}).[/dim]"
630
+ )
631
+ return
632
+ if node.state == watch_state.REVIEWED:
633
+ _run_action(live, project_root, "code" if key == "r" else "refresh", node, state)
634
+ else:
635
+ console.print(
636
+ f"[dim]watch: {node.short_sha} is '{node.state}'; "
637
+ "wait for review to finish before acting.[/dim]"
638
+ )
639
+ elif key in ("m", "x", "v"):
640
+ console.print(
641
+ "[dim]watch: finalize actions apply after this commit's requests are "
642
+ "resolved onto a refactor branch (press [r]).[/dim]"
643
+ )
644
+
645
+
346
646
  def _run_tui(project_root: Path, state: WatchState, runner: ReviewRunner) -> None:
347
647
  """Render the live Graphite-style graph and consume the queue serially."""
348
648
  from refactorai_cli.main import _print_agent_intro
@@ -370,26 +670,12 @@ def _run_tui(project_root: Path, state: WatchState, runner: ReviewRunner) -> Non
370
670
  state.move_selection(-1)
371
671
  elif key in ("down", "j"):
372
672
  state.move_selection(+1)
373
- elif key in ("r", "f", "d", "m", "x"):
374
- action = {
375
- "r": "code",
376
- "f": "refresh",
377
- "d": "diff",
378
- "m": "merge",
379
- "x": "drop",
380
- }[key]
381
- node = state.selected()
382
- if node and node.state == watch_state.REVIEWED:
383
- _run_action(live, project_root, action, node, state)
384
- elif node:
385
- console.print(
386
- f"[dim]watch: {node.short_sha} is '{node.state}'; "
387
- "wait for review to finish before acting.[/dim]"
388
- )
389
673
  elif key == "p":
390
674
  console.print(
391
675
  "[dim]watch: push arrives in Phase 5; use `git push` for now.[/dim]"
392
676
  )
677
+ elif key in ("r", "f", "d", "m", "x", "v", "b"):
678
+ _dispatch_key(live, project_root, key, state)
393
679
  refresh(state)
394
680
  finally:
395
681
  live.stop()
@@ -418,6 +704,10 @@ def watch(
418
704
  _ensure_auth_in_window(json_output)
419
705
 
420
706
  state = WatchState(repo_name=project_root.name)
707
+ try:
708
+ state.current_branch = git_scope.current_branch(project_root) or ""
709
+ except Exception:
710
+ state.current_branch = ""
421
711
  for event in _load_initial_events(project_root, since, limit):
422
712
  sha = str(event.get("sha") or "")
423
713
  if sha and not state.has(sha):