feat: implement starting UI for root command

Signed-off-by: Chapman Pendery <cpendery@vt.edu>
This commit is contained in:
Chapman Pendery
2023-10-04 18:22:22 -07:00
parent 50b58c9afc
commit 16d038dee3
11 changed files with 1769 additions and 1 deletions
+2 -1
View File
@@ -7,4 +7,5 @@ clac
.env*
dist/
t*.md
__pycache__
__pycache__
build/
+1415
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -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"
}
}
+17
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
import {render} from "../ui/ui.js"
export const action = () => {
render()
}
+16
View File
@@ -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();
+29
View File
@@ -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;
};
+16
View File
@@ -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>;
}
+143
View File
@@ -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>
);
}
+79
View File
@@ -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
);
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@tsconfig/node18/tsconfig.json",
"include": ["src/**/*"],
"compilerOptions": {
"jsx": "react",
"outDir": "./build",
"types": ["@withfig/autocomplete-types"]
}
}