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/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 and cleared on commit ephemeral, never flushed (the durable record is the
213
- * action/reply the thinking produced). */
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 transient dimmed block as the JSON-reasoning
404
- // path (reuses the screen-safe tail renderer; no new frame structure). Ephemeral:
405
- // cleared on commit, never flushed into scrollback.
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
- // Flush the streamed reasoning once into scrollback as a jeo-ref reasoning
417
- // block a muted "Reasoning" divider header, the prose below it (the durable
418
- // record) then stop showing the transient live reasoning row.
419
- if (this.streamingReasoning && this.streamingReasoning !== this.flushedReasoning) {
420
- this.flushedReasoning = this.streamingReasoning;
421
- // A muted "Reasoning" card-header divider announces the reasoning block
422
- // boundary (consistent with the section design tokens — Thinking/Reasoning/
423
- // Output share one visual language); the prose below it is the durable record.
424
- // A full-width divider ROW respects appendLedger's 1-line=1-row pre-wrap
425
- // invariant (no per-line prefix), unlike a left-border enclosure which would
426
- // push wrapped lines past `cols` and tear the frame.
427
- const header = sectionLabel("Reasoning", Math.max(20, size().cols), {
428
- color: this.theme.color,
429
- unicode: this.unicode,
430
- });
431
- this.appendLedger(`${header}\n${this.streamingReasoning}\n`, "reasoning");
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(`jeo> ${renderedReply}`);
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(`jeo> ${renderedReply}`);
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", "auth", ...ctx.providers], "provider");
167
- // `/provider login|auth <name>` → cloud provider names (OAuth-capable).
168
- if (argIndex === 1 && (tokens[1]?.toLowerCase() === "login" || tokens[1]?.toLowerCase() === "auth")) return finish(cloud, "provider");
169
- if (argIndex === 1) return finish(ctx.modelsForProvider(tokens[1] ?? ""), "model");
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 /provider <name> or /search)`));
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 /provider <name>)`));
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} ${chalk.dim(`(${url})`)}`)
64
- .replace(/`([^`]+)`/g, (_m, code: string) => chalk.cyan(code))
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(chalk.dim(`▎ ${styleInline(quote[1]!)}`));
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(chalk.dim("─".repeat(24)));
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|#N]", description: "Credentials, switch provider, set model; `login <name>` starts OAuth", group: "models" },
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) return [`Unknown command '${input}'. Try /help.`];
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);