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/city.py ADDED
@@ -0,0 +1,833 @@
1
+ """The pixel city: what Forge is doing, acted out by tiny tiger-cub workers on a neon street.
2
+
3
+ There are no spinners here. Every animation is driven by a real state change reported by the
4
+ agent (ui.py calls City.set when a model call starts, a tool runs, a provider fails, a turn ends):
5
+
6
+ idle sits on the bench under the lamp, tail swishing
7
+ queued waits by the depot, tapping a foot, "..." over its head
8
+ thinking at the desk, thought bubble; every few seconds gets up and paces
9
+ coding at the desk typing, code scrolling up the monitor
10
+ processing pushes a wheelbarrow of glowing parcels from the depot to the FORGE door and back
11
+ researching walks to the archive, pulls a document, reads it under a magnifier
12
+ delegating hands parcels to helper cubs who wheel them down the street (several at once)
13
+ waiting on the bench with a coffee while rate limits pass, countdown under the sign
14
+ completed jumps and cheers, confetti
15
+ failed drops the parcels, red "!"
16
+ cancelled holds up a paw, STOP
17
+
18
+ Sprites are small string sheets, drawn by code, so nothing is generated per task. The street
19
+ behind them is cached and redrawn only twice a second. Timing comes from the clock alone, so
20
+ the same moment always draws the same frame. Rendering happens on the UI thread from plain
21
+ attributes, so it never blocks the agent. FORGE_MOTION=reduce holds still poses (no walking,
22
+ flicker or confetti). Terminals without truecolor get a one-line text version.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import math
27
+ import os
28
+ import threading
29
+ import time
30
+
31
+ from . import art
32
+ from .art import (AMBER, CYAN, GREEN, INK, LAVENDER, MAGENTA, NIGHT, RED, Canvas, hash32, mix, scale)
33
+
34
+ H = 18 # scene height in pixels = 9 terminal rows
35
+ FEET = 14 # the sidewalk row characters stand on
36
+ HORIZON = 12 # buildings end here; sidewalk 13-14, curb 15, puddles 16-17
37
+ SEAT = 12 # bench and stool seats
38
+ MIN_WIDTH = 90 # narrower terminals get the one-line version
39
+
40
+ STATES = ("idle", "queued", "thinking", "coding", "processing", "researching", "delegating", "waiting",
41
+ "completed", "failed", "cancelled")
42
+ FINAL = ("completed", "failed", "cancelled")
43
+ SIGN = {"idle": "IDLE", "queued": "QUEUE", "thinking": "THINK", "coding": "CODE", "processing": "RUN",
44
+ "researching": "READ", "delegating": "TEAM", "waiting": "WAIT", "completed": "DONE",
45
+ "failed": "OOPS", "cancelled": "STOP"}
46
+ SIGN_COL = {"completed": GREEN, "failed": RED, "cancelled": AMBER, "waiting": AMBER, "thinking": CYAN,
47
+ "researching": CYAN, "idle": LAVENDER}
48
+
49
+ WALK_SPEED = 22.0 # pixels per second when walking somewhere
50
+ PUSH_SPEED = 15.0 # with a loaded wheelbarrow
51
+
52
+ # ---------------------------------------------------------------------------------------------
53
+ # Sprite sheet. Facing right, 8 wide; the body is drawn from these rows, arms and legs by pose.
54
+ # f fur s ear k stripe e eye n nose w muzzle c cloth C strap d cloth shade
55
+ HEAD = [".s..s...",
56
+ ".ffkff..",
57
+ ".fffefn.",
58
+ ".kffwww."]
59
+ BODY = ["..cCcc..",
60
+ "..cccc..",
61
+ "..dddd.."]
62
+ LEGS = [((1, 0), (5, 0)), ((3, 0), (4, 0)), ((2, 0), (6, 0)), ((3, 0), (4, 0))] # stride frames
63
+ # arms: list of (dx, dy, key) on top of the body; dy counts from the head's top row
64
+ ARMS = {
65
+ "stand": [(3, 5, "f"), (3, 6, "p")],
66
+ "walk0": [(2, 5, "f"), (1, 6, "p")],
67
+ "walk1": [(4, 5, "f"), (5, 6, "p")],
68
+ "push": [(5, 5, "f"), (6, 5, "f"), (7, 5, "p")],
69
+ "pull": [(2, 5, "f"), (1, 5, "f"), (0, 5, "p")],
70
+ "carry": [(5, 4, "f"), (6, 3, "p"), (7, 3, "p")],
71
+ "type0": [(5, 5, "f"), (6, 5, "p")],
72
+ "type1": [(5, 5, "f"), (6, 4, "p")],
73
+ "think": [(5, 4, "f"), (6, 3, "p")],
74
+ "cheer": [(5, 3, "f"), (6, 2, "f"), (6, 1, "p"), (1, 3, "f"), (0, 2, "f"), (0, 1, "p")],
75
+ "startle": [(6, 3, "f"), (7, 2, "p"), (1, 3, "f"), (0, 2, "p")],
76
+ "stop": [(5, 4, "f"), (6, 3, "f"), (7, 2, "p"), (7, 1, "p")],
77
+ "reach": [(5, 3, "f"), (6, 2, "f"), (6, 1, "p")],
78
+ "read": [(5, 5, "f"), (6, 5, "p")],
79
+ "cup": [(5, 5, "f"), (6, 4, "p")],
80
+ }
81
+
82
+ CREW = [ # cloth colours: the main cub wears Forge cyan; helpers wear magenta, amber, lime
83
+ {"c": (34, 176, 214), "C": (140, 240, 255), "d": (20, 104, 150)},
84
+ {"c": (214, 58, 178), "C": (255, 150, 230), "d": (130, 30, 112)},
85
+ {"c": (226, 150, 40), "C": (255, 214, 130), "d": (150, 90, 20)},
86
+ {"c": (90, 200, 90), "C": (180, 255, 170), "d": (40, 120, 50)},
87
+ ]
88
+ BASE = {"f": art.FUR, "s": art.FUR_SHADE, "k": art.STRIPE, "e": CYAN, "n": art.NOSE, "w": art.CREAM,
89
+ "p": art.CREAM, "b": (36, 18, 46)}
90
+
91
+ PARCELS = [AMBER, CYAN, MAGENTA, (255, 140, 70)]
92
+
93
+
94
+ def cub(cv: Canvas, x: int, yb: int, facing: int = 1, pose: str = "stand", step: int = 0, crew: int = 0,
95
+ sit: bool = False, blink: bool = False, look_up: bool = False, dim: float = 1.0) -> None:
96
+ """Draw a tiger-cub worker. x is the left of its 8-pixel box, yb the row its feet are on."""
97
+ pal = dict(BASE)
98
+ pal.update(CREW[crew % len(CREW)])
99
+ if dim < 1:
100
+ pal = {k: scale(v, dim) for k, v in pal.items()}
101
+
102
+ def P(dx, dy, key, top):
103
+ X = x + (dx if facing > 0 else 7 - dx)
104
+ cv.put(X, top + dy, pal[key])
105
+
106
+ walking = pose.startswith("walk") or pose in ("push", "pull")
107
+ bob = 1 if (walking and step % 2 == 1) else 0
108
+ top = (yb - 7 - bob) if not sit else (yb - 7)
109
+ # tail, behind the body; swishes with the stride or slowly when sitting
110
+ ty = 4 + (step % 2 if not sit else 0)
111
+ P(0, ty, "f", top)
112
+ P(0, ty - 1, "k", top)
113
+ for dy, row in enumerate(HEAD):
114
+ for dx, ch in enumerate(row):
115
+ if ch == ".":
116
+ continue
117
+ if ch == "e":
118
+ if blink:
119
+ ch = "k"
120
+ elif look_up:
121
+ P(dx, dy - 1, "e", top) if dy - 1 >= 1 else None
122
+ ch = "f"
123
+ P(dx, dy, ch, top)
124
+ for dy, row in enumerate(BODY):
125
+ for dx, ch in enumerate(row):
126
+ if ch != ".":
127
+ P(dx, 4 + dy, ch, top)
128
+ if sit:
129
+ # sitting: legs forward over the seat edge
130
+ for dx, dy in ((4, 7), (5, 7), (6, 7), (6, 8)):
131
+ P(dx, dy, "b", top)
132
+ else:
133
+ for dx, _ in LEGS[step % 4]:
134
+ P(dx, 7, "b", top)
135
+ if bob:
136
+ P(dx, 8, "b", top)
137
+ for dx, dy, key in ARMS.get(pose, ARMS["stand"]):
138
+ P(dx, dy, key, top)
139
+
140
+
141
+ def parcel(cv: Canvas, x: float, y: float, col, halo: float = 0.3) -> None:
142
+ """A glowing 2x2 parcel with a soft halo."""
143
+ xi, yi = int(round(x)), int(round(y))
144
+ if halo:
145
+ for dx in range(-1, 3):
146
+ for dy in range(-1, 3):
147
+ if dx in (-1, 2) or dy in (-1, 2):
148
+ cv.put(xi + dx, yi + dy, col, halo * (0.5 if dx in (-1, 2) and dy in (-1, 2) else 1))
149
+ cv.put(xi, yi, mix(col, (255, 255, 255), 0.45))
150
+ cv.put(xi + 1, yi, col)
151
+ cv.put(xi, yi + 1, col)
152
+ cv.put(xi + 1, yi + 1, scale(col, 0.7))
153
+
154
+
155
+ SPOKES = [(0, -1), (1, -1), (1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1)] # clockwise
156
+
157
+
158
+ def barrow(cv: Canvas, x: float, yb: int, cargo: int = 0, dim: float = 1.0) -> None:
159
+ """The wheelbarrow: handle on the left at x, wheel on the right. The wheel's spoke turns with
160
+ the distance travelled, so it rolls exactly as fast as it moves."""
161
+ xi = int(round(x))
162
+ metal = scale((150, 158, 205), dim)
163
+ body = scale((84, 88, 150), dim)
164
+ dark = scale((48, 46, 96), dim)
165
+ cv.put(xi, yb - 2, dark)
166
+ cv.put(xi + 1, yb - 3, dark)
167
+ for dx in range(2, 9):
168
+ cv.put(xi + dx, yb - 4, metal)
169
+ cv.put(xi + dx, yb - 3, body if dx < 8 else dark)
170
+ cv.put(xi + 3, yb - 2, dark)
171
+ cv.put(xi + 3, yb - 1, dark)
172
+ cv.put(xi + 3, yb, dark)
173
+ cx, cy = xi + 7, yb - 1
174
+ for dx, dy in SPOKES:
175
+ cv.put(cx + dx, cy + dy, scale((30, 20, 44), dim))
176
+ cv.put(cx, cy, metal)
177
+ sx, sy = SPOKES[int(math.floor(x / 1.15)) % 8]
178
+ cv.put(cx + sx, cy + sy, scale((150, 230, 255), dim))
179
+ for i in range(min(3, cargo)):
180
+ parcel(cv, xi + 2 + i * 2, yb - 6, PARCELS[i], 0.28 * dim)
181
+
182
+
183
+ # ---------------------------------------------------------------------------------------------
184
+ class Layout:
185
+ """Where everything stands, for a given width."""
186
+
187
+ def __init__(self, w: int):
188
+ self.w = w
189
+ self.depot = 2 # crates from x=2 to 10
190
+ self.load = 12 # the cub stands here to load the wheelbarrow
191
+ self.door = w - 9 # the FORGE door, 6 wide
192
+ self.unload = self.door - 17 # worker x when the barrow's nose is at the door
193
+ self.desk = int(w * 0.30) # desk 9 wide, stool in front
194
+ self.archive = int(w * 0.50) # shelf 7 wide; reading spot to its right
195
+ self.bench = int(w * 0.68) # bench 9 wide, lamp 2 to its right
196
+ self.sign = (w - 25, w - 2) # neon sign above the door
197
+
198
+ def spot(self, state: str) -> int:
199
+ return {"idle": self.bench + 1, "waiting": self.bench + 1, "queued": self.load,
200
+ "processing": self.load, "delegating": self.load, "thinking": self.desk - 7,
201
+ "coding": self.desk - 7, "researching": self.archive - 9}.get(state, self.load)
202
+
203
+
204
+ def background(w: int, t: float, motion: bool) -> Canvas:
205
+ """The street: sky, skyline, props. Cached by the caller."""
206
+ L = Layout(w)
207
+ cv = Canvas(w, H)
208
+ wins = art.skyline(cv, HORIZON, seed=w * 7 + 3, moon=(int(w * 0.16), 3))
209
+ art.windows_on(cv, wins, t, motion)
210
+ # sidewalk
211
+ for y, col in ((13, (40, 26, 76)), (14, (34, 22, 66))):
212
+ for x in range(w):
213
+ cv.px[y][x] = scale(col, 0.85) if (x + (y * 3)) % 9 == 0 else col
214
+ # depot: stacked crates
215
+ crate, edge = (118, 66, 46), (170, 104, 64)
216
+ for cx, cy in ((2, 12), (5, 12), (8, 12), (3, 9), (6, 9)):
217
+ cv.rect(cx, cy, 3, 3, crate)
218
+ cv.rect(cx, cy, 3, 1, edge)
219
+ cv.put(cx + 1, cy + 1, (230, 160, 90))
220
+ # the FORGE door: a lit doorway in a dark wall, cyan neon frame
221
+ d = L.door
222
+ cv.rect(d - 2, 3, 10, HORIZON - 2, (22, 12, 46))
223
+ cv.rect(d, 7, 6, FEET - 6, (60, 24, 90))
224
+ for y in range(7, FEET + 1):
225
+ cv.put(d - 1, y, CYAN)
226
+ cv.light(d - 1, y, CYAN, 0.7)
227
+ cv.put(d + 6, y, CYAN)
228
+ cv.light(d + 6, y, CYAN, 0.7)
229
+ for x in range(d - 1, d + 7):
230
+ cv.put(x, 6, CYAN)
231
+ cv.light(x, 6, CYAN, 0.7)
232
+ cv.rect(d + 1, 8, 4, FEET - 8, (120, 50, 150))
233
+ # the sign above it: a dark board with a magenta border (the word is drawn each frame)
234
+ s0, s1 = L.sign
235
+ cv.rect(s0, 0, s1 - s0, 6, (14, 8, 30))
236
+ for x in range(s0, s1):
237
+ cv.put(x, 0, MAGENTA, 0.6)
238
+ cv.put(x, 5, MAGENTA, 0.6)
239
+ # desk with monitor, stool
240
+ k = L.desk
241
+ wood, leg = (88, 56, 110), (50, 34, 70)
242
+ cv.rect(k, 10, 9, 1, wood)
243
+ cv.put(k + 1, 11, leg); cv.put(k + 1, 12, leg); cv.put(k + 1, 13, leg); cv.put(k + 1, 14, leg)
244
+ cv.put(k + 7, 11, leg); cv.put(k + 7, 12, leg); cv.put(k + 7, 13, leg); cv.put(k + 7, 14, leg)
245
+ cv.rect(k + 3, 5, 6, 5, (30, 30, 60))
246
+ cv.rect(k + 4, 6, 4, 3, (10, 20, 40))
247
+ cv.put(k + 5, 9, (60, 60, 100)); cv.put(k + 6, 9, (60, 60, 100))
248
+ cv.rect(k - 5, SEAT, 4, 1, (110, 80, 140))
249
+ cv.put(k - 4, 13, leg); cv.put(k - 4, 14, leg); cv.put(k - 2, 13, leg); cv.put(k - 2, 14, leg)
250
+ # archive shelf with books, a floor pile beside it
251
+ a = L.archive
252
+ cv.rect(a, 4, 7, FEET - 3, (54, 34, 84))
253
+ for row, y in enumerate((5, 8, 11)):
254
+ for i in range(5):
255
+ h = 2 if (hash32(a, row, i) % 3) else 1
256
+ col = (PARCELS + [GREEN, LAVENDER])[hash32(row, i, 9) % 6]
257
+ cv.rect(a + 1 + i, y + (2 - h), 1, h, scale(col, 0.75))
258
+ cv.rect(a, y + 2, 7, 1, (90, 60, 120))
259
+ # bench and lamp
260
+ b = L.bench
261
+ cv.rect(b, SEAT, 9, 1, (120, 80, 60))
262
+ cv.rect(b, SEAT - 2, 9, 1, (96, 62, 48))
263
+ for x in (b + 1, b + 7):
264
+ cv.put(x, 13, (60, 40, 40)); cv.put(x, 14, (60, 40, 40))
265
+ lp = b + 11
266
+ for y in range(4, FEET + 1):
267
+ cv.put(lp, y, (70, 60, 100))
268
+ cv.rect(lp - 1, 3, 3, 1, (90, 80, 120))
269
+ cv.put(lp, 4, (255, 226, 160))
270
+ cv.light(lp, 4, AMBER, 2.2)
271
+ cv.light(lp, 5, AMBER, 1.2)
272
+ cv.bloom(radius=1, strength=0.55)
273
+ # a cone of lamplight on the pavement
274
+ for y in range(6, FEET + 1):
275
+ spread = (y - 5) // 2
276
+ for x in range(lp - spread, lp + spread + 1):
277
+ cv.put(x, y, AMBER, 0.06)
278
+ return cv
279
+
280
+
281
+ # ---------------------------------------------------------------------------------------------
282
+ class City:
283
+ """Holds the current state and draws frames. set()/blip() are called from any thread; frame()
284
+ is called by the UI's redraw timer."""
285
+
286
+ LINGER = 3.5 # completed / failed / cancelled stay on screen this long
287
+
288
+ def __init__(self):
289
+ self.lock = threading.Lock()
290
+ self.state = "idle"
291
+ self.since = time.monotonic()
292
+ self.detail = ""
293
+ self.helpers = 0
294
+ self.wait_until = 0.0
295
+ self.wait_total = 0.0
296
+ self.swap_at = -99.0
297
+ self.x = None # where the main cub is
298
+ self.facing = 1
299
+ self.arrived = None # when it reached the spot for the current state
300
+ self.walked = 0.0 # distance walked, drives the stride
301
+ self.last = None
302
+ self.anchor = None # where an end-of-turn animation is played
303
+ self._bg = (None, None)
304
+ self.motion = os.environ.get("FORGE_MOTION", "").lower() not in ("reduce", "off", "0", "no")
305
+
306
+ # -- state changes (called by the agent's UI hooks) ----------------------------------------
307
+ def set(self, state: str, detail: str = "", wait: float = 0.0) -> None:
308
+ if state not in STATES:
309
+ return
310
+ with self.lock:
311
+ now = time.monotonic()
312
+ if state != self.state:
313
+ self.state = state
314
+ self.since = now
315
+ self.arrived = None
316
+ self.anchor = None
317
+ self.detail = detail
318
+ if state == "waiting" and wait:
319
+ self.wait_until = now + wait
320
+ self.wait_total = wait
321
+
322
+ def blip(self) -> None:
323
+ """A provider failed and Forge is switching to the next one: a spark, the sign flickers."""
324
+ with self.lock:
325
+ self.swap_at = time.monotonic()
326
+
327
+ def set_helpers(self, n: int) -> None:
328
+ with self.lock:
329
+ self.helpers = max(0, min(3, n))
330
+
331
+ def settled(self) -> bool:
332
+ """True once an end-of-turn animation has finished playing."""
333
+ return self.state in FINAL and time.monotonic() - self.since > self.LINGER
334
+
335
+ # -- drawing ----------------------------------------------------------------------------------
336
+ def frame(self, width: int, now: float | None = None) -> str:
337
+ """The scene as half-block ANSI text (H/2 lines), or one plain line if it doesn't fit."""
338
+ now = time.monotonic() if now is None else now
339
+ w = min(width, 240)
340
+ if w < MIN_WIDTH:
341
+ return self.line(width, now)
342
+ with self.lock:
343
+ cv = self.draw(w, now)
344
+ return cv.ansi()
345
+
346
+ def draw(self, w: int, now: float) -> Canvas:
347
+ motion = self.motion
348
+ L = Layout(w)
349
+ bucket = int(now * 2) if motion else 0
350
+ key = (w, bucket)
351
+ if self._bg[0] != key:
352
+ self._bg = (key, background(w, bucket / 2, motion))
353
+ cv = self._bg[1].copy()
354
+ t = now if motion else 0.0
355
+ state = self.state
356
+ age = now - self.since
357
+ self._move(L, state, now)
358
+ here = self.arrived is not None
359
+ at = (now - self.arrived) if here else 0.0
360
+ if not motion:
361
+ at = 1.6
362
+ x = self.x
363
+ blink = motion and (now + 0.37 * hash32(int(x))) % 4.2 < 0.14
364
+
365
+ # the wheelbarrow is parked at the depot unless someone is using it
366
+ working_barrow = state in ("processing",) and here
367
+ if not working_barrow and state != "delegating":
368
+ barrow(cv, L.load + 8, FEET, 0)
369
+
370
+ # helpers: in delegating, and alongside processing when tasks run in parallel
371
+ crew = self.helpers if state in ("delegating", "processing") else 0
372
+ if state == "delegating":
373
+ crew = max(1, crew)
374
+ if crew and here:
375
+ start = L.load + 9 if state == "delegating" else L.load
376
+ for i in range(crew):
377
+ ph = self._loop(at + (i + 1) * 2.9, start, L.unload, L, cv, from_hand=(x + 7, FEET - 5)
378
+ if state == "delegating" else None)
379
+ self._cub_with_barrow(cv, ph, crew=i + 1, dim=0.82)
380
+
381
+ draw_main = True
382
+ if not here:
383
+ pose = "walk%d" % ((int(self.walked / 3)) % 2)
384
+ cub(cv, int(x), FEET, self.facing, pose, int(self.walked / 2.2), blink=blink)
385
+ draw_main = False
386
+ if draw_main:
387
+ self._act(cv, L, state, t, at, age, x, blink, working_barrow)
388
+ self._sign(cv, L, state, t, now)
389
+ art.street(cv, 15, t, motion)
390
+ return cv
391
+
392
+ def _move(self, L: Layout, state: str, now: float) -> None:
393
+ """Walk the main cub towards the spot for this state; record when it arrives."""
394
+ target = L.spot(state)
395
+ if state in FINAL:
396
+ if self.anchor is None:
397
+ self.anchor = self.x if self.x is not None else target
398
+ target = self.anchor
399
+ if self.x is None:
400
+ self.x = float(target)
401
+ last = self.last if self.last is not None else now
402
+ dt = max(0.0, min(0.25, now - last))
403
+ self.last = now
404
+ if state == "processing" and self.arrived is not None:
405
+ return # the delivery loop moves it now
406
+ if not self.motion:
407
+ self.x = float(target)
408
+ gap = target - self.x
409
+ if abs(gap) > 0.5:
410
+ stepx = math.copysign(min(abs(gap), WALK_SPEED * dt), gap)
411
+ self.x += stepx
412
+ self.walked += abs(stepx)
413
+ self.facing = 1 if gap > 0 else -1
414
+ self.arrived = None
415
+ elif self.arrived is None:
416
+ self.x = float(target)
417
+ self.arrived = now
418
+ self.facing = 1
419
+
420
+ def _loop(self, t: float, xa: float, xb: float, L: Layout, cv: Canvas, from_hand=None) -> dict:
421
+ """One wheelbarrow delivery cycle: load at xa, push to xb, unload into the door, pull back.
422
+ Pure function of t, so several cubs can run it with different offsets."""
423
+ d = max(4.0, xb - xa)
424
+ travel = d / PUSH_SPEED
425
+ back = d / (PUSH_SPEED * 1.25)
426
+ T_load, T_unload = 1.3, 1.1
427
+ period = T_load + travel + T_unload + back
428
+ p = t % period
429
+ res = {"x": xa, "facing": 1, "cargo": 0, "pose": "push", "moving": False}
430
+ if p < T_load:
431
+ n = int(p / 0.4)
432
+ res["cargo"] = min(3, n)
433
+ f = (p % 0.4) / 0.4
434
+ if n < 3:
435
+ src = from_hand or (L.depot + 4 + (n % 2) * 3, 9)
436
+ dst = (xa + 10 + n * 2, FEET - 6)
437
+ bx = src[0] + (dst[0] - src[0]) * f
438
+ by = src[1] + (dst[1] - src[1]) * f - math.sin(f * math.pi) * 4
439
+ parcel(cv, bx, by, PARCELS[n], 0.3)
440
+ elif p < T_load + travel:
441
+ f = (p - T_load) / travel
442
+ f = f * f * (3 - 2 * f) * 0.35 + f * 0.65 # a little ease at each end
443
+ res.update(x=xa + d * f, cargo=3, moving=True)
444
+ elif p < T_load + travel + T_unload:
445
+ q = p - T_load - travel
446
+ n = int(q / 0.35)
447
+ res.update(x=xb, cargo=max(0, 3 - n))
448
+ if n < 3:
449
+ f = (q % 0.35) / 0.35
450
+ sx, sy = xb + 10 + (2 - n) * 2, FEET - 6
451
+ tx, ty = L.door + 2, FEET - 4
452
+ parcel(cv, sx + (tx - sx) * f, sy + (ty - sy) * f - math.sin(f * math.pi) * 3,
453
+ PARCELS[2 - n], 0.3 * (1 - f))
454
+ glow = 0.25 + 0.25 * math.sin(q * 12)
455
+ for y in range(8, FEET):
456
+ for xx in range(L.door + 1, L.door + 5):
457
+ cv.put(xx, y, (180, 255, 255), glow * 0.5)
458
+ else:
459
+ f = (p - T_load - travel - T_unload) / back
460
+ res.update(x=xb - d * f, facing=-1, pose="pull", moving=True)
461
+ return res
462
+
463
+ def _cub_with_barrow(self, cv: Canvas, ph: dict, crew: int = 0, dim: float = 1.0) -> None:
464
+ x = ph["x"]
465
+ step = int(x / 2.2) if ph["moving"] else 1
466
+ barrow(cv, x + 8, FEET, ph["cargo"], dim)
467
+ cub(cv, int(round(x)), FEET, ph["facing"], ph["pose"], step, crew=crew, dim=dim)
468
+
469
+ def _act(self, cv, L, state, t, at, age, x, blink, working_barrow) -> None:
470
+ """The main cub's activity once it is in place."""
471
+ xi = int(round(x))
472
+ if state == "processing":
473
+ ph = self._loop(at, L.load, L.unload, L, cv)
474
+ self._cub_with_barrow(cv, ph)
475
+ self.x = ph["x"] # keep track of it so the next state walks from here
476
+ self.arrived = self.arrived # still in place for the loop
477
+ return
478
+ if state == "delegating":
479
+ # the dispatcher: lifts a parcel from the depot and hands it over
480
+ ph = (at % 1.2) / 1.2
481
+ cub(cv, xi, FEET, 1, "carry" if ph > 0.3 else "reach", 1, blink=blink)
482
+ if ph > 0.3:
483
+ parcel(cv, xi + 6, FEET - 7, PARCELS[int(at / 1.2) % 4], 0.3)
484
+ return
485
+ if state in ("thinking", "coding"):
486
+ self._desk(cv, L, state, t, at, xi, blink)
487
+ return
488
+ if state == "researching":
489
+ self._research(cv, L, t, at, blink)
490
+ return
491
+ if state in ("idle", "waiting"):
492
+ sx = L.bench + 1
493
+ cub(cv, sx, SEAT - 1, 1, "cup" if state == "waiting" else "stand", 0, sit=True, blink=blink)
494
+ if state == "waiting":
495
+ cx, cy = sx + 7, SEAT - 5
496
+ cv.put(cx, cy, (240, 240, 250)); cv.put(cx, cy + 1, (240, 240, 250))
497
+ cv.put(cx + 1, cy, (200, 120, 60))
498
+ for i in range(3):
499
+ k = (t * 1.6 + i / 3) % 1
500
+ cv.put(cx + int(math.sin(k * 6 + i) * 1.2), int(cy - 1 - k * 4), (220, 220, 255), 0.5 * (1 - k))
501
+ return
502
+ if state == "queued":
503
+ step = int(t * 3) % 2
504
+ cub(cv, xi, FEET, 1, "stand", step * 2, blink=blink)
505
+ for i in range(3):
506
+ on = int(t * 2.5) % 4 > i
507
+ cv.put(xi + 3 + i * 2, FEET - 10, INK, 0.9 if on else 0.25)
508
+ return
509
+ if state == "completed":
510
+ hop = abs(math.sin(age * 7)) * 2.5 if age < 2.2 else 0
511
+ cub(cv, xi, FEET - int(hop), 1, "cheer", 1, blink=False)
512
+ self._confetti(cv, xi + 4, FEET - 8, age)
513
+ return
514
+ if state == "failed":
515
+ cub(cv, xi, FEET, 1, "startle", 1)
516
+ for i in range(3):
517
+ vx = (hash32(i, 5) % 7 - 3) * 6
518
+ tt = min(age, 0.9)
519
+ bx = xi + 6 + vx * tt
520
+ by = min(FEET - 1, FEET - 7 - 8 * tt + 26 * tt * tt)
521
+ parcel(cv, bx, by, PARCELS[i], 0.2)
522
+ if int(age * 3) % 2 == 0 or not self.motion:
523
+ cv.text("!", xi + 4, FEET - 13, RED, art.FONT3, 1)
524
+ if age < 1.0 and self.motion:
525
+ for i in range(6):
526
+ a = i * 1.05 + age * 3
527
+ r = 2 + age * 5
528
+ cv.put(int(xi + 8 + math.cos(a) * r), int(FEET - 3 + math.sin(a) * r * 0.5), (140, 120, 160), 0.6 * (1 - age))
529
+ return
530
+ if state == "cancelled":
531
+ cub(cv, xi, FEET, 1, "stop", 1, blink=blink)
532
+ ox, oy = xi + 9, FEET - 12
533
+ for dx in range(5):
534
+ for dy in range(5):
535
+ if (dx, dy) not in ((0, 0), (4, 0), (0, 4), (4, 4)):
536
+ cv.put(ox + dx, oy + dy, (220, 40, 60))
537
+ cv.rect(ox + 1, oy + 2, 3, 1, (255, 240, 240))
538
+ return
539
+ cub(cv, xi, FEET, 1, "stand", 1, blink=blink)
540
+
541
+ def _desk(self, cv, L, state, t, at, xi, blink) -> None:
542
+ k = L.desk
543
+ mon = (k + 4, 6)
544
+ if state == "coding":
545
+ pose = "type%d" % (int(t * 9) % 2)
546
+ cub(cv, k - 7, SEAT - 1, 1, pose, 0, sit=True, blink=blink)
547
+ for r in range(3):
548
+ line = int(t * 5) + r
549
+ n = 1 + hash32(line, 77) % 4
550
+ col = (CYAN, MAGENTA, GREEN, AMBER)[hash32(line, 3) % 4]
551
+ for i in range(n):
552
+ cv.put(mon[0] + i, mon[1] + r, col)
553
+ cv.put(mon[0] + 3, mon[1] + 2, INK, 0.9 if int(t * 3) % 2 else 0.2)
554
+ return
555
+ # thinking: sit and look up with a thought bubble; every few seconds, pace about
556
+ cyc = at % 9.0
557
+ if cyc < 6.0 or not self.motion:
558
+ cub(cv, k - 7, SEAT - 1, 1, "think", 0, sit=True, blink=blink, look_up=int(t) % 3 == 0)
559
+ bx, by = k - 1, 2
560
+ cv.put(k - 2, 5, INK, 0.6)
561
+ cv.put(k - 1, 4, INK, 0.7)
562
+ cv.ellipse(bx + 4, by + 1, 5, 2.6, (230, 222, 255), 0.95)
563
+ for i in range(3):
564
+ lit = int(t * 3) % 3 == i
565
+ cv.put(bx + 2 + i * 2, by + 1, CYAN if lit else (120, 110, 170))
566
+ cv.put(mon[0] + int(t * 2) % 4, mon[1] + 1, CYAN, 0.4)
567
+ else:
568
+ q = cyc - 6.0
569
+ px = k - 7 + (q * 8 if q < 1.5 else (3 - q) * 8)
570
+ face = 1 if q < 1.5 else -1
571
+ cub(cv, int(px), FEET, face, "walk%d" % (int(q * 4) % 2), int(q * 6), blink=blink)
572
+
573
+ def _research(self, cv, L, t, at, blink) -> None:
574
+ a = L.archive
575
+ shelf, read = a - 7, a + 9
576
+ cyc = at % 7.0
577
+ if not self.motion:
578
+ cyc = 4.0
579
+ if cyc < 1.0: # reach up and pull a document
580
+ cub(cv, shelf, FEET, 1, "reach", 1, blink=blink)
581
+ if cyc > 0.5:
582
+ cv.rect(shelf + 6, FEET - 9, 2, 2, (240, 240, 250))
583
+ elif cyc < 2.4: # walk to the reading spot holding it
584
+ f = (cyc - 1.0) / 1.4
585
+ x = shelf + (read - shelf) * f
586
+ cub(cv, int(x), FEET, 1, "read", int(x / 2.2))
587
+ cv.rect(int(x) + 7, FEET - 4, 2, 3, (240, 240, 250))
588
+ elif cyc < 5.6: # read it under a magnifier
589
+ cub(cv, read, FEET, 1, "read", 1, blink=blink)
590
+ cv.rect(read + 7, FEET - 5, 2, 3, (240, 240, 250))
591
+ cv.put(read + 7, FEET - 4, (120, 120, 160))
592
+ g = (t * 1.4) % 1
593
+ cv.put(read + 7 + int(g * 2), FEET - 5 + int(g * 3), CYAN)
594
+ cv.light(read + 7, FEET - 4, CYAN, 0.2)
595
+ else: # walk back for the next one
596
+ f = (cyc - 5.6) / 1.4
597
+ x = read + (shelf - read) * f
598
+ cub(cv, int(x), FEET, -1, "walk%d" % (int(x / 3) % 2), int(x / 2.2))
599
+ pile = int(at / 7.0) % 5
600
+ for i in range(pile):
601
+ cv.rect(read + 10, FEET - i, 3, 1, (220, 220, 235) if i % 2 else (190, 190, 215))
602
+
603
+ def _confetti(self, cv, ox, oy, age) -> None:
604
+ if age > 2.6:
605
+ return
606
+ for i in range(22):
607
+ ang = (hash32(i, 41) % 360) * math.pi / 180
608
+ sp = 8 + hash32(i, 7) % 10
609
+ tt = age if self.motion else 0.35
610
+ x = ox + math.cos(ang) * sp * tt
611
+ y = oy + math.sin(ang) * sp * tt * 0.6 - 10 * tt + 14 * tt * tt
612
+ col = (MAGENTA, CYAN, AMBER, GREEN, INK)[i % 5]
613
+ cv.put(int(x), int(y), col, max(0.0, 1 - tt / 2.6))
614
+
615
+ def _sign(self, cv, L, state, t, now) -> None:
616
+ s0, s1 = L.sign
617
+ word = SIGN[state]
618
+ col = SIGN_COL.get(state, MAGENTA)
619
+ swap = now - self.swap_at
620
+ if 0 <= swap < 1.6:
621
+ word, col = "SWAP", CYAN
622
+ if self.motion and int(swap * 10) % 3 == 0:
623
+ col = scale(CYAN, 0.35)
624
+ # a rare neon flicker
625
+ if self.motion and hash32(int(now * 8), 99) % 97 == 0:
626
+ col = scale(col, 0.4)
627
+ wd = len(word) * 4 - 1
628
+ x0 = s0 + (s1 - s0 - wd) // 2
629
+ tmp = Canvas(wd, 5, (0, 0, 0))
630
+ tmp.text(word, 0, 0, col, art.FONT3, 1)
631
+ for y in range(5):
632
+ for x in range(wd):
633
+ if tmp.px[y][x] != (0, 0, 0):
634
+ for dx, dy in ((-1, 0), (1, 0), (0, -1), (0, 1)):
635
+ cv.put(x0 + x + dx, 0 + y + dy + 0, col, 0.18)
636
+ for y in range(5):
637
+ for x in range(wd):
638
+ if tmp.px[y][x] != (0, 0, 0):
639
+ cv.put(x0 + x, y, mix(col, (255, 255, 255), 0.35))
640
+ if state == "waiting" and self.wait_total:
641
+ left = max(0.0, self.wait_until - now) / self.wait_total
642
+ n = int((s1 - s0 - 4) * left)
643
+ cv.rect(s0 + 2, 5, n, 1, AMBER)
644
+ if 0 <= swap < 0.8 and self.x is not None:
645
+ # a spark jumps over the worker: the model behind it changed
646
+ xi = int(self.x)
647
+ for i, (dx, dy) in enumerate(((3, -11), (4, -10), (3, -9), (5, -9), (4, -8))):
648
+ cv.put(xi + dx, FEET + dy, (255, 240, 120))
649
+
650
+ # -- terminals without colour --------------------------------------------------------------
651
+ def line(self, width: int, now: float | None = None) -> str:
652
+ """One plain line: a tiny text street for terminals that can't draw the city."""
653
+ now = time.monotonic() if now is None else now
654
+ st = self.state
655
+ t = now if self.motion else 0.0
656
+ if st in ("processing", "delegating"):
657
+ n = 18
658
+ p = t * 6 % (2 * n)
659
+ pos = int(p if p < n else 2 * n - p)
660
+ cart = "o=[■]" if p < n else "o=[ ]"
661
+ track = "." * pos + cart + "." * (n - pos)
662
+ return f" ▤ {track} ▯ {SIGN[st].lower()}"
663
+ faces = {"thinking": "(=・ω・=) ?", "coding": "(=^‿^=)⌨", "researching": "(=・ᴥ・=)📄",
664
+ "waiting": "(=-ω-=)☕", "queued": "(=・ω・=) …", "completed": "\\(=^▽^=)/ ✦",
665
+ "failed": "(=;ω;=) !", "cancelled": "(=・ω・=)✋", "idle": "(=^ω^=)"}
666
+ return f" {faces.get(st, '(=^ω^=)')} {SIGN[st].lower()}"
667
+
668
+
669
+ # --- the little wheelbarrow -------------------------------------------------------------------
670
+ # One line, about as wide as the word "Thinking", standing in for the spinner next to the status
671
+ # word. It follows what Forge is really doing:
672
+ # thinking / coding wheels loads from the pile (left) to the house (right) and back
673
+ # a tool is running pushes a load to the house and waits at the door until the tool finishes
674
+ # tool finished unloads at the house, walks back empty for the next load
675
+ # waiting (limits) stops where it is, zZ
676
+ # completed delivers the last load, the house shows a tick
677
+ # failed the load spills, the house goes red
678
+ # cancelled stops where it is
679
+ # Cargo colour shows the kind of work: lavender thinking, amber running, cyan reading, magenta helpers.
680
+
681
+ TRACK = 6 # cells the barrow travels: about as wide as the word 'Thinking'
682
+ _SPEED = 5.0 # cells per second
683
+ _PAUSE = 0.5 # seconds spent loading / unloading at each end
684
+ _WHEEL = "◐◓◑◒" # quarter turns, so the wheel rolls as it goes
685
+ TOOL_STATES = ("processing", "researching", "delegating")
686
+ _CARGO = {"thinking": "#c3a6ff", "coding": "#c3a6ff", "processing": "#ffb347",
687
+ "researching": "#3ef0ff", "delegating": "#ff4fd8"}
688
+
689
+
690
+ class Barrow:
691
+ """State follows UI.act(); pieces(now) moves it towards where that state wants it."""
692
+
693
+ def __init__(self, motion: bool = True):
694
+ self.lock = threading.Lock()
695
+ self.motion = motion
696
+ self.state = "idle"
697
+ self.pos = 0.0
698
+ self.right = True # facing / pushing towards the house
699
+ self.loaded = False
700
+ self.kind = "thinking" # what the current load is
701
+ self.pause_until = 0.0 # loading / unloading in progress until then
702
+ self.pause_kind = "" # "load" | "unload"
703
+ self.last = None
704
+ self.delivered = 0
705
+ self.walked = 0.0 # total distance pushed (drives the walk-cycle frame)
706
+
707
+ def on(self, state: str) -> None:
708
+ with self.lock:
709
+ if state == self.state:
710
+ return
711
+ self.state = state
712
+ if state in TOOL_STATES:
713
+ self.kind = state
714
+ elif state in ("thinking", "coding") and not self.loaded:
715
+ self.kind = state
716
+ if state == "idle":
717
+ self.pos, self.loaded, self.right, self.pause_until = 0.0, False, True, 0.0
718
+
719
+ def _target(self) -> float | None:
720
+ st = self.state
721
+ if st in ("waiting", "failed", "cancelled", "idle", "queued"):
722
+ return None # stay put
723
+ if st == "completed":
724
+ return float(TRACK) # the last load goes in
725
+ if st in TOOL_STATES: # fetch a load if empty, then wait at the door
726
+ return float(TRACK) if self.loaded else 0.0
727
+ # thinking / coding: shuttle back and forth
728
+ return float(TRACK) if self.loaded else 0.0
729
+
730
+ def _step(self, now: float) -> None:
731
+ last = self.last if self.last is not None else now
732
+ dt = max(0.0, min(0.25, now - last))
733
+ self.last = now
734
+ if not self.motion:
735
+ self.pos, self.loaded = TRACK / 2, self.state not in ("failed", "idle")
736
+ return
737
+ if now < self.pause_until:
738
+ return
739
+ if self.pause_kind == "load":
740
+ self.loaded = True
741
+ elif self.pause_kind == "unload":
742
+ if self.loaded:
743
+ self.delivered += 1
744
+ self.loaded = False
745
+ self.pause_kind = ""
746
+ target = self._target()
747
+ if target is None:
748
+ return
749
+ gap = target - self.pos
750
+ if abs(gap) > 0.01:
751
+ self.right = gap > 0
752
+ step = min(abs(gap), getattr(self, "speed", _SPEED) * dt)
753
+ self.pos += math.copysign(step, gap)
754
+ self.walked += step
755
+ return
756
+ # arrived
757
+ at_pile, at_house = self.pos <= 0.01, self.pos >= TRACK - 0.01
758
+ if at_pile and not self.loaded and self.state != "completed":
759
+ self.pause_kind, self.pause_until = "load", now + _PAUSE
760
+ elif at_house and self.loaded:
761
+ # tools: park at the door until the tool is done; otherwise unload now
762
+ if self.state not in TOOL_STATES:
763
+ self.pause_kind, self.pause_until = "unload", now + _PAUSE
764
+ elif at_pile and self.state in ("thinking", "coding"):
765
+ self.pause_kind, self.pause_until = "load", now + _PAUSE
766
+
767
+ def pieces(self, now: float) -> list[tuple[str, str]]:
768
+ """(text, style) pieces for the line at time now."""
769
+ with self.lock:
770
+ self._step(now)
771
+ st, pos, right, loaded = self.state, self.pos, self.right, self.loaded
772
+ busy = now < self.pause_until
773
+ kind = self.kind
774
+ x = max(0, min(TRACK, int(round(pos))))
775
+ moving = self.motion and not busy and self._target() is not None and abs((self._target() or 0) - pos) > 0.01
776
+ if st == "failed":
777
+ loaded = False
778
+ wheel = _WHEEL[(int(pos * 2) if right else -int(pos * 2)) % 4] if moving else "◐"
779
+ cargo = ("▆", "bold " + _CARGO.get(kind, "#ffb347")) if loaded else ("▂", "#6b5f9e")
780
+ if st == "failed":
781
+ cargo = ("▂", "#ff5f6d")
782
+ if self.motion and busy: # lifting a parcel in or out: it bobs
783
+ cargo = ("▄" if int(now * 6) % 2 else cargo[0], cargo[1])
784
+ worker = ("ȯ" if moving and int(pos * 2) % 2 else "o", "bold #3ef0ff")
785
+ if st == "cancelled":
786
+ worker = ("o", "#6b5f9e")
787
+ cart = [worker, ("╼" if right else "╾", "#a096d2"), cargo, (wheel, "#e8e0ff")]
788
+ if not right:
789
+ cart.reverse()
790
+ track_w = TRACK + 4
791
+ lane = [("·", "#3a2f5c")] * track_w
792
+ for i, piece in enumerate(cart):
793
+ lane[min(track_w - 1, x + i)] = piece
794
+ at_house = x >= TRACK
795
+ pulse = self.motion and int(now * 3) % 2
796
+ pile = ("▤", "bold #ffb347" if (x == 0 and busy) else "#b8813a")
797
+ house_style = "#9c3c8c"
798
+ if at_house and (busy or (st in TOOL_STATES and loaded)):
799
+ house_style = "bold #ff4fd8" if pulse or not self.motion else "#ff4fd8"
800
+ house = ("⌂", house_style)
801
+ if st == "completed" and not loaded and at_house:
802
+ house = ("✓", "bold #7dff9a")
803
+ elif st == "failed":
804
+ house = ("⌂", "bold #ff5f6d")
805
+ out = [pile, (" ", "")] + lane + [(" ", ""), house]
806
+ if st == "waiting":
807
+ out.append((" zZ", "#6b5f9e"))
808
+ return out
809
+
810
+ def snapshot(self, now: float) -> dict:
811
+ """Where everything is at time now, for the pixel courier (courier.py)."""
812
+ with self.lock:
813
+ self._step(now)
814
+ busy = now < self.pause_until
815
+ left = max(0.0, self.pause_until - now) / _PAUSE if busy else 0.0
816
+ if busy and self.pause_kind == "load":
817
+ fill = 1.0 - left
818
+ elif busy and self.pause_kind == "unload":
819
+ fill = left
820
+ else:
821
+ fill = 1.0 if self.loaded else 0.0
822
+ target = self._target()
823
+ moving = self.motion and not busy and target is not None and abs(target - self.pos) > 0.01
824
+ at_door = self.pos >= TRACK - 0.01
825
+ return {"state": self.state, "pos": self.pos, "track": TRACK, "right": self.right,
826
+ "moving": moving, "walked": self.walked, "kind": self.kind, "fill": fill,
827
+ "loaded": self.loaded, "busy": busy, "at_door": at_door,
828
+ "parked": at_door and self.loaded and self.state in TOOL_STATES,
829
+ "motion": self.motion, "delivered": self.delivered}
830
+
831
+ def text(self, now: float) -> str:
832
+ """Plain text of the line (tests, terminals without colour)."""
833
+ return "".join(s for s, _ in self.pieces(now))