forgecommandline 0.8.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 (113) hide show
  1. forgecli/__init__.py +2 -0
  2. forgecli/ads.py +322 -0
  3. forgecli/agent.py +1093 -0
  4. forgecli/art.py +574 -0
  5. forgecli/attach.py +246 -0
  6. forgecli/brief.py +215 -0
  7. forgecli/browsercheck.py +99 -0
  8. forgecli/checkpoints.py +81 -0
  9. forgecli/city.py +833 -0
  10. forgecli/cli.py +1668 -0
  11. forgecli/config.py +194 -0
  12. forgecli/courier.py +177 -0
  13. forgecli/courier_data.py +164 -0
  14. forgecli/courier_hd.py +431 -0
  15. forgecli/courier_hd_data.bin +0 -0
  16. forgecli/courier_hd_data.py +19 -0
  17. forgecli/ghlogin.py +76 -0
  18. forgecli/github.py +54 -0
  19. forgecli/guards.py +403 -0
  20. forgecli/hooks.py +50 -0
  21. forgecli/httpapi.py +434 -0
  22. forgecli/images.py +110 -0
  23. forgecli/keys.py +152 -0
  24. forgecli/keyvault.py +320 -0
  25. forgecli/mcp.py +674 -0
  26. forgecli/memory.py +42 -0
  27. forgecli/openapi.py +331 -0
  28. forgecli/parallel.py +308 -0
  29. forgecli/prices.py +72 -0
  30. forgecli/procs.py +144 -0
  31. forgecli/providers.py +758 -0
  32. forgecli/quality.py +192 -0
  33. forgecli/repomap.py +78 -0
  34. forgecli/roster.py +227 -0
  35. forgecli/safety.py +93 -0
  36. forgecli/sandbox.py +83 -0
  37. forgecli/sessions.py +97 -0
  38. forgecli/skills.py +446 -0
  39. forgecli/tools.py +1184 -0
  40. forgecli/ui.py +860 -0
  41. forgecli/worktrees.py +79 -0
  42. forgecommandline-0.8.0.data/data/skills/commit/SKILL.md +14 -0
  43. forgecommandline-0.8.0.data/data/skills/debug/SKILL.md +17 -0
  44. forgecommandline-0.8.0.data/data/skills/explain/SKILL.md +14 -0
  45. forgecommandline-0.8.0.data/data/skills/init/SKILL.md +19 -0
  46. forgecommandline-0.8.0.data/data/skills/library/LICENSES/agent-skills-MIT.txt +21 -0
  47. forgecommandline-0.8.0.data/data/skills/library/LICENSES/wondelai-MIT.txt +23 -0
  48. forgecommandline-0.8.0.data/data/skills/library/README.md +9 -0
  49. forgecommandline-0.8.0.data/data/skills/library/_references/accessibility-checklist.md +160 -0
  50. forgecommandline-0.8.0.data/data/skills/library/_references/definition-of-done.md +67 -0
  51. forgecommandline-0.8.0.data/data/skills/library/_references/observability-checklist.md +92 -0
  52. forgecommandline-0.8.0.data/data/skills/library/_references/orchestration-patterns.md +370 -0
  53. forgecommandline-0.8.0.data/data/skills/library/_references/performance-checklist.md +238 -0
  54. forgecommandline-0.8.0.data/data/skills/library/_references/security-checklist.md +248 -0
  55. forgecommandline-0.8.0.data/data/skills/library/_references/testing-patterns.md +235 -0
  56. forgecommandline-0.8.0.data/data/skills/library/api-and-interface-design/SKILL.md +367 -0
  57. forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/SKILL.md +279 -0
  58. forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/artifact-templates.md +157 -0
  59. forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/methods.md +87 -0
  60. forgecommandline-0.8.0.data/data/skills/library/awwwards-tier-web-application/SKILL.md +877 -0
  61. forgecommandline-0.8.0.data/data/skills/library/browser-testing-with-devtools/SKILL.md +317 -0
  62. forgecommandline-0.8.0.data/data/skills/library/ci-cd-and-automation/SKILL.md +390 -0
  63. forgecommandline-0.8.0.data/data/skills/library/code-review-and-quality/SKILL.md +398 -0
  64. forgecommandline-0.8.0.data/data/skills/library/code-simplification/SKILL.md +331 -0
  65. forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/SKILL.md +311 -0
  66. forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/references/floor-guard.md +182 -0
  67. forgecommandline-0.8.0.data/data/skills/library/context-engineering/SKILL.md +353 -0
  68. forgecommandline-0.8.0.data/data/skills/library/debugging-and-error-recovery/SKILL.md +300 -0
  69. forgecommandline-0.8.0.data/data/skills/library/deprecation-and-migration/SKILL.md +247 -0
  70. forgecommandline-0.8.0.data/data/skills/library/documentation-and-adrs/SKILL.md +297 -0
  71. forgecommandline-0.8.0.data/data/skills/library/doubt-driven-development/SKILL.md +243 -0
  72. forgecommandline-0.8.0.data/data/skills/library/frontend-ui-engineering/SKILL.md +340 -0
  73. forgecommandline-0.8.0.data/data/skills/library/git-workflow-and-versioning/SKILL.md +355 -0
  74. forgecommandline-0.8.0.data/data/skills/library/idea-refine/SKILL.md +193 -0
  75. forgecommandline-0.8.0.data/data/skills/library/idea-refine/examples.md +238 -0
  76. forgecommandline-0.8.0.data/data/skills/library/idea-refine/frameworks.md +99 -0
  77. forgecommandline-0.8.0.data/data/skills/library/idea-refine/refinement-criteria.md +113 -0
  78. forgecommandline-0.8.0.data/data/skills/library/idea-refine/scripts/idea-refine.sh +15 -0
  79. forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/SKILL.md +275 -0
  80. forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/references/artifact-templates.md +101 -0
  81. forgecommandline-0.8.0.data/data/skills/library/incremental-implementation/SKILL.md +249 -0
  82. forgecommandline-0.8.0.data/data/skills/library/interview-me/SKILL.md +235 -0
  83. forgecommandline-0.8.0.data/data/skills/library/observability-and-instrumentation/SKILL.md +238 -0
  84. forgecommandline-0.8.0.data/data/skills/library/performance-optimization/SKILL.md +267 -0
  85. forgecommandline-0.8.0.data/data/skills/library/performance-optimization/references/optimization-patterns.md +250 -0
  86. forgecommandline-0.8.0.data/data/skills/library/planning-and-task-breakdown/SKILL.md +257 -0
  87. forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/SKILL.md +203 -0
  88. forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/references/artifact-templates.md +79 -0
  89. forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/SKILL.md +216 -0
  90. forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/references/hardening-patterns.md +324 -0
  91. forgecommandline-0.8.0.data/data/skills/library/shipping-and-launch/SKILL.md +336 -0
  92. forgecommandline-0.8.0.data/data/skills/library/source-driven-development/SKILL.md +216 -0
  93. forgecommandline-0.8.0.data/data/skills/library/spec-driven-development/SKILL.md +259 -0
  94. forgecommandline-0.8.0.data/data/skills/library/system-design/SKILL.md +207 -0
  95. forgecommandline-0.8.0.data/data/skills/library/system-design/references/building-blocks.md +272 -0
  96. forgecommandline-0.8.0.data/data/skills/library/system-design/references/common-designs.md +257 -0
  97. forgecommandline-0.8.0.data/data/skills/library/system-design/references/database-scaling.md +295 -0
  98. forgecommandline-0.8.0.data/data/skills/library/system-design/references/estimation-numbers.md +250 -0
  99. forgecommandline-0.8.0.data/data/skills/library/system-design/references/four-step-process.md +286 -0
  100. forgecommandline-0.8.0.data/data/skills/library/system-design/references/reliability-operations.md +373 -0
  101. forgecommandline-0.8.0.data/data/skills/library/test-driven-development/SKILL.md +398 -0
  102. forgecommandline-0.8.0.data/data/skills/library/using-agent-skills/SKILL.md +192 -0
  103. forgecommandline-0.8.0.data/data/skills/review/SKILL.md +16 -0
  104. forgecommandline-0.8.0.data/data/skills/run/SKILL.md +17 -0
  105. forgecommandline-0.8.0.data/data/skills/security-review/SKILL.md +20 -0
  106. forgecommandline-0.8.0.data/data/skills/simplify/SKILL.md +18 -0
  107. forgecommandline-0.8.0.data/data/skills/test/SKILL.md +16 -0
  108. forgecommandline-0.8.0.data/data/skills/web-build/SKILL.md +25 -0
  109. forgecommandline-0.8.0.dist-info/METADATA +357 -0
  110. forgecommandline-0.8.0.dist-info/RECORD +113 -0
  111. forgecommandline-0.8.0.dist-info/WHEEL +5 -0
  112. forgecommandline-0.8.0.dist-info/entry_points.txt +2 -0
  113. forgecommandline-0.8.0.dist-info/top_level.txt +1 -0
forgecli/ui.py ADDED
@@ -0,0 +1,860 @@
1
+ """Terminal rendering with rich: logo, actions, coloured diffs, live status, tables."""
2
+ from __future__ import annotations
3
+
4
+ import difflib
5
+ import os
6
+ import pathlib
7
+ import re
8
+ import sys
9
+ import threading
10
+ import time
11
+
12
+ from . import courier
13
+ from . import courier_hd
14
+
15
+ if os.name == "nt":
16
+ for stream in (sys.stdout, sys.stderr):
17
+ try:
18
+ stream.reconfigure(encoding="utf-8", errors="replace")
19
+ except (AttributeError, ValueError):
20
+ pass
21
+
22
+ from rich import box
23
+ from rich.console import Console, Group
24
+ from rich.live import Live
25
+ from rich.markdown import Markdown
26
+ from rich.padding import Padding
27
+ from rich.panel import Panel
28
+ from rich.syntax import Syntax
29
+ from rich.table import Table
30
+ from rich.text import Text
31
+ from rich.theme import Theme
32
+
33
+ # Neon night palette (shared with art.py / city.py): one ground (the terminal's own dark), one ink,
34
+ # one accent (magenta neon) and cyan for anything live; amber for warnings, lime for done.
35
+ ACCENT = "#ff4fd8"
36
+ EMBER = "#3ef0ff"
37
+ THEME = Theme({
38
+ "accent": f"bold {ACCENT}",
39
+ "accent.dim": "#9b4fc8",
40
+ "ember": f"bold {EMBER}",
41
+ "muted": "#a096d2",
42
+ "faint": "#6b6290",
43
+ "tool": "bold #ece4ff",
44
+ "tool.write": "bold #5bff9d",
45
+ "tool.shell": "bold #ffc857",
46
+ "tool.web": "bold #3ef0ff",
47
+ "target": "#d8d0f5",
48
+ "tree": "#4a3a72",
49
+ "ok": "#4ade80",
50
+ "warn": "#fbbf24",
51
+ "err": "bold #f87171",
52
+ "user": "bold #e2e8f0",
53
+ "add": "#d1fae5 on #14532d",
54
+ "del": "#fee2e2 on #7f1d1d",
55
+ "lineno": "grey46",
56
+ "markdown.code": "#fda4af",
57
+ "markdown.h1": f"bold {ACCENT}",
58
+ "markdown.h2": f"bold {ACCENT}",
59
+ "markdown.link": "#93c5fd",
60
+ })
61
+
62
+ console = Console(theme=THEME, highlight=False, soft_wrap=False)
63
+
64
+ # How each tool is shown, Claude Code style: ● Verb(target)
65
+ VERBS = {"write_file": "Write", "edit_file": "Update", "run_shell": "Shell", "open_url": "Open",
66
+ "process": "Process", "use_skill": "Skill", "delegate": "Delegate", "run_parallel": "Parallel"}
67
+ PEEK = {"read": ("read", "file", "files"), "search": ("searched", "pattern", "patterns"),
68
+ "list": ("listed", "folder", "folders"), "fetch": ("fetched", "page", "pages")}
69
+ LEXERS = {".py": "python", ".js": "javascript", ".mjs": "javascript", ".ts": "typescript", ".tsx": "tsx",
70
+ ".jsx": "jsx", ".html": "html", ".css": "css", ".json": "json", ".md": "markdown", ".sh": "bash",
71
+ ".ps1": "powershell", ".yml": "yaml", ".yaml": "yaml", ".toml": "toml", ".sql": "sql", ".go": "go",
72
+ ".rs": "rust", ".java": "java", ".c": "c", ".cpp": "cpp", ".cs": "csharp", ".php": "php", ".rb": "ruby"}
73
+
74
+ PREVIEW_LINES = 10 # lines of a new file shown on screen (display only, costs no tokens)
75
+ DIFF_LINES = 40 # lines of an edit's diff shown on screen
76
+
77
+
78
+ def _gutter(mark: Text, body) -> Table:
79
+ t = Table.grid(padding=(0, 1))
80
+ t.add_column(width=1, no_wrap=True)
81
+ t.add_column(ratio=1)
82
+ t.add_row(mark, body)
83
+ return t
84
+
85
+
86
+ def short(path: str) -> str:
87
+ """Path relative to the current folder when inside it."""
88
+ try:
89
+ rel = os.path.relpath(path)
90
+ return path if rel.startswith("..") else rel
91
+ except ValueError:
92
+ return path
93
+
94
+
95
+ def _brief(target: str) -> str:
96
+ """A short readable name for what was looked at: a file's name, a pattern, a host."""
97
+ t = str(target).strip()
98
+ m = re.match(r"https?://([^/]+)(/[^?#]*)?", t)
99
+ if m:
100
+ tail = (m.group(2) or "").rstrip("/").rsplit("/", 1)[-1]
101
+ return (m.group(1) + ("/…/" + tail if tail else ""))[:48]
102
+ if " in " in t: # "pattern in C:\some\folder"
103
+ pat, _, where = t.rpartition(" in ")
104
+ return f"{pat[:30]} in {pathlib.PurePath(where).name or where}"[:48]
105
+ p = pathlib.PurePath(t)
106
+ return (p.name or t)[:48] if ("\\" in t or "/" in t) else t[:48]
107
+
108
+
109
+ def ktok(n: int) -> str:
110
+ return f"{n / 1000:.1f}k" if n >= 1000 else str(n)
111
+
112
+
113
+ # --- logo ----------------------------------------------------------------------------
114
+
115
+ STREAM_LINES = 8 # how much of a reply-in-progress to show
116
+ PURPLES = ["#7b2cff", "#ff4fd8", "#e6fdff"] # violet → neon magenta, plus a cyan-white shimmer
117
+
118
+
119
+ def _blend(a: str, b: str, t: float) -> str:
120
+ x = [int(a[i:i + 2], 16) for i in (1, 3, 5)]
121
+ y = [int(b[i:i + 2], 16) for i in (1, 3, 5)]
122
+ return "#" + "".join(f"{round(p + (q - p) * t):02x}" for p, q in zip(x, y))
123
+
124
+
125
+ # Forge's mascot: a small red tiger head, drawn as pixel art. Each character cell shows two
126
+ # pixels stacked (the upper half block takes the top colour, its background the bottom one).
127
+ _TIGER_HALF = [
128
+ ".KK.....",
129
+ "KRWK.KKK",
130
+ "KRRKKRRR",
131
+ "KRRRRRKR",
132
+ "KRKRRRRR",
133
+ "KRWWKRRR",
134
+ "KRWYKKRR",
135
+ ".KRRRRRW",
136
+ "KRKRRRWW",
137
+ "KWRRRWWK",
138
+ ".KWWWWKP",
139
+ "..KWWWKM",
140
+ "...KWWWK",
141
+ "....KKKK",
142
+ ]
143
+ TIGER = [row + row[::-1] for row in _TIGER_HALF] # 16 x 14 pixels, mirrored
144
+ TIGER_COLORS = {"K": "#14100f", "R": "#ef2b2d", "W": "#f7f3ee", "Y": "#f5b301",
145
+ "P": "#f472b6", "M": "#7f1d1d", "T": "#ffffff"}
146
+
147
+ # The name in 5x7 pixel letters, each letter a step along a red -> amber gradient.
148
+ _FONT = {
149
+ "F": ["#####", "#....", "#....", "####.", "#....", "#....", "#...."],
150
+ "O": [".###.", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."],
151
+ "R": ["####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#"],
152
+ "G": [".####", "#....", "#....", "#.###", "#...#", "#...#", ".###."],
153
+ "E": ["#####", "#....", "#....", "####.", "#....", "#....", "#####"],
154
+ }
155
+
156
+
157
+ def wordmark(word: str = "FORGE", a: str = "#ef2b2d", b: str = "#f5b301") -> list[Text]:
158
+ keys = "abcdefghij"
159
+ colors = {keys[i]: _blend(a, b, i / max(1, len(word) - 1)) for i in range(len(word))}
160
+ rows = ["." .join(_FONT[ch][r].replace("#", keys[i]) for i, ch in enumerate(word)) for r in range(7)]
161
+ return pixels(rows, colors)
162
+
163
+
164
+ def pixels(grid: list[str], colors: dict[str, str], scale: int = 1) -> list[Text]:
165
+ """Render a pixel grid ('.' = transparent) as lines of half-block characters."""
166
+ grid = [("".join(c * scale for c in row)) for row in grid for _ in range(scale)]
167
+ if len(grid) % 2:
168
+ grid.append("." * len(grid[0]))
169
+ out = []
170
+ for top, bottom in zip(grid[0::2], grid[1::2]):
171
+ line = Text()
172
+ for a, b in zip(top, bottom):
173
+ if a == "." and b == ".":
174
+ line.append(" ")
175
+ elif b == ".":
176
+ line.append("▀", style=colors[a])
177
+ elif a == ".":
178
+ line.append("▄", style=colors[b])
179
+ else:
180
+ line.append("▀", style=f"{colors[a]} on {colors[b]}")
181
+ out.append(line)
182
+ return out
183
+
184
+
185
+ def tiger(scale: int = 1) -> list[Text]:
186
+ return pixels(TIGER, TIGER_COLORS, scale)
187
+
188
+
189
+ def _gradient(word: str, a: str = EMBER, b: str = ACCENT) -> Text:
190
+ t = Text()
191
+ for i, ch in enumerate(word):
192
+ t.append(ch, style=f"bold {_blend(a, b, i / max(1, len(word) - 1))}")
193
+ return t
194
+
195
+
196
+ def banner_art(width: int, rows: int = 10) -> Text:
197
+ """The neon city banner (tiger cub, FORGE sign, skyline) as half-block text, rows lines tall."""
198
+ from . import art
199
+ w = max(40, min(width, 120))
200
+ return Text.from_ansi(art.banner(w, rows * 2).ansi().rstrip("\n"))
201
+
202
+
203
+ def banner_sixel(width_cells: int) -> str | None:
204
+ """The same banner as a sixel image (several real pixels per cell) when the terminal can show
205
+ one, else None. Sized to about width_cells columns."""
206
+ from . import art
207
+ if art.graphics(console) != "sixel":
208
+ return None
209
+ try:
210
+ cols = max(40, min(width_cells, 110))
211
+ w = int(cols * 10 / 3) # ~10px per cell, 3 image pixels per canvas pixel
212
+ return art.banner(w, int(w * 0.30)).sixel(3)
213
+ except Exception:
214
+ return None
215
+
216
+
217
+ def logo(version: str, tagline: str, info: list[Text] | None = None, width: int | None = None,
218
+ picture: bool = True):
219
+ """The start-up header: the neon city banner, then the version line and the facts. With
220
+ picture=False (a sixel image was already drawn above) only the text part."""
221
+ width = width or console.width
222
+ line = Text(f"v{version} ", style="ember").append(tagline, style="muted")
223
+ if width < 60 or not picture:
224
+ head = Group(*wordmark(a=ACCENT, b=EMBER), line) if (width < 60 and picture) else line
225
+ return Group(head, Text(""), *(info or []))
226
+ return Group(banner_art(width - 8), line, Text(""), *(info or []))
227
+
228
+
229
+ def goodbye(lines: list[tuple[str, str]], animate: bool = True) -> None:
230
+ """Clear the terminal and fill it with a goodbye: the mascot waving, centred, plus a few notes.
231
+ lines: (text, style) shown under the mascot."""
232
+ from rich.align import Align
233
+ from . import art as artmod
234
+
235
+ cub = Text.from_ansi(artmod.mascot(24).ansi().rstrip("\n"))
236
+
237
+ def frame(wave: str = "|") -> Group:
238
+ art = cub
239
+ word = _gradient("see you soon", ACCENT, EMBER)
240
+ notes = [Align.center(Text(t, style=s)) for t, s in lines]
241
+ # No screen clear and no full-height padding: the conversation above stays in the
242
+ # scrollback so you can still read and copy it after quitting.
243
+ return Group(Text(""), Align.center(art), Align.center(word), Text(""), *notes)
244
+
245
+ if animate and console.is_terminal and os.environ.get("FORGE_BYE_ANIMATION") == "1":
246
+ from rich.live import Live
247
+ try:
248
+ with Live(frame("\\"), console=console, transient=True, auto_refresh=False) as live:
249
+ for wave in ("|", "/", "|", "\\"):
250
+ time.sleep(0.22)
251
+ live.update(frame(wave), refresh=True)
252
+ time.sleep(0.22)
253
+ except KeyboardInterrupt:
254
+ pass
255
+ console.print(frame("^"))
256
+
257
+
258
+ # --- live status line -----------------------------------------------------------------
259
+
260
+ class _Live:
261
+ """Re-rendered by rich several times a second: shows what is happening right now."""
262
+
263
+ SPIN = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
264
+
265
+ def __init__(self, ui: "UI", spinner: bool = False, docked: bool | None = None):
266
+ self.ui = ui
267
+ self.spinner = spinner # draw the wheelbarrow in front of the status word
268
+ self.docked = spinner if docked is None else docked # inside the message-box layout
269
+
270
+ def __rich__(self):
271
+ u = self.ui
272
+ now = time.time()
273
+ secs = now - u.t0
274
+ # Purple gradient with a soft shimmer that sweeps slowly across the words.
275
+ word = f"{u.shown_activity(now)}…"
276
+ t = Text(" ")
277
+ sprite = None
278
+ u._hd_rows = 0
279
+ if (self.spinner and self.docked and getattr(u, "courier_overlay", None) is not None
280
+ and not getattr(u, "courier_hd_failed", False)):
281
+ try:
282
+ # Real pixels: keep blank rows here; courier_hd.Overlay paints into them.
283
+ rows = courier_hd.pick_rows(console.height, console.width)
284
+ sprite = [Text(" " * courier_hd.lane_cells(rows)) for _ in range(rows)]
285
+ u._hd_rows = rows
286
+ except Exception:
287
+ sprite = None
288
+ if sprite is None and self.spinner and courier.enabled():
289
+ try:
290
+ rows = courier.pick_rows(console.height, console.width)
291
+ sprite = courier.scene(u.barrow, time.monotonic(), rows)
292
+ t = Text(" ")
293
+ except Exception:
294
+ sprite = None
295
+ if self.spinner and sprite is None:
296
+ try:
297
+ for ch, style in u.barrow.pieces(time.monotonic()):
298
+ t.append(ch, style=style or None)
299
+ t.append(" ")
300
+ except Exception:
301
+ t.append(self.SPIN[int(now * 8) % len(self.SPIN)] + " ", style=f"bold {PURPLES[1]}")
302
+ sweep = (now * 7) % (len(word) + 10) - 5
303
+ for i, ch in enumerate(word):
304
+ base = _blend(PURPLES[0], PURPLES[1], i / max(1, len(word) - 1))
305
+ glow = max(0.0, 1 - abs(i - sweep) / 4)
306
+ t.append(ch, style=f"bold {_blend(base, PURPLES[2], glow * 0.85)}")
307
+ t.append(" ")
308
+ bits = [f"{secs:.0f}s"]
309
+ if u.tokens_hint:
310
+ bits.append(u.tokens_hint)
311
+ if u.model:
312
+ bits.append(u.model)
313
+ stop = "esc to stop · type to add to the task" if self.docked else "ctrl+c to stop"
314
+ t.append(" " + " · ".join(bits), style="muted").append(" " + stop, style="faint")
315
+ parts = []
316
+ if u.trail:
317
+ # The last few steps, so a fast run of reads is still readable.
318
+ trail = Text()
319
+ for i, step in enumerate(u.trail[-TRAIL_LINES:]):
320
+ trail.append(("\n" if i else "") + " ✓ ", style=f"{_blend(PURPLES[0], PURPLES[1], 0.4)}")
321
+ trail.append(step, style="faint")
322
+ parts.append(trail)
323
+ live = _strip_think(u.streamed)
324
+ width = max(20, console.width - 8)
325
+ if live.strip():
326
+ # The reply as it is being written: the last few lines, under the status line.
327
+ parts.append(Text("\n".join(" " + ln for ln in _wrap(live, width)[-STREAM_LINES:]),
328
+ style="text"))
329
+ else:
330
+ thought = u.thinking or _think_part(u.streamed)
331
+ if thought.strip():
332
+ # The model's reasoning as it happens. Display only: it is never added to the
333
+ # conversation, so it costs no context tokens.
334
+ lines = _wrap(thought, width)[-THINK_LINES:]
335
+ parts.append(Text("\n".join((" ✻ " if i == 0 else " ") + ln for i, ln in enumerate(lines)),
336
+ style="faint italic"))
337
+ if not self.docked:
338
+ # Not docked (rich draws this live): the city goes on top here too, if turned on.
339
+ scene = u._scene_ansi(max(20, console.width))
340
+ if scene:
341
+ parts.insert(0, Text.from_ansi(scene.rstrip("\n")))
342
+ if sprite:
343
+ # The courier stands on the status line: words sit beside his bottom row.
344
+ lines = [Text(" ") + ln for ln in sprite]
345
+ lines[-1] = lines[-1] + t
346
+ return Group(*parts, *lines)
347
+ return Group(*parts, t) if parts else t
348
+
349
+
350
+ THINK_LINES = 4
351
+ TRAIL_LINES = 3 # recent steps kept under the status line
352
+ HOLD_SECS = 1.2 # each activity stays on screen at least this long, so it can be read
353
+
354
+
355
+ def _wrap(text: str, width: int) -> list[str]:
356
+ lines: list[str] = []
357
+ for para in text.rstrip().split("\n"):
358
+ lines += [para[i:i + width] for i in range(0, max(1, len(para)), width)]
359
+ return lines
360
+
361
+
362
+ def _strip_think(text: str) -> str:
363
+ """Reply text without a <think>…</think> block (some models put their reasoning there)."""
364
+ if "<think>" not in text:
365
+ return text
366
+ head, _, rest = text.partition("<think>")
367
+ _, closed, tail = rest.partition("</think>")
368
+ return head + tail if closed else head
369
+
370
+
371
+ def _think_part(text: str) -> str:
372
+ if "<think>" not in text:
373
+ return ""
374
+ return text.partition("<think>")[2].partition("</think>")[0]
375
+
376
+
377
+ # Which city activity a tool call means, as soon as the model starts calling it.
378
+ _TOOL_STATE = {
379
+ "read_file": "researching", "list_dir": "researching", "glob": "researching", "grep": "researching",
380
+ "web_fetch": "researching", "web_search": "researching", "open_url": "researching",
381
+ "write_file": "coding", "edit_file": "coding", "run_shell": "processing", "process": "processing",
382
+ "delegate": "delegating", "run_parallel": "delegating", "use_skill": "thinking", "todo": "thinking",
383
+ }
384
+
385
+ _ACTION_WORDS = {
386
+ "read_file": "reading a file", "list_dir": "listing a folder", "glob": "finding files",
387
+ "grep": "searching the code", "write_file": "writing a file", "edit_file": "editing a file",
388
+ "run_shell": "running a command", "web_fetch": "fetching a page", "web_search": "searching the web",
389
+ "delegate": "handing work to a helper", "use_skill": "loading a skill", "todo": "updating the plan",
390
+ }
391
+
392
+
393
+ class UI:
394
+ def __init__(self, verbose: bool = False):
395
+ self.verbose = verbose
396
+ self.console = console
397
+ self._status = None
398
+ self.lock = threading.RLock()
399
+ self.activity = "Thinking"
400
+ self.model = ""
401
+ self.tokens_hint = ""
402
+ self.t0 = time.time()
403
+ self.peeks: dict[str, list[str]] = {}
404
+ self.trail: list[str] = []
405
+ self._shown = ("Thinking", 0.0)
406
+ self.streamed = ""
407
+ self.thinking = ""
408
+ self.think_t0 = 0.0
409
+ self.think_secs = 0.0
410
+ # Docked: the message box stays open while Forge works. The status is then drawn above the
411
+ # box by prompt_toolkit (see status_ansi) instead of by a rich live display, and questions
412
+ # (approvals, "Forge asks") are answered in that same box.
413
+ self.docked = False
414
+ self.busy = False
415
+ self.pending: dict | None = None
416
+ # The pixel city above the message box: tiny workers acting out what Forge is doing.
417
+ # FORGE_SCENE=off turns it off; it also hides itself in short or narrow terminals.
418
+ from .city import City
419
+ self.city = City()
420
+ # The big city is opt-in now (FORGE_SCENE=city); by default a small wheelbarrow in front of
421
+ # the status word acts out the same events.
422
+ self.scene = os.environ.get("FORGE_SCENE", "").lower() in ("city", "on", "1", "yes", "true")
423
+ from .city import Barrow
424
+ self.barrow = Barrow(self.city.motion)
425
+
426
+ # the pixel city ---------------------------------------------------------------------
427
+ def act(self, state: str, detail: str = "", wait: float = 0.0) -> None:
428
+ """Tell the city what is really happening (only ever called from real agent events)."""
429
+ try:
430
+ self.city.set(state, detail, wait)
431
+ self.barrow.on(state)
432
+ except Exception:
433
+ pass
434
+
435
+ def _scene_ansi(self, width: int) -> str:
436
+ """The city frame, or "" when it shouldn't be shown (off, idle, terminal too small)."""
437
+ c = self.city
438
+ if not self.scene or not console.is_terminal:
439
+ return ""
440
+ if c.settled():
441
+ c.set("idle")
442
+ if c.state == "idle" and not self.busy:
443
+ return ""
444
+ try:
445
+ height = console.size.height
446
+ except Exception:
447
+ height = 40
448
+ if height < 26:
449
+ return ""
450
+ try:
451
+ return c.frame(width)
452
+ except Exception:
453
+ return ""
454
+
455
+ # docked mode ------------------------------------------------------------------
456
+ def status_ansi(self) -> str:
457
+ """The live status (spinner, activity, reasoning or reply preview) as ANSI text, for the
458
+ area above the message box. Empty when Forge is idle."""
459
+ width = max(20, console.width)
460
+ scene = self._scene_ansi(width)
461
+ if not self.busy:
462
+ self.hd_lane = None
463
+ return scene
464
+ import io
465
+ c = getattr(self, "_status_console", None)
466
+ if c is None or c.width != width:
467
+ # Reused between redraws: building a Console every frame cost a quarter of a CPU core.
468
+ c = Console(file=io.StringIO(), theme=THEME, force_terminal=True, color_system="truecolor",
469
+ width=width, highlight=False, soft_wrap=False)
470
+ self._status_console = c
471
+ buf = c.file
472
+ buf.seek(0)
473
+ buf.truncate()
474
+ c.print(_Live(self, spinner=True))
475
+ out = scene + buf.getvalue()
476
+ rows = getattr(self, "_hd_rows", 0)
477
+ # Where the courier's blank rows sit inside the prompt's message (they end the status).
478
+ self.hd_lane = (out.count(chr(10)) - rows, rows, 2) if rows else None
479
+ return out
480
+
481
+ def answer(self, text: str) -> bool:
482
+ """The message box was submitted while a question is waiting: deliver it. False = no question."""
483
+ p = self.pending
484
+ if not p:
485
+ return False
486
+ p["answer"] = text
487
+ p["event"].set()
488
+ return True
489
+
490
+ def _input(self, markup: str, label: str) -> str:
491
+ """Read one answer: from the open message box when docked, else a normal console prompt."""
492
+ if not self.docked:
493
+ return console.input(markup)
494
+ p = {"label": label, "event": threading.Event(), "answer": ""}
495
+ self.pending = p
496
+ try:
497
+ while not p["event"].wait(0.1): # short waits so a stop (ctrl+c) gets through
498
+ pass
499
+ finally:
500
+ self.pending = None
501
+ if p.get("stopped"):
502
+ raise KeyboardInterrupt # a stop is never an answer (an empty answer means "yes")
503
+ return p["answer"]
504
+
505
+ def queued(self) -> None:
506
+ """A message typed while Forge works is read by the model at its next step."""
507
+ console.print(Text(" ↳ added to the task — Forge reads it at its next step", style="faint"))
508
+
509
+ # live status ------------------------------------------------------------------
510
+ def start_spinner(self, label: str = "Thinking") -> None:
511
+ self.activity = label[:1].upper() + label[1:]
512
+ if label.lower().startswith("think"):
513
+ self.act("thinking")
514
+ elif self.city.state in ("idle", "queued") + ("completed", "failed", "cancelled"):
515
+ self.act("processing")
516
+ if self.docked:
517
+ if not self.busy:
518
+ self.t0 = time.time()
519
+ self.trail = []
520
+ self.busy = True
521
+ return
522
+ if not console.is_terminal:
523
+ return
524
+ if self._status:
525
+ return
526
+ self.t0 = time.time()
527
+ self.trail = []
528
+ self._status = Live(_Live(self, spinner=True, docked=False), console=console, transient=True,
529
+ refresh_per_second=12)
530
+ self._status.start()
531
+
532
+ def set_activity(self, label: str) -> None:
533
+ self.activity = label[:1].upper() + label[1:]
534
+
535
+ def shown_activity(self, now: float | None = None) -> str:
536
+ """The activity on screen. A new one replaces the old only after HOLD_SECS, so a burst of
537
+ quick steps doesn't flicker past unreadably (the trail lists the ones in between)."""
538
+ now = time.time() if now is None else now
539
+ label, since = self._shown
540
+ if self.activity != label and now - since >= HOLD_SECS:
541
+ self._shown = (self.activity, now)
542
+ return self._shown[0]
543
+
544
+ def _step_done(self, text: str) -> None:
545
+ with self.lock:
546
+ if not self.trail or self.trail[-1] != text:
547
+ self.trail = (self.trail + [text])[-TRAIL_LINES:]
548
+
549
+ def stream(self, piece: str) -> None:
550
+ """Called as the model's reply arrives. Reply text, reasoning (a Thought) and the name of a
551
+ tool it starts calling (an Action) are shown live in the status area and then dropped;
552
+ none of it is added to the conversation."""
553
+ if not piece:
554
+ return
555
+ kind = type(piece).__name__
556
+ if kind == "Thought":
557
+ if not self.thinking:
558
+ self.think_t0 = time.time()
559
+ self.thinking = (self.thinking + piece)[-4000:]
560
+ self.think_secs = time.time() - self.think_t0
561
+ return
562
+ if kind == "Action":
563
+ self.set_activity(f"Preparing: {_ACTION_WORDS.get(str(piece), str(piece).replace('_', ' '))}")
564
+ self.act(_TOOL_STATE.get(str(piece), "processing"))
565
+ return
566
+ self.streamed += piece
567
+ if "<think>" in self.streamed and "</think>" not in self.streamed:
568
+ return # still reasoning inside a <think> block
569
+ if self.activity == "Thinking":
570
+ self.activity = "Writing"
571
+
572
+ def stream_reset(self) -> None:
573
+ self.streamed = ""
574
+ self.thinking = ""
575
+
576
+ def thought_note(self) -> None:
577
+ """After a model step: one quiet line saying how long it reasoned (its text is not kept)."""
578
+ secs, self.think_secs = self.think_secs, 0.0
579
+ if secs >= 1:
580
+ self._print(Text(f" ✻ thought for {secs:.0f}s", style="faint italic"))
581
+
582
+ def stop_spinner(self) -> None:
583
+ self.streamed = ""
584
+ self.thinking = ""
585
+ self.busy = False
586
+ if self._status:
587
+ self._status.stop()
588
+ self._status = None
589
+
590
+ def log(self, msg: str) -> None:
591
+ """Failover diagnostics. Shown with --verbose (or /verbose)."""
592
+ if self.verbose:
593
+ console.print(Text(f" ↳ {msg}", style="faint"))
594
+
595
+ # read-only actions are grouped into one quiet line ---------------------------------
596
+ def peek(self, kind: str, target: str) -> None:
597
+ name = _brief(target)
598
+ with self.lock:
599
+ self.peeks.setdefault(kind, []).append(name)
600
+ self.act("researching", name)
601
+ verb = {"read": "Reading", "search": "Searching", "list": "Listing", "fetch": "Fetching"}.get(kind, "Looking at")
602
+ done = PEEK.get(kind, (kind,))[0]
603
+ self.set_activity(f"{verb} {name}")
604
+ self._step_done(f"{done[:1].upper() + done[1:]} {name}")
605
+
606
+ def flush(self) -> None:
607
+ """Print the reads/searches since the last change as one quiet line that names them."""
608
+ with self.lock:
609
+ if not self.peeks:
610
+ return
611
+ peeks, self.peeks = self.peeks, {}
612
+ line = Text(" ")
613
+ line.append("○ ", style="accent.dim")
614
+ for i, (kind, names) in enumerate(peeks.items()):
615
+ done = PEEK.get(kind, (kind,))[0]
616
+ unique = list(dict.fromkeys(names))
617
+ if i:
618
+ line.append(" · ", style="faint")
619
+ line.append(done[:1].upper() + done[1:] + " ", style="muted")
620
+ line.append(", ".join(unique[:3]), style="faint")
621
+ if len(unique) > 3:
622
+ line.append(f" +{len(unique) - 3} more", style="faint italic")
623
+ self._print(line)
624
+
625
+ def _print(self, *items) -> None:
626
+ live = self._status is not None
627
+ if live:
628
+ self._status.stop()
629
+ for it in items:
630
+ console.print(it)
631
+ if live:
632
+ self._status.start()
633
+
634
+ # conversation -------------------------------------------------------------------
635
+ def user(self, text: str) -> None:
636
+ console.print(_gutter(Text("❯", style="accent"), Text(text, style="user")))
637
+
638
+ def assistant(self, text: str, meta: str = "") -> None:
639
+ self.flush()
640
+ self.stop_spinner()
641
+ body = Markdown(text.strip(), code_theme="monokai", inline_code_theme="monokai")
642
+ console.print()
643
+ console.print(Padding(_gutter(Text("◆", style="accent"), body), (0, 0, 0, 1)))
644
+ if meta:
645
+ console.print(Text(f" {meta}", style="faint"))
646
+ console.print()
647
+
648
+ def action(self, verb: str, target: str, note: str = "") -> None:
649
+ """● Verb(target) — one line per change or command."""
650
+ self.flush()
651
+ self.act("coding" if verb in ("Write", "Update", "Delete", "Move") else
652
+ "processing" if verb in ("Shell", "Process", "Run") else
653
+ "researching" if verb in ("Fetch", "Search", "Open", "Browse") else
654
+ "delegating" if verb in ("Delegate", "Parallel", "Team") else
655
+ "thinking" if verb == "Skill" else self.city.state, target)
656
+ kind = ("tool.write" if verb in ("Write", "Update", "Delete", "Move") else
657
+ "tool.shell" if verb in ("Shell", "Process", "Run") else
658
+ "tool.web" if verb in ("Fetch", "Search", "Open", "Browse") else "accent")
659
+ line = Text(verb, style=kind).append(" ").append(target[:160], style="target")
660
+ if note:
661
+ line.append(" · " + note, style="faint")
662
+ self._print(Text(""), Padding(_gutter(Text("●", style=kind), line), (0, 0, 0, 1)))
663
+
664
+ def tool(self, name: str, summary: str) -> None:
665
+ self.action(VERBS.get(name, name), summary)
666
+
667
+ def result(self, first: str, body=None) -> None:
668
+ """ ⎿ first line, then an optional renderable indented under it."""
669
+ items = [Padding(Text("└─ ", style="tree").append(first, style="muted"), (0, 0, 0, 3))]
670
+ if body is not None:
671
+ items.append(Padding(body, (0, 0, 0, 6)))
672
+ self._print(*items)
673
+
674
+ def tool_result(self, text: str) -> None:
675
+ lines = text.splitlines() or ["(no output)"]
676
+ body = Text()
677
+ for ln in lines[1:4]:
678
+ body.append(ln[:200] + "\n", style="faint")
679
+ if len(lines) > 4:
680
+ body.append(f"… +{len(lines) - 4} lines\n", style="faint")
681
+ body.rstrip()
682
+ self.result(lines[0][:200], body if body.plain else None)
683
+
684
+ # files ---------------------------------------------------------------------------
685
+ def wrote(self, path: str, content: str, old: str | None) -> None:
686
+ if old is not None:
687
+ return self.edited(path, old, content)
688
+ lines = content.splitlines()
689
+ self.action("Write", short(path))
690
+ lexer = LEXERS.get(pathlib.Path(path).suffix.lower(), "text")
691
+ shown = "\n".join(lines[:PREVIEW_LINES])
692
+ body = [Syntax(shown, lexer, theme="monokai", line_numbers=True, background_color="default",
693
+ word_wrap=False)] if shown else []
694
+ if len(lines) > PREVIEW_LINES:
695
+ body.append(Text(f"… +{len(lines) - PREVIEW_LINES} lines", style="faint"))
696
+ self.result(f"Wrote {len(lines)} lines to {short(path)}", Group(*body) if body else None)
697
+
698
+ def edited(self, path: str, old: str, new: str) -> None:
699
+ a, b = old.splitlines(), new.splitlines()
700
+ added = removed = 0
701
+ out = Text()
702
+ shown = 0
703
+ for group in difflib.SequenceMatcher(None, a, b).get_grouped_opcodes(2):
704
+ if shown:
705
+ out.append(" ⋮\n", style="faint")
706
+ for tag, i1, i2, j1, j2 in group:
707
+ rows = []
708
+ if tag == "equal":
709
+ rows = [(j1 + k + 1, " ", a[i1 + k], None) for k in range(i2 - i1)]
710
+ if tag in ("replace", "delete"):
711
+ removed += i2 - i1
712
+ rows += [(i + 1, "-", a[i], "del") for i in range(i1, i2)]
713
+ if tag in ("replace", "insert"):
714
+ added += j2 - j1
715
+ rows += [(j + 1, "+", b[j], "add") for j in range(j1, j2)]
716
+ for no, sign, txt, style in rows:
717
+ shown += 1
718
+ if shown > DIFF_LINES:
719
+ continue
720
+ out.append(f"{no:>5} ", style="lineno")
721
+ out.append(f"{sign} {txt[:180]}".ljust(60) + "\n", style=style or "muted")
722
+ if shown > DIFF_LINES:
723
+ out.append(f"… +{shown - DIFF_LINES} more diff lines\n", style="faint")
724
+ out.rstrip()
725
+ self.action("Update", short(path))
726
+ summary = ", ".join(x for x in (f"Added {added} line{'s' * (added != 1)}" if added else "",
727
+ f"removed {removed} line{'s' * (removed != 1)}" if removed else "") if x)
728
+ self.result(summary or "No changes", out if out.plain else None)
729
+
730
+ # status messages ----------------------------------------------------------------
731
+ def info(self, msg: str) -> None:
732
+ self.flush()
733
+ self._print(Text(msg, style="muted"))
734
+
735
+ def plan(self, title: str, body: str) -> None:
736
+ self.flush()
737
+ self._print(Panel(Markdown(body), title=title, border_style="accent", expand=False))
738
+
739
+ def ok(self, msg: str) -> None:
740
+ self.flush()
741
+ self._print(Text("✓ ", style="ok").append(msg))
742
+
743
+ def warn(self, msg: str) -> None:
744
+ self.flush()
745
+ self._print(Text(msg, style="warn"))
746
+
747
+ def failover(self, msg: str) -> None:
748
+ low = msg.lower()
749
+ m = re.search(r"waiting (\d+(?:\.\d+)?)\s*s", low)
750
+ if "resting" in low or m:
751
+ self.act("waiting", wait=float(m.group(1)) if m else 30.0)
752
+ elif "checking the answer" in low:
753
+ self.act("thinking")
754
+ elif "failed" in low or "trying the next" in low:
755
+ self.city.blip()
756
+ self._print(Padding(Text("↻ ", style="warn").append(msg, style="faint"), (0, 0, 0, 3)))
757
+
758
+ def error(self, msg: str) -> None:
759
+ self.flush()
760
+ self.act("failed")
761
+ self.stop_spinner()
762
+ console.print(Text("✗ ", style="err").append(msg, style="err"))
763
+
764
+ def rule(self, title: str = "") -> None:
765
+ console.rule(Text(title, style="faint") if title else "", style="faint")
766
+
767
+ # panels and tables ---------------------------------------------------------------
768
+ def panel(self, body, title: str = "", subtitle: str = "") -> None:
769
+ console.print(Panel(body, title=Text(title, style="accent") if title else None,
770
+ subtitle=Text(subtitle, style="faint") if subtitle else None,
771
+ title_align="left", subtitle_align="right",
772
+ border_style="accent.dim", box=box.ROUNDED, padding=(0, 1)))
773
+
774
+ def table(self, title: str, columns: list[str], rows: list[list], styles: list[str] | None = None) -> None:
775
+ t = Table(title=Text(title, style="accent") if title else None, title_justify="left",
776
+ box=box.SIMPLE_HEAD, header_style="muted", border_style="faint", pad_edge=False)
777
+ for i, col in enumerate(columns):
778
+ t.add_column(col, style=(styles[i] if styles else None), overflow="fold")
779
+ for r in rows:
780
+ t.add_row(*[x if isinstance(x, Text) else str(x) for x in r])
781
+ console.print(t)
782
+
783
+ def replay(self, messages: list[dict], last: int = 6) -> None:
784
+ """Show the tail of a resumed conversation."""
785
+ shown = [m for m in messages if m["role"] in ("user", "assistant") and (m.get("content") or "").strip()]
786
+ if len(shown) > last:
787
+ console.print(Text(f" … {len(shown) - last} earlier messages", style="faint"))
788
+ for m in shown[-last:]:
789
+ if m["role"] == "user":
790
+ self.user(m["content"].split("\n\n[Forge: skills")[0])
791
+ else:
792
+ self.assistant(m["content"])
793
+
794
+ # permission ----------------------------------------------------------------------
795
+ def ask(self, question: str, options: list[str]) -> str:
796
+ """The model asks the user something; returns the answer (or '' if nobody can answer)."""
797
+ self.flush()
798
+ self.stop_spinner()
799
+ if not self.docked and not sys.stdin.isatty():
800
+ return ""
801
+ body = [Text(question, style="text")]
802
+ for i, o in enumerate(options, 1):
803
+ body.append(Text(f" {i}. {o}", style="accent"))
804
+ console.print(Panel(Group(*body), title=Text("? Forge asks", style="accent"), title_align="left",
805
+ border_style="accent", box=box.ROUNDED, padding=(0, 1)))
806
+ hint = " (number or your own answer)" if options else ""
807
+ try:
808
+ ans = self._input(f" [muted]Answer{hint}[/] [accent]›[/] ", f"Answer{hint}").strip()
809
+ except (EOFError, KeyboardInterrupt):
810
+ return ""
811
+ if ans.isdigit() and 1 <= int(ans) <= len(options):
812
+ return options[int(ans) - 1]
813
+ return ans
814
+
815
+ def todos(self, items: list[dict]) -> None:
816
+ marks = {"done": ("✔", "ok"), "doing": ("◐", "accent"), "todo": ("☐", "muted")}
817
+ lines = Text()
818
+ for it in items:
819
+ mark, style = marks.get(it.get("status", "todo"), marks["todo"])
820
+ done = it.get("status") == "done"
821
+ lines.append(f" {mark} ", style=style).append(
822
+ str(it.get("task", ""))[:120] + "\n", style="faint strike" if done else "text")
823
+ lines.rstrip()
824
+ self._print(lines if lines.plain else Text(" (empty list)", style="muted"))
825
+
826
+ def confirm(self, title: str, detail: str, why: str = "") -> tuple[str, str]:
827
+ """Returns (choice, reason). choice is 'y', 'a' (always) or 'n'."""
828
+ self.flush()
829
+ self.stop_spinner()
830
+ if not self.docked and not sys.stdin.isatty():
831
+ return "n", "non-interactive session; rerun with --yes to allow"
832
+ lines = detail.splitlines()
833
+ body = []
834
+ if why:
835
+ body.append(Text(f"needs your OK: {why}", style="warn"))
836
+ if lines:
837
+ clipped = "\n".join(lines[:40])
838
+ lexer = "diff" if any(l[:1] in "+-@" for l in lines[:5]) else "powershell"
839
+ body.append(Syntax(clipped, lexer, theme="monokai", background_color="default", word_wrap=True))
840
+ if len(lines) > 40:
841
+ body.append(Text(f"… +{len(lines) - 40} more lines", style="faint"))
842
+ console.print(Panel(Group(*body) if body else Text(""), title=Text(f"? {title}", style="warn"),
843
+ title_align="left", border_style="warn", box=box.ROUNDED, padding=(0, 1)))
844
+ try:
845
+ ans = self._input(
846
+ " [warn]Allow?[/] [bold]y[/][muted]es[/] · [bold]a[/][muted]lways for this kind[/] · "
847
+ "[bold]n[/][muted]o[/] [accent]›[/] ", "Allow? y = yes · a = always for this kind · n = no"
848
+ ).strip().lower()
849
+ except (EOFError, KeyboardInterrupt):
850
+ return "n", ""
851
+ if ans in ("y", "yes", ""):
852
+ return "y", ""
853
+ if ans in ("a", "always"):
854
+ return "a", ""
855
+ try:
856
+ reason = self._input(" [muted]What should it do instead? (optional)[/] [accent]›[/] ",
857
+ "What should it do instead? (optional, enter to skip)").strip()
858
+ except (EOFError, KeyboardInterrupt):
859
+ reason = ""
860
+ return "n", reason