@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,170 @@
1
+ import { mkdir, readFile, readdir, stat, writeFile } from "fs/promises";
2
+ import { dirname, isAbsolute, join, relative, resolve } from "path";
3
+ import { toolInputSchemas, Mode, type ModeType } from "@nightcode/shared";
4
+
5
+ const MAX_FILE_SIZE = 10_000;
6
+ const MAX_RESULTS = 200;
7
+ const MAX_MATCHES = 50;
8
+ const MAX_OUTPUT = 20_000;
9
+ const DEFAULT_TIMEOUT = 30_000;
10
+
11
+ function resolveInsideCwd(path: string) {
12
+ const cwd = process.cwd();
13
+ const resolved = resolve(cwd, path);
14
+ const rel = relative(cwd, resolved);
15
+
16
+ if (rel.startsWith("..") || isAbsolute(rel)) {
17
+ throw new Error("Path is outside the project directory");
18
+ }
19
+
20
+ return { cwd, resolved };
21
+ }
22
+
23
+ function truncate(value: string, limit: number) {
24
+ return value.length > limit
25
+ ? `${value.slice(0, limit)}\n... (truncated, ${value.length} total chars)`
26
+ : value;
27
+ }
28
+
29
+ export async function executeLocalTool(toolName: string, input: unknown, mode: ModeType) {
30
+ if (mode === Mode.PLAN && !["readFile", "listDirectory", "glob", "grep"].includes(toolName)) {
31
+ throw new Error(`Tool ${toolName} is not available in PLAN mode`);
32
+ }
33
+
34
+ switch (toolName) {
35
+ case "readFile": {
36
+ const { path } = toolInputSchemas.readFile.parse(input);
37
+ const { resolved } = resolveInsideCwd(path);
38
+ const content = await readFile(resolved, "utf-8");
39
+ return content.length > MAX_FILE_SIZE
40
+ ? { content: content.slice(0, MAX_FILE_SIZE), truncated: true, totalLength: content.length }
41
+ : { content };
42
+ }
43
+ case "listDirectory": {
44
+ const { path } = toolInputSchemas.listDirectory.parse(input);
45
+ const { cwd, resolved } = resolveInsideCwd(path);
46
+ const entries = await readdir(resolved);
47
+ const results: { name: string; type: "file" | "directory" }[] = [];
48
+
49
+ for (const entry of entries) {
50
+ if (entry.startsWith(".") || entry === "node_modules") continue;
51
+ const info = await stat(join(resolved, entry));
52
+ results.push({ name: entry, type: info.isDirectory() ? "directory" : "file" });
53
+ }
54
+
55
+ results.sort((a, b) =>
56
+ a.type !== b.type ? (a.type === "directory" ? -1 : 1) : a.name.localeCompare(b.name),
57
+ );
58
+ return { path: relative(cwd, resolved) || ".", entries: results };
59
+ }
60
+ case "glob": {
61
+ const { pattern, path } = toolInputSchemas.glob.parse(input);
62
+ const { cwd, resolved } = resolveInsideCwd(path);
63
+ const glob = new Bun.Glob(pattern);
64
+ const files: string[] = [];
65
+ let truncated = false;
66
+
67
+ for await (const match of glob.scan({ cwd: resolved, dot: false, onlyFiles: true })) {
68
+ if (match.includes("node_modules")) continue;
69
+ if (files.length >= MAX_RESULTS) {
70
+ truncated = true;
71
+ break;
72
+ }
73
+ files.push(relative(cwd, resolve(resolved, match)));
74
+ }
75
+
76
+ files.sort();
77
+ return { files, ...(truncated ? { truncated: true } : {}) };
78
+ }
79
+ case "grep": {
80
+ const { pattern, path, include } = toolInputSchemas.grep.parse(input);
81
+ const { cwd, resolved } = resolveInsideCwd(path);
82
+ const args = [
83
+ "-rn",
84
+ "--color=never",
85
+ "--exclude-dir=node_modules",
86
+ "--exclude-dir=.git",
87
+ "-E",
88
+ ];
89
+ if (include) args.push(`--include=${include}`);
90
+ args.push(pattern, resolved);
91
+
92
+ const proc = Bun.spawn(["grep", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
93
+ const [stdout, stderr] = await Promise.all([
94
+ new Response(proc.stdout).text(),
95
+ new Response(proc.stderr).text(),
96
+ ]);
97
+ const exitCode = await proc.exited;
98
+
99
+ if (exitCode !== 0 && exitCode !== 1) throw new Error(`grep failed: ${stderr.trim()}`);
100
+ if (!stdout.trim()) return { matches: [], message: "No matches found" };
101
+
102
+ const lines = stdout.trim().split("\n");
103
+ const matches: { file: string; line: number; content: string }[] = [];
104
+ let truncated = false;
105
+
106
+ for (const line of lines) {
107
+ if (matches.length >= MAX_MATCHES) {
108
+ truncated = true;
109
+ break;
110
+ }
111
+ const match = line.match(/^(.+?):(\d+):(.*)$/);
112
+ if (match) {
113
+ matches.push({
114
+ file: relative(cwd, match[1]!),
115
+ line: Number(match[2]),
116
+ content: match[3]!,
117
+ });
118
+ }
119
+ }
120
+
121
+ return { matches, ...(truncated ? { truncated: true, totalMatches: lines.length } : {}) };
122
+ }
123
+ case "writeFile": {
124
+ const { path, content } = toolInputSchemas.writeFile.parse(input);
125
+ const { cwd, resolved } = resolveInsideCwd(path);
126
+ await mkdir(dirname(resolved), { recursive: true });
127
+ await writeFile(resolved, content, "utf-8");
128
+ return {
129
+ success: true as const,
130
+ path: relative(cwd, resolved),
131
+ bytesWritten: Buffer.byteLength(content, "utf-8"),
132
+ };
133
+ }
134
+ case "editFile": {
135
+ const { path, oldString, newString } = toolInputSchemas.editFile.parse(input);
136
+ const { cwd, resolved } = resolveInsideCwd(path);
137
+ const content = await readFile(resolved, "utf-8");
138
+ const occurrences = content.split(oldString).length - 1;
139
+
140
+ if (occurrences === 0) throw new Error("oldString not found in file");
141
+ if (occurrences > 1) throw new Error(`oldString is ambiguous; found ${occurrences} matches`);
142
+
143
+ await writeFile(resolved, content.replace(oldString, newString), "utf-8");
144
+ return { success: true as const, path: relative(cwd, resolved) };
145
+ }
146
+ case "bash": {
147
+ const { command, timeout = DEFAULT_TIMEOUT } = toolInputSchemas.bash.parse(input);
148
+ const proc = Bun.spawn(["bash", "-c", command], {
149
+ cwd: resolveInsideCwd(".").resolved,
150
+ stdout: "pipe",
151
+ stderr: "pipe",
152
+ env: { ...process.env, TERM: "dumb" },
153
+ });
154
+ const timer = setTimeout(() => proc.kill(), timeout);
155
+ const [stdout, stderr] = await Promise.all([
156
+ new Response(proc.stdout).text(),
157
+ new Response(proc.stderr).text(),
158
+ ]);
159
+ const exitCode = await proc.exited;
160
+ clearTimeout(timer);
161
+ return {
162
+ stdout: truncate(stdout, MAX_OUTPUT),
163
+ stderr: truncate(stderr, MAX_OUTPUT),
164
+ exitCode,
165
+ };
166
+ }
167
+ default:
168
+ throw new Error(`Unknown tool: ${toolName}`);
169
+ }
170
+ };
@@ -0,0 +1,166 @@
1
+ import open from "open";
2
+ import { saveAuth } from "./auth";
3
+
4
+ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
5
+
6
+ type OAuthState = {
7
+ nonce: string;
8
+ port: number;
9
+ };
10
+
11
+ function toBase64Url(input: Uint8Array | string) {
12
+ return Buffer.from(input).toString("base64url");
13
+ }
14
+
15
+ async function createPkceChallenge(verifier: string) {
16
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
17
+ return toBase64Url(new Uint8Array(digest));
18
+ }
19
+
20
+ function encodeState(state: OAuthState) {
21
+ return toBase64Url(JSON.stringify(state));
22
+ }
23
+
24
+ function decodeState(state: string) {
25
+ const [encoded] = state.split(".");
26
+ if (!encoded) {
27
+ throw new Error("Invalid state");
28
+ }
29
+
30
+ return JSON.parse(Buffer.from(encoded, "base64url").toString()) as OAuthState;
31
+ }
32
+
33
+ function getErrorMessage(error: unknown) {
34
+ return error instanceof Error ? error.message : String(error);
35
+ }
36
+
37
+ export async function performLogin() {
38
+ const clerkFrontendApi = process.env.CLERK_FRONTEND_API;
39
+ const clientId = process.env.CLERK_OAUTH_CLIENT_ID;
40
+ const clientSecret = process.env.CLERK_OAUTH_CLIENT_SECRET;
41
+ const apiUrl = process.env.API_URL ?? "http://localhost:3000";
42
+
43
+ if (!clerkFrontendApi) throw new Error("CLERK_FRONTEND_API not set");
44
+ if (!clientId) throw new Error("CLERK_OAUTH_CLIENT_ID not set");
45
+
46
+ const nonce = crypto.randomUUID();
47
+ const codeVerifier = toBase64Url(crypto.getRandomValues(new Uint8Array(32)));
48
+ const codeChallenge = await createPkceChallenge(codeVerifier);
49
+
50
+ let settled = false;
51
+
52
+ return new Promise<{ token: string }>((resolve, reject) => {
53
+ const server = Bun.serve({
54
+ port: 0,
55
+ async fetch(req) {
56
+ const url = new URL(req.url);
57
+
58
+ if (url.pathname !== "/callback") {
59
+ return new Response("Not found", { status: 404 });
60
+ }
61
+
62
+ const error = url.searchParams.get("error");
63
+
64
+ if (error) {
65
+ const msg = url.searchParams.get("error_description") ?? error;
66
+ settled = true;
67
+ reject(new Error(msg));
68
+ setTimeout(() => server.stop(), 500);
69
+ return new Response(`Authentication failed: ${msg}`, { status: 400 });
70
+ }
71
+
72
+ const code = url.searchParams.get("code");
73
+ const state = url.searchParams.get("state");
74
+
75
+ if (!code || !state) {
76
+ settled = true;
77
+ reject(new Error("Missing code or state"));
78
+ setTimeout(() => server.stop(), 500);
79
+ return new Response("Bad request", { status: 400 });
80
+ }
81
+
82
+ try {
83
+ const payload = decodeState(state);
84
+
85
+ if (payload.nonce !== nonce) throw new Error("State mismatch");
86
+ } catch (err) {
87
+ settled = true;
88
+ reject(err);
89
+ setTimeout(() => server.stop(), 500);
90
+ return new Response("Invalid state", { status: 400 });
91
+ }
92
+
93
+ try {
94
+ const redirectUri = `${apiUrl}/auth/callback`;
95
+
96
+ const tokenParams: Record<string, string> = {
97
+ grant_type: "authorization_code",
98
+ code,
99
+ redirect_uri: redirectUri,
100
+ client_id: clientId,
101
+ code_verifier: codeVerifier,
102
+ };
103
+ if (clientSecret) {
104
+ tokenParams.client_secret = clientSecret;
105
+ }
106
+
107
+ const tokenRes = await fetch(`${clerkFrontendApi}/oauth/token`, {
108
+ method: "POST",
109
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
110
+ body: new URLSearchParams(tokenParams),
111
+ });
112
+
113
+ if (!tokenRes.ok) {
114
+ const details = await tokenRes.text();
115
+ throw new Error(details || "Failed to exchange authorization code");
116
+ }
117
+
118
+ const tokenData = (await tokenRes.json()) as { access_token: string };
119
+
120
+ settled = true;
121
+ saveAuth({ token: tokenData.access_token });
122
+ resolve({ token: tokenData.access_token });
123
+ setTimeout(() => server.stop(), 500);
124
+ return new Response("Authenticated! You can close this tab.");
125
+ } catch (err) {
126
+ settled = true;
127
+ reject(err);
128
+ const message = getErrorMessage(err);
129
+ setTimeout(() => server.stop(), 500);
130
+ return new Response(`Authentication failed: ${message}`, { status: 400 });
131
+ }
132
+ },
133
+ });
134
+
135
+ // Build state with port and nonce
136
+ const port = server.port;
137
+ if (typeof port !== "number") {
138
+ server.stop();
139
+ reject(new Error("Failed to start callback server"));
140
+ return;
141
+ }
142
+
143
+ const state = encodeState({ port, nonce });
144
+ const redirectUri = `${apiUrl}/auth/callback`;
145
+
146
+ const authorizeUrl = new URL(`${clerkFrontendApi}/oauth/authorize`);
147
+ authorizeUrl.searchParams.set("response_type", "code");
148
+ authorizeUrl.searchParams.set("client_id", clientId);
149
+ authorizeUrl.searchParams.set("redirect_uri", redirectUri);
150
+ authorizeUrl.searchParams.set("scope", "openid email profile");
151
+ authorizeUrl.searchParams.set("state", state);
152
+ authorizeUrl.searchParams.set("prompt", "login");
153
+ authorizeUrl.searchParams.set("code_challenge", codeChallenge);
154
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
155
+
156
+ void open(authorizeUrl.toString());
157
+
158
+ setTimeout(() => {
159
+ if (!settled) {
160
+ settled = true;
161
+ server.stop();
162
+ reject(new Error("Login timed out"));
163
+ }
164
+ }, LOGIN_TIMEOUT_MS)
165
+ });
166
+ }
@@ -0,0 +1,27 @@
1
+ import open from "open";
2
+ import { apiClient } from "./api-client";
3
+ import { getErrorMessage } from "./http-errors";
4
+
5
+ export async function openUpgradeCheckout() {
6
+ const response = await apiClient.billing.checkout.$post();
7
+
8
+ if (response.ok) {
9
+ const data = await response.json();
10
+ await open(data.url);
11
+ return;
12
+ }
13
+
14
+ throw new Error(await getErrorMessage(response));
15
+ };
16
+
17
+ export async function openBillingPortal() {
18
+ const response = await apiClient.billing.portal.$post();
19
+
20
+ if (response.ok) {
21
+ const data = await response.json();
22
+ await open(data.url);
23
+ return;
24
+ }
25
+
26
+ throw new Error(await getErrorMessage(response));
27
+ };
@@ -0,0 +1,123 @@
1
+ import { createContext, useContext, useState, useCallback } from "react";
2
+ import type { ReactNode } from "react";
3
+ import { TextAttributes, RGBA } from "@opentui/core";
4
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
5
+ import type { DialogConfig } from "./types";
6
+ import { useKeyboardLayer } from "../keyboard-layer";
7
+ import { useTheme } from "../theme";
8
+
9
+ export type DialogContextValue = {
10
+ open: (config: DialogConfig) => void;
11
+ close: () => void;
12
+ };
13
+
14
+ const DialogContext = createContext<DialogContextValue | null>(null);
15
+
16
+ export function useDialog(): DialogContextValue {
17
+ const value = useContext(DialogContext);
18
+ if (!value) {
19
+ throw new Error("useDialog must be used within a DialogProvider");
20
+ }
21
+ return value;
22
+ };
23
+
24
+ type DialogProviderProps = {
25
+ children: ReactNode;
26
+ };
27
+
28
+ export function DialogProvider({ children }: DialogProviderProps) {
29
+ const [currentDialog, setCurrentDialog] = useState<DialogConfig | null>(null);
30
+ const { push, pop } = useKeyboardLayer();
31
+
32
+ const close = useCallback(() => {
33
+ setCurrentDialog(null);
34
+ pop("dialog");
35
+ }, [pop]);
36
+
37
+ const open = useCallback(
38
+ (config: DialogConfig) => {
39
+ setCurrentDialog(config);
40
+ push("dialog", () => {
41
+ close();
42
+ return true;
43
+ });
44
+ },
45
+ [push, close],
46
+ );
47
+
48
+ const value: DialogContextValue = {
49
+ open,
50
+ close,
51
+ };
52
+
53
+ return (
54
+ <DialogContext.Provider value={value}>
55
+ {children}
56
+ <Dialog currentDialog={currentDialog} close={close} />
57
+ </DialogContext.Provider>
58
+ );
59
+ };
60
+
61
+ type DialogProps = {
62
+ currentDialog: DialogConfig | null;
63
+ close: () => void;
64
+ };
65
+
66
+ function Dialog({ currentDialog, close }: DialogProps) {
67
+ const { isTopLayer } = useKeyboardLayer();
68
+ const dimensions = useTerminalDimensions();
69
+ const { colors } = useTheme();
70
+
71
+ useKeyboard((key) => {
72
+ if (!currentDialog || !isTopLayer("dialog")) return;
73
+
74
+ if (key.name === "escape") {
75
+ close();
76
+ }
77
+ });
78
+
79
+ if (!currentDialog) {
80
+ return null;
81
+ }
82
+
83
+ const { title, children } = currentDialog;
84
+
85
+ return (
86
+ <box
87
+ position="absolute"
88
+ left={0}
89
+ top={0}
90
+ width={dimensions.width}
91
+ height={dimensions.height}
92
+ justifyContent="center"
93
+ alignItems="center"
94
+ backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
95
+ zIndex={100}
96
+ onMouseDown={() => close()}
97
+ >
98
+ <box
99
+ width={Math.min(60, dimensions.width - 4)}
100
+ height="auto"
101
+ backgroundColor={colors.dialogSurface}
102
+ paddingX={4}
103
+ paddingY={1}
104
+ flexDirection="column"
105
+ gap={1}
106
+ onMouseDown={(e) => e.stopPropagation()}
107
+ >
108
+ <box
109
+ paddingBottom={1}
110
+ flexDirection="row"
111
+ alignItems="center"
112
+ justifyContent="space-between"
113
+ >
114
+ <text attributes={TextAttributes.BOLD}>{title}</text>
115
+ <text attributes={TextAttributes.DIM} onMouseDown={() => close()}>
116
+ esc
117
+ </text>
118
+ </box>
119
+ <box flexGrow={1}>{children}</box>
120
+ </box>
121
+ </box>
122
+ );
123
+ };
@@ -0,0 +1,6 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export type DialogConfig = {
4
+ title: string;
5
+ children: ReactNode;
6
+ };
@@ -0,0 +1,98 @@
1
+ import React, {
2
+ createContext,
3
+ useContext,
4
+ useState,
5
+ useCallback,
6
+ useRef
7
+ } from "react";
8
+ import { useKeyboard, useRenderer } from "@opentui/react";
9
+
10
+ type Responder = () => boolean;
11
+
12
+ type KeyboardLayerContextValue = {
13
+ push: (id: string, responder?: Responder) => void;
14
+ pop: (id: string) => void;
15
+ isTopLayer: (id: string) => boolean;
16
+ setResponder: (id: string, responder: Responder | null) => void;
17
+ };
18
+
19
+ const KeyboardLayerContext = createContext<KeyboardLayerContextValue | null>(null);
20
+
21
+ export function KeyboardLayerProvider({ children }: { children: React.ReactNode }) {
22
+ const [stack, setStack] = useState<string[]>(["base"]);
23
+ const stackRef = useRef(stack);
24
+ stackRef.current = stack;
25
+
26
+ const responders = useRef<Map<string, Responder>>(new Map());
27
+ const renderer = useRenderer();
28
+
29
+ const push = useCallback((id: string, responder?: Responder) => {
30
+ if (responder) {
31
+ responders.current.set(id, responder);
32
+ }
33
+
34
+ setStack((prev) => {
35
+ if (prev.includes(id)) {
36
+ return prev;
37
+ }
38
+
39
+ return [...prev, id];
40
+ });
41
+ }, []);
42
+
43
+ const pop = useCallback((id: string) => {
44
+ responders.current.delete(id);
45
+ setStack((prev) => prev.filter((layer) => layer !== id));
46
+ }, []);
47
+
48
+ const isTopLayer = useCallback(
49
+ (id: string) => {
50
+ return stack.length === 0 || stack[stack.length - 1] === id;
51
+ },
52
+ [stack],
53
+ );
54
+
55
+ const setResponder = useCallback((
56
+ id: string,
57
+ responder: Responder | null
58
+ ) => {
59
+ if (responder) {
60
+ responders.current.set(id, responder);
61
+ } else {
62
+ responders.current.delete(id);
63
+ }
64
+ }, []);
65
+
66
+ // Single ctrl+c handler that walks the responder chain
67
+ useKeyboard((key) => {
68
+ if (!key.ctrl || key.name !== "c") return;
69
+
70
+ const currentStack = stackRef.current;
71
+ for (let i = currentStack.length - 1; i >= 0; i--) {
72
+ const layerId = currentStack[i]!;
73
+ const responder = responders.current.get(layerId);
74
+ if (responder && responder()) {
75
+ return;
76
+ }
77
+ };
78
+
79
+ // No responder handled it — exit
80
+ renderer.destroy();
81
+ });
82
+
83
+ return (
84
+ <KeyboardLayerContext.Provider
85
+ value={{ push, pop, isTopLayer, setResponder }}
86
+ >
87
+ {children}
88
+ </KeyboardLayerContext.Provider>
89
+ );
90
+ };
91
+
92
+ export function useKeyboardLayer() {
93
+ const context = useContext(KeyboardLayerContext);
94
+ if (!context) {
95
+ throw new Error("useKeyboardLayer must be used within a KeyboardLayerProvider");
96
+ }
97
+ return context;
98
+ };
@@ -0,0 +1,52 @@
1
+ import { createContext, useContext, useState, useCallback } from "react";
2
+ import type { ReactNode } from "react";
3
+ import {
4
+ DEFAULT_CHAT_MODEL_ID,
5
+ Mode,
6
+ type ModeType,
7
+ type SupportedChatModelId,
8
+ } from "@nightcode/shared";
9
+
10
+ type PromptConfigContextValue = {
11
+ mode: ModeType;
12
+ toggleMode: () => void;
13
+ setMode: (mode: ModeType) => void;
14
+ model: SupportedChatModelId;
15
+ setModel: (model: SupportedChatModelId) => void;
16
+ };
17
+
18
+ const PromptConfigContext = createContext<PromptConfigContextValue | null>(null);
19
+
20
+ export function usePromptConfig(): PromptConfigContextValue {
21
+ const value = useContext(PromptConfigContext);
22
+ if (!value) {
23
+ throw new Error("usePromptConfig must be used within a PromptConfigProvider");
24
+ }
25
+ return value;
26
+ };
27
+
28
+ type PromptConfigProviderProps = {
29
+ children: ReactNode;
30
+ };
31
+
32
+ export function PromptConfigProvider({ children }: PromptConfigProviderProps) {
33
+ const [mode, setMode] = useState<ModeType>(Mode.BUILD);
34
+ const [model, setModel] = useState<SupportedChatModelId>(DEFAULT_CHAT_MODEL_ID);
35
+
36
+ const toggleMode = useCallback(() => {
37
+ setMode((m) => (m === Mode.BUILD ? Mode.PLAN : Mode.BUILD));
38
+ }, []);
39
+
40
+ return (
41
+ <PromptConfigContext.Provider
42
+ value={{
43
+ mode,
44
+ toggleMode,
45
+ setMode,
46
+ model,
47
+ setModel
48
+ }}>
49
+ {children}
50
+ </PromptConfigContext.Provider>
51
+ );
52
+ };