@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,160 @@
|
|
|
1
|
+
import prettyMs from "pretty-ms";
|
|
2
|
+
import { EmptyBorder } from "../border";
|
|
3
|
+
import { useTheme } from "../../providers/theme";
|
|
4
|
+
import type { Message } from "../../hooks/use-chat";
|
|
5
|
+
import { Mode, type ModeType } from "@nightcode/shared";
|
|
6
|
+
import { TextAttributes } from "@opentui/core";
|
|
7
|
+
|
|
8
|
+
type ClientMessagePart = Message["parts"][number];
|
|
9
|
+
type ToolPart = Extract<ClientMessagePart, { type: `tool-${string}` | "dynamic-tool" }>;
|
|
10
|
+
|
|
11
|
+
type Props = {
|
|
12
|
+
parts: ClientMessagePart[];
|
|
13
|
+
model: string;
|
|
14
|
+
mode: ModeType;
|
|
15
|
+
durationMs?: number;
|
|
16
|
+
streaming?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function formatToolName(name: string): string {
|
|
20
|
+
return name
|
|
21
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
22
|
+
.replace(/^./, (c) => c.toUpperCase());
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function isToolPart(part: ClientMessagePart): part is ToolPart {
|
|
26
|
+
return part.type === "dynamic-tool" || part.type.startsWith("tool-");
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function formatToolArgs(tc: ToolPart): string {
|
|
30
|
+
if (!("input" in tc) || tc.input == null) return "";
|
|
31
|
+
if (typeof tc.input !== "object") return String(tc.input);
|
|
32
|
+
return Object.values(tc.input).map(String).join(" ");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type PartGroup = {
|
|
36
|
+
type: ClientMessagePart["type"];
|
|
37
|
+
parts: ClientMessagePart[];
|
|
38
|
+
key: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function groupConsecutiveParts(parts: ClientMessagePart[]): PartGroup[] {
|
|
42
|
+
const groups: PartGroup[] = [];
|
|
43
|
+
|
|
44
|
+
for (let i = 0; i < parts.length; i++) {
|
|
45
|
+
const part = parts[i]!;
|
|
46
|
+
const lastGroup = groups[groups.length - 1];
|
|
47
|
+
|
|
48
|
+
if (lastGroup && lastGroup.type === part.type) {
|
|
49
|
+
lastGroup.parts.push(part);
|
|
50
|
+
} else {
|
|
51
|
+
const key =
|
|
52
|
+
isToolPart(part) ? `group-tc-${part.toolCallId}` : `group-${part.type}-${i}`;
|
|
53
|
+
groups.push({ type: part.type, parts: [part], key });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return groups;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function BotMessage({
|
|
61
|
+
parts,
|
|
62
|
+
model,
|
|
63
|
+
mode,
|
|
64
|
+
durationMs,
|
|
65
|
+
streaming = false,
|
|
66
|
+
}: Props) {
|
|
67
|
+
const { colors } = useTheme();
|
|
68
|
+
return (
|
|
69
|
+
<box width="100%" alignItems="center">
|
|
70
|
+
{groupConsecutiveParts(parts).map((group, i) => (
|
|
71
|
+
<box key={group.key} width="100%" paddingTop={i === 0 ? 0 : 1}>
|
|
72
|
+
{group.parts.map((part, j) => {
|
|
73
|
+
if (part.type === "reasoning") {
|
|
74
|
+
return (
|
|
75
|
+
<box
|
|
76
|
+
key={`reasoning-${j}`}
|
|
77
|
+
border={["left"]}
|
|
78
|
+
borderColor={colors.thinkingBorder}
|
|
79
|
+
customBorderChars={{
|
|
80
|
+
...EmptyBorder,
|
|
81
|
+
vertical: "│",
|
|
82
|
+
}}
|
|
83
|
+
width="100%"
|
|
84
|
+
paddingX={2}
|
|
85
|
+
>
|
|
86
|
+
<text attributes={TextAttributes.DIM}>
|
|
87
|
+
<em fg={colors.thinking}>Thinking:</em> {part.text}
|
|
88
|
+
</text>
|
|
89
|
+
</box>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (isToolPart(part)) {
|
|
94
|
+
const toolName =
|
|
95
|
+
part.type === "dynamic-tool" ? part.toolName : part.type.slice("tool-".length);
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<box
|
|
99
|
+
key={part.toolCallId}
|
|
100
|
+
border={["left"]}
|
|
101
|
+
borderColor={colors.thinkingBorder}
|
|
102
|
+
customBorderChars={{
|
|
103
|
+
...EmptyBorder,
|
|
104
|
+
vertical: "│",
|
|
105
|
+
}}
|
|
106
|
+
width="100%"
|
|
107
|
+
paddingX={2}
|
|
108
|
+
>
|
|
109
|
+
<text attributes={TextAttributes.DIM}>
|
|
110
|
+
<em fg={colors.info}>{formatToolName(toolName)}:</em> {formatToolArgs(part)}
|
|
111
|
+
{part.state !== "output-available" && part.state !== "output-error"
|
|
112
|
+
? " …"
|
|
113
|
+
: ""
|
|
114
|
+
}
|
|
115
|
+
{part.state === "output-error" ? ` ${part.errorText}` : ""}
|
|
116
|
+
</text>
|
|
117
|
+
</box>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (part.type === "text") {
|
|
122
|
+
return (
|
|
123
|
+
<box key={`text-${j}`} paddingX={3} width="100%">
|
|
124
|
+
<text>{part.text}</text>
|
|
125
|
+
</box>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return null;
|
|
130
|
+
})}
|
|
131
|
+
</box>
|
|
132
|
+
))}
|
|
133
|
+
|
|
134
|
+
<box paddingX={3} paddingY={1} gap={1} width="100%">
|
|
135
|
+
<box flexDirection="row" gap={2}>
|
|
136
|
+
<text fg={mode === Mode.PLAN ? colors.planMode : colors.primary}>◉</text>
|
|
137
|
+
<box flexDirection="row" gap={1}>
|
|
138
|
+
<text>
|
|
139
|
+
{mode === Mode.PLAN ? "Plan" : "Build"}
|
|
140
|
+
</text>
|
|
141
|
+
<text attributes={TextAttributes.DIM} fg={colors.dimSeparator}>
|
|
142
|
+
›
|
|
143
|
+
</text>
|
|
144
|
+
<text attributes={TextAttributes.DIM}>{model}</text>
|
|
145
|
+
{(durationMs != null) && (
|
|
146
|
+
<>
|
|
147
|
+
<text attributes={TextAttributes.DIM} fg={colors.dimSeparator}>
|
|
148
|
+
›
|
|
149
|
+
</text>
|
|
150
|
+
<text attributes={TextAttributes.DIM}>
|
|
151
|
+
{prettyMs(durationMs)}
|
|
152
|
+
</text>
|
|
153
|
+
</>
|
|
154
|
+
)}
|
|
155
|
+
</box>
|
|
156
|
+
</box>
|
|
157
|
+
</box>
|
|
158
|
+
</box>
|
|
159
|
+
);
|
|
160
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core";
|
|
2
|
+
import { EmptyBorder } from "../border";
|
|
3
|
+
import { useTheme } from "../../providers/theme";
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
message: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function ErrorMessage({ message }: Props) {
|
|
10
|
+
const { colors } = useTheme();
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<box width="100%" alignItems="center">
|
|
14
|
+
<box
|
|
15
|
+
border={["left"]}
|
|
16
|
+
borderColor={colors.error}
|
|
17
|
+
width="100%"
|
|
18
|
+
customBorderChars={{
|
|
19
|
+
...EmptyBorder,
|
|
20
|
+
vertical: "┃",
|
|
21
|
+
bottomLeft: "╹",
|
|
22
|
+
}}
|
|
23
|
+
>
|
|
24
|
+
<box
|
|
25
|
+
justifyContent="center"
|
|
26
|
+
paddingX={2}
|
|
27
|
+
paddingY={1}
|
|
28
|
+
backgroundColor={colors.surface}
|
|
29
|
+
width="100%"
|
|
30
|
+
>
|
|
31
|
+
<text attributes={TextAttributes.DIM}>{message}</text>
|
|
32
|
+
</box>
|
|
33
|
+
</box>
|
|
34
|
+
</box>
|
|
35
|
+
);
|
|
36
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Mode, type ModeType } from "@nightcode/shared";
|
|
2
|
+
import { EmptyBorder } from "../border";
|
|
3
|
+
import { useTheme } from "../../providers/theme";
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
message: string;
|
|
7
|
+
mode: ModeType;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function UserMessage({ message, mode }: Props) {
|
|
11
|
+
const { colors } = useTheme();
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<box width="100%" alignItems="center">
|
|
15
|
+
<box
|
|
16
|
+
border={["left"]}
|
|
17
|
+
borderColor={mode === Mode.PLAN ? colors.planMode : colors.primary} width="100%"
|
|
18
|
+
customBorderChars={{
|
|
19
|
+
...EmptyBorder,
|
|
20
|
+
vertical: "┃",
|
|
21
|
+
bottomLeft: "╹",
|
|
22
|
+
}}
|
|
23
|
+
>
|
|
24
|
+
<box
|
|
25
|
+
justifyContent="center"
|
|
26
|
+
paddingX={2}
|
|
27
|
+
paddingY={1}
|
|
28
|
+
backgroundColor={colors.surface}
|
|
29
|
+
width="100%"
|
|
30
|
+
>
|
|
31
|
+
<text>{message}</text>
|
|
32
|
+
</box>
|
|
33
|
+
</box>
|
|
34
|
+
</box>
|
|
35
|
+
);
|
|
36
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { InputBar } from "./input-bar";
|
|
4
|
+
import { Spinner } from "./spinner";
|
|
5
|
+
import { usePromptConfig } from "../providers/prompt-config";
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
children?: ReactNode;
|
|
9
|
+
onSubmit: (text: string) => void;
|
|
10
|
+
inputDisabled?: boolean;
|
|
11
|
+
loading?: boolean;
|
|
12
|
+
interruptible?: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function SessionShell({
|
|
16
|
+
children,
|
|
17
|
+
onSubmit,
|
|
18
|
+
inputDisabled = false,
|
|
19
|
+
loading = false,
|
|
20
|
+
interruptible = false,
|
|
21
|
+
}: Props) {
|
|
22
|
+
const { mode } = usePromptConfig();
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<box
|
|
26
|
+
flexDirection="column"
|
|
27
|
+
flexGrow={1}
|
|
28
|
+
width="100%"
|
|
29
|
+
height="100%"
|
|
30
|
+
paddingY={1}
|
|
31
|
+
paddingX={2}
|
|
32
|
+
gap={1}
|
|
33
|
+
>
|
|
34
|
+
<scrollbox flexGrow={1} width="100%" stickyScroll stickyStart="bottom">
|
|
35
|
+
<box>{children}</box>
|
|
36
|
+
</scrollbox>
|
|
37
|
+
<box flexShrink={0}>
|
|
38
|
+
<InputBar onSubmit={onSubmit} disabled={inputDisabled} />
|
|
39
|
+
</box>
|
|
40
|
+
<box
|
|
41
|
+
flexShrink={0}
|
|
42
|
+
flexDirection="row"
|
|
43
|
+
justifyContent="space-between"
|
|
44
|
+
width="100%"
|
|
45
|
+
height={1}
|
|
46
|
+
gap={2}
|
|
47
|
+
paddingLeft={1}
|
|
48
|
+
>
|
|
49
|
+
<box flexDirection="row" alignItems="center" gap={2}>
|
|
50
|
+
{loading ? (
|
|
51
|
+
<>
|
|
52
|
+
<Spinner mode={mode} />
|
|
53
|
+
{interruptible ? <text>esc to interrupt</text> : null}
|
|
54
|
+
</>
|
|
55
|
+
) : null}
|
|
56
|
+
</box>
|
|
57
|
+
|
|
58
|
+
<box flexDirection="row" gap={1} flexShrink={0} marginLeft="auto">
|
|
59
|
+
<text>tab</text>
|
|
60
|
+
<text attributes={TextAttributes.DIM}>agents</text>
|
|
61
|
+
</box>
|
|
62
|
+
</box>
|
|
63
|
+
</box>
|
|
64
|
+
);
|
|
65
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import "opentui-spinner/react";
|
|
2
|
+
import { Mode, type ModeType } from "@nightcode/shared";
|
|
3
|
+
import { useTheme } from "../providers/theme";
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
mode?: ModeType;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function Spinner({ mode = Mode.BUILD }: Props) {
|
|
10
|
+
const { colors } = useTheme();
|
|
11
|
+
const activeColor = mode === Mode.PLAN ? colors.planMode : colors.primary;
|
|
12
|
+
|
|
13
|
+
return <spinner name="aesthetic" color={activeColor} />;
|
|
14
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core";
|
|
2
|
+
import { useTheme } from "../providers/theme";
|
|
3
|
+
import { usePromptConfig } from "../providers/prompt-config";
|
|
4
|
+
import { Mode } from "@nightcode/shared";
|
|
5
|
+
|
|
6
|
+
export function StatusBar() {
|
|
7
|
+
const { mode, model } = usePromptConfig();
|
|
8
|
+
const { colors } = useTheme();
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<box flexDirection="row" gap={1}>
|
|
12
|
+
|
|
13
|
+
<text fg={mode === Mode.PLAN ? colors.planMode : colors.primary}>
|
|
14
|
+
{mode === Mode.PLAN ? "Plan" : "Build"}
|
|
15
|
+
</text>
|
|
16
|
+
|
|
17
|
+
<text attributes={TextAttributes.DIM} fg={colors.dimSeparator}>
|
|
18
|
+
›
|
|
19
|
+
</text>
|
|
20
|
+
<text>{model}</text>
|
|
21
|
+
</box>
|
|
22
|
+
);
|
|
23
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { useChat as useAiChat } from "@ai-sdk/react";
|
|
3
|
+
import {
|
|
4
|
+
DefaultChatTransport,
|
|
5
|
+
type InferUITools,
|
|
6
|
+
lastAssistantMessageIsCompleteWithToolCalls,
|
|
7
|
+
type LanguageModelUsage,
|
|
8
|
+
type UIMessage,
|
|
9
|
+
} from "ai";
|
|
10
|
+
import { type ModeType, type SupportedChatModelId, type ToolContracts } from "@nightcode/shared";
|
|
11
|
+
import { apiClient } from "../lib/api-client";
|
|
12
|
+
import { getAuth } from "../lib/auth";
|
|
13
|
+
import { executeLocalTool } from "../lib/local-tools";
|
|
14
|
+
|
|
15
|
+
export type ChatMessageMetadata = {
|
|
16
|
+
mode?: ModeType;
|
|
17
|
+
model?: SupportedChatModelId | string;
|
|
18
|
+
durationMs?: number;
|
|
19
|
+
usage?: LanguageModelUsage;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type ChatTools = {
|
|
23
|
+
[Name in keyof InferUITools<ToolContracts>]: {
|
|
24
|
+
input: InferUITools<ToolContracts>[Name]["input"];
|
|
25
|
+
output: unknown;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type Message = UIMessage<ChatMessageMetadata, never, ChatTools>;
|
|
30
|
+
|
|
31
|
+
export function useChat(sessionId: string, initialMessages: Message[]) {
|
|
32
|
+
const transport = useMemo(() => {
|
|
33
|
+
return new DefaultChatTransport<Message>({
|
|
34
|
+
api: apiClient.chat.$url().toString(),
|
|
35
|
+
headers() {
|
|
36
|
+
const auth = getAuth();
|
|
37
|
+
return auth ? { Authorization: `Bearer ${auth.token}` } : new Headers();
|
|
38
|
+
},
|
|
39
|
+
prepareSendMessagesRequest({ messages }) {
|
|
40
|
+
const message = messages[messages.length - 1];
|
|
41
|
+
if (!message) throw new Error("No message to send");
|
|
42
|
+
|
|
43
|
+
const metadata = messages.findLast(
|
|
44
|
+
(m) => m.metadata?.mode && m.metadata?.model,
|
|
45
|
+
)?.metadata;
|
|
46
|
+
const previousMessage = messages[messages.length - 2];
|
|
47
|
+
const requestMessages =
|
|
48
|
+
message.role === "assistant" && previousMessage?.role === "user"
|
|
49
|
+
? [previousMessage, message]
|
|
50
|
+
: [message];
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
body: {
|
|
54
|
+
id: sessionId,
|
|
55
|
+
messages: requestMessages,
|
|
56
|
+
mode: message.metadata?.mode ?? metadata?.mode,
|
|
57
|
+
model: message.metadata?.model ?? metadata?.model,
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}, [sessionId]);
|
|
63
|
+
|
|
64
|
+
const chat = useAiChat<Message>({
|
|
65
|
+
id: sessionId,
|
|
66
|
+
messages: initialMessages,
|
|
67
|
+
transport,
|
|
68
|
+
onToolCall({ toolCall }) {
|
|
69
|
+
const mode = chat.messages.at(-1)?.metadata?.mode ?? "BUILD";
|
|
70
|
+
|
|
71
|
+
void executeLocalTool(toolCall.toolName, toolCall.input, mode)
|
|
72
|
+
.then((output) =>
|
|
73
|
+
chat.addToolOutput({
|
|
74
|
+
tool: toolCall.toolName as keyof ChatTools,
|
|
75
|
+
toolCallId: toolCall.toolCallId,
|
|
76
|
+
output,
|
|
77
|
+
}),
|
|
78
|
+
)
|
|
79
|
+
.catch((error) =>
|
|
80
|
+
chat.addToolOutput({
|
|
81
|
+
tool: toolCall.toolName as keyof ChatTools,
|
|
82
|
+
toolCallId: toolCall.toolCallId,
|
|
83
|
+
state: "output-error",
|
|
84
|
+
errorText: error instanceof Error ? error.message : String(error),
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
},
|
|
88
|
+
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
messages: chat.messages,
|
|
93
|
+
status: chat.status,
|
|
94
|
+
error: chat.error,
|
|
95
|
+
submit: (params: { userText: string; mode: ModeType; model: SupportedChatModelId }) => {
|
|
96
|
+
return chat.sendMessage({
|
|
97
|
+
text: params.userText,
|
|
98
|
+
metadata: {
|
|
99
|
+
mode: params.mode,
|
|
100
|
+
model: params.model,
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
},
|
|
104
|
+
abort: chat.stop,
|
|
105
|
+
interrupt: chat.stop,
|
|
106
|
+
};
|
|
107
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Baked-in config for the hosted API — safe to ship (no secrets). */
|
|
2
|
+
export const HOSTED_CONFIG = {
|
|
3
|
+
API_URL: "https://nightcode-production.up.railway.app",
|
|
4
|
+
CLERK_FRONTEND_API: "https://meet-spider-0.clerk.accounts.dev",
|
|
5
|
+
CLERK_OAUTH_CLIENT_ID: "UoAp1kIZL8SfGqU7",
|
|
6
|
+
} as const;
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createCliRenderer } from "@opentui/core";
|
|
2
|
+
import { createRoot } from "@opentui/react";
|
|
3
|
+
import { createMemoryRouter, RouterProvider } from "react-router";
|
|
4
|
+
import { RootLayout } from "./layouts/root-layout";
|
|
5
|
+
import { Home } from "./screens/home";
|
|
6
|
+
import { NewSession } from "./screens/new-session";
|
|
7
|
+
import { Session } from "./screens/session";
|
|
8
|
+
|
|
9
|
+
const router = createMemoryRouter([
|
|
10
|
+
{
|
|
11
|
+
path: "/",
|
|
12
|
+
element: <RootLayout />,
|
|
13
|
+
children: [
|
|
14
|
+
{ index: true, element: <Home /> },
|
|
15
|
+
{ path: "sessions/new", element: <NewSession /> },
|
|
16
|
+
{ path: "sessions/:id", element: <Session /> },
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
function App() {
|
|
22
|
+
return <RouterProvider router={router} />
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const renderer = await createCliRenderer({
|
|
26
|
+
targetFps: 60,
|
|
27
|
+
exitOnCtrlC: false,
|
|
28
|
+
});
|
|
29
|
+
createRoot(renderer).render(<App />);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Outlet } from "react-router";
|
|
2
|
+
import { ToastProvider } from "../providers/toast";
|
|
3
|
+
import { DialogProvider } from "../providers/dialog";
|
|
4
|
+
import { KeyboardLayerProvider } from "../providers/keyboard-layer";
|
|
5
|
+
import { ThemeProvider } from "../providers/theme";
|
|
6
|
+
import { ThemedRoot } from "./themed-root";
|
|
7
|
+
import { PromptConfigProvider } from "../providers/prompt-config";
|
|
8
|
+
|
|
9
|
+
export function RootLayout() {
|
|
10
|
+
return (
|
|
11
|
+
<ThemeProvider>
|
|
12
|
+
<ToastProvider>
|
|
13
|
+
<KeyboardLayerProvider>
|
|
14
|
+
<DialogProvider>
|
|
15
|
+
<PromptConfigProvider>
|
|
16
|
+
<ThemedRoot>
|
|
17
|
+
<Outlet />
|
|
18
|
+
</ThemedRoot>
|
|
19
|
+
</PromptConfigProvider>
|
|
20
|
+
</DialogProvider>
|
|
21
|
+
</KeyboardLayerProvider>
|
|
22
|
+
</ToastProvider>
|
|
23
|
+
</ThemeProvider>
|
|
24
|
+
);
|
|
25
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { useTheme } from "../providers/theme";
|
|
3
|
+
|
|
4
|
+
type Props = {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function ThemedRoot({ children }: Props) {
|
|
9
|
+
const { colors } = useTheme();
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<box
|
|
13
|
+
backgroundColor={colors.background}
|
|
14
|
+
width="100%"
|
|
15
|
+
height="100%"
|
|
16
|
+
flexGrow={1}
|
|
17
|
+
>
|
|
18
|
+
{children}
|
|
19
|
+
</box>
|
|
20
|
+
);
|
|
21
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { hc } from "hono/client";
|
|
2
|
+
import { clearAuth, getAuth } from "./auth";
|
|
3
|
+
|
|
4
|
+
const apiUrl = process.env.API_URL ?? "http://localhost:3000";
|
|
5
|
+
|
|
6
|
+
export const apiClient = hc(apiUrl, {
|
|
7
|
+
fetch: async (
|
|
8
|
+
input: Parameters<typeof fetch>[0],
|
|
9
|
+
init?: Parameters<typeof fetch>[1],
|
|
10
|
+
) => {
|
|
11
|
+
const headers = new Headers(init?.headers);
|
|
12
|
+
const auth = getAuth();
|
|
13
|
+
|
|
14
|
+
if (auth) {
|
|
15
|
+
headers.set("Authorization", `Bearer ${auth.token}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const response = await fetch(input, { ...init, headers });
|
|
19
|
+
if (response.status === 401) {
|
|
20
|
+
clearAuth();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return response;
|
|
24
|
+
},
|
|
25
|
+
});
|
package/src/lib/auth.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
type AuthData = {
|
|
6
|
+
token: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const AUTH_DIR = join(homedir(), ".nightcode");
|
|
10
|
+
const AUTH_FILE = join(AUTH_DIR, "auth.json");
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
export function getAuth(): AuthData | null {
|
|
14
|
+
try {
|
|
15
|
+
const data = readFileSync(AUTH_FILE, "utf-8");
|
|
16
|
+
const parsed = JSON.parse(data) as Partial<AuthData>;
|
|
17
|
+
return typeof parsed.token === "string" ? { token: parsed.token } : null;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function saveAuth(data: AuthData) {
|
|
24
|
+
if (!existsSync(AUTH_DIR)) {
|
|
25
|
+
// Owner-only permissions (rwx------) so other users on the machine can't read tokens
|
|
26
|
+
mkdirSync(AUTH_DIR, { mode: 0o700 });
|
|
27
|
+
}
|
|
28
|
+
writeFileSync(AUTH_FILE, JSON.stringify(data), { mode: 0o600 });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function clearAuth() {
|
|
32
|
+
try {
|
|
33
|
+
unlinkSync(AUTH_FILE);
|
|
34
|
+
} catch {
|
|
35
|
+
// File doesn't exist
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
type ErrorResponse = {
|
|
2
|
+
json: () => Promise<unknown>;
|
|
3
|
+
status: number;
|
|
4
|
+
statusText: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export async function getErrorMessage(response: ErrorResponse) {
|
|
8
|
+
try {
|
|
9
|
+
const data = (await response.json()) as { error?: string };
|
|
10
|
+
if (typeof data.error === "string" && data.error.length > 0) {
|
|
11
|
+
return data.error;
|
|
12
|
+
}
|
|
13
|
+
} catch {
|
|
14
|
+
// Ignore invalid error payloads and fall back to the status text below.
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return response.statusText || `Request failed with status ${response.status}`;
|
|
18
|
+
};
|