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.
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ import { pruneOlderThan } from "../db/queries";
2
+
3
+ export function runPrune(days: number): void {
4
+ const deleted = pruneOlderThan(days);
5
+ console.log(`Pruned ${deleted} entries older than ${days} days.`);
6
+ }
@@ -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
+ }
@@ -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
+ }