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.
- package/README.md +342 -0
- package/package.json +57 -0
- package/scripts/install.sh +322 -0
- package/scripts/uninstall.sh +30 -0
- package/src/agent/compaction.ts +75 -0
- package/src/agent/config-schema.ts +87 -0
- package/src/agent/context-files.ts +51 -0
- package/src/agent/engine.ts +208 -0
- package/src/agent/json.ts +87 -0
- package/src/agent/loop.ts +22 -0
- package/src/agent/session.ts +198 -0
- package/src/agent/state.ts +199 -0
- package/src/agent/subagents.ts +149 -0
- package/src/agent/tools.ts +355 -0
- package/src/ai/index.ts +11 -0
- package/src/ai/model-catalog-compat.ts +119 -0
- package/src/ai/model-catalog.ts +97 -0
- package/src/ai/model-discovery.ts +148 -0
- package/src/ai/model-enrich.ts +75 -0
- package/src/ai/model-manager.ts +178 -0
- package/src/ai/model-picker.ts +73 -0
- package/src/ai/model-registry.ts +83 -0
- package/src/ai/provider-status.ts +77 -0
- package/src/ai/providers/anthropic.ts +87 -0
- package/src/ai/providers/errors.ts +47 -0
- package/src/ai/providers/gemini.ts +77 -0
- package/src/ai/providers/ollama.ts +54 -0
- package/src/ai/providers/openai.ts +67 -0
- package/src/ai/sse.ts +46 -0
- package/src/ai/types.ts +37 -0
- package/src/auth/callback-server.ts +195 -0
- package/src/auth/flows/anthropic.ts +114 -0
- package/src/auth/flows/google.ts +120 -0
- package/src/auth/flows/index.ts +50 -0
- package/src/auth/flows/openai.ts +130 -0
- package/src/auth/index.ts +23 -0
- package/src/auth/oauth.ts +80 -0
- package/src/auth/pkce.ts +24 -0
- package/src/auth/refresh.ts +60 -0
- package/src/auth/storage.ts +113 -0
- package/src/auth/types.ts +26 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/runner.ts +245 -0
- package/src/cli.ts +17 -0
- package/src/commands/approve.ts +63 -0
- package/src/commands/auth.ts +144 -0
- package/src/commands/chat.ts +37 -0
- package/src/commands/deep-interview.ts +239 -0
- package/src/commands/doctor.ts +250 -0
- package/src/commands/evolve.ts +191 -0
- package/src/commands/launch.ts +745 -0
- package/src/commands/mcp.ts +18 -0
- package/src/commands/models.ts +104 -0
- package/src/commands/ralplan.ts +86 -0
- package/src/commands/resume.ts +6 -0
- package/src/commands/setup-helpers.ts +93 -0
- package/src/commands/setup.ts +190 -0
- package/src/commands/skills.ts +38 -0
- package/src/commands/team.ts +337 -0
- package/src/commands/ultragoal.ts +102 -0
- package/src/index.ts +31 -0
- package/src/mcp/index.ts +3 -0
- package/src/mcp/protocol.ts +45 -0
- package/src/mcp/server.ts +97 -0
- package/src/mcp/tools.ts +156 -0
- package/src/skills/catalog.ts +61 -0
- package/src/tui/app.ts +297 -0
- package/src/tui/components/ascii-art.ts +340 -0
- package/src/tui/components/autocomplete.ts +165 -0
- package/src/tui/components/capability.ts +29 -0
- package/src/tui/components/code-view.ts +146 -0
- package/src/tui/components/color.ts +172 -0
- package/src/tui/components/config-panel.ts +193 -0
- package/src/tui/components/evolution.ts +305 -0
- package/src/tui/components/footer.ts +95 -0
- package/src/tui/components/forge.ts +167 -0
- package/src/tui/components/index.ts +7 -0
- package/src/tui/components/layout.ts +105 -0
- package/src/tui/components/meter.ts +61 -0
- package/src/tui/components/model-picker.ts +82 -0
- package/src/tui/components/provider-picker.ts +42 -0
- package/src/tui/components/select-list.ts +199 -0
- package/src/tui/components/slash.ts +34 -0
- package/src/tui/components/spinner.ts +49 -0
- package/src/tui/components/status.ts +45 -0
- package/src/tui/components/stream.ts +36 -0
- package/src/tui/components/themes.ts +86 -0
- package/src/tui/components/tool-list.ts +67 -0
- package/src/tui/index.ts +2 -0
- package/src/tui/renderer.ts +70 -0
- package/src/tui/terminal.ts +78 -0
- package/src/util/retry.ts +108 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {
|
|
2
|
+
stageIndexForStep,
|
|
3
|
+
|
|
4
|
+
overallProgress,
|
|
5
|
+
nextStageName,
|
|
6
|
+
stepsToNextStage,
|
|
7
|
+
evolutionTrack,
|
|
8
|
+
EVOLUTION_STAGE_COUNT,
|
|
9
|
+
} from "./evolution";
|
|
10
|
+
|
|
11
|
+
export interface FooterData {
|
|
12
|
+
model: string;
|
|
13
|
+
provider?: string;
|
|
14
|
+
step?: number;
|
|
15
|
+
maxSteps?: number;
|
|
16
|
+
elapsedMs?: number;
|
|
17
|
+
sessionId?: string;
|
|
18
|
+
/** Append a compact evolution-stage tag (default true when step+maxSteps known). */
|
|
19
|
+
showStage?: boolean;
|
|
20
|
+
/** Use ASCII track markers in the stage tag (default unicode). */
|
|
21
|
+
unicode?: boolean;
|
|
22
|
+
/** Show an estimated time-to-completion (`eta Ns`); opt-in. */
|
|
23
|
+
showEta?: boolean;
|
|
24
|
+
/** Show whole-turn evolution progress (`evo NN%`) + steps to next stage; opt-in. */
|
|
25
|
+
showProgress?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function renderFooter(d: FooterData): string {
|
|
29
|
+
const parts: string[] = [];
|
|
30
|
+
const unicode = d.unicode !== false;
|
|
31
|
+
|
|
32
|
+
// Model & Provider
|
|
33
|
+
if (d.model) {
|
|
34
|
+
if (d.provider) {
|
|
35
|
+
parts.push(`${d.model} (${d.provider})`);
|
|
36
|
+
} else {
|
|
37
|
+
parts.push(d.model);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Step
|
|
42
|
+
if (d.step !== undefined) {
|
|
43
|
+
if (d.maxSteps !== undefined) {
|
|
44
|
+
parts.push(`step ${d.step}/${d.maxSteps}`);
|
|
45
|
+
} else {
|
|
46
|
+
parts.push(`step ${d.step}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Elapsed
|
|
51
|
+
if (d.elapsedMs !== undefined) {
|
|
52
|
+
const secs = Math.round(d.elapsedMs / 1000);
|
|
53
|
+
parts.push(`${secs}s`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Estimated remaining time (opt-in): linear extrapolation from elapsed/step.
|
|
57
|
+
if (
|
|
58
|
+
d.showEta &&
|
|
59
|
+
d.step !== undefined &&
|
|
60
|
+
d.step > 0 &&
|
|
61
|
+
d.maxSteps !== undefined &&
|
|
62
|
+
d.step < d.maxSteps &&
|
|
63
|
+
d.elapsedMs !== undefined &&
|
|
64
|
+
d.elapsedMs > 0
|
|
65
|
+
) {
|
|
66
|
+
const etaMs = (d.elapsedMs / d.step) * (d.maxSteps - d.step);
|
|
67
|
+
parts.push(`eta ${Math.round(etaMs / 1000)}s`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Whole-turn progress + countdown to the next stage (opt-in).
|
|
71
|
+
if (d.showProgress && d.step !== undefined && d.maxSteps !== undefined) {
|
|
72
|
+
const pct = Math.round(overallProgress(d.step, d.maxSteps) * 100);
|
|
73
|
+
const idx = stageIndexForStep(d.step, d.maxSteps);
|
|
74
|
+
if (idx < EVOLUTION_STAGE_COUNT - 1) {
|
|
75
|
+
const remaining = stepsToNextStage(d.step, d.maxSteps);
|
|
76
|
+
const arrow = unicode ? "\u2192" : "->";
|
|
77
|
+
parts.push(`evo ${pct}% ${arrow} ${nextStageName(d.step, d.maxSteps)} in ${remaining}`);
|
|
78
|
+
} else {
|
|
79
|
+
parts.push(`evo ${pct}%`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Session ID
|
|
84
|
+
if (d.sessionId) {
|
|
85
|
+
parts.push(d.sessionId.slice(0, 8));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Compact evolution-stage tag, e.g. "●●●○○ Tool User (Homo Habilis) [3/5]".
|
|
89
|
+
if (d.showStage !== false && d.step !== undefined && d.maxSteps !== undefined) {
|
|
90
|
+
const idx = stageIndexForStep(d.step, d.maxSteps);
|
|
91
|
+
parts.push(evolutionTrack(idx, { color: true, unicode }));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return parts.join(" · ");
|
|
95
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { BOX_ASCII, BOX_UNICODE, padLineTo, type BoxGlyphs } from "./layout";
|
|
3
|
+
import { stripAnsi, visibleWidth } from "./color";
|
|
4
|
+
|
|
5
|
+
export interface ForgeSummary {
|
|
6
|
+
title: string;
|
|
7
|
+
language?: string;
|
|
8
|
+
lines: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ForgeBoxOptions {
|
|
12
|
+
width?: number;
|
|
13
|
+
maxLines?: number;
|
|
14
|
+
unicode?: boolean;
|
|
15
|
+
paint?: (s: string) => string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const SECRET_VALUE_RE = /(api[_-]?key|authorization|bearer|password|secret|token)(\s*[:=]\s*)(["']?)[^"'\s,}]+/gi;
|
|
19
|
+
const SECRET_JSON_RE = /("(?:api[_-]?key|authorization|password|secret|token)"\s*:\s*")[^"]+(")/gi;
|
|
20
|
+
|
|
21
|
+
export function redactSecrets(input: string): string {
|
|
22
|
+
return input
|
|
23
|
+
.replace(SECRET_VALUE_RE, (_m, key: string, sep: string, quote: string) => `${key}${sep}${quote}<redacted>`)
|
|
24
|
+
.replace(SECRET_JSON_RE, "$1<redacted>$2");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
28
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function stringArg(args: Record<string, unknown>, ...keys: string[]): string | undefined {
|
|
32
|
+
for (const key of keys) {
|
|
33
|
+
const value = args[key];
|
|
34
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
35
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function previewLines(text: string, maxLines: number, maxChars: number): string[] {
|
|
41
|
+
const clean = redactSecrets(text.replace(/\r\n/g, "\n"));
|
|
42
|
+
const raw = clean.split("\n");
|
|
43
|
+
const out: string[] = [];
|
|
44
|
+
let used = 0;
|
|
45
|
+
for (const line of raw) {
|
|
46
|
+
if (out.length >= maxLines || used >= maxChars) break;
|
|
47
|
+
const remaining = Math.max(0, maxChars - used);
|
|
48
|
+
const next = line.length > remaining ? `${line.slice(0, Math.max(0, remaining - 1))}…` : line;
|
|
49
|
+
out.push(next);
|
|
50
|
+
used += next.length + 1;
|
|
51
|
+
}
|
|
52
|
+
if (raw.length > out.length || clean.length > used) out.push(`… ${Math.max(0, raw.length - out.length)} more line(s)`);
|
|
53
|
+
return out.length > 0 ? out : [""];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function jsonPreview(args: Record<string, unknown>): string[] {
|
|
57
|
+
try {
|
|
58
|
+
return previewLines(JSON.stringify(args, null, 2), 6, 500);
|
|
59
|
+
} catch {
|
|
60
|
+
return ["<unrenderable arguments>"];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function summarizeForgeInvocation(tool: string, rawArgs: unknown): ForgeSummary {
|
|
65
|
+
const args = asRecord(rawArgs);
|
|
66
|
+
const normalized = tool.toLowerCase();
|
|
67
|
+
if (normalized === "bash") {
|
|
68
|
+
const command = stringArg(args, "command", "cmd") ?? "";
|
|
69
|
+
const timeout = stringArg(args, "timeoutMs", "timeout");
|
|
70
|
+
const lines = [...previewLines(command, 8, 800)];
|
|
71
|
+
if (timeout) lines.unshift(`# timeoutMs: ${timeout}`);
|
|
72
|
+
return { title: "bash command", language: "bash", lines };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (normalized === "read") {
|
|
76
|
+
const filePath = stringArg(args, "filePath", "path") ?? "<missing path>";
|
|
77
|
+
const range = stringArg(args, "lineRange", "range");
|
|
78
|
+
return {
|
|
79
|
+
title: `read ${filePath}`,
|
|
80
|
+
language: "path",
|
|
81
|
+
lines: [`path: ${filePath}`, range ? `range: ${range}` : "range: full/default preview"],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (normalized === "write") {
|
|
86
|
+
const filePath = stringArg(args, "filePath", "path") ?? "<missing path>";
|
|
87
|
+
const content = typeof args.content === "string" ? args.content : "";
|
|
88
|
+
const lineCount = content.length === 0 ? 0 : content.split("\n").length;
|
|
89
|
+
return {
|
|
90
|
+
title: `write ${filePath}`,
|
|
91
|
+
language: "text",
|
|
92
|
+
lines: [`# ${content.length} bytes · ${lineCount} line(s) -> ${filePath}`, ...previewLines(content, 8, 800)],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (normalized === "edit") {
|
|
97
|
+
const filePath = stringArg(args, "filePath", "path") ?? "<missing path>";
|
|
98
|
+
const editBlock = stringArg(args, "editBlock", "edit") ?? "";
|
|
99
|
+
return {
|
|
100
|
+
title: `edit ${filePath}`,
|
|
101
|
+
language: "patch",
|
|
102
|
+
lines: [`# patch -> ${filePath}`, ...previewLines(editBlock, 8, 800)],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (normalized === "find") {
|
|
107
|
+
const pattern = stringArg(args, "globPattern", "pattern") ?? "<missing glob>";
|
|
108
|
+
return { title: "find files", language: "glob", lines: [`glob: ${pattern}`] };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (normalized === "search") {
|
|
112
|
+
const pattern = stringArg(args, "pattern") ?? "<missing pattern>";
|
|
113
|
+
const glob = stringArg(args, "globPattern", "path") ?? "*";
|
|
114
|
+
return { title: "search content", language: "regex", lines: [`pattern: ${pattern}`, `glob: ${glob}`] };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { title: `${tool} arguments`, language: "json", lines: jsonPreview(args) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function summarizeForgeResult(tool: string, success: boolean, output: string): ForgeSummary {
|
|
121
|
+
const status = success ? "ok" : "failed";
|
|
122
|
+
return {
|
|
123
|
+
title: `${tool} result ${status}`,
|
|
124
|
+
language: "output",
|
|
125
|
+
lines: previewLines(output || "<no output>", success ? 5 : 10, success ? 600 : 1200),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function wrapPlainLine(line: string, width: number): string[] {
|
|
130
|
+
const plain = stripAnsi(line);
|
|
131
|
+
if (width <= 0) return [""];
|
|
132
|
+
if (visibleWidth(line) <= width) return [line];
|
|
133
|
+
const out: string[] = [];
|
|
134
|
+
for (let i = 0; i < plain.length; i += width) out.push(plain.slice(i, i + width));
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function borderGlyphs(unicode: boolean | undefined): BoxGlyphs {
|
|
139
|
+
return unicode === false ? BOX_ASCII : BOX_UNICODE;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function formatForgeBox(summary: ForgeSummary, opts: ForgeBoxOptions = {}): string[] {
|
|
143
|
+
const width = Math.max(24, Math.min(120, Math.trunc(opts.width ?? 80)));
|
|
144
|
+
const maxLines = Math.max(1, Math.trunc(opts.maxLines ?? 10));
|
|
145
|
+
const glyphs = borderGlyphs(opts.unicode);
|
|
146
|
+
const paint = opts.paint ?? chalk.gray;
|
|
147
|
+
const inner = Math.max(1, width - 2);
|
|
148
|
+
const top = paint(glyphs.tl + glyphs.h.repeat(inner) + glyphs.tr);
|
|
149
|
+
const bottom = paint(glyphs.bl + glyphs.h.repeat(inner) + glyphs.br);
|
|
150
|
+
const label = summary.language ? `${summary.title} · ${summary.language}` : summary.title;
|
|
151
|
+
const title = `${chalk.bold(label)}`;
|
|
152
|
+
const rendered: string[] = [top, paint(glyphs.v) + padLineTo(title, inner, "left") + paint(glyphs.v)];
|
|
153
|
+
const separator = paint(glyphs.v) + paint(glyphs.h.repeat(inner)) + paint(glyphs.v);
|
|
154
|
+
rendered.push(separator);
|
|
155
|
+
|
|
156
|
+
const content: string[] = [];
|
|
157
|
+
for (const line of summary.lines) {
|
|
158
|
+
for (const wrapped of wrapPlainLine(line, inner)) content.push(wrapped);
|
|
159
|
+
}
|
|
160
|
+
const clipped = content.slice(0, maxLines);
|
|
161
|
+
for (const line of clipped) rendered.push(paint(glyphs.v) + padLineTo(line, inner, "left") + paint(glyphs.v));
|
|
162
|
+
if (content.length > clipped.length) {
|
|
163
|
+
rendered.push(paint(glyphs.v) + padLineTo(`… ${content.length - clipped.length} hidden line(s)`, inner, "left") + paint(glyphs.v));
|
|
164
|
+
}
|
|
165
|
+
rendered.push(bottom);
|
|
166
|
+
return rendered;
|
|
167
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Responsive layout helpers — fit content to the terminal's width and height.
|
|
3
|
+
*
|
|
4
|
+
* Every alignment/padding decision is based on the *visible* width (ANSI escapes
|
|
5
|
+
* ignored) so colored / gradient art is centered and boxed correctly. These are
|
|
6
|
+
* pure functions over `string[]` blocks, injectable `cols`/`rows` for tests.
|
|
7
|
+
*/
|
|
8
|
+
import { visibleWidth } from "./color";
|
|
9
|
+
|
|
10
|
+
export type HAlign = "left" | "center" | "right";
|
|
11
|
+
export type VAlign = "top" | "center" | "bottom";
|
|
12
|
+
|
|
13
|
+
/** Pad a single line to `width` visible columns with the given horizontal alignment. */
|
|
14
|
+
export function padLineTo(line: string, width: number, align: HAlign = "left"): string {
|
|
15
|
+
const vis = visibleWidth(line);
|
|
16
|
+
if (width <= 0 || vis >= width) return line;
|
|
17
|
+
const total = width - vis;
|
|
18
|
+
if (align === "right") return " ".repeat(total) + line;
|
|
19
|
+
if (align === "center") {
|
|
20
|
+
const left = Math.floor(total / 2);
|
|
21
|
+
return " ".repeat(left) + line + " ".repeat(total - left);
|
|
22
|
+
}
|
|
23
|
+
return line + " ".repeat(total);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Align every line of a block to `width` columns (default centered). */
|
|
27
|
+
export function alignBlock(lines: string[], width: number, align: HAlign = "center"): string[] {
|
|
28
|
+
return lines.map(l => padLineTo(l, width, align));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Center a block horizontally within `cols` by left-padding to its center (no right pad). */
|
|
32
|
+
export function centerBlock(lines: string[], cols: number): string[] {
|
|
33
|
+
const blockWidth = Math.max(0, ...lines.map(visibleWidth));
|
|
34
|
+
if (cols <= blockWidth) return lines;
|
|
35
|
+
const left = Math.floor((cols - blockWidth) / 2);
|
|
36
|
+
const pad = " ".repeat(left);
|
|
37
|
+
return lines.map(l => pad + l);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Grow a block to exactly `rows` lines by inserting blank lines, vertically
|
|
42
|
+
* aligned. Never truncates: a block already at/over `rows` is returned as-is.
|
|
43
|
+
* `fillWidth` (visible cols) makes inserted blanks span the width for a stable
|
|
44
|
+
* background; default empty strings.
|
|
45
|
+
*/
|
|
46
|
+
export function padBlockToHeight(lines: string[], rows: number, align: VAlign = "top", fillWidth = 0): string[] {
|
|
47
|
+
if (rows <= 0 || lines.length >= rows) return lines;
|
|
48
|
+
const blank = fillWidth > 0 ? " ".repeat(fillWidth) : "";
|
|
49
|
+
const missing = rows - lines.length;
|
|
50
|
+
if (align === "top") return [...lines, ...Array(missing).fill(blank)];
|
|
51
|
+
if (align === "bottom") return [...Array(missing).fill(blank), ...lines];
|
|
52
|
+
const top = Math.floor(missing / 2);
|
|
53
|
+
return [...Array(top).fill(blank), ...lines, ...Array(missing - top).fill(blank)];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Compose a full-screen frame that fills `rows`: `header` sits at the top,
|
|
58
|
+
* `body` follows, and `footer` is pinned to the bottom, with blank filler
|
|
59
|
+
* between body and footer. If content already exceeds `rows`, nothing is
|
|
60
|
+
* clipped (the terminal scrolls) — the footer simply follows the body.
|
|
61
|
+
*/
|
|
62
|
+
export function fillScreen(header: string[], body: string[], footer: string[], rows: number): string[] {
|
|
63
|
+
const content = [...header, ...body];
|
|
64
|
+
const used = content.length + footer.length;
|
|
65
|
+
const filler = Math.max(0, rows - used);
|
|
66
|
+
return [...content, ...Array(filler).fill(""), ...footer];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Draw a single-line border box around `lines`, sized to `width` visible columns
|
|
71
|
+
* (inner content centered). `glyphs` selects unicode vs ASCII corners/edges.
|
|
72
|
+
*/
|
|
73
|
+
export interface BoxGlyphs {
|
|
74
|
+
tl: string;
|
|
75
|
+
tr: string;
|
|
76
|
+
bl: string;
|
|
77
|
+
br: string;
|
|
78
|
+
h: string;
|
|
79
|
+
v: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const BOX_UNICODE: BoxGlyphs = { tl: "\u256d", tr: "\u256e", bl: "\u2570", br: "\u256f", h: "\u2500", v: "\u2502" };
|
|
83
|
+
export const BOX_ASCII: BoxGlyphs = { tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|" };
|
|
84
|
+
|
|
85
|
+
export function boxBlock(
|
|
86
|
+
lines: string[],
|
|
87
|
+
width: number,
|
|
88
|
+
opts: { glyphs?: BoxGlyphs; paint?: (s: string) => string; align?: HAlign } = {},
|
|
89
|
+
): string[] {
|
|
90
|
+
const g = opts.glyphs ?? BOX_UNICODE;
|
|
91
|
+
const paint = opts.paint ?? ((s: string) => s);
|
|
92
|
+
const inner = Math.max(0, width - 2);
|
|
93
|
+
const top = paint(g.tl + g.h.repeat(inner) + g.tr);
|
|
94
|
+
const bottom = paint(g.bl + g.h.repeat(inner) + g.br);
|
|
95
|
+
const mid = lines.map(l => {
|
|
96
|
+
if (l === "DIVIDER") {
|
|
97
|
+
const leftChar = g.tl === "+" ? "+" : "├";
|
|
98
|
+
const rightChar = g.tr === "+" ? "+" : "┤";
|
|
99
|
+
return paint(leftChar + g.h.repeat(inner) + rightChar);
|
|
100
|
+
}
|
|
101
|
+
const align = opts.align ?? "left";
|
|
102
|
+
return paint(g.v) + padLineTo(l, inner, align) + paint(g.v);
|
|
103
|
+
});
|
|
104
|
+
return [top, ...mid, bottom];
|
|
105
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { meterGlyphsFor, stageIndexForRatio } from "./evolution";
|
|
2
|
+
import { size } from "../terminal";
|
|
3
|
+
|
|
4
|
+
export interface MeterOptions {
|
|
5
|
+
/** Use ASCII-only glyphs for terminals without unicode (default true = unicode). */
|
|
6
|
+
unicode?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Horizontal evolutionary percent/progress meter for pipeline + doctor TUI views. */
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Render an evolutionary `[#####-----] 50%` meter. The fill/empty glyphs and
|
|
13
|
+
* color evolve through the five canonical stages as the ratio rises, so the
|
|
14
|
+
* meter shares the same visual vocabulary as the spinner and ASCII art.
|
|
15
|
+
* `value`/`max` clamp to a [0,1] ratio; `width` is the bar cell count.
|
|
16
|
+
*/
|
|
17
|
+
export function meter(value: number, max = 1, width?: number, opts: MeterOptions = {}): string {
|
|
18
|
+
const ratio = max <= 0 ? 0 : Math.max(0, Math.min(1, value / max));
|
|
19
|
+
const cells = width !== undefined ? Math.max(0, Math.trunc(width)) : Math.max(10, Math.min(40, size().cols - 30));
|
|
20
|
+
const filledCount = Math.round(ratio * cells);
|
|
21
|
+
const { fill, empty, color } = meterGlyphsFor(stageIndexForRatio(ratio), opts.unicode !== false);
|
|
22
|
+
const bar = color(fill.repeat(filledCount)) + empty.repeat(cells - filledCount);
|
|
23
|
+
return `[${bar}] ${Math.round(ratio * 100)}%`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Render a `3/10` step counter with a trailing meter. Guards a non-positive total. */
|
|
27
|
+
export function stepMeter(step: number, total: number, width?: number): string {
|
|
28
|
+
const safeTotal = total > 0 ? total : 0;
|
|
29
|
+
return `${step}/${safeTotal} ${meter(step, safeTotal, width)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Render a labeled meter, e.g. `tokens [#####-----] 50%`. Empty label → bare meter. */
|
|
33
|
+
export function meterLabeled(label: string, value: number, max = 1, width?: number): string {
|
|
34
|
+
const bar = meter(value, max, width);
|
|
35
|
+
return label ? `${label} ${bar}` : bar;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const SPARK_UNICODE = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
|
|
39
|
+
const SPARK_ASCII = ["_", ".", ",", "-", "=", "+", "*", "#"];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Render a compact sparkline from a series of values, e.g. `▁▂▃▅▇`. Values are
|
|
43
|
+
* normalized against the series max (or `opts.max`). Empty / all-equal series
|
|
44
|
+
* are handled (flat low bar). `opts.unicode:false` uses an ASCII ramp.
|
|
45
|
+
*/
|
|
46
|
+
export function sparkline(values: number[], opts: { unicode?: boolean; max?: number } = {}): string {
|
|
47
|
+
const ramp = opts.unicode === false ? SPARK_ASCII : SPARK_UNICODE;
|
|
48
|
+
const finite = values.filter(v => Number.isFinite(v));
|
|
49
|
+
if (finite.length === 0) return "";
|
|
50
|
+
const hi = opts.max ?? Math.max(...finite);
|
|
51
|
+
const lo = Math.min(...finite);
|
|
52
|
+
const span = hi - lo;
|
|
53
|
+
const last = ramp.length - 1;
|
|
54
|
+
return finite
|
|
55
|
+
.map(v => {
|
|
56
|
+
if (span <= 0) return ramp[0]!;
|
|
57
|
+
const t = (v - lo) / span;
|
|
58
|
+
return ramp[Math.max(0, Math.min(last, Math.round(t * last)))]!;
|
|
59
|
+
})
|
|
60
|
+
.join("");
|
|
61
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model picker — turns the curated catalog + live provider readiness into a
|
|
3
|
+
* grouped, badged `SelectList` for the TUI `/model` flow. Pure builders +
|
|
4
|
+
* formatters (no I/O) so the picker is unit-testable; the interactive loop just
|
|
5
|
+
* feeds keystrokes to the `SelectList` and renders it.
|
|
6
|
+
*/
|
|
7
|
+
import { SelectList, renderSelectList, type SelectItem, type RenderSelectOptions } from "./select-list";
|
|
8
|
+
import { catalogForProvider, type ModelCatalogEntry } from "../../ai/model-catalog-compat";
|
|
9
|
+
import { PROVIDER_NAMES, type ProviderStatus } from "../../ai/provider-status";
|
|
10
|
+
import type { ProviderName } from "../../ai/types";
|
|
11
|
+
|
|
12
|
+
/** Human context-window size: 200000 → "200k", 1000000 → "1M", 0 → "". */
|
|
13
|
+
export function formatContextWindow(tokens: number): string {
|
|
14
|
+
if (!tokens || tokens <= 0) return "";
|
|
15
|
+
if (tokens >= 1_000_000) return `${tokens / 1_000_000}M ctx`.replace(".0M", "M");
|
|
16
|
+
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k ctx`;
|
|
17
|
+
return `${tokens} ctx`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ModelPickerOptions {
|
|
21
|
+
/** Use unicode badges (✓/⚡); ASCII fallback otherwise. */
|
|
22
|
+
unicode?: boolean;
|
|
23
|
+
/** Include providers with no credential (shown with a "no credential" hint). Default true. */
|
|
24
|
+
includeUnready?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Build the right-aligned hint/badges for a catalog entry given its provider readiness. */
|
|
28
|
+
export function modelHint(entry: ModelCatalogEntry, ready: boolean, unicode = true): string {
|
|
29
|
+
const parts: string[] = [];
|
|
30
|
+
const ctx = formatContextWindow(entry.contextWindow);
|
|
31
|
+
if (ctx) parts.push(ctx);
|
|
32
|
+
if (entry.reasoning) parts.push(unicode ? "\u26a1 reasoning" : "reasoning");
|
|
33
|
+
if (entry.recommended) parts.push(unicode ? "\u2605 recommended" : "recommended");
|
|
34
|
+
parts.push(ready ? (unicode ? "\u2713 ready" : "ready") : "no credential");
|
|
35
|
+
return parts.join(" \u00b7 ");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build the model choices: every catalogued model, grouped by provider, ready
|
|
40
|
+
* providers first, recommended models first within a provider. Models whose
|
|
41
|
+
* provider has no credential are still selectable but hinted (you can pick now
|
|
42
|
+
* and add the key after).
|
|
43
|
+
*/
|
|
44
|
+
export function buildModelChoices(statuses: ProviderStatus[], opts: ModelPickerOptions = {}): SelectItem<string>[] {
|
|
45
|
+
const unicode = opts.unicode !== false;
|
|
46
|
+
const includeUnready = opts.includeUnready !== false;
|
|
47
|
+
const readyOf = new Map<ProviderName, boolean>(statuses.map(s => [s.name, s.ready]));
|
|
48
|
+
|
|
49
|
+
// Provider order: ready first, then the canonical order.
|
|
50
|
+
const providers = [...PROVIDER_NAMES].sort((a, b) => {
|
|
51
|
+
const ra = readyOf.get(a) ? 0 : 1;
|
|
52
|
+
const rb = readyOf.get(b) ? 0 : 1;
|
|
53
|
+
if (ra !== rb) return ra - rb;
|
|
54
|
+
return PROVIDER_NAMES.indexOf(a) - PROVIDER_NAMES.indexOf(b);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const items: SelectItem<string>[] = [];
|
|
58
|
+
for (const provider of providers) {
|
|
59
|
+
const ready = !!readyOf.get(provider);
|
|
60
|
+
if (!ready && !includeUnready) continue;
|
|
61
|
+
const group = `${provider}${ready ? "" : " (no credential)"}`;
|
|
62
|
+
for (const entry of catalogForProvider(provider)) {
|
|
63
|
+
items.push({
|
|
64
|
+
value: entry.id,
|
|
65
|
+
label: entry.id,
|
|
66
|
+
group,
|
|
67
|
+
hint: modelHint(entry, ready, unicode),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return items;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Construct a ready-to-drive `SelectList` of models. */
|
|
75
|
+
export function modelPicker(statuses: ProviderStatus[], opts: ModelPickerOptions = {}): SelectList<string> {
|
|
76
|
+
return new SelectList(buildModelChoices(statuses, opts));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Render a model picker `SelectList` with a sensible default title. */
|
|
80
|
+
export function renderModelPicker(list: SelectList<string>, opts: RenderSelectOptions = {}): string[] {
|
|
81
|
+
return renderSelectList(list, { title: "Select a model", rows: 12, ...opts });
|
|
82
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider picker — turns live provider readiness into a `SelectList` for the
|
|
3
|
+
* TUI `/provider` and `joc setup` flows. Ready providers are listed first and
|
|
4
|
+
* the recommended choice is the first ready provider. Pure builders.
|
|
5
|
+
*/
|
|
6
|
+
import { SelectList, renderSelectList, type SelectItem, type RenderSelectOptions } from "./select-list";
|
|
7
|
+
import type { ProviderStatus } from "../../ai/provider-status";
|
|
8
|
+
import type { ProviderName } from "../../ai/types";
|
|
9
|
+
|
|
10
|
+
/** Right-aligned hint for a provider row: credential kind + base URL + readiness. */
|
|
11
|
+
export function providerHint(s: ProviderStatus, unicode = true): string {
|
|
12
|
+
const parts: string[] = [s.label];
|
|
13
|
+
if (s.baseUrl) parts.push(s.baseUrl);
|
|
14
|
+
parts.push(s.ready ? (unicode ? "\u2713 ready" : "ready") : (unicode ? "\u00b7 setup" : "setup"));
|
|
15
|
+
return parts.join(" \u00b7 ");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Build provider choices, ready providers first (stable within each group). */
|
|
19
|
+
export function buildProviderChoices(statuses: ProviderStatus[], unicode = true): SelectItem<ProviderName>[] {
|
|
20
|
+
const sorted = [...statuses].sort((a, b) => (a.ready === b.ready ? 0 : a.ready ? -1 : 1));
|
|
21
|
+
return sorted.map(s => ({
|
|
22
|
+
value: s.name,
|
|
23
|
+
label: s.name,
|
|
24
|
+
group: s.ready ? "ready" : "needs setup",
|
|
25
|
+
hint: providerHint(s, unicode),
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The recommended provider: the first ready provider, or the first overall. */
|
|
30
|
+
export function recommendedProvider(statuses: ProviderStatus[]): ProviderName | undefined {
|
|
31
|
+
return (statuses.find(s => s.ready) ?? statuses[0])?.name;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Construct a ready-to-drive `SelectList` of providers. */
|
|
35
|
+
export function providerPicker(statuses: ProviderStatus[], unicode = true): SelectList<ProviderName> {
|
|
36
|
+
return new SelectList(buildProviderChoices(statuses, unicode));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Render a provider picker `SelectList` with a sensible default title. */
|
|
40
|
+
export function renderProviderPicker(list: SelectList<ProviderName>, opts: RenderSelectOptions = {}): string[] {
|
|
41
|
+
return renderSelectList(list, { title: "Select a provider", rows: 8, ...opts });
|
|
42
|
+
}
|