shellwise 0.2.7 → 0.2.10

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 CHANGED
@@ -60,14 +60,11 @@ eval "$(shellwise init bash)"
60
60
 
61
61
  ## Update
62
62
 
63
+ `shellwise version` and `shellwise stats` will notify you when a new version is available.
64
+
63
65
  ```bash
64
- # Homebrew
65
66
  brew upgrade shellwise
66
-
67
- # Bun
68
67
  bun install -g shellwise@latest
69
-
70
- # npm
71
68
  npm install -g shellwise@latest
72
69
  ```
73
70
 
@@ -110,9 +107,22 @@ shellwise import [zsh|bash] # Import existing shell history
110
107
  shellwise stats # Show usage statistics
111
108
  shellwise prune --days <n> # Remove entries older than n days
112
109
  shellwise daemon start|stop|status # Manage background daemon
113
- shellwise version # Show current version
110
+ shellwise version # Show current version
114
111
  ```
115
112
 
113
+ ### Delete a command
114
+
115
+ Opens an interactive picker to search and delete a command from history:
116
+
117
+ ```bash
118
+ shellwise delete # Browse all commands, pick one to delete
119
+ shellwise delete git # Pre-filter with "git", pick one to delete
120
+ ```
121
+
122
+ 1. Run the command and press `Enter` to open the picker
123
+ 2. Type to filter results
124
+ 3. `Tab` / `↑↓` to navigate, `Enter` to confirm deletion, `Esc` to cancel
125
+
116
126
  ### Import existing history
117
127
 
118
128
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shellwise",
3
- "version": "0.2.7",
3
+ "version": "0.2.10",
4
4
  "description": "Smart command history with inline auto-suggest and fuzzy search for your terminal",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/add.ts CHANGED
@@ -22,6 +22,10 @@ export function runAdd(opts: AddOptions): void {
22
22
  // Only save successful commands (exit code 0)
23
23
  if (opts.exitCode !== undefined && opts.exitCode !== 0) return;
24
24
 
25
+ // Skip shellwise's own commands
26
+ const baseCmd = cmd.split(/\s+/)[0];
27
+ if (baseCmd === "shellwise" || baseCmd === "sw") return;
28
+
25
29
  insertCommand({
26
30
  command: cmd,
27
31
  cwd: opts.cwd,
package/src/cli/init.ts CHANGED
@@ -25,25 +25,48 @@ typeset -ga __sw_suggestions=()
25
25
  typeset -g __sw_selected=0
26
26
  typeset -g __sw_fd=""
27
27
  typeset -g __sw_ready=0
28
+ typeset -g __sw_port=0
29
+
30
+ # ─── Daemon connection (self-healing) ──────────────────────
31
+ # The daemon idle-exits after inactivity and the TCP port is
32
+ # deterministic, so the shell must be able to reconnect on
33
+ # demand — otherwise a long-idle pane loses suggest forever.
34
+
35
+ # Read daemon port from PID file (line 2). No fork (\$(<file) is builtin).
36
+ __sw_read_port() {
37
+ local pf="/tmp/shellwise-\${UID}.pid"
38
+ [[ -f "\$pf" ]] || return 1
39
+ local lines=("\${(@f)\$(<\$pf)}")
40
+ __sw_port=\${lines[2]:-0}
41
+ [[ \$__sw_port -gt 0 ]]
42
+ }
43
+
44
+ # Is the daemon process alive? No fork (kill -0 is a builtin).
45
+ __sw_daemon_alive() {
46
+ local pf="/tmp/shellwise-\${UID}.pid"
47
+ [[ -f "\$pf" ]] || return 1
48
+ local lines=("\${(@f)\$(<\$pf)}")
49
+ kill -0 \${lines[1]:-0} 2>/dev/null
50
+ }
51
+
52
+ # (Re)establish the persistent TCP connection. No fork while typing.
53
+ __sw_connect() {
54
+ __sw_ready=0
55
+ [[ -n "\$__sw_fd" ]] && { ztcp -c \$__sw_fd 2>/dev/null; __sw_fd="" }
56
+ [[ \$__sw_port -gt 0 ]] || __sw_read_port || return 1
57
+ ztcp 127.0.0.1 \$__sw_port 2>/dev/null || return 1
58
+ __sw_fd=\$REPLY
59
+ __sw_ready=1
60
+ }
28
61
 
29
62
  # Load TCP module + connect at shell startup
30
63
  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
64
+ if [[ ! -f "/tmp/shellwise-\${UID}.pid" ]]; then
34
65
  # Start daemon in background (non-blocking)
35
66
  command ${bin} daemon start &>/dev/null &!
36
67
  sleep 0.3
37
68
  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
69
+ __sw_connect
47
70
  }
48
71
 
49
72
  # ─── Persistent TCP query (no connect/disconnect overhead) ─
@@ -52,14 +75,19 @@ typeset -ga __sw_tcp_result=()
52
75
 
53
76
  __sw_tcp_query() {
54
77
  __sw_tcp_result=()
55
- [[ \$__sw_ready -ne 1 ]] && return 1
56
78
 
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
- }
79
+ # Neutralize SIGPIPE: writing to a daemon that has idle-exited must return
80
+ # a recoverable error, never raise a signal that freezes the line editor.
81
+ setopt local_options local_traps
82
+ trap '' PIPE
83
+
84
+ # (Re)connect if needed — daemon may have idle-exited or restarted
85
+ [[ \$__sw_ready -ne 1 ]] && { __sw_connect || return 1 }
86
+
87
+ # Send request; one transparent reconnect+retry on a stale connection
88
+ if ! print -u \$__sw_fd "\$1" 2>/dev/null; then
89
+ __sw_connect && print -u \$__sw_fd "\$1" 2>/dev/null || { __sw_ready=0; return 1 }
90
+ fi
63
91
 
64
92
  # Read response lines until empty line
65
93
  local line
@@ -87,8 +115,11 @@ __sw_precmd() {
87
115
  duration=\${duration%%.*}
88
116
  fi
89
117
 
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" || \\
118
+ # Save via persistent TCP (instant). On failure, restart a dead daemon
119
+ # (a fork between commands is fine) so the next keystroke can reconnect,
120
+ # and fall back to a direct write for this command.
121
+ if ! __sw_tcp_query "ADD\\t\$__SW_COMMAND\\t\$PWD\\t\$exit_code\\t\$duration\\t\$SW_SESSION_ID\\tzsh"; then
122
+ __sw_daemon_alive || command ${bin} daemon start &>/dev/null &!
92
123
  command ${bin} add \\
93
124
  --command "\$__SW_COMMAND" \\
94
125
  --cwd "\$PWD" \\
@@ -96,6 +127,15 @@ __sw_precmd() {
96
127
  --duration "\$duration" \\
97
128
  --session "\$SW_SESSION_ID" \\
98
129
  --shell "zsh" &!
130
+ fi
131
+
132
+ # Show update notice if available
133
+ local __sw_line
134
+ for __sw_line in "\${__sw_tcp_result[@]}"; do
135
+ if [[ "\$__sw_line" == UPDATE$'\\t'* ]]; then
136
+ print -P "%F{yellow}\${__sw_line#UPDATE }%f"
137
+ fi
138
+ done
99
139
 
100
140
  unset __SW_COMMAND __SW_START_TIME
101
141
  fi
@@ -2,6 +2,7 @@ import { getDb, closeDb } from "../db/connection";
2
2
  import { insertCommand } from "../db/queries";
3
3
  import { getHostname } from "../utils/platform";
4
4
  import { getCommonSuggestions } from "../data/common-commands";
5
+ import { checkForUpdate, getUpdateNotice } from "../utils/update-check";
5
6
  import { parseRequest, getSocketPath, getPidPath, getDaemonPort } from "./protocol";
6
7
  import { unlinkSync, writeFileSync, existsSync } from "fs";
7
8
  import type { Socket } from "bun";
@@ -10,6 +11,7 @@ const IDLE_TIMEOUT = 30 * 60_000; // 30 min
10
11
  let idleTimer: ReturnType<typeof setTimeout> | null = null;
11
12
  let server: ReturnType<typeof Bun.listen> | null = null;
12
13
  let tcpServer: ReturnType<typeof Bun.listen> | null = null;
14
+ let updateNotified = false;
13
15
 
14
16
  // Pre-warm DB + prepared statements on start
15
17
  let suggestPrefix: ReturnType<ReturnType<typeof getDb>["prepare"]>;
@@ -57,10 +59,14 @@ function handleRequest(raw: string): string {
57
59
  // Escape LIKE wildcards in user input
58
60
  const escapedQuery = req.query.replace(/[%_\\]/g, "\\$&");
59
61
 
60
- // History: prefix matches
62
+ // History: prefix matches (exact match first)
61
63
  const prefixes = suggestPrefix.all(escapedQuery, historyLimit) as { command: string }[];
62
64
  for (const r of prefixes) {
63
- if (r.command !== req.query) historyResults.push(r.command);
65
+ if (r.command === req.query) {
66
+ historyResults.unshift(r.command);
67
+ } else {
68
+ historyResults.push(r.command);
69
+ }
64
70
  }
65
71
 
66
72
  // History: contains matches (fill remaining)
@@ -71,7 +77,7 @@ function handleRequest(raw: string): string {
71
77
  command: string;
72
78
  }[];
73
79
  for (const r of contains) {
74
- if (!resultSet.has(r.command) && r.command !== req.query) {
80
+ if (!resultSet.has(r.command)) {
75
81
  historyResults.push(r.command);
76
82
  if (historyResults.length >= historyLimit) break;
77
83
  }
@@ -81,7 +87,7 @@ function handleRequest(raw: string): string {
81
87
  // Common commands: fill with suggestions not already in history
82
88
  const seen = new Set(historyResults);
83
89
  const commonResults = getCommonSuggestions(req.query, 10)
84
- .filter((cmd) => !seen.has(cmd) && cmd !== req.query)
90
+ .filter((cmd) => !seen.has(cmd))
85
91
  .slice(0, 5);
86
92
 
87
93
  // Merge: history first, then common
@@ -93,7 +99,9 @@ function handleRequest(raw: string): string {
93
99
  case "ADD": {
94
100
  const cmd = req.command.trim();
95
101
  if (!cmd || cmd.length < 2 || cmd.startsWith(" ")) return "OK\n\n";
96
- if (req.exitCode !== 0) return "OK\n\n"; // Only save successful commands
102
+ if (req.exitCode !== 0) return "OK\n\n";
103
+ const baseCmd = cmd.split(/\s+/)[0];
104
+ if (baseCmd === "shellwise" || baseCmd === "sw") return "OK\n\n";
97
105
  insertCommand({
98
106
  command: cmd,
99
107
  cwd: req.cwd || undefined,
@@ -103,6 +111,22 @@ function handleRequest(raw: string): string {
103
111
  session_id: req.session || undefined,
104
112
  shell: req.shell || undefined,
105
113
  });
114
+
115
+ // Check update notice from cache (sync, fast, once per daemon session)
116
+ const pkg = require("../../package.json");
117
+ if (!updateNotified) {
118
+ const notice = getUpdateNotice(pkg.version);
119
+ if (notice) {
120
+ updateNotified = true;
121
+ // Refresh cache in background
122
+ checkForUpdate(pkg.version, true).catch(() => {});
123
+ return `OK\nUPDATE\t${notice}\n\n`;
124
+ }
125
+ }
126
+
127
+ // Refresh cache in background for next time
128
+ checkForUpdate(pkg.version, true).catch(() => {});
129
+
106
130
  return "OK\n\n";
107
131
  }
108
132
 
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ import { runStats } from "./cli/stats";
9
9
  import { runPrune } from "./cli/prune";
10
10
  import { deleteCommand } from "./db/queries";
11
11
  import { closeDb } from "./db/connection";
12
+ import { checkForUpdate } from "./utils/update-check";
12
13
  import { startServer, isDaemonRunning, getDaemonInfo } from "./daemon/server";
13
14
  import { daemonRequest } from "./daemon/client";
14
15
 
@@ -225,6 +226,13 @@ async function main(): Promise<void> {
225
226
  }
226
227
  } finally {
227
228
  if (command !== "daemon") closeDb();
229
+
230
+ // Check for updates on interactive commands (cached, max once per day)
231
+ const silentCommands = new Set(["suggest", "add", "search", "init", "daemon", "_run"]);
232
+ if (command && !silentCommands.has(command)) {
233
+ const pkg = require("../package.json");
234
+ await checkForUpdate(pkg.version);
235
+ }
228
236
  }
229
237
  }
230
238
 
@@ -55,5 +55,5 @@ export function search(input: SearchInput): ScoredResult[] {
55
55
  cwdCommands = new Set(rows.map((r) => r.command_hash));
56
56
  }
57
57
 
58
- return rankResults(matches, dbResults, input.cwd, cwdCommands).slice(0, limit);
58
+ return rankResults(matches, dbResults, input.query, input.cwd, cwdCommands).slice(0, limit);
59
59
  }
@@ -27,6 +27,7 @@ const DEFAULT_WEIGHTS: ScoreWeights = {
27
27
  export function rankResults(
28
28
  matches: FuzzyMatch[],
29
29
  stats: CommandStats[],
30
+ query: string,
30
31
  currentCwd?: string,
31
32
  cwdCommands?: Set<string>,
32
33
  weights: ScoreWeights = DEFAULT_WEIGHTS
@@ -65,6 +66,11 @@ export function rankResults(
65
66
  });
66
67
  }
67
68
 
68
- results.sort((a, b) => b.finalScore - a.finalScore);
69
+ results.sort((a, b) => {
70
+ const aExact = a.command.toLowerCase() === query ? 1 : 0;
71
+ const bExact = b.command.toLowerCase() === query ? 1 : 0;
72
+ if (aExact !== bExact) return bExact - aExact;
73
+ return b.finalScore - a.finalScore;
74
+ });
69
75
  return results;
70
76
  }
package/src/tui/input.ts CHANGED
@@ -23,13 +23,17 @@ export type SpecialKey =
23
23
  export function parseKeypress(data: Buffer): KeyEvent {
24
24
  const str = data.toString("utf-8");
25
25
 
26
+ // Backspace (127 = DEL, 8 = BS)
27
+ if (data.length === 1 && (data[0] === 127 || data[0] === 8)) {
28
+ return { type: "special", key: "backspace" };
29
+ }
30
+
26
31
  // Ctrl combinations
27
32
  if (data.length === 1 && data[0] < 32) {
28
33
  const char = data[0];
29
34
  if (char === 13) return { type: "special", key: "enter" };
30
35
  if (char === 9) return { type: "special", key: "tab" };
31
36
  if (char === 27) return { type: "special", key: "escape" };
32
- if (char === 127) return { type: "special", key: "backspace" };
33
37
  // Ctrl+A = 1, Ctrl+B = 2, etc.
34
38
  return { type: "ctrl", char: String.fromCharCode(char + 96) };
35
39
  }
@@ -0,0 +1,87 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { getDataDir } from "./paths";
4
+ import { color } from "../tui/theme";
5
+
6
+ const CHECK_INTERVAL = 24 * 3600_000; // 1 day
7
+
8
+ interface CachedCheck {
9
+ latestVersion: string;
10
+ checkedAt: number;
11
+ }
12
+
13
+ function getCachePath(): string {
14
+ return join(getDataDir(), "update-check.json");
15
+ }
16
+
17
+ function readCache(): CachedCheck | null {
18
+ const path = getCachePath();
19
+ if (!existsSync(path)) return null;
20
+ try {
21
+ return JSON.parse(readFileSync(path, "utf-8"));
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ function writeCache(data: CachedCheck): void {
28
+ try {
29
+ writeFileSync(getCachePath(), JSON.stringify(data));
30
+ } catch {}
31
+ }
32
+
33
+ function compareVersions(current: string, latest: string): number {
34
+ const a = current.split(".").map(Number);
35
+ const b = latest.split(".").map(Number);
36
+ for (let i = 0; i < 3; i++) {
37
+ if ((a[i] ?? 0) < (b[i] ?? 0)) return -1;
38
+ if ((a[i] ?? 0) > (b[i] ?? 0)) return 1;
39
+ }
40
+ return 0;
41
+ }
42
+
43
+ export async function checkForUpdate(
44
+ currentVersion: string,
45
+ silent: boolean = false
46
+ ): Promise<void> {
47
+ const cache = readCache();
48
+ const now = Date.now();
49
+
50
+ // Cache hit — skip network, only notify if not silent
51
+ if (cache && now - cache.checkedAt < CHECK_INTERVAL) {
52
+ if (!silent && compareVersions(currentVersion, cache.latestVersion) < 0) {
53
+ printUpdateNotice(currentVersion, cache.latestVersion);
54
+ }
55
+ return;
56
+ }
57
+
58
+ // Cache miss — fetch in background, don't block
59
+ try {
60
+ const res = await fetch("https://registry.npmjs.org/shellwise/latest", {
61
+ signal: AbortSignal.timeout(3000),
62
+ });
63
+ if (!res.ok) return;
64
+ const data = (await res.json()) as { version: string };
65
+ writeCache({ latestVersion: data.version, checkedAt: now });
66
+
67
+ if (!silent && compareVersions(currentVersion, data.version) < 0) {
68
+ printUpdateNotice(currentVersion, data.version);
69
+ }
70
+ } catch {}
71
+ }
72
+
73
+ export function getUpdateNotice(currentVersion: string): string | null {
74
+ const cache = readCache();
75
+ if (!cache) return null;
76
+ if (compareVersions(currentVersion, cache.latestVersion) < 0) {
77
+ return `Update available: ${currentVersion} → ${cache.latestVersion}. Run: brew upgrade shellwise | bun install -g shellwise@latest | npm install -g shellwise@latest`;
78
+ }
79
+ return null;
80
+ }
81
+
82
+ function printUpdateNotice(current: string, latest: string): void {
83
+ console.log(
84
+ `\n${color.yellow}Update available: ${current} → ${latest}${color.reset}` +
85
+ `\n${color.dim}Run: brew upgrade shellwise | bun install -g shellwise@latest | npm install -g shellwise@latest${color.reset}`
86
+ );
87
+ }