refactorai-cli 0.4.9__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.9 → refactorai_cli-0.4.11}/PKG-INFO +2 -2
  2. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/pyproject.toml +2 -2
  3. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/__init__.py +1 -1
  4. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/commands/branch_cmds.py +3 -0
  5. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/commands/run_cmds.py +10 -0
  6. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/commands/watch_cmds.py +326 -32
  7. refactorai_cli-0.4.11/refactorai_cli/refactor_branch_store.py +189 -0
  8. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/watch_state.py +289 -13
  9. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli.egg-info/PKG-INFO +2 -2
  10. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli.egg-info/requires.txt +1 -1
  11. refactorai_cli-0.4.9/refactorai_cli/refactor_branch_store.py +0 -116
  12. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/README.md +0 -0
  13. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/auth.py +0 -0
  14. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/client.py +0 -0
  15. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/cloud_rr.py +0 -0
  16. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/commands/__init__.py +0 -0
  17. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/commands/account_cmds.py +0 -0
  18. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/commands/auth_cmds.py +0 -0
  19. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/commands/cloud_cmds.py +0 -0
  20. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/commands/engine_cmds.py +0 -0
  21. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/commands/hook_cmds.py +0 -0
  22. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/commands/model_cmds.py +0 -0
  23. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/commands/request_cmds.py +0 -0
  24. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/commands/rules_cmds.py +0 -0
  25. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/commands/runtime_cmds.py +0 -0
  26. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/commands/runtime_proxy_cmds.py +0 -0
  27. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/commands/setup_cmds.py +0 -0
  28. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/commands/toolchains_cmds.py +0 -0
  29. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/commit_queue.py +0 -0
  30. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/control_plane.py +0 -0
  31. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/credentials.py +0 -0
  32. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/dotenv_loader.py +0 -0
  33. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/git_scope.py +0 -0
  34. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/local_constitution.py +0 -0
  35. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/local_engine_runtime.py +0 -0
  36. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/local_paths.py +0 -0
  37. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/main.py +0 -0
  38. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/model_policy.py +0 -0
  39. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/review_runner.py +0 -0
  40. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/runtime_manager.py +0 -0
  41. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/settings.py +0 -0
  42. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli/setup_flow.py +0 -0
  43. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli.egg-info/SOURCES.txt +0 -0
  44. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli.egg-info/dependency_links.txt +0 -0
  45. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli.egg-info/entry_points.txt +0 -0
  46. {refactorai_cli-0.4.9 → refactorai_cli-0.4.11}/refactorai_cli.egg-info/top_level.txt +0 -0
  47. {refactorai_cli-0.4.9 → 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.9
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.9"
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.9"
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,27 +408,52 @@ 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. ``merge`` /
265
- ``drop`` operate on the refactor branch built by ``code`` (local only).
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.
266
430
  """
267
431
  if action == "code":
268
432
  # Commit-scoped so `[r]` resolves THIS commit's requests (docs/41 Phase 4).
269
- return [executable, "code", "--commit", sha]
433
+ return [executable, "code", "--commit", sha, "--keep"]
270
434
  if action == "refresh":
271
435
  # `[f]` regenerates the requests for this commit before resolving.
272
- return [executable, "code", "--commit", sha, "--refresh-requests"]
436
+ return [executable, "code", "--commit", sha, "--refresh-requests", "--keep"]
273
437
  if action == "diff":
274
438
  # Shows the pending refactor-request patch diffs (not the git diff).
275
439
  return [executable, "diff"]
276
440
  if action == "merge":
277
441
  # `[m]` merges this commit's refactor branch into your working branch.
278
- return [executable, "branch", "merge"]
442
+ cmd = [executable, "branch", "merge"]
443
+ if branch:
444
+ cmd += ["--branch", branch]
445
+ return cmd
279
446
  if action == "drop":
280
447
  # `[x]` discards this commit's refactor branch and its commits.
281
- 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]
282
457
  return None
283
458
 
284
459
 
@@ -300,7 +475,7 @@ def _run_json_stream(project_root: Path, state: WatchState, runner: ReviewRunner
300
475
 
301
476
  def emit(st: WatchState) -> None:
302
477
  for node in st.nodes_oldest_first():
303
- signature = f"{node.state}:{node.gate_status}:{node.rr_count}"
478
+ signature = f"{node.state}:{node.phase}:{node.gate_status}:{node.rr_count}"
304
479
  if emitted.get(node.sha) != signature:
305
480
  emitted[node.sha] = signature
306
481
  sys.stdout.write(json.dumps({"type": "node", "node": node.to_contract()}) + "\n")
@@ -313,9 +488,25 @@ def _run_json_stream(project_root: Path, state: WatchState, runner: ReviewRunner
313
488
  time.sleep(_POLL_SECONDS)
314
489
 
315
490
 
316
- 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:
317
507
  """Suspend the live graph, run an interactive action, then refresh the node."""
318
- 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)
319
510
  if not cmd:
320
511
  return
321
512
  live.stop()
@@ -324,21 +515,134 @@ def _run_action(live, project_root: Path, action: str, node, state: WatchState)
324
515
  _launch_interactive(cmd, project_root)
325
516
  if action == "refresh":
326
517
  # `[f]` explicitly regenerates requests, so re-review this commit on the
327
- # next pass.
518
+ # next pass (also clears any `[b] back` override).
328
519
  state.requeue(node.sha)
329
520
  elif action == "code":
330
521
  # `[r]` is resolve/reuse. Do NOT auto re-review here (that can regenerate
331
522
  # duplicate-looking findings); instead reflect current pending requests
332
- # 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)
333
526
  node_after = state.get(node.sha)
334
527
  if node_after and node_after.run_id:
335
528
  pending_count, preview = _pending_for_run(project_root, node_after.run_id)
336
529
  node_after.rr_count = pending_count
337
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]")
338
580
  console.print("\n[dim]watch: returning to the Refactor Terminal...[/dim]")
339
581
  live.start()
340
582
 
341
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
+
342
646
  def _run_tui(project_root: Path, state: WatchState, runner: ReviewRunner) -> None:
343
647
  """Render the live Graphite-style graph and consume the queue serially."""
344
648
  from refactorai_cli.main import _print_agent_intro
@@ -366,26 +670,12 @@ def _run_tui(project_root: Path, state: WatchState, runner: ReviewRunner) -> Non
366
670
  state.move_selection(-1)
367
671
  elif key in ("down", "j"):
368
672
  state.move_selection(+1)
369
- elif key in ("r", "f", "d", "m", "x"):
370
- action = {
371
- "r": "code",
372
- "f": "refresh",
373
- "d": "diff",
374
- "m": "merge",
375
- "x": "drop",
376
- }[key]
377
- node = state.selected()
378
- if node and node.state == watch_state.REVIEWED:
379
- _run_action(live, project_root, action, node, state)
380
- elif node:
381
- console.print(
382
- f"[dim]watch: {node.short_sha} is '{node.state}'; "
383
- "wait for review to finish before acting.[/dim]"
384
- )
385
673
  elif key == "p":
386
674
  console.print(
387
675
  "[dim]watch: push arrives in Phase 5; use `git push` for now.[/dim]"
388
676
  )
677
+ elif key in ("r", "f", "d", "m", "x", "v", "b"):
678
+ _dispatch_key(live, project_root, key, state)
389
679
  refresh(state)
390
680
  finally:
391
681
  live.stop()
@@ -414,6 +704,10 @@ def watch(
414
704
  _ensure_auth_in_window(json_output)
415
705
 
416
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 = ""
417
711
  for event in _load_initial_events(project_root, since, limit):
418
712
  sha = str(event.get("sha") or "")
419
713
  if sha and not state.has(sha):