@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,611 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { useRef, useState, useCallback, useEffect, type RefObject } from "react";
|
|
5
|
+
|
|
6
|
+
import { TextAttributes } from "@opentui/core";
|
|
7
|
+
|
|
8
|
+
import type { TextareaRenderable, ScrollBoxRenderable } from "@opentui/core";
|
|
9
|
+
import { useKeyboard, useRenderer } from "@opentui/react";
|
|
10
|
+
import type { KeyBinding } from "@opentui/core";
|
|
11
|
+
import { useNavigate } from "react-router";
|
|
12
|
+
import { EmptyBorder } from "./border";
|
|
13
|
+
import { StatusBar } from "./status-bar";
|
|
14
|
+
import { CommandMenu } from "./command-menu";
|
|
15
|
+
import type { Command } from "./command-menu/types";
|
|
16
|
+
import { useCommandMenu } from "./command-menu/use-command-menu";
|
|
17
|
+
import { useToast } from "../providers/toast";
|
|
18
|
+
import { useKeyboardLayer } from "../providers/keyboard-layer";
|
|
19
|
+
import { useDialog } from "../providers/dialog";
|
|
20
|
+
import { useTheme } from "../providers/theme";
|
|
21
|
+
import { usePromptConfig } from "../providers/prompt-config";
|
|
22
|
+
import { Mode } from "@nightcode/shared";
|
|
23
|
+
|
|
24
|
+
const MAX_VISIBLE_MENTIONS = 8;
|
|
25
|
+
const CURRENT_DIRECTORY = process.cwd();
|
|
26
|
+
const MAX_FALLBACK_MENTION_CANDIDATES = 32;
|
|
27
|
+
const MENTION_QUERY_CHARACTER = /[A-Za-z0-9._/-]/;
|
|
28
|
+
const RECURSIVE_MENTION_IGNORED_DIRECTORIES = new Set(["node_modules"]);
|
|
29
|
+
|
|
30
|
+
type MentionMatch = {
|
|
31
|
+
start: number;
|
|
32
|
+
end: number;
|
|
33
|
+
query: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type MentionCandidate = {
|
|
37
|
+
path: string;
|
|
38
|
+
kind: "file" | "directory";
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function isWithinCurrentDirectory(targetPath: string) {
|
|
42
|
+
const relativePath = relative(CURRENT_DIRECTORY, targetPath);
|
|
43
|
+
return relativePath === ""
|
|
44
|
+
|| (!relativePath.startsWith("..")
|
|
45
|
+
&& !isAbsolute(relativePath));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isMentionQueryCharacter(character: string) {
|
|
49
|
+
return MENTION_QUERY_CHARACTER.test(character);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function findActiveMention(text: string, cursorOffset: number): MentionMatch | null {
|
|
53
|
+
const safeOffset = Math.max(0, Math.min(cursorOffset, text.length));
|
|
54
|
+
|
|
55
|
+
let start = safeOffset;
|
|
56
|
+
while (start > 0 && !/\s/.test(text[start - 1]!)) {
|
|
57
|
+
start -= 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let end = safeOffset;
|
|
61
|
+
while (end < text.length && !/\s/.test(text[end]!)) {
|
|
62
|
+
end += 1;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const token = text.slice(start, end);
|
|
66
|
+
const relativeCursor = safeOffset - start;
|
|
67
|
+
const mentionStart = token.lastIndexOf("@", relativeCursor);
|
|
68
|
+
|
|
69
|
+
if (mentionStart === -1) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const previousCharacter = token[mentionStart - 1];
|
|
74
|
+
if (previousCharacter && isMentionQueryCharacter(previousCharacter)) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let mentionEnd = mentionStart + 1;
|
|
79
|
+
while (mentionEnd < token.length && isMentionQueryCharacter(token[mentionEnd]!)) {
|
|
80
|
+
mentionEnd += 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (relativeCursor < mentionStart || relativeCursor > mentionEnd) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
start: start + mentionStart,
|
|
89
|
+
end: start + mentionEnd,
|
|
90
|
+
query: token.slice(mentionStart + 1, mentionEnd),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function getMentionCandidates(query: string): Promise<MentionCandidate[]> {
|
|
95
|
+
const normalizedQuery = query.startsWith("./") ? query.slice(2) : query;
|
|
96
|
+
if (normalizedQuery.startsWith("/")) {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const hasTrailingSlash = normalizedQuery.endsWith("/");
|
|
101
|
+
const lastSlashIndex = hasTrailingSlash
|
|
102
|
+
? normalizedQuery.length - 1
|
|
103
|
+
: normalizedQuery.lastIndexOf("/");
|
|
104
|
+
|
|
105
|
+
const directoryPart = hasTrailingSlash
|
|
106
|
+
? normalizedQuery.slice(0, -1)
|
|
107
|
+
: lastSlashIndex === -1
|
|
108
|
+
? ""
|
|
109
|
+
: normalizedQuery.slice(0, lastSlashIndex);
|
|
110
|
+
|
|
111
|
+
const namePrefix = hasTrailingSlash
|
|
112
|
+
? ""
|
|
113
|
+
: lastSlashIndex === -1
|
|
114
|
+
? normalizedQuery
|
|
115
|
+
: normalizedQuery.slice(lastSlashIndex + 1);
|
|
116
|
+
|
|
117
|
+
const absoluteDirectory = resolve(CURRENT_DIRECTORY, directoryPart || ".");
|
|
118
|
+
if (!isWithinCurrentDirectory(absoluteDirectory)) {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const entries = await readdir(absoluteDirectory, { withFileTypes: true });
|
|
124
|
+
const lowercasePrefix = namePrefix.toLowerCase();
|
|
125
|
+
const showHiddenEntries = namePrefix.startsWith(".");
|
|
126
|
+
|
|
127
|
+
const directMatches = entries
|
|
128
|
+
.filter((entry) => showHiddenEntries || !entry.name.startsWith("."))
|
|
129
|
+
.filter((entry) => {
|
|
130
|
+
return lowercasePrefix === "" || entry.name.toLowerCase().startsWith(lowercasePrefix);
|
|
131
|
+
})
|
|
132
|
+
.sort((left, right) => {
|
|
133
|
+
if (left.isDirectory() !== right.isDirectory()) {
|
|
134
|
+
return left.isDirectory() ? -1 : 1;
|
|
135
|
+
}
|
|
136
|
+
return left.name.localeCompare(right.name);
|
|
137
|
+
})
|
|
138
|
+
.map((entry) => {
|
|
139
|
+
const path = directoryPart ? `${directoryPart}/${entry.name}` : entry.name;
|
|
140
|
+
const kind: MentionCandidate["kind"] = entry.isDirectory() ? "directory" : "file";
|
|
141
|
+
return {
|
|
142
|
+
path: kind === "directory" ? `${path}/` : path,
|
|
143
|
+
kind,
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (directMatches.length > 0 || directoryPart !== "" || namePrefix === "") {
|
|
148
|
+
return directMatches;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const fallbackMatches: MentionCandidate[] = [];
|
|
152
|
+
const visit = async (
|
|
153
|
+
absoluteDirectory: string,
|
|
154
|
+
directoryPart: string
|
|
155
|
+
): Promise<void> => {
|
|
156
|
+
const entries = await readdir(absoluteDirectory, { withFileTypes: true });
|
|
157
|
+
|
|
158
|
+
for (const entry of entries) {
|
|
159
|
+
if (!showHiddenEntries && entry.name.startsWith(".")) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (
|
|
164
|
+
entry.isDirectory()
|
|
165
|
+
&& RECURSIVE_MENTION_IGNORED_DIRECTORIES.has(entry.name)
|
|
166
|
+
) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const path = directoryPart ? `${directoryPart}/${entry.name}` : entry.name;
|
|
171
|
+
const kind: MentionCandidate["kind"] =
|
|
172
|
+
entry.isDirectory() ? "directory" : "file";
|
|
173
|
+
|
|
174
|
+
if (entry.name.toLowerCase().startsWith(lowercasePrefix)) {
|
|
175
|
+
fallbackMatches.push({
|
|
176
|
+
path: kind === "directory" ? `${path}/` : path,
|
|
177
|
+
kind,
|
|
178
|
+
});
|
|
179
|
+
if (fallbackMatches.length >= MAX_FALLBACK_MENTION_CANDIDATES) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (entry.isDirectory()) {
|
|
185
|
+
await visit(resolve(absoluteDirectory, entry.name), path);
|
|
186
|
+
if (fallbackMatches.length >= MAX_FALLBACK_MENTION_CANDIDATES) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
await visit(CURRENT_DIRECTORY, "");
|
|
194
|
+
return fallbackMatches.sort((left, right) => left.path.localeCompare(right.path));
|
|
195
|
+
} catch {
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
type FileMentionMenuProps = {
|
|
201
|
+
candidates: MentionCandidate[];
|
|
202
|
+
selectedIndex: number;
|
|
203
|
+
scrollRef: RefObject<ScrollBoxRenderable | null>;
|
|
204
|
+
onSelect: (index: number) => void;
|
|
205
|
+
onExecute: (index: number) => void;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
function FileMentionMenu({
|
|
209
|
+
candidates,
|
|
210
|
+
selectedIndex,
|
|
211
|
+
scrollRef,
|
|
212
|
+
onSelect,
|
|
213
|
+
onExecute,
|
|
214
|
+
}: FileMentionMenuProps) {
|
|
215
|
+
const { colors } = useTheme();
|
|
216
|
+
const visibleHeight = Math.min(candidates.length, MAX_VISIBLE_MENTIONS);
|
|
217
|
+
|
|
218
|
+
if (candidates.length === 0) {
|
|
219
|
+
return (
|
|
220
|
+
<box paddingX={1}>
|
|
221
|
+
<text attributes={TextAttributes.DIM}>No matching files or folders</text>
|
|
222
|
+
</box>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<scrollbox ref={scrollRef} height={visibleHeight}>
|
|
228
|
+
{candidates.map((candidate, index) => {
|
|
229
|
+
const isSelected = index === selectedIndex;
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<box
|
|
233
|
+
key={candidate.path}
|
|
234
|
+
flexDirection="row"
|
|
235
|
+
paddingX={1}
|
|
236
|
+
height={1}
|
|
237
|
+
overflow="hidden"
|
|
238
|
+
backgroundColor={isSelected ? colors.selection : undefined}
|
|
239
|
+
onMouseMove={() => onSelect(index)}
|
|
240
|
+
onMouseDown={() => onExecute(index)}
|
|
241
|
+
>
|
|
242
|
+
<box flexGrow={1} flexShrink={1} overflow="hidden">
|
|
243
|
+
<text selectable={false} fg={isSelected ? "black" : "white"}>
|
|
244
|
+
{candidate.path}
|
|
245
|
+
</text>
|
|
246
|
+
</box>
|
|
247
|
+
|
|
248
|
+
<box width={8} alignItems="flex-end" flexShrink={0}>
|
|
249
|
+
<text selectable={false} fg={isSelected ? "black" : "gray"}>
|
|
250
|
+
{candidate.kind === "directory" ? "Folder" : "File"}
|
|
251
|
+
</text>
|
|
252
|
+
</box>
|
|
253
|
+
</box>
|
|
254
|
+
);
|
|
255
|
+
})}
|
|
256
|
+
</scrollbox>
|
|
257
|
+
);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
type Props = {
|
|
261
|
+
onSubmit: (text: string) => void;
|
|
262
|
+
disabled?: boolean;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
export const TEXTAREA_KEY_BINDINGS: KeyBinding[] = [
|
|
266
|
+
{ name: "return", action: "submit" },
|
|
267
|
+
{ name: "enter", action: "submit" },
|
|
268
|
+
{ name: "return", shift: true, action: "newline" },
|
|
269
|
+
{ name: "enter", shift: true, action: "newline" },
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
export function InputBar({ onSubmit, disabled = false }: Props) {
|
|
273
|
+
const { mode, toggleMode, setMode, setModel } = usePromptConfig();
|
|
274
|
+
const textareaRef = useRef<TextareaRenderable>(null);
|
|
275
|
+
const onSubmitRef = useRef<() => void>(() => {});
|
|
276
|
+
const activeMentionRef = useRef<MentionMatch | null>(null);
|
|
277
|
+
const mentionScrollRef = useRef<ScrollBoxRenderable>(null);
|
|
278
|
+
|
|
279
|
+
const renderer = useRenderer();
|
|
280
|
+
const navigate = useNavigate();
|
|
281
|
+
const toast = useToast();
|
|
282
|
+
const dialog = useDialog();
|
|
283
|
+
const { colors } = useTheme();
|
|
284
|
+
const { isTopLayer, push, pop, setResponder } = useKeyboardLayer();
|
|
285
|
+
|
|
286
|
+
const [activeMention, setActiveMention] = useState<MentionMatch | null>(null);
|
|
287
|
+
const [mentionCandidates, setMentionCandidates] = useState<MentionCandidate[]>([]);
|
|
288
|
+
const [mentionSelectedIndex, setMentionSelectedIndex] = useState(0);
|
|
289
|
+
|
|
290
|
+
const {
|
|
291
|
+
showCommandMenu,
|
|
292
|
+
commandQuery,
|
|
293
|
+
selectedIndex,
|
|
294
|
+
scrollRef,
|
|
295
|
+
handleContentChange,
|
|
296
|
+
resolveCommand,
|
|
297
|
+
setSelectedIndex,
|
|
298
|
+
} = useCommandMenu();
|
|
299
|
+
|
|
300
|
+
const showMentionMenu = activeMention !== null;
|
|
301
|
+
|
|
302
|
+
const closeMentionMenu = useCallback(() => {
|
|
303
|
+
activeMentionRef.current = null;
|
|
304
|
+
setActiveMention(null);
|
|
305
|
+
setMentionCandidates([]);
|
|
306
|
+
pop("mention");
|
|
307
|
+
}, [pop]);
|
|
308
|
+
|
|
309
|
+
const syncMentionMenu = useCallback((text: string, cursorOffset: number) => {
|
|
310
|
+
const nextMention = findActiveMention(text, cursorOffset);
|
|
311
|
+
const previousMention = activeMentionRef.current;
|
|
312
|
+
const mentionChanged =
|
|
313
|
+
previousMention?.start !== nextMention?.start ||
|
|
314
|
+
previousMention?.end !== nextMention?.end ||
|
|
315
|
+
previousMention?.query !== nextMention?.query;
|
|
316
|
+
|
|
317
|
+
if (!nextMention) {
|
|
318
|
+
if (previousMention) {
|
|
319
|
+
closeMentionMenu();
|
|
320
|
+
}
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
activeMentionRef.current = nextMention;
|
|
325
|
+
setActiveMention(nextMention);
|
|
326
|
+
push("mention", () => {
|
|
327
|
+
closeMentionMenu();
|
|
328
|
+
return true;
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
if (mentionChanged) {
|
|
332
|
+
setMentionSelectedIndex(0);
|
|
333
|
+
mentionScrollRef.current?.scrollTo(0);
|
|
334
|
+
}
|
|
335
|
+
}, [closeMentionMenu, push]);
|
|
336
|
+
|
|
337
|
+
const handleTextareaContentChange = useCallback(() => {
|
|
338
|
+
const textarea = textareaRef.current;
|
|
339
|
+
if (!textarea) return;
|
|
340
|
+
|
|
341
|
+
const text = textarea.plainText;
|
|
342
|
+
|
|
343
|
+
handleContentChange(textarea.plainText);
|
|
344
|
+
syncMentionMenu(text, textarea.cursorOffset);
|
|
345
|
+
}, [handleContentChange, syncMentionMenu]);
|
|
346
|
+
|
|
347
|
+
const handleSubmit = useCallback(() => {
|
|
348
|
+
if (disabled) return;
|
|
349
|
+
|
|
350
|
+
const textarea = textareaRef.current;
|
|
351
|
+
if (!textarea) return;
|
|
352
|
+
|
|
353
|
+
const text = textarea.plainText.trim();
|
|
354
|
+
if (text.length === 0) return;
|
|
355
|
+
|
|
356
|
+
onSubmit(text);
|
|
357
|
+
textarea.setText("");
|
|
358
|
+
}, [disabled, onSubmit])
|
|
359
|
+
|
|
360
|
+
const handleMentionExecute = useCallback((index: number) => {
|
|
361
|
+
const textarea = textareaRef.current;
|
|
362
|
+
const mention = activeMentionRef.current;
|
|
363
|
+
const candidate = mentionCandidates[index];
|
|
364
|
+
|
|
365
|
+
if (!textarea || !mention || !candidate) return;
|
|
366
|
+
|
|
367
|
+
const insertion = candidate.kind === "directory"
|
|
368
|
+
? candidate.path
|
|
369
|
+
: `${candidate.path} `;
|
|
370
|
+
|
|
371
|
+
const nextText = `${textarea.plainText.slice(0, mention.start)}@${insertion}${textarea.plainText.slice(mention.end)}`;
|
|
372
|
+
|
|
373
|
+
textarea.replaceText(nextText);
|
|
374
|
+
textarea.cursorOffset = mention.start + insertion.length + 1;
|
|
375
|
+
syncMentionMenu(nextText, textarea.cursorOffset);
|
|
376
|
+
}, [mentionCandidates, syncMentionMenu]);
|
|
377
|
+
|
|
378
|
+
const handleTextareaCursorChange = useCallback(() => {
|
|
379
|
+
const textarea = textareaRef.current;
|
|
380
|
+
if (!textarea) return;
|
|
381
|
+
|
|
382
|
+
syncMentionMenu(textarea.plainText, textarea.cursorOffset);
|
|
383
|
+
}, [syncMentionMenu]);
|
|
384
|
+
|
|
385
|
+
const handleCommand = useCallback((
|
|
386
|
+
command: Command | undefined
|
|
387
|
+
) => {
|
|
388
|
+
const textarea = textareaRef.current;
|
|
389
|
+
if (!textarea || !command) return;
|
|
390
|
+
|
|
391
|
+
textarea.setText("");
|
|
392
|
+
|
|
393
|
+
if (command.action) {
|
|
394
|
+
command.action({
|
|
395
|
+
exit: () => renderer.destroy(),
|
|
396
|
+
toast,
|
|
397
|
+
dialog,
|
|
398
|
+
navigate,
|
|
399
|
+
mode,
|
|
400
|
+
setMode,
|
|
401
|
+
setModel,
|
|
402
|
+
});
|
|
403
|
+
} else {
|
|
404
|
+
textarea.insertText(command.value + " ");
|
|
405
|
+
}
|
|
406
|
+
}, [renderer, toast, dialog, navigate, mode, setMode, setModel]);
|
|
407
|
+
|
|
408
|
+
const handleCommandExecute = useCallback(
|
|
409
|
+
(index: number) => {
|
|
410
|
+
const command = resolveCommand(index);
|
|
411
|
+
handleCommand(command);
|
|
412
|
+
},
|
|
413
|
+
[resolveCommand, handleCommand],
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
// Keep the file picker in sync with the current @mention token.
|
|
417
|
+
useEffect(() => {
|
|
418
|
+
if (!activeMention) {
|
|
419
|
+
setMentionCandidates([]);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
let ignore = false;
|
|
424
|
+
const loadCandidates = async () => {
|
|
425
|
+
const nextCandidates = await getMentionCandidates(activeMention.query);
|
|
426
|
+
if (ignore) return;
|
|
427
|
+
|
|
428
|
+
setMentionCandidates(nextCandidates);
|
|
429
|
+
setMentionSelectedIndex((currentIndex) => {
|
|
430
|
+
if (nextCandidates.length === 0) {
|
|
431
|
+
return 0;
|
|
432
|
+
}
|
|
433
|
+
return Math.min(currentIndex, nextCandidates.length - 1);
|
|
434
|
+
});
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
void loadCandidates();
|
|
438
|
+
|
|
439
|
+
return () => {
|
|
440
|
+
ignore = true;
|
|
441
|
+
};
|
|
442
|
+
}, [activeMention]);
|
|
443
|
+
|
|
444
|
+
// Wire up textarea submit handler once so it always reads the latest state.
|
|
445
|
+
useEffect(() => {
|
|
446
|
+
const textarea = textareaRef.current;
|
|
447
|
+
if (!textarea) return;
|
|
448
|
+
|
|
449
|
+
textarea.onSubmit = () => {
|
|
450
|
+
onSubmitRef.current();
|
|
451
|
+
};
|
|
452
|
+
}, []);
|
|
453
|
+
|
|
454
|
+
onSubmitRef.current = () => {
|
|
455
|
+
if (disabled) return;
|
|
456
|
+
|
|
457
|
+
if (showCommandMenu) {
|
|
458
|
+
const command = resolveCommand(selectedIndex);
|
|
459
|
+
handleCommand(command);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (showMentionMenu) {
|
|
464
|
+
const candidate = mentionCandidates[mentionSelectedIndex];
|
|
465
|
+
if (candidate) {
|
|
466
|
+
handleMentionExecute(mentionSelectedIndex);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
handleSubmit();
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
useKeyboard((key) => {
|
|
475
|
+
if (disabled) return;
|
|
476
|
+
if (!isTopLayer("base")) return;
|
|
477
|
+
if (key.name === "tab") {
|
|
478
|
+
key.preventDefault();
|
|
479
|
+
toggleMode();
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// Register the base layer responder for ctrl+c dismissal
|
|
484
|
+
useEffect(() => {
|
|
485
|
+
setResponder("base", () => {
|
|
486
|
+
if (disabled) return false;
|
|
487
|
+
|
|
488
|
+
const textarea = textareaRef.current;
|
|
489
|
+
if (textarea && textarea.plainText.length > 0) {
|
|
490
|
+
textarea.setText("");
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
return false;
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
return () => setResponder("base", null);
|
|
497
|
+
}, [disabled, setResponder]);
|
|
498
|
+
|
|
499
|
+
useKeyboard((key) => {
|
|
500
|
+
if (disabled) return;
|
|
501
|
+
if (!showMentionMenu || !isTopLayer("mention")) return;
|
|
502
|
+
|
|
503
|
+
if (key.name === "escape") {
|
|
504
|
+
key.preventDefault();
|
|
505
|
+
closeMentionMenu();
|
|
506
|
+
} else if (key.name === "up") {
|
|
507
|
+
key.preventDefault();
|
|
508
|
+
setMentionSelectedIndex((currentIndex) => {
|
|
509
|
+
const nextIndex = Math.max(0, currentIndex - 1);
|
|
510
|
+
const scrollbox = mentionScrollRef.current;
|
|
511
|
+
if (scrollbox && nextIndex < scrollbox.scrollTop) {
|
|
512
|
+
scrollbox.scrollTo(nextIndex);
|
|
513
|
+
}
|
|
514
|
+
return nextIndex;
|
|
515
|
+
});
|
|
516
|
+
} else if (key.name === "down") {
|
|
517
|
+
key.preventDefault();
|
|
518
|
+
setMentionSelectedIndex((currentIndex) => {
|
|
519
|
+
if (mentionCandidates.length === 0) {
|
|
520
|
+
return 0;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const nextIndex = Math.min(mentionCandidates.length - 1, currentIndex + 1);
|
|
524
|
+
const scrollbox = mentionScrollRef.current;
|
|
525
|
+
|
|
526
|
+
if (scrollbox) {
|
|
527
|
+
const viewportHeight = scrollbox.viewport.height;
|
|
528
|
+
const visibleEnd = scrollbox.scrollTop + viewportHeight - 1;
|
|
529
|
+
if (nextIndex > visibleEnd) {
|
|
530
|
+
scrollbox.scrollTo(nextIndex - viewportHeight + 1);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return nextIndex;
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
return (
|
|
540
|
+
<box width="100%" alignItems="center">
|
|
541
|
+
<box
|
|
542
|
+
border={["left"]}
|
|
543
|
+
borderColor={mode === Mode.BUILD ? colors.primary : colors.planMode}
|
|
544
|
+
customBorderChars={{
|
|
545
|
+
...EmptyBorder,
|
|
546
|
+
vertical: "┃",
|
|
547
|
+
bottomLeft: "╹",
|
|
548
|
+
}}
|
|
549
|
+
width="100%"
|
|
550
|
+
>
|
|
551
|
+
<box
|
|
552
|
+
position="relative"
|
|
553
|
+
justifyContent="center"
|
|
554
|
+
paddingX={2}
|
|
555
|
+
paddingY={1}
|
|
556
|
+
backgroundColor={colors.surface}
|
|
557
|
+
width="100%"
|
|
558
|
+
gap={1}
|
|
559
|
+
>
|
|
560
|
+
{showCommandMenu && (
|
|
561
|
+
<box
|
|
562
|
+
position="absolute"
|
|
563
|
+
bottom="100%"
|
|
564
|
+
left={0}
|
|
565
|
+
width="100%"
|
|
566
|
+
backgroundColor={colors.surface}
|
|
567
|
+
zIndex={10}
|
|
568
|
+
>
|
|
569
|
+
<CommandMenu
|
|
570
|
+
query={commandQuery}
|
|
571
|
+
selectedIndex={selectedIndex}
|
|
572
|
+
scrollRef={scrollRef}
|
|
573
|
+
onSelect={setSelectedIndex}
|
|
574
|
+
onExecute={handleCommandExecute}
|
|
575
|
+
/>
|
|
576
|
+
</box>
|
|
577
|
+
)}
|
|
578
|
+
{!showCommandMenu && showMentionMenu && (
|
|
579
|
+
<box
|
|
580
|
+
position="absolute"
|
|
581
|
+
bottom="100%"
|
|
582
|
+
left={0}
|
|
583
|
+
width="100%"
|
|
584
|
+
backgroundColor={colors.surface}
|
|
585
|
+
zIndex={10}
|
|
586
|
+
>
|
|
587
|
+
<FileMentionMenu
|
|
588
|
+
candidates={mentionCandidates}
|
|
589
|
+
selectedIndex={mentionSelectedIndex}
|
|
590
|
+
scrollRef={mentionScrollRef}
|
|
591
|
+
onSelect={setMentionSelectedIndex}
|
|
592
|
+
onExecute={handleMentionExecute}
|
|
593
|
+
/>
|
|
594
|
+
</box>
|
|
595
|
+
)}
|
|
596
|
+
<textarea
|
|
597
|
+
ref={textareaRef}
|
|
598
|
+
focused={
|
|
599
|
+
!disabled &&
|
|
600
|
+
(isTopLayer("base") || isTopLayer("command") || isTopLayer("mention"))
|
|
601
|
+
}
|
|
602
|
+
keyBindings={TEXTAREA_KEY_BINDINGS}
|
|
603
|
+
onContentChange={handleTextareaContentChange}
|
|
604
|
+
placeholder={`Ask anything... "Fix a bug in the database"`}
|
|
605
|
+
/>
|
|
606
|
+
<StatusBar />
|
|
607
|
+
</box>
|
|
608
|
+
</box>
|
|
609
|
+
</box>
|
|
610
|
+
);
|
|
611
|
+
};
|