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/docs/HOW-TO.md
CHANGED
|
@@ -141,8 +141,9 @@ ghfind "cli" --sort forks
|
|
|
141
141
|
ghfind "cli" --sort best-match # GitHub's own relevance order
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
-
In the TUI, `
|
|
145
|
-
|
|
144
|
+
In the TUI, `Esc` opens the leader menu (or click the <kbd>☰ menu</kbd> button in
|
|
145
|
+
the bottom-right corner), where **Sort** cycles the same strategies without
|
|
146
|
+
leaving the results.
|
|
146
147
|
|
|
147
148
|
---
|
|
148
149
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "github-search-cli",
|
|
3
|
-
"version": "9.
|
|
4
|
-
"description": "Search GitHub from your terminal — repo search, trending repos, npm package search, bookmarks, deep-dive, compare, export. Keyboard
|
|
3
|
+
"version": "9.10.1",
|
|
4
|
+
"description": "Search GitHub from your terminal — repo search, trending repos, npm package search, bookmarks, deep-dive, compare, export. Keyboard + mouse TUI + pipeable CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "bun@1",
|
|
7
7
|
"volta": {
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# v9.10.0
|
|
2
|
+
|
|
3
|
+
ghfind's TUI has always been keyboard-only — and, quietly, half-broken for the
|
|
4
|
+
mouse. OpenTUI was already emitting SGR mouse tracking and hit-testing every
|
|
5
|
+
event into a target renderable. Nothing in ghfind consumed any of it. The
|
|
6
|
+
events were parsed, dispatched, and thrown away. This release makes the TUI
|
|
7
|
+
clickable, and fixes the two keyboard rough edges that were hiding behind it.
|
|
8
|
+
|
|
9
|
+
## The TUI is now fully clickable
|
|
10
|
+
|
|
11
|
+
Click a result and the detail pane updates. Click it again and your browser
|
|
12
|
+
opens. Scroll to move through results. Click outside an overlay to close it.
|
|
13
|
+
|
|
14
|
+
Every list, card, tab and button responds: the results list, the landing mode
|
|
15
|
+
cards, trending tabs, the update dialog's buttons, and every overlay list
|
|
16
|
+
(history, bookmarks, saved searches, topics, export, share, and the command
|
|
17
|
+
menu itself).
|
|
18
|
+
|
|
19
|
+
The important detail is that clicks do not reimplement anything. A click calls
|
|
20
|
+
`setSelectedIndex`, which emits the same `selectionChanged` event `↑`/`↓`
|
|
21
|
+
already emitted, so the detail pane, the detail graph and the packages view all
|
|
22
|
+
work identically without a parallel code path. A double-click calls the same
|
|
23
|
+
activation path as `Enter`. There is no mouse branch to keep in sync — the
|
|
24
|
+
keyboard and the mouse converge on one implementation, which is also why they
|
|
25
|
+
cannot drift.
|
|
26
|
+
|
|
27
|
+
## The wheel scrolled the wrong pane
|
|
28
|
+
|
|
29
|
+
Worth calling out, because it predates this release and was a real bug rather
|
|
30
|
+
than a missing feature.
|
|
31
|
+
|
|
32
|
+
When a scroll event hit nothing, OpenTUI falls back to delivering it to
|
|
33
|
+
whatever renderable currently holds focus. In ghfind that was almost always the
|
|
34
|
+
results list. So opening the README viewer and scrolling would move the
|
|
35
|
+
_result selection_ instead of the README — the one thing you were trying to do.
|
|
36
|
+
|
|
37
|
+
Every scrollable region now claims the wheel explicitly, and the list's wheel
|
|
38
|
+
handler steps the selection through `moveUp`/`moveDown`, so wheel navigation
|
|
39
|
+
and `↑`/`↓` navigation cannot diverge either.
|
|
40
|
+
|
|
41
|
+
## Text that swallowed clicks
|
|
42
|
+
|
|
43
|
+
`TextRenderable` defaults to OpenTUI's `selectable: true`, and a left-press on
|
|
44
|
+
a selectable renderable is consumed as a text-selection drag _before_ the app's
|
|
45
|
+
own handlers run. The detail pane, comparison table, tab bar and other read-only
|
|
46
|
+
text are all `TextRenderable`s, so clicks landing on them did nothing at all.
|
|
47
|
+
|
|
48
|
+
They now opt out of text selection. The query input deliberately keeps it, so
|
|
49
|
+
clicking into the input still places your cursor.
|
|
50
|
+
|
|
51
|
+
## A hover highlight that painted the list magenta
|
|
52
|
+
|
|
53
|
+
Small one, but it would have been immediately obvious to anyone using it.
|
|
54
|
+
OpenTUI's colour parser accepts `"transparent"`, CSS colour names, and 3/4/6/8
|
|
55
|
+
digit hex — nothing else. An `rgba(255,255,255,0.06)` hover tint failed that
|
|
56
|
+
check and fell through to its magenta fallback, so hovering a list would have
|
|
57
|
+
flashed it magenta. Now written as `#ffffff0f`.
|
|
58
|
+
|
|
59
|
+
Found because the test harness prints that warning. Worth noting the class of
|
|
60
|
+
bug: OpenTUI degrades an invalid colour to magenta rather than throwing, so this
|
|
61
|
+
fails silently and visibly at the same time.
|
|
62
|
+
|
|
63
|
+
## `Space` opened the menu only if you had clicked something first
|
|
64
|
+
|
|
65
|
+
The subtlest problem in this release, and it was not caused by mouse support —
|
|
66
|
+
it was exposed by it.
|
|
67
|
+
|
|
68
|
+
The query input is focused by default. The command menu was bound to `Space`,
|
|
69
|
+
gated on the input _not_ being focused, so that spaces could still be typed into
|
|
70
|
+
a query. That gate meant `Space` opened the menu only when focus had moved
|
|
71
|
+
somewhere else. The one way focus moved was clicking a result row: OpenTUI's
|
|
72
|
+
auto-focus-on-press walks up from the pressed renderable to the nearest
|
|
73
|
+
focusable ancestor, finds the results list, and focuses it — blurring the input
|
|
74
|
+
as a side effect.
|
|
75
|
+
|
|
76
|
+
So on a fresh launch, `Space` typed a space. Click any row, and `Space` opened
|
|
77
|
+
the menu. Same key, same screen, different behaviour depending on invisible
|
|
78
|
+
state.
|
|
79
|
+
|
|
80
|
+
A regression test now pins this down from both directions: with `keepFocus` a row
|
|
81
|
+
click does not move focus, and without it one does.
|
|
82
|
+
|
|
83
|
+
The resolution is that **`Esc` opens the command menu**, and `Space` always
|
|
84
|
+
types. `Esc` already closed every overlay, so this adds a meaning rather than
|
|
85
|
+
replacing one. For anyone who prefers not to reach for `Esc`, there is now a
|
|
86
|
+
<kbd>☰ menu</kbd> button in the bottom-right corner — clickable, it floats above
|
|
87
|
+
the layout, and it hides while an overlay is open so it never sits on top of one.
|
|
88
|
+
|
|
89
|
+
## Added
|
|
90
|
+
|
|
91
|
+
- Full mouse support: click to select, double-click to open, wheel to scroll,
|
|
92
|
+
click-outside to dismiss, hover feedback with a pointer cursor.
|
|
93
|
+
- <kbd>☰ menu</kbd> button in the bottom-right corner to open the command menu.
|
|
94
|
+
- A "Mouse" section in the in-app help, documenting every binding.
|
|
95
|
+
- README keybinding tables gained a mouse table; the command-menu references
|
|
96
|
+
across the README and `docs/HOW-TO.md` now say `Esc` instead of `Space`.
|
|
97
|
+
|
|
98
|
+
## Fixed
|
|
99
|
+
|
|
100
|
+
- The scroll wheel scrolled the focused renderable rather than the pane under
|
|
101
|
+
the pointer, so long-pane scrolling moved the result selection instead.
|
|
102
|
+
- Non-interactive text consumed left-presses as text-selection drags, so clicks
|
|
103
|
+
over the detail pane and comparison table did nothing.
|
|
104
|
+
- The hover tint used an `rgba()` string that OpenTUI's colour parser rejects,
|
|
105
|
+
rendering the list magenta.
|
|
106
|
+
- `Space` could not open the command menu on a fresh launch, because it was
|
|
107
|
+
gated on focus that only moved after a mouse click.
|
|
108
|
+
|
|
109
|
+
## Verification
|
|
110
|
+
|
|
111
|
+
The suite grew by 33 tests, to 657.
|
|
112
|
+
|
|
113
|
+
- **15 unit tests** on the click→row geometry: scroll offsets, multi-line rows
|
|
114
|
+
(overlay lists render a description line, so each option is two lines tall),
|
|
115
|
+
`itemSpacing`, clicks past the last row, unmeasured renderables, and empty
|
|
116
|
+
lists. OpenTUI keeps `scrollOffset` and `linesPerItem` private, so this
|
|
117
|
+
arithmetic is read through a single narrow cast — the tests are what keep that
|
|
118
|
+
cast honest.
|
|
119
|
+
- **12 integration tests** driving the real handlers with OpenTUI's mock mouse
|
|
120
|
+
against a live renderer: click-selects, double-click-opens, the double-click
|
|
121
|
+
window expiring, click-outside dismissal, row clicks _not_ leaking to the
|
|
122
|
+
enclosing panel, focus behaviour with and without `keepFocus`, and wheel
|
|
123
|
+
stepping including both ends of the list.
|
|
124
|
+
- **4 button tests** asserting the corner button actually lands in the
|
|
125
|
+
bottom-right, that hidden buttons neither render nor take clicks, and that its
|
|
126
|
+
click does not bubble.
|
|
127
|
+
|
|
128
|
+
The first two geometry tests failed on the run that wrote them, and caught a
|
|
129
|
+
real off-by-one: the empty area below a short list returned "no row" instead of
|
|
130
|
+
resolving to the last row, which would have dismissed an overlay instead of
|
|
131
|
+
selecting its final entry. That is the exact class of bug that would have been
|
|
132
|
+
very annoying and very hard to report.
|
|
133
|
+
|
|
134
|
+
`test`, `format:check`, `lint` (Biome) and `typecheck` (tsc) are all green, and
|
|
135
|
+
`bun run build` succeeds. The TUI was also driven end-to-end in a pty to confirm
|
|
136
|
+
the menu opens from both `Esc` and the corner button, since the mock mouse does
|
|
137
|
+
not exercise a real terminal's mouse reporting.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# v9.10.1
|
|
2
|
+
|
|
3
|
+
A bug-fix release, found by crawling the whole source against its own docs.
|
|
4
|
+
Three of the eight issues were the kind that quietly cost you time: a flag
|
|
5
|
+
that was documented everywhere but never parsed, a typo that opened a GitHub
|
|
6
|
+
issue tab instead of printing an error, and an empty query that came back as
|
|
7
|
+
a network problem you didn't have.
|
|
8
|
+
|
|
9
|
+
## `--interval` was never parsed
|
|
10
|
+
|
|
11
|
+
`--interval` appeared in `--help`, the README, the how-to guide and all
|
|
12
|
+
three shell completions. `parseArgs` had no case for it. So
|
|
13
|
+
`ghfind --watch --interval 60` searched for the literal string
|
|
14
|
+
`"--interval 60"` and polled at the default 300s anyway. It now parses a
|
|
15
|
+
positive whole number of seconds — and rejects anything else with a message
|
|
16
|
+
instead of a mangled query.
|
|
17
|
+
|
|
18
|
+
## A typo used to open a GitHub issue tab
|
|
19
|
+
|
|
20
|
+
`--format jsonn` threw `Unknown format` from inside the run, which the top
|
|
21
|
+
level error handler classified as unexpected: it printed "ghfind encountered
|
|
22
|
+
an unexpected error" and opened a prefilled GitHub issue page in your
|
|
23
|
+
browser. `--pipe clonee` was the opposite failure — it did nothing at all
|
|
24
|
+
and exited 0.
|
|
25
|
+
|
|
26
|
+
Both flags now validate up front, print the list of valid values, and exit 1
|
|
27
|
+
before any search happens.
|
|
28
|
+
|
|
29
|
+
## An empty query was a network error
|
|
30
|
+
|
|
31
|
+
`ghfind --count` with no query sent `q=" "` to GitHub, got a 422 back, and
|
|
32
|
+
surfaced it as `⚠ Network error — check your connection. Retrying in 5s...`
|
|
33
|
+
— a connectivity problem you didn't have, on a loop. The search, format,
|
|
34
|
+
pipe and watch handlers now fail fast with a usage hint instead.
|
|
35
|
+
|
|
36
|
+
## The rest
|
|
37
|
+
|
|
38
|
+
- **Bare `ghfind --trending` opens the trending view.** It used to fall
|
|
39
|
+
through to the landing menu, contradicting TRACKER.md.
|
|
40
|
+
- **`--since` validates its value** (`daily|weekly|monthly`) instead of
|
|
41
|
+
silently falling back to daily — the same treatment `--sort` already had.
|
|
42
|
+
- **Watch mode honors `--since`.** The trending period was derived from the
|
|
43
|
+
query string, so `--watch --trending --since weekly` polled daily. An
|
|
44
|
+
explicit `--since` wins; the query-string convention still works when the
|
|
45
|
+
flag is absent.
|
|
46
|
+
- **The MCP `ghfind_user_profile` tool returns formatted text**, matching
|
|
47
|
+
`ghfind_org_profile` and the CLI's default `ghfind user` output — the raw
|
|
48
|
+
JSON still rides along in `data`.
|
|
49
|
+
- **The trending footer matches its `/` key** (`/ top` — it scrolls to the
|
|
50
|
+
top, it never opened search), and the quit paths of the main TUI, trending
|
|
51
|
+
and package browser now call `renderer.destroy()` before exiting, so the
|
|
52
|
+
terminal is released properly.
|
|
53
|
+
|
|
54
|
+
## Removed
|
|
55
|
+
|
|
56
|
+
Dead code with no importers: `src/search-state.ts` (still referenced the
|
|
57
|
+
pre-unification `config.githubTokens`), `src/export.ts` and `src/pipe.ts`
|
|
58
|
+
(byte-for-byte duplicates of `src/output.ts`, which is what everything
|
|
59
|
+
imports), and the unused `historyCount()` export.
|
package/src/cli.ts
CHANGED
|
@@ -25,6 +25,8 @@ import { reportError } from "./error-report";
|
|
|
25
25
|
import {
|
|
26
26
|
format as formatOutput,
|
|
27
27
|
pipeExec,
|
|
28
|
+
FORMAT_VALUES,
|
|
29
|
+
PIPE_TARGETS,
|
|
28
30
|
type Format,
|
|
29
31
|
type ExportFormat,
|
|
30
32
|
} from "./output";
|
|
@@ -104,6 +106,8 @@ interface CLIFlags {
|
|
|
104
106
|
token?: string;
|
|
105
107
|
trending: boolean;
|
|
106
108
|
since: string;
|
|
109
|
+
/** True when --since was passed (watch falls back to the query-string convention otherwise). */
|
|
110
|
+
sinceExplicit: boolean;
|
|
107
111
|
pipe?: string;
|
|
108
112
|
format?: string;
|
|
109
113
|
watch: boolean;
|
|
@@ -156,6 +160,9 @@ const SORT_VALUES = [
|
|
|
156
160
|
"name",
|
|
157
161
|
];
|
|
158
162
|
|
|
163
|
+
/** Valid `--since` values — the periods github.com/trending accepts. */
|
|
164
|
+
const SINCE_VALUES: readonly string[] = ["daily", "weekly", "monthly"];
|
|
165
|
+
|
|
159
166
|
function parseArgs(args: string[]): CLIFlags {
|
|
160
167
|
const flags: CLIFlags = {
|
|
161
168
|
query: "",
|
|
@@ -168,6 +175,7 @@ function parseArgs(args: string[]): CLIFlags {
|
|
|
168
175
|
token: process.env.GITHUB_TOKEN,
|
|
169
176
|
trending: false,
|
|
170
177
|
since: "daily",
|
|
178
|
+
sinceExplicit: false,
|
|
171
179
|
watch: false,
|
|
172
180
|
releases: false,
|
|
173
181
|
interval: 300,
|
|
@@ -276,15 +284,51 @@ function parseArgs(args: string[]): CLIFlags {
|
|
|
276
284
|
case "--token":
|
|
277
285
|
flags.token = args[++i];
|
|
278
286
|
break;
|
|
279
|
-
case "--
|
|
280
|
-
|
|
287
|
+
case "--interval": {
|
|
288
|
+
const parsed = parseInt(args[++i], 10);
|
|
289
|
+
if (Number.isNaN(parsed) || parsed < 1) {
|
|
290
|
+
console.error(
|
|
291
|
+
`Invalid interval: ${args[i] ?? "(missing)"} (must be a whole number of seconds, e.g. 300).`,
|
|
292
|
+
);
|
|
293
|
+
process.exit(1);
|
|
294
|
+
}
|
|
295
|
+
flags.interval = parsed;
|
|
281
296
|
break;
|
|
282
|
-
|
|
283
|
-
|
|
297
|
+
}
|
|
298
|
+
case "--since": {
|
|
299
|
+
const since = args[++i];
|
|
300
|
+
if (!since || !SINCE_VALUES.includes(since)) {
|
|
301
|
+
console.error(
|
|
302
|
+
`Invalid since: ${since ?? "(missing)"} (use daily|weekly|monthly).`,
|
|
303
|
+
);
|
|
304
|
+
process.exit(1);
|
|
305
|
+
}
|
|
306
|
+
flags.since = since;
|
|
307
|
+
flags.sinceExplicit = true;
|
|
284
308
|
break;
|
|
285
|
-
|
|
286
|
-
|
|
309
|
+
}
|
|
310
|
+
case "--pipe": {
|
|
311
|
+
const target = args[++i];
|
|
312
|
+
if (!target || !PIPE_TARGETS.includes(target)) {
|
|
313
|
+
console.error(
|
|
314
|
+
`Invalid pipe target: ${target ?? "(missing)"} (use open|clone).`,
|
|
315
|
+
);
|
|
316
|
+
process.exit(1);
|
|
317
|
+
}
|
|
318
|
+
flags.pipe = target;
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
case "--format": {
|
|
322
|
+
const fmt = args[++i];
|
|
323
|
+
if (!fmt || !FORMAT_VALUES.includes(fmt)) {
|
|
324
|
+
console.error(
|
|
325
|
+
`Invalid format: ${fmt ?? "(missing)"} (use ${FORMAT_VALUES.join("|")}).`,
|
|
326
|
+
);
|
|
327
|
+
process.exit(1);
|
|
328
|
+
}
|
|
329
|
+
flags.format = fmt;
|
|
287
330
|
break;
|
|
331
|
+
}
|
|
288
332
|
case "--registry":
|
|
289
333
|
flags.registry = args[++i] ?? "npm";
|
|
290
334
|
break;
|
|
@@ -636,7 +680,13 @@ Options:
|
|
|
636
680
|
return;
|
|
637
681
|
}
|
|
638
682
|
|
|
639
|
-
await launchBrowser(
|
|
683
|
+
await launchBrowser(
|
|
684
|
+
flags.theme,
|
|
685
|
+
// `ghfind --trending` with no query/format promises the trending view
|
|
686
|
+
// (TRACKER.md), but the bare flag used to fall through to the landing
|
|
687
|
+
// menu. An explicit flag is explicit intent — skip the menu.
|
|
688
|
+
flags.trending ? "trending" : undefined,
|
|
689
|
+
);
|
|
640
690
|
}
|
|
641
691
|
|
|
642
692
|
interface SearchContext {
|
|
@@ -1197,6 +1247,7 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
|
|
|
1197
1247
|
token: flags.token,
|
|
1198
1248
|
intervalMs: flags.interval * 1000,
|
|
1199
1249
|
trending: flags.trending,
|
|
1250
|
+
since: flags.sinceExplicit ? flags.since : undefined,
|
|
1200
1251
|
},
|
|
1201
1252
|
(repos, delta) => {
|
|
1202
1253
|
tick++;
|
|
@@ -1298,6 +1349,25 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
|
|
|
1298
1349
|
? "format"
|
|
1299
1350
|
: "search";
|
|
1300
1351
|
|
|
1352
|
+
// The search/format/pipe/watch handlers all need a query. Without this
|
|
1353
|
+
// gate an empty query was sent to GitHub as `q=" "`, which returns 422 and
|
|
1354
|
+
// surfaced to the user as a misleading "Network error — check your
|
|
1355
|
+
// connection" retry loop.
|
|
1356
|
+
const needsQuery =
|
|
1357
|
+
key === "search" ||
|
|
1358
|
+
key === "format" ||
|
|
1359
|
+
key === "pipe" ||
|
|
1360
|
+
(key === "watch" && !flags.trending);
|
|
1361
|
+
if (needsQuery && flags.query.trim() === "") {
|
|
1362
|
+
console.error("⚠ No search query given.");
|
|
1363
|
+
console.error(
|
|
1364
|
+
' Usage: ghfind "<query>" [--count|--format <fmt>|--pipe <tgt>|--watch]',
|
|
1365
|
+
);
|
|
1366
|
+
console.error(' Example: ghfind "language:Rust stars:>500" --count');
|
|
1367
|
+
console.error(" Run `ghfind --help` for all options.");
|
|
1368
|
+
process.exit(1);
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1301
1371
|
const ctx = buildSearchContext(flags);
|
|
1302
1372
|
await handlers[key](ctx);
|
|
1303
1373
|
}
|
package/src/help.ts
CHANGED
|
@@ -30,7 +30,7 @@ export function buildHelpSections(): HelpSection[] {
|
|
|
30
30
|
{ keys: "↑ ↓ / j k", action: "Move selection" },
|
|
31
31
|
{ keys: "Enter", action: "Open repo in browser" },
|
|
32
32
|
{ keys: "/", action: "Focus search input" },
|
|
33
|
-
{ keys: "
|
|
33
|
+
{ keys: "Esc", action: "Open command menu" },
|
|
34
34
|
{ keys: "? / Ctrl+H", action: "Toggle this help screen" },
|
|
35
35
|
{ keys: "q", action: "Quit" },
|
|
36
36
|
],
|
|
@@ -65,6 +65,7 @@ export function buildHelpSections(): HelpSection[] {
|
|
|
65
65
|
{ keys: "1-5", action: "Switch tab: Today → Week → Month → …" },
|
|
66
66
|
{ keys: "← → / h l", action: "Navigate tabs" },
|
|
67
67
|
],
|
|
68
|
+
note: "Tabs are also clickable.",
|
|
68
69
|
},
|
|
69
70
|
{
|
|
70
71
|
title: " Packages Mode ",
|
|
@@ -73,12 +74,12 @@ export function buildHelpSections(): HelpSection[] {
|
|
|
73
74
|
{ keys: "/", action: "Search npm packages" },
|
|
74
75
|
{ keys: "↑ ↓ / j k", action: "Move selection" },
|
|
75
76
|
{ keys: "Enter", action: "View package details" },
|
|
76
|
-
{ keys: "
|
|
77
|
+
{ keys: "Esc", action: "Open command menu" },
|
|
77
78
|
],
|
|
78
79
|
note: "Search by name, description, or tags. Sort by downloads or score.",
|
|
79
80
|
},
|
|
80
81
|
{
|
|
81
|
-
title: " Command Menu (
|
|
82
|
+
title: " Command Menu (Esc)",
|
|
82
83
|
titleColor: "purple",
|
|
83
84
|
rows: [
|
|
84
85
|
{ keys: "Sort", action: "Cycle sort: best-match → stars → …" },
|
|
@@ -103,7 +104,7 @@ export function buildHelpSections(): HelpSection[] {
|
|
|
103
104
|
{ keys: "History", action: "View search history" },
|
|
104
105
|
{ keys: "Help", action: "Show this help screen" },
|
|
105
106
|
],
|
|
106
|
-
note: "Navigate menu with ↑ ↓, select with Enter, back with ← / Esc",
|
|
107
|
+
note: "Navigate menu with ↑ ↓, select with Enter, back with ← / Esc. Clicking a row runs it directly. Esc (or the ☰ menu button) opens this menu; Space types a space in the query.",
|
|
107
108
|
},
|
|
108
109
|
{
|
|
109
110
|
title: " Bookmarks Page ",
|
|
@@ -116,6 +117,22 @@ export function buildHelpSections(): HelpSection[] {
|
|
|
116
117
|
],
|
|
117
118
|
note: "A full page, not an overlay — Esc returns to the mode menu.",
|
|
118
119
|
},
|
|
120
|
+
{
|
|
121
|
+
title: " Mouse ",
|
|
122
|
+
titleColor: "green",
|
|
123
|
+
rows: [
|
|
124
|
+
{ keys: "Click", action: "Select a row / card / tab" },
|
|
125
|
+
{ keys: "Double-click", action: "Open the repo or run the action" },
|
|
126
|
+
{ keys: "Scroll wheel", action: "Move the selection / scroll a pane" },
|
|
127
|
+
{ keys: "Click outside", action: "Dismiss an open overlay" },
|
|
128
|
+
{ keys: "Click query box", action: "Focus the search input" },
|
|
129
|
+
{
|
|
130
|
+
keys: "☰ menu button",
|
|
131
|
+
action: "Open the command menu (bottom-right)",
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
note: "Every list is fully clickable; keyboard and mouse are interchangeable.",
|
|
135
|
+
},
|
|
119
136
|
{
|
|
120
137
|
title: " Overlay Keys ",
|
|
121
138
|
titleColor: "teal",
|
package/src/history.ts
CHANGED
|
@@ -29,6 +29,10 @@ export function readHistory(): HistoryEntry[] {
|
|
|
29
29
|
|
|
30
30
|
/** Delete a single history entry by index (0 = newest). */
|
|
31
31
|
export function deleteHistoryEntry(index: number): void {
|
|
32
|
+
// The index comes from the overlay, which lists readHistory()'s deduplicated
|
|
33
|
+
// newest-first view — so delete against that same view. Raw lines can't be
|
|
34
|
+
// addressed by this index: consecutive duplicate queries are collapsed in
|
|
35
|
+
// the view, which shifts every raw-line position after them.
|
|
32
36
|
const entries = readHistory();
|
|
33
37
|
if (index < 0 || index >= entries.length) return;
|
|
34
38
|
entries.splice(index, 1);
|
|
@@ -59,8 +63,3 @@ export function rotateHistory(): void {
|
|
|
59
63
|
.join("\n")}\n`,
|
|
60
64
|
);
|
|
61
65
|
}
|
|
62
|
-
|
|
63
|
-
/** Count entries in history file. */
|
|
64
|
-
export function historyCount(): number {
|
|
65
|
-
return readJSONL<HistoryEntry>(HISTORY_FILE).length;
|
|
66
|
-
}
|
package/src/mcp-server.ts
CHANGED
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
type PackageSortMode,
|
|
39
39
|
} from "./package";
|
|
40
40
|
import { fetchOrgProfile, formatOrgText } from "./org";
|
|
41
|
-
import { fetchUserProfile,
|
|
41
|
+
import { fetchUserProfile, formatUserText } from "./user";
|
|
42
42
|
import {
|
|
43
43
|
fetchDeepDive,
|
|
44
44
|
buildDeepDiveText,
|
|
@@ -511,7 +511,9 @@ const TOOLS: ToolDefinition[] = [
|
|
|
511
511
|
token: resolveToken(state, args.token),
|
|
512
512
|
limit,
|
|
513
513
|
});
|
|
514
|
-
|
|
514
|
+
// Human-readable text like ghfind_org_profile (the structured data
|
|
515
|
+
// still rides along in `data` for callers that want the raw numbers).
|
|
516
|
+
return { text: formatUserText(profile), data: { ...profile } };
|
|
515
517
|
},
|
|
516
518
|
},
|
|
517
519
|
{
|
package/src/output.ts
CHANGED
|
@@ -22,8 +22,11 @@ export type Format =
|
|
|
22
22
|
|
|
23
23
|
export type FormatFn = (repos: Repo[]) => string;
|
|
24
24
|
|
|
25
|
+
/** Accepted `--pipe` targets — the cases `pipeExec` actually handles. */
|
|
26
|
+
export const PIPE_TARGETS: readonly string[] = ["open", "clone"];
|
|
27
|
+
|
|
25
28
|
/** Registry of all format functions. Adding a format = 1 line here. */
|
|
26
|
-
const FORMATS: Map<Format, FormatFn> = new Map([
|
|
29
|
+
const FORMATS: Map<Format, FormatFn> = new Map<Format, FormatFn>([
|
|
27
30
|
// Export formats
|
|
28
31
|
["json", formatJson],
|
|
29
32
|
["csv", formatCsv],
|
|
@@ -37,6 +40,13 @@ const FORMATS: Map<Format, FormatFn> = new Map([
|
|
|
37
40
|
["ids", formatIds],
|
|
38
41
|
]);
|
|
39
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Every accepted `--format` value, derived from the registry above so the CLI
|
|
45
|
+
* can validate up front instead of throwing `Unknown format` mid-run (a plain
|
|
46
|
+
* typo used to route into the crash-report flow).
|
|
47
|
+
*/
|
|
48
|
+
export const FORMAT_VALUES: readonly string[] = [...FORMATS.keys()];
|
|
49
|
+
|
|
40
50
|
// ── Export format functions ────────────────────────────────────────────
|
|
41
51
|
|
|
42
52
|
export function formatJson(repos: Repo[]): string {
|
package/src/package-browser.ts
CHANGED
|
@@ -265,6 +265,8 @@ export async function launchPackageBrowser(): Promise<void> {
|
|
|
265
265
|
renderer.keyInput.on("keypress", (key: import("@opentui/core").KeyEvent) => {
|
|
266
266
|
const name = key.name || "";
|
|
267
267
|
if (name === "q" || name === "escape") {
|
|
268
|
+
// Release the terminal before the hard exit, same as the main TUI.
|
|
269
|
+
renderer.destroy();
|
|
268
270
|
process.exit(0);
|
|
269
271
|
return;
|
|
270
272
|
}
|
package/src/status.ts
CHANGED
|
@@ -12,12 +12,16 @@ export class StatusManager {
|
|
|
12
12
|
private type: StatusType = "idle";
|
|
13
13
|
private message = "";
|
|
14
14
|
private onUpdate: (text: string) => void;
|
|
15
|
+
private disposed = false;
|
|
15
16
|
|
|
16
17
|
constructor(onUpdate: (text: string) => void) {
|
|
17
18
|
this.onUpdate = onUpdate;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
set(type: StatusType, message: string): void {
|
|
22
|
+
// Late async continuations (a search resolving after quit) can call
|
|
23
|
+
// set() long after the UI is gone; a disposed manager is inert.
|
|
24
|
+
if (this.disposed) return;
|
|
21
25
|
this.stopSpinner();
|
|
22
26
|
this.type = type;
|
|
23
27
|
this.message = message;
|
|
@@ -32,6 +36,7 @@ export class StatusManager {
|
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
private startSpinner(frames: string[]): void {
|
|
39
|
+
if (this.disposed) return;
|
|
35
40
|
this.spinnerIndex = 0;
|
|
36
41
|
this.emit(frames[0]);
|
|
37
42
|
this.spinnerInterval = setInterval(() => {
|
|
@@ -48,6 +53,8 @@ export class StatusManager {
|
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
private emit(spinner?: string): void {
|
|
56
|
+
// A tick can already be queued when dispose() clears the interval.
|
|
57
|
+
if (this.disposed) return;
|
|
51
58
|
const icon =
|
|
52
59
|
this.type === "searching"
|
|
53
60
|
? "🔍"
|
|
@@ -63,6 +70,39 @@ export class StatusManager {
|
|
|
63
70
|
}
|
|
64
71
|
|
|
65
72
|
dispose(): void {
|
|
73
|
+
this.disposed = true;
|
|
66
74
|
this.stopSpinner();
|
|
67
75
|
}
|
|
68
76
|
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The status-bar renderable, narrowed to what the manager needs. TextRenderable
|
|
80
|
+
* satisfies this structurally (isDestroyed is a public getter on Renderable).
|
|
81
|
+
*/
|
|
82
|
+
export interface StatusBarTarget {
|
|
83
|
+
readonly isDestroyed: boolean;
|
|
84
|
+
// Accessor pair, not `content: string`: TextRenderable's getter widens to
|
|
85
|
+
// StyledText while its setter accepts a plain string. `unknown` on the read
|
|
86
|
+
// side keeps the renderable assignable; we only ever write strings.
|
|
87
|
+
get content(): unknown;
|
|
88
|
+
set content(value: string);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build a StatusManager wired to a status bar, plus a hook to request a frame.
|
|
93
|
+
*
|
|
94
|
+
* Writes are dropped once the renderable is destroyed. The renderer tears its
|
|
95
|
+
* tree down on Ctrl+C / SIGTERM / terminal close but leaves the event loop
|
|
96
|
+
* running, so an unguarded spinner tick would assign to a destroyed TextBuffer
|
|
97
|
+
* and throw out of a timer callback, taking the whole process with it.
|
|
98
|
+
*/
|
|
99
|
+
export function createStatusManager(
|
|
100
|
+
bar: StatusBarTarget,
|
|
101
|
+
onFrame: () => void,
|
|
102
|
+
): StatusManager {
|
|
103
|
+
return new StatusManager((text) => {
|
|
104
|
+
if (bar.isDestroyed) return;
|
|
105
|
+
bar.content = ` ${text}`;
|
|
106
|
+
onFrame();
|
|
107
|
+
});
|
|
108
|
+
}
|
package/src/trending.ts
CHANGED
|
@@ -398,7 +398,7 @@ export async function launchTrending(): Promise<void> {
|
|
|
398
398
|
paddingX: 1,
|
|
399
399
|
});
|
|
400
400
|
const footerText = new TextRenderable(renderer, {
|
|
401
|
-
content: " ↑↓ navigate ↵ open /
|
|
401
|
+
content: " ↑↓ navigate ↵ open / top 1-3 tab q quit r refresh",
|
|
402
402
|
fg: C.muted,
|
|
403
403
|
height: 1,
|
|
404
404
|
});
|
|
@@ -430,6 +430,8 @@ export async function launchTrending(): Promise<void> {
|
|
|
430
430
|
renderer.keyInput.on("keypress", (key) => {
|
|
431
431
|
const name = key.name || "";
|
|
432
432
|
if (name === "q") {
|
|
433
|
+
// Release the terminal before the hard exit, same as the main TUI.
|
|
434
|
+
renderer.destroy();
|
|
433
435
|
process.exit(0);
|
|
434
436
|
return;
|
|
435
437
|
}
|