From e3931a9bbd760641f2cea849dcde348c75c99960 Mon Sep 17 00:00:00 2001 From: Vatsal Umrania Date: Wed, 27 May 2026 18:56:22 +0530 Subject: [PATCH] 01-project-setup-component-architecture --- .gitignore | 35 +++- bun.lock | 91 ++++++++++ package.json | 8 + packages/cli/package.json | 21 +++ packages/cli/src/components/border.tsx | 21 +++ .../src/components/command-menu/commands.tsx | 57 +++++++ .../command-menu/filter-commands.tsx | 8 + .../cli/src/components/command-menu/index.tsx | 72 ++++++++ .../cli/src/components/command-menu/types.ts | 10 ++ .../command-menu/use-command-menu.tsx | 102 ++++++++++++ packages/cli/src/components/header.tsx | 10 ++ packages/cli/src/components/input-bar.tsx | 156 ++++++++++++++++++ packages/cli/src/components/status-bar.tsx | 13 ++ packages/cli/src/index.tsx | 28 ++++ packages/cli/tsconfig.json | 7 + tsconfig.base.json | 28 ++++ 16 files changed, 666 insertions(+), 1 deletion(-) create mode 100644 bun.lock create mode 100644 package.json create mode 100644 packages/cli/package.json create mode 100644 packages/cli/src/components/border.tsx create mode 100644 packages/cli/src/components/command-menu/commands.tsx create mode 100644 packages/cli/src/components/command-menu/filter-commands.tsx create mode 100644 packages/cli/src/components/command-menu/index.tsx create mode 100644 packages/cli/src/components/command-menu/types.ts create mode 100644 packages/cli/src/components/command-menu/use-command-menu.tsx create mode 100644 packages/cli/src/components/header.tsx create mode 100644 packages/cli/src/components/input-bar.tsx create mode 100644 packages/cli/src/components/status-bar.tsx create mode 100644 packages/cli/src/index.tsx create mode 100644 packages/cli/tsconfig.json create mode 100644 tsconfig.base.json diff --git a/.gitignore b/.gitignore index b512c09..81649dc 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,34 @@ -node_modules \ No newline at end of file +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +*.log +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store \ No newline at end of file diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..8c9f010 --- /dev/null +++ b/bun.lock @@ -0,0 +1,91 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "nightcode", + }, + "packages/cli": { + "name": "@nightcode/cli", + "dependencies": { + "@opentui/core": "^0.2.10", + "@opentui/react": "^0.2.10", + "react": "^19.2.6", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/react": "^19.2.15", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@nightcode/cli": ["@nightcode/cli@workspace:packages/cli"], + + "@opentui/core": ["@opentui/core@0.2.14", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.14", "@opentui/core-darwin-x64": "0.2.14", "@opentui/core-linux-arm64": "0.2.14", "@opentui/core-linux-x64": "0.2.14", "@opentui/core-win32-arm64": "0.2.14", "@opentui/core-win32-x64": "0.2.14" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-17YCr3BqM9mhi/DdNVM+omgmrKQNIl0G5RzoaTFOHe4+OAhG+W3iooYi+WdsekJWSUOEwZqDRz0QBTZhOtgZsQ=="], + + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iS4NZQkOKX2EP5rsNjDcU7inDLcKhPaSBn8ENjDXKx2smOh7p/rgM2qlEaiLI3njtL784QoF+nxTzSXbEI6+Jw=="], + + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-ft4ZwYHCV0VtRMwQtHH5mAgwqRLHEXP26DWcwtCZWDEHDvghClBR0cj9UZLH5JAKn/j7ds5hZDCCZz+nUiEHYA=="], + + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-t/EKD4+rlzWuwYAa6NzGCmiBOHvF+hzjNwExj+dnSqX5wK7TU+VHl+N2iYUl4VhhJK94kPP6BnrF5GcHnZGFLg=="], + + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.14", "", { "os": "linux", "cpu": "x64" }, "sha512-Lvqmd92UZ+KZVnr0xU0jYj4XqnCSsBQJHS/FpYkJgZSAN7/4NmPlgMvQXIGW8a3BcFaGRKe8LuGpqM2E4oaX0Q=="], + + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-Jnuud29daaEoZNEp80dxUDLyUcwLr+g6SruHPyyWerOe7J10JE1ihJNkDlXLT7T49xdBGYRQlRkuNGwRfZWx5A=="], + + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.14", "", { "os": "win32", "cpu": "x64" }, "sha512-2ZUNh7yaAMUwAOK8oFEO28qXqPFrWPGGD0KHK1Gp97Th9XuVZniMLtbkbrFtDRh15+PVj7MyrG0N967W7rLr1A=="], + + "@opentui/react": ["@opentui/react@0.2.14", "", { "dependencies": { "@opentui/core": "0.2.14", "react-reconciler": "^0.33.0" }, "peerDependencies": { "react": ">=19.2.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-YXwhRBLKqHCp36UQOmQ2jXlxXLEPmiioez+LFmGu40SuuF1Fci1aHCeN+jf4qm2AmAvD3lLPQbYwEd/XNDI7cA=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + + "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + + "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + + "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], + + "react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="], + + "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], + + "ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], + + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + + "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..17635fc --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "name": "nightcode", + "version": "1.0.0", + "workspaces": ["packages/*"], + "scripts": { + "dev:cli":"bun run --watch packages/cli/src/index.tsx" + } +} \ No newline at end of file diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..acfd218 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,21 @@ +{ + "name": "@nightcode/cli", + "module": "src/index.tsx", + "type": "module", + "private": true, + "scripts": { + "dev": "bun run --watch src/index.tsx" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/react": "^19.2.15" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "@opentui/core": "^0.2.10", + "@opentui/react": "^0.2.10", + "react": "^19.2.6" + } +} \ No newline at end of file diff --git a/packages/cli/src/components/border.tsx b/packages/cli/src/components/border.tsx new file mode 100644 index 0000000..292f280 --- /dev/null +++ b/packages/cli/src/components/border.tsx @@ -0,0 +1,21 @@ +export const EmptyBorder = { + topLeft: "", + bottomLeft: "", + vertical: "", + topRight: "", + bottomRight: "", + horizontal: " ", + bottomT: "", + topT: "", + cross: "", + leftT: "", + rightT: "", +} + +export const SplitBorder = { + border: ["left" as const, "right" as const], + customBorderChars: { + ...EmptyBorder, + vertical: "┃", + }, +} \ No newline at end of file diff --git a/packages/cli/src/components/command-menu/commands.tsx b/packages/cli/src/components/command-menu/commands.tsx new file mode 100644 index 0000000..43e3399 --- /dev/null +++ b/packages/cli/src/components/command-menu/commands.tsx @@ -0,0 +1,57 @@ +import type { Command } from "./types"; + +export const COMMANDS: Command[] = [ + { + name: "new", + description: "Start a new conversation", + value: "/new", + }, + { + name: "agents", + description: "Switch agents", + value: "/agents", + }, + { + name: "models", + description: "Select AI model for generation", + value: "/models", + }, + { + name: "sessions", + description: "Browse past sessions", + value: "/sessions", + }, + { + name: "theme", + description: "Change color theme", + value: "/theme", + }, + { + name: "login", + description: "Sign in with your browser", + value: "/login", + }, + { + name: "logout", + description: "Sign out of your account", + value: "/logout", + }, + { + name: "upgrade", + description: "Buy more credits", + value: "/upgrade", + }, + { + name: "usage", + description: "Open billing portal in your browser", + value: "/usage", + }, + { + name: "exit", + description: "Quit the application", + value: "/exit", + action: (ctx) => { + ctx.exit(); + }, + }, +]; \ No newline at end of file diff --git a/packages/cli/src/components/command-menu/filter-commands.tsx b/packages/cli/src/components/command-menu/filter-commands.tsx new file mode 100644 index 0000000..cbed5c4 --- /dev/null +++ b/packages/cli/src/components/command-menu/filter-commands.tsx @@ -0,0 +1,8 @@ +import type { Command } from "./types"; +import { COMMANDS } from "./commands"; + +export function getFilteredCommands(query: string): Command[] { + if (query.length === 0) return COMMANDS; + return COMMANDS + .filter((cmd) => cmd.name.toLowerCase().startsWith(query.toLowerCase())); +}; \ No newline at end of file diff --git a/packages/cli/src/components/command-menu/index.tsx b/packages/cli/src/components/command-menu/index.tsx new file mode 100644 index 0000000..b5d4258 --- /dev/null +++ b/packages/cli/src/components/command-menu/index.tsx @@ -0,0 +1,72 @@ +import type { RefObject } from "react"; +import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"; +import { getFilteredCommands } from "./filter-commands"; +import { COMMANDS } from "./commands"; + +const MAX_VISIBLE_ITEMS = 8; + +// Align all command names in a fixed-width column so their descriptions +// start at the same horizontal position for a clean tabular look. +// The width adjusts to accommodate the longest command name. +const COMMAND_COL_WIDTH = Math.max(...COMMANDS.map((cmd) => cmd.name.length)) + 4; + +type CommandMenuProps = { + query: string; + selectedIndex: number; + scrollRef: RefObject; + onSelect: (index: number) => void; + onExecute: (index: number) => void; +}; + +export function CommandMenu({ + query, + selectedIndex, + scrollRef, + onSelect, + onExecute, +}: CommandMenuProps) { + const filtered = getFilteredCommands(query); + const visibleHeight = Math.min(filtered.length, MAX_VISIBLE_ITEMS); + + if (filtered.length === 0) { + return ( + + + No matching commands + + + ); + } + + return ( + + {filtered.map((cmd, i) => { + const isSelected = i === selectedIndex; + + return ( + onSelect(i)} + onMouseDown={() => onExecute(i)} + > + + + /{cmd.name} + + + + + {cmd.description} + + + + ); + })} + + ); +}; \ No newline at end of file diff --git a/packages/cli/src/components/command-menu/types.ts b/packages/cli/src/components/command-menu/types.ts new file mode 100644 index 0000000..e096225 --- /dev/null +++ b/packages/cli/src/components/command-menu/types.ts @@ -0,0 +1,10 @@ +export type CommandContext = { + exit: () => void; +}; + +export type Command = { + name: string; + description: string; + value: string; + action?: (ctx: CommandContext) => void | Promise; +}; \ No newline at end of file diff --git a/packages/cli/src/components/command-menu/use-command-menu.tsx b/packages/cli/src/components/command-menu/use-command-menu.tsx new file mode 100644 index 0000000..2f54377 --- /dev/null +++ b/packages/cli/src/components/command-menu/use-command-menu.tsx @@ -0,0 +1,102 @@ +import { useRef, useState, useMemo, type RefObject } from "react"; +import type { ScrollBoxRenderable } from "@opentui/core"; +import { useKeyboard } from "@opentui/react"; +import { getFilteredCommands } from "./filter-commands"; +import type { Command } from "./types"; + +type UseCommandMenuReturn = { + showCommandMenu: boolean; + commandQuery: string; + selectedIndex: number; + scrollRef: RefObject; + handleContentChange: (text: string) => void; + resolveCommand: (index: number) => Command | undefined; + setSelectedIndex: (index: number) => void; +}; + +export function useCommandMenu(): UseCommandMenuReturn { + const [textValue, setTextValue] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const [showCommandMenu, setShowCommandMenu] = useState(false); + const scrollRef = useRef(null); + + const commandQuery = showCommandMenu && textValue.startsWith("/") ? textValue.slice(1) : ""; + + const filteredCommands = useMemo(() => getFilteredCommands(commandQuery), [commandQuery]); + + const handleContentChange = (text: string) => { + setTextValue(text); + setSelectedIndex(0); + + // Jump back to the top of the list when the user types a new character + const scrollbox = scrollRef.current; + if (scrollbox) { + scrollbox.scrollTo(0); + } + + const prefix = text.startsWith("/") ? text.slice(1) : null; + if (prefix !== null && !prefix.includes(" ")) { + setShowCommandMenu(true); + } else { + setShowCommandMenu(false); + } + }; + + // Resolve a command at a specific index (returns the command, caller handles execution) + const resolveCommand = (index: number): Command | undefined => { + const command = filteredCommands[index]; + if (command) { + setShowCommandMenu(false); + } + return command; + }; + + // Arrow keys move selection; the list follows along when the highlight goes off-screen + useKeyboard((key) => { + if (!showCommandMenu) return; + + if (key.name === "escape") { + key.preventDefault(); + setShowCommandMenu(false); + } else if (key.name === "up") { + key.preventDefault(); + setSelectedIndex((i: number) => { + const newIndex = Math.max(0, i - 1); + // Keep the highlighted item visible when arrowing past the edge + const sb = scrollRef.current; + if (sb && newIndex < sb.scrollTop) { + sb.scrollTo(newIndex); + } + return newIndex; + }); + } else if (key.name === "down") { + key.preventDefault(); + setSelectedIndex((i: number) => { + if (filteredCommands.length === 0) { + return 0; + } + + const newIndex = Math.min(filteredCommands.length - 1, i + 1); + const sb = scrollRef.current; + if (sb) { + const viewportHeight = sb.viewport.height; + const visibleEnd = sb.scrollTop + viewportHeight - 1; + if (newIndex > visibleEnd) { + sb.scrollTo(newIndex - viewportHeight + 1); + } + } + return newIndex; + }); + } + }); + + return { + showCommandMenu, + commandQuery, + selectedIndex, + scrollRef, + handleContentChange, + resolveCommand, + setSelectedIndex, + }; +}; \ No newline at end of file diff --git a/packages/cli/src/components/header.tsx b/packages/cli/src/components/header.tsx new file mode 100644 index 0000000..ee542e2 --- /dev/null +++ b/packages/cli/src/components/header.tsx @@ -0,0 +1,10 @@ +export function Header() { + return ( + + + + + + + ); +}; \ No newline at end of file diff --git a/packages/cli/src/components/input-bar.tsx b/packages/cli/src/components/input-bar.tsx new file mode 100644 index 0000000..03bfbf9 --- /dev/null +++ b/packages/cli/src/components/input-bar.tsx @@ -0,0 +1,156 @@ +import { useRef, useCallback, useEffect } from "react"; +import type { TextareaRenderable } from "@opentui/core"; +import { useRenderer } from "@opentui/react"; +import type { KeyBinding } from "@opentui/core"; +import { EmptyBorder } from "./border"; +import { StatusBar } from "./status-bar"; +import { CommandMenu } from "./command-menu"; +import type { Command } from "./command-menu/types"; +import { useCommandMenu } from "./command-menu/use-command-menu"; + +type Props = { + onSubmit: (text: string) => void; + disabled?: boolean; +}; + +export const TEXTAREA_KEY_BINDINGS: KeyBinding[] = [ + { name: "return", action: "submit" }, + { name: "enter", action: "submit" }, + { name: "return", shift: true, action: "newline" }, + { name: "enter", shift: true, action: "newline" }, +]; + +export function InputBar({ onSubmit, disabled = false }: Props) { + const textareaRef = useRef(null); + const onSubmitRef = useRef<() => void>(() => {}); + const renderer = useRenderer(); + + const { + showCommandMenu, + commandQuery, + selectedIndex, + scrollRef, + handleContentChange, + resolveCommand, + setSelectedIndex, + } = useCommandMenu(); + + const handleTextareaContentChange = useCallback(() => { + const textarea = textareaRef.current; + if (!textarea) return; + + handleContentChange(textarea.plainText); + }, []); + + const handleSubmit = useCallback(() => { + if (disabled) return; + + const textarea = textareaRef.current; + if (!textarea) return; + + const text = textarea.plainText.trim(); + if (text.length === 0) return; + + onSubmit(text); + textarea.setText(""); + }, [disabled, onSubmit]) + + const handleCommand = useCallback(( + command: Command | undefined + ) => { + const textarea = textareaRef.current; + if (!textarea || !command) return; + + textarea.setText(""); + + if (command.action) { + command.action({ + exit: () => renderer.destroy(), + }); + } else { + textarea.insertText(command.value + " "); + } + }, [renderer]); + + const handleCommandExecute = useCallback( + (index: number) => { + const command = resolveCommand(index); + handleCommand(command); + }, + [resolveCommand, handleCommand], + ); + + // Wire up textarea submit handler once so it always reads the latest state. + useEffect(() => { + const textarea = textareaRef.current; + if (!textarea) return; + + textarea.onSubmit = () => { + onSubmitRef.current(); + }; + }, []); + + onSubmitRef.current = () => { + if (disabled) return; + + if (showCommandMenu) { + const command = resolveCommand(selectedIndex); + handleCommand(command); + return; + } + + handleSubmit(); + }; + + return ( + + + + {showCommandMenu && ( + + + + )} +