@napisani/scute 0.0.2 → 0.0.4

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.
Files changed (53) hide show
  1. package/README.md +79 -22
  2. package/bin/scute +2 -0
  3. package/package.json +84 -85
  4. package/src/commands/build.tsx +69 -0
  5. package/src/commands/config-debug.ts +33 -0
  6. package/src/commands/explain.ts +35 -0
  7. package/src/commands/generate.ts +47 -0
  8. package/src/commands/init.ts +29 -0
  9. package/src/commands/suggest.ts +26 -0
  10. package/src/components/Footer.tsx +41 -0
  11. package/src/components/Spinner.tsx +43 -0
  12. package/src/components/TokenAnnotatedView.tsx +55 -0
  13. package/src/components/TokenDisplay.tsx +34 -0
  14. package/src/components/TokenEditor.tsx +45 -0
  15. package/src/components/TokenListView.tsx +99 -0
  16. package/src/config/index.ts +255 -0
  17. package/src/config/schema.ts +202 -0
  18. package/src/core/cache.ts +94 -0
  19. package/src/core/constants.ts +13 -0
  20. package/src/core/environment.ts +39 -0
  21. package/src/core/index.ts +2 -0
  22. package/src/core/llm-context.ts +68 -0
  23. package/src/core/llm.ts +403 -0
  24. package/src/core/logger.ts +74 -0
  25. package/src/core/manpage/index.ts +62 -0
  26. package/src/core/manpage/parser.ts +96 -0
  27. package/src/core/manpage/retrieval-bm25.ts +258 -0
  28. package/src/core/output.ts +104 -0
  29. package/src/core/prompts.ts +81 -0
  30. package/src/core/shells/bash.ts +119 -0
  31. package/src/core/shells/common.ts +288 -0
  32. package/src/core/shells/index.ts +109 -0
  33. package/src/core/shells/keybindings.ts +95 -0
  34. package/src/core/shells/sh.ts +115 -0
  35. package/src/core/shells/zsh.ts +124 -0
  36. package/src/core/token-descriptions.ts +258 -0
  37. package/src/hooks/useColoredTokens.ts +35 -0
  38. package/src/hooks/useInsertMode.ts +142 -0
  39. package/src/hooks/useNormalMode.ts +221 -0
  40. package/src/hooks/useParsedCommand.ts +23 -0
  41. package/src/hooks/useTokenDescriptions.ts +95 -0
  42. package/src/hooks/useTokenWidth.ts +31 -0
  43. package/src/hooks/useVimMode.ts +205 -0
  44. package/src/index.ts +129 -0
  45. package/src/pages/build.tsx +364 -0
  46. package/src/utils/annotatedRenderer.tsx +406 -0
  47. package/src/utils/keyboard.ts +32 -0
  48. package/src/utils/prompt.ts +24 -0
  49. package/src/utils/tokenPositions.ts +28 -0
  50. package/dist/scute +0 -0
  51. package/scripts/install.sh +0 -73
  52. package/scripts/postinstall.ts +0 -112
  53. package/scripts/update-brew.ts +0 -145
package/README.md CHANGED
@@ -10,13 +10,21 @@
10
10
  - Integrating through lightweight keybindings and shell hooks
11
11
 
12
12
  The name comes from the scute, the protective shell plate on a turtle, and the tool itself is meant to assist with shell commands.
13
- Scute is built as a single native binary (via Bun) so it can be distributed and updated easily.
13
+
14
+ Scute is built with Bun and can be installed via npm (requires Bun), Homebrew, or downloaded as a prebuilt binary.
14
15
 
15
16
  ## Installation
16
17
 
17
- Supported platforms: macOS and Linux (x86_64 only for now).
18
+ Supported platforms: macOS and Linux (x86_64 and arm64).
19
+
20
+ ### A. Homebrew (Recommended - Prebuilt Binary)
18
21
 
19
- ### A. Install via curl (install.sh)
22
+ ```sh
23
+ brew tap napisani/scute https://github.com/napisani/scute
24
+ brew install scute
25
+ ```
26
+
27
+ ### B. Install via curl (install.sh)
20
28
 
21
29
  Convenience installer (requires `curl` and `tar`):
22
30
 
@@ -26,14 +34,23 @@ curl -fsSL https://raw.githubusercontent.com/napisani/scute/main/scripts/install
26
34
 
27
35
  By default it installs into `/usr/local/bin` and pulls the latest release. Pass `vX.Y.Z` and a custom directory to override.
28
36
 
29
- ### B. Homebrew
37
+ ### C. bunx / bun (Requires Bun)
38
+
39
+ Install globally:
30
40
 
31
41
  ```sh
32
- brew tap napisani/scute https://github.com/napisani/scute
33
- brew install scute
42
+ bun install -g @napisani/scute
34
43
  ```
35
44
 
36
- ### C. npm / npx
45
+ Or run once with:
46
+
47
+ ```sh
48
+ bunx @napisani/scute --help
49
+ ```
50
+
51
+ > **Note:** The npm package requires Bun to be installed on your system. Install Bun from [bun.sh](https://bun.sh).
52
+
53
+ ### D. npm / npx (Requires Bun)
37
54
 
38
55
  Install globally:
39
56
 
@@ -47,9 +64,9 @@ Or run once with:
47
64
  npx @napisani/scute --help
48
65
  ```
49
66
 
50
- > The npm package runs a Bun-based `postinstall` script to download the matching release binary, so Bun must be available on your `PATH`.
67
+ > **Note:** The npm package requires Bun to be installed on your system.
51
68
 
52
- ### D. Nix
69
+ ### E. Nix
53
70
 
54
71
  Add the repo as an input and use it in your Home Manager flake:
55
72
 
@@ -66,12 +83,12 @@ outputs = { self, nixpkgs, scute, ... }: {
66
83
 
67
84
  > The repository ships an intentionally minimal `flake.nix`. Run `nix flake lock --update-input scute` inside your own workspace to pin exact revisions.
68
85
 
69
- ### E. Prebuilt binaries (manual)
86
+ ### F. Prebuilt Binaries (Manual)
70
87
 
71
- Every Git tag publishes `x86_64` macOS and Linux archives on the [GitHub Releases](https://github.com/napisani/scute/releases) page. Download the archive for your platform, unpack it, and move the `scute` binary onto your `PATH`:
88
+ Every Git tag publishes macOS (x86_64, arm64) and Linux (x86_64) archives on the [GitHub Releases](https://github.com/napisani/scute/releases) page. Download the archive for your platform, unpack it, and move the `scute` binary onto your `PATH`:
72
89
 
73
90
  ```sh
74
- curl -L -o scute.tar.gz "https://github.com/napisani/scute/releases/download/vX.Y.Z/scute-vX.Y.Z-macos-x86_64.tar.gz"
91
+ curl -L -o scute.tar.gz "https://github.com/napisani/scute/releases/download/vX.Y.Z/scute-vX.Y.Z-macos-arm64.tar.gz"
75
92
  tar -xzf scute.tar.gz
76
93
  sudo mv scute /usr/local/bin/
77
94
  ```
@@ -80,10 +97,10 @@ Verify downloads with the checksums shipped alongside each release:
80
97
 
81
98
  ```sh
82
99
  curl -LO https://github.com/napisani/scute/releases/download/vX.Y.Z/checksums.txt
83
- grep scute-vX.Y.Z-macos-x86_64.tar.gz checksums.txt | shasum -a 256 -c -
100
+ grep scute-vX.Y.Z-macos-arm64.tar.gz checksums.txt | shasum -a 256 -c -
84
101
  ```
85
102
 
86
- ### F. Install from source
103
+ ### G. Install from Source
87
104
 
88
105
  ```sh
89
106
  git clone https://github.com/napisani/scute.git
@@ -93,7 +110,7 @@ bun run build:bin
93
110
  sudo mv dist/scute /usr/local/bin/
94
111
  ```
95
112
 
96
- If you prefer Make targets:
113
+ Or use Make targets:
97
114
 
98
115
  ```sh
99
116
  make build
@@ -315,13 +332,53 @@ Once installed and configured, you can use the following keyboard shortcuts in y
315
332
 
316
333
  ## Release Process (Maintainers)
317
334
 
318
- 1. Update `package.json` version.
319
- 2. Ensure your working tree is clean and Bun is installed.
320
- 3. Run `make release`.
321
- - Runs lint and tests, builds the binary, tags `vX.Y.Z`, pushes the tag, and publishes to npm.
322
- 4. GitHub Actions builds macOS/Linux archives and uploads them to the release.
323
- 5. Refresh Homebrew checksums:
335
+ ### Creating a New Release
336
+
337
+ 1. **Update the version** in `package.json` (e.g., `"version": "0.0.4"`)
338
+ 2. **Commit the version change**: `git commit -am "Bump version to 0.0.4"`
339
+ 3. **Create the release** (this creates the git tag and triggers CI):
340
+ ```sh
341
+ make release-create
342
+ ```
343
+ This will:
344
+ - Run lint and tests
345
+ - Create and push git tag `v0.0.4`
346
+ - Trigger GitHub Actions to build binaries
347
+
348
+ 4. **Wait for CI** to complete (GitHub Actions builds and uploads binaries to the release)
349
+
350
+ 5. **Publish to npm**:
351
+ ```sh
352
+ make release-publish
353
+ ```
354
+
355
+ 6. **Update Homebrew formula** (after CI finishes):
356
+ ```sh
357
+ make update-brew-latest
358
+ ```
359
+ Or specify a version explicitly:
360
+ ```sh
361
+ make update-brew VERSION=v0.0.4
362
+ ```
363
+
364
+ ### Full Release (Create + Publish)
365
+
366
+ To do it all in one command:
367
+ ```sh
368
+ make release
369
+ ```
370
+
371
+ ### What Gets Published
372
+
373
+ - **npm**: Source code (TypeScript/Bun) - users need Bun installed
374
+ - **GitHub Release**: Prebuilt binaries for macOS (x86_64, arm64) and Linux (x86_64)
375
+ - **Homebrew**: Points to the GitHub release binaries
376
+
377
+ ### Pre-release Testing
324
378
 
379
+ For testing before publishing:
325
380
  ```sh
326
- make update-brew VERSION=vX.Y.Z
381
+ # Create a prerelease tag (contains '-')
382
+ make release-create
383
+ # CI will mark it as prerelease automatically
327
384
  ```
package/bin/scute ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import "../src/index.ts";
package/package.json CHANGED
@@ -1,87 +1,86 @@
1
1
  {
2
- "name": "@napisani/scute",
3
- "version": "0.0.2",
4
- "description": "AI-powered shell assistant",
5
- "module": "index.ts",
6
- "type": "module",
7
- "keywords": [
8
- "shell",
9
- "ai",
10
- "explain",
11
- "bash",
12
- "zsh",
13
- "suggestion",
14
- "tui"
15
- ],
16
- "homepage": "https://github.com/napisani/scute",
17
- "bugs": {
18
- "url": "https://github.com/napisani/scute/issues"
19
- },
20
- "repository": {
21
- "type": "git",
22
- "url": "git+https://github.com/napisani/scute.git"
23
- },
24
- "license": "MIT",
25
- "author": "Nick Pisani",
26
- "main": "index.js",
27
- "bin": {
28
- "scute": "dist/scute"
29
- },
30
- "directories": {
31
- "test": "tests"
32
- },
33
- "files": [
34
- "dist/",
35
- "scripts/",
36
- "README.md",
37
- "LICENSE"
38
- ],
39
- "scripts": {
40
- "build": "bun run build:bin",
41
- "build:bin": "bun build ./src/index.ts --compile --outfile dist/scute",
42
- "clean": "rm -rf dist",
43
- "lint": "bunx tsc --noEmit",
44
- "test": "bun test tests/",
45
- "coverage": "bun test tests/ --coverage",
46
- "evals": "bun test evals --pattern \\\"\\.eval\\.test\\.ts$\\\"",
47
- "prepublishOnly": "bun run clean && bun install --frozen-lockfile && bun run lint && bun run test",
48
- "postinstall": "bun scripts/postinstall.ts"
49
- },
50
- "dependencies": {
51
- "@opentui/core": "^0.1.75",
52
- "@opentui/react": "^0.1.75",
53
- "@tanstack/ai": "^0.2.2",
54
- "@tanstack/ai-anthropic": "^0.2.0",
55
- "@tanstack/ai-gemini": "^0.3.2",
56
- "@tanstack/ai-ollama": "^0.3.0",
57
- "@tanstack/ai-openai": "^0.2.1",
58
- "chalk": "^5.6.2",
59
- "commander": "^14.0.2",
60
- "dotenv": "^17.2.3",
61
- "js-yaml": "^4.1.1",
62
- "react": "^19.2.4",
63
- "shell-quote": "^1.8.3"
64
- },
65
- "devDependencies": {
66
- "@biomejs/biome": "2.3.12",
67
- "@testing-library/react": "^16.3.2",
68
- "@types/bun": "latest",
69
- "@types/js-yaml": "^4.0.9",
70
- "@types/jsdom": "^27.0.0",
71
- "@types/node": "^25.0.10",
72
- "@types/react": "^19.2.9",
73
- "@types/shell-quote": "^1.7.5",
74
- "happy-dom": "^20.5.0",
75
- "jsdom": "^28.0.0"
76
- },
77
- "peerDependencies": {
78
- "typescript": "^5"
79
- },
80
- "engines": {
81
- "node": ">=18"
82
- },
83
- "os": [
84
- "darwin",
85
- "linux"
86
- ]
2
+ "name": "@napisani/scute",
3
+ "version": "0.0.4",
4
+ "description": "AI-powered shell assistant",
5
+ "module": "index.ts",
6
+ "type": "module",
7
+ "keywords": [
8
+ "shell",
9
+ "ai",
10
+ "explain",
11
+ "bash",
12
+ "zsh",
13
+ "suggestion",
14
+ "tui"
15
+ ],
16
+ "homepage": "https://github.com/napisani/scute",
17
+ "bugs": {
18
+ "url": "https://github.com/napisani/scute/issues"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/napisani/scute.git"
23
+ },
24
+ "license": "MIT",
25
+ "author": "Nick Pisani",
26
+ "main": "src/index.ts",
27
+ "bin": {
28
+ "scute": "bin/scute"
29
+ },
30
+ "directories": {
31
+ "test": "tests"
32
+ },
33
+ "files": [
34
+ "bin/",
35
+ "src/",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "scripts": {
40
+ "build": "bun run build:bin",
41
+ "build:bin": "bun build ./src/index.ts --compile --outfile dist/scute",
42
+ "clean": "rm -rf dist",
43
+ "lint": "bunx tsc --noEmit",
44
+ "test": "bun test tests/",
45
+ "coverage": "bun test tests/ --coverage",
46
+ "evals": "bun test evals --pattern \\\"\\.eval\\.test\\.ts$\\\"",
47
+ "prepublishOnly": "bun run lint && bun run test"
48
+ },
49
+ "dependencies": {
50
+ "@opentui/core": "^0.1.75",
51
+ "@opentui/react": "^0.1.75",
52
+ "@tanstack/ai": "^0.2.2",
53
+ "@tanstack/ai-anthropic": "^0.2.0",
54
+ "@tanstack/ai-gemini": "^0.3.2",
55
+ "@tanstack/ai-ollama": "^0.3.0",
56
+ "@tanstack/ai-openai": "^0.2.1",
57
+ "chalk": "^5.6.2",
58
+ "commander": "^14.0.2",
59
+ "dotenv": "^17.2.3",
60
+ "js-yaml": "^4.1.1",
61
+ "react": "^19.2.4",
62
+ "shell-quote": "^1.8.3"
63
+ },
64
+ "devDependencies": {
65
+ "@biomejs/biome": "2.3.12",
66
+ "@testing-library/react": "^16.3.2",
67
+ "@types/bun": "latest",
68
+ "@types/js-yaml": "^4.0.9",
69
+ "@types/jsdom": "^27.0.0",
70
+ "@types/node": "^25.0.10",
71
+ "@types/react": "^19.2.9",
72
+ "@types/shell-quote": "^1.7.5",
73
+ "happy-dom": "^20.5.0",
74
+ "jsdom": "^28.0.0"
75
+ },
76
+ "peerDependencies": {
77
+ "typescript": "^5"
78
+ },
79
+ "engines": {
80
+ "bun": ">=1.0.0"
81
+ },
82
+ "os": [
83
+ "darwin",
84
+ "linux"
85
+ ]
87
86
  }
@@ -0,0 +1,69 @@
1
+ import { createCliRenderer } from "@opentui/core";
2
+ import { createRoot } from "@opentui/react";
3
+ import { emitOutput, type OutputChannel } from "../core/output";
4
+ import { getReadlineLine, hasReadlineLine } from "../core/shells";
5
+ import { BuildApp } from "../pages/build";
6
+ import { promptForLine } from "../utils/prompt";
7
+
8
+ export interface BuildOptions {
9
+ output: OutputChannel;
10
+ }
11
+
12
+ export function resolveBuildCommand(
13
+ inputParts: string[],
14
+ options: { hasReadlineLine: boolean; readlineLine: string | null },
15
+ ): string {
16
+ const positionalCommand = inputParts.join(" ").trim();
17
+ if (positionalCommand.length > 0) {
18
+ return positionalCommand;
19
+ }
20
+ if (options.hasReadlineLine && options.readlineLine) {
21
+ return options.readlineLine;
22
+ }
23
+ return "";
24
+ }
25
+
26
+ export async function build(
27
+ inputParts: string[] = [],
28
+ { output }: BuildOptions,
29
+ ) {
30
+ const isLine = hasReadlineLine();
31
+ const readlineLine = getReadlineLine();
32
+ let command = resolveBuildCommand(inputParts, {
33
+ hasReadlineLine: isLine,
34
+ readlineLine,
35
+ });
36
+ if (command.length === 0) {
37
+ if (process.stdin.isTTY) {
38
+ command = await promptForLine({
39
+ message: "Enter a command to start building: ",
40
+ });
41
+ } else {
42
+ command = await readAllStdin();
43
+ }
44
+ }
45
+ const renderer = await createCliRenderer();
46
+ let didSubmit = false;
47
+ const handleSubmit = (nextCommand: string) => {
48
+ if (didSubmit) {
49
+ return;
50
+ }
51
+ didSubmit = true;
52
+ renderer.destroy();
53
+ emitOutput({
54
+ channel: output,
55
+ text: nextCommand,
56
+ });
57
+ };
58
+ createRoot(renderer).render(
59
+ <BuildApp command={command} onSubmit={handleSubmit} />,
60
+ );
61
+ }
62
+
63
+ async function readAllStdin(): Promise<string> {
64
+ let input = "";
65
+ for await (const chunk of process.stdin) {
66
+ input += chunk;
67
+ }
68
+ return input.trim();
69
+ }
@@ -0,0 +1,33 @@
1
+ import { getConfigSnapshot } from "../config";
2
+ import { getEnv, SUPPORTED_ENV_VARS } from "../core/environment";
3
+ import { emitOutput, type OutputChannel } from "../core/output";
4
+
5
+ type DebugOutput = {
6
+ config: ReturnType<typeof getConfigSnapshot>;
7
+ environment: Record<string, string | undefined>;
8
+ };
9
+
10
+ export interface ConfigDebugOptions {
11
+ output: OutputChannel;
12
+ }
13
+
14
+ export function configDebug({ output }: ConfigDebugOptions): void {
15
+ const resolvedConfig = getConfigSnapshot();
16
+ const environment: DebugOutput["environment"] = SUPPORTED_ENV_VARS.reduce(
17
+ (acc, varName) => {
18
+ acc[varName] = getEnv(varName);
19
+ return acc;
20
+ },
21
+ {} as Record<string, string | undefined>,
22
+ );
23
+
24
+ const payload: DebugOutput = {
25
+ config: resolvedConfig,
26
+ environment,
27
+ };
28
+
29
+ emitOutput({
30
+ channel: output,
31
+ text: JSON.stringify(payload, null, 2),
32
+ });
33
+ }
@@ -0,0 +1,35 @@
1
+ // src/commands/explain.ts
2
+ import { explain as explainCommand } from "../core";
3
+ import { logDebug } from "../core/logger";
4
+ import { emitOutput, type OutputChannel } from "../core/output";
5
+
6
+ /**
7
+ * Handles the 'explain' command by calling the AI service.
8
+ * Renders a non-interfering hint at the bottom of the terminal.
9
+ * @param line The current READLINE_LINE content.
10
+ * @param point The current READLINE_POINT (cursor position).
11
+ */
12
+ export interface ExplainOptions {
13
+ output: OutputChannel;
14
+ }
15
+
16
+ export async function explain(
17
+ line: string,
18
+ point: string,
19
+ { output }: ExplainOptions,
20
+ ) {
21
+ logDebug(`command:explain line="${line}" point=${point}`);
22
+
23
+ const explanation = await explainCommand(line);
24
+ if (explanation === null) {
25
+ logDebug("command:explain result=null");
26
+ return;
27
+ }
28
+ logDebug("command:explain hint ready");
29
+ emitOutput({
30
+ channel: output,
31
+ text: explanation,
32
+ promptPrefix: "[scute] ",
33
+ });
34
+ logDebug("command:explain output written");
35
+ }
@@ -0,0 +1,47 @@
1
+ // src/commands/generate.ts
2
+ import { generateCommand } from "../core";
3
+ import { logDebug } from "../core/logger";
4
+ import { emitOutput, type OutputChannel } from "../core/output";
5
+ import { promptForLine } from "../utils/prompt";
6
+
7
+ export interface GenerateOptions {
8
+ output: OutputChannel;
9
+ }
10
+
11
+ export async function generate(
12
+ inputParts: string[] = [],
13
+ { output }: GenerateOptions,
14
+ ): Promise<void> {
15
+ const prompt = await resolvePrompt(inputParts);
16
+ if (!prompt) {
17
+ logDebug("command:generate empty prompt");
18
+ return;
19
+ }
20
+ logDebug(`command:generate prompt="${prompt}"`);
21
+ const suggestion = await generateCommand(prompt);
22
+ if (suggestion === null) {
23
+ logDebug("command:generate result=null");
24
+ return;
25
+ }
26
+ emitOutput({
27
+ channel: output,
28
+ text: suggestion,
29
+ });
30
+ logDebug("command:generate output written");
31
+ }
32
+
33
+ async function resolvePrompt(inputParts: string[]): Promise<string> {
34
+ if (inputParts.length) {
35
+ return inputParts.join(" ").trim();
36
+ }
37
+ if (process.stdin.isTTY) {
38
+ return await promptForLine({
39
+ message: "Enter a command request: ",
40
+ });
41
+ }
42
+ let input = "";
43
+ for await (const chunk of process.stdin) {
44
+ input += chunk;
45
+ }
46
+ return input.trim();
47
+ }
@@ -0,0 +1,29 @@
1
+ // src/commands/init.ts
2
+
3
+ import { getShellKeybindings } from "../config";
4
+ import { emitOutput, type OutputChannel } from "../core/output";
5
+ import { getShellHelperByName, supportedShells } from "../core/shells";
6
+ import type { ShellName } from "../core/shells/common";
7
+
8
+ export interface InitOptions {
9
+ output: OutputChannel;
10
+ }
11
+
12
+ export function init(shell: string, { output }: InitOptions) {
13
+ if (!supportedShells.includes(shell as ShellName)) {
14
+ console.error(
15
+ `Error: Unsupported shell '${shell}'. Supported shells: ${supportedShells.join(
16
+ ", ",
17
+ )}.`,
18
+ );
19
+ process.exit(1);
20
+ }
21
+
22
+ const shellHelper = getShellHelperByName(shell as ShellName);
23
+ const initScript = shellHelper.getInitScript(getShellKeybindings());
24
+
25
+ emitOutput({
26
+ channel: output,
27
+ text: initScript,
28
+ });
29
+ }
@@ -0,0 +1,26 @@
1
+ // src/commands/suggest.ts
2
+ import { suggest as suggestCommand } from "../core";
3
+ import { logDebug } from "../core/logger";
4
+ import { emitOutput, type OutputChannel } from "../core/output";
5
+
6
+ /**
7
+ * Handles the 'suggest' command by calling the AI service.
8
+ * @param line The current text from the READLINE_LINE environment variable.
9
+ */
10
+ export interface SuggestOptions {
11
+ output: OutputChannel;
12
+ }
13
+
14
+ export async function suggest(line: string, { output }: SuggestOptions) {
15
+ logDebug(`command:suggest line="${line}"`);
16
+ const suggestion = await suggestCommand(line);
17
+ if (suggestion === null) {
18
+ logDebug("command:suggest result=null");
19
+ return;
20
+ }
21
+ logDebug(`command:suggest result="${suggestion}"`);
22
+ emitOutput({
23
+ channel: output,
24
+ text: suggestion,
25
+ });
26
+ }
@@ -0,0 +1,41 @@
1
+ import { getKeybindings } from "../config";
2
+ import type { ViewMode, VimMode } from "../hooks/useVimMode";
3
+ import { Spinner } from "./Spinner";
4
+
5
+ interface FooterProps {
6
+ mode: VimMode;
7
+ viewMode: ViewMode;
8
+ isLoading?: boolean;
9
+ error?: string | null;
10
+ }
11
+
12
+ export function Footer({
13
+ mode,
14
+ viewMode,
15
+ isLoading = false,
16
+ error = null,
17
+ }: FooterProps) {
18
+ const modeColor = mode === "insert" ? "#00FF00" : "#888";
19
+ const modeText = mode === "insert" ? "-- INSERT --" : "-- NORMAL --";
20
+ const toggleViewKey = getKeybindings("toggleView")[0] ?? "m";
21
+
22
+ const viewModeHelp =
23
+ viewMode === "annotated"
24
+ ? "h/l: move, w/b: word, 0/$: line"
25
+ : "j/k: move, gg/G: jump";
26
+
27
+ return (
28
+ <box height={1} flexDirection="row" gap={2}>
29
+ <text fg={modeColor}>{modeText}</text>
30
+ {error ? (
31
+ <text fg="#F38BA8">{error}</text>
32
+ ) : (
33
+ <>
34
+ <text fg="#888">Press '{toggleViewKey}' to toggle view</text>
35
+ <text fg="#888">{viewModeHelp}</text>
36
+ <Spinner isActive={isLoading} />
37
+ </>
38
+ )}
39
+ </box>
40
+ );
41
+ }
@@ -0,0 +1,43 @@
1
+ import { useEffect, useState } from "react";
2
+ import { getThemeColorFor } from "../config";
3
+
4
+ type SpinnerProps = {
5
+ isActive: boolean;
6
+ };
7
+
8
+ function getSpinnerFrame(index: number): string {
9
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
10
+ return frames[index % frames.length]!;
11
+ }
12
+
13
+ export function Spinner({ isActive }: SpinnerProps) {
14
+ const [spinnerIndex, setSpinnerIndex] = useState(0);
15
+ const markerColor = getThemeColorFor("markerColor");
16
+
17
+ useEffect(() => {
18
+ if (!isActive) {
19
+ setSpinnerIndex(0);
20
+ return undefined;
21
+ }
22
+ const timer = setInterval(() => {
23
+ setSpinnerIndex((index) => index + 1);
24
+ }, 120);
25
+ return () => clearInterval(timer);
26
+ }, [isActive]);
27
+
28
+ if (!isActive) {
29
+ return null;
30
+ }
31
+
32
+ return (
33
+ <box
34
+ style={{
35
+ flexDirection: "row",
36
+ justifyContent: "flex-end",
37
+ width: "100%",
38
+ }}
39
+ >
40
+ <text fg={markerColor}>{getSpinnerFrame(spinnerIndex)}</text>
41
+ </box>
42
+ );
43
+ }