@suchitraswain/nightcode-cli 1.0.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/bin/nightcode.cjs +10 -0
- package/bin/nightcode.ts +5 -0
- package/package.json +50 -0
- package/src/bootstrap-env.ts +33 -0
- package/src/components/border.tsx +18 -0
- package/src/components/command-menu/commands.tsx +147 -0
- package/src/components/command-menu/filter-commands.ts +8 -0
- package/src/components/command-menu/index.tsx +74 -0
- package/src/components/command-menu/types.ts +20 -0
- package/src/components/command-menu/use-command-menu.ts +113 -0
- package/src/components/dialog-search-list.tsx +127 -0
- package/src/components/dialogs/agents-dialog.tsx +47 -0
- package/src/components/dialogs/index.tsx +4 -0
- package/src/components/dialogs/models-dialog.tsx +41 -0
- package/src/components/dialogs/sessions-dialog.tsx +94 -0
- package/src/components/dialogs/theme-dialog.tsx +58 -0
- package/src/components/header.tsx +10 -0
- package/src/components/input-bar.tsx +611 -0
- package/src/components/messages/bot-message.tsx +160 -0
- package/src/components/messages/error-message.tsx +36 -0
- package/src/components/messages/index.tsx +3 -0
- package/src/components/messages/user-message.tsx +36 -0
- package/src/components/session-shell.tsx +65 -0
- package/src/components/spinner.tsx +14 -0
- package/src/components/status-bar.tsx +23 -0
- package/src/hooks/use-chat.ts +107 -0
- package/src/hosted-config.ts +6 -0
- package/src/index.tsx +29 -0
- package/src/layouts/root-layout.tsx +25 -0
- package/src/layouts/themed-root.tsx +21 -0
- package/src/lib/api-client.ts +25 -0
- package/src/lib/auth.ts +38 -0
- package/src/lib/http-errors.ts +18 -0
- package/src/lib/local-tools.ts +170 -0
- package/src/lib/oauth.ts +166 -0
- package/src/lib/upgrade.ts +27 -0
- package/src/providers/dialog/index.tsx +123 -0
- package/src/providers/dialog/types.ts +6 -0
- package/src/providers/keyboard-layer/index.tsx +98 -0
- package/src/providers/prompt-config/index.tsx +52 -0
- package/src/providers/theme/index.tsx +75 -0
- package/src/providers/toast/index.tsx +118 -0
- package/src/providers/toast/types.ts +9 -0
- package/src/screens/home.tsx +39 -0
- package/src/screens/new-session.tsx +82 -0
- package/src/screens/session.tsx +171 -0
- package/src/theme.ts +568 -0
- package/vendor/shared/api-types.ts +11 -0
- package/vendor/shared/index.ts +20 -0
- package/vendor/shared/models.ts +72 -0
- package/vendor/shared/schemas.ts +87 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const { spawnSync } = require("node:child_process");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
|
|
7
|
+
const entry = path.join(__dirname, "nightcode.ts");
|
|
8
|
+
const result = spawnSync("bun", [entry], { stdio: "inherit" });
|
|
9
|
+
|
|
10
|
+
process.exit(result.status ?? 1);
|
package/bin/nightcode.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@suchitraswain/nightcode-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "NightCode terminal AI coding agent — connects to the hosted API",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/SuchitraSwain/nightcode.git",
|
|
10
|
+
"directory": "packages/cli"
|
|
11
|
+
},
|
|
12
|
+
"keywords": ["nightcode", "cli", "ai", "terminal", "coding-agent"],
|
|
13
|
+
"bin": {
|
|
14
|
+
"nightcode": "./bin/nightcode.cjs"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"bin",
|
|
18
|
+
"src",
|
|
19
|
+
"vendor"
|
|
20
|
+
],
|
|
21
|
+
"imports": {
|
|
22
|
+
"@nightcode/shared": "./vendor/shared/index.ts"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"dev": "bun run --watch src/index.tsx",
|
|
26
|
+
"prepare-package": "bash scripts/prepare-package.sh",
|
|
27
|
+
"prepublishOnly": "bun run prepare-package"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"bun": ">=1.0.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"typescript": "^5"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@ai-sdk/react": "^3.0.186",
|
|
37
|
+
"@opentui/core": "^0.1.97",
|
|
38
|
+
"@opentui/react": "^0.1.97",
|
|
39
|
+
"ai": "^6.0.184",
|
|
40
|
+
"date-fns": "^4.1.0",
|
|
41
|
+
"dotenv": "^17.4.2",
|
|
42
|
+
"hono": "^4.12.12",
|
|
43
|
+
"open": "^11.0.0",
|
|
44
|
+
"opentui-spinner": "^0.0.6",
|
|
45
|
+
"pretty-ms": "^9.3.0",
|
|
46
|
+
"react": "^19.2.4",
|
|
47
|
+
"react-router": "^7.14.0",
|
|
48
|
+
"zod": "^4.3.6"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import dotenv from "dotenv";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { HOSTED_CONFIG } from "./hosted-config";
|
|
6
|
+
|
|
7
|
+
const CLI_ENV_KEYS = [
|
|
8
|
+
"API_URL",
|
|
9
|
+
"CLERK_FRONTEND_API",
|
|
10
|
+
"CLERK_OAUTH_CLIENT_ID",
|
|
11
|
+
"CLERK_OAUTH_CLIENT_SECRET",
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
14
|
+
function loadEnvFile(filePath: string) {
|
|
15
|
+
if (!fs.existsSync(filePath)) return;
|
|
16
|
+
dotenv.config({ path: filePath, quiet: true, override: false });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Apply hosted defaults for any CLI env var not already set. */
|
|
20
|
+
export function bootstrapEnv() {
|
|
21
|
+
// Monorepo dev: repo root .env
|
|
22
|
+
loadEnvFile(path.resolve(import.meta.dirname, "../../../.env"));
|
|
23
|
+
|
|
24
|
+
// Published CLI: optional user override
|
|
25
|
+
loadEnvFile(path.join(os.homedir(), ".nightcode", ".env"));
|
|
26
|
+
|
|
27
|
+
for (const key of CLI_ENV_KEYS) {
|
|
28
|
+
if (!process.env[key]) {
|
|
29
|
+
const value = HOSTED_CONFIG[key as keyof typeof HOSTED_CONFIG];
|
|
30
|
+
if (value) process.env[key] = value;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const EmptyBorder = {
|
|
2
|
+
topLeft: "",
|
|
3
|
+
bottomLeft: "",
|
|
4
|
+
vertical: "",
|
|
5
|
+
topRight: "",
|
|
6
|
+
bottomRight: "",
|
|
7
|
+
horizontal: " ",
|
|
8
|
+
bottomT: "",
|
|
9
|
+
topT: "",
|
|
10
|
+
cross: "",
|
|
11
|
+
leftT: "",
|
|
12
|
+
rightT: "",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const SplitBorderChars = {
|
|
16
|
+
...EmptyBorder,
|
|
17
|
+
vertical: "┃",
|
|
18
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { SUPPORTED_CHAT_MODELS } from "@nightcode/shared";
|
|
2
|
+
import {
|
|
3
|
+
AgentsDialogContent,
|
|
4
|
+
ModelsDialogContent,
|
|
5
|
+
SessionsDialogContent,
|
|
6
|
+
ThemeDialogContent,
|
|
7
|
+
} from "../dialogs";
|
|
8
|
+
import type { Command } from "./types";
|
|
9
|
+
|
|
10
|
+
import { performLogin } from "../../lib/oauth";
|
|
11
|
+
import { clearAuth } from "../../lib/auth";
|
|
12
|
+
|
|
13
|
+
import { openBillingPortal, openUpgradeCheckout } from "../../lib/upgrade";
|
|
14
|
+
|
|
15
|
+
export const COMMANDS: Command[] = [
|
|
16
|
+
{
|
|
17
|
+
name: "new",
|
|
18
|
+
description: "Start a new conversation",
|
|
19
|
+
value: "/new",
|
|
20
|
+
action: (ctx) => {
|
|
21
|
+
ctx.navigate("/");
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "agents",
|
|
26
|
+
description: "Switch agents",
|
|
27
|
+
value: "/agents",
|
|
28
|
+
action: (ctx) => {
|
|
29
|
+
ctx.dialog.open({
|
|
30
|
+
title: "Select Agent",
|
|
31
|
+
children: <AgentsDialogContent currentMode={ctx.mode} onSelectMode={ctx.setMode} />,
|
|
32
|
+
})
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: "models",
|
|
37
|
+
description: "Select AI model for generation",
|
|
38
|
+
value: "/models",
|
|
39
|
+
action: (ctx) => {
|
|
40
|
+
ctx.dialog.open({
|
|
41
|
+
title: "Select Model",
|
|
42
|
+
children: (
|
|
43
|
+
<ModelsDialogContent
|
|
44
|
+
models={SUPPORTED_CHAT_MODELS.map((model) => model.id)}
|
|
45
|
+
onSelectModel={ctx.setModel}
|
|
46
|
+
/>
|
|
47
|
+
),
|
|
48
|
+
})
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "sessions",
|
|
53
|
+
description: "Browse past sessions",
|
|
54
|
+
value: "/sessions",
|
|
55
|
+
action: (ctx) => {
|
|
56
|
+
ctx.dialog.open({
|
|
57
|
+
title: "Sessions",
|
|
58
|
+
children: <SessionsDialogContent />,
|
|
59
|
+
})
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "theme",
|
|
64
|
+
description: "Change color theme",
|
|
65
|
+
value: "/theme",
|
|
66
|
+
action: (ctx) => {
|
|
67
|
+
ctx.dialog.open({
|
|
68
|
+
title: "Select Theme",
|
|
69
|
+
children: <ThemeDialogContent />,
|
|
70
|
+
})
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: "login",
|
|
75
|
+
description: "Sign in with your browser",
|
|
76
|
+
value: "/login",
|
|
77
|
+
action: async (ctx) => {
|
|
78
|
+
ctx.toast.show({ message: "Opening browser to sign in..." });
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
await performLogin();
|
|
82
|
+
ctx.toast.show({ variant: "success", message: "Signed in" });
|
|
83
|
+
} catch (error) {
|
|
84
|
+
const message = error instanceof Error
|
|
85
|
+
? error.message
|
|
86
|
+
: "Sign in failed or timed out";
|
|
87
|
+
|
|
88
|
+
ctx.toast.show({ variant: "error", message });
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: "logout",
|
|
94
|
+
description: "Sign out of your account",
|
|
95
|
+
value: "/logout",
|
|
96
|
+
action: (ctx) => {
|
|
97
|
+
clearAuth();
|
|
98
|
+
ctx.toast.show({ variant: "success", message: "Signed out" });
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "upgrade",
|
|
103
|
+
description: "Buy more credits",
|
|
104
|
+
value: "/upgrade",
|
|
105
|
+
action: async (ctx) => {
|
|
106
|
+
ctx.toast.show({ message: "Opening credits checkout..." });
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
await openUpgradeCheckout();
|
|
110
|
+
ctx.toast.show({
|
|
111
|
+
variant: "success",
|
|
112
|
+
message: "Checkout opened in browser",
|
|
113
|
+
});
|
|
114
|
+
} catch (error) {
|
|
115
|
+
const message = error instanceof Error ? error.message : "Failed to open checkout";
|
|
116
|
+
ctx.toast.show({ variant: "error", message });
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: "usage",
|
|
122
|
+
description: "Open billing portal in your browser",
|
|
123
|
+
value: "/usage",
|
|
124
|
+
action: async (ctx) => {
|
|
125
|
+
ctx.toast.show({ message: "Opening billing portal..." });
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
await openBillingPortal();
|
|
129
|
+
ctx.toast.show({
|
|
130
|
+
variant: "success",
|
|
131
|
+
message: "Billing portal opened in browser",
|
|
132
|
+
});
|
|
133
|
+
} catch (error) {
|
|
134
|
+
const message = error instanceof Error ? error.message : "Failed to open billing portal";
|
|
135
|
+
ctx.toast.show({ variant: "error", message });
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: "exit",
|
|
141
|
+
description: "Quit the application",
|
|
142
|
+
value: "/exit",
|
|
143
|
+
action: (ctx) => {
|
|
144
|
+
ctx.exit();
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
];
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Command } from "./types";
|
|
2
|
+
import { COMMANDS } from "./commands";
|
|
3
|
+
|
|
4
|
+
export function getFilteredCommands(query: string): Command[] {
|
|
5
|
+
if (query.length === 0) return COMMANDS;
|
|
6
|
+
return COMMANDS
|
|
7
|
+
.filter((cmd) => cmd.name.toLowerCase().startsWith(query.toLowerCase()));
|
|
8
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { RefObject } from "react";
|
|
2
|
+
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
|
|
3
|
+
import { getFilteredCommands } from "./filter-commands";
|
|
4
|
+
import { COMMANDS } from "./commands";
|
|
5
|
+
import { useTheme } from "../../providers/theme";
|
|
6
|
+
|
|
7
|
+
const MAX_VISIBLE_ITEMS = 8;
|
|
8
|
+
|
|
9
|
+
// Align all command names in a fixed-width column so their descriptions
|
|
10
|
+
// start at the same horizontal position for a clean tabular look.
|
|
11
|
+
// The width adjusts to accommodate the longest command name.
|
|
12
|
+
const COMMAND_COL_WIDTH = Math.max(...COMMANDS.map((cmd) => cmd.name.length)) + 4;
|
|
13
|
+
|
|
14
|
+
type CommandMenuProps = {
|
|
15
|
+
query: string;
|
|
16
|
+
selectedIndex: number;
|
|
17
|
+
scrollRef: RefObject<ScrollBoxRenderable | null>;
|
|
18
|
+
onSelect: (index: number) => void;
|
|
19
|
+
onExecute: (index: number) => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function CommandMenu({
|
|
23
|
+
query,
|
|
24
|
+
selectedIndex,
|
|
25
|
+
scrollRef,
|
|
26
|
+
onSelect,
|
|
27
|
+
onExecute,
|
|
28
|
+
}: CommandMenuProps) {
|
|
29
|
+
const { colors } = useTheme();
|
|
30
|
+
const filtered = getFilteredCommands(query);
|
|
31
|
+
const visibleHeight = Math.min(filtered.length, MAX_VISIBLE_ITEMS);
|
|
32
|
+
|
|
33
|
+
if (filtered.length === 0) {
|
|
34
|
+
return (
|
|
35
|
+
<box paddingX={1}>
|
|
36
|
+
<text attributes={TextAttributes.DIM}>
|
|
37
|
+
No matching commands
|
|
38
|
+
</text>
|
|
39
|
+
</box>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<scrollbox ref={scrollRef} height={visibleHeight}>
|
|
45
|
+
{filtered.map((cmd, i) => {
|
|
46
|
+
const isSelected = i === selectedIndex;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<box
|
|
50
|
+
key={cmd.value}
|
|
51
|
+
flexDirection="row"
|
|
52
|
+
paddingX={1}
|
|
53
|
+
height={1}
|
|
54
|
+
overflow="hidden"
|
|
55
|
+
backgroundColor={isSelected ? colors.selection : undefined}
|
|
56
|
+
onMouseMove={() => onSelect(i)}
|
|
57
|
+
onMouseDown={() => onExecute(i)}
|
|
58
|
+
>
|
|
59
|
+
<box width={COMMAND_COL_WIDTH} flexShrink={0}>
|
|
60
|
+
<text selectable={false} fg={isSelected ? "black" : "white"}>
|
|
61
|
+
/{cmd.name}
|
|
62
|
+
</text>
|
|
63
|
+
</box>
|
|
64
|
+
<box flexGrow={1} flexShrink={1} overflow="hidden">
|
|
65
|
+
<text selectable={false} fg={isSelected ? "black" : "gray"}>
|
|
66
|
+
{cmd.description}
|
|
67
|
+
</text>
|
|
68
|
+
</box>
|
|
69
|
+
</box>
|
|
70
|
+
);
|
|
71
|
+
})}
|
|
72
|
+
</scrollbox>
|
|
73
|
+
);
|
|
74
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { DialogContextValue } from "../../providers/dialog";
|
|
2
|
+
import type { ToastContextValue } from "../../providers/toast";
|
|
3
|
+
import type { ModeType, SupportedChatModelId } from "@nightcode/shared";
|
|
4
|
+
|
|
5
|
+
export type CommandContext = {
|
|
6
|
+
exit: () => void;
|
|
7
|
+
toast: ToastContextValue;
|
|
8
|
+
dialog: DialogContextValue;
|
|
9
|
+
navigate: (path: string) => void;
|
|
10
|
+
mode: ModeType;
|
|
11
|
+
setMode: (mode: ModeType) => void;
|
|
12
|
+
setModel: (model: SupportedChatModelId) => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type Command = {
|
|
16
|
+
name: string;
|
|
17
|
+
description: string;
|
|
18
|
+
value: string;
|
|
19
|
+
action?: (ctx: CommandContext) => void | Promise<void>;
|
|
20
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useRef, useState, useMemo, type RefObject } from "react";
|
|
2
|
+
import type { ScrollBoxRenderable } from "@opentui/core";
|
|
3
|
+
import { useKeyboard } from "@opentui/react";
|
|
4
|
+
import { getFilteredCommands } from "./filter-commands";
|
|
5
|
+
import type { Command } from "./types";
|
|
6
|
+
import { useKeyboardLayer } from "../../providers/keyboard-layer";
|
|
7
|
+
|
|
8
|
+
type UseCommandMenuReturn = {
|
|
9
|
+
showCommandMenu: boolean;
|
|
10
|
+
commandQuery: string;
|
|
11
|
+
selectedIndex: number;
|
|
12
|
+
scrollRef: RefObject<ScrollBoxRenderable | null>;
|
|
13
|
+
handleContentChange: (text: string) => void;
|
|
14
|
+
resolveCommand: (index: number) => Command | undefined;
|
|
15
|
+
setSelectedIndex: (index: number) => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function useCommandMenu(): UseCommandMenuReturn {
|
|
19
|
+
const [textValue, setTextValue] = useState("");
|
|
20
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
21
|
+
const [showCommandMenu, setShowCommandMenu] = useState(false);
|
|
22
|
+
const scrollRef = useRef<ScrollBoxRenderable>(null);
|
|
23
|
+
const { push, pop, isTopLayer } = useKeyboardLayer();
|
|
24
|
+
|
|
25
|
+
const commandQuery = showCommandMenu && textValue.startsWith("/") ? textValue.slice(1) : "";
|
|
26
|
+
|
|
27
|
+
const filteredCommands = useMemo(() => getFilteredCommands(commandQuery), [commandQuery]);
|
|
28
|
+
|
|
29
|
+
const close = () => {
|
|
30
|
+
setShowCommandMenu(false);
|
|
31
|
+
pop("command");
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const handleContentChange = (text: string) => {
|
|
35
|
+
setTextValue(text);
|
|
36
|
+
setSelectedIndex(0);
|
|
37
|
+
|
|
38
|
+
// Jump back to the top of the list when the user types a new character
|
|
39
|
+
const scrollbox = scrollRef.current;
|
|
40
|
+
if (scrollbox) {
|
|
41
|
+
scrollbox.scrollTo(0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const prefix = text.startsWith("/") ? text.slice(1) : null;
|
|
45
|
+
if (prefix !== null && !prefix.includes(" ")) {
|
|
46
|
+
setShowCommandMenu(true);
|
|
47
|
+
push("command", () => {
|
|
48
|
+
close();
|
|
49
|
+
return true;
|
|
50
|
+
});
|
|
51
|
+
} else {
|
|
52
|
+
close();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Resolve a command at a specific index (returns the command, caller handles execution)
|
|
57
|
+
const resolveCommand = (index: number): Command | undefined => {
|
|
58
|
+
const command = filteredCommands[index];
|
|
59
|
+
if (command) {
|
|
60
|
+
close();
|
|
61
|
+
}
|
|
62
|
+
return command;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Arrow keys move selection; the list follows along when the highlight goes off-screen
|
|
66
|
+
useKeyboard((key) => {
|
|
67
|
+
if (!showCommandMenu || !isTopLayer("command")) return;
|
|
68
|
+
|
|
69
|
+
if (key.name === "escape") {
|
|
70
|
+
key.preventDefault();
|
|
71
|
+
close();
|
|
72
|
+
} else if (key.name === "up") {
|
|
73
|
+
key.preventDefault();
|
|
74
|
+
setSelectedIndex((i: number) => {
|
|
75
|
+
const newIndex = Math.max(0, i - 1);
|
|
76
|
+
// Keep the highlighted item visible when arrowing past the edge
|
|
77
|
+
const sb = scrollRef.current;
|
|
78
|
+
if (sb && newIndex < sb.scrollTop) {
|
|
79
|
+
sb.scrollTo(newIndex);
|
|
80
|
+
}
|
|
81
|
+
return newIndex;
|
|
82
|
+
});
|
|
83
|
+
} else if (key.name === "down") {
|
|
84
|
+
key.preventDefault();
|
|
85
|
+
setSelectedIndex((i: number) => {
|
|
86
|
+
if (filteredCommands.length === 0) {
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const newIndex = Math.min(filteredCommands.length - 1, i + 1);
|
|
91
|
+
const sb = scrollRef.current;
|
|
92
|
+
if (sb) {
|
|
93
|
+
const viewportHeight = sb.viewport.height;
|
|
94
|
+
const visibleEnd = sb.scrollTop + viewportHeight - 1;
|
|
95
|
+
if (newIndex > visibleEnd) {
|
|
96
|
+
sb.scrollTo(newIndex - viewportHeight + 1);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return newIndex;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
showCommandMenu,
|
|
106
|
+
commandQuery,
|
|
107
|
+
selectedIndex,
|
|
108
|
+
scrollRef,
|
|
109
|
+
handleContentChange,
|
|
110
|
+
resolveCommand,
|
|
111
|
+
setSelectedIndex,
|
|
112
|
+
};
|
|
113
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { useCallback, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import { TextAttributes, type InputRenderable, type ScrollBoxRenderable } from "@opentui/core";
|
|
3
|
+
import { useKeyboard } from "@opentui/react";
|
|
4
|
+
import { useKeyboardLayer } from "../providers/keyboard-layer";
|
|
5
|
+
import { useTheme } from "../providers/theme";
|
|
6
|
+
|
|
7
|
+
const MAX_VISIBLE_ITEMS = 6;
|
|
8
|
+
|
|
9
|
+
type DialogSearchListProps<T> = {
|
|
10
|
+
items: T[];
|
|
11
|
+
onSelect: (item: T) => void;
|
|
12
|
+
onHighlight?: (item: T) => void;
|
|
13
|
+
filterFn: (item: T, query: string) => boolean;
|
|
14
|
+
renderItem: (item: T, isSelected: boolean) => ReactNode;
|
|
15
|
+
getKey: (item: T) => string;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
emptyText?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function DialogSearchList<T>({
|
|
21
|
+
items,
|
|
22
|
+
onSelect,
|
|
23
|
+
onHighlight,
|
|
24
|
+
filterFn,
|
|
25
|
+
renderItem,
|
|
26
|
+
getKey,
|
|
27
|
+
placeholder = "Search",
|
|
28
|
+
emptyText = "No results",
|
|
29
|
+
}: DialogSearchListProps<T>) {
|
|
30
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
31
|
+
const [searchValue, setSearchValue] = useState("");
|
|
32
|
+
const inputRef = useRef<InputRenderable>(null);
|
|
33
|
+
const scrollRef = useRef<ScrollBoxRenderable>(null);
|
|
34
|
+
const { isTopLayer } = useKeyboardLayer();
|
|
35
|
+
const { colors } = useTheme();
|
|
36
|
+
|
|
37
|
+
const handleContentChange = useCallback(() => {
|
|
38
|
+
const text = inputRef.current?.value ?? "";
|
|
39
|
+
setSearchValue(text);
|
|
40
|
+
setSelectedIndex(0);
|
|
41
|
+
|
|
42
|
+
const scrollbox = scrollRef.current;
|
|
43
|
+
if (scrollbox) {
|
|
44
|
+
scrollbox.scrollTo(0);
|
|
45
|
+
}
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
const filtered = searchValue
|
|
49
|
+
? items.filter((item) => filterFn(item, searchValue)) : items;
|
|
50
|
+
|
|
51
|
+
const visibleHeight = Math.min(filtered.length, MAX_VISIBLE_ITEMS);
|
|
52
|
+
|
|
53
|
+
useKeyboard((key) => {
|
|
54
|
+
if (!isTopLayer("dialog")) return;
|
|
55
|
+
|
|
56
|
+
if (key.name === "return" || key.name === "enter") {
|
|
57
|
+
const item = filtered[selectedIndex];
|
|
58
|
+
if (item) {
|
|
59
|
+
onSelect(item);
|
|
60
|
+
}
|
|
61
|
+
} else if (key.name === "up") {
|
|
62
|
+
setSelectedIndex((i) => {
|
|
63
|
+
const newIndex = Math.max(0, i - 1);
|
|
64
|
+
const sb = scrollRef.current;
|
|
65
|
+
if (sb && newIndex < sb.scrollTop) {
|
|
66
|
+
sb.scrollTo(newIndex);
|
|
67
|
+
}
|
|
68
|
+
const item = filtered[newIndex];
|
|
69
|
+
if (item && onHighlight) onHighlight(item);
|
|
70
|
+
return newIndex;
|
|
71
|
+
});
|
|
72
|
+
} else if (key.name === "down") {
|
|
73
|
+
setSelectedIndex((i) => {
|
|
74
|
+
const newIndex = Math.min(filtered.length - 1, i + 1);
|
|
75
|
+
const sb = scrollRef.current;
|
|
76
|
+
if (sb) {
|
|
77
|
+
const viewportHeight = sb.viewport.height;
|
|
78
|
+
const visibleEnd = sb.scrollTop + viewportHeight - 1;
|
|
79
|
+
if (newIndex > visibleEnd) {
|
|
80
|
+
sb.scrollTo(newIndex - viewportHeight + 1);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const item = filtered[newIndex];
|
|
84
|
+
if (item && onHighlight) onHighlight(item);
|
|
85
|
+
return newIndex;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<box flexDirection="column" gap={1}>
|
|
92
|
+
<input
|
|
93
|
+
ref={inputRef}
|
|
94
|
+
placeholder={placeholder}
|
|
95
|
+
focused
|
|
96
|
+
onContentChange={handleContentChange}
|
|
97
|
+
/>
|
|
98
|
+
{filtered.length === 0 ? (
|
|
99
|
+
<text attributes={TextAttributes.DIM}>
|
|
100
|
+
{emptyText}
|
|
101
|
+
</text>
|
|
102
|
+
) : (
|
|
103
|
+
<scrollbox ref={scrollRef} height={visibleHeight}>
|
|
104
|
+
{filtered.map((item, i) => {
|
|
105
|
+
const isSelected = i === selectedIndex;
|
|
106
|
+
return (
|
|
107
|
+
<box
|
|
108
|
+
key={getKey(item)}
|
|
109
|
+
flexDirection="row"
|
|
110
|
+
height={1}
|
|
111
|
+
overflow="hidden"
|
|
112
|
+
backgroundColor={isSelected ? colors.selection : undefined}
|
|
113
|
+
onMouseMove={() => {
|
|
114
|
+
setSelectedIndex(i);
|
|
115
|
+
if (onHighlight) onHighlight(item);
|
|
116
|
+
}}
|
|
117
|
+
onMouseDown={() => onSelect(item)}
|
|
118
|
+
>
|
|
119
|
+
{renderItem(item, isSelected)}
|
|
120
|
+
</box>
|
|
121
|
+
)
|
|
122
|
+
})}
|
|
123
|
+
</scrollbox>
|
|
124
|
+
)}
|
|
125
|
+
</box>
|
|
126
|
+
);
|
|
127
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { useDialog } from "../../providers/dialog";
|
|
3
|
+
import { DialogSearchList } from "../dialog-search-list";
|
|
4
|
+
import { Mode, type ModeType } from "@nightcode/shared";
|
|
5
|
+
|
|
6
|
+
const AVAILABLE_MODES: ModeType[] = [Mode.BUILD, Mode.PLAN];
|
|
7
|
+
|
|
8
|
+
type AgentsDialogContentProps = {
|
|
9
|
+
currentMode: ModeType;
|
|
10
|
+
onSelectMode: (mode: ModeType) => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function getModeLabel(mode: ModeType) {
|
|
14
|
+
return mode === Mode.PLAN ? "Plan" : "Build";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const AgentsDialogContent = ({
|
|
18
|
+
currentMode,
|
|
19
|
+
onSelectMode
|
|
20
|
+
}: AgentsDialogContentProps) => {
|
|
21
|
+
const dialog = useDialog();
|
|
22
|
+
|
|
23
|
+
const handleSelect = useCallback(
|
|
24
|
+
(nextMode: ModeType) => {
|
|
25
|
+
onSelectMode(nextMode);
|
|
26
|
+
dialog.close();
|
|
27
|
+
},
|
|
28
|
+
[onSelectMode, dialog],
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<DialogSearchList
|
|
33
|
+
items={AVAILABLE_MODES}
|
|
34
|
+
onSelect={handleSelect}
|
|
35
|
+
filterFn={(item, query) => getModeLabel(item).toLowerCase().includes(query.toLowerCase())}
|
|
36
|
+
renderItem={(item, isSelected) => (
|
|
37
|
+
<text selectable={false} fg={isSelected ? "black" : "white"}>
|
|
38
|
+
{item === currentMode ? " • " : " "}
|
|
39
|
+
{getModeLabel(item)}
|
|
40
|
+
</text>
|
|
41
|
+
)}
|
|
42
|
+
getKey={(item) => item}
|
|
43
|
+
placeholder="Search agents"
|
|
44
|
+
emptyText="No matching agents"
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
};
|