@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,75 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { createContext, useContext, useState, useCallback } from "react";
5
+ import type { ReactNode } from "react";
6
+ import type { ThemeColors, Theme } from "../../theme";
7
+ import { DEFAULT_THEME, THEMES } from "../../theme";
8
+
9
+ const CONFIG_DIR = join(homedir(), ".nightcode");
10
+ const THEME_PREFERENCES_PATH = join(CONFIG_DIR, "preferences.json");
11
+
12
+ type ThemePreferences = {
13
+ themeName: string;
14
+ };
15
+
16
+ function getInitialTheme(): Theme {
17
+ try {
18
+ const preferences = JSON.parse(
19
+ readFileSync(THEME_PREFERENCES_PATH, "utf8"),
20
+ ) as Partial<ThemePreferences>;
21
+ const savedTheme = THEMES.find((theme) => theme.name === preferences.themeName);
22
+ return savedTheme ?? DEFAULT_THEME;
23
+ } catch {
24
+ return DEFAULT_THEME;
25
+ }
26
+ };
27
+
28
+ function persistTheme(theme: Theme) {
29
+ try {
30
+ mkdirSync(CONFIG_DIR, { recursive: true });
31
+ writeFileSync(
32
+ THEME_PREFERENCES_PATH,
33
+ JSON.stringify({ themeName: theme.name } satisfies ThemePreferences, null, 2),
34
+ "utf8",
35
+ );
36
+ } catch {
37
+ // Ignore preference write failures so theme switching still works for this session.
38
+ }
39
+ };
40
+
41
+ type ThemeContextValue = {
42
+ colors: ThemeColors;
43
+ currentTheme: Theme;
44
+ setTheme: (theme: Theme) => void;
45
+ };
46
+
47
+ const ThemeContext = createContext<ThemeContextValue | null>(null);
48
+
49
+ export function useTheme(): ThemeContextValue {
50
+ const value = useContext(ThemeContext);
51
+ if (!value) {
52
+ throw new Error("useTheme must be used within a ThemeProvider");
53
+ }
54
+ return value;
55
+ }
56
+
57
+ type ThemeProviderProps = {
58
+ children: ReactNode;
59
+ };
60
+
61
+ export function ThemeProvider({ children }: ThemeProviderProps) {
62
+ const [currentTheme, setCurrentTheme] = useState<Theme>(getInitialTheme);
63
+
64
+ const setTheme = useCallback((theme: Theme) => {
65
+ setCurrentTheme(theme);
66
+ persistTheme(theme);
67
+ }, []);
68
+
69
+ return (
70
+ <ThemeContext.Provider
71
+ value={{ colors: currentTheme.colors, currentTheme, setTheme }}>
72
+ {children}
73
+ </ThemeContext.Provider>
74
+ );
75
+ };
@@ -0,0 +1,118 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useRef,
5
+ useState,
6
+ useCallback,
7
+ useMemo
8
+ } from "react";
9
+ import type { ReactNode } from "react";
10
+ import { useTerminalDimensions } from "@opentui/react";
11
+ import type { ToastOptions, ToastVariant } from "./types";
12
+ import { DEFAULT_DURATION } from "./types";
13
+ import { SplitBorderChars } from "../../components/border";
14
+ import { useTheme } from "../theme";
15
+
16
+ export type ToastContextValue = {
17
+ show: (options: ToastOptions) => void;
18
+ };
19
+
20
+ const ToastContext = createContext<ToastContextValue | null>(null);
21
+
22
+ export function useToast(): ToastContextValue {
23
+ const value = useContext(ToastContext);
24
+ if (!value) {
25
+ throw new Error("useToast must be used within a ToastProvider");
26
+ }
27
+
28
+ return value;
29
+ };
30
+
31
+ type ToastProviderProps = {
32
+ children: ReactNode;
33
+ };
34
+
35
+ export function ToastProvider({ children }: ToastProviderProps) {
36
+ const [currentToast, setCurrentToast] = useState<ToastOptions | null>(null);
37
+ const timeoutHandleRef = useRef<NodeJS.Timeout | null>(null);
38
+
39
+ const clearCurrentTimeout = useCallback(() => {
40
+ if (timeoutHandleRef.current) {
41
+ clearTimeout(timeoutHandleRef.current);
42
+ timeoutHandleRef.current = null;
43
+ }
44
+ }, []);
45
+
46
+ const show = useCallback((options: ToastOptions) => {
47
+ const duration = options.duration ?? DEFAULT_DURATION;
48
+
49
+ clearCurrentTimeout();
50
+
51
+ setCurrentToast({
52
+ variant: options.variant ?? "info",
53
+ ...options,
54
+ duration,
55
+ });
56
+
57
+ timeoutHandleRef.current = setTimeout(() => {
58
+ setCurrentToast(null);
59
+ }, duration).unref();
60
+ }, [clearCurrentTimeout]);
61
+
62
+ const value = useMemo(() => ({ show }), [show]);
63
+
64
+ return (
65
+ <ToastContext.Provider value={value}>
66
+ {children}
67
+ <Toast currentToast={currentToast} />
68
+ </ToastContext.Provider>
69
+ );
70
+ };
71
+
72
+ type ToastProps = {
73
+ currentToast: ToastOptions | null;
74
+ };
75
+
76
+ function Toast({ currentToast }: ToastProps) {
77
+ const { width } = useTerminalDimensions();
78
+ const { colors } = useTheme();
79
+
80
+ if (!currentToast) {
81
+ return null;
82
+ }
83
+
84
+ const variantColors: Record<ToastVariant, string> = {
85
+ success: colors.success,
86
+ error: colors.error,
87
+ info: colors.info,
88
+ };
89
+
90
+ const borderColor = currentToast.variant
91
+ ? variantColors[currentToast.variant]
92
+ : variantColors.info;
93
+
94
+ return (
95
+ <box
96
+ position="absolute"
97
+ justifyContent="center"
98
+ alignItems="flex-start"
99
+ top={2}
100
+ right={2}
101
+ width={Math.max(1, Math.min(60, width - 6))}
102
+ paddingLeft={2}
103
+ paddingRight={2}
104
+ paddingTop={1}
105
+ paddingBottom={1}
106
+ backgroundColor={colors.surface}
107
+ borderColor={borderColor}
108
+ border={["left", "right"]}
109
+ customBorderChars={SplitBorderChars}
110
+ >
111
+ <box flexDirection="column" gap={1} width="100%">
112
+ <text fg="#E1E1E1" wrapMode="word" width="100%">
113
+ {currentToast.message}
114
+ </text>
115
+ </box>
116
+ </box>
117
+ );
118
+ };
@@ -0,0 +1,9 @@
1
+ export type ToastVariant = "success" | "error" | "info";
2
+
3
+ export type ToastOptions = {
4
+ message: string;
5
+ variant?: ToastVariant;
6
+ duration?: number;
7
+ };
8
+
9
+ export const DEFAULT_DURATION = 3000;
@@ -0,0 +1,39 @@
1
+ import { useCallback } from "react";
2
+ import { useNavigate } from "react-router";
3
+ import { Header } from "../components/header";
4
+ import { InputBar } from "../components/input-bar";
5
+ import { usePromptConfig } from "../providers/prompt-config";
6
+ import { TextAttributes } from "@opentui/core";
7
+
8
+ export function Home() {
9
+ const navigate = useNavigate();
10
+ const { mode, model } = usePromptConfig();
11
+
12
+ const handleSubmit = useCallback(
13
+ (text: string) => {
14
+ navigate("/sessions/new", { state: { message: text, mode, model } });
15
+ },
16
+ [navigate, mode, model],
17
+ );
18
+
19
+ return (
20
+ <box
21
+ alignItems="center"
22
+ justifyContent="center"
23
+ flexGrow={1}
24
+ gap={2}
25
+ position="relative"
26
+ width="100%"
27
+ height="100%"
28
+ >
29
+ <Header />
30
+ <box width="100%" maxWidth={78} paddingX={2} flexDirection="column" gap={1}>
31
+ <InputBar onSubmit={handleSubmit} />
32
+ <box flexDirection="row" gap={1} flexShrink={0} marginLeft="auto">
33
+ <text>tab</text>
34
+ <text attributes={TextAttributes.DIM}>agents</text>
35
+ </box>
36
+ </box>
37
+ </box>
38
+ );
39
+ };
@@ -0,0 +1,82 @@
1
+ import { useEffect, useMemo, useRef } from "react";
2
+ import { z } from "zod";
3
+ import { Mode, modeSchema } from "@nightcode/shared";
4
+ import { useNavigate, useLocation } from "react-router";
5
+ import { SessionShell } from "../components/session-shell";
6
+ import { UserMessage } from "../components/messages";
7
+ import { useToast } from "../providers/toast";
8
+ import { apiClient } from "../lib/api-client";
9
+ import { getErrorMessage } from "../lib/http-errors";
10
+
11
+ const newSessionStateSchema = z.object({
12
+ message: z.string(),
13
+ mode: modeSchema,
14
+ model: z.string(),
15
+ });
16
+
17
+ export function NewSession() {
18
+ const navigate = useNavigate();
19
+ const location = useLocation();
20
+ const toast = useToast();
21
+ const hasStartedRef = useRef(false);
22
+
23
+ const state = useMemo(() => {
24
+ const parsed = newSessionStateSchema.safeParse(location.state);
25
+ return parsed.success ? parsed.data : null;
26
+ }, [location.state])
27
+
28
+ // Guard: if navigated here directly without state, go home
29
+ useEffect(() => {
30
+ if (!state) {
31
+ navigate("/", { replace: true });
32
+ }
33
+ }, [state, navigate]);
34
+
35
+ // Create the session on mount — this screen exists to do this
36
+ useEffect(() => {
37
+ if (!state || hasStartedRef.current) return;
38
+
39
+ hasStartedRef.current = true;
40
+
41
+ let ignore = false;
42
+ const createSession = async () => {
43
+ try {
44
+ const res = await apiClient.sessions.$post({
45
+ json: {
46
+ title: state.message.slice(0, 100),
47
+ },
48
+ });
49
+
50
+ if (ignore) return;
51
+ if (!res.ok) {
52
+ throw new Error(await getErrorMessage(res));
53
+ }
54
+ const session = await res.json();
55
+ navigate(
56
+ `/sessions/${session.id}`,
57
+ { replace: true, state: { session, initialPrompt: state } }
58
+ );
59
+ } catch (error) {
60
+ if (ignore) return;
61
+ toast.show({
62
+ variant: "error",
63
+ message: error instanceof Error ? error.message : "Failed to create session",
64
+ });
65
+ navigate("/", { replace: true });
66
+ }
67
+ };
68
+
69
+ createSession();
70
+ return () => {
71
+ ignore = true;
72
+ };
73
+ }, [state, navigate, toast]);
74
+
75
+ if (!state) return null;
76
+
77
+ return (
78
+ <SessionShell onSubmit={() => {}} inputDisabled loading>
79
+ <UserMessage message={state.message} mode={state.mode} />
80
+ </SessionShell>
81
+ );
82
+ };
@@ -0,0 +1,171 @@
1
+ import { useState, useEffect, useMemo, useRef } from "react";
2
+ import { useParams, useLocation, useNavigate } from "react-router";
3
+ import { z } from "zod";
4
+ import { useKeyboard } from "@opentui/react";
5
+ import { type ModeType, type SupportedChatModelId, type SessionRecord } from "@nightcode/shared";
6
+ import { SessionShell } from "../components/session-shell";
7
+ import {
8
+ UserMessage,
9
+ BotMessage,
10
+ ErrorMessage
11
+ } from "../components/messages";
12
+ import { useToast } from "../providers/toast";
13
+ import { useChat } from "../hooks/use-chat";
14
+ import { usePromptConfig } from "../providers/prompt-config";
15
+ import type { Message } from "../hooks/use-chat";
16
+ import { apiClient } from "../lib/api-client";
17
+ import { getErrorMessage } from "../lib/http-errors";
18
+ import { useKeyboardLayer } from "../providers/keyboard-layer";
19
+
20
+ type SessionData = SessionRecord;
21
+
22
+ const sessionLocationSchema = z.object({
23
+ session: z.custom<SessionData>((val) => val != null && typeof val === "object" && "id" in val),
24
+ initialPrompt: z
25
+ .object({
26
+ message: z.string(),
27
+ mode: z.custom<ModeType>(),
28
+ model: z.custom<SupportedChatModelId>(),
29
+ })
30
+ .optional(),
31
+ });
32
+
33
+ function ChatMessage(
34
+ { msg }: {
35
+ msg: Message
36
+ }
37
+ ) {
38
+ if (msg.role === "user") {
39
+ const text = msg.parts
40
+ .filter((p) => p.type === "text")
41
+ .map((p) => p.text)
42
+ .join("");
43
+
44
+ return <UserMessage message={text} mode={msg.metadata?.mode ?? "BUILD"} />;
45
+ }
46
+
47
+ return (
48
+ <BotMessage
49
+ parts={msg.parts}
50
+ model={msg.metadata?.model ?? "unknown"}
51
+ mode={msg.metadata?.mode ?? "BUILD"}
52
+ durationMs={msg.metadata?.durationMs}
53
+ streaming={false}
54
+ />
55
+ );
56
+ };
57
+
58
+ function SessionChat({
59
+ session,
60
+ initialPrompt,
61
+ }: {
62
+ session: SessionData,
63
+ initialPrompt?: { message: string; mode: ModeType; model: SupportedChatModelId };
64
+ }) {
65
+ const [initialMessages] = useState(() => session.messages as unknown as Message[]);
66
+ const { mode, model } = usePromptConfig();
67
+ const { isTopLayer } = useKeyboardLayer();
68
+ const { messages, status, submit, abort, interrupt, error } = useChat(
69
+ session.id,
70
+ initialMessages
71
+ );
72
+ const hasSubmittedInitialPromptRef = useRef(false);
73
+
74
+ // Stop the pending reply when the user leaves this session.
75
+ useEffect(() => {
76
+ return () => {
77
+ void abort();
78
+ };
79
+ }, [abort]);
80
+
81
+ // Let the user cancel a reply even before the first streamed chunk arrives.
82
+ useKeyboard((key) => {
83
+ if (key.name === "escape" && isTopLayer("base") && status === "streaming") {
84
+ key.preventDefault();
85
+ interrupt();
86
+ }
87
+ });
88
+
89
+ useEffect(() => {
90
+ if (!initialPrompt || hasSubmittedInitialPromptRef.current) return;
91
+ hasSubmittedInitialPromptRef.current = true;
92
+ void submit({
93
+ userText: initialPrompt.message,
94
+ mode: initialPrompt.mode,
95
+ model: initialPrompt.model,
96
+ });
97
+ }, [initialPrompt, submit]);
98
+
99
+ return (
100
+ <SessionShell
101
+ onSubmit={(text) => submit({ userText: text, mode, model })}
102
+ loading={status === "submitted" || status === "streaming"}
103
+ interruptible={status === "submitted" || status === "streaming"}
104
+ >
105
+ {messages.map((msg) => (
106
+ <ChatMessage key={msg.id} msg={msg} />
107
+ ))}
108
+ {error && <ErrorMessage message={error.message} />}
109
+ </SessionShell>
110
+ );
111
+ }
112
+
113
+ export function Session() {
114
+ const { id } = useParams();
115
+ const location = useLocation();
116
+ const navigate = useNavigate();
117
+ const toast = useToast();
118
+
119
+ const prefetched = useMemo(() => {
120
+ const parsed = sessionLocationSchema.safeParse(location.state);
121
+ return parsed.success ? parsed.data : null;
122
+ }, [location.state]);
123
+
124
+ const [session, setSession] = useState<SessionData | null>(prefetched?.session ?? null);
125
+
126
+ useEffect(() => {
127
+ // Skip fetch if session was passed via location state
128
+ if (prefetched?.session) return;
129
+
130
+ setSession(null);
131
+
132
+ if (!id) return;
133
+
134
+ let ignore = false;
135
+ const fetchSession = async () => {
136
+ try {
137
+ const res = await apiClient.sessions[":id"].$get({
138
+ param: { id },
139
+ });
140
+ if (ignore) return;
141
+ if (!res.ok) throw new Error(await getErrorMessage(res));
142
+ const resolved = await res.json();
143
+ setSession(resolved);
144
+ } catch (err) {
145
+ if (ignore) return;
146
+ toast.show({
147
+ variant: "error",
148
+ message: err instanceof Error ? err.message : "Failed to load session",
149
+ });
150
+ navigate("/", { replace: true });
151
+ }
152
+ };
153
+
154
+ fetchSession();
155
+ return () => {
156
+ ignore = true;
157
+ };
158
+ }, [id, prefetched, toast, navigate]);
159
+
160
+ if (!session) {
161
+ return <SessionShell onSubmit={() => {}} inputDisabled loading />;
162
+ }
163
+
164
+ return (
165
+ <SessionChat
166
+ key={session.id}
167
+ session={session}
168
+ initialPrompt={prefetched?.initialPrompt}
169
+ />
170
+ );
171
+ };