jeo-code 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +342 -0
- package/package.json +57 -0
- package/scripts/install.sh +322 -0
- package/scripts/uninstall.sh +30 -0
- package/src/agent/compaction.ts +75 -0
- package/src/agent/config-schema.ts +87 -0
- package/src/agent/context-files.ts +51 -0
- package/src/agent/engine.ts +208 -0
- package/src/agent/json.ts +87 -0
- package/src/agent/loop.ts +22 -0
- package/src/agent/session.ts +198 -0
- package/src/agent/state.ts +199 -0
- package/src/agent/subagents.ts +149 -0
- package/src/agent/tools.ts +355 -0
- package/src/ai/index.ts +11 -0
- package/src/ai/model-catalog-compat.ts +119 -0
- package/src/ai/model-catalog.ts +97 -0
- package/src/ai/model-discovery.ts +148 -0
- package/src/ai/model-enrich.ts +75 -0
- package/src/ai/model-manager.ts +178 -0
- package/src/ai/model-picker.ts +73 -0
- package/src/ai/model-registry.ts +83 -0
- package/src/ai/provider-status.ts +77 -0
- package/src/ai/providers/anthropic.ts +87 -0
- package/src/ai/providers/errors.ts +47 -0
- package/src/ai/providers/gemini.ts +77 -0
- package/src/ai/providers/ollama.ts +54 -0
- package/src/ai/providers/openai.ts +67 -0
- package/src/ai/sse.ts +46 -0
- package/src/ai/types.ts +37 -0
- package/src/auth/callback-server.ts +195 -0
- package/src/auth/flows/anthropic.ts +114 -0
- package/src/auth/flows/google.ts +120 -0
- package/src/auth/flows/index.ts +50 -0
- package/src/auth/flows/openai.ts +130 -0
- package/src/auth/index.ts +23 -0
- package/src/auth/oauth.ts +80 -0
- package/src/auth/pkce.ts +24 -0
- package/src/auth/refresh.ts +60 -0
- package/src/auth/storage.ts +113 -0
- package/src/auth/types.ts +26 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/runner.ts +245 -0
- package/src/cli.ts +17 -0
- package/src/commands/approve.ts +63 -0
- package/src/commands/auth.ts +144 -0
- package/src/commands/chat.ts +37 -0
- package/src/commands/deep-interview.ts +239 -0
- package/src/commands/doctor.ts +250 -0
- package/src/commands/evolve.ts +191 -0
- package/src/commands/launch.ts +745 -0
- package/src/commands/mcp.ts +18 -0
- package/src/commands/models.ts +104 -0
- package/src/commands/ralplan.ts +86 -0
- package/src/commands/resume.ts +6 -0
- package/src/commands/setup-helpers.ts +93 -0
- package/src/commands/setup.ts +190 -0
- package/src/commands/skills.ts +38 -0
- package/src/commands/team.ts +337 -0
- package/src/commands/ultragoal.ts +102 -0
- package/src/index.ts +31 -0
- package/src/mcp/index.ts +3 -0
- package/src/mcp/protocol.ts +45 -0
- package/src/mcp/server.ts +97 -0
- package/src/mcp/tools.ts +156 -0
- package/src/skills/catalog.ts +61 -0
- package/src/tui/app.ts +297 -0
- package/src/tui/components/ascii-art.ts +340 -0
- package/src/tui/components/autocomplete.ts +165 -0
- package/src/tui/components/capability.ts +29 -0
- package/src/tui/components/code-view.ts +146 -0
- package/src/tui/components/color.ts +172 -0
- package/src/tui/components/config-panel.ts +193 -0
- package/src/tui/components/evolution.ts +305 -0
- package/src/tui/components/footer.ts +95 -0
- package/src/tui/components/forge.ts +167 -0
- package/src/tui/components/index.ts +7 -0
- package/src/tui/components/layout.ts +105 -0
- package/src/tui/components/meter.ts +61 -0
- package/src/tui/components/model-picker.ts +82 -0
- package/src/tui/components/provider-picker.ts +42 -0
- package/src/tui/components/select-list.ts +199 -0
- package/src/tui/components/slash.ts +34 -0
- package/src/tui/components/spinner.ts +49 -0
- package/src/tui/components/status.ts +45 -0
- package/src/tui/components/stream.ts +36 -0
- package/src/tui/components/themes.ts +86 -0
- package/src/tui/components/tool-list.ts +67 -0
- package/src/tui/index.ts +2 -0
- package/src/tui/renderer.ts +70 -0
- package/src/tui/terminal.ts +78 -0
- package/src/util/retry.ts +108 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { runAgentLoop, executorSystemPrompt } from "../agent/engine";
|
|
3
|
+
import { LaunchTui } from "../tui/app";
|
|
4
|
+
import { skillsPromptSection } from "../skills/catalog";
|
|
5
|
+
import { matchSlash, isSlashAttempt } from "../tui/components/slash";
|
|
6
|
+
import { staticCompletionContext, readlineCompleter, type CompletionContext } from "../tui/components/autocomplete";
|
|
7
|
+
import { EVOLUTION_STAGES, renderAsciiArt, animateAsciiArt } from "../tui/components/ascii-art";
|
|
8
|
+
import { getEvolutionTip } from "../tui/components/evolution";
|
|
9
|
+
import chalk from "chalk";
|
|
10
|
+
import type { Message } from "../agent/loop";
|
|
11
|
+
import { readGlobalConfig, saveGlobalConfig } from "../agent/state";
|
|
12
|
+
import { describeModel, describeAllProviders, thinkingMaxTokens, discoverModels, flattenModels, resolveSelection, catalogMetadata, resolveRoleModel, enrichAll, sortByCapability } from "../ai";
|
|
13
|
+
import type { ProviderModelsResult, PickEntry } from "../ai";
|
|
14
|
+
|
|
15
|
+
import { listAliases } from "../ai/model-registry";
|
|
16
|
+
|
|
17
|
+
import { SUBAGENT_ROLES, getSubagentRole, resolveSubagentModel, resolveSubagentMaxSteps } from "../agent/subagents";
|
|
18
|
+
import {
|
|
19
|
+
formatModelLine,
|
|
20
|
+
formatAliasLines,
|
|
21
|
+
formatProviderPanel,
|
|
22
|
+
formatAgentsPanel,
|
|
23
|
+
formatAgentDetail,
|
|
24
|
+
formatConfigPanel,
|
|
25
|
+
|
|
26
|
+
liveModelKnown,
|
|
27
|
+
formatPickList,
|
|
28
|
+
formatCapabilityLine,
|
|
29
|
+
formatEnrichedModels,
|
|
30
|
+
} from "../tui/components/config-panel";
|
|
31
|
+
import { detectLanguage, languageLabel, parseLineRange, sliceLines, formatCodeBlock, formatDiff } from "../tui/components/code-view";
|
|
32
|
+
import { findTool, searchTool } from "../agent/tools";
|
|
33
|
+
import { loadProjectContext, withProjectContext } from "../agent/context-files";
|
|
34
|
+
import { maybeCompact } from "../agent/compaction";
|
|
35
|
+
import * as path from "node:path";
|
|
36
|
+
import * as fs from "node:fs";
|
|
37
|
+
import {
|
|
38
|
+
createSession,
|
|
39
|
+
appendMessage,
|
|
40
|
+
loadSession,
|
|
41
|
+
listSessions,
|
|
42
|
+
latestSessionId,
|
|
43
|
+
} from "../agent/session";
|
|
44
|
+
|
|
45
|
+
interface LaunchFlags {
|
|
46
|
+
list: boolean;
|
|
47
|
+
resume: boolean;
|
|
48
|
+
resumeId?: string;
|
|
49
|
+
noSession: boolean;
|
|
50
|
+
noTui: boolean;
|
|
51
|
+
maxSteps: number;
|
|
52
|
+
message: string;
|
|
53
|
+
tmux: boolean;
|
|
54
|
+
worktree?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseFlags(args: string[]): LaunchFlags {
|
|
58
|
+
const flags: LaunchFlags = { list: false, resume: false, noSession: false, noTui: false, maxSteps: 25, message: "", tmux: false };
|
|
59
|
+
const rest: string[] = [];
|
|
60
|
+
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
61
|
+
for (let i = 0; i < args.length; i++) {
|
|
62
|
+
const a = args[i];
|
|
63
|
+
if (a === "--list") {
|
|
64
|
+
flags.list = true;
|
|
65
|
+
} else if (a === "--tmux") {
|
|
66
|
+
flags.tmux = true;
|
|
67
|
+
} else if (a === "--worktree") {
|
|
68
|
+
const next = args[i + 1];
|
|
69
|
+
if (next && !next.startsWith("-")) {
|
|
70
|
+
flags.worktree = next;
|
|
71
|
+
i++;
|
|
72
|
+
}
|
|
73
|
+
} else if (a.startsWith("--worktree=")) {
|
|
74
|
+
flags.worktree = a.slice("--worktree=".length);
|
|
75
|
+
} else if (a === "--no-session") {
|
|
76
|
+
flags.noSession = true;
|
|
77
|
+
} else if (a === "--no-tui") {
|
|
78
|
+
flags.noTui = true;
|
|
79
|
+
} else if (a === "--max-steps") {
|
|
80
|
+
const n = parseInt(args[i + 1] ?? "", 10);
|
|
81
|
+
if (Number.isFinite(n) && n > 0) {
|
|
82
|
+
flags.maxSteps = n;
|
|
83
|
+
i++;
|
|
84
|
+
}
|
|
85
|
+
} else if (a.startsWith("--max-steps=")) {
|
|
86
|
+
const n = parseInt(a.slice(12), 10);
|
|
87
|
+
if (Number.isFinite(n) && n > 0) flags.maxSteps = n;
|
|
88
|
+
} else if (a === "--resume") {
|
|
89
|
+
flags.resume = true;
|
|
90
|
+
const next = args[i + 1];
|
|
91
|
+
if (next && UUID_REGEX.test(next)) {
|
|
92
|
+
flags.resumeId = next;
|
|
93
|
+
i++;
|
|
94
|
+
}
|
|
95
|
+
} else if (a.startsWith("--resume=")) {
|
|
96
|
+
flags.resume = true;
|
|
97
|
+
const val = a.slice(9);
|
|
98
|
+
if (UUID_REGEX.test(val)) {
|
|
99
|
+
flags.resumeId = val;
|
|
100
|
+
} else {
|
|
101
|
+
rest.push(val);
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
rest.push(a);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
flags.message = rest.join(" ").trim();
|
|
108
|
+
return flags;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Resolve a git worktree path (gjc `--worktree <path>` parity). If the path
|
|
113
|
+
* already exists it is reused as-is; otherwise a new worktree is created on a
|
|
114
|
+
* branch derived from the path basename. Returns the absolute worktree path.
|
|
115
|
+
*/
|
|
116
|
+
function resolveWorktree(cwd: string, wt: string): string {
|
|
117
|
+
const abs = path.isAbsolute(wt) ? wt : path.resolve(cwd, wt);
|
|
118
|
+
if (fs.existsSync(abs)) return abs;
|
|
119
|
+
if (!Bun.which("git")) {
|
|
120
|
+
console.error("error: --worktree requires git on PATH");
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
const branch = (path.basename(abs).replace(/[^a-zA-Z0-9_-]/g, "-") || "joc-wt");
|
|
124
|
+
const withBranch = Bun.spawnSync(["git", "worktree", "add", "-b", branch, abs], {
|
|
125
|
+
cwd,
|
|
126
|
+
stdout: "pipe",
|
|
127
|
+
stderr: "pipe",
|
|
128
|
+
});
|
|
129
|
+
if (withBranch.exitCode !== 0) {
|
|
130
|
+
// Branch may already exist; retry attaching the existing branch.
|
|
131
|
+
const plain = Bun.spawnSync(["git", "worktree", "add", abs], {
|
|
132
|
+
cwd,
|
|
133
|
+
stdout: "pipe",
|
|
134
|
+
stderr: "pipe",
|
|
135
|
+
});
|
|
136
|
+
if (plain.exitCode !== 0) {
|
|
137
|
+
console.error(
|
|
138
|
+
`error: failed to create git worktree at ${abs}: ${withBranch.stderr.toString().trim()}`,
|
|
139
|
+
);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return abs;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
147
|
+
let cwd = process.cwd();
|
|
148
|
+
const flags = parseFlags(args);
|
|
149
|
+
|
|
150
|
+
if (flags.worktree) {
|
|
151
|
+
const wt = resolveWorktree(cwd, flags.worktree);
|
|
152
|
+
if (wt !== cwd) {
|
|
153
|
+
process.chdir(wt);
|
|
154
|
+
cwd = wt;
|
|
155
|
+
if (process.env.JOC_TMUX_LAUNCHED !== "1") console.log(`Using worktree: ${wt}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (flags.tmux) {
|
|
159
|
+
if (!process.env.TMUX && process.env.JOC_TMUX_LAUNCHED !== "1") {
|
|
160
|
+
const tmuxBin = Bun.which("tmux");
|
|
161
|
+
if (tmuxBin) {
|
|
162
|
+
let branch = "";
|
|
163
|
+
try {
|
|
164
|
+
const gitRes = Bun.spawnSync(["git", "symbolic-ref", "--quiet", "--short", "HEAD"], {
|
|
165
|
+
cwd,
|
|
166
|
+
stdout: "pipe",
|
|
167
|
+
stderr: "ignore",
|
|
168
|
+
});
|
|
169
|
+
if (gitRes.exitCode === 0) {
|
|
170
|
+
branch = gitRes.stdout.toString().trim().replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
171
|
+
}
|
|
172
|
+
} catch {}
|
|
173
|
+
const sessionName = branch ? `joc-${branch}` : "joc-session";
|
|
174
|
+
|
|
175
|
+
// Strip orchestration flags: the worktree is already the tmux session
|
|
176
|
+
// cwd (`-c cwd` below), so the inner process inherits it directly.
|
|
177
|
+
const innerArgs: string[] = [];
|
|
178
|
+
for (let j = 0; j < args.length; j++) {
|
|
179
|
+
const a = args[j];
|
|
180
|
+
if (a === "--tmux") continue;
|
|
181
|
+
if (a === "--worktree") { j++; continue; }
|
|
182
|
+
if (a.startsWith("--worktree=")) continue;
|
|
183
|
+
innerArgs.push(a);
|
|
184
|
+
}
|
|
185
|
+
const entrypoint = process.argv[1] || "joc";
|
|
186
|
+
const resolvedEntrypoint = path.isAbsolute(entrypoint) ? entrypoint : path.resolve(cwd, entrypoint);
|
|
187
|
+
let cmd: string[] = [];
|
|
188
|
+
if (entrypoint.endsWith(".ts") || entrypoint.endsWith(".js") || entrypoint.endsWith(".mjs")) {
|
|
189
|
+
cmd = [process.execPath, resolvedEntrypoint];
|
|
190
|
+
} else {
|
|
191
|
+
cmd = [resolvedEntrypoint];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const innerCmd = `exec env JOC_TMUX_LAUNCHED=1 ${[...cmd, "launch", ...innerArgs].map(a => `"${a.replace(/"/g, '\\"')}"`).join(" ")}`;
|
|
195
|
+
|
|
196
|
+
const hasSession = Bun.spawnSync([tmuxBin, "has-session", "-t", `=${sessionName}`]);
|
|
197
|
+
if (hasSession.exitCode === 0) {
|
|
198
|
+
console.log(`Attaching to existing tmux session: ${sessionName}`);
|
|
199
|
+
const proc = Bun.spawn([tmuxBin, "attach-session", "-t", `=${sessionName}`], {
|
|
200
|
+
stdin: "inherit",
|
|
201
|
+
stdout: "inherit",
|
|
202
|
+
stderr: "inherit",
|
|
203
|
+
});
|
|
204
|
+
await proc.exited;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
console.log(`Starting new tmux session: ${sessionName}`);
|
|
209
|
+
const createSession = Bun.spawnSync([
|
|
210
|
+
tmuxBin,
|
|
211
|
+
"new-session",
|
|
212
|
+
"-d",
|
|
213
|
+
"-s",
|
|
214
|
+
sessionName,
|
|
215
|
+
"-c",
|
|
216
|
+
cwd,
|
|
217
|
+
innerCmd
|
|
218
|
+
]);
|
|
219
|
+
if (createSession.exitCode !== 0) {
|
|
220
|
+
console.error(`Error: Failed to create tmux session: ${createSession.stderr.toString()}`);
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const attach = Bun.spawn([tmuxBin, "attach-session", "-t", `=${sessionName}`], {
|
|
225
|
+
stdin: "inherit",
|
|
226
|
+
stdout: "inherit",
|
|
227
|
+
stderr: "inherit",
|
|
228
|
+
});
|
|
229
|
+
await attach.exited;
|
|
230
|
+
return;
|
|
231
|
+
} else {
|
|
232
|
+
console.warn("warning: tmux is not available on PATH. Launching directly...");
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const cfg = await readGlobalConfig();
|
|
238
|
+
const defaultModel = cfg.defaultModel;
|
|
239
|
+
|
|
240
|
+
// --list: print persisted sessions and exit.
|
|
241
|
+
if (flags.list) {
|
|
242
|
+
const sessions = await listSessions(cwd);
|
|
243
|
+
if (sessions.length === 0) {
|
|
244
|
+
console.log("No saved sessions in .joc/sessions/.");
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
console.log("Saved sessions (newest first):");
|
|
248
|
+
for (const s of sessions) {
|
|
249
|
+
console.log(` ${s.id} ${s.timestamp} (${s.messageCount} msgs) ${s.preview}`);
|
|
250
|
+
}
|
|
251
|
+
console.log("\nResume with: joc launch --resume <id>");
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// pi-style: load project context (JEO.md / AGENTS.md / .joc/context.md / CLAUDE.md) into the prompt.
|
|
256
|
+
const contextFiles = await loadProjectContext(cwd);
|
|
257
|
+
const baseSystemPrompt =
|
|
258
|
+
executorSystemPrompt("joc, an interactive coding agent") +
|
|
259
|
+
"\nWhen you have finished the user's request, or need to reply to or ask the user something, call done with {\"reason\": <your natural-language reply to the user>}. The reason text is shown to the user as your message." +
|
|
260
|
+
"\n\nAvailable joc workflow skills (suggest the relevant command when the user's task fits one):\n" +
|
|
261
|
+
skillsPromptSection();
|
|
262
|
+
const systemPrompt = withProjectContext(baseSystemPrompt, contextFiles);
|
|
263
|
+
|
|
264
|
+
const history: Message[] = [{ role: "system", content: systemPrompt }];
|
|
265
|
+
let sessionModel: string | undefined = undefined;
|
|
266
|
+
// Session thinking-level override (`/thinking`); falls back to the config level.
|
|
267
|
+
let sessionThinking: "minimal" | "low" | "medium" | "high" | "xhigh" | undefined = cfg.thinkingLevel;
|
|
268
|
+
// Cache of live, credential-validated models per provider (refreshed via `/models refresh`).
|
|
269
|
+
let liveModelsCache: ProviderModelsResult[] | null = null;
|
|
270
|
+
const getLiveModels = async (force = false): Promise<ProviderModelsResult[]> => {
|
|
271
|
+
if (force || !liveModelsCache) {
|
|
272
|
+
process.stdout.write("(fetching models from logged-in providers…)\n");
|
|
273
|
+
liveModelsCache = await discoverModels({ timeoutMs: 4000 });
|
|
274
|
+
}
|
|
275
|
+
return liveModelsCache;
|
|
276
|
+
};
|
|
277
|
+
// The most recently displayed numbered pick list; `/model #N` selects from it.
|
|
278
|
+
let lastPickIndex: PickEntry[] = [];
|
|
279
|
+
|
|
280
|
+
// pi-style session persistence: resume an existing session or create a new one.
|
|
281
|
+
let sessionId: string | undefined;
|
|
282
|
+
if (!flags.noSession) {
|
|
283
|
+
if (flags.resume) {
|
|
284
|
+
const id = flags.resumeId ?? (await latestSessionId(cwd));
|
|
285
|
+
if (!id) {
|
|
286
|
+
console.log("No session to resume. Starting a new one.");
|
|
287
|
+
sessionId = (await createSession(cwd)).id;
|
|
288
|
+
} else {
|
|
289
|
+
try {
|
|
290
|
+
const { messages } = await loadSession(id, cwd);
|
|
291
|
+
for (const m of messages) history.push(m);
|
|
292
|
+
sessionId = id;
|
|
293
|
+
console.log(`Resumed session ${id} (${messages.length} messages).`);
|
|
294
|
+
} catch (err) {
|
|
295
|
+
console.log(`Could not resume ${id}: ${(err as Error).message}. Starting fresh.`);
|
|
296
|
+
sessionId = (await createSession(cwd)).id;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
sessionId = (await createSession(cwd)).id;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const streamEvents = {
|
|
305
|
+
onToolResult: (tool: string, ok: boolean) => console.log(` └─ stream:${ok ? "complete" : "error"} tool ${tool}`),
|
|
306
|
+
onError: (msg: string) => console.log(` └─ stream:error ${msg}`),
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// Run one conversational turn: compact, persist user msg, run the loop, persist + return the reply.
|
|
310
|
+
// When `useTui`, a live TUI renders the turn and prints the final reply itself (rendered=true).
|
|
311
|
+
const runTurn = async (
|
|
312
|
+
userInput: string,
|
|
313
|
+
useTui: boolean
|
|
314
|
+
): Promise<{ done: boolean; steps: number; reply: string; rendered: boolean; usage: string }> => {
|
|
315
|
+
await maybeCompact(history, { model: sessionModel });
|
|
316
|
+
const beforeLen = history.length;
|
|
317
|
+
history.push({ role: "user", content: userInput });
|
|
318
|
+
|
|
319
|
+
const activeModel = sessionModel || defaultModel;
|
|
320
|
+
const { provider: activeProvider } = await describeModel(activeModel);
|
|
321
|
+
const tui = useTui ? new LaunchTui({ model: activeModel, provider: activeProvider, sessionId, maxSteps: flags.maxSteps }) : null;
|
|
322
|
+
if (tui) tui.start();
|
|
323
|
+
let result;
|
|
324
|
+
const ac = new AbortController();
|
|
325
|
+
const onSigint = () => ac.abort();
|
|
326
|
+
process.once("SIGINT", onSigint);
|
|
327
|
+
try {
|
|
328
|
+
result = await runAgentLoop(history, {
|
|
329
|
+
cwd,
|
|
330
|
+
maxSteps: flags.maxSteps,
|
|
331
|
+
model: sessionModel,
|
|
332
|
+
maxTokens: sessionThinking ? thinkingMaxTokens(sessionThinking) : undefined,
|
|
333
|
+
signal: ac.signal,
|
|
334
|
+
events: tui ? tui.events() : streamEvents,
|
|
335
|
+
});
|
|
336
|
+
} catch (err) {
|
|
337
|
+
if (tui) tui.finish(`! ${(err as Error).message}`);
|
|
338
|
+
throw err;
|
|
339
|
+
} finally {
|
|
340
|
+
process.removeListener("SIGINT", onSigint);
|
|
341
|
+
}
|
|
342
|
+
const reply = result.doneReason || `(reached the ${result.steps}-step limit without signaling done)`;
|
|
343
|
+
// Full-fidelity persistence: append every message the engine added this turn
|
|
344
|
+
// (user prompt + intermediate tool-call/tool-result turns), then the final reply.
|
|
345
|
+
if (sessionId) {
|
|
346
|
+
for (const m of history.slice(beforeLen)) await appendMessage(sessionId, m, cwd);
|
|
347
|
+
}
|
|
348
|
+
history.push({ role: "assistant", content: reply });
|
|
349
|
+
if (sessionId) await appendMessage(sessionId, { role: "assistant", content: reply }, cwd);
|
|
350
|
+
if (tui) tui.finish(reply);
|
|
351
|
+
const usage = result.usage ? ` (${result.usage.inputTokens} in / ${result.usage.outputTokens} out tokens)` : "";
|
|
352
|
+
return { done: result.done, steps: result.steps, reply, rendered: !!tui, usage };
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const joinedArgs = flags.message;
|
|
356
|
+
const isOneShot = joinedArgs.length > 0 || !process.stdin.isTTY;
|
|
357
|
+
|
|
358
|
+
if (isOneShot) {
|
|
359
|
+
let messageContent = joinedArgs;
|
|
360
|
+
if (!process.stdin.isTTY && joinedArgs.length === 0) {
|
|
361
|
+
messageContent = (await Bun.stdin.text()).trim();
|
|
362
|
+
}
|
|
363
|
+
if (!messageContent) {
|
|
364
|
+
console.log("No input provided.");
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
const { reply, usage } = await runTurn(messageContent, false);
|
|
369
|
+
console.log(reply + usage);
|
|
370
|
+
} catch (err) {
|
|
371
|
+
console.log(`! ${(err as Error).message}`);
|
|
372
|
+
}
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// INTERACTIVE mode
|
|
377
|
+
const welcomeStage = EVOLUTION_STAGES[0];
|
|
378
|
+
await animateAsciiArt(welcomeStage);
|
|
379
|
+
console.log(`\n=== joc launch — interactive coding agent (Evolution Stage: ${welcomeStage.name}) ===`);
|
|
380
|
+
const { provider: startProvider } = await describeModel(defaultModel);
|
|
381
|
+
console.log(`Model: ${defaultModel} (${startProvider}) · thinking: ${sessionThinking ?? "medium"}`);
|
|
382
|
+
if (sessionId) console.log(`Session: ${sessionId}`);
|
|
383
|
+
if (contextFiles.length > 0) console.log(`Project context: ${contextFiles.map(f => f.path).join(", ")}`);
|
|
384
|
+
console.log("Type your request. Slash: /help /model /models /provider /agents /config /thinking /view /diff /find /search /sessions /exit" + (LaunchTui.usable(flags.noTui) ? "" : " (plain output)"));
|
|
385
|
+
|
|
386
|
+
const useTui = LaunchTui.usable(flags.noTui);
|
|
387
|
+
// Tab autocomplete: alias names snapshotted once; live models come from the
|
|
388
|
+
// background-warmed cache (logged-in/OAuth accounts). The completer is sync, so
|
|
389
|
+
// it never blocks on the network — it reads whatever the cache currently holds.
|
|
390
|
+
const aliasNames = Object.keys(await listAliases());
|
|
391
|
+
void discoverModels({ timeoutMs: 4000 })
|
|
392
|
+
.then(r => {
|
|
393
|
+
liveModelsCache ??= r;
|
|
394
|
+
})
|
|
395
|
+
.catch(() => {});
|
|
396
|
+
const completionContext = (): CompletionContext => ({
|
|
397
|
+
...staticCompletionContext(),
|
|
398
|
+
liveModels: liveModelsCache ? flattenModels(liveModelsCache).map(e => e.model) : [],
|
|
399
|
+
aliases: aliasNames,
|
|
400
|
+
modelsForProvider: p => liveModelsCache?.find(r => r.provider === p)?.models ?? [],
|
|
401
|
+
});
|
|
402
|
+
const rl = createInterface({
|
|
403
|
+
input: process.stdin,
|
|
404
|
+
output: process.stdout,
|
|
405
|
+
completer: (line: string) => readlineCompleter(line, completionContext()),
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
try {
|
|
409
|
+
while (true) {
|
|
410
|
+
const input = (await rl.question("\njoc> ")).trim();
|
|
411
|
+
if (input === "/exit" || input === "/quit") break;
|
|
412
|
+
if (input === "") continue;
|
|
413
|
+
if (input === "/help") {
|
|
414
|
+
console.log("Slash Commands:");
|
|
415
|
+
console.log(" /help - Show this help message");
|
|
416
|
+
console.log(" /clear - Clear conversation history (keeps system prompt)");
|
|
417
|
+
console.log(" /model [id|#N|save] - Set the session model by id, by #N from /models, or save as default");
|
|
418
|
+
console.log(" /models [refresh|caps] - Live model list (caps = + context/out/thinking/img)");
|
|
419
|
+
console.log(" /provider [name] [model] - Provider credentials, or switch + list that provider's live models");
|
|
420
|
+
console.log(" /agents [role] [model] - List subagent roles, show one, or pin a role's model (saved)");
|
|
421
|
+
console.log(" /config - Show the effective runtime configuration");
|
|
422
|
+
console.log(" /roles [tier model] - Show or set model role tiers (smol/slow/plan)");
|
|
423
|
+
console.log(" /thinking [level] - Show or set the thinking budget (minimal/low/medium/high/xhigh)");
|
|
424
|
+
console.log(" /view <file> [a-b] - Code view: render a file with line numbers + light highlight");
|
|
425
|
+
console.log(" /diff [file] - Render `git diff` with +/- coloring");
|
|
426
|
+
console.log(" /find <glob> - List files matching a glob");
|
|
427
|
+
console.log(" /search <pat> [glob]- Grep the repo for a pattern");
|
|
428
|
+
console.log(" /sessions - List saved sessions");
|
|
429
|
+
console.log(" /evolve - Simulate and view the agent's evolutionary gallery");
|
|
430
|
+
console.log(" /compact - Summarize older turns to free context");
|
|
431
|
+
console.log(" /exit, /quit - Exit the agent");
|
|
432
|
+
console.log("Tools: read / write / edit / bash / find / search. Sessions persist to .joc/sessions/.");
|
|
433
|
+
const tip = getEvolutionTip(history.length, flags.maxSteps);
|
|
434
|
+
console.log(`\n${chalk.cyan("Evolutionary Tip:")} ${tip}`);
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
if (input === "/clear") {
|
|
438
|
+
history.length = 1;
|
|
439
|
+
console.log("(history cleared)");
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (input === "/compact") {
|
|
443
|
+
const res = await maybeCompact(history, { model: sessionModel, force: true });
|
|
444
|
+
console.log(res.compacted ? `(compacted ${res.removed} older messages)` : "(nothing to compact)");
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (input === "/sessions") {
|
|
448
|
+
const sessions = await listSessions(cwd);
|
|
449
|
+
if (sessions.length === 0) console.log("(no saved sessions)");
|
|
450
|
+
for (const s of sessions) console.log(` ${s.id} (${s.messageCount} msgs) ${s.preview}`);
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (input === "/evolve") {
|
|
454
|
+
console.log("=== Initiating Evolutionary Simulation ===");
|
|
455
|
+
for (const stage of EVOLUTION_STAGES) {
|
|
456
|
+
console.log(`\nStage: ${stage.name}`);
|
|
457
|
+
await animateAsciiArt(stage, { delayMs: 40 });
|
|
458
|
+
}
|
|
459
|
+
console.log("\n=== Evolved to Singularity! ===");
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (input.startsWith("/models") && (input === "/models" || input[7] === " ")) {
|
|
463
|
+
const sub = input.substring(7).trim().toLowerCase();
|
|
464
|
+
const refresh = sub === "refresh";
|
|
465
|
+
if (sub === "caps") {
|
|
466
|
+
const live = await getLiveModels();
|
|
467
|
+
const def = sessionModel || (await readGlobalConfig()).defaultModel;
|
|
468
|
+
const { resolved } = await describeModel(def);
|
|
469
|
+
console.log("Live models with capabilities (ctx/out/thinking/img):");
|
|
470
|
+
for (const line of formatEnrichedModels(sortByCapability(enrichAll(live)), { current: resolved })) console.log(line);
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
const cfgNow = await readGlobalConfig();
|
|
474
|
+
const def = sessionModel || cfgNow.defaultModel;
|
|
475
|
+
const { resolved, provider } = await describeModel(def);
|
|
476
|
+
console.log(`Default model: ${formatModelLine({ label: def, resolved, provider })}`);
|
|
477
|
+
console.log("Aliases:");
|
|
478
|
+
for (const line of formatAliasLines(await listAliases())) console.log(line);
|
|
479
|
+
const live = await getLiveModels(refresh);
|
|
480
|
+
lastPickIndex = flattenModels(live);
|
|
481
|
+
console.log("Live models (logged-in providers) — select with /model #N:");
|
|
482
|
+
for (const line of formatPickList(lastPickIndex, { current: resolved })) console.log(line);
|
|
483
|
+
console.log("Refresh: /models refresh · one provider: /provider <name>");
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (input.startsWith("/provider") && (input === "/provider" || input[9] === " ")) {
|
|
487
|
+
const tokens = input.substring(9).trim().split(/\s+/).filter(Boolean);
|
|
488
|
+
const name = (tokens[0] ?? "").toLowerCase();
|
|
489
|
+
const explicitModel = tokens[1];
|
|
490
|
+
const cfgNow = await readGlobalConfig();
|
|
491
|
+
const statuses = await describeAllProviders(cfgNow);
|
|
492
|
+
if (!name) {
|
|
493
|
+
console.log("Providers (credential · base URL):");
|
|
494
|
+
for (const line of formatProviderPanel(statuses)) console.log(line);
|
|
495
|
+
console.log("Switch with: /provider <name> [model] · list live models: /models");
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
const PROVIDER_DEFAULT: Record<string, string> = { anthropic: "sonnet", openai: "gpt", gemini: "flash", ollama: "fast" };
|
|
499
|
+
if (!(name in PROVIDER_DEFAULT)) {
|
|
500
|
+
console.log(`Unknown provider '${name}'. Known: ${statuses.map(s => s.name).join(", ")}.`);
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
const st = statuses.find(s => s.name === name);
|
|
504
|
+
if (st && !st.ready) {
|
|
505
|
+
console.log(`! ${name} is not logged in — run 'joc auth login' or set ${st.envVar ?? "the provider key"}. Switching anyway.`);
|
|
506
|
+
}
|
|
507
|
+
const target = explicitModel ?? PROVIDER_DEFAULT[name];
|
|
508
|
+
sessionModel = target;
|
|
509
|
+
const { resolved, provider } = await describeModel(target);
|
|
510
|
+
console.log(`Model set to ${formatModelLine({ label: target, resolved, provider, ready: st?.ready })}`);
|
|
511
|
+
// Show the provider's live, credentialed catalog so the user can pick a concrete id.
|
|
512
|
+
const live = await getLiveModels();
|
|
513
|
+
const forProvider = live.filter(r => r.provider === name);
|
|
514
|
+
if (forProvider.length) {
|
|
515
|
+
lastPickIndex = flattenModels(forProvider);
|
|
516
|
+
console.log(`Live ${name} models — select with /model #N:`);
|
|
517
|
+
for (const line of formatPickList(lastPickIndex, { current: resolved })) console.log(line);
|
|
518
|
+
}
|
|
519
|
+
if (explicitModel && !liveModelKnown(live, target)) {
|
|
520
|
+
console.log(` (note: '${target}' is not in ${name}'s live list — it may still work, or pick one above)`);
|
|
521
|
+
}
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (input.startsWith("/agents") && (input === "/agents" || input[7] === " ")) {
|
|
525
|
+
const tokens = input.substring(7).trim().split(/\s+/).filter(Boolean);
|
|
526
|
+
const roleArg = tokens[0];
|
|
527
|
+
const modelArg = tokens[1];
|
|
528
|
+
const cfgNow = await readGlobalConfig();
|
|
529
|
+
if (!roleArg) {
|
|
530
|
+
console.log("Subagent roles (used by 'joc team'):");
|
|
531
|
+
for (const line of formatAgentsPanel(SUBAGENT_ROLES, r => ({
|
|
532
|
+
model: resolveSubagentModel(r.id, cfgNow),
|
|
533
|
+
maxSteps: resolveSubagentMaxSteps(r.id, cfgNow),
|
|
534
|
+
}))) console.log(line);
|
|
535
|
+
console.log("Detail: /agents <role> · set model: /agents <role> <model>");
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
const role = getSubagentRole(roleArg);
|
|
539
|
+
if (!role) {
|
|
540
|
+
console.log(`Unknown role '${roleArg}'. Known: ${SUBAGENT_ROLES.map(r => r.id).join(", ")}.`);
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (modelArg) {
|
|
544
|
+
// Persist a per-role model override to ~/.joc/config.json (consumed by 'joc team').
|
|
545
|
+
const next = { ...cfgNow, subagents: { ...(cfgNow.subagents ?? {}) } };
|
|
546
|
+
next.subagents[role.id] = { ...next.subagents[role.id], model: modelArg };
|
|
547
|
+
await saveGlobalConfig(next);
|
|
548
|
+
const { provider } = await describeModel(modelArg);
|
|
549
|
+
console.log(`${role.title} model set to ${modelArg} (${provider}) — saved to ~/.joc/config.json`);
|
|
550
|
+
const live = await getLiveModels();
|
|
551
|
+
if (!liveModelKnown(live, modelArg)) {
|
|
552
|
+
console.log(` (note: '${modelArg}' is not in any live model list — verify it is valid for ${provider})`);
|
|
553
|
+
}
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
for (const line of formatAgentDetail(role, {
|
|
557
|
+
model: resolveSubagentModel(role.id, cfgNow),
|
|
558
|
+
maxSteps: resolveSubagentMaxSteps(role.id, cfgNow),
|
|
559
|
+
})) console.log(line);
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
if (input === "/config") {
|
|
563
|
+
const cfgNow = await readGlobalConfig();
|
|
564
|
+
const label = sessionModel || cfgNow.defaultModel;
|
|
565
|
+
const { resolved, provider } = await describeModel(label);
|
|
566
|
+
console.log("Effective runtime config:");
|
|
567
|
+
for (const line of formatConfigPanel({
|
|
568
|
+
model: label,
|
|
569
|
+
resolved,
|
|
570
|
+
provider,
|
|
571
|
+
thinkingLevel: sessionThinking ?? cfgNow.thinkingLevel ?? "medium",
|
|
572
|
+
ollamaBaseUrl: cfgNow.ollamaBaseUrl,
|
|
573
|
+
openaiBaseUrl: cfgNow.openaiBaseUrl,
|
|
574
|
+
requestMaxRetries: cfgNow.retry?.requestMaxRetries,
|
|
575
|
+
sessionId,
|
|
576
|
+
})) console.log(line);
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
if (input.startsWith("/roles") && (input === "/roles" || input[6] === " ")) {
|
|
580
|
+
const tokens = input.substring(6).trim().split(/\s+/).filter(Boolean);
|
|
581
|
+
const cfgNow = await readGlobalConfig();
|
|
582
|
+
const TIERS = ["smol", "slow", "plan"] as const;
|
|
583
|
+
if (tokens.length >= 2 && (TIERS as readonly string[]).includes(tokens[0])) {
|
|
584
|
+
const tier = tokens[0] as (typeof TIERS)[number];
|
|
585
|
+
const next = { ...cfgNow, roles: { ...(cfgNow.roles ?? {}), [tier]: tokens[1] } };
|
|
586
|
+
await saveGlobalConfig(next);
|
|
587
|
+
console.log(`Role '${tier}' model set to ${tokens[1]} → ~/.joc/config.json`);
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
console.log("Model role tiers (fall back to the default model):");
|
|
591
|
+
for (const tier of TIERS) {
|
|
592
|
+
const { provider } = await describeModel(resolveRoleModel(tier, cfgNow));
|
|
593
|
+
console.log(` ${tier.padEnd(5)} ${resolveRoleModel(tier, cfgNow)} (${provider})`);
|
|
594
|
+
}
|
|
595
|
+
console.log("Set a tier: /roles <smol|slow|plan> <model>");
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
if (input.startsWith("/thinking") && (input === "/thinking" || input[9] === " ")) {
|
|
599
|
+
const arg = input.substring(9).trim().toLowerCase();
|
|
600
|
+
if (!arg) {
|
|
601
|
+
console.log(`Thinking level: ${sessionThinking ?? "medium"} (~${thinkingMaxTokens(sessionThinking)} max tokens/step)`);
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
if (arg === "minimal" || arg === "low" || arg === "medium" || arg === "high" || arg === "xhigh") {
|
|
605
|
+
sessionThinking = arg;
|
|
606
|
+
console.log(`Thinking set to ${arg} (~${thinkingMaxTokens(arg)} max tokens/step)`);
|
|
607
|
+
} else {
|
|
608
|
+
console.log(`Invalid level '${arg}'. Use: minimal | low | medium | high | xhigh.`);
|
|
609
|
+
}
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
if (input.startsWith("/model") && (input === "/model" || input[6] === " ")) {
|
|
613
|
+
let arg = input.substring(6).trim();
|
|
614
|
+
// `/model save [id]` → persist the (session or given) model as the config default.
|
|
615
|
+
if (arg === "save" || arg.startsWith("save ")) {
|
|
616
|
+
const toSave = arg.slice(4).trim() || sessionModel || defaultModel;
|
|
617
|
+
const cfgNow = await readGlobalConfig();
|
|
618
|
+
await saveGlobalConfig({ ...cfgNow, defaultModel: toSave });
|
|
619
|
+
const { resolved, provider } = await describeModel(toSave);
|
|
620
|
+
console.log(`Default model saved: ${formatModelLine({ label: toSave, resolved, provider })} → ~/.joc/config.json`);
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
// Selection from the last numbered pick list (`#N`) or a fuzzy substring.
|
|
624
|
+
if (arg && lastPickIndex.length) {
|
|
625
|
+
const sel = resolveSelection(lastPickIndex, arg);
|
|
626
|
+
if (sel.kind === "index" || sel.kind === "match") {
|
|
627
|
+
arg = sel.entry.model;
|
|
628
|
+
} else if (sel.kind === "ambiguous") {
|
|
629
|
+
console.log(`'${arg}' matches ${sel.matches.length} models — be more specific:`);
|
|
630
|
+
for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
|
|
631
|
+
continue;
|
|
632
|
+
} else if (sel.kind === "out-of-range") {
|
|
633
|
+
console.log(`#${arg.slice(1)} is out of range (1-${sel.max}). Run /models first.`);
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
// kind "none" → fall through and treat `arg` as a literal model id/alias.
|
|
637
|
+
} else if (arg.startsWith("#")) {
|
|
638
|
+
console.log("Run /models (or /provider <name>) first to build the numbered list.");
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
const label = arg || (sessionModel || defaultModel);
|
|
642
|
+
if (arg) sessionModel = arg;
|
|
643
|
+
const { resolved, provider } = await describeModel(label);
|
|
644
|
+
const statuses = await describeAllProviders();
|
|
645
|
+
const st = statuses.find(s => s.name === provider);
|
|
646
|
+
console.log(`${arg ? "Model set to" : "Current model"}: ${formatModelLine({ label, resolved, provider, ready: st?.ready })}`);
|
|
647
|
+
if (st && !st.ready) console.log(` ! ${provider} has no credential — run 'joc setup' or set ${st.envVar ?? "the provider key"}.`);
|
|
648
|
+
if (arg && liveModelsCache && resolved === label && !liveModelKnown(liveModelsCache, resolved)) {
|
|
649
|
+
console.log(` (note: '${resolved}' is not in the live ${provider} catalog — run /models to see valid ids)`);
|
|
650
|
+
}
|
|
651
|
+
const meta = catalogMetadata(resolved);
|
|
652
|
+
if (meta) console.log(` ${formatCapabilityLine(meta)}`);
|
|
653
|
+
console.log(" (persist as default: /model save)");
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
if (input.startsWith("/view") && (input === "/view" || input[5] === " ")) {
|
|
657
|
+
const tokens = input.substring(5).trim().split(/\s+/).filter(Boolean);
|
|
658
|
+
const file = tokens[0];
|
|
659
|
+
if (!file) {
|
|
660
|
+
console.log("Usage: /view <file> [start-end] (e.g. /view src/cli.ts 1-40)");
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
let content: string;
|
|
664
|
+
try {
|
|
665
|
+
content = await fs.promises.readFile(path.resolve(cwd, file), "utf-8");
|
|
666
|
+
} catch (err) {
|
|
667
|
+
console.log(`! cannot read ${file}: ${(err as Error).message}`);
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
const range = tokens[1] ? parseLineRange(tokens[1]) : undefined;
|
|
671
|
+
if (tokens[1] && !range) {
|
|
672
|
+
console.log(`Invalid range '${tokens[1]}'. Use start-end | start- | start.`);
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
const lang = detectLanguage(file);
|
|
676
|
+
const { lines, startLine } = sliceLines(content, range ?? undefined);
|
|
677
|
+
const { cols } = await import("../tui/terminal").then(m => m.size());
|
|
678
|
+
console.log(chalk.bold(`${file}`) + chalk.gray(` (${languageLabel(lang)}, lines ${startLine}-${startLine + lines.length - 1})`));
|
|
679
|
+
for (const line of formatCodeBlock(lines.join("\n"), { startLine, lang, cols: Math.max(40, cols - 1), maxLines: 200 })) {
|
|
680
|
+
console.log(line);
|
|
681
|
+
}
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
if (input.startsWith("/diff") && (input === "/diff" || input[5] === " ")) {
|
|
685
|
+
const target = input.substring(5).trim();
|
|
686
|
+
const proc = Bun.spawnSync(["git", "diff", ...(target ? ["--", target] : [])], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
687
|
+
if (proc.exitCode !== 0 && !proc.stdout.length) {
|
|
688
|
+
console.log(`! git diff failed: ${proc.stderr.toString().trim() || "not a git repo?"}`);
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
const text = proc.stdout.toString();
|
|
692
|
+
if (!text.trim()) {
|
|
693
|
+
console.log("(no unstaged changes)");
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
const { cols } = await import("../tui/terminal").then(m => m.size());
|
|
697
|
+
for (const line of formatDiff(text, { cols: Math.max(40, cols - 1), maxLines: 400 })) console.log(line);
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
if (input.startsWith("/find") && (input === "/find" || input[5] === " ")) {
|
|
701
|
+
const glob = input.substring(5).trim();
|
|
702
|
+
if (!glob) {
|
|
703
|
+
console.log("Usage: /find <glob> (e.g. /find src/**/*.ts)");
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
const res = await findTool(glob, cwd);
|
|
707
|
+
console.log(res.success ? (res.output || "(no matches)") : `! ${res.error}`);
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
if (input.startsWith("/search") && (input === "/search" || input[7] === " ")) {
|
|
711
|
+
const tokens = input.substring(7).trim().split(/\s+/).filter(Boolean);
|
|
712
|
+
const pattern = tokens[0];
|
|
713
|
+
const glob = tokens[1] ?? "*";
|
|
714
|
+
if (!pattern) {
|
|
715
|
+
console.log("Usage: /search <pattern> [glob] (e.g. /search resolveProvider src/**/*.ts)");
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
const res = await searchTool(pattern, glob, cwd);
|
|
719
|
+
console.log(res.success ? (res.output || "(no matches)") : `! ${res.error}`);
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Unhandled slash attempt → suggest, don't send the typo to the model.
|
|
724
|
+
if (isSlashAttempt(input)) {
|
|
725
|
+
const m = matchSlash(input);
|
|
726
|
+
console.log(m.length ? `Did you mean: ${m.join(" ")} ?` : `Unknown command '${input}'. Try /help.`);
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
try {
|
|
731
|
+
const { done, steps, reply, rendered, usage } = await runTurn(input, useTui);
|
|
732
|
+
if (!rendered) {
|
|
733
|
+
console.log(`joc> ${reply}${usage}`);
|
|
734
|
+
if (!done) console.log(`(agent did not converge in ${steps} steps)`);
|
|
735
|
+
} else if (usage) {
|
|
736
|
+
console.log(usage.trim());
|
|
737
|
+
}
|
|
738
|
+
} catch (err) {
|
|
739
|
+
console.log(`! ${(err as Error).message}`);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
} finally {
|
|
743
|
+
rl.close();
|
|
744
|
+
}
|
|
745
|
+
}
|