jeo-code 0.1.0

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 (93) hide show
  1. package/README.md +342 -0
  2. package/package.json +57 -0
  3. package/scripts/install.sh +322 -0
  4. package/scripts/uninstall.sh +30 -0
  5. package/src/agent/compaction.ts +75 -0
  6. package/src/agent/config-schema.ts +87 -0
  7. package/src/agent/context-files.ts +51 -0
  8. package/src/agent/engine.ts +208 -0
  9. package/src/agent/json.ts +87 -0
  10. package/src/agent/loop.ts +22 -0
  11. package/src/agent/session.ts +198 -0
  12. package/src/agent/state.ts +199 -0
  13. package/src/agent/subagents.ts +149 -0
  14. package/src/agent/tools.ts +355 -0
  15. package/src/ai/index.ts +11 -0
  16. package/src/ai/model-catalog-compat.ts +119 -0
  17. package/src/ai/model-catalog.ts +97 -0
  18. package/src/ai/model-discovery.ts +148 -0
  19. package/src/ai/model-enrich.ts +75 -0
  20. package/src/ai/model-manager.ts +178 -0
  21. package/src/ai/model-picker.ts +73 -0
  22. package/src/ai/model-registry.ts +83 -0
  23. package/src/ai/provider-status.ts +77 -0
  24. package/src/ai/providers/anthropic.ts +87 -0
  25. package/src/ai/providers/errors.ts +47 -0
  26. package/src/ai/providers/gemini.ts +77 -0
  27. package/src/ai/providers/ollama.ts +54 -0
  28. package/src/ai/providers/openai.ts +67 -0
  29. package/src/ai/sse.ts +46 -0
  30. package/src/ai/types.ts +37 -0
  31. package/src/auth/callback-server.ts +195 -0
  32. package/src/auth/flows/anthropic.ts +114 -0
  33. package/src/auth/flows/google.ts +120 -0
  34. package/src/auth/flows/index.ts +50 -0
  35. package/src/auth/flows/openai.ts +130 -0
  36. package/src/auth/index.ts +23 -0
  37. package/src/auth/oauth.ts +80 -0
  38. package/src/auth/pkce.ts +24 -0
  39. package/src/auth/refresh.ts +60 -0
  40. package/src/auth/storage.ts +113 -0
  41. package/src/auth/types.ts +26 -0
  42. package/src/cli/index.ts +1 -0
  43. package/src/cli/runner.ts +245 -0
  44. package/src/cli.ts +17 -0
  45. package/src/commands/approve.ts +63 -0
  46. package/src/commands/auth.ts +144 -0
  47. package/src/commands/chat.ts +37 -0
  48. package/src/commands/deep-interview.ts +239 -0
  49. package/src/commands/doctor.ts +250 -0
  50. package/src/commands/evolve.ts +191 -0
  51. package/src/commands/launch.ts +745 -0
  52. package/src/commands/mcp.ts +18 -0
  53. package/src/commands/models.ts +104 -0
  54. package/src/commands/ralplan.ts +86 -0
  55. package/src/commands/resume.ts +6 -0
  56. package/src/commands/setup-helpers.ts +93 -0
  57. package/src/commands/setup.ts +190 -0
  58. package/src/commands/skills.ts +38 -0
  59. package/src/commands/team.ts +337 -0
  60. package/src/commands/ultragoal.ts +102 -0
  61. package/src/index.ts +31 -0
  62. package/src/mcp/index.ts +3 -0
  63. package/src/mcp/protocol.ts +45 -0
  64. package/src/mcp/server.ts +97 -0
  65. package/src/mcp/tools.ts +156 -0
  66. package/src/skills/catalog.ts +61 -0
  67. package/src/tui/app.ts +297 -0
  68. package/src/tui/components/ascii-art.ts +340 -0
  69. package/src/tui/components/autocomplete.ts +165 -0
  70. package/src/tui/components/capability.ts +29 -0
  71. package/src/tui/components/code-view.ts +146 -0
  72. package/src/tui/components/color.ts +172 -0
  73. package/src/tui/components/config-panel.ts +193 -0
  74. package/src/tui/components/evolution.ts +305 -0
  75. package/src/tui/components/footer.ts +95 -0
  76. package/src/tui/components/forge.ts +167 -0
  77. package/src/tui/components/index.ts +7 -0
  78. package/src/tui/components/layout.ts +105 -0
  79. package/src/tui/components/meter.ts +61 -0
  80. package/src/tui/components/model-picker.ts +82 -0
  81. package/src/tui/components/provider-picker.ts +42 -0
  82. package/src/tui/components/select-list.ts +199 -0
  83. package/src/tui/components/slash.ts +34 -0
  84. package/src/tui/components/spinner.ts +49 -0
  85. package/src/tui/components/status.ts +45 -0
  86. package/src/tui/components/stream.ts +36 -0
  87. package/src/tui/components/themes.ts +86 -0
  88. package/src/tui/components/tool-list.ts +67 -0
  89. package/src/tui/index.ts +2 -0
  90. package/src/tui/renderer.ts +70 -0
  91. package/src/tui/terminal.ts +78 -0
  92. package/src/util/retry.ts +108 -0
  93. package/tsconfig.json +18 -0
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Terminal color-capability detection + a tiny truecolor gradient engine.
3
+ *
4
+ * The evolution TUI wants smooth per-character color gradients across its ASCII
5
+ * art, but a terminal might support 24-bit truecolor, 256 colors, 16 colors, or
6
+ * nothing at all. This module detects the level from the environment (honoring
7
+ * `NO_COLOR` / `FORCE_COLOR` / `COLORTERM` / `TERM`) and renders gradients that
8
+ * gracefully downgrade: truecolor → 256 → 16 → plain text.
9
+ *
10
+ * Escapes are emitted directly (not via chalk) so the output is deterministic
11
+ * and testable regardless of chalk's own TTY auto-detection.
12
+ */
13
+
14
+ /** Color capability tiers. */
15
+ export enum ColorLevel {
16
+ None = 0,
17
+ Basic = 1, // 16 colors
18
+ Ansi256 = 2,
19
+ TrueColor = 3,
20
+ }
21
+
22
+ export interface RGB {
23
+ r: number;
24
+ g: number;
25
+ b: number;
26
+ }
27
+
28
+ export type EnvLike = Record<string, string | undefined>;
29
+
30
+ const ESC = "\x1b[";
31
+ /** Matches any SGR / CSI escape sequence. */
32
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
33
+
34
+ /** Remove every SGR escape sequence, leaving only visible characters. */
35
+ export function stripAnsi(s: string): string {
36
+ return s.replace(ANSI_RE, "");
37
+ }
38
+
39
+ /** Visible (printable) width of a string, ignoring SGR escapes. */
40
+ export function visibleWidth(s: string): number {
41
+ return stripAnsi(s).length;
42
+ }
43
+
44
+ /**
45
+ * Detect the terminal color level from an environment map. Pure + injectable so
46
+ * tests can pin behavior. `isTty` is a hint for the ambiguous "no signal" case.
47
+ */
48
+ export function detectColorLevel(env: EnvLike = process.env, isTty = false): ColorLevel {
49
+ // NO_COLOR: presence (any value, even empty) disables color. https://no-color.org
50
+ if (env.NO_COLOR !== undefined) return ColorLevel.None;
51
+
52
+ const force = env.FORCE_COLOR;
53
+ if (force !== undefined) {
54
+ if (force === "0" || force === "false") return ColorLevel.None;
55
+ if (force === "1" || force === "true") return ColorLevel.Basic;
56
+ if (force === "2") return ColorLevel.Ansi256;
57
+ if (force === "3") return ColorLevel.TrueColor;
58
+ // Any other truthy value: assume basic.
59
+ return ColorLevel.Basic;
60
+ }
61
+
62
+ const term = (env.TERM ?? "").toLowerCase();
63
+ if (term === "dumb") return ColorLevel.None;
64
+
65
+ const colorterm = (env.COLORTERM ?? "").toLowerCase();
66
+ if (colorterm === "truecolor" || colorterm === "24bit") return ColorLevel.TrueColor;
67
+
68
+ if (/-256(color)?$/.test(term) || term.includes("256")) return ColorLevel.Ansi256;
69
+ if (/^(xterm|screen|vt100|vt220|rxvt|tmux|ansi|linux|konsole|alacritty|kitty|wezterm)/.test(term)) {
70
+ return ColorLevel.Basic;
71
+ }
72
+
73
+ return isTty ? ColorLevel.Basic : ColorLevel.None;
74
+ }
75
+
76
+ /** Parse `#rrggbb` / `#rgb` / `rrggbb` into an RGB triple. Defaults to black on bad input. */
77
+ export function hexToRgb(hex: string): RGB {
78
+ let h = hex.trim().replace(/^#/, "");
79
+ if (h.length === 3) h = h[0]! + h[0]! + h[1]! + h[1]! + h[2]! + h[2]!;
80
+ if (!/^[0-9a-fA-F]{6}$/.test(h)) return { r: 0, g: 0, b: 0 };
81
+ return {
82
+ r: parseInt(h.slice(0, 2), 16),
83
+ g: parseInt(h.slice(2, 4), 16),
84
+ b: parseInt(h.slice(4, 6), 16),
85
+ };
86
+ }
87
+
88
+ const clamp255 = (n: number) => Math.max(0, Math.min(255, Math.round(n)));
89
+
90
+ /** Linear interpolation between two RGB colors. `t` in [0,1]. */
91
+ export function lerpColor(a: RGB, b: RGB, t: number): RGB {
92
+ const k = Math.max(0, Math.min(1, t));
93
+ return {
94
+ r: clamp255(a.r + (b.r - a.r) * k),
95
+ g: clamp255(a.g + (b.g - a.g) * k),
96
+ b: clamp255(a.b + (b.b - a.b) * k),
97
+ };
98
+ }
99
+
100
+ /** `n` evenly spaced RGB stops from `from` to `to` (inclusive endpoints). */
101
+ export function gradientStops(from: RGB, to: RGB, n: number): RGB[] {
102
+ const count = Math.max(1, Math.trunc(n));
103
+ if (count === 1) return [from];
104
+ const out: RGB[] = [];
105
+ for (let i = 0; i < count; i++) out.push(lerpColor(from, to, i / (count - 1)));
106
+ return out;
107
+ }
108
+
109
+ /** Convert an RGB triple to the nearest xterm-256 color index. */
110
+ export function rgbToAnsi256(c: RGB): number {
111
+ const { r, g, b } = c;
112
+ // Grayscale ramp (232-255) when channels are near-equal.
113
+ if (Math.abs(r - g) < 8 && Math.abs(g - b) < 8) {
114
+ if (r < 8) return 16;
115
+ if (r > 248) return 231;
116
+ return Math.round(((r - 8) / 247) * 24) + 232;
117
+ }
118
+ const q = (v: number) => Math.round((v / 255) * 5);
119
+ return 16 + 36 * q(r) + 6 * q(g) + q(b);
120
+ }
121
+
122
+ /** Convert an RGB triple to the nearest basic 16-color SGR foreground code (30-37 / 90-97). */
123
+ export function rgbToAnsi16(c: RGB): number {
124
+ const { r, g, b } = c;
125
+ const max = Math.max(r, g, b);
126
+ if (max < 40) return 30; // black
127
+ const bright = max > 170;
128
+ const bit = (v: number) => (v > max / 2 ? 1 : 0);
129
+ const code = bit(r) + bit(g) * 2 + bit(b) * 4; // 0..7
130
+ return (bright ? 90 : 30) + code;
131
+ }
132
+
133
+ /** Open-foreground escape for one color at a given level (empty at None). */
134
+ export function fgEscape(c: RGB, level: ColorLevel): string {
135
+ switch (level) {
136
+ case ColorLevel.TrueColor:
137
+ return `${ESC}38;2;${c.r};${c.g};${c.b}m`;
138
+ case ColorLevel.Ansi256:
139
+ return `${ESC}38;5;${rgbToAnsi256(c)}m`;
140
+ case ColorLevel.Basic:
141
+ return `${ESC}${rgbToAnsi16(c)}m`;
142
+ default:
143
+ return "";
144
+ }
145
+ }
146
+
147
+ /** Reset escape (empty at None). */
148
+ export function resetEscape(level: ColorLevel): string {
149
+ return level === ColorLevel.None ? "" : `${ESC}0m`;
150
+ }
151
+
152
+ /**
153
+ * Apply a left→right color gradient across the visible characters of `text`.
154
+ * SGR escapes already in `text` are stripped first (the gradient owns color).
155
+ * At `ColorLevel.None` the plain text is returned unchanged. Whitespace runs
156
+ * still consume gradient positions so multi-line art stays phase-aligned.
157
+ */
158
+ export function applyGradient(text: string, from: RGB, to: RGB, level: ColorLevel = ColorLevel.TrueColor): string {
159
+ const plain = stripAnsi(text);
160
+ if (level === ColorLevel.None || plain.length === 0) return plain;
161
+ const stops = gradientStops(from, to, plain.length);
162
+ let out = "";
163
+ for (let i = 0; i < plain.length; i++) {
164
+ const ch = plain[i]!;
165
+ if (ch === " ") {
166
+ out += ch; // don't paint spaces (keeps escapes lean, transparent bg)
167
+ continue;
168
+ }
169
+ out += fgEscape(stops[i]!, level) + ch;
170
+ }
171
+ return out + resetEscape(level);
172
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Pure formatters for the TUI configuration panels (`/model`, `/models`,
3
+ * `/provider`, `/agents`, `/config`). Each returns plain string lines (color via
4
+ * chalk) so it can be unit-tested with an ANSI-stripping helper and reused by
5
+ * both the interactive REPL and one-shot commands.
6
+ */
7
+ import chalk from "chalk";
8
+ import type { ProviderStatus } from "../../ai/provider-status";
9
+ import type { SubagentRole } from "../../agent/subagents";
10
+ import type { ProviderModelsResult } from "../../ai/model-discovery";
11
+ import type { PickEntry } from "../../ai/model-picker";
12
+ import type { CatalogModel } from "../../ai/model-catalog";
13
+ import type { EnrichedModel } from "../../ai/model-enrich";
14
+ import { formatTokens } from "../../ai/model-catalog";
15
+
16
+ /** A single "Model: alias → resolved (provider)" status line. */
17
+ export function formatModelLine(d: {
18
+ label: string;
19
+ resolved: string;
20
+ provider: string;
21
+ ready?: boolean;
22
+ }): string {
23
+ const expansion = d.resolved !== d.label ? ` → ${d.resolved}` : "";
24
+ const readyMark = d.ready === undefined ? "" : d.ready ? ` ${chalk.green("✓")}` : ` ${chalk.yellow("· no credential")}`;
25
+ return `${chalk.bold(d.label)}${expansion} ${chalk.gray(`(${d.provider})`)}${readyMark}`;
26
+ }
27
+
28
+ /** Aliases section: ` alias → target` lines, sorted by alias. */
29
+ export function formatAliasLines(aliases: Record<string, string>): string[] {
30
+ const entries = Object.entries(aliases).sort((a, b) => a[0].localeCompare(b[0]));
31
+ if (entries.length === 0) return [" (no aliases)"];
32
+ const width = Math.max(...entries.map(([a]) => a.length), 4);
33
+ return entries.map(([alias, target]) => ` ${alias.padEnd(width)} → ${target}`);
34
+ }
35
+
36
+ /** Provider credential table: ` name ✓ label [baseUrl]`. */
37
+ export function formatProviderPanel(statuses: ProviderStatus[]): string[] {
38
+ if (statuses.length === 0) return [" (no providers)"];
39
+ const width = Math.max(...statuses.map(s => s.name.length), 6);
40
+ return statuses.map(s => {
41
+ const mark = s.ready ? chalk.green("✓") : chalk.gray("·");
42
+ const base = s.baseUrl ? chalk.gray(` [${s.baseUrl}]`) : "";
43
+ const label = s.ready ? s.label : chalk.yellow(s.label);
44
+ return ` ${s.name.padEnd(width)} ${mark} ${label}${base}`;
45
+ });
46
+ }
47
+
48
+ /** Subagent roster: ` id title — model ≤N steps (read-only)`. */
49
+ export function formatAgentsPanel(
50
+ roles: readonly SubagentRole[],
51
+ resolve: (role: SubagentRole) => { model: string; maxSteps: number },
52
+ ): string[] {
53
+ if (roles.length === 0) return [" (no subagent roles)"];
54
+ const width = Math.max(...roles.map(r => r.id.length), 8);
55
+ return roles.map(r => {
56
+ const { model, maxSteps } = resolve(r);
57
+ const ro = r.readOnly ? chalk.gray(" (read-only)") : "";
58
+ return ` ${chalk.cyan(r.id.padEnd(width))} ${r.title} — ${model} ≤${maxSteps} steps${ro}`;
59
+ });
60
+ }
61
+
62
+ /** Detail block for a single subagent role. */
63
+ export function formatAgentDetail(
64
+ role: SubagentRole,
65
+ resolved: { model: string; maxSteps: number },
66
+ ): string[] {
67
+ return [
68
+ `${chalk.cyan(role.id)} — ${role.title}`,
69
+ ` ${role.description}`,
70
+ ` model: ${resolved.model}`,
71
+ ` maxSteps: ${resolved.maxSteps}`,
72
+ ` mutates: ${role.readOnly ? "no (read-only: read/find/search only)" : "yes (full toolset)"}`,
73
+ ];
74
+ }
75
+
76
+ /** Effective runtime-config snapshot used by `/config`. */
77
+ export function formatConfigPanel(d: {
78
+ model: string;
79
+ resolved: string;
80
+ provider: string;
81
+ thinkingLevel: string;
82
+ ollamaBaseUrl?: string;
83
+ openaiBaseUrl?: string;
84
+ requestMaxRetries?: number;
85
+ sessionId?: string;
86
+ }): string[] {
87
+ const lines = [
88
+ ` model: ${formatModelLine({ label: d.model, resolved: d.resolved, provider: d.provider })}`,
89
+ ` thinking: ${d.thinkingLevel}`,
90
+ ];
91
+ if (d.ollamaBaseUrl) lines.push(` ollama: ${d.ollamaBaseUrl}`);
92
+ if (d.openaiBaseUrl) lines.push(` openai: ${d.openaiBaseUrl}`);
93
+ if (typeof d.requestMaxRetries === "number") lines.push(` retries: ${d.requestMaxRetries}`);
94
+ if (d.sessionId) lines.push(` session: ${d.sessionId}`);
95
+ return lines;
96
+ }
97
+
98
+ /**
99
+ * Live-discovered models grouped by provider. Each provider header shows the
100
+ * auth source or a failure reason; the active model id (if any) is marked.
101
+ */
102
+ export function formatLiveModels(
103
+ results: ProviderModelsResult[],
104
+ opts: { current?: string; perProvider?: number } = {},
105
+ ): string[] {
106
+ const cap = opts.perProvider ?? 12;
107
+ const lines: string[] = [];
108
+ for (const r of results) {
109
+ if (r.ok && r.models.length === 0) continue; // reachable but empty → skip the header noise
110
+ if (!r.ok) {
111
+ lines.push(`${chalk.bold(r.provider)} ${chalk.gray(`(${r.source})`)}: ${chalk.yellow(r.error ?? "unavailable")}`);
112
+ continue;
113
+ }
114
+ lines.push(`${chalk.bold(r.provider)} ${chalk.gray(`(${r.source})`)}: ${r.models.length} model${r.models.length === 1 ? "" : "s"}`);
115
+ for (const m of r.models.slice(0, cap)) {
116
+ const mark = opts.current && m === opts.current ? chalk.green(" ◀ current") : "";
117
+ lines.push(` ${m}${mark}`);
118
+ }
119
+ if (r.models.length > cap) lines.push(chalk.gray(` …(+${r.models.length - cap} more)`));
120
+ }
121
+ if (lines.length === 0) lines.push(" (no live models — log in with 'joc auth login' or start Ollama)");
122
+ return lines;
123
+ }
124
+
125
+ /** True when `model` appears in a provider's discovered list (exact match). */
126
+ export function liveModelKnown(results: ProviderModelsResult[], model: string): boolean {
127
+ return results.some(r => r.ok && r.models.includes(model));
128
+ }
129
+
130
+ /**
131
+ * Numbered pick list: ` #N model (provider)`. Select one with `/model #N`.
132
+ * The active model (if any) is marked.
133
+ */
134
+ export function formatPickList(entries: PickEntry[], opts: { current?: string; cap?: number } = {}): string[] {
135
+ if (entries.length === 0) return [" (no models — log in with 'joc auth login' or start Ollama)"];
136
+ const cap = opts.cap ?? 60;
137
+ const width = String(Math.min(entries.length, cap)).length + 1; // "#" + digits
138
+ const lines = entries.slice(0, cap).map(e => {
139
+ const tag = `#${e.index}`.padStart(width);
140
+ const mark = opts.current && e.model === opts.current ? chalk.green(" ◀ current") : "";
141
+ return ` ${chalk.yellow(tag)} ${e.model} ${chalk.gray(`(${e.provider})`)}${mark}`;
142
+ });
143
+ if (entries.length > cap) lines.push(chalk.gray(` …(+${entries.length - cap} more — narrow with /provider <name> or /search)`));
144
+ return lines;
145
+ }
146
+
147
+ function thinkCell(levels: string[]): string {
148
+ return levels.length ? levels.join(",") : "-";
149
+ }
150
+
151
+ /** Catalog table grouped by provider: provider · model · ctx · out · thinking · img. */
152
+ export function formatCatalogTable(models: CatalogModel[], opts: { current?: string } = {}): string[] {
153
+ if (models.length === 0) return [" (no catalog matches)"];
154
+ const pw = Math.max(...models.map(m => m.provider.length), 8);
155
+ const mw = Math.min(Math.max(...models.map(m => m.canonical.length), 6), 30);
156
+ const lines = [` ${"provider".padEnd(pw)} ${"model".padEnd(mw)} ${"ctx".padStart(5)} ${"out".padStart(5)} thinking img`];
157
+ for (const m of models) {
158
+ const mark = opts.current && (m.canonical === opts.current || m.providerModel === opts.current) ? chalk.green(" ◀") : "";
159
+ lines.push(
160
+ ` ${chalk.gray(m.provider.padEnd(pw))} ${m.canonical.padEnd(mw)} ${formatTokens(m.contextTokens).padStart(5)} ${formatTokens(m.maxOutputTokens).padStart(5)} ${chalk.cyan(thinkCell(m.thinking))} ${m.images ? "yes" : "no"}${mark}`,
161
+ );
162
+ }
163
+ return lines;
164
+ }
165
+
166
+ /** One-line capability summary for a single model, e.g. for `/model` output. */
167
+ export function formatCapabilityLine(m: CatalogModel): string {
168
+ return `${chalk.gray("caps:")} ctx ${formatTokens(m.contextTokens)} · out ${formatTokens(m.maxOutputTokens)} · thinking ${thinkCell(m.thinking)} · images ${m.images ? "yes" : "no"}`;
169
+ }
170
+
171
+ /**
172
+ * Live + catalog capability table: live (logged-in) models annotated with
173
+ * context/out/thinking/img when the catalog knows them, "-" otherwise.
174
+ */
175
+ export function formatEnrichedModels(models: EnrichedModel[], opts: { current?: string; cap?: number } = {}): string[] {
176
+ if (models.length === 0) return [" (no live models — log in with 'joc auth login' or start Ollama)"];
177
+ const cap = opts.cap ?? 50;
178
+ const shown = models.slice(0, cap);
179
+ const pw = Math.max(...shown.map(m => m.provider.length), 8);
180
+ const mw = Math.min(Math.max(...shown.map(m => m.id.length), 6), 36);
181
+ const lines = [` ${"provider".padEnd(pw)} ${"model".padEnd(mw)} ${"ctx".padStart(5)} ${"out".padStart(5)} thinking img`];
182
+ for (const m of shown) {
183
+ const ctx = m.meta ? formatTokens(m.meta.contextTokens) : "-";
184
+ const out = m.meta ? formatTokens(m.meta.maxOutputTokens) : "-";
185
+ const think = m.meta ? thinkCell(m.meta.thinking) : "?";
186
+ const img = m.meta ? (m.meta.images ? "yes" : "no") : "?";
187
+ const mark = opts.current && m.id === opts.current ? chalk.green(" ◀") : "";
188
+ const id = m.id.length > mw ? m.id.slice(0, mw - 1) + "…" : m.id.padEnd(mw);
189
+ lines.push(` ${chalk.gray(m.provider.padEnd(pw))} ${id} ${ctx.padStart(5)} ${out.padStart(5)} ${chalk.cyan(think)} ${img}${mark}`);
190
+ }
191
+ if (models.length > cap) lines.push(chalk.gray(` …(+${models.length - cap} more)`));
192
+ return lines;
193
+ }
@@ -0,0 +1,305 @@
1
+ import chalk from "chalk";
2
+
3
+ /**
4
+ * Canonical "evolution" model for the joc TUI (single source of truth).
5
+ *
6
+ * Every evolving surface — the ASCII art, the spinner, the progress meter, and
7
+ * the footer track — derives its stage from the functions and tables here, so a
8
+ * turn evolves in lockstep instead of each component drifting with its own
9
+ * threshold copy. Five stages map an agent's progress from a primordial cell to
10
+ * a singularity:
11
+ *
12
+ * 0 Primordial Cell → 1 Double Helix → 2 Tool User → 3 AI Coding Agent → 4 Singularity
13
+ */
14
+ export const EVOLUTION_STAGE_COUNT = 5;
15
+
16
+ export const EVOLUTION_STAGE_NAMES: readonly string[] = [
17
+ "Primordial Cell",
18
+ "Double Helix (DNA)",
19
+ "Tool User (Homo Habilis)",
20
+ "AI Coding Agent",
21
+ "Super intelligence (Singularity)",
22
+ ];
23
+
24
+ /** Per-stage accent color (chalk). Index-aligned with the stage tables. */
25
+ export const EVOLUTION_STAGE_COLORS: readonly ((s: string) => string)[] = [
26
+ s => chalk.cyan(s),
27
+ s => chalk.green(s),
28
+ s => chalk.yellow(s),
29
+ s => chalk.magenta(s),
30
+ s => chalk.blue(s),
31
+ ];
32
+
33
+ /** Spinner frame sets, one per evolution stage. */
34
+ export const EVOLUTION_SPINNER_FRAMES: readonly string[][] = [
35
+ [".", "..", "...", "....", "...", ".."],
36
+ ["\u2801", "\u2802", "\u2804", "\u2808", "\u2810", "\u2820"],
37
+ ["|", "/", "-", "\\"],
38
+ ["\u280b", "\u2819", "\u2839", "\u2838", "\u283c", "\u2834", "\u2826", "\u2827", "\u2807", "\u280f"],
39
+ ["\u25dc", "\u25dd", "\u25de", "\u25df"],
40
+ ];
41
+
42
+ /**
43
+ * ASCII-only spinner fallback, one set per stage, for terminals that cannot
44
+ * render braille/box-drawing (`supportsUnicode === false`). Index-aligned with
45
+ * `EVOLUTION_SPINNER_FRAMES`.
46
+ */
47
+ export const EVOLUTION_SPINNER_FRAMES_ASCII: readonly string[][] = [
48
+ [".", "..", "...", "....", "...", ".."],
49
+ ["-", "=", "~", "="],
50
+ ["|", "/", "-", "\\"],
51
+ ["[. ]", "[.. ]", "[...]", "[ ..]", "[ .]", "[ ]"],
52
+ ["+", "x", "*", "x"],
53
+ ];
54
+
55
+ /** Stage spinner frames honoring unicode capability (defaults to unicode). */
56
+ export function spinnerFramesFor(stageIndex: number, unicode = true): string[] {
57
+ const table = unicode ? EVOLUTION_SPINNER_FRAMES : EVOLUTION_SPINNER_FRAMES_ASCII;
58
+ return [...table[clampStageIndex(stageIndex)]!];
59
+ }
60
+
61
+ export interface MeterGlyphs {
62
+ fill: string;
63
+ empty: string;
64
+ color: (s: string) => string;
65
+ }
66
+
67
+ /** Progress-bar fill/empty glyphs + color, one per evolution stage. */
68
+ export const EVOLUTION_METER_GLYPHS: readonly MeterGlyphs[] = [
69
+ { fill: "o", empty: ".", color: chalk.cyan },
70
+ { fill: "x", empty: " ", color: chalk.green },
71
+ { fill: "=", empty: "-", color: chalk.yellow },
72
+ { fill: "#", empty: "-", color: chalk.magenta },
73
+ { fill: "\u2588", empty: "\u2591", color: chalk.blue },
74
+ ];
75
+
76
+ /** ASCII-only meter glyphs (stage 4's block glyphs swapped for `#`/`-`). */
77
+ export const EVOLUTION_METER_GLYPHS_ASCII: readonly MeterGlyphs[] = [
78
+ { fill: "o", empty: ".", color: chalk.cyan },
79
+ { fill: "x", empty: " ", color: chalk.green },
80
+ { fill: "=", empty: "-", color: chalk.yellow },
81
+ { fill: "#", empty: "-", color: chalk.magenta },
82
+ { fill: "#", empty: "-", color: chalk.blue },
83
+ ];
84
+
85
+ /** Stage meter glyphs honoring unicode capability (defaults to unicode). */
86
+ export function meterGlyphsFor(stageIndex: number, unicode = true): MeterGlyphs {
87
+ const table = unicode ? EVOLUTION_METER_GLYPHS : EVOLUTION_METER_GLYPHS_ASCII;
88
+ return table[clampStageIndex(stageIndex)]!;
89
+ }
90
+
91
+ /**
92
+ * Per-stage truecolor gradient palette (`from` → `to` hex), index-aligned with
93
+ * the stage tables. Drives smooth per-character gradients in the ASCII art when
94
+ * the terminal supports it; downgrades to 256/16/plain via `src/tui/components/color.ts`.
95
+ * The palette traces a cosmic arc: cyan tide → green helix → amber tools →
96
+ * magenta machine → white-hot singularity.
97
+ */
98
+ export interface StageGradient {
99
+ from: string;
100
+ to: string;
101
+ }
102
+
103
+ export const EVOLUTION_STAGE_GRADIENTS: readonly StageGradient[] = [
104
+ { from: "#0a3d62", to: "#48dbfb" }, // Primordial Cell — deep tide → bright cyan
105
+ { from: "#10ac84", to: "#7bed9f" }, // Double Helix — emerald → mint
106
+ { from: "#ff9f1a", to: "#feca57" }, // Tool User — amber → gold
107
+ { from: "#8e44ad", to: "#f368e0" }, // AI Coding Agent — violet → magenta
108
+ { from: "#5352ed", to: "#ffffff" }, // Singularity — indigo → white-hot
109
+ ];
110
+
111
+ /** Gradient palette for a stage index (clamped). */
112
+ export function stageGradient(index: number): StageGradient {
113
+ return EVOLUTION_STAGE_GRADIENTS[clampStageIndex(index)]!;
114
+ }
115
+
116
+ /** Clamp any index into the valid stage range [0, COUNT-1]. */
117
+ export function clampStageIndex(index: number): number {
118
+ if (!Number.isFinite(index)) return 0;
119
+ return Math.max(0, Math.min(EVOLUTION_STAGE_COUNT - 1, Math.trunc(index)));
120
+ }
121
+
122
+ /**
123
+ * Canonical stage for a discrete agent step against a step budget. Step 0 is
124
+ * always the primordial stage; thereafter progress is split into quartiles.
125
+ * Guards against non-finite / non-positive `maxSteps`.
126
+ */
127
+ export function stageIndexForStep(step: number, maxSteps: number): number {
128
+ if (!Number.isFinite(step) || step <= 0) return 0;
129
+ if (!Number.isFinite(maxSteps) || maxSteps <= 0) return 0;
130
+ const ratio = step / maxSteps;
131
+ if (ratio <= 0.25) return 1;
132
+ if (ratio <= 0.5) return 2;
133
+ if (ratio <= 0.75) return 3;
134
+ return 4;
135
+ }
136
+
137
+ /**
138
+ * Canonical stage for a continuous progress ratio in [0,1] (used by the meter,
139
+ * which measures generic completion rather than agent steps). Five equal-ish
140
+ * bands; non-finite ratios fall back to stage 0.
141
+ */
142
+ export function stageIndexForRatio(ratio: number): number {
143
+ if (!Number.isFinite(ratio)) return 0;
144
+ const r = Math.max(0, Math.min(1, ratio));
145
+ if (r <= 0.2) return 0;
146
+ if (r <= 0.4) return 1;
147
+ if (r <= 0.6) return 2;
148
+ if (r <= 0.8) return 3;
149
+ return 4;
150
+ }
151
+
152
+ /** Stage name for a discrete step (convenience for footers/summaries). */
153
+ export function evolutionStageName(step: number, maxSteps: number): string {
154
+ return EVOLUTION_STAGE_NAMES[stageIndexForStep(step, maxSteps)]!;
155
+ }
156
+
157
+ /**
158
+ * Render a compact evolution track, e.g. `●●●○○ Tool User (Homo Habilis) [3/5]`.
159
+ * The active marker is tinted with the stage color; pass `color: false` for
160
+ * plain (NO_COLOR / non-TTY) output.
161
+ */
162
+ export function evolutionTrack(
163
+ activeIndex: number,
164
+ opts: { color?: boolean; unicode?: boolean; ratio?: number } = {},
165
+ ): string {
166
+ const active = clampStageIndex(activeIndex);
167
+ const useColor = opts.color !== false;
168
+ const unicode = opts.unicode !== false;
169
+ const filled = unicode ? "\u25cf" : "#"; // ● / #
170
+ const empty = unicode ? "\u25cb" : "-"; // ○ / -
171
+ const half = unicode ? "\u25d0" : "+"; // ◐ / + (in-progress next stage)
172
+ // The next (not-yet-reached) stage shows a half marker while progress within
173
+ // the current stage is partway (continuous sub-stage feedback).
174
+ const ratio = opts.ratio;
175
+ const nextPartial = ratio !== undefined && Number.isFinite(ratio) && ratio > 0 && ratio < 1 ? active + 1 : -1;
176
+ let markers = "";
177
+ for (let i = 0; i < EVOLUTION_STAGE_COUNT; i++) {
178
+ const glyph = i <= active ? filled : i === nextPartial ? half : empty;
179
+ if (useColor && i === active) {
180
+ markers += EVOLUTION_STAGE_COLORS[i]!(glyph);
181
+ } else {
182
+ markers += glyph;
183
+ }
184
+ }
185
+ return `${markers} ${EVOLUTION_STAGE_NAMES[active]} [${active + 1}/${EVOLUTION_STAGE_COUNT}]`;
186
+ }
187
+
188
+ /**
189
+ * Monotonic stage progress: evolution should only move forward within a turn.
190
+ * `observe(step, maxSteps)` returns the highest stage seen so far, so a transient
191
+ * step drop (e.g. a retry resetting the counter) never visibly "devolves" the UI.
192
+ */
193
+ export interface StageProgress {
194
+ observe(step: number, maxSteps: number): number;
195
+ current(): number;
196
+ /** True iff the most recent `observe` increased the peak stage (a transition). */
197
+ advanced(): boolean;
198
+ reset(): void;
199
+ }
200
+
201
+ export function createStageProgress(): StageProgress {
202
+ let peak = 0;
203
+ let justAdvanced = false;
204
+ return {
205
+ observe(step: number, maxSteps: number): number {
206
+ const next = Math.max(peak, stageIndexForStep(step, maxSteps));
207
+ justAdvanced = next > peak;
208
+ peak = next;
209
+ return peak;
210
+ },
211
+ current() {
212
+ return peak;
213
+ },
214
+ advanced() {
215
+ return justAdvanced;
216
+ },
217
+ reset() {
218
+ peak = 0;
219
+ justAdvanced = false;
220
+ },
221
+ };
222
+ }
223
+ export const EVOLUTION_STAGE_TIPS: readonly string[] = [
224
+ "Primordial code is forming. Use /model to switch models if details are lost.",
225
+ "DNA replication in progress. Use /compact to clean up long context histories.",
226
+ "Tool use discovered! The agent can run read/write/edit/bash/find/search tools.",
227
+ "AI reasoning active. You can run workflow skills like deep-interview, ralplan, team, and ultragoal.",
228
+ "Cosmic singularity reached. Your agent has evolved to maximum intelligence.",
229
+ ];
230
+
231
+ export function getEvolutionTip(step: number, maxSteps: number): string {
232
+ const idx = stageIndexForStep(step, maxSteps);
233
+ return EVOLUTION_STAGE_TIPS[idx]!;
234
+ }
235
+ export const EVOLUTION_STAGE_STATUS_MESSAGES: readonly string[][] = [
236
+ ["Synthesizing primordial logic...", "Forming basic concepts...", "Replicating data structures..."],
237
+ ["Transcribing instructions...", "Binding code blocks...", "Mapping logic pathways..."],
238
+ ["Analyzing codebase structure...", "Grasping edit patterns...", "Executing tool commands..."],
239
+ ["Formulating execution plan...", "Refining syntax trees...", "Resolving type boundaries..."],
240
+ ["Achieving absolute consensus...", "Optimizing to zero entropy...", "Transcending human limits..."],
241
+ ];
242
+
243
+ export function getEvolutionStatusMessage(step: number, maxSteps: number, tickCount: number): string {
244
+ const stageIdx = stageIndexForStep(step, maxSteps);
245
+ const pool = EVOLUTION_STAGE_STATUS_MESSAGES[stageIdx]!;
246
+ return pool[tickCount % pool.length]!;
247
+ }
248
+
249
+ /**
250
+ * Fraction [0,1] of progress *within the current stage's band* — drives smooth
251
+ * sub-stage animation (e.g. a partially-filled "next stage" marker). Step 0 and
252
+ * non-positive/non-finite inputs yield 0; beyond budget yields 1.
253
+ */
254
+ export function stageProgressRatio(step: number, maxSteps: number): number {
255
+ if (!Number.isFinite(step) || step <= 0) return 0;
256
+ if (!Number.isFinite(maxSteps) || maxSteps <= 0) return 0;
257
+ const r = step / maxSteps;
258
+ if (r <= 0.25) return r / 0.25;
259
+ if (r <= 0.5) return (r - 0.25) / 0.25;
260
+ if (r <= 0.75) return (r - 0.5) / 0.25;
261
+ return Math.min(1, (r - 0.75) / 0.25);
262
+ }
263
+
264
+ /** Whole-turn completion ratio [0,1] (step against budget). */
265
+ export function overallProgress(step: number, maxSteps: number): number {
266
+ if (!Number.isFinite(step) || step <= 0) return 0;
267
+ if (!Number.isFinite(maxSteps) || maxSteps <= 0) return 0;
268
+ return Math.max(0, Math.min(1, step / maxSteps));
269
+ }
270
+
271
+ /** Name of the next stage (clamped at the final stage). */
272
+ export function nextStageName(step: number, maxSteps: number): string {
273
+ return EVOLUTION_STAGE_NAMES[clampStageIndex(stageIndexForStep(step, maxSteps) + 1)]!;
274
+ }
275
+
276
+ /**
277
+ * Whole steps remaining until the stage index increases. Returns 0 at the final
278
+ * stage. Bounded loop so a pathological `maxSteps` cannot spin forever.
279
+ */
280
+ export function stepsToNextStage(step: number, maxSteps: number): number {
281
+ const cur = stageIndexForStep(step, maxSteps);
282
+ if (cur >= EVOLUTION_STAGE_COUNT - 1) return 0;
283
+ let s = Math.max(0, Math.trunc(Number.isFinite(step) ? step : 0));
284
+ let n = 0;
285
+ const cap = Number.isFinite(maxSteps) && maxSteps > 0 ? maxSteps * 2 + 10 : 10;
286
+ while (stageIndexForStep(s, maxSteps) <= cur && n < cap) {
287
+ s++;
288
+ n++;
289
+ }
290
+ return n;
291
+ }
292
+
293
+ /** Per-stage transition message, shown when the agent first enters a stage. */
294
+ export const EVOLUTION_TRANSITION_MESSAGES: readonly string[] = [
295
+ "Spark of life — a primordial cell stirs.",
296
+ "Strands align — the double helix forms.",
297
+ "Tools in hand — the agent learns to build.",
298
+ "Cognition online — the AI coding agent awakens.",
299
+ "Singularity — intelligence transcends its bounds.",
300
+ ];
301
+
302
+ /** Transition message for a stage index (clamped). */
303
+ export function transitionMessage(index: number): string {
304
+ return EVOLUTION_TRANSITION_MESSAGES[clampStageIndex(index)]!;
305
+ }