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
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { color, icon } from "../theme";
|
|
2
|
+
import { truncate } from "../renderer";
|
|
3
|
+
import type { ScoredResult } from "../../search";
|
|
4
|
+
|
|
5
|
+
export interface ResultListState {
|
|
6
|
+
results: ScoredResult[];
|
|
7
|
+
selectedIndex: number;
|
|
8
|
+
scrollOffset: number;
|
|
9
|
+
visibleCount: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function renderResultList(state: ResultListState, width: number): string[] {
|
|
13
|
+
const lines: string[] = [];
|
|
14
|
+
const { results, selectedIndex, scrollOffset, visibleCount } = state;
|
|
15
|
+
|
|
16
|
+
if (results.length === 0) {
|
|
17
|
+
lines.push(`${color.dim} No matches found${color.reset}`);
|
|
18
|
+
return lines;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const end = Math.min(scrollOffset + visibleCount, results.length);
|
|
22
|
+
|
|
23
|
+
for (let i = scrollOffset; i < end; i++) {
|
|
24
|
+
const result = results[i];
|
|
25
|
+
const isSelected = i === selectedIndex;
|
|
26
|
+
const prefix = isSelected
|
|
27
|
+
? `${color.cyan}${color.bold}${icon.selected} ${color.reset}`
|
|
28
|
+
: " ";
|
|
29
|
+
|
|
30
|
+
const timeStr = formatTimeAgo(result.lastUsedAt);
|
|
31
|
+
const freqStr = result.frequency > 1 ? `${color.dim}(${result.frequency}x)${color.reset} ` : "";
|
|
32
|
+
|
|
33
|
+
const metaLen = timeStr.length + (result.frequency > 1 ? `(${result.frequency}x) `.length : 0) + 4;
|
|
34
|
+
const maxCmdWidth = width - metaLen - 2;
|
|
35
|
+
|
|
36
|
+
let cmdDisplay: string;
|
|
37
|
+
if (isSelected) {
|
|
38
|
+
cmdDisplay = highlightMatches(
|
|
39
|
+
truncate(result.command, maxCmdWidth),
|
|
40
|
+
result.matchPositions,
|
|
41
|
+
`${color.white}${color.bold}`,
|
|
42
|
+
`${color.yellow}${color.bold}${color.underline}`
|
|
43
|
+
);
|
|
44
|
+
} else {
|
|
45
|
+
cmdDisplay = highlightMatches(
|
|
46
|
+
truncate(result.command, maxCmdWidth),
|
|
47
|
+
result.matchPositions,
|
|
48
|
+
color.white,
|
|
49
|
+
`${color.cyan}`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const rightSide = `${freqStr}${color.dim}${timeStr}${color.reset}`;
|
|
54
|
+
const padding = Math.max(0, width - truncate(result.command, maxCmdWidth).length - metaLen - 2);
|
|
55
|
+
|
|
56
|
+
lines.push(`${prefix}${cmdDisplay}${" ".repeat(padding)}${rightSide}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return lines;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function highlightMatches(
|
|
63
|
+
text: string,
|
|
64
|
+
positions: number[],
|
|
65
|
+
normalStyle: string,
|
|
66
|
+
matchStyle: string
|
|
67
|
+
): string {
|
|
68
|
+
if (positions.length === 0) return `${normalStyle}${text}${color.reset}`;
|
|
69
|
+
|
|
70
|
+
const posSet = new Set(positions);
|
|
71
|
+
let result = "";
|
|
72
|
+
let inMatch = false;
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < text.length; i++) {
|
|
75
|
+
if (posSet.has(i)) {
|
|
76
|
+
if (!inMatch) {
|
|
77
|
+
result += matchStyle;
|
|
78
|
+
inMatch = true;
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
if (inMatch) {
|
|
82
|
+
result += color.reset + normalStyle;
|
|
83
|
+
inMatch = false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
result += text[i];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return `${normalStyle}${result}${color.reset}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatTimeAgo(timestamp: number): string {
|
|
93
|
+
const diff = Date.now() - timestamp;
|
|
94
|
+
const seconds = Math.floor(diff / 1000);
|
|
95
|
+
const minutes = Math.floor(seconds / 60);
|
|
96
|
+
const hours = Math.floor(minutes / 60);
|
|
97
|
+
const days = Math.floor(hours / 24);
|
|
98
|
+
const weeks = Math.floor(days / 7);
|
|
99
|
+
|
|
100
|
+
if (seconds < 60) return "just now";
|
|
101
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
102
|
+
if (hours < 24) return `${hours}h ago`;
|
|
103
|
+
if (days < 7) return `${days}d ago`;
|
|
104
|
+
if (weeks < 52) return `${weeks}w ago`;
|
|
105
|
+
return `${Math.floor(days / 365)}y ago`;
|
|
106
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { color, icon } from "../theme";
|
|
2
|
+
import { truncate } from "../renderer";
|
|
3
|
+
|
|
4
|
+
export interface SearchBoxState {
|
|
5
|
+
query: string;
|
|
6
|
+
cursorPos: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function renderSearchBox(state: SearchBoxState, width: number): string {
|
|
10
|
+
const prefix = `${color.cyan}${color.bold}${icon.prompt} ${color.reset}`;
|
|
11
|
+
const prefixLen = 2; // "> "
|
|
12
|
+
const maxQueryWidth = width - prefixLen - 1;
|
|
13
|
+
const displayQuery = truncate(state.query, maxQueryWidth);
|
|
14
|
+
|
|
15
|
+
return `${prefix}${displayQuery}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getSearchBoxCursorCol(state: SearchBoxState): number {
|
|
19
|
+
return state.cursorPos + 3; // "> " + 1-based column
|
|
20
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { color } from "../theme";
|
|
2
|
+
|
|
3
|
+
export function renderStatusBar(resultCount: number, width: number): string {
|
|
4
|
+
const left = `${color.dim} ${resultCount} result${resultCount !== 1 ? "s" : ""}`;
|
|
5
|
+
const right = `Tab/S-Tab:navigate Enter:select Esc:cancel ${color.reset}`;
|
|
6
|
+
const rightClean = `Tab/S-Tab:navigate Enter:select Esc:cancel `;
|
|
7
|
+
const padding = Math.max(0, width - ` ${resultCount} results`.length - rightClean.length);
|
|
8
|
+
|
|
9
|
+
return `${left}${" ".repeat(padding)}${right}`;
|
|
10
|
+
}
|
package/src/tui/input.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export type KeyEvent =
|
|
2
|
+
| { type: "char"; char: string }
|
|
3
|
+
| { type: "special"; key: SpecialKey }
|
|
4
|
+
| { type: "ctrl"; char: string };
|
|
5
|
+
|
|
6
|
+
export type SpecialKey =
|
|
7
|
+
| "up"
|
|
8
|
+
| "down"
|
|
9
|
+
| "left"
|
|
10
|
+
| "right"
|
|
11
|
+
| "tab"
|
|
12
|
+
| "shift-tab"
|
|
13
|
+
| "enter"
|
|
14
|
+
| "escape"
|
|
15
|
+
| "backspace"
|
|
16
|
+
| "delete"
|
|
17
|
+
| "home"
|
|
18
|
+
| "end";
|
|
19
|
+
|
|
20
|
+
export function parseKeypress(data: Buffer): KeyEvent {
|
|
21
|
+
const str = data.toString("utf-8");
|
|
22
|
+
|
|
23
|
+
// Ctrl combinations
|
|
24
|
+
if (data.length === 1 && data[0] < 32) {
|
|
25
|
+
const char = data[0];
|
|
26
|
+
if (char === 13) return { type: "special", key: "enter" };
|
|
27
|
+
if (char === 9) return { type: "special", key: "tab" };
|
|
28
|
+
if (char === 27) return { type: "special", key: "escape" };
|
|
29
|
+
if (char === 127) return { type: "special", key: "backspace" };
|
|
30
|
+
// Ctrl+A = 1, Ctrl+B = 2, etc.
|
|
31
|
+
return { type: "ctrl", char: String.fromCharCode(char + 96) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Escape sequences
|
|
35
|
+
if (str.startsWith("\x1b[")) {
|
|
36
|
+
const seq = str.slice(2);
|
|
37
|
+
if (seq === "A") return { type: "special", key: "up" };
|
|
38
|
+
if (seq === "B") return { type: "special", key: "down" };
|
|
39
|
+
if (seq === "C") return { type: "special", key: "right" };
|
|
40
|
+
if (seq === "D") return { type: "special", key: "left" };
|
|
41
|
+
if (seq === "H") return { type: "special", key: "home" };
|
|
42
|
+
if (seq === "F") return { type: "special", key: "end" };
|
|
43
|
+
if (seq === "Z") return { type: "special", key: "shift-tab" };
|
|
44
|
+
if (seq === "3~") return { type: "special", key: "delete" };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Escape only
|
|
48
|
+
if (data.length === 1 && data[0] === 27) {
|
|
49
|
+
return { type: "special", key: "escape" };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Regular character
|
|
53
|
+
return { type: "char", char: str };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let originalRawMode: boolean | undefined;
|
|
57
|
+
|
|
58
|
+
export function enableRawMode(): void {
|
|
59
|
+
if (process.stdin.isTTY) {
|
|
60
|
+
originalRawMode = process.stdin.isRaw;
|
|
61
|
+
process.stdin.setRawMode(true);
|
|
62
|
+
process.stdin.resume();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function disableRawMode(): void {
|
|
67
|
+
if (process.stdin.isTTY) {
|
|
68
|
+
process.stdin.setRawMode(originalRawMode ?? false);
|
|
69
|
+
process.stdin.pause();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const ESC = "\x1b[";
|
|
2
|
+
|
|
3
|
+
export function moveCursorUp(n: number): string {
|
|
4
|
+
return n > 0 ? `${ESC}${n}A` : "";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function moveCursorDown(n: number): string {
|
|
8
|
+
return n > 0 ? `${ESC}${n}B` : "";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function moveCursorToColumn(col: number): string {
|
|
12
|
+
return `${ESC}${col}G`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function clearLine(): string {
|
|
16
|
+
return `${ESC}2K`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function clearDown(): string {
|
|
20
|
+
return `${ESC}J`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function hideCursor(): string {
|
|
24
|
+
return `${ESC}?25l`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function showCursor(): string {
|
|
28
|
+
return `${ESC}?25h`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getTerminalSize(): { rows: number; cols: number } {
|
|
32
|
+
return {
|
|
33
|
+
rows: process.stdout.rows || 24,
|
|
34
|
+
cols: process.stdout.columns || 80,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function write(text: string): void {
|
|
39
|
+
process.stderr.write(text);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function truncate(text: string, maxWidth: number): string {
|
|
43
|
+
if (text.length <= maxWidth) return text;
|
|
44
|
+
return text.slice(0, maxWidth - 1) + "…";
|
|
45
|
+
}
|
package/src/tui/theme.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const ESC = "\x1b[";
|
|
2
|
+
|
|
3
|
+
export const color = {
|
|
4
|
+
reset: `${ESC}0m`,
|
|
5
|
+
bold: `${ESC}1m`,
|
|
6
|
+
dim: `${ESC}2m`,
|
|
7
|
+
italic: `${ESC}3m`,
|
|
8
|
+
underline: `${ESC}4m`,
|
|
9
|
+
inverse: `${ESC}7m`,
|
|
10
|
+
|
|
11
|
+
// Foreground
|
|
12
|
+
black: `${ESC}30m`,
|
|
13
|
+
red: `${ESC}31m`,
|
|
14
|
+
green: `${ESC}32m`,
|
|
15
|
+
yellow: `${ESC}33m`,
|
|
16
|
+
blue: `${ESC}34m`,
|
|
17
|
+
magenta: `${ESC}35m`,
|
|
18
|
+
cyan: `${ESC}36m`,
|
|
19
|
+
white: `${ESC}37m`,
|
|
20
|
+
gray: `${ESC}90m`,
|
|
21
|
+
|
|
22
|
+
// Background
|
|
23
|
+
bgBlack: `${ESC}40m`,
|
|
24
|
+
bgBlue: `${ESC}44m`,
|
|
25
|
+
bgCyan: `${ESC}46m`,
|
|
26
|
+
bgWhite: `${ESC}47m`,
|
|
27
|
+
bgGray: `${ESC}100m`,
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
export const icon = {
|
|
31
|
+
prompt: ">",
|
|
32
|
+
selected: ">",
|
|
33
|
+
dot: "·",
|
|
34
|
+
} as const;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { homedir } from "os";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { mkdirSync, existsSync } from "fs";
|
|
4
|
+
|
|
5
|
+
const home = homedir();
|
|
6
|
+
|
|
7
|
+
export function getDataDir(): string {
|
|
8
|
+
const xdg = process.env.XDG_DATA_HOME;
|
|
9
|
+
const dir = xdg ? join(xdg, "shellwise") : join(home, ".local", "share", "shellwise");
|
|
10
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
11
|
+
return dir;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getConfigDir(): string {
|
|
15
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
16
|
+
const dir = xdg ? join(xdg, "shellwise") : join(home, ".config", "shellwise");
|
|
17
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
18
|
+
return dir;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getDbPath(): string {
|
|
22
|
+
return join(getDataDir(), "history.db");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getLogPath(): string {
|
|
26
|
+
return join(getDataDir(), "debug.log");
|
|
27
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { basename } from "path";
|
|
2
|
+
|
|
3
|
+
export function detectShell(): "zsh" | "bash" | "unknown" {
|
|
4
|
+
const shell = process.env.SHELL || "";
|
|
5
|
+
const name = basename(shell);
|
|
6
|
+
if (name === "zsh") return "zsh";
|
|
7
|
+
if (name === "bash") return "bash";
|
|
8
|
+
return "unknown";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getHostname(): string {
|
|
12
|
+
return process.env.HOSTNAME || require("os").hostname();
|
|
13
|
+
}
|