jeo-code 0.7.31 → 0.7.32

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.
@@ -9,6 +9,10 @@ import { createTaskTool, taskToolProtocolLine, type TaskSubEvent } from "../agen
9
9
  import { createSubagentTool, SUBAGENT_TOOL_PROTOCOL_LINE } from "../agent/subagent-tool";
10
10
  import { SubagentRegistry } from "../agent/subagent-registry";
11
11
  import { createTodoTool, TODO_TOOL_PROTOCOL_LINE } from "../agent/todo-tool";
12
+ import { createJobTool, JOB_TOOL_PROTOCOL_LINE } from "../agent/job-tool";
13
+ import { JobRegistry } from "../agent/job-registry";
14
+ import { createIrcTool, IRC_TOOL_PROTOCOL_LINE } from "../agent/irc-tool";
15
+ import { createGoalTool, GOAL_TOOL_PROTOCOL_LINE } from "../agent/goal-tool";
12
16
  import { LaunchTui } from "../tui/app";
13
17
  import { runDeepInterviewEngine, type DeepInterviewEngineOptions } from "./deep-interview";
14
18
  import { runRalplanEngine, type RalplanEngineOptions } from "./ralplan";
@@ -46,7 +50,7 @@ import { openaiCompatDef, SUBSCRIPTION_PROVIDER_NAMES } from "../ai/providers/op
46
50
 
47
51
  import { allSubagentRoles, getSubagentRole, resolveSubagentModel, resolveSubagentMaxSteps, resolveSubagentThinking, parseMaxSteps, withSubagentSetting, clearSubagentSetting, applyTargetChoices } from "../agent/subagents";
48
52
  import { SelectList, renderSelectList, type SelectItem } from "../tui/components/select-list";
49
- import { SessionPicker, renderSessionPicker } from "../tui/components/session-picker";
53
+
50
54
  import {
51
55
  formatModelLine,
52
56
  formatProviderPanel,
@@ -70,7 +74,7 @@ import { renderStatusBar } from "../tui/components/status";
70
74
  import { detectColorLevel, ColorLevel, visibleWidth } from "../tui/components/color";
71
75
  import { readClipboardImage } from "../util/clipboard-image";
72
76
  import { attachImagePaths, insertImageTag } from "../util/file-attachment";
73
- import { formatTranscript } from "../tui/components/transcript";
77
+
74
78
  import { copyTextToClipboard, readClipboardText } from "../tui/clipboard";
75
79
  import { loadInputHistory, appendInputHistory } from "../agent/input-history";
76
80
  import type { ImageAttachment } from "../ai/types";
@@ -95,11 +99,9 @@ import {
95
99
  listSessions,
96
100
  latestSessionId,
97
101
  exportSession,
98
- renameSession,
99
102
  updateSessionModel,
100
- deleteSession,
101
- sessionPath,
102
103
  appendCompaction,
104
+ resolveSessionRef,
103
105
  } from "../agent/session";
104
106
  import { clearLine, cursorUp, toColumn, truncate as truncateAnsi, size as terminalSize, resetMouseTracking, clearScreen, clearVisible, clearToEnd, enableModifyOtherKeys, disableModifyOtherKeys, enableKittyKeyboard, disableKittyKeyboard } from "../tui/terminal";
105
107
 
@@ -197,7 +199,11 @@ import {
197
199
  handleSearchSlash,
198
200
  } from "./launch/code-slash";
199
201
  import { handleUndoSlash } from "./launch/git-slash";
202
+ import { runSessionSlash } from "./launch/session-slash";
203
+ import { runModelSlash } from "./launch/model-slash";
204
+ import { runAgentsSlash, runRolesSlash, runFastSlash, runThinkingSlash } from "./launch/agents-slash";
200
205
  import { wikiRootPromptLine, decideWikiSlash } from "./launch/wiki-slash";
206
+ import { decideThemeSlash, buildConfigPanelLines, runEvolveSimulation } from "./launch/system-slash";
201
207
 
202
208
 
203
209
 
@@ -267,6 +273,12 @@ export function normalizeSlashAlias(input: string): string {
267
273
  if (input === "/subagent" || input.startsWith("/subagent ")) return `/agents${input.slice("/subagent".length)}`;
268
274
  if (input === "/subagents" || input.startsWith("/subagents ")) return `/agents${input.slice("/subagents".length)}`;
269
275
  if (input === "/resume" || input.startsWith("/resume ")) return `/session resume${input.slice("/resume".length)}`;
276
+ // gjc-parity: /new, /drop, /rename, /sessions are top-level palette aliases for
277
+ // their /session <sub> implementations (same pattern as /resume above).
278
+ if (input === "/new") return "/session new";
279
+ if (input === "/drop") return "/session drop";
280
+ if (input === "/rename" || input.startsWith("/rename ")) return `/session rename${input.slice("/rename".length)}`;
281
+ if (input === "/sessions") return "/session list";
270
282
  return input;
271
283
  }
272
284
 
@@ -513,7 +525,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
513
525
  // pi-style: load project context (JEO.md / AGENTS.md / .jeo/context.md / CLAUDE.md) into the prompt.
514
526
  const contextFiles = await loadProjectContext(cwd);
515
527
 
516
- const KNOWN_TOOLS = new Set(["read", "write", "edit", "bash", "find", "search", "ls", "task", "todo", "subagent"]);
528
+ const KNOWN_TOOLS = new Set(["read", "write", "edit", "bash", "find", "search", "ls", "task", "todo", "subagent", "job", "irc", "goal", "ast_grep", "ast_edit", "computer", "lsp", "lsp_rename", "debug"]);
517
529
  let allowedTools = new Set(KNOWN_TOOLS);
518
530
 
519
531
  if (flags.noTools) {
@@ -584,6 +596,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
584
596
  (allowedTools.has("todo") ? "\n\nPlanning: " + TODO_TOOL_PROTOCOL_LINE : "") +
585
597
  (allowedTools.has("subagent") ? "\n\nDetached subagents: " + SUBAGENT_TOOL_PROTOCOL_LINE +
586
598
  " Launch background work with task {\"detached\": true, \"role\": <role>, \"task\": <assignment>}; it returns a subagent id immediately so you can keep working and collect the result later." : "") +
599
+ (allowedTools.has("job") ? "\n\nBackground jobs: " + JOB_TOOL_PROTOCOL_LINE : "") +
600
+ (allowedTools.has("irc") ? "\n\nPeer messaging: " + IRC_TOOL_PROTOCOL_LINE : "") +
601
+ (allowedTools.has("goal") ? "\n\nGoal tracking: " + GOAL_TOOL_PROTOCOL_LINE : "") +
587
602
  (effectiveNoSkills ? "" :
588
603
  "\n\nJEO workflow routing:\n" +
589
604
  "- Answer the user's request DIRECTLY. Never reply with a catalog, list, or summary of skills unless the user explicitly asks what skills exist.\n" +
@@ -710,9 +725,50 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
710
725
  let sessionId: string | undefined;
711
726
  let compactionSeq = 0;
712
727
  if (!flags.noSession) {
713
- if (flags.resume) {
714
- const id = flags.resumeId ?? (await latestSessionId(cwd));
715
- if (!id) {
728
+ if (flags.resumeInteractive) {
729
+ // Bare `--resume`/`-r` (no value) at cold startup (gjc-parity): the interactive
730
+ // session picker — added right before the REPL loop starts, below — takes over
731
+ // and replaces this session before the user ever sees it, but ONLY in a TTY.
732
+ // Start with a fresh session here as the safe default either way.
733
+ sessionId = (await createSession(cwd, undefined, sessionModel)).id;
734
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
735
+ // No TTY means no picker is possible — fall back to the same silent-latest
736
+ // behavior as `--continue` rather than silently doing nothing.
737
+ const id = await latestSessionId(cwd);
738
+ if (id) {
739
+ try {
740
+ const { header, messages } = await loadSession(id, cwd);
741
+ for (const m of messages) history.push(m);
742
+ sessionId = id;
743
+ if (!initialSessionModel && header.model) sessionModel = header.model;
744
+ const modelNote = sessionModel ? ` · model ${sessionModel}` : "";
745
+ console.log(`Resumed session ${id} (${messages.length} messages).${modelNote}`);
746
+ } catch (err) {
747
+ console.log(`Could not resume ${id}: ${(err as Error).message}. Starting fresh.`);
748
+ }
749
+ }
750
+ }
751
+ } else if (flags.resume) {
752
+ let id: string | undefined;
753
+ let skipResume = false;
754
+ if (flags.resumeId) {
755
+ // gjc-parity: resolve as a full id OR a short id prefix.
756
+ const resolved = await resolveSessionRef(flags.resumeId, cwd);
757
+ if (resolved.kind === "ok") {
758
+ id = resolved.id;
759
+ } else if (resolved.kind === "ambiguous") {
760
+ console.log(`Ambiguous session id '${flags.resumeId}' — matches: ${resolved.matches.slice(0, 5).join(", ")}${resolved.matches.length > 5 ? " …" : ""}. Use more characters or run with --resume (no value) to pick interactively.`);
761
+ skipResume = true;
762
+ } else {
763
+ console.log(`Session '${flags.resumeId}' not found. Starting a new one.`);
764
+ skipResume = true;
765
+ }
766
+ } else {
767
+ id = await latestSessionId(cwd);
768
+ }
769
+ if (skipResume) {
770
+ sessionId = (await createSession(cwd, undefined, sessionModel)).id;
771
+ } else if (!id) {
716
772
  console.log("No session to resume. Starting a new one.");
717
773
  sessionId = (await createSession(cwd, undefined, sessionModel)).id;
718
774
  } else {
@@ -964,6 +1020,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
964
1020
  // #9: per-turn registry for DETACHED subagents (task{detached:true}); the
965
1021
  // `subagent` tool controls them and cancelAll() in finally prevents orphans.
966
1022
  const subagentRegistry = new SubagentRegistry();
1023
+ const jobRegistry = new JobRegistry();
967
1024
  try {
968
1025
  // Per-turn todo snapshot: drives the done-time reconciliation gate (the
969
1026
  // Todos checklist used to end a finished turn stuck at "✓0 ◐1 ·4 / 5"
@@ -1025,6 +1082,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1025
1082
  }),
1026
1083
  todo: createTodoTool({ onChange: items => { turnTodos = items; tui?.setTodos(items); } }),
1027
1084
  subagent: createSubagentTool(subagentRegistry),
1085
+ job: createJobTool(jobRegistry),
1086
+ irc: createIrcTool(subagentRegistry),
1087
+ goal: createGoalTool(),
1028
1088
  };
1029
1089
  const tools = filterToolMap(fullTools, Array.from(allowedTools));
1030
1090
  // Opik observability (opt-in via JEO_OPIK): one trace per turn, spans per
@@ -1083,6 +1143,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1083
1143
  } finally {
1084
1144
  harness.dispose();
1085
1145
  subagentRegistry.cancelAll(); // #9: no detached run leaks past the turn
1146
+ jobRegistry.cancelAll(); // background jobs are turn-scoped too — no orphaned processes
1086
1147
  // Steering typed but never drained (e.g. entered just after the final step)
1087
1148
  // must not be lost — fold it into the next prompt draft so it runs next.
1088
1149
  const leftover = steerInbox.splice(0, steerInbox.length).map(s => s.trim()).filter(Boolean);
@@ -1217,6 +1278,74 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1217
1278
  console.log("Tools: read / write / edit / bash / find / search. Sessions persist to .jeo/sessions/.");
1218
1279
  return;
1219
1280
  }
1281
+ if (cmd === "/usage") {
1282
+ const cfg = await readGlobalConfig();
1283
+ const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
1284
+ const result = handleUsage(ctx, sessionUsage);
1285
+ if (result && "lines" in result) for (const line of result.lines) console.log(line);
1286
+ return;
1287
+ }
1288
+ if (cmd === "/context") {
1289
+ const cfg = await readGlobalConfig();
1290
+ const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
1291
+ const result = await handleContext(ctx);
1292
+ if (result && "lines" in result) for (const line of result.lines) console.log(line);
1293
+ return;
1294
+ }
1295
+ if (cmd === "/tools") {
1296
+ const cfg = await readGlobalConfig();
1297
+ const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
1298
+ const result = await handleTools(ctx);
1299
+ if (result && "lines" in result) for (const line of result.lines) console.log(line);
1300
+ return;
1301
+ }
1302
+ if (cmd === "/hotkeys") {
1303
+ const cfg = await readGlobalConfig();
1304
+ const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
1305
+ const result = handleHotkeys(ctx);
1306
+ if (result && "lines" in result) for (const line of result.lines) console.log(line);
1307
+ return;
1308
+ }
1309
+ if (cmd === "/theme" || cmd.startsWith("/theme ")) {
1310
+ const decision = decideThemeSlash(cmd, listThemes(), resolveTheme().name);
1311
+ for (const line of decision.lines) console.log(line);
1312
+ if (decision.kind === "set") {
1313
+ process.env.JEO_TUI_THEME = decision.themeName!;
1314
+ await saveConfigPatch(raw => ({ theme: decision.themeName }));
1315
+ // No refreshUiTheme() here — one-shot mode returns immediately after this
1316
+ // block, so there is no live TUI paint state to re-resolve (and
1317
+ // refreshUiTheme is declared later in this closure, out of scope here).
1318
+ }
1319
+ return;
1320
+ }
1321
+ if (cmd === "/wiki" || cmd.startsWith("/wiki ")) {
1322
+ const decision = decideWikiSlash(
1323
+ cmd,
1324
+ resolveWikiRoot(await readGlobalConfig()),
1325
+ !!jeoEnv("WIKI_ROOT"),
1326
+ );
1327
+ for (const line of decision.lines) console.log(line);
1328
+ if (decision.kind === "clear") {
1329
+ await saveConfigPatch(() => ({ wikiRoot: undefined }));
1330
+ delete process.env.JEO_WIKI_ROOT;
1331
+ sessionWikiRoot = undefined;
1332
+ await refreshSessionMemory("");
1333
+ } else if (decision.kind === "set") {
1334
+ await saveConfigPatch(() => ({ wikiRoot: decision.persistArg }));
1335
+ process.env.JEO_WIKI_ROOT = decision.root;
1336
+ sessionWikiRoot = decision.root;
1337
+ await refreshSessionMemory("");
1338
+ }
1339
+ return;
1340
+ }
1341
+ if (cmd === "/config" || cmd === "/settings") {
1342
+ for (const line of await buildConfigPanelLines({ sessionModel, sessionThinking, sessionId })) console.log(line);
1343
+ return;
1344
+ }
1345
+ if (cmd === "/evolve") {
1346
+ await runEvolveSimulation(animateAsciiArt);
1347
+ return;
1348
+ }
1220
1349
  }
1221
1350
  // One skill run (bundle workflow → engine; regular skill → agent turn). Shared by the
1222
1351
  // single-invocation path and the `$a $b …` chain path so every `$` skill actually runs.
@@ -2379,14 +2508,6 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2379
2508
  }
2380
2509
  };
2381
2510
 
2382
- // Antigravity with ANY Google OAuth (own login or the gemini-cli fallback) stays
2383
- // SELECTABLE in pickers even when not call-ready: picking the model is how users
2384
- // reach the flow, and the auth layer gives actionable login guidance on the first
2385
- // call if the fallback token is rejected (403). Refusing selection was a dead end.
2386
- const selectableThoughNotReady = (st?: { name: string; kind: string }): boolean =>
2387
- !!st && st.name === "antigravity" && st.kind === "oauth";
2388
- const notReadyWarning = (st: { name: string; label: string }): string =>
2389
- ` ! ${st.name} is not call-ready yet (${st.label}) — run /provider login antigravity before the first turn.`;
2390
2511
 
2391
2512
 
2392
2513
  const MODEL_BADGE_ROLE_ORDER = ["planner", "architect", "executor", "critic"] as const;
@@ -3088,6 +3209,21 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3088
3209
  });
3089
3210
  }
3090
3211
 
3212
+ // Bare `--resume`/`-r` (no value) at cold startup (gjc-parity): show the SAME
3213
+ // interactive session picker `/session resume` (no arg) uses, in place of the
3214
+ // fresh session created above. TTY-only — non-TTY already fell back to
3215
+ // silent-latest resume in the startup block above.
3216
+ if (flags.resumeInteractive && !flags.noSession && process.stdin.isTTY && process.stdout.isTTY) {
3217
+ const pickResult = await runSessionSlash("/session resume", {
3218
+ cwd, history, noSession: flags.noSession, sessionId, sessionModel, rl,
3219
+ advanceSessionBoxColor, disarmPreview, clearScreen, freshWelcomeLines, logLines, runSelectPicker,
3220
+ });
3221
+ sessionId = pickResult.sessionId;
3222
+ if (pickResult.sessionModel !== undefined) sessionModel = pickResult.sessionModel;
3223
+ if (pickResult.lastUserInput !== undefined) lastUserInput = pickResult.lastUserInput;
3224
+ if (pickResult.lastReply !== undefined) lastReply = pickResult.lastReply;
3225
+ }
3226
+
3091
3227
  while (true) {
3092
3228
  drainPendingTtyInput();
3093
3229
  // "New input first": queued FULL lines from the previous turn are folded
@@ -3227,10 +3363,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3227
3363
  const res = await maybeCompact(history, { model: activeModel, force: true, contextTokens });
3228
3364
  if (res.error) {
3229
3365
  console.error(chalk.red(res.error));
3230
- } else if (res.compacted && sessionId && res.replacesThrough !== undefined) {
3231
- const touchedNote = res.touchedFiles?.length ? ` Files touched: ${res.touchedFiles.join(", ")}.` : "";
3232
- const summaryText = res.summary ?? `[Earlier conversation omitted: ${res.removed} messages — summary unavailable.${touchedNote}]`;
3233
- await appendCompaction(sessionId, ++compactionSeq, summaryText, res.replacesThrough, cwd);
3366
+ } else if (res.compacted) {
3367
+ if (sessionId && res.replacesThrough !== undefined) {
3368
+ const touchedNote = res.touchedFiles?.length ? ` Files touched: ${res.touchedFiles.join(", ")}.` : "";
3369
+ const summaryText = res.summary ?? `[Earlier conversation omitted: ${res.removed} messages — summary unavailable.${touchedNote}]`;
3370
+ await appendCompaction(sessionId, ++compactionSeq, summaryText, res.replacesThrough, cwd);
3371
+ }
3234
3372
  console.log(`(compacted ${res.removed} older messages${res.touchedFiles?.length ? `; kept ${res.touchedFiles.length} file refs` : ""})`);
3235
3373
  } else {
3236
3374
  console.log("(nothing to compact)");
@@ -3238,226 +3376,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3238
3376
  continue;
3239
3377
  }
3240
3378
  if (input === "/session" || input.startsWith("/session ")) {
3241
- const tokens = input.substring(8).trim().split(/\s+/).filter(Boolean);
3242
- const sub = (tokens[0] ?? "").toLowerCase();
3243
-
3244
- const startFreshSession = async (verb: string): Promise<void> => {
3245
- history.length = 1;
3246
- if (!flags.noSession) {
3247
- sessionId = (await createSession(cwd, undefined, sessionModel)).id;
3248
- advanceSessionBoxColor(); // distinct input-box hue per newly opened session
3249
- console.log(`(${verb} — new session ${sessionId})`);
3250
- } else {
3251
- sessionId = undefined;
3252
- console.log(`(${verb} — sessions disabled)`);
3253
- }
3254
- };
3255
-
3256
- if (sub === "new") {
3257
- await startFreshSession("started fresh");
3258
- continue;
3259
- }
3260
- if (sub === "drop" || sub === "delete") {
3261
- if (sessionId) {
3262
- const removed = await deleteSession(sessionId, cwd);
3263
- console.log(removed ? `(deleted session ${sessionId})` : `(session ${sessionId} already gone)`);
3264
- }
3265
- await startFreshSession("dropped");
3266
- continue;
3267
- }
3268
- if (sub === "rename") {
3269
- const title = tokens.slice(1).join(" ").trim();
3270
- if (!title) {
3271
- console.log("Usage: /session rename <title>");
3272
- continue;
3273
- }
3274
- if (!sessionId) {
3275
- console.log("(sessions are disabled — nothing to rename)");
3276
- continue;
3277
- }
3278
- try {
3279
- await renameSession(sessionId, title, cwd);
3280
- console.log(`(session renamed to '${title}')`);
3281
- } catch (err) {
3282
- console.log(`! rename failed: ${(err as Error).message}`);
3283
- }
3284
- continue;
3285
- }
3286
- if (sub === "resume") {
3287
- const arg = tokens.slice(1).join(" ").trim();
3288
- const applyResume = async (rid: string): Promise<void> => {
3289
- try {
3290
- const { header, messages } = await loadSession(rid, cwd);
3291
- history.length = 1;
3292
- for (const m of messages) history.push(m);
3293
- sessionId = rid;
3294
- // Restore the model this session was last using (per-session model).
3295
- if (header.model) sessionModel = header.model;
3296
- // Seed /retry + reply marker from the last user/assistant turn.
3297
- lastUserInput = ""; lastReply = "";
3298
- for (let k = history.length - 1; k >= 1; k--) {
3299
- if (history[k]!.role === "user" && !lastUserInput) lastUserInput = String(history[k]!.content ?? "");
3300
- if (history[k]!.role === "assistant" && !lastReply) lastReply = String(history[k]!.content ?? "");
3301
- if (lastUserInput && lastReply) break;
3302
- }
3303
- // Seed readline's input history so ↑ in the prompt recalls THIS session's
3304
- // prior prompts (not just lines typed in the current run). readline history
3305
- // is newest-first; unshift in chronological order so the session's newest
3306
- // prompt lands at the front (first ↑). Skip injected/framed messages.
3307
- const rli = rl as unknown as { history?: string[] };
3308
- if (Array.isArray(rli.history)) {
3309
- const priorPrompts = history
3310
- .filter(m => m.role === "user")
3311
- .map(m => String(m.content ?? "").trim())
3312
- .filter(c => c && !c.startsWith("Tool [") && !c.startsWith("[mid-turn steering") && !c.startsWith("[Earlier conversation summary]"));
3313
- for (const p of priorPrompts) {
3314
- if (rli.history[0] !== p) rli.history.unshift(p);
3315
- }
3316
- }
3317
- // Clean restore: wipe the screen + scrollback BEFORE replaying the
3318
- // transcript so it can't collide with picker remnants, the prior
3319
- // conversation, or the live input frame — the "/resume corrupts the
3320
- // TUI" fix. Same proven path as /clear; re-render the welcome banner
3321
- // so the resumed view reads like a fresh, intact screen.
3322
- if (process.stdout.isTTY) {
3323
- disarmPreview();
3324
- process.stdout.write(clearScreen());
3325
- console.log(freshWelcomeLines().join("\n"));
3326
- }
3327
- const sep = "─".repeat(Math.min(48, Math.max(20, (process.stdout.columns ?? 80) - 1)));
3328
- logLines([
3329
- sep,
3330
- `resumed session ${rid} · ${messages.length} message(s) (/history all for the full transcript)`,
3331
- sep,
3332
- ...formatTranscript(history, { maxTurns: 6, color: true, unicode: true }),
3333
- sep,
3334
- ]);
3335
- } catch (err) {
3336
- console.log(`! ${(err as Error).message}`);
3337
- }
3338
- };
3339
- if (arg) { await applyResume(arg); continue; }
3340
- // No id → only sessions with a real conversation are resumable (every launch
3341
- // creates an empty session; those are noise).
3342
- let pool = (await listSessions(cwd)).filter(s => s.messageCount > 0);
3343
- if (pool.length === 0) {
3344
- console.log("(no saved sessions with history)");
3345
- continue;
3346
- }
3347
- // Interactive gjc-style picker on a TTY: type to filter, ↑↓/PgUp/PgDn to
3348
- // move, Enter resumes, Del deletes (press Del twice to confirm), Esc cancels.
3349
- if (process.stdin.isTTY && process.stdout.isTTY) {
3350
- // Loop so a delete refreshes the list and re-opens the picker in place.
3351
- for (;;) {
3352
- const picker = new SessionPicker(pool);
3353
- let action: { kind: "resume" | "delete"; id: string } | undefined;
3354
- let confirmDeleteId: string | undefined;
3355
- await runSelectPicker(
3356
- (cols, rows) => renderSessionPicker(picker, {
3357
- title: "Resume a session",
3358
- cols,
3359
- rows: Math.max(8, rows),
3360
- unicode: true,
3361
- color: true,
3362
- confirmDeleteId,
3363
- }),
3364
- (ch, key) => {
3365
- if (key?.name === "up") { confirmDeleteId = undefined; picker.up(); return false; }
3366
- if (key?.name === "down") { confirmDeleteId = undefined; picker.down(); return false; }
3367
- if (key?.name === "pageup") { confirmDeleteId = undefined; picker.page(-1); return false; }
3368
- if (key?.name === "pagedown") { confirmDeleteId = undefined; picker.page(1); return false; }
3369
- if (key?.name === "escape" || (key?.ctrl && key.name === "c")) return true;
3370
- if (key?.name === "delete") {
3371
- const sel = picker.selected();
3372
- if (!sel) return false;
3373
- if (confirmDeleteId === sel.id) { action = { kind: "delete", id: sel.id }; return true; }
3374
- confirmDeleteId = sel.id;
3375
- return false;
3376
- }
3377
- if (key?.name === "return" || key?.name === "enter") {
3378
- const sel = picker.selected();
3379
- if (sel) { action = { kind: "resume", id: sel.id }; return true; }
3380
- return false;
3381
- }
3382
- confirmDeleteId = undefined;
3383
- if (key?.name === "backspace") { picker.backspace(); return false; }
3384
- if (ch && ch >= " " && !key?.ctrl && !key?.meta) picker.typeChar(ch);
3385
- return false;
3386
- },
3387
- );
3388
- if (!action) { console.log("(resume cancelled)"); break; }
3389
- if (action.kind === "resume") { await applyResume(action.id); break; }
3390
- // Delete: drop the file, refresh the pool, and re-open the picker.
3391
- const delId = action.id;
3392
- try {
3393
- const removed = await deleteSession(delId, cwd);
3394
- console.log(removed ? `(deleted session ${delId})` : `(session ${delId} already gone)`);
3395
- } catch (err) {
3396
- console.log(`! delete failed: ${(err as Error).message}`);
3397
- }
3398
- if (delId === sessionId) await startFreshSession("dropped current session");
3399
- pool = pool.filter(s => s.id !== delId);
3400
- if (pool.length === 0) { console.log("(no saved sessions with history)"); break; }
3401
- }
3402
- continue;
3403
- }
3404
- // Non-TTY fallback: static list (resume with /session resume <id>).
3405
- console.log("Saved sessions — resume with /session resume <id>:");
3406
- for (const s of pool.slice(0, 15)) {
3407
- const marker = s.id === sessionId ? "*" : " ";
3408
- console.log(` ${marker}${s.id} (${s.messageCount} msgs) ${s.title ? `[${s.title}] ` : ""}${s.preview}`);
3409
- }
3410
- continue;
3411
- }
3412
- if (sub === "list") {
3413
- const sessions = await listSessions(cwd);
3414
- if (sessions.length === 0) console.log("(no saved sessions)");
3415
- for (const s of sessions) {
3416
- const marker = s.id === sessionId ? "*" : " ";
3417
- const title = s.title ? `[${s.title}] ` : "";
3418
- console.log(` ${marker}${s.id} (${s.messageCount} msgs) ${title}${s.preview}`);
3419
- }
3420
- continue;
3421
- }
3422
- if (sub === "info") {
3423
- if (!sessionId) {
3424
- console.log("Session: disabled (--no-session)");
3425
- continue;
3426
- }
3427
- const all = await listSessions(cwd);
3428
- const current = all.find(s => s.id === sessionId);
3429
- console.log("Session info:");
3430
- console.log(` id ${sessionId}`);
3431
- if (current?.title) console.log(` title ${current.title}`);
3432
- console.log(` file ${sessionPath(sessionId, cwd)}`);
3433
- console.log(` started ${current?.timestamp ?? "(this run)"}`);
3434
- console.log(` messages ${current?.messageCount ?? Math.max(0, history.length - 1)} persisted · ${history.length - 1} in context`);
3435
- console.log(` workspace ${cwd}`);
3436
- continue;
3437
- }
3438
- if (sub && sub !== "info") {
3439
- console.log("Usage: /session [list|info|new|drop|rename <title>|resume [id]]");
3440
- continue;
3441
- }
3442
-
3443
- // Default: list sessions AND show current session info
3444
- const sessions = await listSessions(cwd);
3445
- if (sessions.length === 0) {
3446
- console.log("(no saved sessions)");
3447
- } else {
3448
- console.log("Saved sessions:");
3449
- for (const s of sessions) {
3450
- const marker = s.id === sessionId ? "*" : " ";
3451
- const title = s.title ? `[${s.title}] ` : "";
3452
- console.log(` ${marker}${s.id} (${s.messageCount} msgs) ${title}${s.preview}`);
3453
- }
3454
- }
3455
- if (sessionId) {
3456
- const current = sessions.find(s => s.id === sessionId);
3457
- console.log(`\nCurrent session: ${sessionId}${current?.title ? ` [${current.title}]` : ""}`);
3458
- } else {
3459
- console.log("\nCurrent session: disabled (--no-session)");
3460
- }
3379
+ const result = await runSessionSlash(input, {
3380
+ cwd, history, noSession: flags.noSession, sessionId, sessionModel, rl,
3381
+ advanceSessionBoxColor, disarmPreview, clearScreen, freshWelcomeLines, logLines, runSelectPicker,
3382
+ });
3383
+ sessionId = result.sessionId;
3384
+ if (result.sessionModel !== undefined) sessionModel = result.sessionModel;
3385
+ if (result.lastUserInput !== undefined) lastUserInput = result.lastUserInput;
3386
+ if (result.lastReply !== undefined) lastReply = result.lastReply;
3461
3387
  continue;
3462
3388
  }
3463
3389
  if (input === "/retry") {
@@ -3632,22 +3558,13 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3632
3558
  continue;
3633
3559
  }
3634
3560
  if (input === "/theme" || input.startsWith("/theme ")) {
3635
- const want = input.substring(6).trim().toLowerCase();
3636
- const themes = listThemes();
3637
- if (!want) {
3638
- const active = resolveTheme().name;
3639
- console.log("TUI themes (set with /theme <name>, persists via ~/.jeo/config.json):");
3640
- for (const t of themes) console.log(` ${t.name === active ? "*" : " "} ${t.name.padEnd(10)} ${t.description}`);
3641
- continue;
3642
- }
3643
- if (!themes.some(t => t.name === want)) {
3644
- console.log(`Unknown theme '${want}'. Known: ${themes.map(t => t.name).join(", ")}.`);
3645
- continue;
3561
+ const decision = decideThemeSlash(input, listThemes(), resolveTheme().name);
3562
+ for (const line of decision.lines) console.log(line);
3563
+ if (decision.kind === "set") {
3564
+ process.env.JEO_TUI_THEME = decision.themeName!;
3565
+ await saveConfigPatch(raw => ({ theme: decision.themeName }));
3566
+ refreshUiTheme(); // re-resolve the keystroke-hot theme handle immediately
3646
3567
  }
3647
- process.env.JEO_TUI_THEME = want;
3648
- await saveConfigPatch(raw => ({ theme: want }));
3649
- refreshUiTheme(); // re-resolve the keystroke-hot theme handle immediately
3650
- console.log(`Theme set to ${want} — saved to ~/.jeo/config.json`);
3651
3568
  continue;
3652
3569
  }
3653
3570
  if (input === "/wiki" || input.startsWith("/wiki ")) {
@@ -3671,12 +3588,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3671
3588
  continue;
3672
3589
  }
3673
3590
  if (input === "/evolve") {
3674
- console.log("=== Initiating Evolutionary Simulation ===");
3675
- for (const stage of EVOLUTION_STAGES) {
3676
- console.log(`\nStage: ${stage.name}`);
3677
- await animateAsciiArt(stage, { delayMs: 40 });
3678
- }
3679
- console.log("\n=== Evolved to Singularity! ===");
3591
+ await runEvolveSimulation(animateAsciiArt);
3680
3592
  continue;
3681
3593
  }
3682
3594
  if (input.startsWith("/provider") && (input === "/provider" || input[9] === " ")) {
@@ -3947,508 +3859,55 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3947
3859
  await refreshLiveModelsCache();
3948
3860
  continue;
3949
3861
  }
3950
- const agentsCommand =
3951
- input === "/agents" || input.startsWith("/agents ") ? "/agents" :
3952
- undefined;
3953
- if (agentsCommand) {
3954
- const tokens = input.substring(agentsCommand.length).trim().split(/\s+/).filter(Boolean);
3955
- const roleArg = tokens[0];
3956
- const modelArg = tokens[1];
3957
- const cfgNow = await readGlobalConfig();
3958
- const subcommand = roleArg?.toLowerCase();
3959
- const printRoster = () => {
3960
- console.log("Subagent roles (used by 'jeo team'):");
3961
- for (const line of formatAgentsPanel(allSubagentRoles(cfgNow), r => ({
3962
- model: resolveSubagentModel(r.id, cfgNow),
3963
- maxSteps: resolveSubagentMaxSteps(r.id, cfgNow),
3964
- thinking: resolveSubagentThinking(r.id, cfgNow),
3965
- }))) console.log(line);
3966
- console.log("Detail: /agents <role> · set model: /agents <role> <model|#N> · provider: /agents <role> provider <name> [model] · thinking: /agents <role> thinking <level|inherit> · steps: /agents <role> maxSteps <N> · picker: /agents edit");
3967
- console.log("Tip: primary model flow: /model → pick model → choose default or subagent role → choose thinking level");
3968
- console.log(`Available: ${allSubagentRoles(cfgNow).map(r => r.id).join(", ")} (declare custom roles in config.subagents)`);
3969
- console.log("Subcommands: edit, <role> <model|#N>, <role> thinking <level|inherit>, <role> provider <name> [model], <role> maxSteps <N>, <role> reset");
3970
- };
3971
- if (!roleArg || roleArg === "/" || roleArg === "?" || subcommand === "help") {
3972
- printRoster();
3973
- continue;
3974
- }
3975
- if (subcommand === "edit" || subcommand === "picker") {
3976
- printRoster();
3977
- // Interactive editor (TTY): role picker → action picker → live model /
3978
- // thinking / reset — the arrows+Enter way to CHANGE an existing setting.
3979
- const rolePick = await pickFromOptions(
3980
- "Edit a subagent role (ESC to skip)",
3981
- allSubagentRoles(cfgNow).map(r => ({
3982
- value: r.id,
3983
- label: `${r.id} — ${r.title}`,
3984
- hint: `${resolveSubagentModel(r.id, cfgNow)} · ${resolveSubagentMaxSteps(r.id, cfgNow)} steps${cfgNow.subagents?.[r.id]?.model ? "" : " (default)"}`,
3985
- })),
3986
- );
3987
- const editRole = rolePick ? getSubagentRole(rolePick, cfgNow) : undefined;
3988
- if (!editRole) continue;
3989
- const action = await pickFromOptions(`${editRole.title} — choose action`, [
3990
- { value: "model", label: "change model", hint: resolveSubagentModel(editRole.id, cfgNow) },
3991
- { value: "thinking", label: "change thinking", hint: resolveSubagentThinking(editRole.id, cfgNow) ?? `inherit (${cfgNow.thinkingLevel ?? "medium"})` },
3992
- { value: "reset", label: "reset to defaults", hint: "clears model + maxSteps + thinking override" },
3993
- ]);
3994
- if (action === "reset") {
3995
- await saveConfigPatch(raw => ({ subagents: clearSubagentSetting(raw, editRole.id) }));
3996
- console.log(`${editRole.title} settings reset to defaults → ~/.jeo/config.json`);
3997
- } else if (action === "model") {
3998
- const live = await getLiveModels();
3999
- const entries = flattenModels(live);
4000
- const picked = await pickLiveProviderModel(`${editRole.id}`, entries, resolveSubagentModel(editRole.id, cfgNow));
4001
- if (picked) {
4002
- const pinned = qualifyModelId(picked.model, picked.provider);
4003
- await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, editRole.id, { model: pinned }) }));
4004
- console.log(`Subagent '${editRole.id}' model set to ${pinned} → ~/.jeo/config.json`);
4005
- }
4006
- } else if (action === "thinking") {
4007
- const lvl = await pickThinkingLevel(
4008
- `Reasoning for ${editRole.title}`,
4009
- cfgNow.subagents?.[editRole.id]?.thinking,
4010
- `inherit — follow default (${cfgNow.thinkingLevel ?? "medium"})`,
4011
- );
4012
- if (lvl) await setRoleThinking(editRole.id, lvl);
4013
- }
4014
- continue;
4015
- }
4016
- const role = getSubagentRole(roleArg, cfgNow);
4017
- if (!role) {
4018
- console.log(`Unknown role '${roleArg}'. Known: ${allSubagentRoles(cfgNow).map(r => r.id).join(", ")}.`);
4019
- continue;
4020
- }
4021
- if (modelArg?.toLowerCase() === "reset") {
4022
- await saveConfigPatch(raw => ({ subagents: clearSubagentSetting(raw, role.id) }));
4023
- console.log(`${role.title} settings reset to defaults → ~/.jeo/config.json`);
4024
- continue;
4025
- }
4026
- if (modelArg?.toLowerCase() === "maxsteps" || modelArg?.toLowerCase() === "steps") {
4027
- const maxSteps = parseMaxSteps(tokens[2]);
4028
- if (!maxSteps) {
4029
- console.log(`Usage: /agents ${role.id} maxSteps <positive-number>`);
4030
- continue;
4031
- }
4032
- await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { maxSteps }) }));
4033
- console.log(`${role.title} maxSteps set to ${maxSteps} → ~/.jeo/config.json`);
4034
- continue;
4035
- }
4036
- if (modelArg?.toLowerCase() === "thinking" || modelArg?.toLowerCase() === "think") {
4037
- await setRoleThinking(role.id, tokens[2]);
4038
- continue;
4039
- }
4040
- if (modelArg?.toLowerCase() === "provider") {
4041
- const want = (tokens[2] ?? "").toLowerCase();
4042
- if (!isProviderName(want)) {
4043
- console.log(`Usage: /agents ${role.id} provider <name> [model|#N] — e.g. anthropic, openai, gemini, groq, deepseek, openrouter (any configured provider)`);
4044
- continue;
4045
- }
4046
- const st = (await describeAllProviders()).find(s => s.name === want);
4047
- if (st && !st.ready) {
4048
- if (selectableThoughNotReady(st)) {
4049
- console.log(notReadyWarning(st));
4050
- } else {
4051
- console.log(`Cannot pin ${role.title} to ${want}: not ready (${st.label}). Set ${st.envVar ?? "the provider key"} first.`);
4052
- continue;
4053
- }
4054
- }
4055
- const live = await getLiveModels();
4056
- const forProvider = providerPickEntries(live, want);
4057
- const liveForProvider = live.some(r => r.ok && r.provider === want && r.models.length > 0);
4058
- const explicit = tokens[3];
4059
- let chosenModel: string;
4060
- if (explicit && forProvider.length) {
4061
- const sel = resolveSelection(forProvider, explicit);
4062
- if (sel.kind === "index" || sel.kind === "match") chosenModel = qualifyModelId(sel.entry.model, want);
4063
- else if (sel.kind === "ambiguous") {
4064
- console.log(`'${explicit}' matches ${sel.matches.length} ${want} models — be more specific:`);
4065
- for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model}`);
4066
- continue;
4067
- } else if (sel.kind === "out-of-range") {
4068
- console.log(`#${explicit.slice(1)} is out of range for ${want} (1-${sel.max}).`);
4069
- continue;
4070
- } else {
4071
- chosenModel = qualifyModelId(explicit, want);
4072
- }
4073
- } else if (explicit) {
4074
- chosenModel = qualifyModelId(explicit, want);
4075
- } else if (forProvider.length) {
4076
- // No model given → the provider's first known model, provider-qualified.
4077
- chosenModel = qualifyModelId(forProvider[0]!.model, want);
4078
- } else {
4079
- chosenModel = providerDefaultModel(want);
4080
- }
4081
- await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { model: chosenModel }) }));
4082
- console.log(`${role.title} pinned to ${want} via model ${chosenModel} — saved to ~/.jeo/config.json`);
4083
- if (forProvider.length) {
4084
- lastPickIndex = forProvider;
4085
- const sourceNote = liveForProvider ? "Live" : "Catalog";
4086
- const tail = liveForProvider ? "" : " (log in to list live models)";
4087
- console.log(`${sourceNote} ${want} models — refine with /agents ${role.id} #N:${tail}`);
4088
- for (const line of formatPickListWithCapabilities(lastPickIndex, { current: chosenModel, cap: 12 })) console.log(line);
4089
- }
4090
- continue;
4091
- }
4092
- if (modelArg) {
4093
- let chosenModel = modelArg;
4094
- let entries = lastPickIndex;
4095
- if (modelArg.startsWith("#") && entries.length === 0) {
4096
- const live = await getLiveModels();
4097
- entries = flattenModels(live);
4098
- }
4099
- if (entries.length) {
4100
- const sel = resolveSelection(entries, modelArg);
4101
- if (sel.kind === "index" || sel.kind === "match") {
4102
- chosenModel = qualifyModelId(sel.entry.model, sel.entry.provider);
4103
- const bad = (await describeAllProviders()).find(s => s.name === sel.entry.provider && !s.ready);
4104
- if (bad) {
4105
- if (selectableThoughNotReady(bad)) {
4106
- console.log(notReadyWarning(bad));
4107
- } else {
4108
- console.log(`Cannot pin ${sel.entry.model}: ${sel.entry.provider} is not ready (${bad.label}). Set ${bad.envVar ?? "the provider key"} first.`);
4109
- continue;
4110
- }
4111
- }
4112
- } else if (sel.kind === "ambiguous") {
4113
- console.log(`'${modelArg}' matches ${sel.matches.length} live models — be more specific:`);
4114
- for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
4115
- continue;
4116
- } else if (sel.kind === "out-of-range") {
4117
- console.log(`#${modelArg.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
4118
- continue;
4119
- }
4120
- } else if (modelArg.startsWith("#")) {
4121
- console.log("Use /model first to build the numbered live model list.");
4122
- continue;
4123
- }
4124
- // Persist a per-role model override to ~/.jeo/config.json (consumed by 'jeo team').
4125
- await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { model: chosenModel }) }));
4126
- const { provider } = await describeModel(chosenModel);
4127
- console.log(`${role.title} model set to ${chosenModel} (${provider}) — saved to ~/.jeo/config.json`);
4128
- const live = await getLiveModels();
4129
- if (!liveModelKnown(live, chosenModel)) {
4130
- console.log(` (note: '${chosenModel}' is not in any live model list — verify it is valid for ${provider})`);
4131
- }
4132
- continue;
4133
- }
4134
- for (const line of formatAgentDetail(role, {
4135
- model: resolveSubagentModel(role.id, cfgNow),
4136
- maxSteps: resolveSubagentMaxSteps(role.id, cfgNow),
4137
- thinking: resolveSubagentThinking(role.id, cfgNow),
4138
- })) console.log(line);
4139
- const live = await getLiveModels();
4140
- const agentPick = flattenModels(live);
4141
- if (agentPick.length) {
4142
- lastPickIndex = agentPick;
4143
- console.log(`Live models for ${role.title} — pin with /agents ${role.id} #N:`);
4144
- for (const line of formatPickListWithCapabilities(lastPickIndex, { current: resolveSubagentModel(role.id, cfgNow), cap: 20 })) console.log(line);
4145
- }
3862
+ if (input === "/agents" || input.startsWith("/agents ")) {
3863
+ const agentsResult = await runAgentsSlash(input, {
3864
+ sessionModel, sessionThinking, lastPickIndex,
3865
+ getLiveModels, pickLiveProviderModel, setRoleThinking, pickFromOptions, pickThinkingLevel,
3866
+ });
3867
+ if (agentsResult.sessionThinking !== undefined) sessionThinking = agentsResult.sessionThinking;
3868
+ if (agentsResult.lastPickIndex !== undefined) lastPickIndex = agentsResult.lastPickIndex;
4146
3869
  continue;
4147
3870
  }
4148
3871
  if (input === "/config") {
4149
- const cfgNow = await readGlobalConfig();
4150
- const label = sessionModel || cfgNow.defaultModel;
4151
- const { resolved, provider } = await describeModel(label);
4152
- console.log("Effective runtime config:");
4153
- for (const line of formatConfigPanel({
4154
- model: label,
4155
- resolved,
4156
- provider,
4157
- thinkingLevel: sessionThinking ?? cfgNow.thinkingLevel ?? "medium",
4158
- ollamaBaseUrl: cfgNow.ollamaBaseUrl,
4159
- openaiBaseUrl: cfgNow.openaiBaseUrl,
4160
- requestMaxRetries: cfgNow.retry?.requestMaxRetries,
4161
- sessionId,
4162
- })) console.log(line);
3872
+ for (const line of await buildConfigPanelLines({ sessionModel, sessionThinking, sessionId })) console.log(line);
4163
3873
  continue;
4164
3874
  }
4165
3875
  if (input.startsWith("/roles") && (input === "/roles" || input[6] === " ")) {
4166
- const tokens = input.substring(6).trim().split(/\s+/).filter(Boolean);
4167
- const cfgNow = await readGlobalConfig();
4168
- const TIERS = ["smol", "slow", "plan"] as const;
4169
- if (tokens.length >= 2 && (TIERS as readonly string[]).includes(tokens[0])) {
4170
- const tier = tokens[0] as (typeof TIERS)[number];
4171
- let chosenModel = tokens[1]!;
4172
- let entries = lastPickIndex;
4173
- if (chosenModel.startsWith("#") && entries.length === 0) {
4174
- const live = await getLiveModels();
4175
- entries = flattenModels(live);
4176
- }
4177
- if (entries.length) {
4178
- const sel = resolveSelection(entries, chosenModel);
4179
- if (sel.kind === "index" || sel.kind === "match") {
4180
- chosenModel = qualifyModelId(sel.entry.model, sel.entry.provider);
4181
- const bad = (await describeAllProviders()).find(s => s.name === sel.entry.provider && !s.ready);
4182
- if (bad) {
4183
- console.log(`Cannot set role ${tier} to ${sel.entry.model}: ${sel.entry.provider} is not ready (${bad.label}). Set ${bad.envVar ?? "the provider key"} first.`);
4184
- continue;
4185
- }
4186
- } else if (sel.kind === "ambiguous") {
4187
- console.log(`'${chosenModel}' matches ${sel.matches.length} live models — be more specific:`);
4188
- for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
4189
- continue;
4190
- } else if (sel.kind === "out-of-range") {
4191
- console.log(`#${chosenModel.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
4192
- continue;
4193
- }
4194
- } else if (chosenModel.startsWith("#")) {
4195
- console.log("Use /model first to build the numbered live model list.");
4196
- continue;
4197
- }
4198
- await saveConfigPatch(raw => ({ roles: { ...(raw.roles ?? {}), [tier]: chosenModel } }));
4199
- console.log(`Role '${tier}' model set to ${chosenModel} → ~/.jeo/config.json`);
4200
- continue;
4201
- }
4202
- console.log("Model role tiers (fall back to the default model):");
4203
- for (const tier of TIERS) {
4204
- const { provider } = await describeModel(resolveRoleModel(tier, cfgNow));
4205
- console.log(` ${tier.padEnd(5)} ${resolveRoleModel(tier, cfgNow)} (${provider})`);
4206
- }
4207
- console.log("Set a tier: /roles <smol|slow|plan> <model>");
4208
- const live = await getLiveModels();
4209
- const rolePick = flattenModels(live);
4210
- if (rolePick.length) {
4211
- lastPickIndex = rolePick;
4212
- console.log("Live models for role tiers — set with /roles <tier> #N:");
4213
- for (const line of formatPickListWithCapabilities(lastPickIndex, { cap: 15 })) console.log(line);
4214
- }
3876
+ const rolesResult = await runRolesSlash(input, {
3877
+ sessionModel, sessionThinking, lastPickIndex,
3878
+ getLiveModels, pickLiveProviderModel, setRoleThinking, pickFromOptions, pickThinkingLevel,
3879
+ });
3880
+ if (rolesResult.sessionThinking !== undefined) sessionThinking = rolesResult.sessionThinking;
3881
+ if (rolesResult.lastPickIndex !== undefined) lastPickIndex = rolesResult.lastPickIndex;
4215
3882
  continue;
4216
3883
  }
4217
3884
  if (input.startsWith("/fast") && (input === "/fast" || input[5] === " ")) {
4218
- const arg = input.substring(5).trim().toLowerCase() || "status";
4219
- const cfgNow = await readGlobalConfig();
4220
- const currentModel = sessionModel || cfgNow.defaultModel;
4221
- const { resolved, provider } = await describeModel(currentModel);
4222
- const fastLevel = fastThinkingLevelForModel(resolved);
4223
- const currentThinking = sessionThinking ?? cfgNow.thinkingLevel ?? "medium";
4224
- const status = fastLevel && currentThinking === fastLevel ? "on" : "off";
4225
- if (arg === "status") {
4226
- const support = fastLevel ? `supported (thinking ${fastLevel})` : "unsupported";
4227
- console.log(`Fast mode: ${status} · ${support} · ${formatModelLine({ label: currentModel, resolved, provider })} · current thinking ${currentThinking}`);
4228
- continue;
4229
- }
4230
- if (arg === "on") {
4231
- if (!fastLevel) {
4232
- console.log(`Fast mode is not advertised for ${formatModelLine({ label: currentModel, resolved, provider })}; pick a thinking-capable model with /model.`);
4233
- continue;
4234
- }
4235
- sessionThinking = fastLevel;
4236
- console.log(`Fast mode on: ${formatModelLine({ label: currentModel, resolved, provider })} · thinking ${fastLevel} (~${thinkingMaxTokens(fastLevel)} max tokens/step)`);
4237
- continue;
4238
- }
4239
- if (arg === "off") {
4240
- sessionThinking = cfgNow.thinkingLevel ?? "medium";
4241
- console.log(`Fast mode off: restored thinking ${sessionThinking} (~${thinkingMaxTokens(sessionThinking)} max tokens/step)`);
4242
- continue;
4243
- }
4244
- console.log("Usage: /fast [on|off|status]");
3885
+ const fastResult = await runFastSlash(input, {
3886
+ sessionModel, sessionThinking, lastPickIndex,
3887
+ getLiveModels, pickLiveProviderModel, setRoleThinking, pickFromOptions, pickThinkingLevel,
3888
+ });
3889
+ if (fastResult.sessionThinking !== undefined) sessionThinking = fastResult.sessionThinking;
3890
+ if (fastResult.lastPickIndex !== undefined) lastPickIndex = fastResult.lastPickIndex;
4245
3891
  continue;
4246
3892
  }
4247
3893
  if (input.startsWith("/thinking") && (input === "/thinking" || input[9] === " ")) {
4248
- const arg = input.substring(9).trim().toLowerCase();
4249
- if (!arg) {
4250
- console.log(`Thinking level: ${sessionThinking ?? "medium"} (~${thinkingMaxTokens(sessionThinking)} max tokens/step)`);
4251
- continue;
4252
- }
4253
- if (arg === "minimal" || arg === "low" || arg === "medium" || arg === "high" || arg === "xhigh") {
4254
- sessionThinking = arg;
4255
- console.log(`Thinking set to ${arg} (~${thinkingMaxTokens(arg)} max tokens/step)`);
4256
- } else {
4257
- console.log(`Invalid level '${arg}'. Use: minimal | low | medium | high | xhigh.`);
4258
- }
3894
+ const thinkingResult = await runThinkingSlash(input, {
3895
+ sessionModel, sessionThinking, lastPickIndex,
3896
+ getLiveModels, pickLiveProviderModel, setRoleThinking, pickFromOptions, pickThinkingLevel,
3897
+ });
3898
+ if (thinkingResult.sessionThinking !== undefined) sessionThinking = thinkingResult.sessionThinking;
3899
+ if (thinkingResult.lastPickIndex !== undefined) lastPickIndex = thinkingResult.lastPickIndex;
4259
3900
  continue;
4260
3901
  }
4261
3902
  if (input.startsWith("/model") && (input === "/model" || input[6] === " ")) {
4262
- let arg = input.substring(6).trim();
4263
- // `/model save [id]` → persist the (session or given) model as the config default.
4264
- if (arg === "save" || arg.startsWith("save ")) {
4265
- let toSave = arg.slice(4).trim();
4266
- // Resolve `#N`/fuzzy through the same pick-list logic as `/model #N`, so we never
4267
- // persist a literal token like "#2" as defaultModel (which then fails to route).
4268
- if (toSave && lastPickIndex.length) {
4269
- const sel = resolveSelection(lastPickIndex, toSave);
4270
- if (sel.kind === "index" || sel.kind === "match") toSave = qualifyModelId(sel.entry.model, sel.entry.provider);
4271
- else if (sel.kind === "ambiguous") {
4272
- console.log(`'${toSave}' matches ${sel.matches.length} models — be more specific:`);
4273
- for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
4274
- continue;
4275
- } else if (sel.kind === "out-of-range") {
4276
- console.log(`#${toSave.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
4277
- continue;
4278
- }
4279
- // kind "none" → treat `toSave` as a literal model id/alias.
4280
- } else if (toSave.startsWith("#")) {
4281
- console.log("Use /model first to build the numbered list.");
4282
- continue;
4283
- }
4284
- // Fall back to the FRESH on-disk default (not the stale session-start snapshot) so a
4285
- // bare `/model save` after a prior `/model save <id>` never reverts the saved default.
4286
- const finalSave = toSave || sessionModel || (await readGlobalConfig()).defaultModel;
4287
- await saveConfigPatch(raw => rememberModelPatch(raw, finalSave));
4288
- const { resolved, provider } = await describeModel(finalSave);
4289
- console.log(`Default model saved: ${formatModelLine({ label: finalSave, resolved, provider })} → ~/.jeo/config.json`);
4290
- continue;
4291
- }
4292
- const modelThinking = /^(?:thinking|think)(?:\s+(\S+))?$/i.exec(arg);
4293
- if (modelThinking) {
4294
- const level = (modelThinking[1] ?? "").toLowerCase();
4295
- if (!isThinkingLevel(level)) {
4296
- console.log("Usage: /model thinking <minimal|low|medium|high|xhigh>");
4297
- continue;
4298
- }
4299
- sessionThinking = level;
4300
- await saveConfigPatch(() => ({ thinkingLevel: level }));
4301
- console.log(`Default thinking set to ${level} (~${thinkingMaxTokens(level)} max tokens/step) → ~/.jeo/config.json`);
4302
- continue;
4303
- }
4304
- const statuses = await describeAllProviders();
4305
- const disabledModelProviders = statuses.filter(s => !s.ready && !selectableThoughNotReady(s)).map(s => s.name);
4306
- const roleMatch = /^(subagent|role)\s+(\S+)(?:\s+(.+))?$/i.exec(arg);
4307
- if (roleMatch) {
4308
- const role = getSubagentRole(roleMatch[2] ?? "", await readGlobalConfig());
4309
- if (!role) {
4310
- console.log("Usage: /model subagent <executor|planner|architect|critic> [model|#N]");
4311
- continue;
4312
- }
4313
- let roleModelArg = (roleMatch[3] ?? "").trim();
4314
- const roleThinking = /^(?:thinking|think)(?:\s+(\S+))?$/i.exec(roleModelArg);
4315
- if (roleThinking) {
4316
- console.log(`Subagent thinking is set via /agents — try: /agents ${role.id} thinking ${roleThinking[1] ?? "<level|inherit>"} (or /agents edit). /model only sets the default thinking.`);
4317
- continue;
4318
- }
4319
-
4320
- if (!roleModelArg && process.stdin.isTTY && process.stdout.isTTY) {
4321
- const live = await getLiveModels();
4322
- lastPickIndex = flattenModels(live);
4323
- if (lastPickIndex.length) {
4324
- const currentResolved = (await describeModel(resolveSubagentModel(role.id, await readGlobalConfig()))).resolved;
4325
- const picked = await pickLiveProviderModel(role.id, lastPickIndex, currentResolved, disabledModelProviders);
4326
- if (!picked) {
4327
- console.log("(cancelled)");
4328
- continue;
4329
- }
4330
- roleModelArg = qualifyModelId(picked.model, picked.provider);
4331
-
4332
- }
4333
- }
4334
- if (roleModelArg && lastPickIndex.length) {
4335
- const sel = resolveSelection(lastPickIndex, roleModelArg);
4336
- if (sel.kind === "index" || sel.kind === "match") {
4337
- if (disabledModelProviders.includes(sel.entry.provider)) {
4338
- const bad = statuses.find(s => s.name === sel.entry.provider);
4339
- console.log(`Cannot select ${sel.entry.model}: ${sel.entry.provider} is not ready (${bad?.label ?? "not ready"}). Set ${bad?.envVar ?? "the provider key"} first.`);
4340
- continue;
4341
- }
4342
- roleModelArg = qualifyModelId(sel.entry.model, sel.entry.provider);
4343
-
4344
- } else if (sel.kind === "ambiguous") {
4345
- console.log(`'${roleModelArg}' matches ${sel.matches.length} models — be more specific:`);
4346
- for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
4347
- continue;
4348
- } else if (sel.kind === "out-of-range") {
4349
- console.log(`#${roleModelArg.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
4350
- continue;
4351
- }
4352
- } else if (roleModelArg.startsWith("#")) {
4353
- console.log("Use /model first to build the numbered list.");
4354
- continue;
4355
- }
4356
- if (roleModelArg) {
4357
- await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { model: roleModelArg }) }));
4358
- const { provider } = await describeModel(roleModelArg);
4359
- console.log(`${role.title} model set to ${roleModelArg} (${provider}) — saved to ~/.jeo/config.json. Set its thinking via /agents ${role.id} thinking <level> (or /agents edit).`);
4360
- } else {
4361
- const current = resolveSubagentModel(role.id, await readGlobalConfig());
4362
- const { resolved, provider } = await describeModel(current);
4363
- console.log(`${role.title} model: ${formatModelLine({ label: current, resolved, provider })}`);
4364
- const live = await getLiveModels();
4365
- lastPickIndex = flattenModels(live);
4366
- if (lastPickIndex.length) {
4367
- console.log(`Live models for ${role.title} — set with /model subagent ${role.id} #N:`);
4368
- for (const line of formatPickListWithCapabilities(lastPickIndex, { current: resolved, cap: 20 })) console.log(line);
4369
- }
4370
- }
4371
- continue;
4372
- }
4373
- let modelPickedFromSelector = false;
4374
- if (!arg && process.stdin.isTTY && process.stdout.isTTY) {
4375
- const live = await getLiveModels();
4376
- lastPickIndex = flattenModels(live);
4377
- if (lastPickIndex.length) {
4378
- const currentResolved = (await describeModel(sessionModel || defaultModel)).resolved;
4379
- const picked = await pickLiveProviderModel("live", lastPickIndex, currentResolved, disabledModelProviders);
4380
- if (!picked) {
4381
- console.log("(cancelled)");
4382
- continue;
4383
- }
4384
- arg = qualifyModelId(picked.model, picked.provider);
4385
- modelPickedFromSelector = true;
4386
- }
4387
- }
4388
- // Selection from the last numbered pick list (`#N`) or a fuzzy substring.
4389
- if (arg && lastPickIndex.length) {
4390
- const sel = resolveSelection(lastPickIndex, arg);
4391
- if (sel.kind === "index" || sel.kind === "match") {
4392
- if (disabledModelProviders.includes(sel.entry.provider)) {
4393
- const bad = statuses.find(s => s.name === sel.entry.provider);
4394
- console.log(`Cannot select ${sel.entry.model}: ${sel.entry.provider} is not ready (${bad?.label ?? "not ready"}). Set ${bad?.envVar ?? "the provider key"} first.`);
4395
- continue;
4396
- }
4397
- arg = qualifyModelId(sel.entry.model, sel.entry.provider);
4398
- modelPickedFromSelector = true;
4399
- } else if (sel.kind === "ambiguous") {
4400
- console.log(`'${arg}' matches ${sel.matches.length} models — be more specific:`);
4401
- for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
4402
- continue;
4403
- } else if (sel.kind === "out-of-range") {
4404
- console.log(`#${arg.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
4405
- continue;
4406
- }
4407
- // kind "none" → fall through and treat `arg` as a literal model id/alias.
4408
- } else if (arg.startsWith("#")) {
4409
- console.log("Use /model first to build the numbered list.");
4410
- continue;
4411
- }
4412
- const label = arg || (sessionModel || defaultModel);
4413
- if (arg && modelPickedFromSelector && await applyPickedModelWithTarget(arg)) {
4414
- continue;
4415
- }
4416
- if (arg) {
4417
- sessionModel = arg;
4418
- // MRU persistence: picking a model IS saving it — the newest pick wins
4419
- // as the global default; recents keep the rotation for every session.
4420
- await saveConfigPatch(raw => rememberModelPatch(raw, arg));
4421
- await persistSessionModel();
4422
- }
4423
- const { resolved, provider } = await describeModel(label);
4424
- const st = statuses.find(s => s.name === provider);
4425
- console.log(`${arg ? "Model set to" : "Current model"}: ${formatModelLine({ label, resolved, provider, ready: st?.ready })}${arg ? " — saved as default" : ""}`);
4426
- if (st && !st.ready) console.log(` ! ${provider} is not ready (${st.label}) — set ${st.envVar ?? "the provider key"} or run 'jeo setup'.`);
4427
- // ChatGPT OAuth only serves the Codex models; warn before the turn fails if the user
4428
- // pins a non-Codex id with no local base URL to fall back to (gjc-parity readiness guard).
4429
- if (arg && provider === "openai" && st?.kind === "oauth" && !CODEX_MODELS.includes(resolved)) {
4430
- const hasLocalBase = !!((await readGlobalConfig()).openaiBaseUrl || process.env.OPENAI_BASE_URL);
4431
- if (!hasLocalBase) {
4432
- console.log(` ! ChatGPT OAuth serves only Codex models (${CODEX_MODELS.join(", ")}); '${resolved}' will be rejected at runtime — pick one of those, or set OPENAI_API_KEY / OPENAI_BASE_URL.`);
4433
- }
4434
- }
4435
- if (arg && liveModelsCache && resolved === label && !liveModelKnown(liveModelsCache, resolved)) {
4436
- console.log(` (note: '${resolved}' is not in the live ${provider} catalog — use /model to pick a valid id)`);
4437
- }
4438
- const meta = catalogMetadata(resolved);
4439
- if (meta) console.log(` ${formatCapabilityLine(meta)}`);
4440
- if (!arg) {
4441
- const recents = recentModelsForDisplay(await readGlobalConfig());
4442
- if (recents.length > 1) {
4443
- console.log("Recent models (newest first):");
4444
- recents.slice(0, 5).forEach((m, i) => console.log(` ${i + 1}. ${m}${i === 0 ? " ◀ default" : ""}`));
4445
- }
4446
- const live = await getLiveModels();
4447
- lastPickIndex = flattenModels(live);
4448
- console.log("Live models (logged-in providers) — set with /model #N:");
4449
- for (const line of formatPickListWithCapabilities(lastPickIndex, { current: resolved, cap: 20 })) console.log(line);
4450
- }
4451
- console.log(" (model picks persist automatically — newest selection is the default everywhere)");
3903
+ const modelResult = await runModelSlash(input, {
3904
+ sessionModel, sessionThinking, defaultModel, lastPickIndex, liveModelsCache,
3905
+ isTTY: !!(process.stdin.isTTY && process.stdout.isTTY),
3906
+ getLiveModels, applyPickedModelWithTarget, persistSessionModel, pickLiveProviderModel,
3907
+ });
3908
+ if (modelResult.sessionModel !== undefined) sessionModel = modelResult.sessionModel;
3909
+ if (modelResult.sessionThinking !== undefined) sessionThinking = modelResult.sessionThinking;
3910
+ if (modelResult.lastPickIndex !== undefined) lastPickIndex = modelResult.lastPickIndex;
4452
3911
  continue;
4453
3912
  }
4454
3913
  if (input.startsWith("/view") && (input === "/view" || input[5] === " ")) {