github-search-cli 9.9.0 → 9.10.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/CHANGELOG.md +94 -0
- package/README.md +49 -51
- package/dist/cli.js +562 -121
- package/docs/HOW-TO.md +3 -2
- package/package.json +2 -2
- package/release-notes/9.10.0.md +137 -0
- package/release-notes/9.10.1.md +59 -0
- package/src/cli.ts +77 -7
- package/src/help.ts +21 -4
- package/src/history.ts +4 -5
- package/src/mcp-server.ts +4 -2
- package/src/output.ts +11 -1
- package/src/package-browser.ts +2 -0
- package/src/status.ts +40 -0
- package/src/trending.ts +3 -1
- package/src/tui/mouse.ts +349 -0
- package/src/tui.ts +428 -114
- package/src/watch.ts +18 -6
- package/src/export.ts +0 -73
- package/src/pipe.ts +0 -60
- package/src/search-state.ts +0 -278
package/src/tui/mouse.ts
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mouse support for the ghfind TUI.
|
|
3
|
+
*
|
|
4
|
+
* OpenTUI already emits SGR mouse tracking and hit-tests every event into a
|
|
5
|
+
* `MouseEvent` whose `target` is the deepest renderable under the pointer
|
|
6
|
+
* (`Renderable.processMouseEvent`). Nothing in the shell consumed them, so the
|
|
7
|
+
* renderer threw the events away. This module turns them into the same
|
|
8
|
+
* affordances the keyboard already offers.
|
|
9
|
+
*
|
|
10
|
+
* Design notes (all verified against @opentui/core 0.4.5):
|
|
11
|
+
*
|
|
12
|
+
* - `SelectRenderable` has no `onMouseEvent` override and no public row
|
|
13
|
+
* geometry, so `hitIndexForY` reads `scrollOffset` / `linesPerItem` through
|
|
14
|
+
* ONE narrow cast (see `selectRowGeometry`). If a future release renames
|
|
15
|
+
* those fields the helper degrades to "no row" — clicks no-op instead of
|
|
16
|
+
* throwing. Single choke point, easy to re-point.
|
|
17
|
+
* - `setSelectedIndex` / `selectCurrent` both emit `selectionChanged` /
|
|
18
|
+
* `itemSelected`, so clicks reuse the handlers the keyboard path already
|
|
19
|
+
* drives (`src/tui.ts`) with zero duplicated logic.
|
|
20
|
+
* - `dispatchMouseEvent` auto-focuses the nearest `focusable` ancestor on
|
|
21
|
+
* left-press *unless the event is `defaultPrevented`*. `keepFocus: true`
|
|
22
|
+
* calls `preventDefault()` so clicking a row never steals focus from the
|
|
23
|
+
* query input.
|
|
24
|
+
* - `processMouseEvent` bubbles to the parent unless `stopPropagation()` is
|
|
25
|
+
* called, so every row handler stops it — otherwise a click would also fire
|
|
26
|
+
* the enclosing panel's dismiss handler.
|
|
27
|
+
* - Every `TextRenderable` defaults to `selectable: true`, and a left-press on
|
|
28
|
+
* a selectable renderable is swallowed into a text-selection drag before the
|
|
29
|
+
* shell ever sees it. `isInteractiveText()` marks the ones that must opt out.
|
|
30
|
+
*/
|
|
31
|
+
import type {
|
|
32
|
+
BoxRenderable,
|
|
33
|
+
CliRenderer,
|
|
34
|
+
SelectRenderable,
|
|
35
|
+
} from "@opentui/core";
|
|
36
|
+
|
|
37
|
+
/** Two clicks closer together than this count as a double-click. */
|
|
38
|
+
export const DOUBLE_CLICK_MS = 400;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Row geometry the hit test needs. `SelectRenderable` keeps all three fields
|
|
42
|
+
* private, so this is the structural shape `selectRowGeometry` projects onto.
|
|
43
|
+
*/
|
|
44
|
+
export interface SelectRowGeometry {
|
|
45
|
+
/** Row's screen-space top edge (`Renderable.screenY`). */
|
|
46
|
+
readonly screenY: number;
|
|
47
|
+
/** Visible viewport height in rows. */
|
|
48
|
+
readonly height: number;
|
|
49
|
+
/** Current option count. */
|
|
50
|
+
readonly optionCount: number;
|
|
51
|
+
/** Index of the first visible row (private `scrollOffset`). */
|
|
52
|
+
readonly scrollOffset: number;
|
|
53
|
+
/** Row pitch in terminal lines (private `linesPerItem`). */
|
|
54
|
+
readonly linesPerItem: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Public stand-in used by the tests — no OpenTUI renderer required. */
|
|
58
|
+
export type SelectGeometryLike = SelectRowGeometry;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Map a screen-space y coordinate to an option index, or -1 when the click
|
|
62
|
+
* misses every row (empty list, click below the last row, click past the
|
|
63
|
+
* viewport). Pure — no renderer state, so the off-by-one surface at scroll
|
|
64
|
+
* boundaries is directly testable.
|
|
65
|
+
*/
|
|
66
|
+
export function hitIndexForY(
|
|
67
|
+
geometry: SelectGeometryLike,
|
|
68
|
+
screenY: number,
|
|
69
|
+
): number {
|
|
70
|
+
const pitch = geometry.linesPerItem;
|
|
71
|
+
// A zero/negative pitch means the renderable is unmeasured or malformed;
|
|
72
|
+
// treat every click as a miss rather than dividing by zero.
|
|
73
|
+
if (pitch <= 0) return -1;
|
|
74
|
+
if (geometry.optionCount <= 0) return -1;
|
|
75
|
+
|
|
76
|
+
const localY = screenY - geometry.screenY;
|
|
77
|
+
// Above the first row, or at/below the viewport's bottom edge.
|
|
78
|
+
if (localY < 0 || localY >= geometry.height) return -1;
|
|
79
|
+
|
|
80
|
+
const row = Math.floor(localY / pitch);
|
|
81
|
+
const index = geometry.scrollOffset + row;
|
|
82
|
+
// The last page of options is usually shorter than the viewport, leaving
|
|
83
|
+
// empty rows below it. Clamp those onto the final option rather than
|
|
84
|
+
// treating the click as a miss — a user aiming at the last row should land on
|
|
85
|
+
// it even when their pointer drifts a line or two.
|
|
86
|
+
const last = geometry.optionCount - 1;
|
|
87
|
+
if (index < 0) return 0;
|
|
88
|
+
return index > last ? last : index;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Project a `SelectRenderable` onto `SelectRowGeometry`.
|
|
93
|
+
*
|
|
94
|
+
* The single narrow cast in this module: `scrollOffset` and `linesPerItem` are
|
|
95
|
+
* declared `private` in OpenTUI's `.d.ts` but are plain instance fields at
|
|
96
|
+
* runtime (verified in `index.node.js`). A rename in a future release yields
|
|
97
|
+
* `undefined` → `pitch <= 0` → every click misses, which is a visible no-op
|
|
98
|
+
* rather than a crash.
|
|
99
|
+
*/
|
|
100
|
+
export function selectRowGeometry(select: SelectRenderable): SelectRowGeometry {
|
|
101
|
+
const internal = select as unknown as {
|
|
102
|
+
scrollOffset?: number;
|
|
103
|
+
linesPerItem?: number;
|
|
104
|
+
};
|
|
105
|
+
return {
|
|
106
|
+
screenY: select.screenY,
|
|
107
|
+
height: select.height,
|
|
108
|
+
optionCount: select.options.length,
|
|
109
|
+
scrollOffset: internal.scrollOffset ?? 0,
|
|
110
|
+
linesPerItem: internal.linesPerItem ?? 0,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface ClickToSelectOptions {
|
|
115
|
+
/**
|
|
116
|
+
* Runs the list's Enter-equivalent (open the URL, run the export, ...).
|
|
117
|
+
* Called on the second click of a double-click, and only when the row is
|
|
118
|
+
* already selected.
|
|
119
|
+
*/
|
|
120
|
+
onOpen?: () => void;
|
|
121
|
+
/**
|
|
122
|
+
* Called when a click lands on the list but resolves to no row — the empty
|
|
123
|
+
* area under the last option. Full-screen overlays use it to dismiss on a
|
|
124
|
+
* click below their content.
|
|
125
|
+
*/
|
|
126
|
+
onClickMiss?: () => void;
|
|
127
|
+
/**
|
|
128
|
+
* `true` for the main results list: `preventDefault()` the press so OpenTUI
|
|
129
|
+
* does not auto-focus the list away from the query input. Overlays leave it
|
|
130
|
+
* `false` so ↑↓ / Enter keep working right after a click.
|
|
131
|
+
*/
|
|
132
|
+
keepFocus?: boolean;
|
|
133
|
+
/** Rows per wheel notch. Defaults to 3. */
|
|
134
|
+
wheelStep?: number;
|
|
135
|
+
/** Called with the row index as the pointer moves over the list. */
|
|
136
|
+
onHoverRow?: (index: number) => void;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface AttachedSelect {
|
|
140
|
+
/** Forget the pending double-click (call when the list's options change). */
|
|
141
|
+
reset(): void;
|
|
142
|
+
/** Row index the pointer last selected, or -1. Exposed for tests/status. */
|
|
143
|
+
readonly lastHovered: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Wire click-to-select, double-click-to-open, wheel scrolling, and hover
|
|
148
|
+
* feedback onto one `SelectRenderable`.
|
|
149
|
+
*/
|
|
150
|
+
export function attachClickToSelect(
|
|
151
|
+
renderer: CliRenderer,
|
|
152
|
+
select: SelectRenderable,
|
|
153
|
+
options: ClickToSelectOptions = {},
|
|
154
|
+
): AttachedSelect {
|
|
155
|
+
const wheelStep = options.wheelStep ?? 3;
|
|
156
|
+
let lastClickIndex = -1;
|
|
157
|
+
let lastClickAt = 0;
|
|
158
|
+
let lastHovered = -1;
|
|
159
|
+
|
|
160
|
+
select.onMouseDown = (event) => {
|
|
161
|
+
// A click on the list must not also reach the enclosing overlay panel's
|
|
162
|
+
// dismiss handler, and must not steal focus from the query input.
|
|
163
|
+
event.stopPropagation();
|
|
164
|
+
if (options.keepFocus) event.preventDefault();
|
|
165
|
+
|
|
166
|
+
const index = hitIndexForY(selectRowGeometry(select), event.y);
|
|
167
|
+
if (index < 0) {
|
|
168
|
+
lastClickIndex = -1;
|
|
169
|
+
options.onClickMiss?.();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const now = Date.now();
|
|
174
|
+
const alreadySelected = select.getSelectedIndex() === index;
|
|
175
|
+
const isDouble =
|
|
176
|
+
alreadySelected &&
|
|
177
|
+
index === lastClickIndex &&
|
|
178
|
+
now - lastClickAt < DOUBLE_CLICK_MS;
|
|
179
|
+
|
|
180
|
+
lastClickIndex = index;
|
|
181
|
+
lastClickAt = now;
|
|
182
|
+
|
|
183
|
+
if (isDouble) {
|
|
184
|
+
lastClickIndex = -1;
|
|
185
|
+
options.onOpen?.();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Emits `selectionChanged`, which the shell already uses to refresh the
|
|
190
|
+
// detail pane — the click path and the keyboard path converge here.
|
|
191
|
+
select.setSelectedIndex(index);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
select.onMouseScroll = (event) => {
|
|
195
|
+
// Claim the wheel: without stopPropagation the renderer's focused-renderable
|
|
196
|
+
// fallback would also scroll whatever happens to hold focus.
|
|
197
|
+
event.stopPropagation();
|
|
198
|
+
const direction = event.scroll?.direction;
|
|
199
|
+
if (direction === "up") select.moveUp(wheelStep);
|
|
200
|
+
else if (direction === "down") select.moveDown(wheelStep);
|
|
201
|
+
// "left"/"right" belong to the scrollbar, not the list.
|
|
202
|
+
else return;
|
|
203
|
+
renderer.requestRender();
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
attachHoverHighlight(renderer, select, {
|
|
207
|
+
onHover: (index) => {
|
|
208
|
+
lastHovered = index;
|
|
209
|
+
options.onHoverRow?.(index);
|
|
210
|
+
},
|
|
211
|
+
// A stale row must never outlive the options it pointed at.
|
|
212
|
+
onLeave: () => {
|
|
213
|
+
lastHovered = -1;
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
reset() {
|
|
219
|
+
lastClickIndex = -1;
|
|
220
|
+
lastClickAt = 0;
|
|
221
|
+
},
|
|
222
|
+
get lastHovered() {
|
|
223
|
+
return lastHovered;
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface HoverOptions {
|
|
229
|
+
/** Row under the pointer, or -1 when the pointer left the list / missed. */
|
|
230
|
+
onHover?: (index: number) => void;
|
|
231
|
+
/** Called once when the pointer leaves the list. */
|
|
232
|
+
onLeave?: () => void;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Give a list a pointer cursor and a subtle background tint while hovered.
|
|
237
|
+
*
|
|
238
|
+
* A `SelectRenderable` can only tint its whole background (the selected row
|
|
239
|
+
* owns `selectedBackgroundColor`), so the hover state is a list-wide lift —
|
|
240
|
+
* enough to read as "this is live", without pretending to be per-row.
|
|
241
|
+
*/
|
|
242
|
+
export function attachHoverHighlight(
|
|
243
|
+
renderer: CliRenderer,
|
|
244
|
+
select: SelectRenderable,
|
|
245
|
+
options: HoverOptions = {},
|
|
246
|
+
): void {
|
|
247
|
+
const idleBackground = select.backgroundColor;
|
|
248
|
+
// OpenTUI's `parseColor` only understands "transparent", CSS colour names,
|
|
249
|
+
// and 3/4/6/8-digit hex — an `rgba(...)` string silently resolves to magenta.
|
|
250
|
+
// A white tint at 6% alpha is the 8-digit-hex form of that.
|
|
251
|
+
const hoverBackground = "#ffffff0f";
|
|
252
|
+
let hovered = -1;
|
|
253
|
+
|
|
254
|
+
select.onMouseOver = () => {
|
|
255
|
+
renderer.setMousePointer("pointer");
|
|
256
|
+
select.backgroundColor = hoverBackground;
|
|
257
|
+
renderer.requestRender();
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
select.onMouseOut = () => {
|
|
261
|
+
renderer.setMousePointer("default");
|
|
262
|
+
select.backgroundColor = idleBackground;
|
|
263
|
+
if (hovered !== -1) {
|
|
264
|
+
hovered = -1;
|
|
265
|
+
options.onLeave?.();
|
|
266
|
+
}
|
|
267
|
+
renderer.requestRender();
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
// Report the row under the pointer, resolved exactly like a click — but only
|
|
271
|
+
// when it changed, so a stationary pointer does not re-render every frame.
|
|
272
|
+
select.onMouseMove = (event) => {
|
|
273
|
+
const index = hitIndexForY(selectRowGeometry(select), event.y);
|
|
274
|
+
if (index === hovered) return;
|
|
275
|
+
hovered = index;
|
|
276
|
+
options.onHover?.(index);
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Dismiss a full-screen overlay when the click lands on the panel itself
|
|
282
|
+
* rather than on its content (border, padding, or a sibling region). Children
|
|
283
|
+
* that handle their own clicks call `stopPropagation()`, so this only fires for
|
|
284
|
+
* genuine outside-clicks.
|
|
285
|
+
*/
|
|
286
|
+
export function attachClickOutside(
|
|
287
|
+
target: BoxRenderable,
|
|
288
|
+
onDismiss: () => void,
|
|
289
|
+
): void {
|
|
290
|
+
target.onMouseDown = (event) => {
|
|
291
|
+
event.stopPropagation();
|
|
292
|
+
onDismiss();
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Make a non-list Box clickable: click runs `onActivate`, hovering swaps the
|
|
298
|
+
* border to the accent colour and shows a pointer cursor.
|
|
299
|
+
*
|
|
300
|
+
* `setBorder`/`getBorder` are passed in rather than imported so the unit tests
|
|
301
|
+
* can drive this with a plain object.
|
|
302
|
+
*/
|
|
303
|
+
export function attachClickableBox(
|
|
304
|
+
renderer: CliRenderer,
|
|
305
|
+
box: BoxRenderable,
|
|
306
|
+
handlers: {
|
|
307
|
+
onActivate: () => void;
|
|
308
|
+
/** Border colour while not hovered. */
|
|
309
|
+
idleBorder: string;
|
|
310
|
+
/** Border colour while hovered / selected. */
|
|
311
|
+
activeBorder: string;
|
|
312
|
+
onStateChange?: () => void;
|
|
313
|
+
},
|
|
314
|
+
): void {
|
|
315
|
+
box.onMouseDown = (event) => {
|
|
316
|
+
event.stopPropagation();
|
|
317
|
+
handlers.onActivate();
|
|
318
|
+
};
|
|
319
|
+
box.onMouseOver = () => {
|
|
320
|
+
renderer.setMousePointer("pointer");
|
|
321
|
+
box.borderColor = handlers.activeBorder;
|
|
322
|
+
handlers.onStateChange?.();
|
|
323
|
+
renderer.requestRender();
|
|
324
|
+
};
|
|
325
|
+
box.onMouseOut = () => {
|
|
326
|
+
renderer.setMousePointer("default");
|
|
327
|
+
box.borderColor = handlers.idleBorder;
|
|
328
|
+
handlers.onStateChange?.();
|
|
329
|
+
renderer.requestRender();
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Option-key that keeps a non-interactive `TextRenderable` from swallowing
|
|
335
|
+
* left-presses as a text-selection drag.
|
|
336
|
+
*
|
|
337
|
+
* Every `TextRenderable` (and `BoxRenderable`) defaults to `selectable: true`,
|
|
338
|
+
* and a left-press on a selectable renderable is turned into a selection drag
|
|
339
|
+
* *before* the shell's handlers run — so a click over such text does nothing.
|
|
340
|
+
* Pass this to every purely decorative renderable:
|
|
341
|
+
*
|
|
342
|
+
* ```ts
|
|
343
|
+
* new TextRenderable(renderer, { content: "", selectable: false })
|
|
344
|
+
* ```
|
|
345
|
+
*
|
|
346
|
+
* The query input keeps the default (`true`) so clicking into it still places
|
|
347
|
+
* the cursor.
|
|
348
|
+
*/
|
|
349
|
+
export const NON_INTERACTIVE_TEXT = { selectable: false } as const;
|