@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.
Files changed (51) hide show
  1. package/bin/nightcode.cjs +10 -0
  2. package/bin/nightcode.ts +5 -0
  3. package/package.json +50 -0
  4. package/src/bootstrap-env.ts +33 -0
  5. package/src/components/border.tsx +18 -0
  6. package/src/components/command-menu/commands.tsx +147 -0
  7. package/src/components/command-menu/filter-commands.ts +8 -0
  8. package/src/components/command-menu/index.tsx +74 -0
  9. package/src/components/command-menu/types.ts +20 -0
  10. package/src/components/command-menu/use-command-menu.ts +113 -0
  11. package/src/components/dialog-search-list.tsx +127 -0
  12. package/src/components/dialogs/agents-dialog.tsx +47 -0
  13. package/src/components/dialogs/index.tsx +4 -0
  14. package/src/components/dialogs/models-dialog.tsx +41 -0
  15. package/src/components/dialogs/sessions-dialog.tsx +94 -0
  16. package/src/components/dialogs/theme-dialog.tsx +58 -0
  17. package/src/components/header.tsx +10 -0
  18. package/src/components/input-bar.tsx +611 -0
  19. package/src/components/messages/bot-message.tsx +160 -0
  20. package/src/components/messages/error-message.tsx +36 -0
  21. package/src/components/messages/index.tsx +3 -0
  22. package/src/components/messages/user-message.tsx +36 -0
  23. package/src/components/session-shell.tsx +65 -0
  24. package/src/components/spinner.tsx +14 -0
  25. package/src/components/status-bar.tsx +23 -0
  26. package/src/hooks/use-chat.ts +107 -0
  27. package/src/hosted-config.ts +6 -0
  28. package/src/index.tsx +29 -0
  29. package/src/layouts/root-layout.tsx +25 -0
  30. package/src/layouts/themed-root.tsx +21 -0
  31. package/src/lib/api-client.ts +25 -0
  32. package/src/lib/auth.ts +38 -0
  33. package/src/lib/http-errors.ts +18 -0
  34. package/src/lib/local-tools.ts +170 -0
  35. package/src/lib/oauth.ts +166 -0
  36. package/src/lib/upgrade.ts +27 -0
  37. package/src/providers/dialog/index.tsx +123 -0
  38. package/src/providers/dialog/types.ts +6 -0
  39. package/src/providers/keyboard-layer/index.tsx +98 -0
  40. package/src/providers/prompt-config/index.tsx +52 -0
  41. package/src/providers/theme/index.tsx +75 -0
  42. package/src/providers/toast/index.tsx +118 -0
  43. package/src/providers/toast/types.ts +9 -0
  44. package/src/screens/home.tsx +39 -0
  45. package/src/screens/new-session.tsx +82 -0
  46. package/src/screens/session.tsx +171 -0
  47. package/src/theme.ts +568 -0
  48. package/vendor/shared/api-types.ts +11 -0
  49. package/vendor/shared/index.ts +20 -0
  50. package/vendor/shared/models.ts +72 -0
  51. 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);
@@ -0,0 +1,5 @@
1
+ import { bootstrapEnv } from "../src/bootstrap-env.ts";
2
+
3
+ bootstrapEnv();
4
+
5
+ await import("../src/index.tsx");
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
+ };
@@ -0,0 +1,4 @@
1
+ export { ThemeDialogContent } from "./theme-dialog";
2
+ export { SessionsDialogContent } from "./sessions-dialog";
3
+ export { AgentsDialogContent } from "./agents-dialog";
4
+ export { ModelsDialogContent } from "./models-dialog";