shellwise 0.1.0
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/README.md +141 -0
- package/bin/sw.js +16 -0
- package/package.json +46 -0
- package/scripts/setup.sh +92 -0
- package/scripts/uninstall.sh +36 -0
- package/src/cli/add.ts +40 -0
- package/src/cli/import.ts +76 -0
- package/src/cli/init.ts +325 -0
- package/src/cli/prune.ts +6 -0
- package/src/cli/search.ts +291 -0
- package/src/cli/stats.ts +42 -0
- package/src/cli/suggest.ts +60 -0
- package/src/daemon/client.ts +41 -0
- package/src/daemon/protocol.ts +89 -0
- package/src/daemon/server.ts +222 -0
- package/src/data/common-commands.ts +276 -0
- package/src/db/connection.ts +29 -0
- package/src/db/queries.ts +181 -0
- package/src/db/schema.ts +58 -0
- package/src/index.ts +207 -0
- package/src/search/fuzzy.ts +77 -0
- package/src/search/index.ts +59 -0
- package/src/search/scorer.ts +71 -0
- package/src/tui/components/result-list.ts +106 -0
- package/src/tui/components/search-box.ts +20 -0
- package/src/tui/components/status-bar.ts +10 -0
- package/src/tui/input.ts +71 -0
- package/src/tui/renderer.ts +45 -0
- package/src/tui/theme.ts +34 -0
- package/src/utils/paths.ts +27 -0
- package/src/utils/platform.ts +13 -0
package/src/cli/init.ts
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
export function runInit(shell: string, binaryPath: string): void {
|
|
2
|
+
switch (shell) {
|
|
3
|
+
case "zsh":
|
|
4
|
+
process.stdout.write(generateZshScript(binaryPath));
|
|
5
|
+
break;
|
|
6
|
+
case "bash":
|
|
7
|
+
process.stdout.write(generateBashScript(binaryPath));
|
|
8
|
+
break;
|
|
9
|
+
default:
|
|
10
|
+
console.error(`Unsupported shell: ${shell}. Supported: zsh, bash`);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function generateZshScript(bin: string): string {
|
|
16
|
+
return `
|
|
17
|
+
# --- shellwise shell integration ---
|
|
18
|
+
|
|
19
|
+
# Session tracking
|
|
20
|
+
export SW_SESSION_ID="\$(command uuidgen 2>/dev/null || echo "\$\$-\$RANDOM")"
|
|
21
|
+
|
|
22
|
+
# State
|
|
23
|
+
typeset -g __sw_prev_buffer=""
|
|
24
|
+
typeset -ga __sw_suggestions=()
|
|
25
|
+
typeset -g __sw_selected=0
|
|
26
|
+
typeset -g __sw_fd=""
|
|
27
|
+
typeset -g __sw_ready=0
|
|
28
|
+
|
|
29
|
+
# Load TCP module + connect at shell startup
|
|
30
|
+
zmodload zsh/net/tcp 2>/dev/null && {
|
|
31
|
+
# Read daemon port from PID file
|
|
32
|
+
local __sw_pidfile="/tmp/shellwise-\${UID}.pid"
|
|
33
|
+
if [[ ! -f "\$__sw_pidfile" ]]; then
|
|
34
|
+
# Start daemon in background (non-blocking)
|
|
35
|
+
command ${bin} daemon start &>/dev/null &!
|
|
36
|
+
sleep 0.3
|
|
37
|
+
fi
|
|
38
|
+
if [[ -f "\$__sw_pidfile" ]]; then
|
|
39
|
+
local __sw_port=\${"\$(sed -n 2p "\$__sw_pidfile")":-0}
|
|
40
|
+
if [[ \$__sw_port -gt 0 ]]; then
|
|
41
|
+
ztcp 127.0.0.1 \$__sw_port 2>/dev/null && {
|
|
42
|
+
__sw_fd=\$REPLY
|
|
43
|
+
__sw_ready=1
|
|
44
|
+
}
|
|
45
|
+
fi
|
|
46
|
+
fi
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# ─── Persistent TCP query (no connect/disconnect overhead) ─
|
|
50
|
+
|
|
51
|
+
typeset -ga __sw_tcp_result=()
|
|
52
|
+
|
|
53
|
+
__sw_tcp_query() {
|
|
54
|
+
__sw_tcp_result=()
|
|
55
|
+
[[ \$__sw_ready -ne 1 ]] && return 1
|
|
56
|
+
|
|
57
|
+
# Send request
|
|
58
|
+
print -u \$__sw_fd "\$1" 2>/dev/null || {
|
|
59
|
+
# Connection broken — mark unavailable
|
|
60
|
+
__sw_ready=0
|
|
61
|
+
return 1
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Read response lines until empty line
|
|
65
|
+
local line
|
|
66
|
+
while IFS= read -r -t 0.1 -u \$__sw_fd line 2>/dev/null; do
|
|
67
|
+
[[ -z "\$line" ]] && break
|
|
68
|
+
__sw_tcp_result+=("\$line")
|
|
69
|
+
done
|
|
70
|
+
|
|
71
|
+
[[ \${#__sw_tcp_result} -gt 0 ]]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# ─── Command Capture (auto-save) ───────────────────────────
|
|
75
|
+
|
|
76
|
+
__sw_preexec() {
|
|
77
|
+
export __SW_START_TIME=\$EPOCHREALTIME
|
|
78
|
+
export __SW_COMMAND="\$1"
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
__sw_precmd() {
|
|
82
|
+
local exit_code=\$?
|
|
83
|
+
if [[ -n "\$__SW_COMMAND" ]]; then
|
|
84
|
+
local duration=0
|
|
85
|
+
if [[ -n "\$__SW_START_TIME" ]]; then
|
|
86
|
+
duration=\$(( (EPOCHREALTIME - __SW_START_TIME) * 1000 ))
|
|
87
|
+
duration=\${duration%%.*}
|
|
88
|
+
fi
|
|
89
|
+
|
|
90
|
+
# Save via persistent TCP (instant) or fallback to background process
|
|
91
|
+
__sw_tcp_query "ADD\\t\$__SW_COMMAND\\t\$PWD\\t\$exit_code\\t\$duration\\t\$SW_SESSION_ID\\tzsh" || \\
|
|
92
|
+
command ${bin} add \\
|
|
93
|
+
--command "\$__SW_COMMAND" \\
|
|
94
|
+
--cwd "\$PWD" \\
|
|
95
|
+
--exit-code "\$exit_code" \\
|
|
96
|
+
--duration "\$duration" \\
|
|
97
|
+
--session "\$SW_SESSION_ID" \\
|
|
98
|
+
--shell "zsh" &!
|
|
99
|
+
|
|
100
|
+
unset __SW_COMMAND __SW_START_TIME
|
|
101
|
+
fi
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# ─── Render dropdown ───────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
__sw_render() {
|
|
107
|
+
POSTDISPLAY=""
|
|
108
|
+
region_highlight=()
|
|
109
|
+
|
|
110
|
+
[[ \${#__sw_suggestions} -eq 0 ]] && return
|
|
111
|
+
|
|
112
|
+
local buf_len=\${#BUFFER}
|
|
113
|
+
local offset=\$buf_len
|
|
114
|
+
|
|
115
|
+
local i
|
|
116
|
+
for (( i=1; i<=\${#__sw_suggestions}; i++ )); do
|
|
117
|
+
local item="\${__sw_suggestions[\$i]}"
|
|
118
|
+
local marker=" "
|
|
119
|
+
if [[ \$(( i - 1 )) -eq \$__sw_selected ]]; then
|
|
120
|
+
marker="› "
|
|
121
|
+
fi
|
|
122
|
+
local line=\$'\\n'" \${marker}\${item}"
|
|
123
|
+
local start=\$offset
|
|
124
|
+
POSTDISPLAY+="\$line"
|
|
125
|
+
offset=\$(( offset + \${#line} ))
|
|
126
|
+
|
|
127
|
+
if [[ \$(( i - 1 )) -eq \$__sw_selected ]]; then
|
|
128
|
+
region_highlight+=("\$start \$offset fg=cyan,bold")
|
|
129
|
+
else
|
|
130
|
+
region_highlight+=("\$start \$offset fg=245")
|
|
131
|
+
fi
|
|
132
|
+
done
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
# ─── Auto-suggest (zero-fork, never blocks typing) ───────
|
|
136
|
+
|
|
137
|
+
__sw_suggest() {
|
|
138
|
+
[[ "\$BUFFER" == "\$__sw_prev_buffer" ]] && return
|
|
139
|
+
__sw_prev_buffer="\$BUFFER"
|
|
140
|
+
__sw_selected=0
|
|
141
|
+
__sw_suggestions=()
|
|
142
|
+
POSTDISPLAY=""
|
|
143
|
+
region_highlight=()
|
|
144
|
+
|
|
145
|
+
[[ \${#BUFFER} -lt 2 ]] && return
|
|
146
|
+
|
|
147
|
+
# TCP query only — no fallback, never spawn process during typing
|
|
148
|
+
__sw_tcp_query "SUGGEST\\t\$BUFFER\\t5" || return
|
|
149
|
+
|
|
150
|
+
__sw_suggestions=("\${__sw_tcp_result[@]}")
|
|
151
|
+
__sw_render
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# ─── Widget wrappers (trigger suggest on keystroke) ────────
|
|
155
|
+
|
|
156
|
+
__sw_self_insert() {
|
|
157
|
+
zle .self-insert
|
|
158
|
+
__sw_suggest
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
__sw_backward_delete_char() {
|
|
162
|
+
zle .backward-delete-char
|
|
163
|
+
__sw_suggest
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
__sw_backward_kill_word() {
|
|
167
|
+
zle .backward-kill-word
|
|
168
|
+
__sw_suggest
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# ─── Tab: next result ──────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
__sw_next() {
|
|
174
|
+
if [[ \${#__sw_suggestions} -gt 0 ]]; then
|
|
175
|
+
__sw_selected=\$(( (__sw_selected + 1) % \${#__sw_suggestions} ))
|
|
176
|
+
__sw_render
|
|
177
|
+
else
|
|
178
|
+
zle expand-or-complete
|
|
179
|
+
fi
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
# ─── Shift+Tab: previous result ────────────────────────────
|
|
183
|
+
|
|
184
|
+
__sw_prev() {
|
|
185
|
+
if [[ \${#__sw_suggestions} -gt 0 ]]; then
|
|
186
|
+
__sw_selected=\$(( (__sw_selected - 1 + \${#__sw_suggestions}) % \${#__sw_suggestions} ))
|
|
187
|
+
__sw_render
|
|
188
|
+
else
|
|
189
|
+
zle .reverse-menu-complete
|
|
190
|
+
fi
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
# ─── Enter: accept selected or execute ─────────────────────
|
|
194
|
+
|
|
195
|
+
__sw_accept_line() {
|
|
196
|
+
if [[ \${#__sw_suggestions} -gt 0 ]]; then
|
|
197
|
+
BUFFER="\${__sw_suggestions[\$(( __sw_selected + 1 ))]}"
|
|
198
|
+
CURSOR=\${#BUFFER}
|
|
199
|
+
POSTDISPLAY=""
|
|
200
|
+
region_highlight=()
|
|
201
|
+
__sw_suggestions=()
|
|
202
|
+
__sw_prev_buffer="\$BUFFER"
|
|
203
|
+
else
|
|
204
|
+
zle .accept-line
|
|
205
|
+
fi
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
# ─── Escape: clear suggestions ─────────────────────────────
|
|
209
|
+
|
|
210
|
+
__sw_dismiss() {
|
|
211
|
+
if [[ \${#__sw_suggestions} -gt 0 ]]; then
|
|
212
|
+
POSTDISPLAY=""
|
|
213
|
+
region_highlight=()
|
|
214
|
+
__sw_suggestions=()
|
|
215
|
+
__sw_prev_buffer="\$BUFFER"
|
|
216
|
+
else
|
|
217
|
+
zle .send-break
|
|
218
|
+
fi
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
# ─── Right arrow: accept top suggestion inline ─────────────
|
|
222
|
+
|
|
223
|
+
__sw_forward_char() {
|
|
224
|
+
if [[ \${#__sw_suggestions} -gt 0 && \$CURSOR -eq \${#BUFFER} ]]; then
|
|
225
|
+
BUFFER="\${__sw_suggestions[\$(( __sw_selected + 1 ))]}"
|
|
226
|
+
CURSOR=\${#BUFFER}
|
|
227
|
+
POSTDISPLAY=""
|
|
228
|
+
region_highlight=()
|
|
229
|
+
__sw_suggestions=()
|
|
230
|
+
__sw_prev_buffer="\$BUFFER"
|
|
231
|
+
else
|
|
232
|
+
zle .forward-char
|
|
233
|
+
fi
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
# ─── Ctrl+R: full interactive search ───────────────────────
|
|
237
|
+
|
|
238
|
+
__sw_search_widget() {
|
|
239
|
+
POSTDISPLAY=""
|
|
240
|
+
region_highlight=()
|
|
241
|
+
__sw_suggestions=()
|
|
242
|
+
local selected
|
|
243
|
+
selected="\$(command ${bin} search --query "\$LBUFFER" </dev/tty 2>/dev/tty)"
|
|
244
|
+
local ret=\$?
|
|
245
|
+
if [[ \$ret -eq 0 && -n "\$selected" ]]; then
|
|
246
|
+
BUFFER="\$selected"
|
|
247
|
+
CURSOR=\${#BUFFER}
|
|
248
|
+
fi
|
|
249
|
+
__sw_prev_buffer="\$BUFFER"
|
|
250
|
+
zle reset-prompt
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
# ─── Register widgets & bindings ───────────────────────────
|
|
254
|
+
|
|
255
|
+
zle -N self-insert __sw_self_insert
|
|
256
|
+
zle -N backward-delete-char __sw_backward_delete_char
|
|
257
|
+
zle -N backward-kill-word __sw_backward_kill_word
|
|
258
|
+
zle -N __sw_next
|
|
259
|
+
zle -N __sw_prev
|
|
260
|
+
zle -N __sw_accept_line
|
|
261
|
+
zle -N __sw_dismiss
|
|
262
|
+
zle -N __sw_forward_char
|
|
263
|
+
zle -N __sw_search_widget
|
|
264
|
+
|
|
265
|
+
autoload -Uz add-zsh-hook
|
|
266
|
+
add-zsh-hook preexec __sw_preexec
|
|
267
|
+
add-zsh-hook precmd __sw_precmd
|
|
268
|
+
|
|
269
|
+
bindkey '^R' __sw_search_widget
|
|
270
|
+
bindkey '\\t' __sw_next
|
|
271
|
+
bindkey '^[[Z' __sw_prev
|
|
272
|
+
bindkey '^M' __sw_accept_line
|
|
273
|
+
bindkey '^[' __sw_dismiss
|
|
274
|
+
bindkey '^[[C' __sw_forward_char
|
|
275
|
+
bindkey '^[OC' __sw_forward_char
|
|
276
|
+
`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function generateBashScript(bin: string): string {
|
|
280
|
+
return `
|
|
281
|
+
# --- shellwise shell integration ---
|
|
282
|
+
|
|
283
|
+
export SW_SESSION_ID="\$(command uuidgen 2>/dev/null || echo "\$\$-\$RANDOM")"
|
|
284
|
+
|
|
285
|
+
# ─── Command Capture ───────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
__sw_preexec() {
|
|
288
|
+
export __SW_START_TIME=\$SECONDS
|
|
289
|
+
export __SW_COMMAND="\$(HISTTIMEFORMAT= history 1 | sed 's/^[ ]*[0-9]*[ ]*//')"
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
__sw_precmd() {
|
|
293
|
+
local exit_code=\$?
|
|
294
|
+
if [[ -n "\$__SW_COMMAND" ]]; then
|
|
295
|
+
local duration=\$(( (SECONDS - __SW_START_TIME) * 1000 ))
|
|
296
|
+
|
|
297
|
+
command ${bin} add \\
|
|
298
|
+
--command "\$__SW_COMMAND" \\
|
|
299
|
+
--cwd "\$PWD" \\
|
|
300
|
+
--exit-code "\$exit_code" \\
|
|
301
|
+
--duration "\$duration" \\
|
|
302
|
+
--session "\$SW_SESSION_ID" \\
|
|
303
|
+
--shell "bash" &
|
|
304
|
+
|
|
305
|
+
unset __SW_COMMAND __SW_START_TIME
|
|
306
|
+
fi
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
PROMPT_COMMAND="__sw_precmd;\${PROMPT_COMMAND}"
|
|
310
|
+
trap '__sw_preexec' DEBUG
|
|
311
|
+
|
|
312
|
+
# ─── Ctrl+R: interactive search ────────────────────────────
|
|
313
|
+
|
|
314
|
+
__sw_search() {
|
|
315
|
+
local selected
|
|
316
|
+
selected="\$(command ${bin} search --query "\$READLINE_LINE" </dev/tty 2>/dev/tty)"
|
|
317
|
+
if [[ -n "\$selected" ]]; then
|
|
318
|
+
READLINE_LINE="\$selected"
|
|
319
|
+
READLINE_POINT=\${#READLINE_LINE}
|
|
320
|
+
fi
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
bind -x '"\\C-r": __sw_search'
|
|
324
|
+
`;
|
|
325
|
+
}
|
package/src/cli/prune.ts
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { search, type ScoredResult } from "../search";
|
|
2
|
+
import { enableRawMode, disableRawMode, parseKeypress } from "../tui/input";
|
|
3
|
+
import {
|
|
4
|
+
write,
|
|
5
|
+
clearLine,
|
|
6
|
+
moveCursorUp,
|
|
7
|
+
moveCursorToColumn,
|
|
8
|
+
hideCursor,
|
|
9
|
+
showCursor,
|
|
10
|
+
getTerminalSize,
|
|
11
|
+
clearDown,
|
|
12
|
+
} from "../tui/renderer";
|
|
13
|
+
import { renderSearchBox, getSearchBoxCursorCol } from "../tui/components/search-box";
|
|
14
|
+
import { renderResultList } from "../tui/components/result-list";
|
|
15
|
+
import { renderStatusBar } from "../tui/components/status-bar";
|
|
16
|
+
|
|
17
|
+
interface SearchState {
|
|
18
|
+
query: string;
|
|
19
|
+
cursorPos: number;
|
|
20
|
+
results: ScoredResult[];
|
|
21
|
+
selectedIndex: number;
|
|
22
|
+
scrollOffset: number;
|
|
23
|
+
renderedLines: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function runSearch(initialQuery: string = ""): Promise<void> {
|
|
27
|
+
const cwd = process.env.PWD || process.cwd();
|
|
28
|
+
|
|
29
|
+
const state: SearchState = {
|
|
30
|
+
query: initialQuery,
|
|
31
|
+
cursorPos: initialQuery.length,
|
|
32
|
+
results: [],
|
|
33
|
+
selectedIndex: 0,
|
|
34
|
+
scrollOffset: 0,
|
|
35
|
+
renderedLines: 0,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Initial search
|
|
39
|
+
state.results = search({ query: state.query, cwd });
|
|
40
|
+
|
|
41
|
+
// Setup
|
|
42
|
+
enableRawMode();
|
|
43
|
+
write(hideCursor());
|
|
44
|
+
|
|
45
|
+
const cleanup = () => {
|
|
46
|
+
// Clear rendered UI
|
|
47
|
+
if (state.renderedLines > 0) {
|
|
48
|
+
write(moveCursorUp(state.renderedLines));
|
|
49
|
+
}
|
|
50
|
+
write(clearDown());
|
|
51
|
+
write(showCursor());
|
|
52
|
+
write(moveCursorToColumn(1));
|
|
53
|
+
disableRawMode();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Handle unexpected exit
|
|
57
|
+
const onExit = () => {
|
|
58
|
+
cleanup();
|
|
59
|
+
process.exit(0);
|
|
60
|
+
};
|
|
61
|
+
process.on("SIGINT", onExit);
|
|
62
|
+
process.on("SIGTERM", onExit);
|
|
63
|
+
|
|
64
|
+
// Render initial frame
|
|
65
|
+
render(state);
|
|
66
|
+
|
|
67
|
+
// Input loop
|
|
68
|
+
try {
|
|
69
|
+
for await (const chunk of readStdin()) {
|
|
70
|
+
const key = parseKeypress(chunk);
|
|
71
|
+
|
|
72
|
+
if (key.type === "special" && key.key === "escape") {
|
|
73
|
+
cleanup();
|
|
74
|
+
// Output nothing = cancel
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (key.type === "ctrl" && key.char === "c") {
|
|
79
|
+
cleanup();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (key.type === "special" && key.key === "enter") {
|
|
84
|
+
const selected = state.results[state.selectedIndex];
|
|
85
|
+
cleanup();
|
|
86
|
+
if (selected) {
|
|
87
|
+
// Output to stdout for shell to capture
|
|
88
|
+
process.stdout.write(selected.command);
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let needsSearch = false;
|
|
94
|
+
|
|
95
|
+
if (key.type === "char") {
|
|
96
|
+
state.query =
|
|
97
|
+
state.query.slice(0, state.cursorPos) +
|
|
98
|
+
key.char +
|
|
99
|
+
state.query.slice(state.cursorPos);
|
|
100
|
+
state.cursorPos += key.char.length;
|
|
101
|
+
needsSearch = true;
|
|
102
|
+
} else if (key.type === "special") {
|
|
103
|
+
switch (key.key) {
|
|
104
|
+
case "backspace":
|
|
105
|
+
if (state.cursorPos > 0) {
|
|
106
|
+
state.query =
|
|
107
|
+
state.query.slice(0, state.cursorPos - 1) +
|
|
108
|
+
state.query.slice(state.cursorPos);
|
|
109
|
+
state.cursorPos--;
|
|
110
|
+
needsSearch = true;
|
|
111
|
+
}
|
|
112
|
+
break;
|
|
113
|
+
|
|
114
|
+
case "delete":
|
|
115
|
+
if (state.cursorPos < state.query.length) {
|
|
116
|
+
state.query =
|
|
117
|
+
state.query.slice(0, state.cursorPos) +
|
|
118
|
+
state.query.slice(state.cursorPos + 1);
|
|
119
|
+
needsSearch = true;
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
|
|
123
|
+
case "left":
|
|
124
|
+
if (state.cursorPos > 0) state.cursorPos--;
|
|
125
|
+
break;
|
|
126
|
+
|
|
127
|
+
case "right":
|
|
128
|
+
if (state.cursorPos < state.query.length) state.cursorPos++;
|
|
129
|
+
break;
|
|
130
|
+
|
|
131
|
+
case "home":
|
|
132
|
+
state.cursorPos = 0;
|
|
133
|
+
break;
|
|
134
|
+
|
|
135
|
+
case "end":
|
|
136
|
+
state.cursorPos = state.query.length;
|
|
137
|
+
break;
|
|
138
|
+
|
|
139
|
+
case "tab":
|
|
140
|
+
case "down":
|
|
141
|
+
if (state.results.length > 0) {
|
|
142
|
+
state.selectedIndex = (state.selectedIndex + 1) % state.results.length;
|
|
143
|
+
adjustScroll(state);
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
|
|
147
|
+
case "shift-tab":
|
|
148
|
+
case "up":
|
|
149
|
+
if (state.results.length > 0) {
|
|
150
|
+
state.selectedIndex =
|
|
151
|
+
(state.selectedIndex - 1 + state.results.length) % state.results.length;
|
|
152
|
+
adjustScroll(state);
|
|
153
|
+
}
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
} else if (key.type === "ctrl") {
|
|
157
|
+
switch (key.char) {
|
|
158
|
+
case "a":
|
|
159
|
+
state.cursorPos = 0;
|
|
160
|
+
break;
|
|
161
|
+
case "e":
|
|
162
|
+
state.cursorPos = state.query.length;
|
|
163
|
+
break;
|
|
164
|
+
case "u":
|
|
165
|
+
state.query = state.query.slice(state.cursorPos);
|
|
166
|
+
state.cursorPos = 0;
|
|
167
|
+
needsSearch = true;
|
|
168
|
+
break;
|
|
169
|
+
case "w": {
|
|
170
|
+
// Delete word backward
|
|
171
|
+
const before = state.query.slice(0, state.cursorPos);
|
|
172
|
+
const trimmed = before.replace(/\S+\s*$/, "");
|
|
173
|
+
state.query = trimmed + state.query.slice(state.cursorPos);
|
|
174
|
+
state.cursorPos = trimmed.length;
|
|
175
|
+
needsSearch = true;
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (needsSearch) {
|
|
182
|
+
state.results = search({ query: state.query, cwd });
|
|
183
|
+
state.selectedIndex = 0;
|
|
184
|
+
state.scrollOffset = 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
render(state);
|
|
188
|
+
}
|
|
189
|
+
} finally {
|
|
190
|
+
cleanup();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function getVisibleCount(): number {
|
|
195
|
+
const { rows } = getTerminalSize();
|
|
196
|
+
return Math.min(Math.max(rows - 4, 3), 15); // 3 minimum, 15 max
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function adjustScroll(state: SearchState): void {
|
|
200
|
+
const visibleCount = getVisibleCount();
|
|
201
|
+
if (state.selectedIndex < state.scrollOffset) {
|
|
202
|
+
state.scrollOffset = state.selectedIndex;
|
|
203
|
+
} else if (state.selectedIndex >= state.scrollOffset + visibleCount) {
|
|
204
|
+
state.scrollOffset = state.selectedIndex - visibleCount + 1;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function render(state: SearchState): void {
|
|
209
|
+
const { cols } = getTerminalSize();
|
|
210
|
+
const visibleCount = getVisibleCount();
|
|
211
|
+
|
|
212
|
+
// Move up to clear previous render
|
|
213
|
+
if (state.renderedLines > 0) {
|
|
214
|
+
write(moveCursorUp(state.renderedLines));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const lines: string[] = [];
|
|
218
|
+
|
|
219
|
+
// Search box
|
|
220
|
+
lines.push(
|
|
221
|
+
clearLine() +
|
|
222
|
+
renderSearchBox({ query: state.query, cursorPos: state.cursorPos }, cols)
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
// Separator
|
|
226
|
+
lines.push(clearLine() + `\x1b[90m${"─".repeat(cols)}\x1b[0m`);
|
|
227
|
+
|
|
228
|
+
// Results
|
|
229
|
+
const resultLines = renderResultList(
|
|
230
|
+
{
|
|
231
|
+
results: state.results,
|
|
232
|
+
selectedIndex: state.selectedIndex,
|
|
233
|
+
scrollOffset: state.scrollOffset,
|
|
234
|
+
visibleCount,
|
|
235
|
+
},
|
|
236
|
+
cols
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
for (const line of resultLines) {
|
|
240
|
+
lines.push(clearLine() + line);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Pad remaining visible slots
|
|
244
|
+
for (let i = resultLines.length; i < visibleCount; i++) {
|
|
245
|
+
lines.push(clearLine());
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Separator + Status bar
|
|
249
|
+
lines.push(clearLine() + `\x1b[90m${"─".repeat(cols)}\x1b[0m`);
|
|
250
|
+
lines.push(clearLine() + renderStatusBar(state.results.length, cols));
|
|
251
|
+
|
|
252
|
+
// Write all lines
|
|
253
|
+
write(lines.join("\r\n") + "\r\n");
|
|
254
|
+
|
|
255
|
+
// Track rendered lines for next clear
|
|
256
|
+
state.renderedLines = lines.length;
|
|
257
|
+
|
|
258
|
+
// Position cursor at search box
|
|
259
|
+
write(moveCursorUp(lines.length));
|
|
260
|
+
write(
|
|
261
|
+
moveCursorToColumn(getSearchBoxCursorCol({ query: state.query, cursorPos: state.cursorPos }))
|
|
262
|
+
);
|
|
263
|
+
write(showCursor());
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function* readStdin(): AsyncGenerator<Buffer> {
|
|
267
|
+
const stdin = process.stdin;
|
|
268
|
+
stdin.resume();
|
|
269
|
+
|
|
270
|
+
const buffers: Buffer[] = [];
|
|
271
|
+
let resolve: (() => void) | null = null;
|
|
272
|
+
|
|
273
|
+
stdin.on("data", (data: Buffer) => {
|
|
274
|
+
buffers.push(data);
|
|
275
|
+
if (resolve) {
|
|
276
|
+
resolve();
|
|
277
|
+
resolve = null;
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
while (true) {
|
|
282
|
+
if (buffers.length === 0) {
|
|
283
|
+
await new Promise<void>((r) => {
|
|
284
|
+
resolve = r;
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
while (buffers.length > 0) {
|
|
288
|
+
yield buffers.shift()!;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
package/src/cli/stats.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { getDb } from "../db/connection";
|
|
2
|
+
import { getTotalCommandCount, getUniqueCommandCount } from "../db/queries";
|
|
3
|
+
import { color } from "../tui/theme";
|
|
4
|
+
|
|
5
|
+
export function runStats(): void {
|
|
6
|
+
const total = getTotalCommandCount();
|
|
7
|
+
const unique = getUniqueCommandCount();
|
|
8
|
+
|
|
9
|
+
const db = getDb();
|
|
10
|
+
const top = db
|
|
11
|
+
.query<{ command: string; frequency: number }, []>(
|
|
12
|
+
"SELECT command, frequency FROM command_stats ORDER BY frequency DESC LIMIT 10"
|
|
13
|
+
)
|
|
14
|
+
.all();
|
|
15
|
+
|
|
16
|
+
const oldest = db
|
|
17
|
+
.query<{ created_at: number }, []>(
|
|
18
|
+
"SELECT MIN(created_at) as created_at FROM commands"
|
|
19
|
+
)
|
|
20
|
+
.get();
|
|
21
|
+
|
|
22
|
+
console.log(`${color.bold}shellwise stats${color.reset}`);
|
|
23
|
+
console.log(`${color.dim}─────────────────────────${color.reset}`);
|
|
24
|
+
console.log(`Total executions: ${color.cyan}${total}${color.reset}`);
|
|
25
|
+
console.log(`Unique commands: ${color.cyan}${unique}${color.reset}`);
|
|
26
|
+
|
|
27
|
+
if (oldest?.created_at) {
|
|
28
|
+
const date = new Date(oldest.created_at).toLocaleDateString();
|
|
29
|
+
console.log(`History since: ${color.cyan}${date}${color.reset}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (top.length > 0) {
|
|
33
|
+
console.log(`\n${color.bold}Top commands${color.reset}`);
|
|
34
|
+
console.log(`${color.dim}─────────────────────────${color.reset}`);
|
|
35
|
+
for (let i = 0; i < top.length; i++) {
|
|
36
|
+
const num = String(i + 1).padStart(2);
|
|
37
|
+
console.log(
|
|
38
|
+
`${color.dim}${num}.${color.reset} ${top[i].command} ${color.dim}(${top[i].frequency}x)${color.reset}`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|