@megacode/cli 0.2.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/LICENSE +21 -0
- package/README.md +268 -0
- package/dist/adapters/accounts.js +34 -0
- package/dist/adapters/auth/anthropic.js +36 -0
- package/dist/adapters/auth/chatgpt.js +100 -0
- package/dist/adapters/auth/oauth.js +68 -0
- package/dist/adapters/auth/openrouter.js +27 -0
- package/dist/adapters/auth/store.js +25 -0
- package/dist/adapters/git/worktree.js +113 -0
- package/dist/adapters/mcp/config.js +59 -0
- package/dist/adapters/mcp/manager.js +165 -0
- package/dist/adapters/mcp/results.js +21 -0
- package/dist/adapters/mcp/transport.js +19 -0
- package/dist/adapters/project.js +15 -0
- package/dist/adapters/providers/anthropic.js +103 -0
- package/dist/adapters/providers/catalog.js +80 -0
- package/dist/adapters/providers/chatgpt.js +117 -0
- package/dist/adapters/providers/credentials.js +55 -0
- package/dist/adapters/providers/gemini.js +123 -0
- package/dist/adapters/providers/openai.js +128 -0
- package/dist/adapters/providers/registry.js +68 -0
- package/dist/adapters/providers/shared.js +12 -0
- package/dist/adapters/providers/usage.js +95 -0
- package/dist/adapters/settings.js +15 -0
- package/dist/adapters/skills.js +163 -0
- package/dist/adapters/storage.js +30 -0
- package/dist/adapters/tools/files.js +113 -0
- package/dist/adapters/tools/image.js +63 -0
- package/dist/adapters/tools/index.js +26 -0
- package/dist/adapters/tools/output.js +56 -0
- package/dist/adapters/tools/paths.js +5 -0
- package/dist/adapters/tools/questions.js +52 -0
- package/dist/adapters/tools/shell.js +73 -0
- package/dist/adapters/tools/validation.js +34 -0
- package/dist/args.js +30 -0
- package/dist/cli.js +111 -0
- package/dist/composition.js +18 -0
- package/dist/core/agent.js +128 -0
- package/dist/core/conversation.js +37 -0
- package/dist/core/prompts.js +12 -0
- package/dist/core/provider.js +8 -0
- package/dist/core/settings.js +15 -0
- package/dist/core/suggestion.js +29 -0
- package/dist/core/tools.js +10 -0
- package/dist/lib/async.js +6 -0
- package/dist/ui/App.js +218 -0
- package/dist/ui/ApprovalDialog.js +39 -0
- package/dist/ui/ConfigMenu.js +106 -0
- package/dist/ui/Dialog.js +10 -0
- package/dist/ui/DiffLines.js +12 -0
- package/dist/ui/EffortPicker.js +8 -0
- package/dist/ui/Help.js +21 -0
- package/dist/ui/KeyList.js +10 -0
- package/dist/ui/LoginDialog.js +154 -0
- package/dist/ui/McpMenu.js +138 -0
- package/dist/ui/ModelPicker.js +117 -0
- package/dist/ui/PromptInput.js +161 -0
- package/dist/ui/Questionnaire.js +35 -0
- package/dist/ui/Select.js +28 -0
- package/dist/ui/Spinner.js +18 -0
- package/dist/ui/StatusLine.js +19 -0
- package/dist/ui/TextField.js +22 -0
- package/dist/ui/Transcript.js +40 -0
- package/dist/ui/WorktreeMenu.js +83 -0
- package/dist/ui/autocomplete.js +6 -0
- package/dist/ui/commands.js +117 -0
- package/dist/ui/diff.js +130 -0
- package/dist/ui/format.js +138 -0
- package/dist/ui/history.js +9 -0
- package/dist/ui/hooks/useAgentSession.js +34 -0
- package/dist/ui/hooks/usePromptSuggestion.js +23 -0
- package/dist/ui/hooks/useStreamedText.js +38 -0
- package/dist/ui/hooks/useWorktree.js +30 -0
- package/dist/ui/loginFlow.js +37 -0
- package/dist/ui/mcpWizard.js +18 -0
- package/dist/ui/plain.js +60 -0
- package/dist/ui/session.js +168 -0
- package/package.json +57 -0
package/dist/ui/diff.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { diffWordsWithSpace, structuredPatch } from "diff";
|
|
4
|
+
// A file change as Claude Code shows it: one line-number column, removed lines red on a red band and
|
|
5
|
+
// added lines green on a green band (the changed words brighter), plain context around them.
|
|
6
|
+
// New files are listed whole.
|
|
7
|
+
export const DIFF_COLORS = {
|
|
8
|
+
removedNumber: "#dc5a5a",
|
|
9
|
+
removed: "#3d0100",
|
|
10
|
+
removedWord: "#5c0200",
|
|
11
|
+
addedNumber: "#50c850",
|
|
12
|
+
added: "#022800",
|
|
13
|
+
addedWord: "#044700",
|
|
14
|
+
muted: "#999999",
|
|
15
|
+
};
|
|
16
|
+
const MAX_COMPARE = 1_000_000; // characters; larger files aren't diffed
|
|
17
|
+
/** Marks the words that changed between a replaced line and its replacement, unless they're mostly different. */
|
|
18
|
+
function markChangedWords(removed, added) {
|
|
19
|
+
const parts = diffWordsWithSpace(removed.text, added.text);
|
|
20
|
+
const same = parts.filter((p) => !p.added && !p.removed).reduce((n, p) => n + p.value.length, 0);
|
|
21
|
+
if (same < Math.max(removed.text.length, added.text.length) / 2)
|
|
22
|
+
return;
|
|
23
|
+
for (const [row, side] of [[removed, "removed"], [added, "added"]]) {
|
|
24
|
+
row.marks = [];
|
|
25
|
+
let at = 0;
|
|
26
|
+
for (const p of parts) {
|
|
27
|
+
if (p[side === "added" ? "removed" : "added"])
|
|
28
|
+
continue; // the other side's text
|
|
29
|
+
if (p[side])
|
|
30
|
+
row.marks.push([at, at + p.value.length]);
|
|
31
|
+
at += p.value.length;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** The rows to show for a change, at most `maxRows`. Null when the files are too large to compare. */
|
|
36
|
+
export function buildDiff(change, maxRows = 60) {
|
|
37
|
+
if (change.before.length + change.after.length > MAX_COMPARE)
|
|
38
|
+
return null;
|
|
39
|
+
const file = relative(process.cwd(), change.file) || change.file;
|
|
40
|
+
const bounded = (rows, rest) => ({
|
|
41
|
+
file,
|
|
42
|
+
rows: rows.slice(0, maxRows),
|
|
43
|
+
hidden: Math.max(0, rows.length - maxRows),
|
|
44
|
+
...rest,
|
|
45
|
+
});
|
|
46
|
+
if (change.created) {
|
|
47
|
+
const lines = change.after === "" ? [] : change.after.replace(/\n$/, "").split("\n");
|
|
48
|
+
const rows = lines.map((text, i) => ({ type: "line", number: i + 1, text }));
|
|
49
|
+
return bounded(rows, { created: true, numberWidth: String(lines.length).length, added: lines.length, removed: 0 });
|
|
50
|
+
}
|
|
51
|
+
const patch = structuredPatch(file, file, change.before, change.after, undefined, undefined, { context: 3, timeout: 200 });
|
|
52
|
+
if (!patch)
|
|
53
|
+
return null;
|
|
54
|
+
const rows = [];
|
|
55
|
+
let [added, removed, widest] = [0, 0, 0];
|
|
56
|
+
for (const [h, hunk] of patch.hunks.entries()) {
|
|
57
|
+
if (h > 0)
|
|
58
|
+
rows.push({ type: "gap" });
|
|
59
|
+
let [oldNo, newNo] = [hunk.oldStart, hunk.newStart];
|
|
60
|
+
const lines = hunk.lines.filter((l) => !l.startsWith("\\")); // ""
|
|
61
|
+
for (let i = 0; i < lines.length;) {
|
|
62
|
+
if (lines[i][0] === " ") {
|
|
63
|
+
rows.push({ type: "context", number: newNo++, text: lines[i++].slice(1) });
|
|
64
|
+
oldNo++;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
// A block of removals then additions; same-sized blocks are line-for-line replacements.
|
|
68
|
+
const minus = [];
|
|
69
|
+
const plus = [];
|
|
70
|
+
for (; lines[i]?.[0] === "-"; i++)
|
|
71
|
+
minus.push({ type: "remove", number: oldNo++, text: lines[i].slice(1) });
|
|
72
|
+
for (; lines[i]?.[0] === "+"; i++)
|
|
73
|
+
plus.push({ type: "add", number: newNo++, text: lines[i].slice(1) });
|
|
74
|
+
if (minus.length === plus.length)
|
|
75
|
+
minus.forEach((m, k) => markChangedWords(m, plus[k]));
|
|
76
|
+
rows.push(...minus, ...plus);
|
|
77
|
+
[removed, added] = [removed + minus.length, added + plus.length];
|
|
78
|
+
}
|
|
79
|
+
widest = Math.max(widest, oldNo - 1, newNo - 1);
|
|
80
|
+
}
|
|
81
|
+
return bounded(rows, { created: false, numberWidth: String(widest).length, added, removed });
|
|
82
|
+
}
|
|
83
|
+
/** The gutter for a row: a space, the line number, a space, and the sign (none for new files). */
|
|
84
|
+
export function gutter(row, model) {
|
|
85
|
+
if (row.type === "gap")
|
|
86
|
+
return " ".repeat(model.numberWidth + (model.created ? 2 : 3));
|
|
87
|
+
const sign = model.created ? "" : row.type === "add" ? "+" : row.type === "remove" ? "-" : " ";
|
|
88
|
+
return ` ${String(row.number).padStart(model.numberWidth)} ${sign}`;
|
|
89
|
+
}
|
|
90
|
+
/** Text color (line number, sign and code), band and changed-word background for each kind of row. */
|
|
91
|
+
export function rowStyle(row) {
|
|
92
|
+
if (row.type === "add")
|
|
93
|
+
return { color: DIFF_COLORS.addedNumber, background: DIFF_COLORS.added, word: DIFF_COLORS.addedWord };
|
|
94
|
+
if (row.type === "remove")
|
|
95
|
+
return { color: DIFF_COLORS.removedNumber, background: DIFF_COLORS.removed, word: DIFF_COLORS.removedWord };
|
|
96
|
+
return {};
|
|
97
|
+
}
|
|
98
|
+
/** A row's text in pieces, each marked if it's a changed word. */
|
|
99
|
+
export function pieces(row) {
|
|
100
|
+
const out = [];
|
|
101
|
+
let at = 0;
|
|
102
|
+
for (const [start, end] of row.marks ?? []) {
|
|
103
|
+
if (start > at)
|
|
104
|
+
out.push({ text: row.text.slice(at, start), changed: false });
|
|
105
|
+
out.push({ text: row.text.slice(start, end), changed: true });
|
|
106
|
+
at = end;
|
|
107
|
+
}
|
|
108
|
+
if (at < row.text.length || !out.length)
|
|
109
|
+
out.push({ text: row.text.slice(at), changed: false });
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
/** The diff as ANSI text `width` columns wide, for plain (non-TUI) output. */
|
|
113
|
+
export function diffToAnsi(model, width = process.stdout.columns || 80) {
|
|
114
|
+
const lines = model.rows.map((row) => {
|
|
115
|
+
const head = gutter(row, model);
|
|
116
|
+
if (row.type === "gap")
|
|
117
|
+
return chalk.hex(DIFF_COLORS.muted)(head + "…");
|
|
118
|
+
const style = rowStyle(row);
|
|
119
|
+
const paint = (background, text) => {
|
|
120
|
+
const colored = style.color ? chalk.hex(style.color)(text) : text;
|
|
121
|
+
return background ? chalk.bgHex(background)(colored) : colored;
|
|
122
|
+
};
|
|
123
|
+
const body = pieces(row).map((p) => paint(p.changed ? style.word : style.background, p.text)).join("");
|
|
124
|
+
const padding = style.background ? paint(style.background, " ".repeat(Math.max(0, width - head.length - row.text.length))) : "";
|
|
125
|
+
return paint(style.background, head) + body + padding;
|
|
126
|
+
});
|
|
127
|
+
if (model.hidden)
|
|
128
|
+
lines.push(chalk.hex(DIFF_COLORS.muted)(`… +${model.hidden} lines`));
|
|
129
|
+
return lines.join("\n");
|
|
130
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { stripVTControlCharacters, styleText } from "node:util";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { buildDiff, diffToAnsi } from "./diff.js";
|
|
5
|
+
const TOOL_LABELS = {
|
|
6
|
+
ask_questions: "Ask",
|
|
7
|
+
view_image: "View",
|
|
8
|
+
read_file: "Read",
|
|
9
|
+
write_file: "Write",
|
|
10
|
+
edit_file: "Update",
|
|
11
|
+
bash: "Bash",
|
|
12
|
+
list_files: "List",
|
|
13
|
+
grep: "Search",
|
|
14
|
+
};
|
|
15
|
+
/** A tool call's name and its main argument: "Bash" + "npm test", "Read" + "src/cli.ts". */
|
|
16
|
+
export function callParts(c) {
|
|
17
|
+
const mcp = c.name.match(/^mcp__(.+?)__(.+)$/);
|
|
18
|
+
const firstString = Object.values(c.input ?? {}).find((v) => typeof v === "string");
|
|
19
|
+
const arg = String((mcp ? firstString : (c.input.command ?? c.input.path ?? c.input.pattern)) ?? "").split("\n")[0];
|
|
20
|
+
return { name: mcp ? `${mcp[1]} · ${mcp[2]} (MCP)` : (TOOL_LABELS[c.name] ?? c.name), arg: arg.length > 80 ? arg.slice(0, 80) + "…" : arg };
|
|
21
|
+
}
|
|
22
|
+
/** "Bash(npm test)", "Read(src/cli.ts)" */
|
|
23
|
+
export function formatCall(c) {
|
|
24
|
+
const { name, arg } = callParts(c);
|
|
25
|
+
return `${name}(${arg})`;
|
|
26
|
+
}
|
|
27
|
+
/** The text shown for an approval request in plain output: a diff for file changes, else the tool's own description. */
|
|
28
|
+
export function approvalBody(req) {
|
|
29
|
+
if (!req.change)
|
|
30
|
+
return req.body ?? "";
|
|
31
|
+
const model = buildDiff(req.change);
|
|
32
|
+
return model ? diffToAnsi(model) : "Diff preview unavailable (file too large).";
|
|
33
|
+
}
|
|
34
|
+
/** Shortens paths under the home directory to ~/… for display. */
|
|
35
|
+
export const tildify = (p) => p.replace(os.homedir(), "~");
|
|
36
|
+
/** Display-only prompt preview; queued/sent prompts and history retain the full text. */
|
|
37
|
+
export function previewPrompt(text) {
|
|
38
|
+
const chars = Array.from(text.replace(/\r\n|[\r\n]/g, " ⏎ ").replace(/\t/g, " "));
|
|
39
|
+
return chars.length > 200 ? chars.slice(0, 199).join("") + "…" : chars.join("");
|
|
40
|
+
}
|
|
41
|
+
export const plural = (n, word) => `${n.toLocaleString("en-US")} ${word}${n === 1 ? "" : "s"}`;
|
|
42
|
+
/** First few lines of tool output for the transcript. */
|
|
43
|
+
export function previewOutput(output, lines = 3) {
|
|
44
|
+
const all = output.trimEnd().split("\n");
|
|
45
|
+
const head = all.slice(0, lines).join("\n");
|
|
46
|
+
return all.length > lines ? `${head}\n${styleText("dim", `… +${plural(all.length - lines, "line")}`)}` : head;
|
|
47
|
+
}
|
|
48
|
+
const count = (n, word) => `${chalk.bold(n.toLocaleString("en-US"))} ${word}${n === 1 ? "" : "s"}`;
|
|
49
|
+
/** Claude Code's summary of an applied change: "Added 2 lines, removed 1 line", "Wrote 14 lines to a.ts". */
|
|
50
|
+
export function describeChange(change, model = buildDiff(change, 0)) {
|
|
51
|
+
if (!model)
|
|
52
|
+
return "Changed (too large to compare)";
|
|
53
|
+
if (model.created)
|
|
54
|
+
return `Wrote ${count(model.added, "line")} to ${chalk.bold(model.file)}`;
|
|
55
|
+
const parts = [model.added && `added ${count(model.added, "line")}`, model.removed && `removed ${count(model.removed, "line")}`].filter(Boolean);
|
|
56
|
+
const text = parts.join(", ");
|
|
57
|
+
return text ? text[0].toUpperCase() + text.slice(1) : "No changes";
|
|
58
|
+
}
|
|
59
|
+
/** "Read 3 lines", or "Read 200 of 1,234 lines" for a partial read. */
|
|
60
|
+
function describeRead(output) {
|
|
61
|
+
if (output.startsWith("[EOF:"))
|
|
62
|
+
return output;
|
|
63
|
+
const shown = output.match(/^\d+\t/gm)?.length ?? 0;
|
|
64
|
+
const total = output.match(/\[(\d+) lines total; continue/)?.[1];
|
|
65
|
+
return total ? `Read ${shown.toLocaleString("en-US")} of ${plural(Number(total), "line")}` : `Read ${plural(shown, "line")}`;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* What the transcript shows for a finished tool call. The model gets the full output; people get
|
|
69
|
+
* a summary where the raw text would only repeat what the diff or the call already says.
|
|
70
|
+
*/
|
|
71
|
+
export function displayOutput(call, result, model) {
|
|
72
|
+
if (result.isError)
|
|
73
|
+
return result.output;
|
|
74
|
+
if (result.change)
|
|
75
|
+
return describeChange(result.change, model);
|
|
76
|
+
if (call.name === "read_file")
|
|
77
|
+
return describeRead(result.output);
|
|
78
|
+
return result.output;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Tiny Markdown → ANSI renderer: headings, bold, italics, inline code, fenced code, lists, quotes.
|
|
82
|
+
* Good enough for model output in a terminal; not a full CommonMark implementation.
|
|
83
|
+
*/
|
|
84
|
+
export function renderMarkdown(md) {
|
|
85
|
+
const out = [];
|
|
86
|
+
let fence;
|
|
87
|
+
for (const line of md.split("\n")) {
|
|
88
|
+
const next = fenceAfter(fence, line);
|
|
89
|
+
if (fence !== undefined || next !== undefined) {
|
|
90
|
+
// Code is indented; control characters are dropped so a reply can't restyle the terminal.
|
|
91
|
+
if (fence !== undefined && next !== undefined)
|
|
92
|
+
out.push(` ${stripVTControlCharacters(line)}`);
|
|
93
|
+
fence = next;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
let m;
|
|
97
|
+
if ((m = line.match(/^(#{1,6})\s+(.*)$/)))
|
|
98
|
+
out.push(styleText("bold", inline(m[2])));
|
|
99
|
+
else if ((m = line.match(/^(\s*)[-*+]\s+(.*)$/)))
|
|
100
|
+
out.push(`${m[1]}• ${inline(m[2])}`);
|
|
101
|
+
else if ((m = line.match(/^>\s?(.*)$/)))
|
|
102
|
+
out.push(styleText(["dim", "italic"], `│ ${inline(m[1])}`));
|
|
103
|
+
else if (/^\s*([-*_])\1{2,}\s*$/.test(line))
|
|
104
|
+
out.push(styleText("dim", "─".repeat(40)));
|
|
105
|
+
else
|
|
106
|
+
out.push(inline(line));
|
|
107
|
+
}
|
|
108
|
+
return out.join("\n");
|
|
109
|
+
}
|
|
110
|
+
/** The fence open after `line`: a line of ``` or ~~~ opens one; the same marker, at least as long, closes it. */
|
|
111
|
+
function fenceAfter(open, line) {
|
|
112
|
+
const m = line.match(/^\s*(`{3,}|~{3,})(.*)$/);
|
|
113
|
+
if (!m)
|
|
114
|
+
return open;
|
|
115
|
+
if (open === undefined)
|
|
116
|
+
return m[1];
|
|
117
|
+
return m[1][0] === open[0] && m[1].length >= open.length && !m[2].trim() ? undefined : open;
|
|
118
|
+
}
|
|
119
|
+
function inline(s) {
|
|
120
|
+
return s
|
|
121
|
+
.replace(/`([^`]+)`/g, (_, c) => styleText("cyan", c))
|
|
122
|
+
.replace(/\*\*([^*]+)\*\*/g, (_, t) => styleText("bold", t))
|
|
123
|
+
.replace(/(^|[^*\w])\*([^*\s][^*]*)\*(?!\w)/g, (_, p, t) => p + styleText("italic", t));
|
|
124
|
+
}
|
|
125
|
+
/** Index just past the last paragraph break outside a code fence, or -1. Used to flush streamed text. */
|
|
126
|
+
export function lastSafeBreak(text) {
|
|
127
|
+
let fence;
|
|
128
|
+
let last = -1;
|
|
129
|
+
let offset = 0;
|
|
130
|
+
for (const line of text.split("\n")) {
|
|
131
|
+
fence = fenceAfter(fence, line);
|
|
132
|
+
const end = offset + line.length;
|
|
133
|
+
if (!fence && text[end] === "\n" && text[end + 1] === "\n")
|
|
134
|
+
last = end + 2;
|
|
135
|
+
offset = end + 1;
|
|
136
|
+
}
|
|
137
|
+
return last;
|
|
138
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { loadSettings } from "../adapters/settings.js";
|
|
2
|
+
import { readJson, writeJson } from "../adapters/storage.js";
|
|
3
|
+
// Prompt history shared across sessions, newest last.
|
|
4
|
+
const MAX = 200;
|
|
5
|
+
export const loadHistory = () => (loadSettings().saveHistory ? readJson("history.json", []) : []);
|
|
6
|
+
export const saveHistory = (entries) => {
|
|
7
|
+
if (loadSettings().saveHistory)
|
|
8
|
+
writeJson("history.json", entries.slice(-MAX));
|
|
9
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useRef, useState, useSyncExternalStore } from "react";
|
|
2
|
+
import { AgentSession } from "../session.js";
|
|
3
|
+
import { useStreamedText } from "./useStreamedText.js";
|
|
4
|
+
/**
|
|
5
|
+
* React binding for an AgentSession. The host callbacks may change on every render;
|
|
6
|
+
* the session always calls the latest ones.
|
|
7
|
+
*/
|
|
8
|
+
export function useAgentSession(agent, host) {
|
|
9
|
+
const latest = useRef(host);
|
|
10
|
+
latest.current = host;
|
|
11
|
+
const stream = useStreamedText((text, first) => latest.current.push({ kind: "assistant", text, first }));
|
|
12
|
+
const streamRef = useRef(stream);
|
|
13
|
+
streamRef.current = stream;
|
|
14
|
+
const [session] = useState(() => new AgentSession(agent, {
|
|
15
|
+
push: (...items) => latest.current.push(...items),
|
|
16
|
+
notice: (text, level) => latest.current.notice(text, level),
|
|
17
|
+
canSend: (text) => latest.current.canSend(text),
|
|
18
|
+
mode: () => latest.current.mode(),
|
|
19
|
+
onAllowEdits: () => latest.current.onAllowEdits(),
|
|
20
|
+
restoreInput: (text) => latest.current.restoreInput(text),
|
|
21
|
+
onText: (delta) => streamRef.current.append(delta),
|
|
22
|
+
onStepEnd: () => streamRef.current.end(),
|
|
23
|
+
}));
|
|
24
|
+
const state = useSyncExternalStore(session.subscribe, () => session.state);
|
|
25
|
+
return {
|
|
26
|
+
...state,
|
|
27
|
+
streaming: { text: stream.streaming, first: stream.first },
|
|
28
|
+
submit: session.submit,
|
|
29
|
+
interrupt: session.interrupt,
|
|
30
|
+
flushQueue: session.flushQueue,
|
|
31
|
+
answerApproval: session.answerApproval,
|
|
32
|
+
abort: session.abort,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Asks the model for a next-prompt suggestion after each completed turn. Any change
|
|
4
|
+
* (new turn, /clear, model switch) drops the current suggestion and cancels a pending one.
|
|
5
|
+
*/
|
|
6
|
+
export function usePromptSuggestion(agent, { enabled, running, completedTurn, epoch, model }) {
|
|
7
|
+
const [suggestion, setSuggestion] = useState("");
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
setSuggestion("");
|
|
10
|
+
if (!enabled || running || !completedTurn || !agent.messages.length)
|
|
11
|
+
return;
|
|
12
|
+
const ctrl = new AbortController();
|
|
13
|
+
const timer = setTimeout(() => {
|
|
14
|
+
void agent.suggestPrompt(ctrl.signal).then((text) => {
|
|
15
|
+
if (!ctrl.signal.aborted)
|
|
16
|
+
setSuggestion(text);
|
|
17
|
+
}).catch(() => { }); // Optional suggestions must never disrupt the main conversation.
|
|
18
|
+
}, 300);
|
|
19
|
+
const timeout = setTimeout(() => ctrl.abort(), 15_000);
|
|
20
|
+
return () => { clearTimeout(timer); clearTimeout(timeout); ctrl.abort(); };
|
|
21
|
+
}, [agent, enabled, running, completedTurn, epoch, model]);
|
|
22
|
+
return suggestion;
|
|
23
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
|
+
import { lastSafeBreak } from "../format.js";
|
|
3
|
+
/**
|
|
4
|
+
* Buffers streamed assistant text. Finished paragraphs go to `commit` (into <Static>) so the
|
|
5
|
+
* live area stays short; `streaming` is the unfinished rest.
|
|
6
|
+
*/
|
|
7
|
+
export function useStreamedText(commit) {
|
|
8
|
+
const [streaming, setStreaming] = useState("");
|
|
9
|
+
const buffer = useRef("");
|
|
10
|
+
const first = useRef(true); // the next committed chunk starts a new reply
|
|
11
|
+
const timer = useRef(null);
|
|
12
|
+
const flush = (all) => {
|
|
13
|
+
timer.current = null;
|
|
14
|
+
const text = buffer.current;
|
|
15
|
+
const cut = all ? text.length : lastSafeBreak(text);
|
|
16
|
+
if (cut > 0 && text.slice(0, cut).trim()) {
|
|
17
|
+
commit(text.slice(0, cut).trim(), first.current);
|
|
18
|
+
first.current = false;
|
|
19
|
+
}
|
|
20
|
+
buffer.current = cut > 0 ? text.slice(cut) : text;
|
|
21
|
+
setStreaming(buffer.current);
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
streaming,
|
|
25
|
+
first: first.current,
|
|
26
|
+
append(delta) {
|
|
27
|
+
buffer.current += delta;
|
|
28
|
+
timer.current ??= setTimeout(() => flush(false), 40);
|
|
29
|
+
},
|
|
30
|
+
/** Commit everything; the next text starts a new reply. */
|
|
31
|
+
end() {
|
|
32
|
+
if (timer.current)
|
|
33
|
+
clearTimeout(timer.current);
|
|
34
|
+
flush(true);
|
|
35
|
+
first.current = true;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { openWorktree, removeWorktree } from "../../adapters/git/worktree.js";
|
|
3
|
+
/** The git worktree the session works in, if any. Actions return a message to show and throw on git errors. */
|
|
4
|
+
export function useWorktree(agent, initial, home) {
|
|
5
|
+
const [worktree, setWorktree] = useState(initial);
|
|
6
|
+
const moveTo = (dir) => {
|
|
7
|
+
process.chdir(dir);
|
|
8
|
+
agent.reloadSystemPrompt(); // the system prompt includes the working directory
|
|
9
|
+
};
|
|
10
|
+
return {
|
|
11
|
+
worktree,
|
|
12
|
+
/** Switches to the named worktree, creating it if needed; no name creates one with a random name. */
|
|
13
|
+
async enter(name) {
|
|
14
|
+
const wt = await openWorktree(name);
|
|
15
|
+
moveTo(wt.path);
|
|
16
|
+
setWorktree(wt);
|
|
17
|
+
return `${wt.created ? "Created" : "Switched to"} worktree ${wt.name} on branch ${wt.branch} · ${wt.path}`;
|
|
18
|
+
},
|
|
19
|
+
/** Returns to `home`, optionally removing the worktree and its branch. */
|
|
20
|
+
async leave(remove) {
|
|
21
|
+
const wt = worktree;
|
|
22
|
+
moveTo(home);
|
|
23
|
+
setWorktree(null);
|
|
24
|
+
if (!remove)
|
|
25
|
+
return `Kept worktree ${wt.name} at ${wt.path} (branch ${wt.branch}). Return with megacode -w ${wt.name}.`;
|
|
26
|
+
await removeWorktree(wt);
|
|
27
|
+
return `Removed worktree ${wt.name} and branch ${wt.branch}.`;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** A provider's first step: choose a method when there are several, else go straight to its only one. */
|
|
2
|
+
export function firstStep(info) {
|
|
3
|
+
if (info.methods.length > 1)
|
|
4
|
+
return "method";
|
|
5
|
+
return info.methods[0] === "url" ? "url" : "key";
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Where esc leads from `step`; "close" ends the dialog. A dialog opened for one provider
|
|
9
|
+
* (e.g. `/login openai`) has no provider list to go back to.
|
|
10
|
+
*/
|
|
11
|
+
export function stepBack(step, info, openedForProvider) {
|
|
12
|
+
if (step === "pick" || !info)
|
|
13
|
+
return "close";
|
|
14
|
+
if (step === "key" && info.keyOptional)
|
|
15
|
+
return "url";
|
|
16
|
+
if (step === "browser" || (step === "key" && info.methods.length > 1))
|
|
17
|
+
return "method";
|
|
18
|
+
return openedForProvider ? "close" : "pick";
|
|
19
|
+
}
|
|
20
|
+
/** After credentials fail, the step where the likely cause can be fixed. */
|
|
21
|
+
export function stepAfterFailure(info, method, key) {
|
|
22
|
+
if (info.local || (info.keyOptional && !key))
|
|
23
|
+
return "url"; // the endpoint is the likely problem
|
|
24
|
+
if (method && method !== "key")
|
|
25
|
+
return "method"; // a browser sign-in: try again or pick another way
|
|
26
|
+
return "key";
|
|
27
|
+
}
|
|
28
|
+
/** An error from verifying credentials, in words that say what to do. */
|
|
29
|
+
export function friendlyError(e, info, baseURL) {
|
|
30
|
+
if (e.status === 401 || e.status === 403 || /api key|unauthori[sz]ed|invalid.*key/i.test(e.message))
|
|
31
|
+
return "Those credentials were rejected. Check them and try again.";
|
|
32
|
+
if (/ECONNREFUSED|fetch failed|Connection error|Timed out/i.test(e.message)) {
|
|
33
|
+
const hint = info?.local ? `Is ${info.label.replace(" (local)", "")} running at ${baseURL}?` : "Check the URL and your network.";
|
|
34
|
+
return `Couldn't connect. ${hint}`;
|
|
35
|
+
}
|
|
36
|
+
return e.message.split("\n")[0];
|
|
37
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { isHttpUrl, SERVER_NAME, splitCommand } from "../adapters/mcp/config.js";
|
|
2
|
+
// Steps and checks for the /mcp "Add server" wizard, kept apart from rendering so they can be tested.
|
|
3
|
+
export const ADD_STEPS = ["name", "type", "target", "extras"];
|
|
4
|
+
/** The step before `step`, or null to leave the wizard. */
|
|
5
|
+
export const previousAddStep = (step) => ADD_STEPS[ADD_STEPS.indexOf(step) - 1] ?? null;
|
|
6
|
+
export function serverNameError(name, existing) {
|
|
7
|
+
if (!SERVER_NAME.test(name))
|
|
8
|
+
return "Use letters, digits, - and _ (max 32).";
|
|
9
|
+
if (existing.includes(name))
|
|
10
|
+
return `A server named ${name} already exists.`;
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
/** `target` is a URL for http/sse servers and a command line for stdio ones. */
|
|
14
|
+
export function targetError(type, target) {
|
|
15
|
+
if (type === "stdio")
|
|
16
|
+
return splitCommand(target).length ? null : "Enter a command.";
|
|
17
|
+
return isHttpUrl(target) ? null : "Enter an http:// or https:// URL.";
|
|
18
|
+
}
|
package/dist/ui/plain.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { styleText } from "node:util";
|
|
3
|
+
import { providerInfo } from "../adapters/providers/catalog.js";
|
|
4
|
+
import { needsLogin, providerOf } from "../adapters/providers/credentials.js";
|
|
5
|
+
import { autoApproved } from "../core/settings.js";
|
|
6
|
+
import { approvalBody, displayOutput, formatCall, previewOutput } from "./format.js";
|
|
7
|
+
/** Non-interactive mode for one-shot prompts and pipes: prints the transcript as plain text. */
|
|
8
|
+
export async function runPlain(agent, prompt, mode) {
|
|
9
|
+
if (needsLogin(agent.model)) {
|
|
10
|
+
const info = providerInfo(providerOf(agent.model));
|
|
11
|
+
const env = info.env[0] ? ` or set $${info.env[0]}` : "";
|
|
12
|
+
console.error(`Not logged in to ${info.label}. Run megacode and use /login${env}.`);
|
|
13
|
+
return 1;
|
|
14
|
+
}
|
|
15
|
+
const controller = new AbortController();
|
|
16
|
+
process.on("SIGINT", () => controller.abort());
|
|
17
|
+
const rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : null;
|
|
18
|
+
const approve = async (req) => {
|
|
19
|
+
if (autoApproved(mode, req.tool))
|
|
20
|
+
return true;
|
|
21
|
+
if (!rl)
|
|
22
|
+
return false;
|
|
23
|
+
console.log(`${styleText("yellow", req.title)}\n${approvalBody(req)}`);
|
|
24
|
+
try {
|
|
25
|
+
const answer = await rl.question(styleText("yellow", "Allow? [y/N] "), { signal: controller.signal });
|
|
26
|
+
return answer.trim().toLowerCase() === "y";
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// Ctrl+C at the prompt: readline takes it (so SIGINT never fires) and rejects. Stop the whole run.
|
|
30
|
+
controller.abort();
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
let midLine = false;
|
|
35
|
+
try {
|
|
36
|
+
await agent.send(prompt, controller.signal, {
|
|
37
|
+
approve,
|
|
38
|
+
onText(d) {
|
|
39
|
+
process.stdout.write(d);
|
|
40
|
+
midLine = !d.endsWith("\n");
|
|
41
|
+
},
|
|
42
|
+
onStepEnd() {
|
|
43
|
+
if (midLine)
|
|
44
|
+
process.stdout.write("\n");
|
|
45
|
+
midLine = false;
|
|
46
|
+
},
|
|
47
|
+
onToolStart: (call) => console.log(styleText("cyan", `⏺ ${formatCall(call)}`)),
|
|
48
|
+
onToolEnd: (call, r) => console.log(styleText(r.isError ? "red" : "dim", ` ⎿ ${previewOutput(displayOutput(call, r)).replace(/\n/g, "\n ")}`)),
|
|
49
|
+
onNotice: (text, level) => console.log(styleText(level === "error" ? "red" : "yellow", text)),
|
|
50
|
+
});
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
console.error(styleText("red", controller.signal.aborted ? "Interrupted." : `Error: ${e.message}`));
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
rl?.close();
|
|
59
|
+
}
|
|
60
|
+
}
|