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,199 @@
1
+ /**
2
+ * Generic keyboard-navigable selection list for the TUI (model/provider pickers).
3
+ *
4
+ * Split into a pure state machine (`SelectList`) and a pure renderer
5
+ * (`renderSelectList`) so the picker logic is fully unit-testable without a real
6
+ * TTY. The renderer is viewport-aware: it shows a scrolling window of `rows`
7
+ * around the cursor and fits each line to `cols`, so long lists never overflow
8
+ * the terminal height/width.
9
+ */
10
+ import chalk from "chalk";
11
+ import { padLineTo } from "./layout";
12
+ import { visibleWidth } from "./color";
13
+
14
+ export interface SelectItem<T> {
15
+ value: T;
16
+ label: string;
17
+ /** Optional group header shown above the first item of each group. */
18
+ group?: string;
19
+ /** Disabled items are shown dimmed and skipped by cursor navigation. */
20
+ disabled?: boolean;
21
+ /** Optional right-aligned hint/badge (e.g. "✓ ready · 200k"). */
22
+ hint?: string;
23
+ }
24
+
25
+ export class SelectList<T> {
26
+ private readonly items: SelectItem<T>[];
27
+ private query = "";
28
+ private cursor = 0; // index into the *visible* list
29
+
30
+ constructor(items: SelectItem<T>[]) {
31
+ this.items = items;
32
+ this.cursor = this.firstEnabled(this.computeVisible());
33
+ }
34
+
35
+ /** Items matching the current filter (case-insensitive substring on label). */
36
+ visible(): SelectItem<T>[] {
37
+ return this.computeVisible();
38
+ }
39
+
40
+ private computeVisible(): SelectItem<T>[] {
41
+ const q = this.query.trim().toLowerCase();
42
+ if (!q) return this.items;
43
+ return this.items.filter(i => i.label.toLowerCase().includes(q) || (i.group ?? "").toLowerCase().includes(q));
44
+ }
45
+
46
+ private firstEnabled(list: SelectItem<T>[]): number {
47
+ const i = list.findIndex(it => !it.disabled);
48
+ return i < 0 ? 0 : i;
49
+ }
50
+
51
+ /** Current cursor index within the visible list (clamped). */
52
+ cursorIndex(): number {
53
+ const n = this.computeVisible().length;
54
+ if (n === 0) return 0;
55
+ return Math.max(0, Math.min(this.cursor, n - 1));
56
+ }
57
+
58
+ isEmpty(): boolean {
59
+ return this.computeVisible().length === 0;
60
+ }
61
+
62
+ /** The currently selected item (skips when empty / all disabled). */
63
+ selected(): SelectItem<T> | undefined {
64
+ const list = this.computeVisible();
65
+ const item = list[this.cursorIndex()];
66
+ return item && !item.disabled ? item : undefined;
67
+ }
68
+
69
+ /** Set the filter query; cursor jumps to the first enabled match. */
70
+ setFilter(query: string): void {
71
+ this.query = query;
72
+ this.cursor = this.firstEnabled(this.computeVisible());
73
+ }
74
+
75
+ filter(): string {
76
+ return this.query;
77
+ }
78
+
79
+ /** Append a character to the filter (typing). */
80
+ typeChar(ch: string): void {
81
+ this.setFilter(this.query + ch);
82
+ }
83
+
84
+ /** Remove the last filter character (backspace). */
85
+ backspace(): void {
86
+ this.setFilter(this.query.slice(0, -1));
87
+ }
88
+
89
+ private step(dir: 1 | -1): void {
90
+ const list = this.computeVisible();
91
+ const n = list.length;
92
+ if (n === 0) return;
93
+ let i = this.cursorIndex();
94
+ for (let tries = 0; tries < n; tries++) {
95
+ i = (i + dir + n) % n;
96
+ if (!list[i]!.disabled) break;
97
+ }
98
+ this.cursor = i;
99
+ }
100
+
101
+ up(): void {
102
+ this.step(-1);
103
+ }
104
+ down(): void {
105
+ this.step(1);
106
+ }
107
+
108
+ /** Move by a page (clamped, no wrap), landing on an enabled item. */
109
+ page(dir: 1 | -1, size = 5): void {
110
+ const list = this.computeVisible();
111
+ const n = list.length;
112
+ if (n === 0) return;
113
+ let i = Math.max(0, Math.min(n - 1, this.cursorIndex() + dir * Math.max(1, size)));
114
+ // settle onto the nearest enabled item in the travel direction
115
+ while (i >= 0 && i < n && list[i]!.disabled) i += dir;
116
+ if (i < 0 || i >= n) i = this.firstEnabled(list);
117
+ this.cursor = i;
118
+ }
119
+ }
120
+
121
+ export interface RenderSelectOptions {
122
+ /** Title line shown above the list. */
123
+ title?: string;
124
+ /** Max body rows for the scrolling window (default 10). */
125
+ rows?: number;
126
+ /** Total width to fit each line to (default: natural). */
127
+ cols?: number;
128
+ /** Use unicode glyphs for the cursor/markers (default true). */
129
+ unicode?: boolean;
130
+ /** Apply chalk color (default true). */
131
+ color?: boolean;
132
+ }
133
+
134
+ /**
135
+ * Render a `SelectList` to lines: optional title, a scrolling window of items
136
+ * with the cursor highlighted, group headers, right-aligned hints, and a footer
137
+ * with the active filter + key hints. Pure — no I/O.
138
+ */
139
+ export function renderSelectList<T>(list: SelectList<T>, opts: RenderSelectOptions = {}): string[] {
140
+ const unicode = opts.unicode !== false;
141
+ const color = opts.color !== false;
142
+ const rows = Math.max(1, opts.rows ?? 10);
143
+ const cols = opts.cols;
144
+ const pointer = unicode ? "\u276f" : ">"; // ❯ / >
145
+ const tint = (s: string, fn: (x: string) => string) => (color ? fn(s) : s);
146
+
147
+ const out: string[] = [];
148
+ if (opts.title) out.push(tint(opts.title, chalk.bold));
149
+
150
+ const items = list.visible();
151
+ if (items.length === 0) {
152
+ out.push(tint(" (no matches)", chalk.gray));
153
+ } else {
154
+ const cur = list.cursorIndex();
155
+ // Scrolling window centered-ish on the cursor.
156
+ let start = Math.max(0, cur - Math.floor(rows / 2));
157
+ start = Math.min(start, Math.max(0, items.length - rows));
158
+ const end = Math.min(items.length, start + rows);
159
+
160
+ if (start > 0) out.push(tint(` \u2191 ${start} more`, chalk.gray));
161
+ let lastGroup: string | undefined;
162
+ for (let i = start; i < end; i++) {
163
+ const it = items[i]!;
164
+ if (it.group && it.group !== lastGroup) {
165
+ out.push(tint(` ${it.group}`, chalk.gray));
166
+ lastGroup = it.group;
167
+ }
168
+ const isCur = i === cur;
169
+ const marker = isCur ? tint(pointer, chalk.cyan) : " ";
170
+ let label = it.disabled ? tint(it.label, chalk.gray) : isCur ? tint(it.label, chalk.cyan.bold) : it.label;
171
+ let line = `${marker} ${label}`;
172
+ if (it.hint) {
173
+ const hint = tint(it.hint, chalk.gray);
174
+ if (cols) {
175
+ // right-align the hint within cols
176
+ const used = visibleWidth(line) + visibleWidth(hint) + 1;
177
+ const gap = Math.max(1, cols - used);
178
+ line = `${line}${" ".repeat(gap)}${hint}`;
179
+ } else {
180
+ line = `${line} ${hint}`;
181
+ }
182
+ }
183
+ out.push(cols ? clampToCols(line, cols) : line);
184
+ }
185
+ if (end < items.length) out.push(tint(` \u2193 ${items.length - end} more`, chalk.gray));
186
+ }
187
+
188
+ const q = list.filter();
189
+ const filterPart = q ? `filter: ${q}` : "type to filter";
190
+ const keys = unicode ? "\u2191/\u2193 move \u00b7 enter select \u00b7 esc cancel" : "up/down move . enter select . esc cancel";
191
+ out.push(tint(` ${filterPart} \u2014 ${keys}`, chalk.gray));
192
+ return out;
193
+ }
194
+
195
+ /** Fit a (possibly colored) line to cols by visible width, preserving the right hint. */
196
+ function clampToCols(line: string, cols: number): string {
197
+ if (visibleWidth(line) <= cols) return padLineTo(line, cols, "left");
198
+ return line; // caller-built hint lines are already gap-fitted; leave longer plain lines to the renderer truncate
199
+ }
@@ -0,0 +1,34 @@
1
+ /** Slash-command palette/autocomplete for the interactive REPL (TUI M3). */
2
+
3
+ export const SLASH_COMMANDS = [
4
+ "/help",
5
+ "/clear",
6
+ "/compact",
7
+ "/model",
8
+ "/models",
9
+ "/provider",
10
+ "/agents",
11
+ "/config",
12
+ "/roles",
13
+ "/thinking",
14
+ "/view",
15
+ "/diff",
16
+ "/find",
17
+ "/search",
18
+ "/sessions",
19
+ "/evolve",
20
+ "/exit",
21
+ "/quit",
22
+ ];
23
+
24
+ /** Return the slash commands that prefix-match `input` (case-insensitive). Empty for non-slash input. */
25
+ export function matchSlash(input: string, commands: string[] = SLASH_COMMANDS): string[] {
26
+ if (!input.startsWith("/")) return [];
27
+ const q = input.toLowerCase();
28
+ return commands.filter(c => c.startsWith(q));
29
+ }
30
+
31
+ /** True when `input` looks like a slash command (starts with "/" and has no space). */
32
+ export function isSlashAttempt(input: string): boolean {
33
+ return input.startsWith("/") && !input.slice(1).includes(" ");
34
+ }
@@ -0,0 +1,49 @@
1
+ import { spinnerFramesFor, stageIndexForStep, clampStageIndex } from "./evolution";
2
+
3
+ /**
4
+ * Stage-aware spinner. Frames evolve with the agent's step against its budget,
5
+ * sourced from the canonical evolution model so the spinner stays in lockstep
6
+ * with the ASCII art, meter, and footer track.
7
+ */
8
+ export class Spinner {
9
+ private defaultFrames: string[];
10
+ private frames: string[];
11
+ private index: number = 0;
12
+ private unicode: boolean;
13
+
14
+ constructor(frames?: string[], opts: { unicode?: boolean } = {}) {
15
+ this.unicode = opts.unicode !== false;
16
+ // Default to the "AI Coding Agent" stage frames when none are supplied.
17
+ this.defaultFrames = frames || spinnerFramesFor(3, this.unicode);
18
+ this.frames = this.defaultFrames;
19
+ }
20
+
21
+ /** Switch frame set to the stage matching `step`/`maxSteps`. */
22
+ updateStep(step: number, maxSteps: number = 25): void {
23
+ const idx = stageIndexForStep(step, maxSteps);
24
+ this.setStage(idx);
25
+ }
26
+
27
+ /** Switch frame set to an explicit stage index (clamped). */
28
+ setStage(stageIndex: number): void {
29
+ this.frames = spinnerFramesFor(clampStageIndex(stageIndex), this.unicode);
30
+ // Keep the animation phase valid when the frame count shrinks.
31
+ this.index = this.frames.length ? this.index % this.frames.length : 0;
32
+ }
33
+
34
+ /** Reset to the default frame set and phase. */
35
+ reset(): void {
36
+ this.frames = this.defaultFrames;
37
+ this.index = 0;
38
+ }
39
+
40
+ next(): string {
41
+ const frame = this.frames[this.index]!;
42
+ this.index = (this.index + 1) % this.frames.length;
43
+ return frame;
44
+ }
45
+
46
+ current(): string {
47
+ return this.frames[this.index]!;
48
+ }
49
+ }
@@ -0,0 +1,45 @@
1
+ import chalk from "chalk";
2
+ import { meter } from "./meter";
3
+
4
+ export interface JocStatusData {
5
+ step?: number;
6
+ maxSteps?: number;
7
+ elapsedMs?: number;
8
+ message?: string;
9
+ currentTool?: string;
10
+ okCount?: number;
11
+ failCount?: number;
12
+ runningCount?: number;
13
+ totalCount?: number;
14
+ mutationGuarded?: boolean;
15
+ unicode?: boolean;
16
+ }
17
+
18
+ export function progressPercent(step: number | undefined, maxSteps: number | undefined): number {
19
+ if (!Number.isFinite(step) || !Number.isFinite(maxSteps) || (maxSteps ?? 0) <= 0) return 0;
20
+ return Math.max(0, Math.min(100, Math.round(((step ?? 0) / (maxSteps ?? 1)) * 100)));
21
+ }
22
+
23
+ function seconds(ms: number | undefined): number {
24
+ return Number.isFinite(ms) && (ms ?? 0) > 0 ? Math.round((ms ?? 0) / 1000) : 0;
25
+ }
26
+
27
+ export function renderJocStatus(data: JocStatusData): string[] {
28
+ const step = Number.isFinite(data.step) ? Math.max(0, Math.trunc(data.step ?? 0)) : 0;
29
+ const max = Number.isFinite(data.maxSteps) && (data.maxSteps ?? 0) > 0 ? Math.trunc(data.maxSteps ?? 0) : 0;
30
+ const pct = progressPercent(step, max);
31
+ const bar = meter(step, max || 1, 10, { unicode: data.unicode !== false });
32
+ const elapsed = `${seconds(data.elapsedMs)}s`;
33
+ const msg = data.message ?? "thinking through the next tool call";
34
+ const current = data.currentTool ? `forging ${data.currentTool}` : "forge idle";
35
+ const ok = data.okCount ?? 0;
36
+ const fail = data.failCount ?? 0;
37
+ const running = data.runningCount ?? 0;
38
+ const total = data.totalCount ?? ok + fail + running;
39
+ const guard = data.mutationGuarded ? ` · ${chalk.red.bold("mutation locked")}` : "";
40
+
41
+ return [
42
+ ` ${chalk.cyan.bold("joc thinking")} · ${msg} · step ${step}/${max} · ${pct}% ${bar} · ${elapsed}`,
43
+ ` ${chalk.magenta.bold("joc forge")} · ${current} · tools ${total} (${ok} ok / ${fail} fail / ${running} running)${guard}`,
44
+ ];
45
+ }
@@ -0,0 +1,36 @@
1
+ export class StreamRegion {
2
+ private buffer: string = "";
3
+
4
+ append(text: string): void {
5
+ this.buffer += text;
6
+ }
7
+
8
+ render(width: number, maxLines?: number): string[] {
9
+ if (this.buffer === "") {
10
+ return [];
11
+ }
12
+
13
+ const cols = Math.max(1, width);
14
+ const segments = this.buffer.split("\n");
15
+ const result: string[] = [];
16
+
17
+ for (const segment of segments) {
18
+ if (segment === "") {
19
+ result.push("");
20
+ } else {
21
+ for (let i = 0; i < segment.length; i += cols) {
22
+ result.push(segment.slice(i, i + cols));
23
+ }
24
+ }
25
+ }
26
+
27
+ if (maxLines !== undefined && maxLines > 0 && result.length > maxLines) {
28
+ return result.slice(result.length - maxLines);
29
+ }
30
+ return result;
31
+ }
32
+
33
+ clear(): void {
34
+ this.buffer = "";
35
+ }
36
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Evolution TUI themes — selectable palettes for the five-stage identity.
3
+ *
4
+ * A theme only re-skins the *colors* (per-stage gradient palettes) and whether
5
+ * color is emitted at all. The stage model, art, spinner, meter glyphs, and
6
+ * track structure are theme-independent, so switching a theme never changes
7
+ * layout — only the look. `cosmic` is the default; `mono` is the colorless
8
+ * fallback for plain terminals.
9
+ */
10
+ import { EVOLUTION_STAGE_GRADIENTS, EVOLUTION_STAGE_COUNT, type StageGradient } from "./evolution";
11
+ import { clampStageIndex } from "./evolution";
12
+ import type { EnvLike } from "./color";
13
+
14
+ export interface EvolutionTheme {
15
+ name: string;
16
+ description: string;
17
+ /** Per-stage gradient palettes (index-aligned, length EVOLUTION_STAGE_COUNT). */
18
+ gradients: readonly StageGradient[];
19
+ /** Whether the theme emits color at all (`mono` = false → plain output). */
20
+ color: boolean;
21
+ }
22
+
23
+ const COSMIC: EvolutionTheme = {
24
+ name: "cosmic",
25
+ description: "Default — deep-space arc from cyan tide to white-hot singularity.",
26
+ gradients: EVOLUTION_STAGE_GRADIENTS,
27
+ color: true,
28
+ };
29
+
30
+ const MATRIX: EvolutionTheme = {
31
+ name: "matrix",
32
+ description: "Terminal green — every stage glows in shades of phosphor green.",
33
+ gradients: [
34
+ { from: "#003b00", to: "#00ff41" },
35
+ { from: "#005f00", to: "#39ff14" },
36
+ { from: "#008f11", to: "#7fff00" },
37
+ { from: "#00b300", to: "#aaff00" },
38
+ { from: "#00ff41", to: "#ccffcc" },
39
+ ],
40
+ color: true,
41
+ };
42
+
43
+ const SOLAR: EvolutionTheme = {
44
+ name: "solar",
45
+ description: "Warm star — embers to corona, red through gold to white.",
46
+ gradients: [
47
+ { from: "#7a1f00", to: "#ff6b00" },
48
+ { from: "#a83200", to: "#ff8c00" },
49
+ { from: "#d35400", to: "#ffb700" },
50
+ { from: "#e67e22", to: "#ffd24a" },
51
+ { from: "#ff8c00", to: "#fff5cc" },
52
+ ],
53
+ color: true,
54
+ };
55
+
56
+ const MONO: EvolutionTheme = {
57
+ name: "mono",
58
+ description: "Colorless — plain text for NO_COLOR / minimal terminals.",
59
+ gradients: EVOLUTION_STAGE_GRADIENTS,
60
+ color: false,
61
+ };
62
+
63
+ export const THEMES: readonly EvolutionTheme[] = [COSMIC, MATRIX, SOLAR, MONO];
64
+
65
+ /** Look up a theme by name (case-insensitive); unknown names fall back to cosmic. */
66
+ export function getTheme(name: string | undefined): EvolutionTheme {
67
+ if (!name) return COSMIC;
68
+ const lc = name.trim().toLowerCase();
69
+ return THEMES.find(t => t.name === lc) ?? COSMIC;
70
+ }
71
+
72
+ /** Theme names + descriptions for `joc evolve --list-themes`. */
73
+ export function listThemes(): { name: string; description: string }[] {
74
+ return THEMES.map(t => ({ name: t.name, description: t.description }));
75
+ }
76
+
77
+ /** Resolve the active theme from the environment (`JOC_TUI_THEME`), default cosmic. */
78
+ export function resolveTheme(env: EnvLike = process.env): EvolutionTheme {
79
+ return getTheme(env.JOC_TUI_THEME);
80
+ }
81
+
82
+ /** The gradient palette for a stage index under a theme (clamped). */
83
+ export function themeGradient(theme: EvolutionTheme, index: number): StageGradient {
84
+ const i = clampStageIndex(index);
85
+ return theme.gradients[i] ?? theme.gradients[EVOLUTION_STAGE_COUNT - 1]!;
86
+ }
@@ -0,0 +1,67 @@
1
+ import chalk from "chalk";
2
+
3
+ export type ToolStatus = "running" | "ok" | "fail";
4
+
5
+ interface ToolRow {
6
+ tool: string;
7
+ status: ToolStatus;
8
+ }
9
+ export interface ToolStats {
10
+ running: number;
11
+ ok: number;
12
+ fail: number;
13
+ total: number;
14
+ }
15
+
16
+
17
+ export class ToolList {
18
+ private rows: ToolRow[] = [];
19
+
20
+ start(tool: string): number {
21
+ this.rows.push({ tool, status: "running" });
22
+ return this.rows.length - 1;
23
+ }
24
+
25
+ finish(index: number, ok: boolean): void {
26
+ if (this.rows[index]) {
27
+ this.rows[index].status = ok ? "ok" : "fail";
28
+ }
29
+ }
30
+
31
+ render(maxRows?: number): string[] {
32
+ const rows =
33
+ maxRows !== undefined && maxRows > 0 && this.rows.length > maxRows
34
+ ? this.rows.slice(this.rows.length - (maxRows - 1)) // keep the most recent rows
35
+ : this.rows;
36
+ const hidden = this.rows.length - rows.length;
37
+ const lines = rows.map(row => {
38
+ if (row.status === "running") {
39
+ return ` ${chalk.yellow("·")} ${row.tool} ${chalk.yellow.bold("running...")}`;
40
+ } else if (row.status === "ok") {
41
+ // Faded decay for completed successful tools
42
+ return chalk.gray(` · ${row.tool} ok`);
43
+ } else {
44
+ // Bright red for failures
45
+ return ` ${chalk.red("·")} ${row.tool} ${chalk.red.bold("FAILED")}`;
46
+ }
47
+ });
48
+ if (hidden > 0) {
49
+ lines.unshift(chalk.gray(` · (+${hidden} earlier)`));
50
+ }
51
+ return lines;
52
+ }
53
+
54
+ currentTool(): string | undefined {
55
+ return [...this.rows].reverse().find(row => row.status === "running")?.tool;
56
+ }
57
+
58
+ stats(): ToolStats {
59
+ const stats: ToolStats = { running: 0, ok: 0, fail: 0, total: this.rows.length };
60
+ for (const row of this.rows) stats[row.status]++;
61
+ return stats;
62
+ }
63
+
64
+ reset(): void {
65
+ this.rows = [];
66
+ }
67
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./terminal";
2
+ export * from "./renderer";
@@ -0,0 +1,70 @@
1
+ import { cursorDown, cursorUp, toColumn, clearLine, clearToEnd, size, truncate } from "./terminal";
2
+
3
+ export type Writer = (s: string) => void;
4
+
5
+ export class Renderer {
6
+ private write: Writer;
7
+ private cols: () => number;
8
+ private prev: string[] = [];
9
+ private prevCols?: number;
10
+
11
+ constructor(write?: Writer, cols?: () => number) {
12
+ this.write = write || ((s: string) => process.stdout.write(s));
13
+ this.cols = cols || (() => size().cols);
14
+ }
15
+
16
+ render(lines: string[]): void {
17
+ const currentCols = this.cols();
18
+ if (this.prevCols !== undefined && this.prevCols !== currentCols) {
19
+ this.clear();
20
+ }
21
+ this.prevCols = currentCols;
22
+
23
+ const next = lines.map(line => truncate(line, currentCols));
24
+ const maxLen = Math.max(this.prev.length, next.length);
25
+ let cursorRow = 0;
26
+ let out = "";
27
+
28
+ for (let i = 0; i < maxLen; i++) {
29
+ if (i < next.length) {
30
+ if (next[i] !== this.prev[i]) {
31
+ if (i > cursorRow) {
32
+ out += cursorDown(i - cursorRow);
33
+ } else if (i < cursorRow) {
34
+ out += cursorUp(cursorRow - i);
35
+ }
36
+ cursorRow = i;
37
+ out += toColumn(1) + clearLine() + next[i];
38
+ }
39
+ } else {
40
+ if (i > cursorRow) {
41
+ out += cursorDown(i - cursorRow);
42
+ } else if (i < cursorRow) {
43
+ out += cursorUp(cursorRow - i);
44
+ }
45
+ cursorRow = i;
46
+ out += toColumn(1) + clearLine();
47
+ }
48
+ }
49
+
50
+ if (cursorRow > 0) {
51
+ out += cursorUp(cursorRow);
52
+ }
53
+ out += toColumn(1);
54
+
55
+ if (out.length > 0) {
56
+ this.write(out);
57
+ }
58
+
59
+ this.prev = next;
60
+ }
61
+
62
+ clear(): void {
63
+ this.write(toColumn(1) + clearToEnd());
64
+ this.prev = [];
65
+ }
66
+
67
+ reset(): void {
68
+ this.prev = [];
69
+ }
70
+ }
@@ -0,0 +1,78 @@
1
+ export const ESC = "\x1b[";
2
+
3
+ export function cursorUp(n: number): string {
4
+ return n > 0 ? `${ESC}${n}A` : "";
5
+ }
6
+
7
+ export function cursorDown(n: number): string {
8
+ return n > 0 ? `${ESC}${n}B` : "";
9
+ }
10
+
11
+ export function toColumn(col: number): string {
12
+ return `${ESC}${col}G`;
13
+ }
14
+
15
+ export function clearLine(): string {
16
+ return `${ESC}2K`;
17
+ }
18
+
19
+ export function clearToEnd(): string {
20
+ return `${ESC}0J`;
21
+ }
22
+
23
+ export function hideCursor(): string {
24
+ return `${ESC}?25l`;
25
+ }
26
+
27
+ export function showCursor(): string {
28
+ return `${ESC}?25h`;
29
+ }
30
+
31
+ export function size(): { cols: number; rows: number } {
32
+ return {
33
+ cols: process.stdout.columns || 80,
34
+ rows: process.stdout.rows || 24,
35
+ };
36
+ }
37
+
38
+ export function isTTY(): boolean {
39
+ return !!process.stdout.isTTY;
40
+ }
41
+
42
+ /**
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.
48
+ */
49
+ 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;
78
+ }