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,340 @@
1
+ import chalk from "chalk";
2
+ import { stageIndexForStep, clampStageIndex, type StageGradient } from "./evolution";
3
+ import { applyGradient, hexToRgb, ColorLevel } from "./color";
4
+
5
+ export interface AsciiStage {
6
+ name: string;
7
+ art: string[];
8
+ color: (s: string) => string;
9
+ lineColors?: ((s: string) => string)[];
10
+ /**
11
+ * Optional animation frames (each a full art block). When present the live TUI
12
+ * cycles them by tick for a "breathing"/rotating effect; `art` is frame 0 and
13
+ * the fallback when `frames` is absent. Frames should match `art`'s line count.
14
+ */
15
+ frames?: string[][];
16
+ }
17
+
18
+ export const EVOLUTION_STAGES: AsciiStage[] = [
19
+ {
20
+ name: "Primordial Cell",
21
+ color: s => chalk.cyan(s),
22
+ art: [
23
+ " .---. ",
24
+ " / o o \\ ",
25
+ " \\ - / ",
26
+ " '---' ",
27
+ " [Primordial Cell]"
28
+ ],
29
+ lineColors: [
30
+ chalk.cyan,
31
+ chalk.cyan,
32
+ chalk.cyan,
33
+ chalk.cyan,
34
+ s => chalk.bold.cyan(s)
35
+ ],
36
+ // Pulsing membrane + nucleus (a primordial cell "breathing").
37
+ frames: [
38
+ [
39
+ " .---. ",
40
+ " / o o \\ ",
41
+ " \\ - / ",
42
+ " '---' ",
43
+ " [Primordial Cell]"
44
+ ],
45
+ [
46
+ " .===. ",
47
+ " / O O \\ ",
48
+ " \\ ~ / ",
49
+ " '===' ",
50
+ " [Primordial Cell]"
51
+ ]
52
+ ]
53
+ },
54
+ {
55
+ name: "Double Helix (DNA)",
56
+ color: s => chalk.green(s),
57
+ art: [
58
+ " \\ / ",
59
+ " \\/ ",
60
+ " /\\ ",
61
+ " / \\ ",
62
+ " \\ / ",
63
+ " \\/ ",
64
+ " /\\ ",
65
+ " [Double Helix] "
66
+ ],
67
+ lineColors: [
68
+ chalk.green,
69
+ chalk.cyan,
70
+ chalk.green,
71
+ chalk.cyan,
72
+ chalk.green,
73
+ chalk.cyan,
74
+ chalk.green,
75
+ s => chalk.bold.green(s)
76
+ ],
77
+ // Twisting double helix (diagonals flip to simulate rotation).
78
+ frames: [
79
+ [
80
+ " \\ / ",
81
+ " \\/ ",
82
+ " /\\ ",
83
+ " / \\ ",
84
+ " \\ / ",
85
+ " \\/ ",
86
+ " /\\ ",
87
+ " [Double Helix] "
88
+ ],
89
+ [
90
+ " \\/ ",
91
+ " /\\ ",
92
+ " / \\ ",
93
+ " \\ / ",
94
+ " \\/ ",
95
+ " /\\ ",
96
+ " / \\ ",
97
+ " [Double Helix] "
98
+ ],
99
+ [
100
+ " / \\ ",
101
+ " \\ / ",
102
+ " \\/ ",
103
+ " /\\ ",
104
+ " / \\ ",
105
+ " \\ / ",
106
+ " \\/ ",
107
+ " [Double Helix] "
108
+ ]
109
+ ]
110
+ },
111
+ {
112
+ name: "Tool User (Homo Habilis)",
113
+ color: s => chalk.yellow(s),
114
+ art: [
115
+ " .---. ",
116
+ " /| | |\\ ",
117
+ " | |_|_| | ",
118
+ " | ___ |---|",
119
+ " | | | | |",
120
+ " '-' '-' '-'",
121
+ " [Tool User] "
122
+ ],
123
+ lineColors: [
124
+ chalk.yellow,
125
+ chalk.yellow,
126
+ chalk.yellow,
127
+ s => s.replace("___", chalk.red("___")),
128
+ chalk.yellow,
129
+ chalk.yellow,
130
+ s => chalk.bold.yellow(s)
131
+ ]
132
+ },
133
+ {
134
+ name: "AI Coding Agent",
135
+ color: s => chalk.magenta(s),
136
+ art: [
137
+ " .-------. ",
138
+ " _|_ o o _|_",
139
+ " | | ^ | |",
140
+ " | | === | |",
141
+ " |___|_____|___|",
142
+ " / \\ ",
143
+ " [AI Coding Agent]"
144
+ ],
145
+ lineColors: [
146
+ chalk.magenta,
147
+ s => s.replace("o o", chalk.green("o o")),
148
+ chalk.magenta,
149
+ s => s.replace("===", chalk.cyan("===")),
150
+ chalk.magenta,
151
+ chalk.magenta,
152
+ s => chalk.bold.magenta(s)
153
+ ]
154
+ },
155
+ {
156
+ name: "Super intelligence (Singularity)",
157
+ color: s => chalk.blue(s),
158
+ art: [
159
+ " _ ____ ____ ",
160
+ " | |/ ___| / ___|",
161
+ " _ | | | | | ",
162
+ "| |_| | |___ | |___ ",
163
+ " \\___/ \\____| \\____|",
164
+ " [Singularity Era] "
165
+ ],
166
+ lineColors: [
167
+ chalk.red,
168
+ chalk.yellow,
169
+ chalk.green,
170
+ chalk.blue,
171
+ chalk.magenta,
172
+ s => chalk.bold.cyan(s)
173
+ ]
174
+ }
175
+ ];
176
+
177
+ /**
178
+ * Returns the evolutionary ASCII-art stage for an agent step against its budget.
179
+ * Delegates stage selection to the canonical evolution model so the art evolves
180
+ * in lockstep with the spinner, meter, and footer track. Guards out-of-range
181
+ * step/maxSteps via the canonical index math.
182
+ */
183
+ export function getEvolutionStage(step: number, maxSteps: number = 25): AsciiStage {
184
+ return EVOLUTION_STAGES[stageIndexForStep(step, maxSteps)]!;
185
+ }
186
+
187
+ /** Returns the ASCII-art stage for an explicit stage index (clamped). */
188
+ export function getStageByIndex(index: number): AsciiStage {
189
+ return EVOLUTION_STAGES[clampStageIndex(index)]!;
190
+ }
191
+
192
+ /** All art blocks for a stage (its animation `frames`, or `[art]` as a fallback). */
193
+ export function stageBlocks(stage: AsciiStage): string[][] {
194
+ return stage.frames && stage.frames.length > 0 ? stage.frames : [stage.art];
195
+ }
196
+
197
+ /** The art block for a given animation tick (wraps; falls back to `art`). */
198
+ export function stageFrame(stage: AsciiStage, tick = 0): string[] {
199
+ const blocks = stageBlocks(stage);
200
+ const t = Number.isFinite(tick) ? Math.trunc(tick) : 0;
201
+ const i = ((t % blocks.length) + blocks.length) % blocks.length;
202
+ return blocks[i]!;
203
+ }
204
+
205
+ /** Max art line count across all stages + frames (for stable block height). */
206
+ export function stageHeight(): number {
207
+ let h = 0;
208
+ for (const s of EVOLUTION_STAGES) for (const block of stageBlocks(s)) h = Math.max(h, block.length);
209
+ return h;
210
+ }
211
+
212
+ /** Max plain line width across all stages + frames (for clean right-edge alignment). */
213
+ export function stageWidth(): number {
214
+ let w = 0;
215
+ for (const s of EVOLUTION_STAGES) for (const block of stageBlocks(s)) for (const line of block) w = Math.max(w, line.length);
216
+ return w;
217
+ }
218
+
219
+ /** The bracketed caption line embedded in a stage's art, e.g. "[Tool User]". */
220
+ export function stageCaption(stage: AsciiStage): string | undefined {
221
+ return stage.art.find(line => /\[.+\]/.test(line))?.trim();
222
+ }
223
+
224
+ export interface RenderAsciiOptions {
225
+ /** Apply per-line / stage colors (default true). Pass false for NO_COLOR / plain. */
226
+ color?: boolean;
227
+ /** Right-pad every line to this plain width (default: this stage's max width). */
228
+ width?: number;
229
+ /** Bottom-pad the block to this many lines for a stable block height (default: no pad). */
230
+ height?: number;
231
+ /** Terminal width. If the terminal width is less than the art width, returns an empty block. */
232
+ cols?: number;
233
+ /** Whether to overlay synapses firing animation (random glowing dots). */
234
+ firing?: boolean;
235
+ /** Animation tick: selects a `stage.frames` block (wraps); default frame 0. */
236
+ frame?: number;
237
+ /**
238
+ * Paint each line with a left→right truecolor gradient (`from`→`to` hex),
239
+ * downgrading to 256/16/plain per `colorLevel`. Takes precedence over
240
+ * per-line colors; suppresses the `firing` overlay for a clean gradient.
241
+ */
242
+ gradient?: StageGradient;
243
+ /** Color tier for gradient rendering (default TrueColor). */
244
+ colorLevel?: ColorLevel;
245
+ }
246
+
247
+ /**
248
+ * Render a stage's ASCII art. Lines are right-padded to a uniform width (clean
249
+ * right edge) and the block is optionally bottom-padded to a uniform height so
250
+ * the live TUI never jumps as stages change. Color can be disabled for
251
+ * NO_COLOR / non-TTY / plain previews.
252
+ */
253
+ export function renderAsciiArt(stage: AsciiStage, opts: RenderAsciiOptions = {}): string[] {
254
+ const useColor = opts.color !== false;
255
+ const source = opts.frame !== undefined ? stageFrame(stage, opts.frame) : stage.art;
256
+ const width = opts.width ?? Math.max(0, ...source.map(l => l.length));
257
+ if (opts.cols !== undefined && opts.cols < width) {
258
+ return [];
259
+ }
260
+ const gradient = useColor ? opts.gradient : undefined;
261
+ const level = opts.colorLevel ?? ColorLevel.TrueColor;
262
+ const lines = source.map((line, idx) => {
263
+ let padded = line.length < width ? line + " ".repeat(width - line.length) : line;
264
+ if (gradient) {
265
+ return applyGradient(padded, hexToRgb(gradient.from), hexToRgb(gradient.to), level);
266
+ }
267
+ if (opts.firing && useColor) {
268
+ const spaceIdxs: number[] = [];
269
+ for (let i = 0; i < padded.length; i++) {
270
+ if (padded[i] === " ") {
271
+ spaceIdxs.push(i);
272
+ }
273
+ }
274
+ if (spaceIdxs.length > 0) {
275
+ const chars = ["*", ".", "o", "+", "\u2727"];
276
+ const numSparks = Math.min(2, Math.floor(Math.random() * 3));
277
+ const arr = padded.split("");
278
+ for (let s = 0; s < numSparks; s++) {
279
+ const randSpace = spaceIdxs[Math.floor(Math.random() * spaceIdxs.length)];
280
+ const randChar = chars[Math.floor(Math.random() * chars.length)];
281
+ arr[randSpace] = chalk.yellow.bold(randChar);
282
+ }
283
+ padded = arr.join("");
284
+ }
285
+ }
286
+ if (!useColor) return padded;
287
+ if (stage.lineColors && stage.lineColors[idx]) return stage.lineColors[idx]!(padded);
288
+ return stage.color(padded);
289
+ });
290
+ if (opts.height && lines.length < opts.height) {
291
+ const blank = " ".repeat(width);
292
+ while (lines.length < opts.height) lines.push(blank);
293
+ }
294
+ return lines;
295
+ }
296
+
297
+ export interface AnimateAsciiOptions extends RenderAsciiOptions {
298
+ delayMs?: number;
299
+ write?: (s: string) => void;
300
+ sleep?: (ms: number) => Promise<void>;
301
+ }
302
+
303
+ /** Stream a stage's art line-by-line. `write`/`sleep` are injectable for tests. */
304
+ export async function animateAsciiArt(stage: AsciiStage, opts: AnimateAsciiOptions = {}): Promise<void> {
305
+ const delayMs = opts.delayMs ?? 60;
306
+ const write = opts.write ?? ((s: string) => process.stdout.write(s));
307
+ const sleep = opts.sleep ?? ((ms: number) => Bun.sleep(ms));
308
+ for (const line of renderAsciiArt(stage, opts)) {
309
+ write(line + "\n");
310
+ if (delayMs > 0) await sleep(delayMs);
311
+ }
312
+ }
313
+
314
+ export interface AnimateFramesOptions extends RenderAsciiOptions {
315
+ /** Frames to play across (default = the stage's frame count). */
316
+ frames?: number;
317
+ /** Delay between frames in ms (default 120). */
318
+ frameDelayMs?: number;
319
+ write?: (s: string) => void;
320
+ sleep?: (ms: number) => Promise<void>;
321
+ }
322
+
323
+ /**
324
+ * Play a stage's animation frames in place by clearing and redrawing the block
325
+ * `frames` times. Returns the number of frames drawn. `write`/`sleep` are
326
+ * injectable so tests can run with zero delay.
327
+ */
328
+ export async function animateFrames(stage: AsciiStage, opts: AnimateFramesOptions = {}): Promise<number> {
329
+ const write = opts.write ?? ((s: string) => process.stdout.write(s));
330
+ const sleep = opts.sleep ?? ((ms: number) => Bun.sleep(ms));
331
+ const delay = opts.frameDelayMs ?? 120;
332
+ const total = Math.max(1, opts.frames ?? stageBlocks(stage).length);
333
+ const height = opts.height ?? stageHeight();
334
+ for (let f = 0; f < total; f++) {
335
+ const block = renderAsciiArt(stage, { ...opts, frame: f, height });
336
+ write(block.join("\n") + "\n");
337
+ if (delay > 0 && f < total - 1) await sleep(delay);
338
+ }
339
+ return total;
340
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Interactive autocomplete engine for the REPL.
3
+ *
4
+ * Completes slash-command *names* and their *arguments*:
5
+ * - `/mod` → `/model`, `/models`
6
+ * - `/model gpt` → live (logged-in) model ids + aliases + catalog ids
7
+ * - `/provider an` → provider names; second arg → that provider's live models
8
+ * - `/agents exec` → subagent role ids; second arg → live model ids
9
+ * - `/thinking h` → low/medium/high
10
+ *
11
+ * Pure + synchronous: the dynamic data (live models from the OAuth-authenticated
12
+ * accounts, alias snapshot) is passed in via `CompletionContext`, so the readline
13
+ * completer never blocks on the network. Static data (slash names, catalog ids,
14
+ * provider names, role ids) is filled by `staticCompletionContext()`.
15
+ */
16
+ import { SLASH_COMMANDS } from "./slash";
17
+ import { catalogIds } from "../../ai/model-catalog-compat";
18
+ import { PROVIDER_NAMES } from "../../ai/provider-status";
19
+ import { SUBAGENT_ROLES } from "../../agent/subagents";
20
+
21
+ export interface CompletionContext {
22
+ slashCommands: string[];
23
+ /** Flattened live model ids discovered from logged-in providers (cache). */
24
+ liveModels: string[];
25
+ /** Alias names (e.g. fast/sonnet/gpt). */
26
+ aliases: string[];
27
+ /** Curated catalog model ids. */
28
+ catalogModels: string[];
29
+ providers: string[];
30
+ roleIds: string[];
31
+ thinkingLevels: string[];
32
+ /** Live model ids for a given provider (for `/provider <p> <model>`). */
33
+ modelsForProvider: (provider: string) => string[];
34
+ }
35
+
36
+ export interface CompletionResult {
37
+ /** Candidate completions for `token`, ranked, de-duplicated, capped. */
38
+ completions: string[];
39
+ /** The substring being completed (what readline should replace). */
40
+ token: string;
41
+ /** What was completed: command | model | provider | role | thinking | subcommand | none. */
42
+ kind: string;
43
+ }
44
+
45
+ const MAX_COMPLETIONS = 50;
46
+ const THINKING_LEVELS = ["low", "medium", "high"];
47
+
48
+ /** Static half of a completion context (no network/config needed). */
49
+ export function staticCompletionContext(): Omit<CompletionContext, "liveModels" | "aliases" | "modelsForProvider"> {
50
+ return {
51
+ slashCommands: [...SLASH_COMMANDS],
52
+ catalogModels: catalogIds(),
53
+ providers: [...PROVIDER_NAMES],
54
+ roleIds: SUBAGENT_ROLES.map(r => r.id),
55
+ thinkingLevels: [...THINKING_LEVELS],
56
+ };
57
+ }
58
+
59
+ /** Tokenize a line into words + whether it ends with whitespace (→ completing a new token). */
60
+ export function tokenize(line: string): { tokens: string[]; trailingSpace: boolean } {
61
+ const trailingSpace = /\s$/.test(line);
62
+ const tokens = line.split(/\s+/).filter(t => t.length > 0);
63
+ return { tokens, trailingSpace };
64
+ }
65
+
66
+ function prefixHits(pool: string[], token: string): string[] {
67
+ const q = token.toLowerCase();
68
+ return pool.filter(c => c.toLowerCase().startsWith(q));
69
+ }
70
+
71
+ /** De-duplicate (case-insensitive, first wins), preserving order, capped. */
72
+ function dedupeCap(items: string[], cap = MAX_COMPLETIONS): string[] {
73
+ const seen = new Set<string>();
74
+ const out: string[] = [];
75
+ for (const it of items) {
76
+ const k = it.toLowerCase();
77
+ if (seen.has(k)) continue;
78
+ seen.add(k);
79
+ out.push(it);
80
+ if (out.length >= cap) break;
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /** Rank model candidates: live (logged-in) first, then aliases, then catalog. */
86
+ function rankedModelPool(ctx: CompletionContext): string[] {
87
+ return dedupeCap([...ctx.liveModels, ...ctx.aliases, ...ctx.catalogModels], Number.MAX_SAFE_INTEGER);
88
+ }
89
+
90
+ /**
91
+ * Compute completions for the current input line. Returns an empty list for
92
+ * non-slash input (free-text prompts are not completed).
93
+ */
94
+ export function complete(line: string, ctx: CompletionContext): CompletionResult {
95
+ if (!line.startsWith("/")) return { completions: [], token: line, kind: "none" };
96
+
97
+ const { tokens, trailingSpace } = tokenize(line);
98
+ // Completing the command name itself (single token, still typing it).
99
+ if (tokens.length <= 1 && !trailingSpace) {
100
+ const token = tokens[0] ?? "/";
101
+ return { completions: dedupeCap(prefixHits(ctx.slashCommands, token)), token, kind: "command" };
102
+ }
103
+
104
+ const cmd = tokens[0]!.toLowerCase();
105
+ // Token currently being completed (empty when the line ends with a space).
106
+ const token = trailingSpace ? "" : tokens[tokens.length - 1]!;
107
+ // 0-based index of the argument being completed.
108
+ const argIndex = (trailingSpace ? tokens.length : tokens.length - 1) - 1;
109
+
110
+ const finish = (pool: string[], kind: string): CompletionResult => ({
111
+ completions: dedupeCap(prefixHits(pool, token)),
112
+ token,
113
+ kind,
114
+ });
115
+
116
+ switch (cmd) {
117
+ case "/model": {
118
+ if (token.startsWith("#")) return { completions: [], token, kind: "none" }; // numbered pick
119
+ if (argIndex === 0) return finish(["save", ...rankedModelPool(ctx)], "model");
120
+ // `/model save <id>` second arg → models
121
+ if (argIndex === 1 && tokens[1]?.toLowerCase() === "save") return finish(rankedModelPool(ctx), "model");
122
+ return { completions: [], token, kind: "none" };
123
+ }
124
+ case "/models":
125
+ return argIndex === 0 ? finish(["refresh"], "subcommand") : { completions: [], token, kind: "none" };
126
+ case "/provider": {
127
+ if (argIndex === 0) return finish(ctx.providers, "provider");
128
+ if (argIndex === 1) return finish(ctx.modelsForProvider(tokens[1] ?? ""), "model");
129
+ return { completions: [], token, kind: "none" };
130
+ }
131
+ case "/agents": {
132
+ if (argIndex === 0) return finish(ctx.roleIds, "role");
133
+ if (argIndex === 1) return finish(["maxSteps", ...rankedModelPool(ctx)], "model");
134
+ if (argIndex === 2 && tokens[2]?.toLowerCase() === "maxsteps") return { completions: [], token, kind: "none" };
135
+ return { completions: [], token, kind: "none" };
136
+ }
137
+ case "/thinking":
138
+ return argIndex === 0 ? finish(ctx.thinkingLevels, "thinking") : { completions: [], token, kind: "none" };
139
+ default:
140
+ return { completions: [], token, kind: "none" };
141
+ }
142
+ }
143
+
144
+ /** Longest common prefix of a list (for tab "fill to ambiguity"). */
145
+ export function commonPrefix(items: string[]): string {
146
+ if (items.length === 0) return "";
147
+ let prefix = items[0]!;
148
+ for (const s of items.slice(1)) {
149
+ let i = 0;
150
+ while (i < prefix.length && i < s.length && prefix[i]!.toLowerCase() === s[i]!.toLowerCase()) i++;
151
+ prefix = prefix.slice(0, i);
152
+ if (!prefix) break;
153
+ }
154
+ return prefix;
155
+ }
156
+
157
+ /**
158
+ * Adapter for Node/Bun `readline` completer contract: returns
159
+ * `[completions, tokenBeingReplaced]`. When nothing matches, returns the empty
160
+ * hit list with the whole line so readline leaves the input untouched.
161
+ */
162
+ export function readlineCompleter(line: string, ctx: CompletionContext): [string[], string] {
163
+ const r = complete(line, ctx);
164
+ return [r.completions, r.completions.length ? r.token : line];
165
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Terminal unicode capability detection for the evolution TUI.
3
+ *
4
+ * The art is ASCII, but the spinner (braille), meter (block glyphs), and track
5
+ * (●/○) use unicode. On a terminal that cannot render them (legacy `linux`
6
+ * console, `dumb`, or a non-UTF locale) we fall back to ASCII-only glyph sets.
7
+ * Pure + injectable so tests can pin the decision.
8
+ */
9
+ import type { EnvLike } from "./color";
10
+
11
+ /**
12
+ * Whether the terminal can render the unicode glyphs the TUI uses. Heuristics:
13
+ * - `TERM=dumb`/`linux` → no (legacy console fonts lack braille).
14
+ * - any locale var (`LC_ALL`/`LC_CTYPE`/`LANG`) mentioning UTF → yes.
15
+ * - a locale var set but NOT mentioning UTF → no.
16
+ * - nothing set → default yes (modern emulators are UTF-8).
17
+ */
18
+ export function supportsUnicode(env: EnvLike = process.env): boolean {
19
+ const term = (env.TERM ?? "").toLowerCase();
20
+ if (term === "dumb" || term === "linux") return false;
21
+
22
+ const locale = env.LC_ALL ?? env.LC_CTYPE ?? env.LANG;
23
+ if (locale !== undefined && locale !== "") {
24
+ return /utf-?8/i.test(locale);
25
+ }
26
+
27
+ // No locale signal — assume a modern UTF-8 terminal.
28
+ return true;
29
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Code view (코드뷰) — pure formatters that render file content and diffs inside
3
+ * the TUI with a line-number gutter, ANSI-aware width clamping, light
4
+ * language-aware coloring, and a bounded line budget. Used by the `/view` and
5
+ * `/diff` slash commands. Everything is a pure function over strings so it can be
6
+ * unit-tested with an ANSI-stripping helper.
7
+ */
8
+ import chalk from "chalk";
9
+ import { truncate } from "../terminal";
10
+
11
+ const LANG_BY_EXT: Record<string, string> = {
12
+ ts: "ts", tsx: "ts", mts: "ts", cts: "ts",
13
+ js: "js", jsx: "js", mjs: "js", cjs: "js",
14
+ json: "json", jsonc: "json",
15
+ md: "md", markdown: "md",
16
+ py: "py", sh: "sh", bash: "sh", zsh: "sh",
17
+ yml: "yaml", yaml: "yaml", toml: "toml",
18
+ css: "css", html: "html", rs: "rust", go: "go",
19
+ };
20
+
21
+ /** Line-comment token per language (used for whole-line comment dimming). */
22
+ const COMMENT_TOKEN: Record<string, string> = {
23
+ ts: "//", js: "//", rust: "//", go: "//", css: "/*",
24
+ py: "#", sh: "#", yaml: "#", toml: "#",
25
+ };
26
+
27
+ const KEYWORDS = new Set([
28
+ "import", "export", "from", "const", "let", "var", "function", "return", "class",
29
+ "interface", "type", "if", "else", "for", "while", "await", "async", "new",
30
+ "try", "catch", "finally", "throw", "def", "fn", "pub", "struct", "enum", "impl",
31
+ ]);
32
+
33
+ /** Map a file path to a language id for highlighting. Unknown → "". */
34
+ export function detectLanguage(filePath: string): string {
35
+ const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
36
+ return LANG_BY_EXT[ext] ?? "";
37
+ }
38
+
39
+ export function languageLabel(lang: string): string {
40
+ return lang || "text";
41
+ }
42
+
43
+ export interface ParsedRange {
44
+ start: number;
45
+ end?: number;
46
+ }
47
+
48
+ /** Parse "start-end" / "start-" / "start" into 1-based bounds. null when invalid. */
49
+ export function parseLineRange(spec: string): ParsedRange | null {
50
+ const m = spec.trim().match(/^(\d+)(?:-(\d+)?)?$/);
51
+ if (!m) return null;
52
+ const start = Math.max(1, parseInt(m[1], 10));
53
+ if (m[2]) {
54
+ const end = parseInt(m[2], 10);
55
+ if (end < start) return null;
56
+ return { start, end };
57
+ }
58
+ // "start-" → open-ended; "start" → single line.
59
+ return spec.includes("-") ? { start } : { start, end: start };
60
+ }
61
+
62
+ /** Slice content by 1-based [start,end]; returns { lines, startLine }. */
63
+ export function sliceLines(content: string, range?: ParsedRange): { lines: string[]; startLine: number } {
64
+ const all = content.split("\n");
65
+ if (!range) return { lines: all, startLine: 1 };
66
+ const start = Math.min(Math.max(1, range.start), all.length || 1);
67
+ const end = range.end ? Math.min(range.end, all.length) : all.length;
68
+ return { lines: all.slice(start - 1, end), startLine: start };
69
+ }
70
+
71
+ /** Conservative, single-pass light highlight: whole-line comments, then string literals. */
72
+ export function lightHighlightLine(line: string, lang: string): string {
73
+ const token = COMMENT_TOKEN[lang];
74
+ if (token && line.trimStart().startsWith(token)) return chalk.gray(line);
75
+ // String literals (double / single / backtick), non-greedy, no escapes handling.
76
+ let out = line.replace(/(["'`])(?:\\.|(?!\1).)*\1/g, m => chalk.green(m));
77
+ if (out !== line) return out;
78
+ // No strings → keyword pass on word boundaries.
79
+ out = line.replace(/\b[A-Za-z_]+\b/g, w => (KEYWORDS.has(w) ? chalk.cyan(w) : w));
80
+ return out;
81
+ }
82
+
83
+ export interface CodeViewOptions {
84
+ startLine?: number;
85
+ lang?: string;
86
+ cols?: number;
87
+ maxLines?: number;
88
+ /** 1-based absolute line numbers to mark in the gutter. */
89
+ highlight?: number[];
90
+ /** Enable light coloring (default true). */
91
+ color?: boolean;
92
+ /** Gutter separator glyph (default "│"). */
93
+ sep?: string;
94
+ }
95
+
96
+ /** Render code with a right-aligned line-number gutter, clamped to `cols`. */
97
+ export function formatCodeBlock(content: string, opts: CodeViewOptions = {}): string[] {
98
+ const startLine = opts.startLine ?? 1;
99
+ const cols = opts.cols ?? 100;
100
+ const maxLines = opts.maxLines ?? 200;
101
+ const sep = opts.sep ?? "│";
102
+ const color = opts.color !== false;
103
+ const lang = opts.lang ?? "";
104
+ const highlight = new Set(opts.highlight ?? []);
105
+
106
+ const allLines = content.split("\n");
107
+ const shown = allLines.slice(0, maxLines);
108
+ const lastNo = startLine + shown.length - 1;
109
+ const gutterW = Math.max(String(lastNo).length, 2);
110
+
111
+ const out: string[] = [];
112
+ for (let i = 0; i < shown.length; i++) {
113
+ const no = startLine + i;
114
+ const marked = highlight.has(no);
115
+ const numText = String(no).padStart(gutterW);
116
+ const num = color ? (marked ? chalk.yellow.bold(numText) : chalk.gray(numText)) : numText;
117
+ const body = color ? lightHighlightLine(shown[i], lang) : shown[i];
118
+ const marker = marked ? (color ? chalk.yellow("▶") : ">") : " ";
119
+ const line = `${marker}${num} ${sep} ${body}`;
120
+ out.push(truncate(line, cols));
121
+ }
122
+ if (allLines.length > maxLines) {
123
+ const more = allLines.length - maxLines;
124
+ out.push(color ? chalk.gray(` …(+${more} more line${more === 1 ? "" : "s"})`) : ` …(+${more} more lines)`);
125
+ }
126
+ return out;
127
+ }
128
+
129
+ /** Render a unified diff with +/-/@@ coloring, clamped to `cols`. */
130
+ export function formatDiff(diffText: string, opts: { cols?: number; maxLines?: number; color?: boolean } = {}): string[] {
131
+ const cols = opts.cols ?? 100;
132
+ const maxLines = opts.maxLines ?? 400;
133
+ const color = opts.color !== false;
134
+ const lines = diffText.split("\n");
135
+ const shown = lines.slice(0, maxLines);
136
+ const out = shown.map(l => {
137
+ if (!color) return truncate(l, cols);
138
+ if (l.startsWith("+++") || l.startsWith("---")) return truncate(chalk.bold(l), cols);
139
+ if (l.startsWith("@@")) return truncate(chalk.cyan(l), cols);
140
+ if (l.startsWith("+")) return truncate(chalk.green(l), cols);
141
+ if (l.startsWith("-")) return truncate(chalk.red(l), cols);
142
+ return truncate(l, cols);
143
+ });
144
+ if (lines.length > maxLines) out.push(color ? chalk.gray(` …(+${lines.length - maxLines} more)`) : ` …(+${lines.length - maxLines} more)`);
145
+ return out;
146
+ }