jeo-code 0.7.31 → 0.7.33
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 +33 -0
- package/README.ja.md +4 -2
- package/README.ko.md +4 -2
- package/README.md +4 -2
- package/README.zh.md +4 -2
- package/package.json +4 -4
- package/scripts/install.sh +19 -0
- package/scripts/uninstall.sh +2 -2
- package/src/agent/AGENTS.md +15 -0
- package/src/agent/ast-edit-tool.ts +123 -0
- package/src/agent/ast-file-scan.ts +62 -0
- package/src/agent/ast-grep-tool.ts +107 -0
- package/src/agent/ast-match.ts +241 -0
- package/src/agent/browser-session.ts +104 -0
- package/src/agent/browser-tab.ts +145 -0
- package/src/agent/browser-tool.ts +188 -0
- package/src/agent/debug-session.ts +435 -0
- package/src/agent/debug-tool.ts +89 -0
- package/src/agent/engine.ts +72 -12
- package/src/agent/goal-tool.ts +57 -0
- package/src/agent/irc-tool.ts +71 -0
- package/src/agent/job-registry.ts +163 -0
- package/src/agent/job-tool.ts +106 -0
- package/src/agent/lsp-rename-tool.ts +108 -0
- package/src/agent/lsp-tool.ts +177 -0
- package/src/agent/session.ts +27 -0
- package/src/agent/subagent-registry.ts +40 -4
- package/src/agent/subagent-tool.ts +27 -7
- package/src/agent/subagents.ts +1 -1
- package/src/agent/task-tool.ts +27 -5
- package/src/agent/tools.ts +68 -1
- package/src/agent/ts-language-service.ts +173 -0
- package/src/commands/launch/agents-slash.ts +489 -0
- package/src/commands/launch/flags.ts +27 -9
- package/src/commands/launch/model-slash.ts +290 -0
- package/src/commands/launch/session-slash.ts +305 -0
- package/src/commands/launch/slash-handlers.ts +6 -0
- package/src/commands/launch/system-slash.ts +81 -0
- package/src/commands/launch.ts +208 -749
|
@@ -6,6 +6,9 @@ export interface LaunchFlags {
|
|
|
6
6
|
list: boolean;
|
|
7
7
|
resume: boolean;
|
|
8
8
|
resumeId?: string;
|
|
9
|
+
/** True when `--resume`/`-r` was given with NO value at cold startup (gjc-parity):
|
|
10
|
+
* triggers the interactive session picker instead of silently resuming latest. */
|
|
11
|
+
resumeInteractive: boolean;
|
|
9
12
|
noSession: boolean;
|
|
10
13
|
noTui: boolean;
|
|
11
14
|
/** Explicit step cap from --max-steps; 0 = dynamic (process-driven budget that
|
|
@@ -63,9 +66,9 @@ export function fastThinkingLevelForModel(modelId: string): ThinkLevel | undefin
|
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
export function parseFlags(args: string[], cwd: string = process.cwd()): LaunchFlags {
|
|
66
|
-
const flags: LaunchFlags = { list: false, resume: false, noSession: false, noTui: false, maxSteps: 0, message: "", tmux: false, errors: [], print: false, noSkills: false, noTools: false };
|
|
69
|
+
const flags: LaunchFlags = { list: false, resume: false, resumeInteractive: false, noSession: false, noTui: false, maxSteps: 0, message: "", tmux: false, errors: [], print: false, noSkills: false, noTools: false };
|
|
67
70
|
const rest: string[] = [];
|
|
68
|
-
|
|
71
|
+
|
|
69
72
|
for (let i = 0; i < args.length; i++) {
|
|
70
73
|
const a = args[i];
|
|
71
74
|
if (a === "--") {
|
|
@@ -115,22 +118,37 @@ export function parseFlags(args: string[], cwd: string = process.cwd()): LaunchF
|
|
|
115
118
|
i = nextIndex;
|
|
116
119
|
} else if (a === "--smol" || a === "--slow" || a === "--plan") {
|
|
117
120
|
flags.modelRole = a.slice(2) as ModelRole;
|
|
118
|
-
} else if (a === "--
|
|
121
|
+
} else if (a === "--continue" || a === "-c") {
|
|
122
|
+
// Always silently resume the MOST RECENT session (gjc-parity: no picker for --continue/-c).
|
|
119
123
|
flags.resume = true;
|
|
124
|
+
flags.resumeInteractive = false;
|
|
120
125
|
const next = args[i + 1];
|
|
121
|
-
if (next &&
|
|
126
|
+
if (next && !next.startsWith("-")) {
|
|
122
127
|
flags.resumeId = next;
|
|
123
128
|
i++;
|
|
124
129
|
}
|
|
125
|
-
} else if (a.startsWith("--
|
|
130
|
+
} else if (a.startsWith("--continue=") || a.startsWith("-c=")) {
|
|
126
131
|
flags.resume = true;
|
|
132
|
+
flags.resumeInteractive = false;
|
|
127
133
|
const eqIdx = a.indexOf("=");
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
134
|
+
flags.resumeId = a.slice(eqIdx + 1);
|
|
135
|
+
} else if (a === "--resume" || a === "-r") {
|
|
136
|
+
// gjc-parity: value present -> resolve as id/prefix directly; no value -> interactive picker.
|
|
137
|
+
flags.resume = true;
|
|
138
|
+
const next = args[i + 1];
|
|
139
|
+
if (next && !next.startsWith("-")) {
|
|
140
|
+
flags.resumeId = next;
|
|
141
|
+
flags.resumeInteractive = false;
|
|
142
|
+
i++;
|
|
131
143
|
} else {
|
|
132
|
-
|
|
144
|
+
flags.resumeId = undefined;
|
|
145
|
+
flags.resumeInteractive = true;
|
|
133
146
|
}
|
|
147
|
+
} else if (a.startsWith("--resume=")) {
|
|
148
|
+
flags.resume = true;
|
|
149
|
+
flags.resumeInteractive = false;
|
|
150
|
+
const eqIdx = a.indexOf("=");
|
|
151
|
+
flags.resumeId = a.slice(eqIdx + 1);
|
|
134
152
|
} else if (a === "--append-system-prompt" || a.startsWith("--append-system-prompt=")) {
|
|
135
153
|
const { value, nextIndex } = takeValue(args, i, "--append-system-prompt=");
|
|
136
154
|
if (value) flags.appendSystemPromptRaw = value;
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/model` slash-command handler extracted from launch.ts.
|
|
3
|
+
*
|
|
4
|
+
* Handles `/model [id|#N|save [id]|thinking <level>|subagent <role> [model|#N]]`
|
|
5
|
+
* (default: interactive live-model picker on a TTY, else prints the current
|
|
6
|
+
* model + a numbered pick list). This block shares mutable REPL state with
|
|
7
|
+
* still-inline commands (`/agents`, `/roles`, `/provider` also read/write
|
|
8
|
+
* `lastPickIndex`; `/thinking` reads/writes `sessionThinking`), so the caller
|
|
9
|
+
* passes the current values in via an explicit context object and reads back
|
|
10
|
+
* a result object instead of this function closing over REPL state directly.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readGlobalConfig, saveConfigPatch } from "../../agent/state";
|
|
14
|
+
import { rememberModelPatch, recentModelsForDisplay } from "../../agent/model-recency";
|
|
15
|
+
import {
|
|
16
|
+
describeModel,
|
|
17
|
+
describeAllProviders,
|
|
18
|
+
thinkingMaxTokens,
|
|
19
|
+
flattenModels,
|
|
20
|
+
resolveSelection,
|
|
21
|
+
catalogMetadata,
|
|
22
|
+
CODEX_MODELS,
|
|
23
|
+
qualifyModelId,
|
|
24
|
+
} from "../../ai";
|
|
25
|
+
import type { ProviderModelsResult, PickEntry, ProviderName, ThinkLevel } from "../../ai";
|
|
26
|
+
import { getSubagentRole, resolveSubagentModel, withSubagentSetting } from "../../agent/subagents";
|
|
27
|
+
import { formatModelLine, liveModelKnown, formatPickListWithCapabilities, formatCapabilityLine } from "../../tui/components/config-panel";
|
|
28
|
+
import { isThinkingLevel } from "./flags";
|
|
29
|
+
|
|
30
|
+
// Antigravity with ANY Google OAuth (own login or the gemini-cli fallback) stays
|
|
31
|
+
// SELECTABLE in pickers even when not call-ready: picking the model is how users
|
|
32
|
+
// reach the flow, and the auth layer gives actionable login guidance on the first
|
|
33
|
+
// call if the fallback token is rejected (403). Refusing selection was a dead end.
|
|
34
|
+
// Pure predicate — mirrors the identically-named REPL-local const in launch.ts.
|
|
35
|
+
const selectableThoughNotReady = (st?: { name: string; kind: string }): boolean =>
|
|
36
|
+
!!st && st.name === "antigravity" && st.kind === "oauth";
|
|
37
|
+
|
|
38
|
+
export interface ModelSlashCtx {
|
|
39
|
+
sessionModel: string | undefined;
|
|
40
|
+
sessionThinking: ThinkLevel | undefined;
|
|
41
|
+
defaultModel: string;
|
|
42
|
+
lastPickIndex: PickEntry[];
|
|
43
|
+
liveModelsCache: ProviderModelsResult[] | null;
|
|
44
|
+
isTTY: boolean;
|
|
45
|
+
getLiveModels: (force?: boolean) => Promise<ProviderModelsResult[]>;
|
|
46
|
+
applyPickedModelWithTarget: (target: string) => Promise<boolean>;
|
|
47
|
+
persistSessionModel: () => Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* REPL's interactive live-model picker menu (raw-mode keypress loop +
|
|
50
|
+
* repaint). Not a pure module export — it closes over `runSelectPicker`
|
|
51
|
+
* and `modelPickerAssignments` (which itself reads `sessionModel`), so the
|
|
52
|
+
* caller threads it through like the other REPL hooks.
|
|
53
|
+
*/
|
|
54
|
+
pickLiveProviderModel: (
|
|
55
|
+
providerName: string,
|
|
56
|
+
entries: PickEntry[],
|
|
57
|
+
current?: string,
|
|
58
|
+
disabledProviders?: readonly ProviderName[],
|
|
59
|
+
) => Promise<PickEntry | undefined>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ModelSlashResult {
|
|
63
|
+
/** Present only when changed. */
|
|
64
|
+
sessionModel?: string;
|
|
65
|
+
/** Present only when changed. */
|
|
66
|
+
sessionThinking?: ThinkLevel;
|
|
67
|
+
/** Present only when the block builds/refreshes a pick list. */
|
|
68
|
+
lastPickIndex?: PickEntry[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Handle `/model [id|#N|save|thinking <level>|subagent <role> ...]`. Extracted
|
|
73
|
+
* verbatim from launch.ts's inline REPL branch — the caller keeps the
|
|
74
|
+
* `input.startsWith("/model") && (input === "/model" || input[6] === " ")`
|
|
75
|
+
* guard and just calls this function.
|
|
76
|
+
*/
|
|
77
|
+
export async function runModelSlash(input: string, ctx: ModelSlashCtx): Promise<ModelSlashResult> {
|
|
78
|
+
const { defaultModel, isTTY, getLiveModels, applyPickedModelWithTarget, persistSessionModel, pickLiveProviderModel } = ctx;
|
|
79
|
+
let sessionModel = ctx.sessionModel;
|
|
80
|
+
let sessionModelChanged = false;
|
|
81
|
+
let sessionThinking = ctx.sessionThinking;
|
|
82
|
+
let sessionThinkingChanged = false;
|
|
83
|
+
let lastPickIndex = ctx.lastPickIndex;
|
|
84
|
+
let lastPickIndexChanged = false;
|
|
85
|
+
const liveModelsCache = ctx.liveModelsCache;
|
|
86
|
+
|
|
87
|
+
const result = (): ModelSlashResult => ({
|
|
88
|
+
...(sessionModelChanged ? { sessionModel } : {}),
|
|
89
|
+
...(sessionThinkingChanged ? { sessionThinking } : {}),
|
|
90
|
+
...(lastPickIndexChanged ? { lastPickIndex } : {}),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
let arg = input.substring(6).trim();
|
|
94
|
+
// `/model save [id]` → persist the (session or given) model as the config default.
|
|
95
|
+
if (arg === "save" || arg.startsWith("save ")) {
|
|
96
|
+
let toSave = arg.slice(4).trim();
|
|
97
|
+
// Resolve `#N`/fuzzy through the same pick-list logic as `/model #N`, so we never
|
|
98
|
+
// persist a literal token like "#2" as defaultModel (which then fails to route).
|
|
99
|
+
if (toSave && lastPickIndex.length) {
|
|
100
|
+
const sel = resolveSelection(lastPickIndex, toSave);
|
|
101
|
+
if (sel.kind === "index" || sel.kind === "match") toSave = qualifyModelId(sel.entry.model, sel.entry.provider);
|
|
102
|
+
else if (sel.kind === "ambiguous") {
|
|
103
|
+
console.log(`'${toSave}' matches ${sel.matches.length} models — be more specific:`);
|
|
104
|
+
for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
|
|
105
|
+
return result();
|
|
106
|
+
} else if (sel.kind === "out-of-range") {
|
|
107
|
+
console.log(`#${toSave.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
|
|
108
|
+
return result();
|
|
109
|
+
}
|
|
110
|
+
// kind "none" → treat `toSave` as a literal model id/alias.
|
|
111
|
+
} else if (toSave.startsWith("#")) {
|
|
112
|
+
console.log("Use /model first to build the numbered list.");
|
|
113
|
+
return result();
|
|
114
|
+
}
|
|
115
|
+
// Fall back to the FRESH on-disk default (not the stale session-start snapshot) so a
|
|
116
|
+
// bare `/model save` after a prior `/model save <id>` never reverts the saved default.
|
|
117
|
+
const finalSave = toSave || sessionModel || (await readGlobalConfig()).defaultModel;
|
|
118
|
+
await saveConfigPatch(raw => rememberModelPatch(raw, finalSave));
|
|
119
|
+
const { resolved, provider } = await describeModel(finalSave);
|
|
120
|
+
console.log(`Default model saved: ${formatModelLine({ label: finalSave, resolved, provider })} → ~/.jeo/config.json`);
|
|
121
|
+
return result();
|
|
122
|
+
}
|
|
123
|
+
const modelThinking = /^(?:thinking|think)(?:\s+(\S+))?$/i.exec(arg);
|
|
124
|
+
if (modelThinking) {
|
|
125
|
+
const level = (modelThinking[1] ?? "").toLowerCase();
|
|
126
|
+
if (!isThinkingLevel(level)) {
|
|
127
|
+
console.log("Usage: /model thinking <minimal|low|medium|high|xhigh>");
|
|
128
|
+
return result();
|
|
129
|
+
}
|
|
130
|
+
sessionThinking = level;
|
|
131
|
+
sessionThinkingChanged = true;
|
|
132
|
+
await saveConfigPatch(() => ({ thinkingLevel: level }));
|
|
133
|
+
console.log(`Default thinking set to ${level} (~${thinkingMaxTokens(level)} max tokens/step) → ~/.jeo/config.json`);
|
|
134
|
+
return result();
|
|
135
|
+
}
|
|
136
|
+
const statuses = await describeAllProviders();
|
|
137
|
+
const disabledModelProviders = statuses.filter(s => !s.ready && !selectableThoughNotReady(s)).map(s => s.name);
|
|
138
|
+
const roleMatch = /^(subagent|role)\s+(\S+)(?:\s+(.+))?$/i.exec(arg);
|
|
139
|
+
if (roleMatch) {
|
|
140
|
+
const role = getSubagentRole(roleMatch[2] ?? "", await readGlobalConfig());
|
|
141
|
+
if (!role) {
|
|
142
|
+
console.log("Usage: /model subagent <executor|planner|architect|critic> [model|#N]");
|
|
143
|
+
return result();
|
|
144
|
+
}
|
|
145
|
+
let roleModelArg = (roleMatch[3] ?? "").trim();
|
|
146
|
+
const roleThinking = /^(?:thinking|think)(?:\s+(\S+))?$/i.exec(roleModelArg);
|
|
147
|
+
if (roleThinking) {
|
|
148
|
+
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.`);
|
|
149
|
+
return result();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!roleModelArg && isTTY) {
|
|
153
|
+
const live = await getLiveModels();
|
|
154
|
+
lastPickIndex = flattenModels(live);
|
|
155
|
+
lastPickIndexChanged = true;
|
|
156
|
+
if (lastPickIndex.length) {
|
|
157
|
+
const currentResolved = (await describeModel(resolveSubagentModel(role.id, await readGlobalConfig()))).resolved;
|
|
158
|
+
const picked = await pickLiveProviderModel(role.id, lastPickIndex, currentResolved, disabledModelProviders);
|
|
159
|
+
if (!picked) {
|
|
160
|
+
console.log("(cancelled)");
|
|
161
|
+
return result();
|
|
162
|
+
}
|
|
163
|
+
roleModelArg = qualifyModelId(picked.model, picked.provider);
|
|
164
|
+
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (roleModelArg && lastPickIndex.length) {
|
|
168
|
+
const sel = resolveSelection(lastPickIndex, roleModelArg);
|
|
169
|
+
if (sel.kind === "index" || sel.kind === "match") {
|
|
170
|
+
if (disabledModelProviders.includes(sel.entry.provider)) {
|
|
171
|
+
const bad = statuses.find(s => s.name === sel.entry.provider);
|
|
172
|
+
console.log(`Cannot select ${sel.entry.model}: ${sel.entry.provider} is not ready (${bad?.label ?? "not ready"}). Set ${bad?.envVar ?? "the provider key"} first.`);
|
|
173
|
+
return result();
|
|
174
|
+
}
|
|
175
|
+
roleModelArg = qualifyModelId(sel.entry.model, sel.entry.provider);
|
|
176
|
+
|
|
177
|
+
} else if (sel.kind === "ambiguous") {
|
|
178
|
+
console.log(`'${roleModelArg}' matches ${sel.matches.length} models — be more specific:`);
|
|
179
|
+
for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
|
|
180
|
+
return result();
|
|
181
|
+
} else if (sel.kind === "out-of-range") {
|
|
182
|
+
console.log(`#${roleModelArg.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
|
|
183
|
+
return result();
|
|
184
|
+
}
|
|
185
|
+
} else if (roleModelArg.startsWith("#")) {
|
|
186
|
+
console.log("Use /model first to build the numbered list.");
|
|
187
|
+
return result();
|
|
188
|
+
}
|
|
189
|
+
if (roleModelArg) {
|
|
190
|
+
await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { model: roleModelArg }) }));
|
|
191
|
+
const { provider } = await describeModel(roleModelArg);
|
|
192
|
+
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).`);
|
|
193
|
+
} else {
|
|
194
|
+
const current = resolveSubagentModel(role.id, await readGlobalConfig());
|
|
195
|
+
const { resolved, provider } = await describeModel(current);
|
|
196
|
+
console.log(`${role.title} model: ${formatModelLine({ label: current, resolved, provider })}`);
|
|
197
|
+
const live = await getLiveModels();
|
|
198
|
+
lastPickIndex = flattenModels(live);
|
|
199
|
+
lastPickIndexChanged = true;
|
|
200
|
+
if (lastPickIndex.length) {
|
|
201
|
+
console.log(`Live models for ${role.title} — set with /model subagent ${role.id} #N:`);
|
|
202
|
+
for (const line of formatPickListWithCapabilities(lastPickIndex, { current: resolved, cap: 20 })) console.log(line);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return result();
|
|
206
|
+
}
|
|
207
|
+
let modelPickedFromSelector = false;
|
|
208
|
+
if (!arg && isTTY) {
|
|
209
|
+
const live = await getLiveModels();
|
|
210
|
+
lastPickIndex = flattenModels(live);
|
|
211
|
+
lastPickIndexChanged = true;
|
|
212
|
+
if (lastPickIndex.length) {
|
|
213
|
+
const currentResolved = (await describeModel(sessionModel || defaultModel)).resolved;
|
|
214
|
+
const picked = await pickLiveProviderModel("live", lastPickIndex, currentResolved, disabledModelProviders);
|
|
215
|
+
if (!picked) {
|
|
216
|
+
console.log("(cancelled)");
|
|
217
|
+
return result();
|
|
218
|
+
}
|
|
219
|
+
arg = qualifyModelId(picked.model, picked.provider);
|
|
220
|
+
modelPickedFromSelector = true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Selection from the last numbered pick list (`#N`) or a fuzzy substring.
|
|
224
|
+
if (arg && lastPickIndex.length) {
|
|
225
|
+
const sel = resolveSelection(lastPickIndex, arg);
|
|
226
|
+
if (sel.kind === "index" || sel.kind === "match") {
|
|
227
|
+
if (disabledModelProviders.includes(sel.entry.provider)) {
|
|
228
|
+
const bad = statuses.find(s => s.name === sel.entry.provider);
|
|
229
|
+
console.log(`Cannot select ${sel.entry.model}: ${sel.entry.provider} is not ready (${bad?.label ?? "not ready"}). Set ${bad?.envVar ?? "the provider key"} first.`);
|
|
230
|
+
return result();
|
|
231
|
+
}
|
|
232
|
+
arg = qualifyModelId(sel.entry.model, sel.entry.provider);
|
|
233
|
+
modelPickedFromSelector = true;
|
|
234
|
+
} else if (sel.kind === "ambiguous") {
|
|
235
|
+
console.log(`'${arg}' matches ${sel.matches.length} models — be more specific:`);
|
|
236
|
+
for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
|
|
237
|
+
return result();
|
|
238
|
+
} else if (sel.kind === "out-of-range") {
|
|
239
|
+
console.log(`#${arg.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
|
|
240
|
+
return result();
|
|
241
|
+
}
|
|
242
|
+
// kind "none" → fall through and treat `arg` as a literal model id/alias.
|
|
243
|
+
} else if (arg.startsWith("#")) {
|
|
244
|
+
console.log("Use /model first to build the numbered list.");
|
|
245
|
+
return result();
|
|
246
|
+
}
|
|
247
|
+
const label = arg || (sessionModel || defaultModel);
|
|
248
|
+
if (arg && modelPickedFromSelector && await applyPickedModelWithTarget(arg)) {
|
|
249
|
+
return result();
|
|
250
|
+
}
|
|
251
|
+
if (arg) {
|
|
252
|
+
sessionModel = arg;
|
|
253
|
+
sessionModelChanged = true;
|
|
254
|
+
// MRU persistence: picking a model IS saving it — the newest pick wins
|
|
255
|
+
// as the global default; recents keep the rotation for every session.
|
|
256
|
+
await saveConfigPatch(raw => rememberModelPatch(raw, arg));
|
|
257
|
+
await persistSessionModel();
|
|
258
|
+
}
|
|
259
|
+
const { resolved, provider } = await describeModel(label);
|
|
260
|
+
const st = statuses.find(s => s.name === provider);
|
|
261
|
+
console.log(`${arg ? "Model set to" : "Current model"}: ${formatModelLine({ label, resolved, provider, ready: st?.ready })}${arg ? " — saved as default" : ""}`);
|
|
262
|
+
if (st && !st.ready) console.log(` ! ${provider} is not ready (${st.label}) — set ${st.envVar ?? "the provider key"} or run 'jeo setup'.`);
|
|
263
|
+
// ChatGPT OAuth only serves the Codex models; warn before the turn fails if the user
|
|
264
|
+
// pins a non-Codex id with no local base URL to fall back to (gjc-parity readiness guard).
|
|
265
|
+
if (arg && provider === "openai" && st?.kind === "oauth" && !CODEX_MODELS.includes(resolved)) {
|
|
266
|
+
const hasLocalBase = !!((await readGlobalConfig()).openaiBaseUrl || process.env.OPENAI_BASE_URL);
|
|
267
|
+
if (!hasLocalBase) {
|
|
268
|
+
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.`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (arg && liveModelsCache && resolved === label && !liveModelKnown(liveModelsCache, resolved)) {
|
|
272
|
+
console.log(` (note: '${resolved}' is not in the live ${provider} catalog — use /model to pick a valid id)`);
|
|
273
|
+
}
|
|
274
|
+
const meta = catalogMetadata(resolved);
|
|
275
|
+
if (meta) console.log(` ${formatCapabilityLine(meta)}`);
|
|
276
|
+
if (!arg) {
|
|
277
|
+
const recents = recentModelsForDisplay(await readGlobalConfig());
|
|
278
|
+
if (recents.length > 1) {
|
|
279
|
+
console.log("Recent models (newest first):");
|
|
280
|
+
recents.slice(0, 5).forEach((m, i) => console.log(` ${i + 1}. ${m}${i === 0 ? " ◀ default" : ""}`));
|
|
281
|
+
}
|
|
282
|
+
const live = await getLiveModels();
|
|
283
|
+
lastPickIndex = flattenModels(live);
|
|
284
|
+
lastPickIndexChanged = true;
|
|
285
|
+
console.log("Live models (logged-in providers) — set with /model #N:");
|
|
286
|
+
for (const line of formatPickListWithCapabilities(lastPickIndex, { current: resolved, cap: 20 })) console.log(line);
|
|
287
|
+
}
|
|
288
|
+
console.log(" (model picks persist automatically — newest selection is the default everywhere)");
|
|
289
|
+
return result();
|
|
290
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/session` slash-command handler extracted from launch.ts.
|
|
3
|
+
*
|
|
4
|
+
* Handles `/session [new|drop|delete|rename <title>|resume [id]|list|info]`
|
|
5
|
+
* (default: list saved sessions + current-session summary). This block has
|
|
6
|
+
* real mutable-state entanglement with the REPL's giant closure — sessionId,
|
|
7
|
+
* sessionModel, lastUserInput, and lastReply are all reassigned inside it —
|
|
8
|
+
* so the caller passes an explicit context object in and reads a result
|
|
9
|
+
* object back out instead of this function closing over REPL state directly.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Message } from "../../agent/loop";
|
|
13
|
+
import { createSession, deleteSession, renameSession, loadSession, listSessions, sessionPath, resolveSessionRef } from "../../agent/session";
|
|
14
|
+
import { SessionPicker, renderSessionPicker } from "../../tui/components/session-picker";
|
|
15
|
+
import { formatTranscript } from "../../tui/components/transcript";
|
|
16
|
+
|
|
17
|
+
export interface SessionSlashCtx {
|
|
18
|
+
cwd: string;
|
|
19
|
+
/** Mutated in place (length reset + push) — same array reference the caller holds. */
|
|
20
|
+
history: Message[];
|
|
21
|
+
noSession: boolean;
|
|
22
|
+
sessionId: string | undefined;
|
|
23
|
+
sessionModel: string | undefined;
|
|
24
|
+
/** readline interface; only accessed via `(rl as unknown as {history?: string[]})`. */
|
|
25
|
+
rl: unknown;
|
|
26
|
+
advanceSessionBoxColor: () => void;
|
|
27
|
+
disarmPreview: () => void;
|
|
28
|
+
clearScreen: () => string;
|
|
29
|
+
freshWelcomeLines: () => string[];
|
|
30
|
+
logLines: (lines: string[]) => void;
|
|
31
|
+
/**
|
|
32
|
+
* REPL's interactive list-picker runner (raw-mode keypress loop + repaint).
|
|
33
|
+
* Not a pure module export — it closes over terminal/readline state owned
|
|
34
|
+
* by the REPL, so the caller threads it through like the other REPL hooks.
|
|
35
|
+
*/
|
|
36
|
+
runSelectPicker: (
|
|
37
|
+
render: (cols: number, rows: number) => string[],
|
|
38
|
+
onKey: (ch: string, key: { name?: string; ctrl?: boolean; meta?: boolean; shift?: boolean } | undefined) => boolean | undefined,
|
|
39
|
+
) => Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SessionSlashResult {
|
|
43
|
+
sessionId: string | undefined;
|
|
44
|
+
/** Present only when changed (by `/session resume` restoring header.model). */
|
|
45
|
+
sessionModel?: string;
|
|
46
|
+
/** Present only when changed (by `/session resume` re-seeding retry state). */
|
|
47
|
+
lastUserInput?: string;
|
|
48
|
+
/** Present only when changed (by `/session resume` re-seeding retry state). */
|
|
49
|
+
lastReply?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Handle `/session [new|drop|delete|rename <title>|resume [id]|list|info]`
|
|
54
|
+
* (default: list + current-session summary). Extracted verbatim from
|
|
55
|
+
* launch.ts's inline REPL branch — the caller keeps the `input === "/session"`
|
|
56
|
+
* guard and just calls this function.
|
|
57
|
+
*/
|
|
58
|
+
export async function runSessionSlash(input: string, ctx: SessionSlashCtx): Promise<SessionSlashResult> {
|
|
59
|
+
const { cwd, history, noSession, rl, advanceSessionBoxColor, disarmPreview, clearScreen, freshWelcomeLines, logLines, runSelectPicker } = ctx;
|
|
60
|
+
let sessionId = ctx.sessionId;
|
|
61
|
+
let sessionModel = ctx.sessionModel;
|
|
62
|
+
let sessionModelChanged = false;
|
|
63
|
+
let lastUserInput: string | undefined;
|
|
64
|
+
let lastReply: string | undefined;
|
|
65
|
+
|
|
66
|
+
const result = (): SessionSlashResult => ({
|
|
67
|
+
sessionId,
|
|
68
|
+
...(sessionModelChanged ? { sessionModel } : {}),
|
|
69
|
+
...(lastUserInput !== undefined ? { lastUserInput } : {}),
|
|
70
|
+
...(lastReply !== undefined ? { lastReply } : {}),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const tokens = input.substring(8).trim().split(/\s+/).filter(Boolean);
|
|
74
|
+
const sub = (tokens[0] ?? "").toLowerCase();
|
|
75
|
+
|
|
76
|
+
const startFreshSession = async (verb: string): Promise<void> => {
|
|
77
|
+
history.length = 1;
|
|
78
|
+
if (!noSession) {
|
|
79
|
+
sessionId = (await createSession(cwd, undefined, sessionModel)).id;
|
|
80
|
+
advanceSessionBoxColor(); // distinct input-box hue per newly opened session
|
|
81
|
+
console.log(`(${verb} — new session ${sessionId})`);
|
|
82
|
+
} else {
|
|
83
|
+
sessionId = undefined;
|
|
84
|
+
console.log(`(${verb} — sessions disabled)`);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
if (sub === "new") {
|
|
89
|
+
await startFreshSession("started fresh");
|
|
90
|
+
return result();
|
|
91
|
+
}
|
|
92
|
+
if (sub === "drop" || sub === "delete") {
|
|
93
|
+
if (sessionId) {
|
|
94
|
+
const removed = await deleteSession(sessionId, cwd);
|
|
95
|
+
console.log(removed ? `(deleted session ${sessionId})` : `(session ${sessionId} already gone)`);
|
|
96
|
+
}
|
|
97
|
+
await startFreshSession("dropped");
|
|
98
|
+
return result();
|
|
99
|
+
}
|
|
100
|
+
if (sub === "rename") {
|
|
101
|
+
const title = tokens.slice(1).join(" ").trim();
|
|
102
|
+
if (!title) {
|
|
103
|
+
console.log("Usage: /session rename <title>");
|
|
104
|
+
return result();
|
|
105
|
+
}
|
|
106
|
+
if (!sessionId) {
|
|
107
|
+
console.log("(sessions are disabled — nothing to rename)");
|
|
108
|
+
return result();
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
await renameSession(sessionId, title, cwd);
|
|
112
|
+
console.log(`(session renamed to '${title}')`);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.log(`! rename failed: ${(err as Error).message}`);
|
|
115
|
+
}
|
|
116
|
+
return result();
|
|
117
|
+
}
|
|
118
|
+
if (sub === "resume") {
|
|
119
|
+
const arg = tokens.slice(1).join(" ").trim();
|
|
120
|
+
const applyResume = async (rid: string): Promise<void> => {
|
|
121
|
+
try {
|
|
122
|
+
const { header, messages } = await loadSession(rid, cwd);
|
|
123
|
+
history.length = 1;
|
|
124
|
+
for (const m of messages) history.push(m);
|
|
125
|
+
sessionId = rid;
|
|
126
|
+
// Restore the model this session was last using (per-session model).
|
|
127
|
+
if (header.model) { sessionModel = header.model; sessionModelChanged = true; }
|
|
128
|
+
// Seed /retry + reply marker from the last user/assistant turn.
|
|
129
|
+
lastUserInput = ""; lastReply = "";
|
|
130
|
+
for (let k = history.length - 1; k >= 1; k--) {
|
|
131
|
+
if (history[k]!.role === "user" && !lastUserInput) lastUserInput = String(history[k]!.content ?? "");
|
|
132
|
+
if (history[k]!.role === "assistant" && !lastReply) lastReply = String(history[k]!.content ?? "");
|
|
133
|
+
if (lastUserInput && lastReply) break;
|
|
134
|
+
}
|
|
135
|
+
// Seed readline's input history so ↑ in the prompt recalls THIS session's
|
|
136
|
+
// prior prompts (not just lines typed in the current run). readline history
|
|
137
|
+
// is newest-first; unshift in chronological order so the session's newest
|
|
138
|
+
// prompt lands at the front (first ↑). Skip injected/framed messages.
|
|
139
|
+
const rli = rl as unknown as { history?: string[] };
|
|
140
|
+
if (Array.isArray(rli.history)) {
|
|
141
|
+
const priorPrompts = history
|
|
142
|
+
.filter(m => m.role === "user")
|
|
143
|
+
.map(m => String(m.content ?? "").trim())
|
|
144
|
+
.filter(c => c && !c.startsWith("Tool [") && !c.startsWith("[mid-turn steering") && !c.startsWith("[Earlier conversation summary]"));
|
|
145
|
+
for (const p of priorPrompts) {
|
|
146
|
+
if (rli.history[0] !== p) rli.history.unshift(p);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// Clean restore: wipe the screen + scrollback BEFORE replaying the
|
|
150
|
+
// transcript so it can't collide with picker remnants, the prior
|
|
151
|
+
// conversation, or the live input frame — the "/resume corrupts the
|
|
152
|
+
// TUI" fix. Same proven path as /clear; re-render the welcome banner
|
|
153
|
+
// so the resumed view reads like a fresh, intact screen.
|
|
154
|
+
if (process.stdout.isTTY) {
|
|
155
|
+
disarmPreview();
|
|
156
|
+
process.stdout.write(clearScreen());
|
|
157
|
+
console.log(freshWelcomeLines().join("\n"));
|
|
158
|
+
}
|
|
159
|
+
const sep = "─".repeat(Math.min(48, Math.max(20, (process.stdout.columns ?? 80) - 1)));
|
|
160
|
+
logLines([
|
|
161
|
+
sep,
|
|
162
|
+
`resumed session ${rid} · ${messages.length} message(s) (/history all for the full transcript)`,
|
|
163
|
+
sep,
|
|
164
|
+
...formatTranscript(history, { maxTurns: 6, color: true, unicode: true }),
|
|
165
|
+
sep,
|
|
166
|
+
]);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
console.log(`! ${(err as Error).message}`);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
if (arg) {
|
|
172
|
+
// gjc-parity: resolve `arg` as a full id OR a short id prefix before resuming.
|
|
173
|
+
const resolved = await resolveSessionRef(arg, cwd);
|
|
174
|
+
if (resolved.kind === "ambiguous") {
|
|
175
|
+
console.log(`Ambiguous session id '${arg}' — matches: ${resolved.matches.slice(0, 5).join(", ")}${resolved.matches.length > 5 ? " …" : ""}. Use more characters or run with --resume (no value) to pick interactively.`);
|
|
176
|
+
return result();
|
|
177
|
+
}
|
|
178
|
+
// "not-found" falls through with the raw arg so loadSession's existing
|
|
179
|
+
// catch-driven "session not found" error message behavior is unchanged.
|
|
180
|
+
await applyResume(resolved.kind === "ok" ? resolved.id : arg);
|
|
181
|
+
return result();
|
|
182
|
+
}
|
|
183
|
+
// No id → only sessions with a real conversation are resumable (every launch
|
|
184
|
+
// creates an empty session; those are noise).
|
|
185
|
+
let pool = (await listSessions(cwd)).filter(s => s.messageCount > 0);
|
|
186
|
+
if (pool.length === 0) {
|
|
187
|
+
console.log("(no saved sessions with history)");
|
|
188
|
+
return result();
|
|
189
|
+
}
|
|
190
|
+
// Interactive gjc-style picker on a TTY: type to filter, ↑↓/PgUp/PgDn to
|
|
191
|
+
// move, Enter resumes, Del deletes (press Del twice to confirm), Esc cancels.
|
|
192
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
193
|
+
// Loop so a delete refreshes the list and re-opens the picker in place.
|
|
194
|
+
for (;;) {
|
|
195
|
+
const picker = new SessionPicker(pool);
|
|
196
|
+
let action: { kind: "resume" | "delete"; id: string } | undefined;
|
|
197
|
+
let confirmDeleteId: string | undefined;
|
|
198
|
+
await runSelectPicker(
|
|
199
|
+
(cols, rows) => renderSessionPicker(picker, {
|
|
200
|
+
title: "Resume a session",
|
|
201
|
+
cols,
|
|
202
|
+
rows: Math.max(8, rows),
|
|
203
|
+
unicode: true,
|
|
204
|
+
color: true,
|
|
205
|
+
confirmDeleteId,
|
|
206
|
+
}),
|
|
207
|
+
(ch, key) => {
|
|
208
|
+
if (key?.name === "up") { confirmDeleteId = undefined; picker.up(); return false; }
|
|
209
|
+
if (key?.name === "down") { confirmDeleteId = undefined; picker.down(); return false; }
|
|
210
|
+
if (key?.name === "pageup") { confirmDeleteId = undefined; picker.page(-1); return false; }
|
|
211
|
+
if (key?.name === "pagedown") { confirmDeleteId = undefined; picker.page(1); return false; }
|
|
212
|
+
if (key?.name === "escape" || (key?.ctrl && key.name === "c")) return true;
|
|
213
|
+
if (key?.name === "delete") {
|
|
214
|
+
const sel = picker.selected();
|
|
215
|
+
if (!sel) return false;
|
|
216
|
+
if (confirmDeleteId === sel.id) { action = { kind: "delete", id: sel.id }; return true; }
|
|
217
|
+
confirmDeleteId = sel.id;
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
if (key?.name === "return" || key?.name === "enter") {
|
|
221
|
+
const sel = picker.selected();
|
|
222
|
+
if (sel) { action = { kind: "resume", id: sel.id }; return true; }
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
confirmDeleteId = undefined;
|
|
226
|
+
if (key?.name === "backspace") { picker.backspace(); return false; }
|
|
227
|
+
if (ch && ch >= " " && !key?.ctrl && !key?.meta) picker.typeChar(ch);
|
|
228
|
+
return false;
|
|
229
|
+
},
|
|
230
|
+
);
|
|
231
|
+
if (!action) { console.log("(resume cancelled)"); break; }
|
|
232
|
+
if (action.kind === "resume") { await applyResume(action.id); break; }
|
|
233
|
+
// Delete: drop the file, refresh the pool, and re-open the picker.
|
|
234
|
+
const delId = action.id;
|
|
235
|
+
try {
|
|
236
|
+
const removed = await deleteSession(delId, cwd);
|
|
237
|
+
console.log(removed ? `(deleted session ${delId})` : `(session ${delId} already gone)`);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
console.log(`! delete failed: ${(err as Error).message}`);
|
|
240
|
+
}
|
|
241
|
+
if (delId === sessionId) await startFreshSession("dropped current session");
|
|
242
|
+
pool = pool.filter(s => s.id !== delId);
|
|
243
|
+
if (pool.length === 0) { console.log("(no saved sessions with history)"); break; }
|
|
244
|
+
}
|
|
245
|
+
return result();
|
|
246
|
+
}
|
|
247
|
+
// Non-TTY fallback: static list (resume with /session resume <id>).
|
|
248
|
+
console.log("Saved sessions — resume with /session resume <id>:");
|
|
249
|
+
for (const s of pool.slice(0, 15)) {
|
|
250
|
+
const marker = s.id === sessionId ? "*" : " ";
|
|
251
|
+
console.log(` ${marker}${s.id} (${s.messageCount} msgs) ${s.title ? `[${s.title}] ` : ""}${s.preview}`);
|
|
252
|
+
}
|
|
253
|
+
return result();
|
|
254
|
+
}
|
|
255
|
+
if (sub === "list") {
|
|
256
|
+
const sessions = await listSessions(cwd);
|
|
257
|
+
if (sessions.length === 0) console.log("(no saved sessions)");
|
|
258
|
+
for (const s of sessions) {
|
|
259
|
+
const marker = s.id === sessionId ? "*" : " ";
|
|
260
|
+
const title = s.title ? `[${s.title}] ` : "";
|
|
261
|
+
console.log(` ${marker}${s.id} (${s.messageCount} msgs) ${title}${s.preview}`);
|
|
262
|
+
}
|
|
263
|
+
return result();
|
|
264
|
+
}
|
|
265
|
+
if (sub === "info") {
|
|
266
|
+
if (!sessionId) {
|
|
267
|
+
console.log("Session: disabled (--no-session)");
|
|
268
|
+
return result();
|
|
269
|
+
}
|
|
270
|
+
const all = await listSessions(cwd);
|
|
271
|
+
const current = all.find(s => s.id === sessionId);
|
|
272
|
+
console.log("Session info:");
|
|
273
|
+
console.log(` id ${sessionId}`);
|
|
274
|
+
if (current?.title) console.log(` title ${current.title}`);
|
|
275
|
+
console.log(` file ${sessionPath(sessionId, cwd)}`);
|
|
276
|
+
console.log(` started ${current?.timestamp ?? "(this run)"}`);
|
|
277
|
+
console.log(` messages ${current?.messageCount ?? Math.max(0, history.length - 1)} persisted · ${history.length - 1} in context`);
|
|
278
|
+
console.log(` workspace ${cwd}`);
|
|
279
|
+
return result();
|
|
280
|
+
}
|
|
281
|
+
if (sub && sub !== "info") {
|
|
282
|
+
console.log("Usage: /session [list|info|new|drop|rename <title>|resume [id]]");
|
|
283
|
+
return result();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Default: list sessions AND show current session info
|
|
287
|
+
const sessions = await listSessions(cwd);
|
|
288
|
+
if (sessions.length === 0) {
|
|
289
|
+
console.log("(no saved sessions)");
|
|
290
|
+
} else {
|
|
291
|
+
console.log("Saved sessions:");
|
|
292
|
+
for (const s of sessions) {
|
|
293
|
+
const marker = s.id === sessionId ? "*" : " ";
|
|
294
|
+
const title = s.title ? `[${s.title}] ` : "";
|
|
295
|
+
console.log(` ${marker}${s.id} (${s.messageCount} msgs) ${title}${s.preview}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (sessionId) {
|
|
299
|
+
const current = sessions.find(s => s.id === sessionId);
|
|
300
|
+
console.log(`\nCurrent session: ${sessionId}${current?.title ? ` [${current.title}]` : ""}`);
|
|
301
|
+
} else {
|
|
302
|
+
console.log("\nCurrent session: disabled (--no-session)");
|
|
303
|
+
}
|
|
304
|
+
return result();
|
|
305
|
+
}
|