@narumitw/pi-btw 0.41.0 → 0.42.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-btw",
3
- "version": "0.41.0",
3
+ "version": "0.42.1",
4
4
  "description": "Pi extension that adds a /btw side-question command.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,5 +38,13 @@
38
38
  "type": "git",
39
39
  "url": "https://github.com/narumiruna/pi-extensions",
40
40
  "directory": "extensions/pi-btw"
41
+ },
42
+ "dependencies": {
43
+ "@narumitw/pi-tui-kit": "^0.42.0"
44
+ },
45
+ "peerDependencies": {
46
+ "@earendil-works/pi-ai": "*",
47
+ "@earendil-works/pi-coding-agent": "*",
48
+ "@earendil-works/pi-tui": "*"
41
49
  }
42
50
  }
@@ -47,11 +47,6 @@ export type BtwQuickBringToMainScope =
47
47
  | { kind: "from"; answeredTurnIndex: number }
48
48
  | { kind: "entire" };
49
49
 
50
- export type BtwMenuSelectorAction =
51
- | { kind: "select"; value: string }
52
- | { kind: "back" }
53
- | { kind: "close" };
54
-
55
50
  export type BtwTextRangeSelectorAction =
56
51
  | { kind: "confirm"; segments: BtwBringToMainSegment[] }
57
52
  | { kind: "back" }
@@ -175,210 +170,6 @@ export function formatBtwBringToMain(segments: readonly BtwBringToMainSegment[])
175
170
  ].join("\n");
176
171
  }
177
172
 
178
- export type BtwBringToMainPreviewAction = { kind: "bring" } | { kind: "back" } | { kind: "close" };
179
-
180
- export class BtwBringToMainPreview implements Component {
181
- private readonly lines: string[];
182
- private displayLines: string[];
183
- private scrollOffset = 0;
184
- private finished = false;
185
-
186
- constructor(
187
- private readonly tui: TUI,
188
- private readonly theme: Theme,
189
- private readonly keybindings: KeybindingsManager,
190
- draft: string,
191
- private readonly summary: BtwBringToMainSummary,
192
- private readonly onAction: (action: BtwBringToMainPreviewAction) => void,
193
- ) {
194
- this.lines = draft.split("\n").map(escapeTerminalControls);
195
- this.displayLines = this.lines;
196
- }
197
-
198
- render(width: number): string[] {
199
- const safeWidth = Math.max(1, width);
200
- this.displayLines = this.lines.flatMap((line) => wrapPreviewLine(line, safeWidth));
201
- const availableRows = Math.max(1, this.tui.terminal.rows - RESERVED_APP_ROWS);
202
- const showFooter = availableRows >= 3;
203
- const viewportHeight = Math.max(1, availableRows - 1 - (showFooter ? 1 : 0));
204
- this.clampScroll(viewportHeight);
205
- const count = this.summary.messages === 1 ? "1 message" : `${this.summary.messages} messages`;
206
- const lineCount = this.summary.lines === 1 ? "1 line" : `${this.summary.lines} lines`;
207
- const firstVisible = Math.min(this.displayLines.length, this.scrollOffset + 1);
208
- const lastVisible = Math.min(this.displayLines.length, this.scrollOffset + viewportHeight);
209
- const scrollable = this.displayLines.length > viewportHeight;
210
- const position = `${firstVisible}–${lastVisible}/${this.displayLines.length}`;
211
- const header = `Preview${scrollable ? ` ${position}` : ""} · ${count} · ${lineCount} · ~${this.summary.tokens} tokens`;
212
- const actions = `${confirmKeyLabel(this.keybindings)} bring • ${keybindingLabel(this.keybindings, "tui.select.cancel", ["ctrl+c"])} back • Ctrl+C close`;
213
- const scroll = `${position} • ${keybindingLabel(this.keybindings, "tui.select.pageUp")}/${keybindingLabel(this.keybindings, "tui.select.pageDown")} scroll`;
214
- const detailedFooter = scrollable ? `${scroll} • ${actions}` : actions;
215
- const footer = visibleWidth(detailedFooter) <= safeWidth ? detailedFooter : actions;
216
- return fitRows(
217
- [
218
- truncateToWidth(this.theme.fg("accent", this.theme.bold(header)), safeWidth, ""),
219
- ...this.displayLines.slice(this.scrollOffset, this.scrollOffset + viewportHeight),
220
- ...(showFooter ? [truncateToWidth(this.theme.fg("muted", footer), safeWidth, "")] : []),
221
- ],
222
- availableRows,
223
- );
224
- }
225
-
226
- handleInput(data: string): void {
227
- if (this.finished) return;
228
- if (matchesKey(data, Key.ctrl("c"))) {
229
- this.finish({ kind: "close" });
230
- return;
231
- }
232
- if (this.keybindings.matches(data, "tui.select.cancel")) {
233
- this.finish({ kind: "back" });
234
- return;
235
- }
236
- if (matchesConfirm(data, this.keybindings)) {
237
- this.finish({ kind: "bring" });
238
- return;
239
- }
240
- if (this.keybindings.matches(data, "tui.select.pageUp")) {
241
- this.scrollOffset -= Math.max(1, this.tui.terminal.rows - RESERVED_APP_ROWS - 2);
242
- this.clampScroll(Math.max(0, this.tui.terminal.rows - RESERVED_APP_ROWS - 2));
243
- this.tui.requestRender();
244
- return;
245
- }
246
- if (this.keybindings.matches(data, "tui.select.pageDown")) {
247
- this.scrollOffset += Math.max(1, this.tui.terminal.rows - RESERVED_APP_ROWS - 2);
248
- this.clampScroll(Math.max(0, this.tui.terminal.rows - RESERVED_APP_ROWS - 2));
249
- this.tui.requestRender();
250
- }
251
- }
252
-
253
- invalidate(): void {}
254
-
255
- private clampScroll(viewportHeight: number): void {
256
- this.scrollOffset = Math.max(
257
- 0,
258
- Math.min(this.scrollOffset, Math.max(0, this.displayLines.length - viewportHeight)),
259
- );
260
- }
261
-
262
- private finish(action: BtwBringToMainPreviewAction): void {
263
- if (this.finished) return;
264
- this.finished = true;
265
- this.onAction(action);
266
- }
267
- }
268
-
269
- export class BtwMenuSelector implements Component {
270
- private cursor = 0;
271
- private scrollOffset = 0;
272
- private finished = false;
273
-
274
- constructor(
275
- private readonly tui: TUI,
276
- private readonly theme: Theme,
277
- private readonly keybindings: KeybindingsManager,
278
- private readonly title: string,
279
- private readonly options: readonly string[],
280
- private readonly onAction: (action: BtwMenuSelectorAction) => void,
281
- initialValue?: string,
282
- ) {
283
- const initialIndex = initialValue === undefined ? -1 : options.indexOf(initialValue);
284
- if (initialIndex >= 0) this.cursor = initialIndex;
285
- }
286
-
287
- render(width: number): string[] {
288
- const safeWidth = Math.max(1, width);
289
- const availableRows = Math.max(1, this.tui.terminal.rows - RESERVED_APP_ROWS);
290
- const showFooter = availableRows >= 3;
291
- const viewportHeight = Math.max(1, availableRows - 1 - (showFooter ? 1 : 0));
292
- this.keepCursorVisible(viewportHeight);
293
- const rows = this.options
294
- .slice(this.scrollOffset, this.scrollOffset + viewportHeight)
295
- .map((option, visibleIndex) => {
296
- const index = this.scrollOffset + visibleIndex;
297
- const raw = `${index === this.cursor ? ">" : " "} ${escapeTerminalControls(option)}`;
298
- const toned = option.startsWith("⚠") ? this.theme.fg("warning", raw) : raw;
299
- const styled =
300
- index === this.cursor ? this.theme.bg("selectedBg", this.theme.fg("text", toned)) : toned;
301
- return truncateToWidth(styled, safeWidth, "");
302
- });
303
- return fitRows(
304
- [
305
- truncateToWidth(
306
- this.theme.fg("accent", this.theme.bold(escapeTerminalControls(this.title))),
307
- safeWidth,
308
- "",
309
- ),
310
- ...rows,
311
- ...(showFooter
312
- ? [
313
- truncateToWidth(
314
- this.theme.fg(
315
- "muted",
316
- `${confirmKeyLabel(this.keybindings)} confirm • ${keybindingLabel(this.keybindings, "tui.select.cancel", ["ctrl+c"])} back • Ctrl+C close`,
317
- ),
318
- safeWidth,
319
- "",
320
- ),
321
- ]
322
- : []),
323
- ],
324
- availableRows,
325
- );
326
- }
327
-
328
- handleInput(data: string): void {
329
- if (this.finished) return;
330
- if (matchesKey(data, Key.ctrl("c"))) {
331
- this.finish({ kind: "close" });
332
- return;
333
- }
334
- if (this.keybindings.matches(data, "tui.select.cancel")) {
335
- this.finish({ kind: "back" });
336
- return;
337
- }
338
- if (matchesConfirm(data, this.keybindings)) {
339
- const value = this.options[this.cursor];
340
- if (value !== undefined) this.finish({ kind: "select", value });
341
- return;
342
- }
343
- if (this.keybindings.matches(data, "tui.select.up")) {
344
- this.cursor = Math.max(0, this.cursor - 1);
345
- this.tui.requestRender();
346
- return;
347
- }
348
- if (this.keybindings.matches(data, "tui.select.down")) {
349
- this.cursor = Math.min(Math.max(0, this.options.length - 1), this.cursor + 1);
350
- this.tui.requestRender();
351
- return;
352
- }
353
- if (this.keybindings.matches(data, "tui.select.pageUp")) {
354
- this.cursor = Math.max(0, this.cursor - 10);
355
- this.tui.requestRender();
356
- return;
357
- }
358
- if (this.keybindings.matches(data, "tui.select.pageDown")) {
359
- this.cursor = Math.min(Math.max(0, this.options.length - 1), this.cursor + 10);
360
- this.tui.requestRender();
361
- return;
362
- }
363
- }
364
-
365
- invalidate(): void {}
366
-
367
- private keepCursorVisible(height: number): void {
368
- if (height <= 0) return;
369
- if (this.cursor < this.scrollOffset) this.scrollOffset = this.cursor;
370
- if (this.cursor >= this.scrollOffset + height) {
371
- this.scrollOffset = this.cursor - height + 1;
372
- }
373
- }
374
-
375
- private finish(action: BtwMenuSelectorAction): void {
376
- if (this.finished) return;
377
- this.finished = true;
378
- this.onAction(action);
379
- }
380
- }
381
-
382
173
  export class BtwTextRangeSelector implements Component {
383
174
  private readonly lines: BtwSelectionLine[];
384
175
  private cursor: BtwTextPosition = { line: 0, column: 0 };
@@ -788,35 +579,6 @@ function splitGraphemes(text: string): string[] {
788
579
  return [...GRAPHEME_SEGMENTER.segment(text)].map(({ segment }) => segment);
789
580
  }
790
581
 
791
- function wrapPreviewLine(text: string, width: number): string[] {
792
- if (!text) return [""];
793
- const rows: string[] = [];
794
- let row = "";
795
- let rowWidth = 0;
796
- const append = (value: string, valueWidth: number) => {
797
- if (row && rowWidth + valueWidth > width) {
798
- rows.push(row);
799
- row = "";
800
- rowWidth = 0;
801
- }
802
- row += value;
803
- rowWidth += valueWidth;
804
- };
805
- for (const character of splitGraphemes(text)) {
806
- const characterWidth = visibleWidth(character);
807
- if (characterWidth <= width) {
808
- append(character, characterWidth);
809
- continue;
810
- }
811
- const codePoints = [...character]
812
- .map((value) => `\\u{${value.codePointAt(0)?.toString(16) ?? "0"}}`)
813
- .join("");
814
- for (const value of codePoints) append(value, 1);
815
- }
816
- rows.push(row);
817
- return rows;
818
- }
819
-
820
582
  function compareTextPositions(first: BtwTextPosition, second: BtwTextPosition): number {
821
583
  return first.line === second.line ? first.column - second.column : first.line - second.line;
822
584
  }
package/src/btw.ts CHANGED
@@ -10,13 +10,10 @@ import {
10
10
  type Theme,
11
11
  } from "@earendil-works/pi-coding-agent";
12
12
  import type { Component, TUI } from "@earendil-works/pi-tui";
13
+ import { defineMenu, type MenuContext, type RunMenuResult, runMenu } from "@narumitw/pi-tui-kit";
13
14
  import {
14
- BtwBringToMainPreview,
15
- type BtwBringToMainPreviewAction,
16
15
  type BtwBringToMainSegment,
17
16
  type BtwBringToMainSummary,
18
- BtwMenuSelector,
19
- type BtwMenuSelectorAction,
20
17
  BtwTextRangeSelector,
21
18
  type BtwTextRangeSelectorState,
22
19
  buildQuickBringToMainSegments,
@@ -391,15 +388,19 @@ type BtwCustomFactory<T> = (
391
388
  async function showBtwCustomPreservingEditor<T>(
392
389
  ctx: ExtensionCommandContext,
393
390
  factory: BtwCustomFactory<T>,
394
- ): Promise<T> {
391
+ ): Promise<T | undefined> {
395
392
  let liveEditorText = ctx.ui.getEditorText();
393
+ let completed = false;
396
394
  const result = await ctx.ui.custom<T>((tui, theme, keybindings, done) =>
397
395
  factory(tui, theme, keybindings, (value) => {
398
396
  liveEditorText = ctx.ui.getEditorText();
397
+ completed = true;
399
398
  done(value);
400
399
  }),
401
400
  );
402
- if (ctx.ui.getEditorText() !== liveEditorText) ctx.ui.setEditorText(liveEditorText);
401
+ if (completed && ctx.ui.getEditorText() !== liveEditorText) {
402
+ ctx.ui.setEditorText(liveEditorText);
403
+ }
403
404
  return result;
404
405
  }
405
406
 
@@ -501,6 +502,7 @@ export async function chooseBringToMain(
501
502
  return selector;
502
503
  },
503
504
  );
505
+ if (!selectedRange) return { kind: "closed" };
504
506
  if (selectedRange.kind === "closed") return selectedRange;
505
507
  if (selectedRange.kind === "back") break;
506
508
  const preview = await showPreview(ctx, selectedRange.draft, selectedRange.summary);
@@ -518,16 +520,47 @@ export async function chooseBringToMain(
518
520
  }
519
521
  }
520
522
 
523
+ type BtwMenuSelectorAction =
524
+ | { kind: "select"; value: string }
525
+ | { kind: "back" }
526
+ | { kind: "close" };
527
+
528
+ type BtwBringToMainPreviewAction = { kind: "bring" } | { kind: "back" } | { kind: "close" };
529
+
521
530
  async function showBringToMainPreview(
522
531
  ctx: ExtensionCommandContext,
523
532
  draft: string,
524
533
  summary: BtwBringToMainSummary,
525
534
  ): Promise<BtwBringToMainPreviewAction> {
526
- return showBtwCustomPreservingEditor<BtwBringToMainPreviewAction>(
527
- ctx,
528
- (tui, theme, keybindings, done) =>
529
- new BtwBringToMainPreview(tui, theme, keybindings, draft, summary, done),
535
+ let confirmed = false;
536
+ const count = summary.messages === 1 ? "1 message" : `${summary.messages} messages`;
537
+ const lineCount = summary.lines === 1 ? "1 line" : `${summary.lines} lines`;
538
+ const menu = defineMenu<void, "preview", "bring", MenuContext>({
539
+ start: "preview",
540
+ screens: {
541
+ preview: () => ({
542
+ kind: "review",
543
+ title: `Preview · ${count} · ${lineCount} · ~${summary.tokens} tokens`,
544
+ content: draft,
545
+ viewportSize: "adaptive",
546
+ hint: "back",
547
+ confirm: { id: "bring", label: "Bring", action: "bring" },
548
+ }),
549
+ },
550
+ actions: {
551
+ bring: async () => {
552
+ confirmed = true;
553
+ return { kind: "close" } as const;
554
+ },
555
+ },
556
+ });
557
+ const result = await runBtwMenuPreservingEditor(ctx, (menuContext) =>
558
+ runMenu(menuContext, menu, { getState: () => undefined }),
530
559
  );
560
+ if (confirmed && result.kind === "closed" && result.reason === "close") {
561
+ return { kind: "bring" };
562
+ }
563
+ return terminalBtwMenuAction(result);
531
564
  }
532
565
 
533
566
  async function showBtwMenu(
@@ -536,11 +569,72 @@ async function showBtwMenu(
536
569
  options: readonly string[],
537
570
  initialValue?: string,
538
571
  ): Promise<BtwMenuSelectorAction> {
539
- return showBtwCustomPreservingEditor<BtwMenuSelectorAction>(
540
- ctx,
541
- (tui, theme, keybindings, done) =>
542
- new BtwMenuSelector(tui, theme, keybindings, title, options, done, initialValue),
572
+ const items = options.map((label, index) => ({ id: `option-${index}`, label }));
573
+ const initialIndex = initialValue === undefined ? -1 : options.indexOf(initialValue);
574
+ let selectedValue: string | undefined;
575
+ const menu = defineMenu<void, "choices", "select", MenuContext>({
576
+ start: "choices",
577
+ screens: {
578
+ choices: () => ({
579
+ kind: "choice",
580
+ title,
581
+ items,
582
+ action: "select",
583
+ initialItemId: initialIndex >= 0 ? `option-${initialIndex}` : undefined,
584
+ hint: "back",
585
+ }),
586
+ },
587
+ actions: {
588
+ select: async ({ itemId }: { itemId: string }) => {
589
+ const index = Number.parseInt(itemId.slice("option-".length), 10);
590
+ selectedValue = options[index];
591
+ return selectedValue === undefined
592
+ ? ({ kind: "stay" } as const)
593
+ : ({ kind: "close" } as const);
594
+ },
595
+ },
596
+ });
597
+ const result = await runBtwMenuPreservingEditor(ctx, (menuContext) =>
598
+ runMenu(menuContext, menu, { getState: () => undefined }),
543
599
  );
600
+ return selectedValue !== undefined && result.kind === "closed" && result.reason === "close"
601
+ ? { kind: "select", value: selectedValue }
602
+ : terminalBtwMenuAction(result);
603
+ }
604
+
605
+ async function runBtwMenuPreservingEditor(
606
+ ctx: ExtensionCommandContext,
607
+ run: (menuContext: MenuContext) => Promise<RunMenuResult>,
608
+ ): Promise<RunMenuResult> {
609
+ let liveEditorText = ctx.ui.getEditorText();
610
+ let completed = false;
611
+ const ui = new Proxy(ctx.ui, {
612
+ get(target, property) {
613
+ if (property === "custom") {
614
+ return <Value>(factory: BtwCustomFactory<Value>) =>
615
+ target.custom<Value>((tui, theme, keybindings, done) =>
616
+ factory(tui, theme, keybindings, (value) => {
617
+ liveEditorText = target.getEditorText();
618
+ completed = true;
619
+ done(value);
620
+ }),
621
+ );
622
+ }
623
+ const value = Reflect.get(target, property, target) as unknown;
624
+ return typeof value === "function" ? value.bind(target) : value;
625
+ },
626
+ });
627
+ const result = await run({ mode: ctx.mode, hasUI: ctx.hasUI, ui });
628
+ if (result.kind !== "stale" && completed && ctx.ui.getEditorText() !== liveEditorText) {
629
+ ctx.ui.setEditorText(liveEditorText);
630
+ }
631
+ return result;
632
+ }
633
+
634
+ function terminalBtwMenuAction(result: RunMenuResult): { kind: "back" } | { kind: "close" } {
635
+ if (result.kind === "closed") return { kind: result.reason };
636
+ if (result.kind === "error") throw result.error;
637
+ return { kind: "close" };
544
638
  }
545
639
 
546
640
  export async function loadBringToMainDraft(