@narumitw/pi-btw 0.55.2 → 0.55.3

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.55.2",
3
+ "version": "0.55.3",
4
4
  "description": "Pi extension that adds a /btw side-question command.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,7 +47,7 @@
47
47
  "directory": "packages/pi-btw"
48
48
  },
49
49
  "dependencies": {
50
- "@narumitw/pi-tui-kit": "^0.58.0"
50
+ "@narumitw/pi-tui-kit": "^0.58.1"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "@earendil-works/pi-ai": "*",
@@ -7,9 +7,14 @@ import {
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
  import {
9
9
  type Component,
10
+ isKeyRelease,
11
+ Key,
12
+ matchesKey,
10
13
  type OverlayHandle,
11
14
  type TUI,
12
15
  TuiAltScreen,
16
+ type TuiInputListener,
17
+ type TuiInputListenerResult,
13
18
  truncateToWidth,
14
19
  } from "@earendil-works/pi-tui";
15
20
  import { sanitizeSingleLine } from "./text.js";
@@ -25,6 +30,7 @@ type BtwCustomFactory<T> = (
25
30
  type BtwFullscreenTui = TUI & {
26
31
  flash?: (message: string, durationMs?: number) => void;
27
32
  setLayoutRoot(component: Component | undefined): void;
33
+ addInputListenerBeforeViewport?(listener: TuiInputListener): () => void;
28
34
  };
29
35
 
30
36
  export interface BtwFullscreenLayoutComponent extends Component {
@@ -69,9 +75,10 @@ export async function runBtwFullscreen<T>(
69
75
  ));
70
76
  let liveEditorText = ctx.ui.getEditorText();
71
77
  let restoreEditor = false;
78
+ let host: BtwFullscreenHost<T> | undefined;
72
79
  const outcome = await ctx.ui.custom<FullscreenOutcome<T>>(
73
- (parent, theme, keybindings, done) =>
74
- new BtwFullscreenHost(
80
+ (parent, theme, keybindings, done) => {
81
+ host = new BtwFullscreenHost(
75
82
  parent,
76
83
  theme,
77
84
  keybindings,
@@ -87,7 +94,13 @@ export async function runBtwFullscreen<T>(
87
94
  done(value);
88
95
  },
89
96
  createTui,
90
- ),
97
+ );
98
+ return host;
99
+ },
100
+ {
101
+ overlay: true,
102
+ onHandle: (handle) => host?.setParentOverlay(handle),
103
+ },
91
104
  );
92
105
  if (restoreEditor) {
93
106
  try {
@@ -100,6 +113,59 @@ export async function runBtwFullscreen<T>(
100
113
  return outcome.value;
101
114
  }
102
115
 
116
+ type BtwInputListeners = {
117
+ beforeViewport: Set<TuiInputListener>;
118
+ regular: Set<TuiInputListener>;
119
+ };
120
+
121
+ const btwInputListeners = new WeakMap<BtwTuiAltScreen, BtwInputListeners>();
122
+
123
+ function dispatchBtwInput(listeners: BtwInputListeners, data: string): TuiInputListenerResult {
124
+ let current = data;
125
+ for (const group of [listeners.beforeViewport, listeners.regular]) {
126
+ for (const listener of group) {
127
+ const result = listener(current);
128
+ if (result?.consume) return result;
129
+ if (result?.data !== undefined) current = result.data;
130
+ }
131
+ }
132
+ return current === data ? undefined : { data: current };
133
+ }
134
+
135
+ class BtwTuiAltScreen extends TuiAltScreen {
136
+ override addInputListener(listener: TuiInputListener): () => void {
137
+ let listeners = btwInputListeners.get(this);
138
+ if (!listeners) {
139
+ const registeredListeners: BtwInputListeners = {
140
+ beforeViewport: new Set(),
141
+ regular: new Set(),
142
+ };
143
+ btwInputListeners.set(this, registeredListeners);
144
+ super.addInputListener((data) => dispatchBtwInput(registeredListeners, data));
145
+ listeners = registeredListeners;
146
+ }
147
+ listeners.regular.add(listener);
148
+ return () => listeners.regular.delete(listener);
149
+ }
150
+
151
+ addInputListenerBeforeViewport(listener: TuiInputListener): () => void {
152
+ const listeners = btwInputListeners.get(this);
153
+ if (!listeners) return super.addInputListener(listener);
154
+ listeners.beforeViewport.add(listener);
155
+ return () => listeners.beforeViewport.delete(listener);
156
+ }
157
+
158
+ override removeInputListener(listener: TuiInputListener): void {
159
+ const listeners = btwInputListeners.get(this);
160
+ if (!listeners) {
161
+ super.removeInputListener(listener);
162
+ return;
163
+ }
164
+ listeners.beforeViewport.delete(listener);
165
+ listeners.regular.delete(listener);
166
+ }
167
+ }
168
+
103
169
  function createBtwFullscreenTui(
104
170
  parent: TUI,
105
171
  theme: Theme,
@@ -108,7 +174,7 @@ function createBtwFullscreenTui(
108
174
  ): BtwFullscreenTui {
109
175
  const styleSearchMatch = (text: string) =>
110
176
  theme.bg("searchMatchBg", theme.fg("searchMatchText", text));
111
- return new TuiAltScreen(parent.terminal, parent.getShowHardwareCursor(), undefined, {
177
+ return new BtwTuiAltScreen(parent.terminal, parent.getShowHardwareCursor(), undefined, {
112
178
  mouse: true,
113
179
  searchMatchStyle: (text) => theme.underline(styleSearchMatch(text)),
114
180
  searchCurrentMatchStyle: (text) => theme.bold(theme.inverse(styleSearchMatch(text))),
@@ -139,10 +205,18 @@ function openUrlInBrowser(target: string): void {
139
205
 
140
206
  class BtwFullscreenHost<T> implements Component {
141
207
  private fullscreen: BtwFullscreenTui | undefined;
208
+ private parentOverlay: OverlayHandle | undefined;
142
209
  private cancelActiveCustom: (() => void) | undefined;
210
+ private hardCancelActiveCustom: (() => void) | undefined;
211
+ private removeHardCancelListener: (() => void) | undefined;
143
212
  private started = false;
144
213
  private disposed = false;
145
214
  private finished = false;
215
+ private parentStopped = false;
216
+ private parentRestarted = false;
217
+ private fullscreenCreated = false;
218
+ private fullscreenStopped = false;
219
+ private cleanupError: unknown;
146
220
 
147
221
  constructor(
148
222
  private readonly parent: TUI,
@@ -156,6 +230,10 @@ class BtwFullscreenHost<T> implements Component {
156
230
  queueMicrotask(() => void this.start());
157
231
  }
158
232
 
233
+ setParentOverlay(overlay: OverlayHandle): void {
234
+ this.parentOverlay = overlay;
235
+ }
236
+
159
237
  render(width: number): string[] {
160
238
  return [truncateToWidth(this.theme.fg("muted", "Opening btw side thread…"), width)];
161
239
  }
@@ -172,45 +250,69 @@ class BtwFullscreenHost<T> implements Component {
172
250
  if (this.started || this.finished) return;
173
251
  this.started = true;
174
252
  let outcome: FullscreenOutcome<T>;
175
- let parentStopped = false;
176
- let fullscreenCreated = false;
177
253
  try {
178
254
  if (this.disposed) throw new FullscreenUiDisposedError();
179
255
  this.parent.stop({ preserveScreen: true });
180
- parentStopped = true;
256
+ this.parentStopped = true;
181
257
  if (this.disposed) throw new FullscreenUiDisposedError();
182
258
  this.fullscreen = this.createTui(this.parent, this.theme);
183
- fullscreenCreated = true;
259
+ this.fullscreenCreated = true;
184
260
  this.fullscreen.start();
261
+ // Waiting for the custom promise would leave follow-up keys bound to the side TUI.
262
+ const addHardCancelListener =
263
+ this.fullscreen.addInputListenerBeforeViewport?.bind(this.fullscreen) ??
264
+ this.fullscreen.addInputListener.bind(this.fullscreen);
265
+ this.removeHardCancelListener = addHardCancelListener((data) => {
266
+ if (isKeyRelease(data) || !matchesKey(data, Key.ctrl("c"))) return undefined;
267
+ try {
268
+ this.hardCancelActiveCustom?.();
269
+ } finally {
270
+ this.restoreParent();
271
+ }
272
+ return { consume: true };
273
+ });
185
274
  outcome = { kind: "completed", value: await this.run(this.createContext()) };
186
275
  } catch (error) {
187
276
  outcome = { kind: "failed", error };
188
277
  }
189
278
 
190
- let cleanupError: unknown;
191
279
  try {
192
280
  this.cancelActiveCustom?.();
193
281
  } catch (error) {
194
- cleanupError = error;
282
+ this.cleanupError ??= error;
195
283
  }
196
- if (fullscreenCreated) {
284
+ this.restoreParent();
285
+ if (this.cleanupError !== undefined) outcome = { kind: "failed", error: this.cleanupError };
286
+ this.finished = true;
287
+ this.done(outcome);
288
+ }
289
+
290
+ private restoreParent(): void {
291
+ this.removeHardCancelListener?.();
292
+ this.removeHardCancelListener = undefined;
293
+ if (this.fullscreenCreated && !this.fullscreenStopped) {
294
+ this.fullscreenStopped = true;
197
295
  try {
198
296
  this.fullscreen?.stop({ preserveScreen: true });
199
297
  } catch (error) {
200
- cleanupError ??= error;
298
+ this.cleanupError ??= error;
201
299
  }
202
300
  }
203
- if (parentStopped) {
204
- try {
205
- this.parent.start();
206
- this.parent.renderNow(false);
207
- } catch (error) {
208
- cleanupError ??= error;
209
- }
301
+ if (!this.parentStopped || this.parentRestarted) return;
302
+ const parentOverlay = this.parentOverlay;
303
+ this.parentOverlay = undefined;
304
+ try {
305
+ parentOverlay?.setHidden(true);
306
+ } catch (error) {
307
+ this.cleanupError ??= error;
308
+ }
309
+ try {
310
+ this.parent.start();
311
+ this.parentRestarted = true;
312
+ this.parent.renderNow(false);
313
+ } catch (error) {
314
+ this.cleanupError ??= error;
210
315
  }
211
- if (cleanupError !== undefined) outcome = { kind: "failed", error: cleanupError };
212
- this.finished = true;
213
- this.done(outcome);
214
316
  }
215
317
 
216
318
  private createContext(): ExtensionCommandContext {
@@ -296,6 +398,7 @@ class BtwFullscreenHost<T> implements Component {
296
398
  if (promiseSettled || !hasPendingValue) return;
297
399
  promiseSettled = true;
298
400
  this.cancelActiveCustom = undefined;
401
+ this.hardCancelActiveCustom = undefined;
299
402
  if (!factorySettled) {
300
403
  resolve(pendingValue as Value);
301
404
  return;
@@ -319,6 +422,7 @@ class BtwFullscreenHost<T> implements Component {
319
422
  closed = true;
320
423
  promiseSettled = true;
321
424
  this.cancelActiveCustom = undefined;
425
+ this.hardCancelActiveCustom = undefined;
322
426
  try {
323
427
  unmount();
324
428
  reject(error);
@@ -331,6 +435,16 @@ class BtwFullscreenHost<T> implements Component {
331
435
  disposeComponent();
332
436
  if (!promiseSettled) fail(new FullscreenUiDisposedError());
333
437
  };
438
+ this.hardCancelActiveCustom = () => {
439
+ if (promiseSettled) return;
440
+ try {
441
+ component?.handleInput?.("\u0003");
442
+ } catch (error) {
443
+ fail(error);
444
+ return;
445
+ }
446
+ this.cancelActiveCustom?.();
447
+ };
334
448
 
335
449
  let created: ReturnType<BtwCustomFactory<Value>>;
336
450
  try {