jeo-code 0.6.2 → 0.6.4
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/CHANGELOG.md +22 -0
- package/README.ja.md +6 -2
- package/README.ko.md +6 -2
- package/README.md +6 -2
- package/README.zh.md +6 -2
- package/package.json +1 -1
- package/src/agent/engine.ts +82 -26
- package/src/agent/goal-verifier.ts +115 -0
- package/src/agent/model-recency.ts +1 -1
- package/src/agent/tools.ts +77 -17
- package/src/auth/callback-server.ts +1 -1
- package/src/commands/launch.ts +218 -136
- package/src/tui/app.ts +87 -25
- package/src/tui/components/autocomplete.ts +6 -4
- package/src/tui/components/config-panel.ts +2 -2
- package/src/tui/components/markdown-text.ts +19 -5
- package/src/tui/components/slash.ts +25 -2
package/src/tui/app.ts
CHANGED
|
@@ -119,6 +119,9 @@ export function tailForWrap(text: string, maxChars = FRAME_WRAP_TAIL_CHARS): str
|
|
|
119
119
|
* on the in-flight tool line). */
|
|
120
120
|
export const STATUS_VERIFY_PALETTE = ["#ffd24a", "#ffb300"] as const;
|
|
121
121
|
const DEFAULT_MAX_STEPS = 100;
|
|
122
|
+
// Resize repaint cap (~30fps): the leading edge fires instantly, follow-ups during a
|
|
123
|
+
// drag-resize are throttled to this interval, and a trailing repaint paints the final size.
|
|
124
|
+
const RESIZE_THROTTLE_MS = 33;
|
|
122
125
|
// Tools light enough that they never get a forge card (gjc parity): completion is a
|
|
123
126
|
// single ✓/✗ ledger line; only failures surface a result card with the error body.
|
|
124
127
|
const LIGHT_TOOLS = new Set(["read", "find", "search", "ls", "todo"]);
|
|
@@ -167,6 +170,15 @@ export class LaunchTui {
|
|
|
167
170
|
private mutationGuarded = false;
|
|
168
171
|
private finished = false;
|
|
169
172
|
private timer: ReturnType<typeof setInterval> | undefined;
|
|
173
|
+
// Resize handling (gjc-style responsiveness): repaint IMMEDIATELY on the first event
|
|
174
|
+
// so a deliberate resize never lags, then cap follow-ups to ~30fps so a drag-resize
|
|
175
|
+
// tracks the cursor live (instead of staying stale until the drag pauses — the lag a
|
|
176
|
+
// pure trailing debounce caused). A trailing timer always fires once more so the FINAL
|
|
177
|
+
// geometry paints exactly. lastCols/lastRows drop spurious same-size resize events.
|
|
178
|
+
private resizeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
179
|
+
private lastResizeAt = 0;
|
|
180
|
+
private lastCols = -1;
|
|
181
|
+
private lastRows = -1;
|
|
170
182
|
private pendingIndex: number | null = null;
|
|
171
183
|
private pendingTitle: string | null = null;
|
|
172
184
|
private pendingForge: ForgeSummary | null = null;
|
|
@@ -209,14 +221,17 @@ export class LaunchTui {
|
|
|
209
221
|
// the model responds, then flushed once into scrollback as a `jeo · …` ledger line.
|
|
210
222
|
private streamingReasoning = "";
|
|
211
223
|
/** Native model thinking text (separate reasoning channel), shown DIMMED while it
|
|
212
|
-
* streams
|
|
213
|
-
*
|
|
224
|
+
* streams, then persisted once into scrollback as a "Thinking" block on commit so the
|
|
225
|
+
* model's reasoning stays visible above the answer (gjc "think → answer" parity). */
|
|
214
226
|
private streamingThought = "";
|
|
215
227
|
/** Uniform live-activity text for the live status field (reasoning OR derived fallback). */
|
|
216
228
|
private streamingActivity = "";
|
|
217
229
|
/** Last stream-driven draw (ms epoch) — throttles per-delta repaints to ≤10/s. */
|
|
218
230
|
private lastStreamDraw = 0;
|
|
219
231
|
private flushedReasoning = "";
|
|
232
|
+
// Native thinking text already persisted into scrollback this step (gjc parity: the
|
|
233
|
+
// model's reasoning stays VISIBLE above the answer instead of vanishing on commit).
|
|
234
|
+
private flushedThought = "";
|
|
220
235
|
// Live streaming output of the currently-running tool (bash stdout via onToolProgress).
|
|
221
236
|
// Shown as a DIMMED bounded block while the tool runs; cleared when the formatted
|
|
222
237
|
// result card lands (onToolResult) — the gjc-style "shaded until complete" effect.
|
|
@@ -366,6 +381,7 @@ export class LaunchTui {
|
|
|
366
381
|
this.streamingThought = "";
|
|
367
382
|
this.streamingActivity = "";
|
|
368
383
|
this.flushedReasoning = "";
|
|
384
|
+
this.flushedThought = "";
|
|
369
385
|
this.liveToolOutput = ""; // fresh step: no tool output yet
|
|
370
386
|
this.currentStepStartedAt = Date.now();
|
|
371
387
|
this.spinner.updateStep(step, this.footer.maxSteps);
|
|
@@ -400,9 +416,9 @@ export class LaunchTui {
|
|
|
400
416
|
},
|
|
401
417
|
onReasoningStream: textSoFar => {
|
|
402
418
|
if (this.finished) return;
|
|
403
|
-
// Native thinking deltas → the SAME
|
|
404
|
-
//
|
|
405
|
-
//
|
|
419
|
+
// Native thinking deltas → the SAME live dimmed block as the JSON-reasoning path
|
|
420
|
+
// (reuses the screen-safe tail renderer; no new frame structure). On commit it is
|
|
421
|
+
// persisted into scrollback as a "Thinking" block (see onAssistant).
|
|
406
422
|
if (textSoFar === this.streamingThought) return;
|
|
407
423
|
this.streamingThought = textSoFar;
|
|
408
424
|
if (Date.now() - this.lastStreamDraw >= 100) {
|
|
@@ -413,22 +429,28 @@ export class LaunchTui {
|
|
|
413
429
|
onAssistant: (_raw, invocation) => {
|
|
414
430
|
this.thinking = false; // model replied; now dispatching the tool
|
|
415
431
|
this.retryNotice = null; // the call got through — clear any backoff notice
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
+
// Persist the model's pre-answer thought into scrollback (gjc parity: "think →
|
|
433
|
+
// answer" reads visibly instead of vanishing on commit). gjc layout: a single
|
|
434
|
+
// `jeo` agent-name label leads the segment, then the thought as ITALIC + dimmed
|
|
435
|
+
// prose (subordinate to the reply) — native reasoning first, then the JSON-protocol
|
|
436
|
+
// plan, both grouped under the one label (no per-block divider header).
|
|
437
|
+
const willFlushThought = !!this.streamingThought && this.streamingThought !== this.flushedThought;
|
|
438
|
+
const willFlushReasoning = !!this.streamingReasoning && this.streamingReasoning !== this.flushedReasoning;
|
|
439
|
+
if (willFlushThought || willFlushReasoning) {
|
|
440
|
+
const styleThought = this.theme.color
|
|
441
|
+
? (s: string) => chalk.italic(mutedPaint(this.theme)(s))
|
|
442
|
+
: (s: string) => s;
|
|
443
|
+
const style = (prose: string) => prose.split("\n").map(styleThought).join("\n");
|
|
444
|
+
const parts: string[] = [this.agentLabel()];
|
|
445
|
+
if (willFlushThought) {
|
|
446
|
+
this.flushedThought = this.streamingThought;
|
|
447
|
+
parts.push(style(this.streamingThought));
|
|
448
|
+
}
|
|
449
|
+
if (willFlushReasoning) {
|
|
450
|
+
this.flushedReasoning = this.streamingReasoning;
|
|
451
|
+
parts.push(style(this.streamingReasoning));
|
|
452
|
+
}
|
|
453
|
+
this.appendLedger(`${parts.join("\n")}\n`, "reasoning");
|
|
432
454
|
}
|
|
433
455
|
this.streamingReasoning = "";
|
|
434
456
|
this.streamingThought = "";
|
|
@@ -926,10 +948,41 @@ export class LaunchTui {
|
|
|
926
948
|
}
|
|
927
949
|
|
|
928
950
|
private readonly onResize = (): void => {
|
|
951
|
+
if (this.finished) return;
|
|
952
|
+
const now = Date.now();
|
|
953
|
+
// Leading edge: a deliberate resize reflows instantly (no perceived lag).
|
|
954
|
+
if (now - this.lastResizeAt >= RESIZE_THROTTLE_MS) {
|
|
955
|
+
this.lastResizeAt = now;
|
|
956
|
+
this.resizeRepaint();
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
// Mid-throttle (continuous drag): coalesce, but ALWAYS schedule a trailing repaint
|
|
960
|
+
// so the final geometry paints exactly — never leave the frame stale at the old size.
|
|
961
|
+
if (this.resizeTimer) clearTimeout(this.resizeTimer);
|
|
962
|
+
this.resizeTimer = setTimeout(() => {
|
|
963
|
+
this.resizeTimer = undefined;
|
|
964
|
+
this.lastResizeAt = Date.now();
|
|
965
|
+
this.resizeRepaint();
|
|
966
|
+
}, RESIZE_THROTTLE_MS);
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
/** Repaint for a resize: re-measure, skip spurious same-geometry events, full repaint. */
|
|
970
|
+
private resizeRepaint(): void {
|
|
971
|
+
if (this.finished) return;
|
|
929
972
|
try {
|
|
973
|
+
const { cols, rows } = size();
|
|
974
|
+
if (cols === this.lastCols && rows === this.lastRows) return;
|
|
975
|
+
this.lastCols = cols;
|
|
976
|
+
this.lastRows = rows;
|
|
930
977
|
this.repaint();
|
|
931
978
|
} catch { /* resize race — next tick repaints */ }
|
|
932
|
-
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/** gjc-style agent identity: a bold accent `jeo` name label on its own line that leads
|
|
982
|
+
* every assistant segment — thought blocks (onAssistant) and the final reply (finish). */
|
|
983
|
+
private agentLabel(): string {
|
|
984
|
+
return this.theme.color ? chalk.bold(accentPaint(this.theme)("jeo")) : "jeo";
|
|
985
|
+
}
|
|
933
986
|
|
|
934
987
|
/** Collapse the live region to static final output. */
|
|
935
988
|
finish(reply: string): void {
|
|
@@ -939,6 +992,10 @@ export class LaunchTui {
|
|
|
939
992
|
clearInterval(this.timer);
|
|
940
993
|
this.timer = undefined;
|
|
941
994
|
}
|
|
995
|
+
if (this.resizeTimer) {
|
|
996
|
+
clearTimeout(this.resizeTimer);
|
|
997
|
+
this.resizeTimer = undefined;
|
|
998
|
+
}
|
|
942
999
|
if (this.tty) {
|
|
943
1000
|
process.stdout.removeListener("resize", this.onResize);
|
|
944
1001
|
}
|
|
@@ -998,8 +1055,11 @@ export class LaunchTui {
|
|
|
998
1055
|
// color:false keeps the plain stripMarkdown text for pipes/tests.
|
|
999
1056
|
const tabled = renderMarkdownTables(reply, { unicode: this.unicode });
|
|
1000
1057
|
const renderedReply = this.theme.color
|
|
1001
|
-
? renderMarkdownAnsi(tabled, { accent: s => chalk.bold(accentPaint(this.theme)(s)) })
|
|
1058
|
+
? renderMarkdownAnsi(tabled, { accent: s => chalk.bold(accentPaint(this.theme)(s)), muted: mutedPaint(this.theme) })
|
|
1002
1059
|
: stripMarkdown(tabled);
|
|
1060
|
+
// gjc-style agent identity: a bold accent `jeo` label on its OWN line leads the reply
|
|
1061
|
+
// (mirrors gjc's `gajae` header), instead of an inline `jeo>` prompt prefix.
|
|
1062
|
+
const nameLabel = this.agentLabel();
|
|
1003
1063
|
const steps = this.footer.step || 0;
|
|
1004
1064
|
const peak = this.progress.current();
|
|
1005
1065
|
const usageSuffix = this.turnUsage ? ` · ${formatUsage(this.turnUsage)}` : "";
|
|
@@ -1009,7 +1069,8 @@ export class LaunchTui {
|
|
|
1009
1069
|
// track). The live ledger above already recorded every step. A blank spacer
|
|
1010
1070
|
// row separates the ledger from the answer (jeo-ref vertical rhythm).
|
|
1011
1071
|
finalLines.push("");
|
|
1012
|
-
finalLines.push(
|
|
1072
|
+
finalLines.push(nameLabel);
|
|
1073
|
+
finalLines.push(renderedReply);
|
|
1013
1074
|
if (planLines.length) {
|
|
1014
1075
|
finalLines.push("");
|
|
1015
1076
|
finalLines.push(...planLines);
|
|
@@ -1018,7 +1079,8 @@ export class LaunchTui {
|
|
|
1018
1079
|
finalLines.push(this.theme.color ? chalk.dim(statusLine) : statusLine);
|
|
1019
1080
|
} else {
|
|
1020
1081
|
finalLines.push(`Evolved to: ${evolutionTrack(peak, { unicode: this.unicode, color: this.theme.color })} (took ${steps} steps in ${formatDuration(Date.now() - this.startedAt)}${usageSuffix})`);
|
|
1021
|
-
finalLines.push(
|
|
1082
|
+
finalLines.push(nameLabel);
|
|
1083
|
+
finalLines.push(renderedReply);
|
|
1022
1084
|
if (planLines.length) {
|
|
1023
1085
|
finalLines.push(...planLines);
|
|
1024
1086
|
}
|
|
@@ -162,11 +162,13 @@ export function complete(line: string, ctx: CompletionContext): CompletionResult
|
|
|
162
162
|
case "/fast":
|
|
163
163
|
return argIndex === 0 ? finish(["on", "off", "status"], "subcommand") : { completions: [], token, kind: "none" };
|
|
164
164
|
case "/provider": {
|
|
165
|
+
// /provider is onboarding-only (gjc parity): login + add. Model/provider
|
|
166
|
+
// switching completes under /model, not here.
|
|
165
167
|
const cloud = ["anthropic", "openai", "gemini", "antigravity"];
|
|
166
|
-
if (argIndex === 0) return finish(["login", "
|
|
167
|
-
|
|
168
|
-
if (
|
|
169
|
-
if (
|
|
168
|
+
if (argIndex === 0) return finish(["login", "add", "help"], "subcommand");
|
|
169
|
+
const sub = tokens[1]?.toLowerCase();
|
|
170
|
+
if (sub === "login" || sub === "auth") return argIndex === 1 ? finish(cloud, "provider") : { completions: [], token, kind: "none" };
|
|
171
|
+
if (sub === "add") return finish(["--base-url", "--model", "--compat", "clear"], "subcommand");
|
|
170
172
|
return { completions: [], token, kind: "none" };
|
|
171
173
|
}
|
|
172
174
|
case "/logout":
|
|
@@ -173,7 +173,7 @@ export function formatPickList(entries: PickEntry[], opts: { current?: string; c
|
|
|
173
173
|
const mark = opts.current && e.model === opts.current ? chalk.green(" ◀ current") : "";
|
|
174
174
|
return ` ${chalk.yellow(tag)} ${e.model} ${chalk.gray(`(${e.provider})`)}${mark}`;
|
|
175
175
|
});
|
|
176
|
-
if (entries.length > cap) lines.push(chalk.gray(` …(+${entries.length - cap} more — narrow with /
|
|
176
|
+
if (entries.length > cap) lines.push(chalk.gray(` …(+${entries.length - cap} more — narrow with /model or /search)`));
|
|
177
177
|
return lines;
|
|
178
178
|
}
|
|
179
179
|
|
|
@@ -202,7 +202,7 @@ export function formatPickListWithCapabilities(entries: PickEntry[], opts: { cur
|
|
|
202
202
|
` ${chalk.yellow(`#${e.index}`.padStart(iw))} ${chalk.gray(e.provider.padEnd(pw))} ${id} ${ctx.padStart(5)} ${out.padStart(5)} ${chalk.cyan(think)} ${img}${mark}`,
|
|
203
203
|
);
|
|
204
204
|
}
|
|
205
|
-
if (entries.length > cap) lines.push(chalk.gray(` …(+${entries.length - cap} more — narrow with /
|
|
205
|
+
if (entries.length > cap) lines.push(chalk.gray(` …(+${entries.length - cap} more — narrow with /model)`));
|
|
206
206
|
return lines;
|
|
207
207
|
}
|
|
208
208
|
|
|
@@ -23,6 +23,7 @@ export function stripMarkdown(text: string): string {
|
|
|
23
23
|
out = out.replace(/___([^\_]+)___/g, "$1");
|
|
24
24
|
out = out.replace(/__([^\_]+)__/g, "$1");
|
|
25
25
|
out = out.replace(/_([^\_]+)_/g, "$1");
|
|
26
|
+
out = out.replace(/~~([^~\n]+)~~/g, "$1");
|
|
26
27
|
|
|
27
28
|
// 5. Convert links [text](url) -> text (url)
|
|
28
29
|
out = out.replace(/\[([^\]]+)\]\(([^\)]+)\)/g, "$1 ($2)");
|
|
@@ -42,6 +43,13 @@ export function stripMarkdown(text: string): string {
|
|
|
42
43
|
export interface MarkdownAnsiOptions {
|
|
43
44
|
/** Heading painter (theme accent + bold); default chalk.bold. */
|
|
44
45
|
accent?: (s: string) => string;
|
|
46
|
+
/** Secondary-text painter for link URLs, the blockquote gutter, horizontal rules,
|
|
47
|
+
* and ~~struck~~ text. jeo tone uses a REAL theme mid-tone hue here instead of ANSI
|
|
48
|
+
* `dim`, which collapses to near-invisible on dark terminals (parity with the
|
|
49
|
+
* todo/forge cards). Default chalk.dim when no theme painter is threaded. */
|
|
50
|
+
muted?: (s: string) => string;
|
|
51
|
+
/** Inline `code` painter; default chalk.cyan so code stays distinct from accent headings. */
|
|
52
|
+
code?: (s: string) => string;
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
/**
|
|
@@ -54,14 +62,16 @@ export interface MarkdownAnsiOptions {
|
|
|
54
62
|
export function renderMarkdownAnsi(text: string, opts: MarkdownAnsiOptions = {}): string {
|
|
55
63
|
if (!text) return "";
|
|
56
64
|
const accent = opts.accent ?? ((s: string) => chalk.bold(s));
|
|
65
|
+
const muted = opts.muted ?? ((s: string) => chalk.dim(s));
|
|
66
|
+
const code = opts.code ?? ((s: string) => chalk.cyan(s));
|
|
57
67
|
|
|
58
68
|
// Links/images FIRST: bold/code styling injects ANSI escapes whose `[` would
|
|
59
69
|
// otherwise be swallowed by the `[label](url)` matcher (corrupting both).
|
|
60
70
|
const styleInline = (line: string): string =>
|
|
61
71
|
line
|
|
62
72
|
.replace(/!\[([^\]]*)\]\(([^\)]+)\)/g, "$1")
|
|
63
|
-
.replace(/\[([^\]]+)\]\(([^\)]+)\)/g, (_m, label: string, url: string) => `${label} ${
|
|
64
|
-
.replace(/`([^`]+)`/g, (_m,
|
|
73
|
+
.replace(/\[([^\]]+)\]\(([^\)]+)\)/g, (_m, label: string, url: string) => `${label} ${muted(`(${url})`)}`)
|
|
74
|
+
.replace(/`([^`]+)`/g, (_m, c: string) => code(c))
|
|
65
75
|
.replace(/\*\*\*([^\*]+)\*\*\*/g, (_m, t: string) => chalk.bold.italic(t))
|
|
66
76
|
.replace(/\*\*([^\*]+)\*\*/g, (_m, t: string) => chalk.bold(t))
|
|
67
77
|
.replace(/__([^\_]+)__/g, (_m, t: string) => chalk.bold(t))
|
|
@@ -70,7 +80,11 @@ export function renderMarkdownAnsi(text: string, opts: MarkdownAnsiOptions = {})
|
|
|
70
80
|
// closing `*`); the `_` form is word-boundary guarded so snake_case
|
|
71
81
|
// identifiers (foo_bar_baz) are never mistaken for emphasis.
|
|
72
82
|
.replace(/\*([^\s*][^*\n]*?[^\s*]|[^\s*])\*/g, (_m, t: string) => chalk.italic(t))
|
|
73
|
-
.replace(/(?<![\p{L}\p{N}_])_(?=\S)([^_\n]*?)(?<=\S)_(?![\p{L}\p{N}_])/gu, (_m, t: string) => chalk.italic(t))
|
|
83
|
+
.replace(/(?<![\p{L}\p{N}_])_(?=\S)([^_\n]*?)(?<=\S)_(?![\p{L}\p{N}_])/gu, (_m, t: string) => chalk.italic(t))
|
|
84
|
+
// ~~strikethrough~~ → struck + muted: retracted/superseded text reads as
|
|
85
|
+
// de-emphasized (jeo tone parity with done-todo rows). Runs last so it never
|
|
86
|
+
// eats an earlier emphasis marker.
|
|
87
|
+
.replace(/~~([^~\n]+)~~/g, (_m, t: string) => chalk.strikethrough(muted(t)));
|
|
74
88
|
|
|
75
89
|
const out: string[] = [];
|
|
76
90
|
let inFence = false;
|
|
@@ -121,14 +135,14 @@ export function renderMarkdownAnsi(text: string, opts: MarkdownAnsiOptions = {})
|
|
|
121
135
|
if (lastType !== "blockquote" && lastType !== "empty") {
|
|
122
136
|
ensureBlankLine();
|
|
123
137
|
}
|
|
124
|
-
out.push(
|
|
138
|
+
out.push(muted(`▎ ${styleInline(quote[1]!)}`));
|
|
125
139
|
lastType = "blockquote";
|
|
126
140
|
continue;
|
|
127
141
|
}
|
|
128
142
|
|
|
129
143
|
if (/^[-\*_]{3,}\s*$/.test(line)) {
|
|
130
144
|
ensureBlankLine();
|
|
131
|
-
out.push(
|
|
145
|
+
out.push(muted("─".repeat(24)));
|
|
132
146
|
ensureBlankLine();
|
|
133
147
|
lastType = "empty";
|
|
134
148
|
continue;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Slash-command palette/autocomplete for the interactive REPL (TUI M3). */
|
|
2
2
|
import chalk from "chalk";
|
|
3
|
+
import { editDistance } from "../../ai/model-catalog-compat";
|
|
3
4
|
|
|
4
5
|
/** Order-preserving subsequence test: every char of `needle` appears in `hay`
|
|
5
6
|
* left-to-right (gjc-style fuzzy match, e.g. "expt" ⊑ "export"). */
|
|
@@ -33,9 +34,10 @@ export const SLASH_COMMAND_DETAILS: readonly SlashCommandInfo[] = [
|
|
|
33
34
|
{ command: "/dump", usage: "/dump", description: "Copy the session transcript to the clipboard", group: "session" },
|
|
34
35
|
{ command: "/btw", usage: "/btw <question>", description: "Ask an ephemeral side question (history untouched)", group: "session" },
|
|
35
36
|
{ command: "/compact", usage: "/compact", description: "Summarize older turns to free context", group: "session" },
|
|
37
|
+
{ command: "/goal", usage: "/goal <condition>", description: "Set a natural language stop condition for the session", group: "session" },
|
|
36
38
|
{ command: "/model", usage: "/model [id|#N|save|thinking <level>|subagent <role> <model|#N|thinking L>]", description: "Show/switch model; picker can apply to default or any subagent role and set thinking", group: "models" },
|
|
37
39
|
{ command: "/fast", usage: "/fast [on|off|status]", description: "Toggle fast thinking mode when the active model supports it", group: "models" },
|
|
38
|
-
{ command: "/provider", usage: "/provider [name] [model
|
|
40
|
+
{ command: "/provider", usage: "/provider [login [name] | add --base-url <url> [--model <m>]]", description: "Provider onboarding: `login [name]` starts OAuth; `add --base-url <url>` registers an OpenAI-compatible endpoint. Switch the active model/provider with /model", group: "models" },
|
|
39
41
|
{ command: "/login", usage: "/login [provider]", description: "OAuth login (alias of /provider login)", group: "models" },
|
|
40
42
|
{ command: "/logout", usage: "/logout <anthropic|openai|gemini|antigravity>", description: "Remove the stored OAuth token for a provider", group: "models" },
|
|
41
43
|
{ command: "/roles", usage: "/roles [tier model]", description: "Show or set model role tiers (smol/slow/plan)", group: "models" },
|
|
@@ -87,6 +89,23 @@ export function isSlashAttempt(input: string): boolean {
|
|
|
87
89
|
return input.startsWith("/") && !input.slice(1).includes(" ");
|
|
88
90
|
}
|
|
89
91
|
|
|
92
|
+
/** Near-miss slash commands for a true typo — edit distance ≤ 2 on the command body,
|
|
93
|
+
* excluding the prefix/fuzzy hits `matchSlash` already surfaces. gjc parity for the
|
|
94
|
+
* `/provicer` → `/provider` correction. Ranked closest-first and capped. */
|
|
95
|
+
export function suggestSlashCommands(input: string, commands: string[] = SLASH_COMMANDS, limit = 3): string[] {
|
|
96
|
+
if (!input.startsWith("/")) return [];
|
|
97
|
+
const body = input.slice(1).toLowerCase();
|
|
98
|
+
if (body === "") return [];
|
|
99
|
+
const already = new Set(matchSlash(input, commands));
|
|
100
|
+
return commands
|
|
101
|
+
.filter(c => !already.has(c))
|
|
102
|
+
.map(c => ({ c, d: editDistance(body, c.slice(1).toLowerCase()) }))
|
|
103
|
+
.filter(s => s.d <= 2)
|
|
104
|
+
.sort((a, b) => a.d - b.d || a.c.localeCompare(b.c))
|
|
105
|
+
.slice(0, limit)
|
|
106
|
+
.map(s => s.c);
|
|
107
|
+
}
|
|
108
|
+
|
|
90
109
|
const GROUP_LABELS: Record<SlashCommandInfo["group"], string> = {
|
|
91
110
|
session: "Session",
|
|
92
111
|
models: "Models / Providers",
|
|
@@ -103,7 +122,11 @@ export function formatSlashCommandList(input = "/", extra: readonly SlashCommand
|
|
|
103
122
|
const commands = details.map(c => c.command);
|
|
104
123
|
const query = input === "/?" ? "/" : input;
|
|
105
124
|
const matches = matchSlash(query, commands);
|
|
106
|
-
if (matches.length === 0)
|
|
125
|
+
if (matches.length === 0) {
|
|
126
|
+
const near = suggestSlashCommands(query, commands);
|
|
127
|
+
const tail = near.length ? `Did you mean ${near.join(", ")}?` : "Try /help.";
|
|
128
|
+
return [`Unknown command '${input}'. ${tail}`];
|
|
129
|
+
}
|
|
107
130
|
const wanted = new Set(matches);
|
|
108
131
|
const rows = details.filter(c => wanted.has(c.command));
|
|
109
132
|
const usageWidth = Math.max(...rows.map(c => c.usage.length), 6);
|