mirror of
https://github.com/wavetermdev/inshellisense.git
synced 2026-08-05 13:43:30 -07:00
feat: implement starting UI for root command
Signed-off-by: Chapman Pendery <cpendery@vt.edu>
This commit is contained in:
+2
-1
@@ -7,4 +7,5 @@ clac
|
||||
.env*
|
||||
dist/
|
||||
t*.md
|
||||
__pycache__
|
||||
__pycache__
|
||||
build/
|
||||
Generated
+1415
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@microsoft/clac",
|
||||
"version": "0.0.0",
|
||||
"description": "IDE style command line auto complete",
|
||||
"main": "./build/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node ./build/index.js"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/microsoft/clac.git"
|
||||
},
|
||||
"author": {
|
||||
"name": "Microsoft Corporation"
|
||||
},
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/microsoft/clac/issues"
|
||||
},
|
||||
"homepage": "https://github.com/microsoft/clac#readme",
|
||||
"dependencies": {
|
||||
"@withfig/autocomplete": "^2.633.0",
|
||||
"commander": "^11.0.0",
|
||||
"ink": "^4.4.1",
|
||||
"react": "^18.2.0",
|
||||
"wrap-ansi": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node18": "^18.2.2",
|
||||
"@types/ink": "^2.0.3",
|
||||
"@types/react": "^18.2.24",
|
||||
"@withfig/autocomplete-types": "^1.28.0",
|
||||
"typescript": "^5.2.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {Command} from "commander";
|
||||
|
||||
const supportedShells = ["bash", "powershell", "windows-powershell"]
|
||||
|
||||
const action = (shell: string) => {
|
||||
if (!supportedShells.includes(shell)) {
|
||||
console.error(`Unsupported shell: ${shell}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Adding keybindings to ${shell} shell`);
|
||||
};
|
||||
|
||||
const cmd = new Command("bind");
|
||||
cmd.description(`adds keybindings to the selected shell: ${supportedShells}`);
|
||||
cmd.action(action);
|
||||
|
||||
export default cmd
|
||||
@@ -0,0 +1,6 @@
|
||||
import {render} from "../ui/ui.js"
|
||||
|
||||
|
||||
export const action = () => {
|
||||
render()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
|
||||
import { Command } from "commander";
|
||||
|
||||
import bind from "./commands/bind.js";
|
||||
import { action } from "./commands/root.js";
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program.name("clac")
|
||||
.description('IDE style command line auto complete')
|
||||
.version("0.0.0", "-v, --version", "output the current version")
|
||||
.action(action)
|
||||
|
||||
program.addCommand(bind);
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,29 @@
|
||||
import speclist, {
|
||||
diffVersionedCompletions as versionedSpeclist,
|
||||
// @ts-ignore
|
||||
} from "@withfig/autocomplete/build/index.js";
|
||||
|
||||
const specs = (await Promise.all(
|
||||
speclist.map(async (spec: string) => {
|
||||
const prefix = versionedSpeclist.includes(spec) ? "/index.js" : `.js`;
|
||||
return (await import(`@withfig/autocomplete/build/${spec}${prefix}`))
|
||||
.default;
|
||||
})
|
||||
)) as Fig.Spec[];
|
||||
|
||||
export const getSuggestions = (cmd: string) => {
|
||||
const suggestions: Fig.Suggestion[] = [
|
||||
{
|
||||
name: "test",
|
||||
description:
|
||||
"test this is a very long description that i'd expect to wrap",
|
||||
},
|
||||
{ name: "test1", description: "test" },
|
||||
{ name: "test2", description: "test" },
|
||||
{ name: "test3", description: "test" },
|
||||
{ name: "test4", description: "test" },
|
||||
{ name: "test5", description: "test" },
|
||||
];
|
||||
|
||||
return suggestions;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Text } from "ink";
|
||||
|
||||
export default function Cursor() {
|
||||
const cursorIcon = "█";
|
||||
const blinkSpeed = 530;
|
||||
const [cursor, setCursor] = useState(cursorIcon);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setCursor(cursor === cursorIcon ? " " : cursorIcon);
|
||||
}, blinkSpeed);
|
||||
}, [cursor]);
|
||||
|
||||
return <Text>{cursor}</Text>;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { Text, useInput, Box, measureElement, DOMElement } from "ink";
|
||||
|
||||
const MaxSuggestions = 5;
|
||||
const SuggestionWidth = 40;
|
||||
const DescriptionWidth = 30;
|
||||
const BorderWidth = 2;
|
||||
const ActiveSuggestionBackgroundColor = "#7D56F4";
|
||||
|
||||
const rightPad = (str: string, len: number) => {
|
||||
return str + " ".repeat(len - str.length);
|
||||
};
|
||||
|
||||
function Description({ description }: { description: string }) {
|
||||
if (description.length !== 0) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box borderStyle="single" width={DescriptionWidth}>
|
||||
<Text>{description}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function SuggestionList({
|
||||
suggestions,
|
||||
activeSuggestionIdx,
|
||||
}: {
|
||||
suggestions: Fig.Suggestion[];
|
||||
activeSuggestionIdx: number;
|
||||
}) {
|
||||
return (
|
||||
<Box borderStyle="single" width={SuggestionWidth} flexDirection="column">
|
||||
{suggestions.map((suggestion, idx) => {
|
||||
const bgColor =
|
||||
idx === activeSuggestionIdx
|
||||
? ActiveSuggestionBackgroundColor
|
||||
: undefined;
|
||||
|
||||
const rawName = suggestion.displayName ?? (suggestion.name || "");
|
||||
const name =
|
||||
typeof rawName === "string" ? rawName : rawName.at(0) || "";
|
||||
if (name.length === 0) return <></>;
|
||||
|
||||
return (
|
||||
<Box key={idx}>
|
||||
<Text backgroundColor={bgColor} wrap="truncate-end">
|
||||
{rightPad(name, SuggestionWidth - BorderWidth)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Suggestions({
|
||||
leftPadding,
|
||||
setActiveSuggestion,
|
||||
suggestions,
|
||||
}: {
|
||||
leftPadding: number;
|
||||
setActiveSuggestion: (_: Fig.Suggestion) => void;
|
||||
suggestions: Fig.Suggestion[];
|
||||
}) {
|
||||
const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(0);
|
||||
const [windowWidth, setWindowWidth] = useState(500);
|
||||
|
||||
const page = Math.floor(activeSuggestionIndex / MaxSuggestions) + 1;
|
||||
const pagedSuggestions = suggestions.filter(
|
||||
(_, idx) =>
|
||||
idx < page * MaxSuggestions && idx >= (page - 1) * MaxSuggestions
|
||||
);
|
||||
const activePagedSuggestionIndex = activeSuggestionIndex % MaxSuggestions;
|
||||
const activeDescription =
|
||||
pagedSuggestions.at(activePagedSuggestionIndex)?.description || "";
|
||||
|
||||
// TODO: tweak this logic to be more accurate as it gives bad offsets on wrap
|
||||
const wrappedPadding = leftPadding % windowWidth;
|
||||
|
||||
const maxPadding =
|
||||
activeDescription.length !== 0
|
||||
? windowWidth - SuggestionWidth - DescriptionWidth
|
||||
: windowWidth - SuggestionWidth;
|
||||
|
||||
const swapDescription = wrappedPadding > maxPadding;
|
||||
|
||||
const swappedPadding = swapDescription
|
||||
? Math.max(wrappedPadding - DescriptionWidth, 0)
|
||||
: wrappedPadding;
|
||||
|
||||
const clampedLeftPadding = Math.min(
|
||||
Math.min(wrappedPadding, swappedPadding),
|
||||
maxPadding
|
||||
);
|
||||
|
||||
useInput((_, key) => {
|
||||
if (key.upArrow) {
|
||||
setActiveSuggestionIndex(Math.max(0, activeSuggestionIndex - 1));
|
||||
setActiveSuggestion(suggestions[activeSuggestionIndex]);
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setActiveSuggestionIndex(
|
||||
Math.min(activeSuggestionIndex + 1, suggestions.length - 1)
|
||||
);
|
||||
setActiveSuggestion(suggestions[activeSuggestionIndex]);
|
||||
}
|
||||
});
|
||||
|
||||
const measureRef = useCallback((node: DOMElement) => {
|
||||
if (node !== null) {
|
||||
const { width } = measureElement(node);
|
||||
setWindowWidth(width);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (suggestions.length === 0) return <></>;
|
||||
|
||||
return (
|
||||
<Box ref={measureRef}>
|
||||
<Box paddingLeft={clampedLeftPadding}>
|
||||
{swapDescription ? (
|
||||
<>
|
||||
<Description description={activeDescription} />
|
||||
<SuggestionList
|
||||
suggestions={pagedSuggestions}
|
||||
activeSuggestionIdx={activePagedSuggestionIndex}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SuggestionList
|
||||
suggestions={pagedSuggestions}
|
||||
activeSuggestionIdx={activePagedSuggestionIndex}
|
||||
/>
|
||||
<Description description={activeDescription} />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Text,
|
||||
Box,
|
||||
useInput,
|
||||
render as inkRender,
|
||||
measureElement,
|
||||
DOMElement,
|
||||
} from "ink";
|
||||
import { getSuggestions } from "../runtime/runtime.js";
|
||||
import Cursor from "./cursor.js";
|
||||
import Suggestions from "./suggestions.js";
|
||||
import wrapAnsi from "wrap-ansi";
|
||||
|
||||
const Prompt = "> ";
|
||||
|
||||
function UI() {
|
||||
const [command, setCommand] = useState("");
|
||||
const [activeSuggestion, setActiveSuggestion] = useState<Fig.Suggestion>();
|
||||
const [suggestions, setSuggestions] = useState<Fig.Suggestion[]>([]);
|
||||
const [windowWidth, setWindowWidth] = useState(500);
|
||||
const leftPadding = getLeftPadding(windowWidth, command);
|
||||
|
||||
const measureRef = useCallback((node: DOMElement) => {
|
||||
if (node !== null) {
|
||||
const { width } = measureElement(node);
|
||||
setWindowWidth(width);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSuggestions(getSuggestions(command));
|
||||
}, [command]);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.backspace) {
|
||||
setCommand([...command].slice(0, -1).join(""));
|
||||
} else {
|
||||
setCommand(command + input);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" ref={measureRef}>
|
||||
<Box>
|
||||
<Text>
|
||||
<Text>
|
||||
{Prompt}
|
||||
{command}
|
||||
</Text>
|
||||
<Cursor />
|
||||
</Text>
|
||||
</Box>
|
||||
<Suggestions
|
||||
leftPadding={leftPadding}
|
||||
setActiveSuggestion={setActiveSuggestion}
|
||||
suggestions={suggestions}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export const render = () => {
|
||||
const { waitUntilExit } = inkRender(<UI />);
|
||||
return waitUntilExit();
|
||||
};
|
||||
|
||||
function getLeftPadding(windowWidth: number, command: string) {
|
||||
const wrappedText = wrapAnsi(command + "", windowWidth, {
|
||||
trim: false,
|
||||
hard: true,
|
||||
});
|
||||
const lines = wrappedText.split("\n");
|
||||
return (
|
||||
(lines.length - 1) * windowWidth +
|
||||
lines[lines.length - 1].length +
|
||||
Prompt.length
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@tsconfig/node18/tsconfig.json",
|
||||
"include": ["src/**/*"],
|
||||
"compilerOptions": {
|
||||
"jsx": "react",
|
||||
"outDir": "./build",
|
||||
"types": ["@withfig/autocomplete-types"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user