Coverage for little_loops / cli / issues / refine_status.py: 0%
233 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-03-18 16:18 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-03-18 16:18 -0500
1"""ll-issues refine-status: Refinement depth table for active issues."""
3from __future__ import annotations
5import argparse
6import json
7from typing import TYPE_CHECKING
9from little_loops.cli.output import PRIORITY_COLOR, TYPE_COLOR, colorize, print_json, terminal_width
11if TYPE_CHECKING:
12 from little_loops.config import BRConfig
13 from little_loops.issue_parser import IssueInfo
15# Minimum width for the title column (before terminal-width trimming)
16_MIN_TITLE_WIDTH = 20
17# Fixed column widths for non-title columns
18_ID_WIDTH = 8 # "BUG-525 "
19_PRI_WIDTH = 4 # "P2 "
20_SCORE_WIDTH = 5 # "ready" col
21_CONF_WIDTH = 5 # "conf" col
22_TOTAL_WIDTH = 5 # "total"
23# Width of each command column
24_CMD_WIDTH = 6
25_NORM_WIDTH = 4 # "norm" / "✓" / "✗"
26_FMT_WIDTH = 4 # "fmt" / "✓" / "✗"
27_SOURCE_WIDTH = 7 # "source" header / "capture" value max
29# Commands that are excluded from dynamic columns (shown as static columns instead)
30_SOURCE_CMDS = {
31 "/ll:capture-issue",
32 "/ll:scan-codebase",
33 "/ll:audit-architecture",
34 "/ll:format-issue",
35}
37# Canonical workflow order for command columns
38_CANONICAL_CMD_ORDER = [
39 "/ll:capture-issue",
40 "/ll:scan-codebase",
41 "/ll:audit-architecture",
42 "/ll:format-issue",
43 "/ll:verify-issues",
44 "/ll:refine-issue",
45 "/ll:tradeoff-review-issues",
46 "/ll:map-dependencies",
47]
49_CMD_ALIASES: dict[str, str] = {
50 "/ll:capture-issue": "capture",
51 "/ll:scan-codebase": "scan",
52 "/ll:audit-architecture": "audit",
53 "/ll:format-issue": "format",
54 "/ll:verify-issues": "verify",
55 "/ll:refine-issue": "refine",
56 "/ll:tradeoff-review-issues": "tradeoff",
57 "/ll:map-dependencies": "map",
58}
60# Static column metadata: name -> (fixed_width, header_text, right_justify)
61# width=0 is a sentinel meaning the column width is computed dynamically (title only)
62_STATIC_COLUMN_SPECS: dict[str, tuple[int, str, bool]] = {
63 "id": (_ID_WIDTH, "ID", False),
64 "priority": (_PRI_WIDTH, "Pri", False),
65 "title": (0, "Title", False),
66 "source": (_SOURCE_WIDTH, "source", False),
67 "norm": (_NORM_WIDTH, "norm", False),
68 "fmt": (_FMT_WIDTH, "fmt", False),
69 "ready": (_SCORE_WIDTH, "ready", True),
70 "confidence": (_CONF_WIDTH, "conf", True),
71 "total": (_TOTAL_WIDTH, "total", True),
72}
74# Default column order when no config is provided
75_DEFAULT_STATIC_COLUMNS: list[str] = [
76 "id",
77 "priority",
78 "title",
79 "source",
80 "norm",
81 "fmt",
82 "ready",
83 "confidence",
84 "total",
85]
87# Columns that belong after the dynamic command block (all others go before)
88_POST_CMD_STATIC: frozenset[str] = frozenset(["ready", "confidence", "total"])
90# Columns that are always pinned — never elided regardless of terminal width
91_PINNED_COLUMNS: frozenset[str] = frozenset(["id", "priority", "title"])
93# Default column elision order: columns dropped first when table overflows.
94# Command columns not listed here are dropped rightmost-first after this list
95# is exhausted.
96_DEFAULT_ELIDE_ORDER: list[str] = ["source", "norm", "fmt", "confidence", "ready", "total"]
99def _cmd_label(cmd: str) -> str:
100 """Return display label for a command column header."""
101 if cmd in _CMD_ALIASES:
102 return _CMD_ALIASES[cmd]
103 # Fallback: strip /ll: prefix and truncate
104 short = cmd[4:] if cmd.startswith("/ll:") else cmd
105 return _truncate(short, _CMD_WIDTH)
108def _source_label(discovered_by: str | None) -> str:
109 """Return short display label for an issue's origin source."""
110 if not discovered_by:
111 return "\u2014" # em-dash
112 if discovered_by in _CMD_ALIASES:
113 return _CMD_ALIASES[discovered_by]
114 # Non-/ll: values like "github_sync" — truncate to fit
115 return _truncate(discovered_by, _SOURCE_WIDTH)
118def _truncate(text: str, width: int) -> str:
119 """Truncate text to width, replacing last char with ellipsis if needed."""
120 if len(text) <= width:
121 return text
122 return text[: width - 1].rstrip() + "\u2026"
125def _col(text: str, width: int) -> str:
126 """Left-justify text in a fixed-width column."""
127 return text.ljust(width)[:width]
130def _rcol(text: str, width: int) -> str:
131 """Right-justify text in a fixed-width column."""
132 return text.rjust(width)[:width]
135def _apply_cell_color(col: str, padded: str, plain: str) -> str:
136 """Colorize the visible content of a padded cell, preserving surrounding whitespace."""
137 if col == "id":
138 issue_type = plain.split("-")[0]
139 code = TYPE_COLOR.get(issue_type, "")
140 elif col == "priority":
141 code = PRIORITY_COLOR.get(plain, "")
142 elif col in ("norm", "fmt"):
143 if plain == "\u2713": # ✓
144 code = "32" # green
145 elif plain == "\u2717": # ✗
146 code = "31" # red
147 else:
148 code = ""
149 else:
150 return padded
152 if not code:
153 return padded
155 # Preserve leading spaces (rjust cells) and trailing spaces (ljust cells)
156 lstripped = padded.lstrip()
157 leading = padded[: len(padded) - len(lstripped)]
158 content = lstripped.rstrip()
159 trailing = lstripped[len(content) :]
160 return leading + colorize(content, code) + trailing
163def _compute_min_total_width(
164 pre_cmd: list[str], all_cmds: list[str], post_cmd: list[str], id_width: int
165) -> int:
166 """Compute the minimum table width with the title column at _MIN_TITLE_WIDTH."""
167 n_parts = len(pre_cmd) + len(all_cmds) + len(post_cmd)
168 if n_parts == 0:
169 return 0
170 col_sum = 0
171 for c in pre_cmd:
172 if c == "title":
173 col_sum += _MIN_TITLE_WIDTH
174 elif c == "id":
175 col_sum += id_width
176 elif c in _STATIC_COLUMN_SPECS:
177 col_sum += _STATIC_COLUMN_SPECS[c][0]
178 else:
179 col_sum += _CMD_WIDTH
180 col_sum += len(all_cmds) * _CMD_WIDTH
181 for c in post_cmd:
182 col_sum += _STATIC_COLUMN_SPECS[c][0] if c in _STATIC_COLUMN_SPECS else _CMD_WIDTH
183 return col_sum + 2 * (n_parts - 1)
186def _elide_columns(
187 pre_cmd: list[str],
188 all_cmds: list[str],
189 post_cmd: list[str],
190 term_cols: int,
191 id_width: int,
192 elide_order: list[str],
193) -> tuple[list[str], list[str], list[str]]:
194 """Drop columns until the table fits within term_cols.
196 Columns in *elide_order* are dropped first (in listed order). Pinned
197 columns (id, priority, title) are silently skipped even if they appear in
198 the list. After the list is exhausted, remaining command columns are
199 dropped rightmost-first.
200 """
201 pre = list(pre_cmd)
202 cmds = list(all_cmds)
203 post = list(post_cmd)
205 def fits() -> bool:
206 return _compute_min_total_width(pre, cmds, post, id_width) <= term_cols
208 if fits():
209 return pre, cmds, post
211 for col in elide_order:
212 if fits():
213 break
214 if col in _PINNED_COLUMNS:
215 continue
216 if col in pre:
217 pre.remove(col)
218 elif col in post:
219 post.remove(col)
220 elif col in cmds:
221 cmds.remove(col)
223 # Drop remaining command columns rightmost-first
224 while not fits() and cmds:
225 cmds.pop()
227 return pre, cmds, post
230def cmd_refine_status(config: BRConfig, args: argparse.Namespace) -> int:
231 """Render a refinement depth table for all active issues.
233 Each column represents a distinct /ll:* command found across Session Log
234 sections. Issues are sorted descending by refinement depth (Total), then
235 ascending by priority as a tiebreaker.
237 Args:
238 config: Project configuration.
239 args: Parsed arguments with optional .type and .format attributes.
241 Returns:
242 Exit code (0 = success).
243 """
244 from little_loops.issue_parser import find_issues, is_formatted, is_normalized
246 type_prefixes = {args.type} if getattr(args, "type", None) else None
247 issues = find_issues(config, type_prefixes=type_prefixes)
249 if not issues:
250 print("No active issues found.")
251 return 0
253 # Derive dynamic column set: all distinct commands across all issues
254 seen: dict[str, None] = {}
255 for issue in issues:
256 for cmd in issue.session_commands:
257 seen[cmd] = None
259 def _canonical_sort_key(cmd: str) -> tuple[int, str]:
260 try:
261 return (_CANONICAL_CMD_ORDER.index(cmd), cmd)
262 except ValueError:
263 return (len(_CANONICAL_CMD_ORDER), cmd)
265 all_cmds: list[str] = [
266 cmd for cmd in sorted(seen.keys(), key=_canonical_sort_key) if cmd not in _SOURCE_CMDS
267 ]
269 # Sort issues: descending by total commands touched, then ascending priority
270 def _sort_key(issue: IssueInfo) -> tuple[int, int]:
271 return (-len(issue.session_commands), issue.priority_int)
273 sorted_issues = sorted(issues, key=_sort_key)
275 # Dynamic ID column width: size to the longest issue_id present, minimum 8
276 id_width = max((len(issue.issue_id) for issue in sorted_issues), default=7) + 1
278 use_json_array = getattr(args, "json", False)
279 fmt = getattr(args, "format", "table")
281 if use_json_array:
282 print_json(
283 [
284 {
285 "id": issue.issue_id,
286 "priority": issue.priority,
287 "title": issue.title,
288 "source": issue.discovered_by,
289 "commands": issue.session_commands,
290 "confidence_score": issue.confidence_score,
291 "outcome_confidence": issue.outcome_confidence,
292 "total": len(issue.session_commands),
293 "normalized": is_normalized(issue.path.name),
294 "formatted": is_formatted(issue.path),
295 "refine_count": issue.session_command_counts.get("/ll:refine-issue", 0),
296 }
297 for issue in sorted_issues
298 ]
299 )
300 return 0
302 if fmt == "json":
303 for issue in sorted_issues:
304 record = {
305 "id": issue.issue_id,
306 "priority": issue.priority,
307 "title": issue.title,
308 "source": issue.discovered_by,
309 "commands": issue.session_commands,
310 "confidence_score": issue.confidence_score,
311 "outcome_confidence": issue.outcome_confidence,
312 "total": len(issue.session_commands),
313 "normalized": is_normalized(issue.path.name),
314 "formatted": is_formatted(issue.path),
315 "refine_count": issue.session_command_counts.get("/ll:refine-issue", 0),
316 }
317 print(json.dumps(record))
318 return 0
320 # --- Table rendering ---
321 term_cols = terminal_width()
323 # Determine active static columns from config (empty list = use defaults)
324 config_cols = config.refine_status.columns
325 active_static = list(config_cols) if config_cols else list(_DEFAULT_STATIC_COLUMNS)
327 # Split active columns: pre-cmd (before dynamic command block) and post-cmd (after)
328 pre_cmd = [c for c in active_static if c not in _POST_CMD_STATIC]
329 post_cmd = [c for c in active_static if c in _POST_CMD_STATIC]
331 # Elide columns when the table would overflow the terminal.
332 # JSON modes exit early above, so this path is table-only.
333 elide_order = config.refine_status.elide_order or _DEFAULT_ELIDE_ORDER
334 pre_cmd, all_cmds, post_cmd = _elide_columns(
335 pre_cmd, all_cmds, post_cmd, term_cols, id_width, elide_order
336 )
338 # Compute title column width based on active columns and terminal size
339 has_title = "title" in pre_cmd
340 title_w = _MIN_TITLE_WIDTH
341 if has_title:
342 n_parts = len(pre_cmd) + len(all_cmds) + len(post_cmd)
343 non_title_sum = (
344 sum(
345 (id_width if c == "id" else _STATIC_COLUMN_SPECS[c][0])
346 if c in _STATIC_COLUMN_SPECS
347 else _CMD_WIDTH
348 for c in pre_cmd
349 if c != "title"
350 )
351 + len(all_cmds) * _CMD_WIDTH
352 + sum(
353 _STATIC_COLUMN_SPECS[c][0] if c in _STATIC_COLUMN_SPECS else _CMD_WIDTH
354 for c in post_cmd
355 )
356 )
357 title_w = max(_MIN_TITLE_WIDTH, term_cols - non_title_sum - 2 * (n_parts - 1))
359 def _get_col_display_width(col: str) -> int:
360 if col == "id":
361 return id_width
362 if col == "title":
363 return title_w
364 if col in _STATIC_COLUMN_SPECS:
365 return _STATIC_COLUMN_SPECS[col][0]
366 return _CMD_WIDTH
368 def _render_cell(col: str, value: str) -> str:
369 w = _get_col_display_width(col)
370 if col in _STATIC_COLUMN_SPECS:
371 rjust = _STATIC_COLUMN_SPECS[col][2]
372 return _rcol(value, w) if rjust else _col(value, w)
373 return _col(value, w)
375 def _header_cell(col: str) -> str:
376 if col in _STATIC_COLUMN_SPECS:
377 hdr = _STATIC_COLUMN_SPECS[col][1]
378 else:
379 hdr = _truncate(col, _get_col_display_width(col))
380 return _render_cell(col, hdr)
382 def _cell_value(col: str, issue: IssueInfo) -> str:
383 if col == "id":
384 return issue.issue_id
385 if col == "priority":
386 return issue.priority
387 if col == "title":
388 return _truncate(issue.title, title_w)
389 if col == "source":
390 return _source_label(issue.discovered_by)
391 if col == "norm":
392 return "\u2713" if is_normalized(issue.path.name) else "\u2717"
393 if col == "fmt":
394 return "\u2713" if is_formatted(issue.path) else "\u2717"
395 if col == "ready":
396 return str(issue.confidence_score) if issue.confidence_score is not None else "\u2014"
397 if col == "confidence":
398 return (
399 str(issue.outcome_confidence) if issue.outcome_confidence is not None else "\u2014"
400 )
401 if col == "total":
402 return str(len(issue.session_commands))
403 return "\u2014" # unknown column: em-dash
405 def _build_row(issue: IssueInfo | None) -> str:
406 parts: list[str] = []
407 cmd_set = set(issue.session_commands) if issue is not None else set()
409 for c in pre_cmd:
410 if issue is None:
411 parts.append(_header_cell(c))
412 else:
413 plain = _cell_value(c, issue)
414 parts.append(_apply_cell_color(c, _render_cell(c, plain), plain))
416 for c in all_cmds:
417 if issue is None:
418 parts.append(_col(_cmd_label(c), _CMD_WIDTH))
419 else:
420 if c == "/ll:refine-issue":
421 cell = str(issue.session_command_counts.get(c, 0))
422 parts.append(_col(cell, _CMD_WIDTH))
423 else:
424 hit = c in cmd_set
425 raw = "\u2713" if hit else "\u2014"
426 padded = _col(raw, _CMD_WIDTH)
427 parts.append(
428 colorize(raw, "32") + padded[len(raw) :]
429 if hit
430 else colorize(raw, "2") + padded[len(raw) :]
431 )
433 for c in post_cmd:
434 if issue is None:
435 parts.append(_header_cell(c))
436 else:
437 plain = _cell_value(c, issue)
438 parts.append(_apply_cell_color(c, _render_cell(c, plain), plain))
440 return " ".join(parts)
442 header = _build_row(None)
443 separator = "-" * len(header)
445 rows: list[str] = [header, separator]
447 for issue in sorted_issues:
448 rows.append(_build_row(issue))
450 print("\n".join(rows))
452 issue_word = "issue" if len(sorted_issues) == 1 else "issues"
453 scored = sum(1 for i in sorted_issues if i.confidence_score is not None)
454 print(f"\n{len(sorted_issues)} {issue_word} ({scored} scored)")
456 if not getattr(args, "no_key", False):
457 _print_key(all_cmds)
459 return 0
462def _print_key(all_cmds: list[str]) -> None:
463 """Print a legend mapping column headers to their full command names."""
464 print("\nKey:")
465 print(f" {'source':<12} Origin command/workflow that created the issue")
466 print(f" {'norm':<12} Filename follows naming convention (P[0-5]-TYPE-NNN-desc.md)")
467 print(f" {'fmt':<12} Issue has all required sections per type template (structural check)")
468 for cmd in all_cmds:
469 label = _cmd_label(cmd)
470 if cmd == "/ll:refine-issue":
471 print(f" {label:<12} Times /ll:refine-issue was run")
472 else:
473 print(f" {label:<12} {cmd}")
474 print(f" {'ready':<12} Readiness score (0\u2013100)")
475 print(f" {'conf':<12} Outcome confidence score (0\u2013100)")
476 print(f" {'total':<12} Number of /ll:* skills applied")