@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 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 names = await getSkillNames(!!opts?.refresh);
154
- if (names.length === 0) {
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
- function buildCardLines(card, ci, selected, cardWidth) {
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 summaryText = truncateVisible(card.summary, innerW);
1387
- lines.push(cardLine(style.dim(summaryText), innerW, borderColor));
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
- return 2 + 1 + (card.summary ? 1 : 0) + (card.meta ? 1 : 0); // top + title + summary? + meta? + bottom
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 onData = async (data) => {
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
- state = await executeTool(state);
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
- state = await executeTool(state);
2813
- paint(renderState(state));
2814
- resetQuitTimer();
2878
+ runTool(state);
2815
2879
  return;
2816
2880
  }
2817
2881
  state = result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@readwise/cli",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Command-line interface for Readwise and Reader",
5
5
  "type": "module",
6
6
  "bin": {
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
- .action(async (platform?: string, opts?: { all?: boolean; refresh?: boolean }) => {
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 names = await getSkillNames(!!opts?.refresh);
167
- if (names.length === 0) {
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
- function buildCardLines(card: CardItem, ci: number, selected: boolean, cardWidth: number): string[] {
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 summaryText = truncateVisible(card.summary, innerW);
1475
- lines.push(cardLine(style.dim(summaryText), innerW, borderColor));
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
- return 2 + 1 + (card.summary ? 1 : 0) + (card.meta ? 1 : 0); // top + title + summary? + meta? + bottom
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 onData = async (data: Buffer) => {
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
- state = await executeTool(state);
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
- state = await executeTool(state);
2942
- paint(renderState(state));
2943
- resetQuitTimer();
3006
+ runTool(state);
2944
3007
  return;
2945
3008
  }
2946
3009