@readwise/cli 0.5.2 → 0.5.4
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/dist/skills.js +100 -2
- package/dist/tui/app.js +76 -12
- package/package.json +1 -1
- package/src/skills.ts +109 -3
- package/src/tui/app.ts +75 -12
package/dist/skills.js
CHANGED
|
@@ -117,6 +117,87 @@ const PLATFORMS = {
|
|
|
117
117
|
codex: { name: "Codex CLI", path: join(homedir(), ".codex", "skills") },
|
|
118
118
|
opencode: { name: "OpenCode", path: join(homedir(), ".opencode", "skills") },
|
|
119
119
|
};
|
|
120
|
+
/** Interactive checkbox picker — returns selected items */
|
|
121
|
+
async function pickSkills(names, descriptions) {
|
|
122
|
+
const selected = new Set(names.filter((n) => n === "readwise-cli"));
|
|
123
|
+
let cursor = 0;
|
|
124
|
+
const render = () => {
|
|
125
|
+
// Move cursor up to overwrite previous render
|
|
126
|
+
process.stderr.write(`\x1b[${names.length + 2}A\x1b[J`);
|
|
127
|
+
process.stderr.write(" Select skills to install (space toggle, a all/none, enter confirm):\n\n");
|
|
128
|
+
for (let i = 0; i < names.length; i++) {
|
|
129
|
+
const check = selected.has(names[i]) ? "\x1b[32m✔\x1b[0m" : " ";
|
|
130
|
+
const pointer = i === cursor ? "\x1b[36m❯\x1b[0m" : " ";
|
|
131
|
+
const desc = descriptions.get(names[i]) || "";
|
|
132
|
+
const descText = desc ? ` \x1b[2m${desc}\x1b[0m` : "";
|
|
133
|
+
process.stderr.write(` ${pointer} [${check}] ${names[i]}${descText}\n`);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
// Initial render (print blank lines first so the upward cursor move works)
|
|
137
|
+
process.stderr.write("\n".repeat(names.length + 2));
|
|
138
|
+
render();
|
|
139
|
+
return new Promise((resolve) => {
|
|
140
|
+
if (!process.stdin.isTTY) {
|
|
141
|
+
// Non-interactive: install all
|
|
142
|
+
resolve(names);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
process.stdin.setRawMode(true);
|
|
146
|
+
process.stdin.resume();
|
|
147
|
+
const onData = (data) => {
|
|
148
|
+
const s = data.toString();
|
|
149
|
+
if (s === "\r" || s === "\n") {
|
|
150
|
+
// Enter — confirm
|
|
151
|
+
cleanup();
|
|
152
|
+
resolve([...selected]);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (s === "\x03" || s === "\x1b") {
|
|
156
|
+
// Ctrl+C or Escape — cancel
|
|
157
|
+
cleanup();
|
|
158
|
+
resolve(null);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (s === " ") {
|
|
162
|
+
// Space — toggle current
|
|
163
|
+
const name = names[cursor];
|
|
164
|
+
if (selected.has(name))
|
|
165
|
+
selected.delete(name);
|
|
166
|
+
else
|
|
167
|
+
selected.add(name);
|
|
168
|
+
render();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (s === "a") {
|
|
172
|
+
// 'a' — toggle all/none
|
|
173
|
+
if (selected.size === names.length)
|
|
174
|
+
selected.clear();
|
|
175
|
+
else
|
|
176
|
+
names.forEach((n) => selected.add(n));
|
|
177
|
+
render();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (s === "\x1b[A" || s === "k") {
|
|
181
|
+
// Up
|
|
182
|
+
cursor = (cursor - 1 + names.length) % names.length;
|
|
183
|
+
render();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (s === "\x1b[B" || s === "j") {
|
|
187
|
+
// Down
|
|
188
|
+
cursor = (cursor + 1) % names.length;
|
|
189
|
+
render();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
const cleanup = () => {
|
|
194
|
+
process.stdin.setRawMode(false);
|
|
195
|
+
process.stdin.pause();
|
|
196
|
+
process.stdin.removeListener("data", onData);
|
|
197
|
+
};
|
|
198
|
+
process.stdin.on("data", onData);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
120
201
|
export function registerSkillsCommands(program) {
|
|
121
202
|
const skills = program.command("skills").description("Manage Readwise skills for AI agents");
|
|
122
203
|
skills
|
|
@@ -148,10 +229,11 @@ export function registerSkillsCommands(program) {
|
|
|
148
229
|
.description("Install skills to an agent platform (claude, codex, opencode)")
|
|
149
230
|
.option("--all", "Detect installed agents and install to all")
|
|
150
231
|
.option("--refresh", "Force refresh from GitHub before installing")
|
|
232
|
+
.option("-y, --yes", "Skip skill selection and install all")
|
|
151
233
|
.action(async (platform, opts) => {
|
|
152
234
|
try {
|
|
153
|
-
const
|
|
154
|
-
if (
|
|
235
|
+
const allNames = (await getSkillNames(!!opts?.refresh)).filter((n) => n !== "readwise-mcp");
|
|
236
|
+
if (allNames.length === 0) {
|
|
155
237
|
console.error("No skills found.");
|
|
156
238
|
process.exitCode = 1;
|
|
157
239
|
return;
|
|
@@ -185,6 +267,22 @@ export function registerSkillsCommands(program) {
|
|
|
185
267
|
process.exitCode = 1;
|
|
186
268
|
return;
|
|
187
269
|
}
|
|
270
|
+
// Let user pick which skills to install (unless --yes)
|
|
271
|
+
let names = allNames;
|
|
272
|
+
if (!opts?.yes && process.stdin.isTTY) {
|
|
273
|
+
const descs = new Map();
|
|
274
|
+
for (const n of allNames) {
|
|
275
|
+
const fm = await readSkillFrontmatter(n);
|
|
276
|
+
if (fm.description)
|
|
277
|
+
descs.set(n, fm.description);
|
|
278
|
+
}
|
|
279
|
+
const picked = await pickSkills(allNames, descs);
|
|
280
|
+
if (!picked || picked.length === 0) {
|
|
281
|
+
console.log("No skills selected.");
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
names = picked;
|
|
285
|
+
}
|
|
188
286
|
const cache = cacheDir();
|
|
189
287
|
for (const target of targets) {
|
|
190
288
|
console.log(`\nInstalling to ${target.name} (${target.path})...`);
|
package/dist/tui/app.js
CHANGED
|
@@ -387,6 +387,12 @@ function extractDocTitle(obj) {
|
|
|
387
387
|
return "Untitled";
|
|
388
388
|
}
|
|
389
389
|
function extractDocSummary(obj) {
|
|
390
|
+
// Prefer first matched chunk text (from search results)
|
|
391
|
+
if (Array.isArray(obj.matches) && obj.matches.length > 0) {
|
|
392
|
+
const first = obj.matches[0];
|
|
393
|
+
if (first?.plaintext && typeof first.plaintext === "string")
|
|
394
|
+
return first.plaintext;
|
|
395
|
+
}
|
|
390
396
|
for (const key of ["summary", "description", "note", "notes", "content"]) {
|
|
391
397
|
const val = obj[key];
|
|
392
398
|
if (val && typeof val === "string")
|
|
@@ -1333,7 +1339,45 @@ const SUCCESS_ICON = [
|
|
|
1333
1339
|
function cardLine(text, innerW, borderFn) {
|
|
1334
1340
|
return " " + borderFn("│") + " " + fitWidth(text, innerW) + " " + borderFn("│");
|
|
1335
1341
|
}
|
|
1336
|
-
|
|
1342
|
+
/** Build a regex that matches any individual word from the query (case-insensitive) */
|
|
1343
|
+
function searchTermRegex(query) {
|
|
1344
|
+
const words = query.trim().split(/\s+/).filter((w) => w.length >= 3);
|
|
1345
|
+
if (words.length === 0)
|
|
1346
|
+
return null;
|
|
1347
|
+
const escaped = words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
1348
|
+
return new RegExp(`(${escaped.join("|")})`, "gi");
|
|
1349
|
+
}
|
|
1350
|
+
/** Shift text so the first match is visible, returning a window of ~maxChars */
|
|
1351
|
+
function snippetAroundMatch(text, query, maxChars) {
|
|
1352
|
+
const re = searchTermRegex(query);
|
|
1353
|
+
if (!re)
|
|
1354
|
+
return text.slice(0, maxChars);
|
|
1355
|
+
const m = re.exec(text);
|
|
1356
|
+
if (!m)
|
|
1357
|
+
return text.slice(0, maxChars);
|
|
1358
|
+
const matchStart = m.index;
|
|
1359
|
+
// Start the window a bit before the match
|
|
1360
|
+
let start = Math.max(0, matchStart - Math.floor(maxChars / 4));
|
|
1361
|
+
// Snap forward to a word boundary so we don't cut mid-word
|
|
1362
|
+
if (start > 0) {
|
|
1363
|
+
const nextSpace = text.indexOf(" ", start);
|
|
1364
|
+
if (nextSpace !== -1 && nextSpace < matchStart) {
|
|
1365
|
+
start = nextSpace + 1;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
const snippet = text.slice(start, start + maxChars);
|
|
1369
|
+
// Only add leading ellipsis; trailing truncation is handled by the card renderer
|
|
1370
|
+
return start > 0 ? "…" + snippet : snippet;
|
|
1371
|
+
}
|
|
1372
|
+
/** Bold all occurrences of search terms in a line */
|
|
1373
|
+
function highlightTerms(line, query) {
|
|
1374
|
+
const re = searchTermRegex(query);
|
|
1375
|
+
if (!re)
|
|
1376
|
+
return line;
|
|
1377
|
+
// \x1b[22;1;3;37m = undim+bold+italic+white, \x1b[22;23;2;39m = unbold+unitalic+dim+default color
|
|
1378
|
+
return line.replace(re, (match) => `\x1b[22;1;3;37m${match}\x1b[22;23;2;39m`);
|
|
1379
|
+
}
|
|
1380
|
+
function buildCardLines(card, ci, selected, cardWidth, searchQuery) {
|
|
1337
1381
|
const borderColor = selected ? style.cyan : style.dim;
|
|
1338
1382
|
const innerW = Math.max(1, cardWidth - 4);
|
|
1339
1383
|
const lines = [];
|
|
@@ -1383,8 +1427,19 @@ function buildCardLines(card, ci, selected, cardWidth) {
|
|
|
1383
1427
|
const titleStyled = selected ? style.bold(style.cyan(titleText)) : style.bold(titleText);
|
|
1384
1428
|
lines.push(cardLine(titleStyled, innerW, borderColor));
|
|
1385
1429
|
if (card.summary) {
|
|
1386
|
-
const
|
|
1387
|
-
|
|
1430
|
+
const snippet = searchQuery
|
|
1431
|
+
? snippetAroundMatch(card.summary, searchQuery, innerW * 3)
|
|
1432
|
+
: card.summary;
|
|
1433
|
+
const wrapped = wrapText(snippet, innerW);
|
|
1434
|
+
const showLines = wrapped.slice(0, 3);
|
|
1435
|
+
if (wrapped.length > 3) {
|
|
1436
|
+
const last = showLines[2];
|
|
1437
|
+
showLines[2] = truncateVisible(last, innerW - 1) + "…";
|
|
1438
|
+
}
|
|
1439
|
+
for (const sl of showLines) {
|
|
1440
|
+
const highlighted = searchQuery ? highlightTerms(sl, searchQuery) : sl;
|
|
1441
|
+
lines.push(cardLine(style.dim(highlighted), innerW, borderColor));
|
|
1442
|
+
}
|
|
1388
1443
|
}
|
|
1389
1444
|
if (card.meta) {
|
|
1390
1445
|
lines.push(cardLine(style.dim(truncateVisible(card.meta, innerW)), innerW, borderColor));
|
|
@@ -1404,10 +1459,12 @@ function renderCardView(state) {
|
|
|
1404
1459
|
const countInfo = style.dim(` (${state.resultCursor + 1} of ${cards.length})`);
|
|
1405
1460
|
content.push(" " + style.bold("Results") + countInfo);
|
|
1406
1461
|
content.push("");
|
|
1462
|
+
// Extract search query from form values (try common field names)
|
|
1463
|
+
const searchQuery = state.formValues["query"] || state.formValues["vector_search_term"] || state.formValues["search"] || "";
|
|
1407
1464
|
// Build all card lines
|
|
1408
1465
|
const allLines = [];
|
|
1409
1466
|
for (let ci = 0; ci < cards.length; ci++) {
|
|
1410
|
-
const cardContentLines = buildCardLines(cards[ci], ci, ci === state.resultCursor, cardWidth);
|
|
1467
|
+
const cardContentLines = buildCardLines(cards[ci], ci, ci === state.resultCursor, cardWidth, searchQuery || undefined);
|
|
1411
1468
|
for (const line of cardContentLines) {
|
|
1412
1469
|
allLines.push({ line, cardIdx: ci });
|
|
1413
1470
|
}
|
|
@@ -2408,7 +2465,8 @@ function cardLineCount(card, cardWidth) {
|
|
|
2408
2465
|
const textLines = Math.min(wrapped.length, 6);
|
|
2409
2466
|
return 2 + textLines + (card.note ? 1 : 0) + (card.meta ? 1 : 0); // top + text + note? + meta? + bottom
|
|
2410
2467
|
}
|
|
2411
|
-
|
|
2468
|
+
const summaryLines = card.summary ? Math.min(wrapText(card.summary, 60).length, 3) : 0;
|
|
2469
|
+
return 2 + 1 + summaryLines + (card.meta ? 1 : 0); // top + title + summary + meta? + bottom
|
|
2412
2470
|
}
|
|
2413
2471
|
function computeCardScroll(cards, cursor, currentScroll, availableHeight) {
|
|
2414
2472
|
const { innerWidth } = getBoxDimensions();
|
|
@@ -2777,7 +2835,17 @@ export async function runApp(tools) {
|
|
|
2777
2835
|
process.stdin.removeListener("data", onData);
|
|
2778
2836
|
resolve();
|
|
2779
2837
|
};
|
|
2780
|
-
const
|
|
2838
|
+
const runTool = (loadingState) => {
|
|
2839
|
+
executeTool(loadingState).then((resultState) => {
|
|
2840
|
+
// If user quit during loading, don't update state
|
|
2841
|
+
if (state.view !== "loading")
|
|
2842
|
+
return;
|
|
2843
|
+
state = resultState;
|
|
2844
|
+
paint(renderState(state));
|
|
2845
|
+
resetQuitTimer();
|
|
2846
|
+
});
|
|
2847
|
+
};
|
|
2848
|
+
const onData = (data) => {
|
|
2781
2849
|
const key = parseKey(data);
|
|
2782
2850
|
if (key.ctrl && key.name === "c") {
|
|
2783
2851
|
cleanup();
|
|
@@ -2801,17 +2869,13 @@ export async function runApp(tools) {
|
|
|
2801
2869
|
if (result === "submit") {
|
|
2802
2870
|
state = { ...state, view: "loading", spinnerFrame: 0 };
|
|
2803
2871
|
paint(renderState(state));
|
|
2804
|
-
|
|
2805
|
-
paint(renderState(state));
|
|
2806
|
-
resetQuitTimer();
|
|
2872
|
+
runTool(state);
|
|
2807
2873
|
return;
|
|
2808
2874
|
}
|
|
2809
2875
|
if (result.view === "loading") {
|
|
2810
2876
|
state = { ...result, spinnerFrame: 0 };
|
|
2811
2877
|
paint(renderState(state));
|
|
2812
|
-
|
|
2813
|
-
paint(renderState(state));
|
|
2814
|
-
resetQuitTimer();
|
|
2878
|
+
runTool(state);
|
|
2815
2879
|
return;
|
|
2816
2880
|
}
|
|
2817
2881
|
state = result;
|
package/package.json
CHANGED
package/src/skills.ts
CHANGED
|
@@ -129,6 +129,95 @@ const PLATFORMS: Record<string, { name: string; path: string }> = {
|
|
|
129
129
|
opencode: { name: "OpenCode", path: join(homedir(), ".opencode", "skills") },
|
|
130
130
|
};
|
|
131
131
|
|
|
132
|
+
/** Interactive checkbox picker — returns selected items */
|
|
133
|
+
async function pickSkills(
|
|
134
|
+
names: string[],
|
|
135
|
+
descriptions: Map<string, string>
|
|
136
|
+
): Promise<string[] | null> {
|
|
137
|
+
const selected = new Set<string>(names.filter((n) => n === "readwise-cli"));
|
|
138
|
+
let cursor = 0;
|
|
139
|
+
|
|
140
|
+
const render = () => {
|
|
141
|
+
// Move cursor up to overwrite previous render
|
|
142
|
+
process.stderr.write(`\x1b[${names.length + 2}A\x1b[J`);
|
|
143
|
+
process.stderr.write(" Select skills to install (space toggle, a all/none, enter confirm):\n\n");
|
|
144
|
+
for (let i = 0; i < names.length; i++) {
|
|
145
|
+
const check = selected.has(names[i]!) ? "\x1b[32m✔\x1b[0m" : " ";
|
|
146
|
+
const pointer = i === cursor ? "\x1b[36m❯\x1b[0m" : " ";
|
|
147
|
+
const desc = descriptions.get(names[i]!) || "";
|
|
148
|
+
const descText = desc ? ` \x1b[2m${desc}\x1b[0m` : "";
|
|
149
|
+
process.stderr.write(` ${pointer} [${check}] ${names[i]}${descText}\n`);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// Initial render (print blank lines first so the upward cursor move works)
|
|
154
|
+
process.stderr.write("\n".repeat(names.length + 2));
|
|
155
|
+
render();
|
|
156
|
+
|
|
157
|
+
return new Promise((resolve) => {
|
|
158
|
+
if (!process.stdin.isTTY) {
|
|
159
|
+
// Non-interactive: install all
|
|
160
|
+
resolve(names);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
process.stdin.setRawMode(true);
|
|
165
|
+
process.stdin.resume();
|
|
166
|
+
|
|
167
|
+
const onData = (data: Buffer) => {
|
|
168
|
+
const s = data.toString();
|
|
169
|
+
|
|
170
|
+
if (s === "\r" || s === "\n") {
|
|
171
|
+
// Enter — confirm
|
|
172
|
+
cleanup();
|
|
173
|
+
resolve([...selected]);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (s === "\x03" || s === "\x1b") {
|
|
177
|
+
// Ctrl+C or Escape — cancel
|
|
178
|
+
cleanup();
|
|
179
|
+
resolve(null);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (s === " ") {
|
|
183
|
+
// Space — toggle current
|
|
184
|
+
const name = names[cursor]!;
|
|
185
|
+
if (selected.has(name)) selected.delete(name);
|
|
186
|
+
else selected.add(name);
|
|
187
|
+
render();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (s === "a") {
|
|
191
|
+
// 'a' — toggle all/none
|
|
192
|
+
if (selected.size === names.length) selected.clear();
|
|
193
|
+
else names.forEach((n) => selected.add(n));
|
|
194
|
+
render();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (s === "\x1b[A" || s === "k") {
|
|
198
|
+
// Up
|
|
199
|
+
cursor = (cursor - 1 + names.length) % names.length;
|
|
200
|
+
render();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (s === "\x1b[B" || s === "j") {
|
|
204
|
+
// Down
|
|
205
|
+
cursor = (cursor + 1) % names.length;
|
|
206
|
+
render();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const cleanup = () => {
|
|
212
|
+
process.stdin.setRawMode(false);
|
|
213
|
+
process.stdin.pause();
|
|
214
|
+
process.stdin.removeListener("data", onData);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
process.stdin.on("data", onData);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
132
221
|
export function registerSkillsCommands(program: Command): void {
|
|
133
222
|
const skills = program.command("skills").description("Manage Readwise skills for AI agents");
|
|
134
223
|
|
|
@@ -161,10 +250,11 @@ export function registerSkillsCommands(program: Command): void {
|
|
|
161
250
|
.description("Install skills to an agent platform (claude, codex, opencode)")
|
|
162
251
|
.option("--all", "Detect installed agents and install to all")
|
|
163
252
|
.option("--refresh", "Force refresh from GitHub before installing")
|
|
164
|
-
.
|
|
253
|
+
.option("-y, --yes", "Skip skill selection and install all")
|
|
254
|
+
.action(async (platform?: string, opts?: { all?: boolean; refresh?: boolean; yes?: boolean }) => {
|
|
165
255
|
try {
|
|
166
|
-
const
|
|
167
|
-
if (
|
|
256
|
+
const allNames = (await getSkillNames(!!opts?.refresh)).filter((n) => n !== "readwise-mcp");
|
|
257
|
+
if (allNames.length === 0) {
|
|
168
258
|
console.error("No skills found.");
|
|
169
259
|
process.exitCode = 1;
|
|
170
260
|
return;
|
|
@@ -199,6 +289,22 @@ export function registerSkillsCommands(program: Command): void {
|
|
|
199
289
|
return;
|
|
200
290
|
}
|
|
201
291
|
|
|
292
|
+
// Let user pick which skills to install (unless --yes)
|
|
293
|
+
let names = allNames;
|
|
294
|
+
if (!opts?.yes && process.stdin.isTTY) {
|
|
295
|
+
const descs = new Map<string, string>();
|
|
296
|
+
for (const n of allNames) {
|
|
297
|
+
const fm = await readSkillFrontmatter(n);
|
|
298
|
+
if (fm.description) descs.set(n, fm.description);
|
|
299
|
+
}
|
|
300
|
+
const picked = await pickSkills(allNames, descs);
|
|
301
|
+
if (!picked || picked.length === 0) {
|
|
302
|
+
console.log("No skills selected.");
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
names = picked;
|
|
306
|
+
}
|
|
307
|
+
|
|
202
308
|
const cache = cacheDir();
|
|
203
309
|
for (const target of targets) {
|
|
204
310
|
console.log(`\nInstalling to ${target.name} (${target.path})...`);
|
package/src/tui/app.ts
CHANGED
|
@@ -445,6 +445,11 @@ function extractDocTitle(obj: Record<string, unknown>): string {
|
|
|
445
445
|
}
|
|
446
446
|
|
|
447
447
|
function extractDocSummary(obj: Record<string, unknown>): string {
|
|
448
|
+
// Prefer first matched chunk text (from search results)
|
|
449
|
+
if (Array.isArray(obj.matches) && obj.matches.length > 0) {
|
|
450
|
+
const first = obj.matches[0] as Record<string, unknown> | undefined;
|
|
451
|
+
if (first?.plaintext && typeof first.plaintext === "string") return first.plaintext;
|
|
452
|
+
}
|
|
448
453
|
for (const key of ["summary", "description", "note", "notes", "content"]) {
|
|
449
454
|
const val = obj[key];
|
|
450
455
|
if (val && typeof val === "string") return val;
|
|
@@ -1420,7 +1425,44 @@ function cardLine(text: string, innerW: number, borderFn: (s: string) => string)
|
|
|
1420
1425
|
return " " + borderFn("│") + " " + fitWidth(text, innerW) + " " + borderFn("│");
|
|
1421
1426
|
}
|
|
1422
1427
|
|
|
1423
|
-
|
|
1428
|
+
/** Build a regex that matches any individual word from the query (case-insensitive) */
|
|
1429
|
+
function searchTermRegex(query: string): RegExp | null {
|
|
1430
|
+
const words = query.trim().split(/\s+/).filter((w) => w.length >= 3);
|
|
1431
|
+
if (words.length === 0) return null;
|
|
1432
|
+
const escaped = words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
1433
|
+
return new RegExp(`(${escaped.join("|")})`, "gi");
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/** Shift text so the first match is visible, returning a window of ~maxChars */
|
|
1437
|
+
function snippetAroundMatch(text: string, query: string, maxChars: number): string {
|
|
1438
|
+
const re = searchTermRegex(query);
|
|
1439
|
+
if (!re) return text.slice(0, maxChars);
|
|
1440
|
+
const m = re.exec(text);
|
|
1441
|
+
if (!m) return text.slice(0, maxChars);
|
|
1442
|
+
const matchStart = m.index;
|
|
1443
|
+
// Start the window a bit before the match
|
|
1444
|
+
let start = Math.max(0, matchStart - Math.floor(maxChars / 4));
|
|
1445
|
+
// Snap forward to a word boundary so we don't cut mid-word
|
|
1446
|
+
if (start > 0) {
|
|
1447
|
+
const nextSpace = text.indexOf(" ", start);
|
|
1448
|
+
if (nextSpace !== -1 && nextSpace < matchStart) {
|
|
1449
|
+
start = nextSpace + 1;
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
const snippet = text.slice(start, start + maxChars);
|
|
1453
|
+
// Only add leading ellipsis; trailing truncation is handled by the card renderer
|
|
1454
|
+
return start > 0 ? "…" + snippet : snippet;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/** Bold all occurrences of search terms in a line */
|
|
1458
|
+
function highlightTerms(line: string, query: string): string {
|
|
1459
|
+
const re = searchTermRegex(query);
|
|
1460
|
+
if (!re) return line;
|
|
1461
|
+
// \x1b[22;1;3;37m = undim+bold+italic+white, \x1b[22;23;2;39m = unbold+unitalic+dim+default color
|
|
1462
|
+
return line.replace(re, (match) => `\x1b[22;1;3;37m${match}\x1b[22;23;2;39m`);
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function buildCardLines(card: CardItem, ci: number, selected: boolean, cardWidth: number, searchQuery?: string): string[] {
|
|
1424
1466
|
const borderColor = selected ? style.cyan : style.dim;
|
|
1425
1467
|
const innerW = Math.max(1, cardWidth - 4);
|
|
1426
1468
|
const lines: string[] = [];
|
|
@@ -1471,8 +1513,19 @@ function buildCardLines(card: CardItem, ci: number, selected: boolean, cardWidth
|
|
|
1471
1513
|
lines.push(cardLine(titleStyled, innerW, borderColor));
|
|
1472
1514
|
|
|
1473
1515
|
if (card.summary) {
|
|
1474
|
-
const
|
|
1475
|
-
|
|
1516
|
+
const snippet = searchQuery
|
|
1517
|
+
? snippetAroundMatch(card.summary, searchQuery, innerW * 3)
|
|
1518
|
+
: card.summary;
|
|
1519
|
+
const wrapped = wrapText(snippet, innerW);
|
|
1520
|
+
const showLines = wrapped.slice(0, 3);
|
|
1521
|
+
if (wrapped.length > 3) {
|
|
1522
|
+
const last = showLines[2]!;
|
|
1523
|
+
showLines[2] = truncateVisible(last, innerW - 1) + "…";
|
|
1524
|
+
}
|
|
1525
|
+
for (const sl of showLines) {
|
|
1526
|
+
const highlighted = searchQuery ? highlightTerms(sl, searchQuery) : sl;
|
|
1527
|
+
lines.push(cardLine(style.dim(highlighted), innerW, borderColor));
|
|
1528
|
+
}
|
|
1476
1529
|
}
|
|
1477
1530
|
|
|
1478
1531
|
if (card.meta) {
|
|
@@ -1498,10 +1551,13 @@ function renderCardView(state: AppState): string[] {
|
|
|
1498
1551
|
content.push(" " + style.bold("Results") + countInfo);
|
|
1499
1552
|
content.push("");
|
|
1500
1553
|
|
|
1554
|
+
// Extract search query from form values (try common field names)
|
|
1555
|
+
const searchQuery = state.formValues["query"] || state.formValues["vector_search_term"] || state.formValues["search"] || "";
|
|
1556
|
+
|
|
1501
1557
|
// Build all card lines
|
|
1502
1558
|
const allLines: { line: string; cardIdx: number }[] = [];
|
|
1503
1559
|
for (let ci = 0; ci < cards.length; ci++) {
|
|
1504
|
-
const cardContentLines = buildCardLines(cards[ci]!, ci, ci === state.resultCursor, cardWidth);
|
|
1560
|
+
const cardContentLines = buildCardLines(cards[ci]!, ci, ci === state.resultCursor, cardWidth, searchQuery || undefined);
|
|
1505
1561
|
for (const line of cardContentLines) {
|
|
1506
1562
|
allLines.push({ line, cardIdx: ci });
|
|
1507
1563
|
}
|
|
@@ -2526,7 +2582,8 @@ function cardLineCount(card: CardItem, cardWidth: number): number {
|
|
|
2526
2582
|
const textLines = Math.min(wrapped.length, 6);
|
|
2527
2583
|
return 2 + textLines + (card.note ? 1 : 0) + (card.meta ? 1 : 0); // top + text + note? + meta? + bottom
|
|
2528
2584
|
}
|
|
2529
|
-
|
|
2585
|
+
const summaryLines = card.summary ? Math.min(wrapText(card.summary, 60).length, 3) : 0;
|
|
2586
|
+
return 2 + 1 + summaryLines + (card.meta ? 1 : 0); // top + title + summary + meta? + bottom
|
|
2530
2587
|
}
|
|
2531
2588
|
|
|
2532
2589
|
function computeCardScroll(cards: CardItem[], cursor: number, currentScroll: number, availableHeight: number): number {
|
|
@@ -2900,7 +2957,17 @@ export async function runApp(tools: ToolDef[]): Promise<void> {
|
|
|
2900
2957
|
resolve();
|
|
2901
2958
|
};
|
|
2902
2959
|
|
|
2903
|
-
const
|
|
2960
|
+
const runTool = (loadingState: AppState) => {
|
|
2961
|
+
executeTool(loadingState).then((resultState) => {
|
|
2962
|
+
// If user quit during loading, don't update state
|
|
2963
|
+
if (state.view !== "loading") return;
|
|
2964
|
+
state = resultState;
|
|
2965
|
+
paint(renderState(state));
|
|
2966
|
+
resetQuitTimer();
|
|
2967
|
+
});
|
|
2968
|
+
};
|
|
2969
|
+
|
|
2970
|
+
const onData = (data: Buffer) => {
|
|
2904
2971
|
const key = parseKey(data);
|
|
2905
2972
|
|
|
2906
2973
|
if (key.ctrl && key.name === "c") {
|
|
@@ -2929,18 +2996,14 @@ export async function runApp(tools: ToolDef[]): Promise<void> {
|
|
|
2929
2996
|
if (result === "submit") {
|
|
2930
2997
|
state = { ...state, view: "loading", spinnerFrame: 0 };
|
|
2931
2998
|
paint(renderState(state));
|
|
2932
|
-
|
|
2933
|
-
paint(renderState(state));
|
|
2934
|
-
resetQuitTimer();
|
|
2999
|
+
runTool(state);
|
|
2935
3000
|
return;
|
|
2936
3001
|
}
|
|
2937
3002
|
|
|
2938
3003
|
if (result.view === "loading") {
|
|
2939
3004
|
state = { ...result, spinnerFrame: 0 };
|
|
2940
3005
|
paint(renderState(state));
|
|
2941
|
-
|
|
2942
|
-
paint(renderState(state));
|
|
2943
|
-
resetQuitTimer();
|
|
3006
|
+
runTool(state);
|
|
2944
3007
|
return;
|
|
2945
3008
|
}
|
|
2946
3009
|
|