@scira/cli 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/LICENSE +21 -0
- package/README.md +128 -0
- package/dist/agent/research-agent.js +253 -0
- package/dist/agent/skills.js +265 -0
- package/dist/agent/tools.js +429 -0
- package/dist/agent/tools.test.js +27 -0
- package/dist/cli/commands/init.js +370 -0
- package/dist/cli/index.js +445 -0
- package/dist/cli/shell/shell.js +76 -0
- package/dist/cli/shell/tui.js +11 -0
- package/dist/config/env-store.js +47 -0
- package/dist/config/load-config.js +58 -0
- package/dist/export/formatters.js +37 -0
- package/dist/providers/llm/gateway.js +64 -0
- package/dist/providers/llm/huggingface.js +33 -0
- package/dist/providers/llm/models.js +97 -0
- package/dist/providers/llm/readiness.js +50 -0
- package/dist/providers/llm/registry.js +56 -0
- package/dist/storage/jsonl.js +29 -0
- package/dist/storage/jsonl.test.js +38 -0
- package/dist/storage/run-store.js +134 -0
- package/dist/storage/run-store.test.js +65 -0
- package/dist/tools/chrome-devtools-mcp.js +61 -0
- package/dist/tools/file-tools.js +128 -0
- package/dist/tools/mcp-bridge.js +118 -0
- package/dist/tools/mcp-oauth.js +276 -0
- package/dist/tools/open-url.js +99 -0
- package/dist/tools/search-web.js +153 -0
- package/dist/types/index.js +91 -0
- package/dist/types/schema.test.js +60 -0
- package/dist/ui/ink/SciraApp.js +274 -0
- package/dist/ui/ink/components/effects.js +44 -0
- package/dist/ui/ink/components/home-screen.js +69 -0
- package/dist/ui/ink/components/overlays.js +111 -0
- package/dist/ui/ink/constants.js +56 -0
- package/dist/ui/ink/hooks/use-agent-turn.js +186 -0
- package/dist/ui/ink/hooks/use-feed-lines.js +186 -0
- package/dist/ui/ink/hooks/use-feed.js +69 -0
- package/dist/ui/ink/hooks/use-keyboard.js +315 -0
- package/dist/ui/ink/hooks/use-mouse.js +31 -0
- package/dist/ui/ink/hooks/use-session.js +103 -0
- package/dist/ui/ink/hooks/use-settings.js +155 -0
- package/dist/ui/ink/hooks/use-submit.js +366 -0
- package/dist/ui/ink/hooks/use-suggestions.js +91 -0
- package/dist/ui/ink/lib/file-mentions.js +71 -0
- package/dist/ui/ink/lib/markdown.js +245 -0
- package/dist/ui/ink/lib/utils.js +224 -0
- package/dist/ui/ink/session-manager.js +160 -0
- package/dist/ui/ink/types.js +1 -0
- package/dist/utils/ids.js +15 -0
- package/dist/utils/markdown-joiner.js +249 -0
- package/dist/watch/runner.js +65 -0
- package/package.json +74 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { displayWidth } from "./utils.js";
|
|
2
|
+
export function parseInlineMarkdown(text) {
|
|
3
|
+
const segs = [];
|
|
4
|
+
const re = /(\[[^\]]+\]\([^)]+\))|(`[^`]+`)|(\*\*[^*]+\*\*)|(__[^_]+__)|(\*[^*\s][^*]*\*)|(_[^_\s][^_]*_)/gu;
|
|
5
|
+
let last = 0;
|
|
6
|
+
let m;
|
|
7
|
+
while ((m = re.exec(text)) !== null) {
|
|
8
|
+
if (m.index > last)
|
|
9
|
+
segs.push({ text: text.slice(last, m.index) });
|
|
10
|
+
const tok = m[0];
|
|
11
|
+
if (tok.startsWith("`"))
|
|
12
|
+
segs.push({ text: tok.slice(1, -1), color: "#FFE0C2" });
|
|
13
|
+
else if (tok.startsWith("**") || tok.startsWith("__"))
|
|
14
|
+
segs.push({ text: tok.slice(2, -2), bold: true });
|
|
15
|
+
else if (tok.startsWith("[")) {
|
|
16
|
+
const link = /^\[([^\]]+)\]\(([^)]+)\)$/u.exec(tok);
|
|
17
|
+
segs.push({ text: link ? link[1] : tok, color: "#FFE0C2", underline: true, url: link ? link[2] : undefined });
|
|
18
|
+
}
|
|
19
|
+
else
|
|
20
|
+
segs.push({ text: tok.slice(1, -1), italic: true });
|
|
21
|
+
last = re.lastIndex;
|
|
22
|
+
}
|
|
23
|
+
if (last < text.length)
|
|
24
|
+
segs.push({ text: text.slice(last) });
|
|
25
|
+
return segs.length > 0 ? segs : [{ text: "" }];
|
|
26
|
+
}
|
|
27
|
+
export function wrapSegments(segs, width) {
|
|
28
|
+
const w = Math.max(10, width);
|
|
29
|
+
const lines = [];
|
|
30
|
+
let line = [];
|
|
31
|
+
let len = 0;
|
|
32
|
+
const flush = () => { lines.push(line); line = []; len = 0; };
|
|
33
|
+
for (const seg of segs) {
|
|
34
|
+
for (const part of seg.text.split(/(\s+)/u)) {
|
|
35
|
+
if (part === "")
|
|
36
|
+
continue;
|
|
37
|
+
if (/^\s+$/u.test(part)) {
|
|
38
|
+
if (len === 0)
|
|
39
|
+
continue;
|
|
40
|
+
if (len + 1 <= w) {
|
|
41
|
+
line.push({ ...seg, text: " " });
|
|
42
|
+
len += 1;
|
|
43
|
+
}
|
|
44
|
+
else
|
|
45
|
+
flush();
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
let word = part;
|
|
49
|
+
while (displayWidth(word) > w) {
|
|
50
|
+
if (len > 0)
|
|
51
|
+
flush();
|
|
52
|
+
let cells = 0;
|
|
53
|
+
let chars = 0;
|
|
54
|
+
for (const ch of word) {
|
|
55
|
+
const cw = displayWidth(ch);
|
|
56
|
+
if (cells + cw > w)
|
|
57
|
+
break;
|
|
58
|
+
cells += cw;
|
|
59
|
+
chars += ch.length;
|
|
60
|
+
}
|
|
61
|
+
line.push({ ...seg, text: word.slice(0, chars) });
|
|
62
|
+
len = w;
|
|
63
|
+
flush();
|
|
64
|
+
word = word.slice(chars);
|
|
65
|
+
}
|
|
66
|
+
const wordW = displayWidth(word);
|
|
67
|
+
if (len > 0 && len + wordW > w)
|
|
68
|
+
flush();
|
|
69
|
+
line.push({ ...seg, text: word });
|
|
70
|
+
len += wordW;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (line.length > 0)
|
|
74
|
+
flush();
|
|
75
|
+
return lines.length > 0 ? lines : [[]];
|
|
76
|
+
}
|
|
77
|
+
export function parseMarkdownTable(lines, start) {
|
|
78
|
+
const header = lines[start];
|
|
79
|
+
const divider = lines[start + 1];
|
|
80
|
+
if (!header || !divider)
|
|
81
|
+
return null;
|
|
82
|
+
const isRow = (line) => /^\s*\|.*\|\s*$/u.test(line);
|
|
83
|
+
const isDivider = (line) => /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/u.test(line);
|
|
84
|
+
if (!isRow(header) || !isDivider(divider))
|
|
85
|
+
return null;
|
|
86
|
+
const split = (line) => {
|
|
87
|
+
const trimmed = line.trim().replace(/^\|/u, "").replace(/\|$/u, "");
|
|
88
|
+
const cells = [];
|
|
89
|
+
let current = "";
|
|
90
|
+
let inCode = false;
|
|
91
|
+
for (let i = 0; i < trimmed.length; i += 1) {
|
|
92
|
+
const ch = trimmed[i];
|
|
93
|
+
const prev = trimmed[i - 1];
|
|
94
|
+
if (ch === "`" && prev !== "\\")
|
|
95
|
+
inCode = !inCode;
|
|
96
|
+
if (ch === "|" && prev !== "\\" && !inCode) {
|
|
97
|
+
cells.push(current.trim().replace(/\\\|/gu, "|"));
|
|
98
|
+
current = "";
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
current += ch;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
cells.push(current.trim().replace(/\\\|/gu, "|"));
|
|
105
|
+
return cells;
|
|
106
|
+
};
|
|
107
|
+
const rows = [split(header)];
|
|
108
|
+
let i = start + 2;
|
|
109
|
+
while (i < lines.length && isRow(lines[i])) {
|
|
110
|
+
rows.push(split(lines[i]));
|
|
111
|
+
i += 1;
|
|
112
|
+
}
|
|
113
|
+
return { rows, end: i };
|
|
114
|
+
}
|
|
115
|
+
export function segsTextLength(segs) {
|
|
116
|
+
return segs.reduce((n, seg) => n + displayWidth(seg.text), 0);
|
|
117
|
+
}
|
|
118
|
+
export function truncateSegs(segs, width) {
|
|
119
|
+
if (segsTextLength(segs) <= width)
|
|
120
|
+
return segs;
|
|
121
|
+
const out = [];
|
|
122
|
+
let remaining = Math.max(0, width - 1);
|
|
123
|
+
for (const seg of segs) {
|
|
124
|
+
if (remaining <= 0)
|
|
125
|
+
break;
|
|
126
|
+
const segW = displayWidth(seg.text);
|
|
127
|
+
if (segW <= remaining) {
|
|
128
|
+
out.push(seg);
|
|
129
|
+
remaining -= segW;
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
let cells = 0;
|
|
133
|
+
let chars = 0;
|
|
134
|
+
for (const ch of seg.text) {
|
|
135
|
+
const cw = displayWidth(ch);
|
|
136
|
+
if (cells + cw > remaining)
|
|
137
|
+
break;
|
|
138
|
+
cells += cw;
|
|
139
|
+
chars += ch.length;
|
|
140
|
+
}
|
|
141
|
+
out.push({ ...seg, text: seg.text.slice(0, chars) });
|
|
142
|
+
remaining = 0;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
out.push({ text: "…", color: "gray", dim: true });
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
export function tableToSegLines(rows, width) {
|
|
149
|
+
const cols = Math.max(...rows.map((row) => row.length));
|
|
150
|
+
const colWidths = Array.from({ length: cols }, (_, col) => Math.max(3, ...rows.map((row) => displayWidth(row[col] ?? ""))));
|
|
151
|
+
const total = colWidths.reduce((n, w) => n + w, 0) + Math.max(0, cols - 1) * 3;
|
|
152
|
+
if (total > width) {
|
|
153
|
+
const scale = Math.max(0.35, (width - Math.max(0, cols - 1) * 3) / Math.max(1, colWidths.reduce((n, w) => n + w, 0)));
|
|
154
|
+
for (let i = 0; i < colWidths.length; i += 1)
|
|
155
|
+
colWidths[i] = Math.max(3, Math.floor(colWidths[i] * scale));
|
|
156
|
+
}
|
|
157
|
+
const out = [];
|
|
158
|
+
rows.forEach((row, rowIndex) => {
|
|
159
|
+
const segs = [];
|
|
160
|
+
for (let col = 0; col < cols; col += 1) {
|
|
161
|
+
const raw = row[col] ?? "";
|
|
162
|
+
const parsed = truncateSegs(parseInlineMarkdown(raw), colWidths[col]);
|
|
163
|
+
const pad = Math.max(0, colWidths[col] - segsTextLength(parsed));
|
|
164
|
+
if (col > 0)
|
|
165
|
+
segs.push({ text: " │ ", color: "gray", dim: true });
|
|
166
|
+
segs.push(...parsed.map((seg) => ({ ...seg, bold: rowIndex === 0 ? true : seg.bold, color: rowIndex === 0 ? "white" : seg.color })));
|
|
167
|
+
if (pad > 0)
|
|
168
|
+
segs.push({ text: " ".repeat(pad) });
|
|
169
|
+
}
|
|
170
|
+
out.push(segs);
|
|
171
|
+
if (rowIndex === 0) {
|
|
172
|
+
const rule = [];
|
|
173
|
+
for (let col = 0; col < cols; col += 1) {
|
|
174
|
+
if (col > 0)
|
|
175
|
+
rule.push({ text: "─┼─", color: "gray", dim: true });
|
|
176
|
+
rule.push({ text: "─".repeat(colWidths[col]), color: "gray", dim: true });
|
|
177
|
+
}
|
|
178
|
+
out.push(rule);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
export function markdownToSegLines(text, width) {
|
|
184
|
+
const out = [];
|
|
185
|
+
let inFence = false;
|
|
186
|
+
const normalized = text
|
|
187
|
+
.replace(/(\[[^\]\n]*)\n\s*([^\]\n]*\]\([^)]+\))/gu, "$1 $2")
|
|
188
|
+
.replace(/(\[[^\]]+\])\n\s*(\([^)]+\))/gu, "$1$2");
|
|
189
|
+
const rawLines = normalized.split("\n");
|
|
190
|
+
for (let lineIndex = 0; lineIndex < rawLines.length; lineIndex += 1) {
|
|
191
|
+
const raw = rawLines[lineIndex];
|
|
192
|
+
if (/^\s*```/u.test(raw)) {
|
|
193
|
+
inFence = !inFence;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (inFence) {
|
|
197
|
+
const gutter = { text: " │ ", color: "gray", dim: true };
|
|
198
|
+
const wrapped = wrapSegments([{ text: raw || " ", color: "#FFE0C2", dim: true }], width - 4);
|
|
199
|
+
for (const ln of wrapped)
|
|
200
|
+
out.push([gutter, ...ln]);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const table = parseMarkdownTable(rawLines, lineIndex);
|
|
204
|
+
if (table) {
|
|
205
|
+
out.push(...tableToSegLines(table.rows, width));
|
|
206
|
+
lineIndex = table.end - 1;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (raw.trim() === "") {
|
|
210
|
+
out.push([]);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (/^\s*([-*_])(\s*\1){2,}\s*$/u.test(raw)) {
|
|
214
|
+
out.push([{ text: "─".repeat(Math.max(3, width - 1)), color: "gray", dim: true }]);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const heading = /^(#{1,6})\s+(.*)$/u.exec(raw);
|
|
218
|
+
if (heading) {
|
|
219
|
+
const color = heading[1].length <= 2 ? "#FFE0C2" : "white";
|
|
220
|
+
const segs = parseInlineMarkdown(heading[2]).map((s) => ({ ...s, bold: true, color }));
|
|
221
|
+
for (const ln of wrapSegments(segs, width))
|
|
222
|
+
out.push(ln);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const quote = /^\s*>\s?(.*)$/u.exec(raw);
|
|
226
|
+
if (quote) {
|
|
227
|
+
const segs = parseInlineMarkdown(quote[1]).map((s) => ({ ...s, dim: true }));
|
|
228
|
+
for (const ln of wrapSegments(segs, width - 2))
|
|
229
|
+
out.push([{ text: "│ ", color: "gray", dim: true }, ...ln]);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const list = /^(\s*)(?:[-*+]|(\d+)[.)])\s+(.*)$/u.exec(raw);
|
|
233
|
+
if (list) {
|
|
234
|
+
const marker = list[2] ? `${list[2]}. ` : "• ";
|
|
235
|
+
const prefix = list[1] + marker;
|
|
236
|
+
const segs = parseInlineMarkdown(list[3]);
|
|
237
|
+
const wrapped = wrapSegments(segs, Math.max(10, width - prefix.length));
|
|
238
|
+
wrapped.forEach((ln, i) => out.push([{ text: i === 0 ? prefix : " ".repeat(prefix.length), color: "#FFE0C2" }, ...ln]));
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
for (const ln of wrapSegments(parseInlineMarkdown(raw), width))
|
|
242
|
+
out.push(ln);
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import stringWidth from "string-width";
|
|
8
|
+
import { FULL_MODE_TRIGGERS } from "../constants.js";
|
|
9
|
+
export const pkgVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../../../../package.json"), "utf8")).version;
|
|
10
|
+
/** Pipe text to the OS clipboard (pbcopy / clip / xclip). Resolves false when unavailable. */
|
|
11
|
+
export function copyToClipboard(text) {
|
|
12
|
+
return new Promise((res) => {
|
|
13
|
+
const cmd = process.platform === "darwin" ? "pbcopy" : process.platform === "win32" ? "clip" : "xclip";
|
|
14
|
+
const args = cmd === "xclip" ? ["-selection", "clipboard"] : [];
|
|
15
|
+
const child = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"] });
|
|
16
|
+
child.on("error", () => res(false));
|
|
17
|
+
child.on("close", (code) => res(code === 0));
|
|
18
|
+
child.stdin.write(text);
|
|
19
|
+
child.stdin.end();
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function historyFile(runDirectory) {
|
|
23
|
+
return resolve(process.cwd(), runDirectory, "..", "input-history.json");
|
|
24
|
+
}
|
|
25
|
+
export async function loadInputHistory(runDirectory) {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(await readFile(historyFile(runDirectory), "utf8"));
|
|
28
|
+
return Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string").slice(-50) : [];
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export async function saveInputHistory(runDirectory, history) {
|
|
35
|
+
try {
|
|
36
|
+
const file = historyFile(runDirectory);
|
|
37
|
+
await mkdir(dirname(file), { recursive: true });
|
|
38
|
+
await writeFile(file, JSON.stringify(history.slice(-50), null, 2));
|
|
39
|
+
}
|
|
40
|
+
catch { /* non-fatal */ }
|
|
41
|
+
}
|
|
42
|
+
export const CWD_DISPLAY = (() => {
|
|
43
|
+
const home = homedir();
|
|
44
|
+
const cwd = process.cwd();
|
|
45
|
+
return cwd.startsWith(home) ? "~" + cwd.slice(home.length) : cwd;
|
|
46
|
+
})();
|
|
47
|
+
export function prettifyModelId(id) {
|
|
48
|
+
const slug = id.includes("/") ? id.split("/").pop() : id;
|
|
49
|
+
return slug
|
|
50
|
+
.split(/[-_]/u)
|
|
51
|
+
.map((w) => (/^[0-9]/u.test(w) ? w : w.charAt(0).toUpperCase() + w.slice(1)))
|
|
52
|
+
.join(" ");
|
|
53
|
+
}
|
|
54
|
+
/** Terminal-cell width of a string (CJK/emoji aware, strips ANSI). */
|
|
55
|
+
export function displayWidth(text) {
|
|
56
|
+
return stringWidth(text);
|
|
57
|
+
}
|
|
58
|
+
/** Longest prefix of `text` whose terminal-cell width fits in `width`; returns its char length. */
|
|
59
|
+
function fitChars(text, width) {
|
|
60
|
+
if (stringWidth(text) === text.length)
|
|
61
|
+
return Math.min(text.length, width);
|
|
62
|
+
let cells = 0;
|
|
63
|
+
let chars = 0;
|
|
64
|
+
for (const ch of text) {
|
|
65
|
+
const w = stringWidth(ch);
|
|
66
|
+
if (cells + w > width)
|
|
67
|
+
break;
|
|
68
|
+
cells += w;
|
|
69
|
+
chars += ch.length;
|
|
70
|
+
}
|
|
71
|
+
return chars;
|
|
72
|
+
}
|
|
73
|
+
export function wrapText(text, width) {
|
|
74
|
+
const w = Math.max(20, width);
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const raw of text.split("\n")) {
|
|
77
|
+
let line = raw;
|
|
78
|
+
if (line.length === 0) {
|
|
79
|
+
out.push("");
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
while (displayWidth(line) > w) {
|
|
83
|
+
const fit = fitChars(line, w);
|
|
84
|
+
const slice = line.slice(0, fit);
|
|
85
|
+
const sp = slice.lastIndexOf(" ");
|
|
86
|
+
if (sp > fit * 0.5) {
|
|
87
|
+
out.push(line.slice(0, sp));
|
|
88
|
+
line = line.slice(sp + 1);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
out.push(slice);
|
|
92
|
+
line = line.slice(fit);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
out.push(line);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
export function formatTime(ts) {
|
|
100
|
+
if (!ts)
|
|
101
|
+
return "";
|
|
102
|
+
return new Date(ts).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
|
|
103
|
+
}
|
|
104
|
+
export function fmtDuration(ms) {
|
|
105
|
+
if (!ms || ms < 0)
|
|
106
|
+
return "0s";
|
|
107
|
+
if (ms < 1000)
|
|
108
|
+
return `${ms}ms`;
|
|
109
|
+
const s = ms / 1000;
|
|
110
|
+
if (s < 60)
|
|
111
|
+
return `${s.toFixed(s < 10 ? 1 : 0)}s`;
|
|
112
|
+
const m = Math.floor(s / 60);
|
|
113
|
+
const rem = Math.round(s - m * 60);
|
|
114
|
+
return rem === 0 ? `${m}m` : `${m}m${rem}s`;
|
|
115
|
+
}
|
|
116
|
+
export function fmtTokens(n) {
|
|
117
|
+
if (n >= 1_000_000)
|
|
118
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
119
|
+
if (n >= 10_000)
|
|
120
|
+
return `${Math.round(n / 1000)}k`;
|
|
121
|
+
if (n >= 1_000)
|
|
122
|
+
return `${(n / 1000).toFixed(1)}k`;
|
|
123
|
+
return String(n);
|
|
124
|
+
}
|
|
125
|
+
/** Compact relative time, e.g. "now", "5m", "3h", "2d", "3w". */
|
|
126
|
+
export function relativeTime(ms) {
|
|
127
|
+
if (!ms)
|
|
128
|
+
return "";
|
|
129
|
+
const diff = Date.now() - ms;
|
|
130
|
+
if (diff < 60_000)
|
|
131
|
+
return "now";
|
|
132
|
+
const mins = Math.floor(diff / 60_000);
|
|
133
|
+
if (mins < 60)
|
|
134
|
+
return `${mins}m ago`;
|
|
135
|
+
const hours = Math.floor(mins / 60);
|
|
136
|
+
if (hours < 24)
|
|
137
|
+
return `${hours}h ago`;
|
|
138
|
+
const days = Math.floor(hours / 24);
|
|
139
|
+
if (days < 7)
|
|
140
|
+
return `${days}d ago`;
|
|
141
|
+
const weeks = Math.floor(days / 7);
|
|
142
|
+
if (weeks < 5)
|
|
143
|
+
return `${weeks}w ago`;
|
|
144
|
+
return new Date(ms).toLocaleDateString();
|
|
145
|
+
}
|
|
146
|
+
/** Wrap text in an OSC 8 terminal hyperlink (clickable in supported terminals). */
|
|
147
|
+
export function hyperlink(text, url) {
|
|
148
|
+
if (!url)
|
|
149
|
+
return text;
|
|
150
|
+
return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
|
|
151
|
+
}
|
|
152
|
+
/** True if the prompt clearly asks for full, report-grade research. */
|
|
153
|
+
export function wantsFullResearch(prompt) {
|
|
154
|
+
const p = prompt.toLowerCase();
|
|
155
|
+
return FULL_MODE_TRIGGERS.some((kw) => p.includes(kw));
|
|
156
|
+
}
|
|
157
|
+
/** Word-wrap input text and locate the caret in the wrapped output (no cursor char injection). */
|
|
158
|
+
export function wrapInputWithCursor(text, width, caretPos) {
|
|
159
|
+
const w = Math.max(1, width);
|
|
160
|
+
const lines = [];
|
|
161
|
+
let cursorLine = 0;
|
|
162
|
+
let cursorCol = 0;
|
|
163
|
+
let i = 0;
|
|
164
|
+
while (i < text.length || lines.length === 0) {
|
|
165
|
+
const remaining = text.slice(i);
|
|
166
|
+
if (remaining.length === 0) {
|
|
167
|
+
lines.push("");
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
let lineEnd;
|
|
171
|
+
if (remaining.length <= w) {
|
|
172
|
+
lineEnd = remaining.length;
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
const slice = remaining.slice(0, w);
|
|
176
|
+
const sp = slice.lastIndexOf(" ");
|
|
177
|
+
lineEnd = sp > Math.floor(w * 0.4) ? sp : w;
|
|
178
|
+
}
|
|
179
|
+
const line = remaining.slice(0, lineEnd);
|
|
180
|
+
if (caretPos >= i && caretPos <= i + lineEnd) {
|
|
181
|
+
cursorLine = lines.length;
|
|
182
|
+
cursorCol = caretPos - i;
|
|
183
|
+
}
|
|
184
|
+
lines.push(line);
|
|
185
|
+
const skipSpace = text[i + lineEnd] === " " ? 1 : 0;
|
|
186
|
+
i += lineEnd + skipSpace;
|
|
187
|
+
}
|
|
188
|
+
if (caretPos >= text.length && lines.length > 0) {
|
|
189
|
+
cursorLine = lines.length - 1;
|
|
190
|
+
cursorCol = lines[lines.length - 1].length;
|
|
191
|
+
}
|
|
192
|
+
return { lines: lines.length ? lines : [""], cursorLine, cursorCol };
|
|
193
|
+
}
|
|
194
|
+
export function aggregateTurns(turns) {
|
|
195
|
+
const byModel = {};
|
|
196
|
+
const total = { input: 0, output: 0, total: 0 };
|
|
197
|
+
for (const t of turns) {
|
|
198
|
+
const cur = byModel[t.model] ?? { input: 0, output: 0, total: 0, turns: 0 };
|
|
199
|
+
byModel[t.model] = { input: cur.input + t.input, output: cur.output + t.output, total: cur.total + t.total, turns: cur.turns + 1 };
|
|
200
|
+
total.input += t.input;
|
|
201
|
+
total.output += t.output;
|
|
202
|
+
total.total += t.total;
|
|
203
|
+
}
|
|
204
|
+
return { total, byModel, turns };
|
|
205
|
+
}
|
|
206
|
+
export function summarizeToolInput(name, input) {
|
|
207
|
+
const obj = (input ?? {});
|
|
208
|
+
if (name === "bash")
|
|
209
|
+
return String(obj.command ?? "");
|
|
210
|
+
if (name === "webSearch") {
|
|
211
|
+
const queries = Array.isArray(obj.queries) ? obj.queries : [];
|
|
212
|
+
return queries.length > 0 ? queries.slice(0, 2).join(" · ") + (queries.length > 2 ? ` +${queries.length - 2}` : "") : String(obj.query ?? "");
|
|
213
|
+
}
|
|
214
|
+
if (name === "readUrl")
|
|
215
|
+
return String(obj.url ?? "");
|
|
216
|
+
if (name === "writeFile" || name === "editFile" || name === "readFile")
|
|
217
|
+
return String(obj.path ?? "");
|
|
218
|
+
try {
|
|
219
|
+
return JSON.stringify(obj).slice(0, 80);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return "";
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
const sessions = new Map();
|
|
2
|
+
export function getSession(runPath) {
|
|
3
|
+
return sessions.get(runPath);
|
|
4
|
+
}
|
|
5
|
+
export function createSession(runPath) {
|
|
6
|
+
const existing = sessions.get(runPath);
|
|
7
|
+
if (existing)
|
|
8
|
+
return existing;
|
|
9
|
+
const session = {
|
|
10
|
+
runPath,
|
|
11
|
+
feedBuffer: [],
|
|
12
|
+
busy: false,
|
|
13
|
+
approvalPending: null,
|
|
14
|
+
abort: null,
|
|
15
|
+
subscriber: null,
|
|
16
|
+
};
|
|
17
|
+
sessions.set(runPath, session);
|
|
18
|
+
return session;
|
|
19
|
+
}
|
|
20
|
+
export function attachSubscriber(runPath, sub) {
|
|
21
|
+
const session = sessions.get(runPath);
|
|
22
|
+
if (!session)
|
|
23
|
+
return [];
|
|
24
|
+
session.subscriber = sub;
|
|
25
|
+
return [...session.feedBuffer];
|
|
26
|
+
}
|
|
27
|
+
export function detachSubscriber(runPath) {
|
|
28
|
+
const session = sessions.get(runPath);
|
|
29
|
+
if (session)
|
|
30
|
+
session.subscriber = null;
|
|
31
|
+
// deliberately NOT aborting — stream keeps running in the background
|
|
32
|
+
}
|
|
33
|
+
export function abortSession(runPath) {
|
|
34
|
+
const session = sessions.get(runPath);
|
|
35
|
+
if (!session)
|
|
36
|
+
return;
|
|
37
|
+
session.abort?.abort();
|
|
38
|
+
session.abort = null;
|
|
39
|
+
session.busy = false;
|
|
40
|
+
sessions.delete(runPath);
|
|
41
|
+
}
|
|
42
|
+
export function removeSession(runPath) {
|
|
43
|
+
sessions.delete(runPath);
|
|
44
|
+
}
|
|
45
|
+
/** Route a feed item to the correct subscriber method and buffer it. */
|
|
46
|
+
export function sessionPushFeed(runPath, item) {
|
|
47
|
+
const session = sessions.get(runPath);
|
|
48
|
+
if (!session)
|
|
49
|
+
return;
|
|
50
|
+
const sub = session.subscriber;
|
|
51
|
+
if (sub) {
|
|
52
|
+
// Route to fine-grained helpers for smooth streaming rendering
|
|
53
|
+
if (item.kind === "text") {
|
|
54
|
+
sub.appendText(item.text);
|
|
55
|
+
// Keep buffer in sync: merge into last text item or push new
|
|
56
|
+
const last = session.feedBuffer.at(-1);
|
|
57
|
+
if (last?.kind === "text") {
|
|
58
|
+
session.feedBuffer[session.feedBuffer.length - 1] = { ...last, text: last.text + item.text };
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
session.feedBuffer.push(item);
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (item.kind === "reasoning") {
|
|
66
|
+
sub.appendReasoning(item.text);
|
|
67
|
+
const last = session.feedBuffer.at(-1);
|
|
68
|
+
if (last?.kind === "reasoning" && last.durationMs === undefined) {
|
|
69
|
+
session.feedBuffer[session.feedBuffer.length - 1] = { ...last, text: last.text + item.text };
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
session.feedBuffer.push({ ...item, startedAt: item.startedAt ?? Date.now() });
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (item.kind === "tool" && (item.status === "done" || item.status === "error") && item.toolCallId) {
|
|
77
|
+
sub.markToolDone(item.toolCallId, item.status, item.result);
|
|
78
|
+
// Update existing tool item in buffer
|
|
79
|
+
for (let i = session.feedBuffer.length - 1; i >= 0; i--) {
|
|
80
|
+
const b = session.feedBuffer[i];
|
|
81
|
+
if (b.kind === "tool" && b.status === "running" && (b.toolCallId === item.toolCallId || !item.toolCallId)) {
|
|
82
|
+
session.feedBuffer[i] = { ...b, status: item.status, result: item.result };
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
sub.pushFeed(item);
|
|
89
|
+
}
|
|
90
|
+
// No subscriber — always buffer
|
|
91
|
+
if (item.kind === "tool" && (item.status === "done" || item.status === "error") && item.toolCallId) {
|
|
92
|
+
for (let i = session.feedBuffer.length - 1; i >= 0; i--) {
|
|
93
|
+
const b = session.feedBuffer[i];
|
|
94
|
+
if (b.kind === "tool" && b.status === "running" && (b.toolCallId === item.toolCallId || !item.toolCallId)) {
|
|
95
|
+
session.feedBuffer[i] = { ...b, status: item.status, result: item.result };
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (item.kind === "text") {
|
|
101
|
+
const last = session.feedBuffer.at(-1);
|
|
102
|
+
if (last?.kind === "text") {
|
|
103
|
+
session.feedBuffer[session.feedBuffer.length - 1] = { ...last, text: last.text + item.text };
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (item.kind === "reasoning") {
|
|
108
|
+
const last = session.feedBuffer.at(-1);
|
|
109
|
+
if (last?.kind === "reasoning" && last.durationMs === undefined) {
|
|
110
|
+
session.feedBuffer[session.feedBuffer.length - 1] = { ...last, text: last.text + item.text };
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
session.feedBuffer.push({ ...item, startedAt: item.startedAt ?? Date.now() });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
session.feedBuffer.push(item);
|
|
117
|
+
}
|
|
118
|
+
export function sessionFinishReasoning(runPath) {
|
|
119
|
+
const session = sessions.get(runPath);
|
|
120
|
+
if (!session)
|
|
121
|
+
return;
|
|
122
|
+
const ended = Date.now();
|
|
123
|
+
for (let i = session.feedBuffer.length - 1; i >= 0; i--) {
|
|
124
|
+
const item = session.feedBuffer[i];
|
|
125
|
+
if (item.kind === "reasoning" && item.durationMs === undefined) {
|
|
126
|
+
session.feedBuffer[i] = { ...item, durationMs: ended - (item.startedAt ?? ended) };
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
session.subscriber?.finishReasoning();
|
|
131
|
+
}
|
|
132
|
+
export function sessionSetBusy(runPath, busy) {
|
|
133
|
+
const session = sessions.get(runPath);
|
|
134
|
+
if (!session)
|
|
135
|
+
return;
|
|
136
|
+
session.busy = busy;
|
|
137
|
+
session.subscriber?.onBusyChange(busy);
|
|
138
|
+
}
|
|
139
|
+
export function sessionSetApproval(runPath, pending) {
|
|
140
|
+
const session = sessions.get(runPath);
|
|
141
|
+
if (!session)
|
|
142
|
+
return;
|
|
143
|
+
session.approvalPending = pending;
|
|
144
|
+
// NOTE: The resolve closure in pending is captured per-promise, so it correctly
|
|
145
|
+
// resolves even if the subscriber changed (user navigated away and back).
|
|
146
|
+
if (pending) {
|
|
147
|
+
session.subscriber?.onApprovalRequired(pending);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
session.subscriber?.onApprovalCleared();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
export function sessionNotifyEscalate(runPath) {
|
|
154
|
+
const session = sessions.get(runPath);
|
|
155
|
+
session?.subscriber?.onEscalate();
|
|
156
|
+
}
|
|
157
|
+
export function sessionNotifyModeChange(runPath, full) {
|
|
158
|
+
const session = sessions.get(runPath);
|
|
159
|
+
session?.subscriber?.onModeChange(full);
|
|
160
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function slugify(input) {
|
|
2
|
+
return input
|
|
3
|
+
.toLowerCase()
|
|
4
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
5
|
+
.replace(/^-+|-+$/g, "")
|
|
6
|
+
.slice(0, 64) || "research";
|
|
7
|
+
}
|
|
8
|
+
export function createRunId(goal, date = new Date()) {
|
|
9
|
+
const stamp = date.toISOString().slice(0, 10);
|
|
10
|
+
const time = date.toISOString().slice(11, 19).replace(/:/g, "");
|
|
11
|
+
return `${stamp}-${time}-${slugify(goal)}`;
|
|
12
|
+
}
|
|
13
|
+
export function createEntityId(prefix, index) {
|
|
14
|
+
return `${prefix}_${String(index).padStart(3, "0")}`;
|
|
15
|
+
}
|