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.
- forgecli/__init__.py +2 -0
- forgecli/ads.py +322 -0
- forgecli/agent.py +1093 -0
- forgecli/art.py +574 -0
- forgecli/attach.py +246 -0
- forgecli/brief.py +215 -0
- forgecli/browsercheck.py +99 -0
- forgecli/checkpoints.py +81 -0
- forgecli/city.py +833 -0
- forgecli/cli.py +1668 -0
- forgecli/config.py +194 -0
- forgecli/courier.py +177 -0
- forgecli/courier_data.py +164 -0
- forgecli/courier_hd.py +431 -0
- forgecli/courier_hd_data.bin +0 -0
- forgecli/courier_hd_data.py +19 -0
- forgecli/ghlogin.py +76 -0
- forgecli/github.py +54 -0
- forgecli/guards.py +403 -0
- forgecli/hooks.py +50 -0
- forgecli/httpapi.py +434 -0
- forgecli/images.py +110 -0
- forgecli/keys.py +152 -0
- forgecli/keyvault.py +320 -0
- forgecli/mcp.py +674 -0
- forgecli/memory.py +42 -0
- forgecli/openapi.py +331 -0
- forgecli/parallel.py +308 -0
- forgecli/prices.py +72 -0
- forgecli/procs.py +144 -0
- forgecli/providers.py +758 -0
- forgecli/quality.py +192 -0
- forgecli/repomap.py +78 -0
- forgecli/roster.py +227 -0
- forgecli/safety.py +93 -0
- forgecli/sandbox.py +83 -0
- forgecli/sessions.py +97 -0
- forgecli/skills.py +446 -0
- forgecli/tools.py +1184 -0
- forgecli/ui.py +860 -0
- forgecli/worktrees.py +79 -0
- forgecommandline-0.8.0.data/data/skills/commit/SKILL.md +14 -0
- forgecommandline-0.8.0.data/data/skills/debug/SKILL.md +17 -0
- forgecommandline-0.8.0.data/data/skills/explain/SKILL.md +14 -0
- forgecommandline-0.8.0.data/data/skills/init/SKILL.md +19 -0
- forgecommandline-0.8.0.data/data/skills/library/LICENSES/agent-skills-MIT.txt +21 -0
- forgecommandline-0.8.0.data/data/skills/library/LICENSES/wondelai-MIT.txt +23 -0
- forgecommandline-0.8.0.data/data/skills/library/README.md +9 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/accessibility-checklist.md +160 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/definition-of-done.md +67 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/observability-checklist.md +92 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/orchestration-patterns.md +370 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/performance-checklist.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/security-checklist.md +248 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/testing-patterns.md +235 -0
- forgecommandline-0.8.0.data/data/skills/library/api-and-interface-design/SKILL.md +367 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/SKILL.md +279 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/artifact-templates.md +157 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/methods.md +87 -0
- forgecommandline-0.8.0.data/data/skills/library/awwwards-tier-web-application/SKILL.md +877 -0
- forgecommandline-0.8.0.data/data/skills/library/browser-testing-with-devtools/SKILL.md +317 -0
- forgecommandline-0.8.0.data/data/skills/library/ci-cd-and-automation/SKILL.md +390 -0
- forgecommandline-0.8.0.data/data/skills/library/code-review-and-quality/SKILL.md +398 -0
- forgecommandline-0.8.0.data/data/skills/library/code-simplification/SKILL.md +331 -0
- forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/SKILL.md +311 -0
- forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/references/floor-guard.md +182 -0
- forgecommandline-0.8.0.data/data/skills/library/context-engineering/SKILL.md +353 -0
- forgecommandline-0.8.0.data/data/skills/library/debugging-and-error-recovery/SKILL.md +300 -0
- forgecommandline-0.8.0.data/data/skills/library/deprecation-and-migration/SKILL.md +247 -0
- forgecommandline-0.8.0.data/data/skills/library/documentation-and-adrs/SKILL.md +297 -0
- forgecommandline-0.8.0.data/data/skills/library/doubt-driven-development/SKILL.md +243 -0
- forgecommandline-0.8.0.data/data/skills/library/frontend-ui-engineering/SKILL.md +340 -0
- forgecommandline-0.8.0.data/data/skills/library/git-workflow-and-versioning/SKILL.md +355 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/SKILL.md +193 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/examples.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/frameworks.md +99 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/refinement-criteria.md +113 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/scripts/idea-refine.sh +15 -0
- forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/SKILL.md +275 -0
- forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/references/artifact-templates.md +101 -0
- forgecommandline-0.8.0.data/data/skills/library/incremental-implementation/SKILL.md +249 -0
- forgecommandline-0.8.0.data/data/skills/library/interview-me/SKILL.md +235 -0
- forgecommandline-0.8.0.data/data/skills/library/observability-and-instrumentation/SKILL.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/performance-optimization/SKILL.md +267 -0
- forgecommandline-0.8.0.data/data/skills/library/performance-optimization/references/optimization-patterns.md +250 -0
- forgecommandline-0.8.0.data/data/skills/library/planning-and-task-breakdown/SKILL.md +257 -0
- forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/SKILL.md +203 -0
- forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/references/artifact-templates.md +79 -0
- forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/SKILL.md +216 -0
- forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/references/hardening-patterns.md +324 -0
- forgecommandline-0.8.0.data/data/skills/library/shipping-and-launch/SKILL.md +336 -0
- forgecommandline-0.8.0.data/data/skills/library/source-driven-development/SKILL.md +216 -0
- forgecommandline-0.8.0.data/data/skills/library/spec-driven-development/SKILL.md +259 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/SKILL.md +207 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/building-blocks.md +272 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/common-designs.md +257 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/database-scaling.md +295 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/estimation-numbers.md +250 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/four-step-process.md +286 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/reliability-operations.md +373 -0
- forgecommandline-0.8.0.data/data/skills/library/test-driven-development/SKILL.md +398 -0
- forgecommandline-0.8.0.data/data/skills/library/using-agent-skills/SKILL.md +192 -0
- forgecommandline-0.8.0.data/data/skills/review/SKILL.md +16 -0
- forgecommandline-0.8.0.data/data/skills/run/SKILL.md +17 -0
- forgecommandline-0.8.0.data/data/skills/security-review/SKILL.md +20 -0
- forgecommandline-0.8.0.data/data/skills/simplify/SKILL.md +18 -0
- forgecommandline-0.8.0.data/data/skills/test/SKILL.md +16 -0
- forgecommandline-0.8.0.data/data/skills/web-build/SKILL.md +25 -0
- forgecommandline-0.8.0.dist-info/METADATA +357 -0
- forgecommandline-0.8.0.dist-info/RECORD +113 -0
- forgecommandline-0.8.0.dist-info/WHEEL +5 -0
- forgecommandline-0.8.0.dist-info/entry_points.txt +2 -0
- 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
|