jeo-code 0.1.0 → 0.4.5

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 (177) hide show
  1. package/README.ja.md +160 -0
  2. package/README.ko.md +160 -0
  3. package/README.md +115 -297
  4. package/README.zh.md +160 -0
  5. package/package.json +11 -6
  6. package/scripts/install.sh +28 -28
  7. package/scripts/uninstall.sh +17 -15
  8. package/src/AGENTS.md +50 -0
  9. package/src/agent/AGENTS.md +49 -0
  10. package/src/agent/bash-fixups.ts +103 -0
  11. package/src/agent/compaction.ts +410 -19
  12. package/src/agent/config-schema.ts +119 -5
  13. package/src/agent/context-files.ts +314 -17
  14. package/src/agent/dev/AGENTS.md +36 -0
  15. package/src/agent/dev/advanced-analyzer.ts +12 -0
  16. package/src/agent/dev/evolution-bridge.ts +82 -0
  17. package/src/agent/dev/evolution-logger.ts +41 -0
  18. package/src/agent/dev/self-analysis.ts +64 -0
  19. package/src/agent/dev/self-improve.ts +24 -0
  20. package/src/agent/dev/spec-automation.ts +49 -0
  21. package/src/agent/engine.ts +808 -54
  22. package/src/agent/hooks.ts +273 -0
  23. package/src/agent/loop.ts +21 -1
  24. package/src/agent/memory.ts +201 -0
  25. package/src/agent/model-recency.ts +32 -0
  26. package/src/agent/output-minimizer.ts +108 -0
  27. package/src/agent/output-util.ts +64 -0
  28. package/src/agent/plan.ts +187 -0
  29. package/src/agent/seed.ts +52 -0
  30. package/src/agent/session.ts +235 -21
  31. package/src/agent/state.ts +286 -39
  32. package/src/agent/step-budget.ts +232 -0
  33. package/src/agent/subagents.ts +223 -26
  34. package/src/agent/task-tool.ts +272 -0
  35. package/src/agent/todo-tool.ts +87 -0
  36. package/src/agent/tokenizer.ts +117 -0
  37. package/src/agent/tool-registry.ts +54 -0
  38. package/src/agent/tools.ts +624 -103
  39. package/src/agent/web-search.ts +538 -0
  40. package/src/ai/AGENTS.md +44 -0
  41. package/src/ai/index.ts +1 -0
  42. package/src/ai/model-catalog-compat.ts +3 -1
  43. package/src/ai/model-catalog.ts +74 -9
  44. package/src/ai/model-discovery.ts +215 -17
  45. package/src/ai/model-manager.ts +346 -32
  46. package/src/ai/model-picker.ts +1 -1
  47. package/src/ai/model-registry.ts +4 -2
  48. package/src/ai/pricing.ts +84 -0
  49. package/src/ai/provider-registry.ts +23 -0
  50. package/src/ai/provider-status.ts +60 -16
  51. package/src/ai/providers/AGENTS.md +42 -0
  52. package/src/ai/providers/anthropic.ts +250 -31
  53. package/src/ai/providers/antigravity.ts +219 -0
  54. package/src/ai/providers/errors.ts +15 -1
  55. package/src/ai/providers/gemini.ts +196 -13
  56. package/src/ai/providers/ollama.ts +37 -7
  57. package/src/ai/providers/openai-responses.ts +173 -0
  58. package/src/ai/providers/openai.ts +64 -12
  59. package/src/ai/sse.ts +4 -1
  60. package/src/ai/types.ts +18 -1
  61. package/src/auth/AGENTS.md +41 -0
  62. package/src/auth/callback-server.ts +6 -1
  63. package/src/auth/flows/AGENTS.md +32 -0
  64. package/src/auth/flows/antigravity.ts +151 -0
  65. package/src/auth/flows/google-project.ts +190 -0
  66. package/src/auth/flows/google.ts +39 -18
  67. package/src/auth/flows/index.ts +15 -5
  68. package/src/auth/flows/openai.ts +2 -2
  69. package/src/auth/oauth.ts +8 -0
  70. package/src/auth/refresh.ts +44 -27
  71. package/src/auth/storage.ts +149 -26
  72. package/src/auth/types.ts +1 -1
  73. package/src/autopilot.ts +362 -0
  74. package/src/bun-imports.d.ts +4 -0
  75. package/src/cli/AGENTS.md +39 -0
  76. package/src/cli/runner.ts +148 -14
  77. package/src/cli.ts +13 -4
  78. package/src/commands/AGENTS.md +40 -0
  79. package/src/commands/approve.ts +62 -3
  80. package/src/commands/auth.ts +167 -25
  81. package/src/commands/chat.ts +37 -8
  82. package/src/commands/deep-interview.ts +633 -175
  83. package/src/commands/doctor.ts +84 -37
  84. package/src/commands/evolve-core.ts +18 -0
  85. package/src/commands/evolve.ts +2 -1
  86. package/src/commands/export.ts +176 -0
  87. package/src/commands/gjc.ts +52 -0
  88. package/src/commands/launch.ts +3549 -240
  89. package/src/commands/mcp.ts +3 -3
  90. package/src/commands/ooo-seed.ts +19 -0
  91. package/src/commands/ralplan.ts +253 -35
  92. package/src/commands/resume.ts +1 -1
  93. package/src/commands/session.ts +183 -0
  94. package/src/commands/setup-helpers.ts +10 -3
  95. package/src/commands/setup.ts +57 -16
  96. package/src/commands/skills.ts +78 -18
  97. package/src/commands/state.ts +198 -0
  98. package/src/commands/status.ts +84 -0
  99. package/src/commands/team.ts +340 -212
  100. package/src/commands/ultragoal.ts +122 -61
  101. package/src/commands/update.ts +244 -0
  102. package/src/ledger.ts +270 -0
  103. package/src/mcp/AGENTS.md +38 -0
  104. package/src/mcp/server.ts +115 -14
  105. package/src/mcp/tools.ts +42 -22
  106. package/src/md-modules.d.ts +4 -0
  107. package/src/prompts/AGENTS.md +41 -0
  108. package/src/prompts/agents/AGENTS.md +35 -0
  109. package/src/prompts/agents/architect.md +35 -0
  110. package/src/prompts/agents/critic.md +37 -0
  111. package/src/prompts/agents/executor.md +36 -0
  112. package/src/prompts/agents/planner.md +37 -0
  113. package/src/prompts/skills/AGENTS.md +36 -0
  114. package/src/prompts/skills/deep-dive/AGENTS.md +31 -0
  115. package/src/prompts/skills/deep-dive/SKILL.md +13 -0
  116. package/src/prompts/skills/deep-interview/AGENTS.md +31 -0
  117. package/src/prompts/skills/deep-interview/SKILL.md +12 -0
  118. package/src/prompts/skills/gjc/AGENTS.md +31 -0
  119. package/src/prompts/skills/gjc/SKILL.md +15 -0
  120. package/src/prompts/skills/ralplan/AGENTS.md +31 -0
  121. package/src/prompts/skills/ralplan/SKILL.md +11 -0
  122. package/src/prompts/skills/team/AGENTS.md +31 -0
  123. package/src/prompts/skills/team/SKILL.md +11 -0
  124. package/src/prompts/skills/ultragoal/AGENTS.md +31 -0
  125. package/src/prompts/skills/ultragoal/SKILL.md +11 -0
  126. package/src/skills/AGENTS.md +38 -0
  127. package/src/skills/catalog.ts +565 -31
  128. package/src/tui/AGENTS.md +43 -0
  129. package/src/tui/app.ts +1181 -92
  130. package/src/tui/components/AGENTS.md +42 -0
  131. package/src/tui/components/ascii-art.ts +257 -15
  132. package/src/tui/components/autocomplete.ts +98 -16
  133. package/src/tui/components/autopilot-status.ts +65 -0
  134. package/src/tui/components/category-index.ts +49 -0
  135. package/src/tui/components/code-view.ts +54 -11
  136. package/src/tui/components/color.ts +171 -2
  137. package/src/tui/components/config-panel.ts +82 -15
  138. package/src/tui/components/duration.ts +38 -0
  139. package/src/tui/components/evolution.ts +3 -3
  140. package/src/tui/components/footer.ts +91 -42
  141. package/src/tui/components/forge.ts +426 -31
  142. package/src/tui/components/hints.ts +54 -0
  143. package/src/tui/components/hud.ts +73 -0
  144. package/src/tui/components/index.ts +4 -0
  145. package/src/tui/components/input-box.ts +150 -0
  146. package/src/tui/components/layout.ts +11 -3
  147. package/src/tui/components/live-model-picker.ts +108 -0
  148. package/src/tui/components/markdown-table.ts +140 -0
  149. package/src/tui/components/markdown-text.ts +97 -0
  150. package/src/tui/components/meter.ts +4 -1
  151. package/src/tui/components/model-picker.ts +3 -2
  152. package/src/tui/components/provider-picker.ts +3 -2
  153. package/src/tui/components/section.ts +70 -0
  154. package/src/tui/components/select-list.ts +40 -10
  155. package/src/tui/components/skill-picker.ts +25 -0
  156. package/src/tui/components/slash.ts +244 -21
  157. package/src/tui/components/status.ts +272 -11
  158. package/src/tui/components/step-timeline.ts +218 -0
  159. package/src/tui/components/stream.ts +26 -9
  160. package/src/tui/components/themes.ts +212 -6
  161. package/src/tui/components/todo-card.ts +47 -0
  162. package/src/tui/components/tool-list.ts +58 -12
  163. package/src/tui/components/transcript.ts +120 -0
  164. package/src/tui/components/update-box.ts +31 -0
  165. package/src/tui/components/welcome.ts +162 -0
  166. package/src/tui/components/width.ts +163 -0
  167. package/src/tui/monitoring/AGENTS.md +31 -0
  168. package/src/tui/monitoring/hud-view.ts +55 -0
  169. package/src/tui/renderer.ts +112 -3
  170. package/src/tui/terminal.ts +40 -33
  171. package/src/util/AGENTS.md +39 -0
  172. package/src/util/clipboard-image.ts +118 -0
  173. package/src/util/env.ts +12 -0
  174. package/src/util/provider-error.ts +78 -0
  175. package/src/util/retry.ts +91 -6
  176. package/src/util/update-check.ts +64 -0
  177. package/src/commands/models.ts +0 -104
@@ -0,0 +1,162 @@
1
+ import chalk from "chalk";
2
+ import { renderDnaClaw, DNA_CLAW_ART_GRAND } from "./ascii-art";
3
+ import { truncate, isTTY } from "../terminal";
4
+ import { detectColorLevel, ColorLevel } from "./color";
5
+
6
+ export interface WelcomeData {
7
+ version: string;
8
+ model: string;
9
+ provider?: string;
10
+ cwd?: string; // absolute; render ~-shortened
11
+ thinking?: string; // e.g. "medium"
12
+ sessionId?: string; // render first 8 chars
13
+ contextFiles?: string[]; // project context file paths (render basenames)
14
+ recentSessions?: { name: string; timeAgo: string }[];
15
+ cols?: number; // default 80
16
+ /** Gradient phase [0..1) for the DNA Claw symbol — drives the launch sweep animation. */
17
+ phase?: number;
18
+ /** Lit-edge painter (top border + left edge); theme accent. Default gray. */
19
+ accent?: (s: string) => string;
20
+ /** Shaded-edge painter (bottom border + right edge); dimmed accent. Default dim gray. */
21
+ accentShadow?: (s: string) => string;
22
+ unicode?: boolean; // default true
23
+ color?: boolean; // default true
24
+ }
25
+
26
+ function getVisibleWidth(s: string): number {
27
+ return s.replace(/\x1b\[[0-9;]*m/g, "").length;
28
+ }
29
+
30
+ function padLine(line: string, width: number, align: "left" | "center" | "right" = "left"): string {
31
+ const vis = getVisibleWidth(line);
32
+ if (width <= 0 || vis >= width) return line;
33
+ const total = width - vis;
34
+ if (align === "right") return " ".repeat(total) + line;
35
+ if (align === "center") {
36
+ const left = Math.floor(total / 2);
37
+ return " ".repeat(left) + line + " ".repeat(total - left);
38
+ }
39
+ return line + " ".repeat(total);
40
+ }
41
+
42
+ /**
43
+ * The gjc-style hero welcome box ("JEO forge"): one outer box with the version
44
+ * embedded in the top border and a SINGLE CENTERED column inside — brand line,
45
+ * tagline, the grand DNA Claw symbol (flowing gradient on capable terminals),
46
+ * and the model/provider pills. Workspace details and key hints intentionally
47
+ * live elsewhere (footer/status bar), matching the gjc forge banner.
48
+ */
49
+ export function renderWelcome(d: WelcomeData): string[] {
50
+ const cols = d.cols ?? 80;
51
+ const unicode = d.unicode !== false;
52
+ const useColor = d.color !== false;
53
+
54
+ if (cols < 30) {
55
+ return [ `jeo v${d.version} · ${d.model}` ];
56
+ }
57
+
58
+ const W = Math.min(100, cols - 2);
59
+ const inner = W - 2;
60
+
61
+ const BOX_UNICODE = { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" };
62
+ const BOX_ASCII = { tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|" };
63
+ const g = unicode ? BOX_UNICODE : BOX_ASCII;
64
+
65
+ // Depth cue (two-tone borders): top border + left edge are "lit" with the
66
+ // accent; bottom border + right edge are "shaded" with the dimmed accent.
67
+ const lit = useColor ? (d.accent ?? chalk.gray) : (s: string) => s;
68
+ const shadow = useColor ? (d.accentShadow ?? ((s: string) => chalk.dim(chalk.gray(s)))) : (s: string) => s;
69
+
70
+ // Title text: ─── jeo v{version} · JEO forge ─── (bold for contrast against the border)
71
+ const dashStr = g.h.repeat(3);
72
+ const titleLabel = ` jeo v${d.version} · JEO forge `;
73
+ const titleHead = `${dashStr}${titleLabel}`;
74
+ let topBorderLine: string;
75
+ if (titleHead.length + 2 > inner) {
76
+ const clipped = titleHead.slice(0, inner);
77
+ topBorderLine = lit(g.tl + clipped + g.h.repeat(Math.max(0, inner - clipped.length)) + g.tr);
78
+ } else {
79
+ const fill = g.h.repeat(inner - titleHead.length);
80
+ topBorderLine = useColor
81
+ ? lit(g.tl + dashStr) + chalk.bold(lit(titleLabel)) + lit(fill) + lit(g.tr)
82
+ : g.tl + titleHead + fill + g.tr;
83
+ }
84
+
85
+ const bottomBorderPlain = g.bl + g.h.repeat(inner) + g.br;
86
+ const bottomBorderLine = shadow(bottomBorderPlain);
87
+
88
+ // Grand symbol when the box is wide enough; compact DNA Claw otherwise.
89
+ const colorLevel = useColor ? detectColorLevel(process.env, isTTY()) : ColorLevel.None;
90
+ const grandWidth = Math.max(...DNA_CLAW_ART_GRAND.map(l => l.length));
91
+ const grand = inner >= grandWidth;
92
+ const artLines = renderDnaClaw({
93
+ color: useColor,
94
+ phase: d.phase ?? 0,
95
+ unicode,
96
+ colorLevel,
97
+ grand,
98
+ cols: inner,
99
+ });
100
+
101
+ // Single centered hero column (gjc forge layout): breathing room, brand,
102
+ // tagline, the symbol, then the model/provider pills.
103
+ const content: string[] = [];
104
+ content.push("");
105
+ content.push(useColor ? chalk.bold.cyan("Jeo forge") : "Jeo forge");
106
+ content.push(useColor ? chalk.dim("evolve · act · prove") : "evolve · act · prove");
107
+ content.push("");
108
+ for (const line of artLines) content.push(line);
109
+ content.push("");
110
+
111
+ const modelIcon = unicode ? "◆" : "*";
112
+ const modelPill = truncate(`[ ${modelIcon} ${d.model} ]`, inner);
113
+ content.push(useColor ? chalk.cyan(modelPill) : modelPill);
114
+ if (d.provider) {
115
+ const providerIcon = unicode ? "◇" : "o";
116
+ const providerPill = truncate(`[ ${providerIcon} ${d.provider} ]`, inner);
117
+ content.push(useColor ? chalk.blue(providerPill) : providerPill);
118
+ }
119
+ content.push("");
120
+
121
+ const leftBorder = lit(g.v);
122
+ const rightBorder = shadow(g.v);
123
+ const finalContentLines = content.map(raw => {
124
+ const line = padLine(truncate(raw, inner), inner, "center");
125
+ return leftBorder + line + rightBorder;
126
+ });
127
+
128
+ return [topBorderLine, ...finalContentLines, bottomBorderLine];
129
+ }
130
+
131
+ /**
132
+ * Launch animation: sweep the DNA Claw's gradient through `cycles` FULL palette
133
+ * cycles by re-printing the welcome box in place (cursor-up rewrites, same row
134
+ * count every frame). The loop is SEAMLESS — the phase wraps exactly at each
135
+ * cycle boundary with a constant frame delay, so consecutive cycles join with
136
+ * no pause or color jump — and every repaint is wrapped in a DECSET 2026
137
+ * synchronized update so frames land atomically (no tearing/flicker on slow
138
+ * terminals). The FINAL frame is phase 0 — byte-identical to the static
139
+ * `renderWelcome` — so the resting banner matches non-animated output exactly.
140
+ * `write`/`sleep` are injectable for tests; callers gate on TTY + truecolor.
141
+ */
142
+ export async function playWelcomeSweep(
143
+ d: WelcomeData,
144
+ opts: { write?: (s: string) => void; sleep?: (ms: number) => Promise<unknown>; frames?: number; delayMs?: number; cycles?: number } = {},
145
+ ): Promise<void> {
146
+ const write = opts.write ?? ((s: string) => process.stdout.write(s));
147
+ const sleep = opts.sleep ?? ((ms: number) => new Promise(r => setTimeout(r, ms)));
148
+ const frames = Math.max(1, Math.trunc(opts.frames ?? 10));
149
+ const cycles = Math.max(1, Math.trunc(opts.cycles ?? 2));
150
+ const delay = opts.delayMs ?? 50;
151
+ const total = frames * cycles;
152
+ let lineCount = 0;
153
+ for (let f = 0; f <= total; f++) {
154
+ const phase = (f % frames) / frames; // wraps each cycle; f === total → 0 (the static banner)
155
+ const lines = renderWelcome({ ...d, phase });
156
+ const rewind = f > 0 ? `\x1b[${lineCount}A` : "";
157
+ // BSU/ESU: the whole repaint (rewind + every row) applies atomically.
158
+ write(`\x1b[?2026h${rewind}${lines.map(l => `${l}\x1b[K`).join("\n")}\n\x1b[?2026l`);
159
+ lineCount = lines.length;
160
+ if (f < total && delay > 0) await sleep(delay);
161
+ }
162
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * ANSI + Unicode display-width helpers (consensus-seed P2.B9).
3
+ *
4
+ * Terminals render some code points two columns wide (CJK ideographs, Hangul,
5
+ * fullwidth forms, most emoji) and some zero (combining marks, ZWJ, variation
6
+ * selectors). Counting `string.length` — as the old ad-hoc `truncate` did —
7
+ * overflows or under-fills any line containing them. These helpers count by
8
+ * DISPLAY width, treat tabs as advancing to the next 8-col stop, and copy SGR
9
+ * color escapes verbatim (never counting them) so colored/CJK lines truncate and
10
+ * wrap without tearing an escape or miscounting a wide glyph.
11
+ */
12
+
13
+ const TAB_STOP = 8;
14
+ // Sticky SGR matcher: scan a heavily color-escaped line in O(n) without slicing.
15
+ const SGR = /\x1b\[[0-9;]*m/y;
16
+
17
+ /**
18
+ * Display columns for a single code point. 0 for combining/zero-width, 2 for
19
+ * East-Asian Wide/Fullwidth and emoji, 1 otherwise. Ranges follow the common
20
+ * wcwidth/East_Asian_Width tables (not exhaustive, but covers the cases a coding
21
+ * TUI actually shows: Hangul, CJK, kana, fullwidth ASCII, emoji blocks).
22
+ */
23
+ export function charWidth(cp: number): number {
24
+ if (cp === 0) return 0;
25
+ // C0/C1 control characters have no width here (callers strip/By handle them).
26
+ if (cp < 32 || (cp >= 0x7f && cp < 0xa0)) return 0;
27
+ // Zero-width: combining marks, ZWSP/ZWNJ/ZWJ, variation selectors, BOM.
28
+ if (
29
+ (cp >= 0x0300 && cp <= 0x036f) || // combining diacritical marks
30
+ (cp >= 0x200b && cp <= 0x200f) || // zero-width space..RLM
31
+ (cp >= 0xfe00 && cp <= 0xfe0f) || // variation selectors
32
+ (cp >= 0x1ab0 && cp <= 0x1aff) || // combining diacritical marks extended
33
+ (cp >= 0x1dc0 && cp <= 0x1dff) || // combining diacritical marks supplement
34
+ (cp >= 0x20d0 && cp <= 0x20ff) || // combining marks for symbols
35
+ cp === 0xfeff
36
+ ) {
37
+ return 0;
38
+ }
39
+ // Wide (2 columns).
40
+ if (
41
+ (cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
42
+ (cp >= 0x2e80 && cp <= 0x303e) || // CJK radicals .. Kangxi
43
+ (cp >= 0x3041 && cp <= 0x33ff) || // Hiragana, Katakana, CJK symbols
44
+ (cp >= 0x3400 && cp <= 0x4dbf) || // CJK Ext A
45
+ (cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified
46
+ (cp >= 0xa000 && cp <= 0xa4cf) || // Yi
47
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
48
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility ideographs
49
+ (cp >= 0xfe30 && cp <= 0xfe4f) || // CJK compatibility forms
50
+ (cp >= 0xff00 && cp <= 0xff60) || // fullwidth forms
51
+ (cp >= 0xffe0 && cp <= 0xffe6) || // fullwidth signs
52
+ (cp >= 0x1f300 && cp <= 0x1faff) || // emoji & pictographs (incl. supplemental/symbols-extended)
53
+ (cp >= 0x1f000 && cp <= 0x1f0ff) || // mahjong/dominoes/playing cards
54
+ (cp >= 0x20000 && cp <= 0x3fffd) // CJK Ext B+ (supplementary ideographic planes)
55
+ ) {
56
+ return 2;
57
+ }
58
+ return 1;
59
+ }
60
+
61
+ /**
62
+ * Visible display width of a string: SGR escapes count 0, tabs advance to the
63
+ * next 8-col stop, wide glyphs count 2. Iterates by code point (surrogate-safe).
64
+ */
65
+ export function visibleWidth(s: string): number {
66
+ if (!s) return 0;
67
+ let w = 0;
68
+ let i = 0;
69
+ while (i < s.length) {
70
+ if (s[i] === "\x1b") {
71
+ SGR.lastIndex = i;
72
+ const m = SGR.exec(s);
73
+ if (m) {
74
+ i += m[0].length;
75
+ continue;
76
+ }
77
+ }
78
+ if (s[i] === "\t") {
79
+ w += TAB_STOP - (w % TAB_STOP);
80
+ i += 1;
81
+ continue;
82
+ }
83
+ const cp = s.codePointAt(i)!;
84
+ w += charWidth(cp);
85
+ i += cp > 0xffff ? 2 : 1;
86
+ }
87
+ return w;
88
+ }
89
+
90
+ /**
91
+ * Truncate a string to at most `cols` DISPLAY columns. SGR escapes are copied
92
+ * verbatim (free); a wide glyph that would straddle the boundary is dropped
93
+ * whole (never half-rendered). If a color was active at the cut, a reset is
94
+ * appended so trailing frame content is not tinted.
95
+ */
96
+ export function truncateToWidth(s: string, cols: number): string {
97
+ const limit = Math.max(0, cols);
98
+ if (limit === 0) return "";
99
+ // Fast path: no escapes, no wide chars, no tabs → plain slice by length.
100
+ if (!s.includes("\x1b") && !/[\t\u0300-\uffff]/.test(s) && !/[\u{10000}-\u{10ffff}]/u.test(s)) {
101
+ return s.length <= limit ? s : s.slice(0, limit);
102
+ }
103
+ let out = "";
104
+ let w = 0;
105
+ let sawEscape = false;
106
+ let i = 0;
107
+ while (i < s.length) {
108
+ if (s[i] === "\x1b") {
109
+ SGR.lastIndex = i;
110
+ const m = SGR.exec(s);
111
+ if (m) {
112
+ out += m[0];
113
+ sawEscape = true;
114
+ i += m[0].length;
115
+ continue;
116
+ }
117
+ }
118
+ let cw: number;
119
+ let chunk: string;
120
+ if (s[i] === "\t") {
121
+ cw = TAB_STOP - (w % TAB_STOP);
122
+ chunk = "\t";
123
+ } else {
124
+ const cp = s.codePointAt(i)!;
125
+ cw = charWidth(cp);
126
+ chunk = cp > 0xffff ? s.slice(i, i + 2) : s[i]!;
127
+ }
128
+ if (w + cw > limit) break;
129
+ out += chunk;
130
+ w += cw;
131
+ i += chunk.length;
132
+ }
133
+ if (i < s.length && sawEscape && !out.endsWith("\x1b[0m")) out += "\x1b[0m";
134
+ return out;
135
+ }
136
+
137
+ /**
138
+ * Hard-wrap text to `cols` display columns, breaking long words and preserving
139
+ * existing newlines. SGR-aware (escapes don't consume width). Returns the wrapped
140
+ * lines. Used by markdown/table rendering where alignment must be column-correct.
141
+ */
142
+ export function wrapTextWithAnsi(text: string, cols: number): string[] {
143
+ const width = Math.max(1, cols);
144
+ const out: string[] = [];
145
+ for (const rawLine of text.split("\n")) {
146
+ if (visibleWidth(rawLine) <= width) {
147
+ out.push(rawLine);
148
+ continue;
149
+ }
150
+ let rest = rawLine;
151
+ while (visibleWidth(rest) > width) {
152
+ const head = truncateToWidth(rest, width);
153
+ // Advance past exactly the consumed substring (head may carry a trailing reset).
154
+ const consumed = head.endsWith("\x1b[0m") && !rest.endsWith("\x1b[0m")
155
+ ? head.slice(0, -"\x1b[0m".length)
156
+ : head;
157
+ out.push(head);
158
+ rest = rest.slice(consumed.length);
159
+ }
160
+ if (rest.length > 0) out.push(rest);
161
+ }
162
+ return out;
163
+ }
@@ -0,0 +1,31 @@
1
+ <!-- Parent: ../../AGENTS.md -->
2
+ <!-- Generated: 2026-06-11 | Updated: 2026-06-11 -->
3
+
4
+ # monitoring
5
+
6
+ ## Purpose
7
+ Specialized HUD views and sovereign monitoring interfaces.
8
+
9
+ ## Key Files
10
+ | File | Description |
11
+ |------|-------------|
12
+ | `hud-view.ts` | Real-time evolution tracking and self-analysis visibility |
13
+
14
+ ## Subdirectories
15
+ *(None)*
16
+
17
+ ## For AI Agents
18
+
19
+ ### Working In This Directory
20
+ - Ensure compatibility with the differential renderer.
21
+
22
+ ### Testing Requirements
23
+ - Unit tests with mock state.
24
+
25
+ ### Common Patterns
26
+ *(None)*
27
+
28
+ ## Dependencies
29
+ *(None)*
30
+
31
+ <!-- MANUAL: -->
@@ -0,0 +1,55 @@
1
+ import chalk from "chalk";
2
+ import { renderHud, type JeoPhase } from "../components/hud";
3
+ import {
4
+ evolutionTrack,
5
+ stageIndexForStep,
6
+ getEvolutionStatusMessage,
7
+ stageProgressRatio,
8
+ meterGlyphsFor,
9
+ EVOLUTION_STAGE_COLORS
10
+ } from "../components/evolution";
11
+
12
+ export interface MonitorState {
13
+ phase: JeoPhase;
14
+ step: number;
15
+ maxSteps: number;
16
+ tickCount: number;
17
+ analysisReport?: string;
18
+ }
19
+
20
+ export function renderMonitorView(state: MonitorState): string {
21
+ const unicode = true;
22
+ const stage = stageIndexForStep(state.step, state.maxSteps);
23
+ const hud = renderHud(state.phase, { unicode, color: true });
24
+ const evo = evolutionTrack(stage, { color: true, unicode, ratio: state.step / state.maxSteps });
25
+ const statusMsg = getEvolutionStatusMessage(state.step, state.maxSteps, state.tickCount);
26
+
27
+ // Progress Bar / Meter
28
+ const ratio = Math.max(0, Math.min(1, state.step / state.maxSteps));
29
+ const barWidth = 30;
30
+ const filledWidth = Math.round(ratio * barWidth);
31
+ const glyphs = meterGlyphsFor(stage, unicode);
32
+ const bar = glyphs.color(glyphs.fill.repeat(filledWidth)) + chalk.dim(glyphs.empty.repeat(barWidth - filledWidth));
33
+ const percentage = (ratio * 100).toFixed(1) + "%";
34
+
35
+ let output = "";
36
+ output += chalk.bold.cyan("┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓") + "\n";
37
+ output += chalk.bold.cyan("┃") + " " + chalk.bold.yellow("ooo ralph") + chalk.bold(" Sovereign Monitoring HUD") + " ".repeat(25) + chalk.bold.cyan("┃") + "\n";
38
+ output += chalk.bold.cyan("┠──────────────────────────────────────────────────────────────┨") + "\n";
39
+ output += chalk.bold.cyan("┃") + " " + chalk.bold("PHASE:") + " " + hud.padEnd(50) + " ".repeat(4) + chalk.bold.cyan("┃") + "\n";
40
+ output += chalk.bold.cyan("┃") + " " + chalk.bold("EVO :") + " " + evo.padEnd(50) + " ".repeat(4) + chalk.bold.cyan("┃") + "\n";
41
+ output += chalk.bold.cyan("┃") + " " + chalk.bold("PROG :") + " " + bar.padEnd(50) + " " + chalk.bold(percentage).padStart(6) + chalk.bold.cyan("┃") + "\n";
42
+ output += chalk.bold.cyan("┠──────────────────────────────────────────────────────────────┨") + "\n";
43
+ output += chalk.bold.cyan("┃") + " " + chalk.italic.dim("> " + statusMsg).padEnd(60) + " " + chalk.bold.cyan("┃") + "\n";
44
+
45
+ if (state.analysisReport) {
46
+ output += chalk.bold.cyan("┠──────────────────────────────────────────────────────────────┨") + "\n";
47
+ const lines = state.analysisReport.split("\n").slice(0, 5);
48
+ for (const line of lines) {
49
+ output += chalk.bold.cyan("┃") + " " + chalk.yellow(line.substring(0, 58).padEnd(58)) + " " + chalk.bold.cyan("┃") + "\n";
50
+ }
51
+ }
52
+ output += chalk.bold.cyan("┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛") + "\n";
53
+
54
+ return output;
55
+ }
@@ -2,15 +2,45 @@ import { cursorDown, cursorUp, toColumn, clearLine, clearToEnd, size, truncate }
2
2
 
3
3
  export type Writer = (s: string) => void;
4
4
 
5
+ export interface RendererOptions {
6
+ /** Inline (main-buffer) mode: before painting a frame TALLER than the previous one,
7
+ * reserve the missing rows with real newlines. Cursor-down can't scroll past the
8
+ * bottom margin, so without reservation a frame anchored near the bottom of the
9
+ * viewport would collapse onto its last rows. The newlines DO scroll, pushing prior
10
+ * content (the progress ledger) up into normal scrollback — which is exactly what
11
+ * keeps tmux / terminal mouse-wheel history working mid-turn.
12
+ * Caller invariant: frames must be sliced to the viewport height. A frame taller
13
+ * than the viewport is NOT reserved (cursor-up would clamp at the top margin and
14
+ * mis-anchor the repaint), so the diff degrades to in-place painting instead. */
15
+ reserve?: boolean;
16
+ }
17
+
18
+ // DECSET 2026 "synchronized update": the terminal buffers everything between BSU and
19
+ // ESU and presents it atomically, so the insertAbove flow (EL-overwrite of the first
20
+ // row(s) + full repaint below) never flashes an intermediate half-painted frame.
21
+ // Unsupported terminals ignore the
22
+ // sequences; supporting ones (incl. tmux ≥3.4) also time the update out (~150ms) if
23
+ // ESU never arrives, so a crash mid-update cannot freeze the screen.
24
+ const BEGIN_SYNC = "\x1b[?2026h";
25
+ const END_SYNC = "\x1b[?2026l";
26
+
5
27
  export class Renderer {
6
28
  private write: Writer;
7
29
  private cols: () => number;
8
30
  private prev: string[] = [];
9
31
  private prevCols?: number;
32
+ private readonly reserve: boolean;
33
+ // Stale rows left on screen by the previous frame after insertAbove() dropped the
34
+ // baseline; the next render() must EL-clear any of them beyond the new frame.
35
+ private coverRows = 0;
36
+ // True between an insertAbove() (which opens a synchronized update) and the next
37
+ // render()/clear() (which closes it after the repaint is fully written).
38
+ private syncOpen = false;
10
39
 
11
- constructor(write?: Writer, cols?: () => number) {
40
+ constructor(write?: Writer, cols?: () => number, opts?: RendererOptions) {
12
41
  this.write = write || ((s: string) => process.stdout.write(s));
13
42
  this.cols = cols || (() => size().cols);
43
+ this.reserve = opts?.reserve ?? false;
14
44
  }
15
45
 
16
46
  render(lines: string[]): void {
@@ -21,10 +51,20 @@ export class Renderer {
21
51
  this.prevCols = currentCols;
22
52
 
23
53
  const next = lines.map(line => truncate(line, currentCols));
24
- const maxLen = Math.max(this.prev.length, next.length);
54
+ const maxLen = Math.max(this.prev.length, next.length, this.coverRows);
55
+ this.coverRows = 0;
25
56
  let cursorRow = 0;
26
57
  let out = "";
27
58
 
59
+ if (this.reserve && next.length > this.prev.length && next.length <= Math.max(1, size().rows)) {
60
+ // The cursor rests on the frame's first row (the anchor). Walk to the last
61
+ // currently-occupied row, emit one newline per missing row (scrolling the
62
+ // viewport when at the bottom margin), then hop back up to the — possibly
63
+ // shifted — anchor so the diff below paints at stable relative positions.
64
+ const have = Math.max(this.prev.length, 1);
65
+ out += cursorDown(have - 1) + "\n".repeat(next.length - have) + cursorUp(next.length - 1) + toColumn(1);
66
+ }
67
+
28
68
  for (let i = 0; i < maxLen; i++) {
29
69
  if (i < next.length) {
30
70
  if (next[i] !== this.prev[i]) {
@@ -52,6 +92,14 @@ export class Renderer {
52
92
  }
53
93
  out += toColumn(1);
54
94
 
95
+ // Close the synchronized update opened by insertAbove() now that the full
96
+ // repaint is in the same buffered stream — the terminal presents the overwritten
97
+ // first row(s), the flushed ledger line, and the repainted frame as ONE atomic update.
98
+ if (this.syncOpen) {
99
+ out += END_SYNC;
100
+ this.syncOpen = false;
101
+ }
102
+
55
103
  if (out.length > 0) {
56
104
  this.write(out);
57
105
  }
@@ -59,8 +107,69 @@ export class Renderer {
59
107
  this.prev = next;
60
108
  }
61
109
 
110
+ /** Flush static text into normal scrollback ABOVE the live frame: overwrite the
111
+ * frame's first row(s) with the text (caller terminates it with "\n") and drop the
112
+ * differential baseline so the next render() repaints the full frame below the
113
+ * newly emitted line(s). The follow-up render's row reservation scrolls the text
114
+ * up into history, where tmux / terminal mouse-wheel can reach it mid-turn.
115
+ * Erases with per-line EL (\x1b[2K), NEVER clear-to-end: tmux pushes ED-erased
116
+ * rows into scrollback, so an ED here would flood history with one full frame
117
+ * copy per flush (the bug this replaced). Rows the new frame doesn't cover are
118
+ * EL-cleared by the next render() via coverRows.
119
+ * Opens a DECSET 2026 synchronized update that the next render()/clear() closes,
120
+ * so the overwrite → flush → repaint triplet never flashes intermediate states.
121
+ * Inline-mode only by convention — the alt screen has no scrollback to flush into. */
122
+ insertAbove(text: string): void {
123
+ this.syncOpen = true;
124
+ const rows = text.split("\n");
125
+ // Rows the body actually writes (the trailing "" from the final "\n" emits nothing).
126
+ const written = rows.length - (rows[rows.length - 1] === "" ? 1 : 0);
127
+ const body = rows
128
+ .map((line, i, arr) => (i === arr.length - 1 && line === "" ? "" : toColumn(1) + clearLine() + line))
129
+ .join("\n");
130
+ let out = BEGIN_SYNC + body;
131
+ // Eagerly EL-clear the old frame rows the inserted block did NOT cover, then hop
132
+ // back to the row right below the insert (where the next render() anchors).
133
+ // The geometry is provably safe HERE: when stale > 0 the body write never hit
134
+ // the bottom margin (the old frame fit on screen and the insert is shorter), so
135
+ // every stale row exists and cursor-down cannot clamp. Deferring this clear to
136
+ // the next render() via coverRows walked PAST the bottom margin, where the
137
+ // clamped cursor-down desynced the row bookkeeping — each subsequent frame then
138
+ // painted one row higher, devouring the flushed scrollback content above (the
139
+ // "truncated card" corruption).
140
+ const stale = this.prev.length - written;
141
+ if (stale > 0) {
142
+ for (let i = 0; i < stale; i++) {
143
+ out += toColumn(1) + clearLine() + (i < stale - 1 ? cursorDown(1) : "");
144
+ }
145
+ out += (stale > 1 ? cursorUp(stale - 1) : "") + toColumn(1);
146
+ }
147
+ this.write(out);
148
+ this.prev = [];
149
+ }
150
+
151
+ /** Clear the live frame. Inline (reserve) mode walks the known frame rows with
152
+ * per-line EL — clear-to-end would make tmux push the erased frame into
153
+ * scrollback (see insertAbove). Alt-screen/non-TTY renderers keep the cheaper
154
+ * ED clear: the alt screen has no history and pipes have no screen. */
62
155
  clear(): void {
63
- this.write(toColumn(1) + clearToEnd());
156
+ let out: string;
157
+ if (this.reserve) {
158
+ const rows = Math.max(this.prev.length, this.coverRows);
159
+ out = toColumn(1);
160
+ for (let i = 0; i < rows; i++) {
161
+ out += (i > 0 ? cursorDown(1) : "") + toColumn(1) + clearLine();
162
+ }
163
+ if (rows > 1) out += cursorUp(rows - 1) + toColumn(1);
164
+ } else {
165
+ out = toColumn(1) + clearToEnd();
166
+ }
167
+ if (this.syncOpen) {
168
+ out += END_SYNC;
169
+ this.syncOpen = false;
170
+ }
171
+ this.coverRows = 0;
172
+ this.write(out);
64
173
  this.prev = [];
65
174
  }
66
175
 
@@ -1,3 +1,5 @@
1
+ import { truncateToWidth } from "./components/width";
2
+
1
3
  export const ESC = "\x1b[";
2
4
 
3
5
  export function cursorUp(n: number): string {
@@ -28,6 +30,37 @@ export function showCursor(): string {
28
30
  return `${ESC}?25h`;
29
31
  }
30
32
 
33
+ /**
34
+ * Defensively DISABLE every xterm mouse-tracking mode + coordinate encoding.
35
+ * jeo never enables these itself, but a previous program that crashed (or a
36
+ * stale tmux pane) can leave them ON — the terminal then reports clicks/motion
37
+ * as escape sequences from the very first prompt, which reads as "the mouse
38
+ * starts out clicked/held" and sprays `[<0;…M`-style garbage into input.
39
+ * Emitting the `l` (reset) forms is harmless when the modes are already off.
40
+ * ?9 X10 · ?1000 normal · ?1002 button-motion · ?1003 any-motion
41
+ * ?1005 UTF-8 · ?1006 SGR · ?1015 urxvt · ?1016 SGR-pixel
42
+ */
43
+ export function resetMouseTracking(): string {
44
+ return `${ESC}?9l${ESC}?1000l${ESC}?1002l${ESC}?1003l${ESC}?1005l${ESC}?1006l${ESC}?1015l${ESC}?1016l`;
45
+ }
46
+
47
+ /** Enter the alternate screen buffer (xterm `?1049h`): a separate, scrollback-free
48
+ * screen. Used for the transient live-turn UI so terminal scroll (mouse wheel) can't
49
+ * fight the in-place repaint — and the main buffer / scrollback is left untouched.
50
+ * Also disables "alternate scroll" (`?1007l`): with it on, terminals (and tmux)
51
+ * translate mouse-wheel motion in the alt screen into Up/Down arrow key sequences,
52
+ * which would otherwise leak into readline's buffer and corrupt the next prompt. */
53
+ export function enterAltScreen(): string {
54
+ return `${ESC}?1049h${ESC}?1007l`;
55
+ }
56
+
57
+ /** Leave the alternate screen buffer (`?1049l`), restoring the main buffer + scrollback.
58
+ * Re-enables alternate scroll (`?1007h`, the common terminal default) so other
59
+ * full-screen apps (vim/less) keep their wheel behavior after jeo exits the turn. */
60
+ export function leaveAltScreen(): string {
61
+ return `${ESC}?1007h${ESC}?1049l`;
62
+ }
63
+
31
64
  export function size(): { cols: number; rows: number } {
32
65
  return {
33
66
  cols: process.stdout.columns || 80,
@@ -40,39 +73,13 @@ export function isTTY(): boolean {
40
73
  }
41
74
 
42
75
  /**
43
- * Truncate a line to `cols` *visible* columns. SGR color escapes are copied
44
- * verbatim and do NOT count toward the width, so a colored line is never cut
45
- * mid-escape (which would spill raw `\x1b[…` bytes onto the screen). If the line
46
- * is cut while a color is active, a reset (`\x1b[0m`) is appended so trailing
47
- * frame content is not tinted by a dangling color.
76
+ * Truncate a line to `cols` *visible* DISPLAY columns. Delegates to the
77
+ * width-aware `truncateToWidth` (consensus-seed P2.B9): SGR escapes are copied
78
+ * verbatim (counted 0), CJK/emoji glyphs count 2 so a wide-char line no longer
79
+ * overflows the terminal width, tabs advance to the next stop, and a reset is
80
+ * appended if the cut lands mid-color. The plain-ASCII fast path is preserved
81
+ * inside `truncateToWidth`, so hot-path render cost is unchanged for ASCII frames.
48
82
  */
49
83
  export function truncate(line: string, cols: number): string {
50
- const limit = Math.max(0, cols);
51
- // Fast path: no escapes → plain slice by length.
52
- if (!line.includes("\x1b")) {
53
- return line.length <= limit ? line : line.slice(0, limit);
54
- }
55
- let out = "";
56
- let visible = 0;
57
- let sawEscape = false;
58
- let i = 0;
59
- while (i < line.length) {
60
- if (line[i] === "\x1b") {
61
- const m = /^\x1b\[[0-9;]*m/.exec(line.slice(i));
62
- if (m) {
63
- out += m[0];
64
- sawEscape = true;
65
- i += m[0].length;
66
- continue;
67
- }
68
- }
69
- if (visible >= limit) break;
70
- out += line[i];
71
- visible++;
72
- i++;
73
- }
74
- if (i < line.length && sawEscape && !out.endsWith("\x1b[0m")) {
75
- out += "\x1b[0m";
76
- }
77
- return out;
84
+ return truncateToWidth(line, cols);
78
85
  }