botholomew 0.8.6 → 0.8.7
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/package.json +1 -1
- package/src/chat/session.ts +19 -0
- package/src/skills/commands.ts +12 -0
- package/src/tui/App.tsx +46 -2
- package/src/tui/components/InputBar.tsx +24 -7
- package/src/tui/slashCompletion.ts +16 -2
package/package.json
CHANGED
package/src/chat/session.ts
CHANGED
|
@@ -140,3 +140,22 @@ export async function endChatSession(session: ChatSession): Promise<void> {
|
|
|
140
140
|
await withDb(session.dbPath, (conn) => endThread(conn, session.threadId));
|
|
141
141
|
await session.cleanup();
|
|
142
142
|
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* End the current thread and start a fresh one on the same session.
|
|
146
|
+
* The old thread is persisted (marked ended) and can still be resumed
|
|
147
|
+
* via `botholomew chat --thread-id <id>`. Returns the previous thread
|
|
148
|
+
* ID so callers can display it to the user.
|
|
149
|
+
*/
|
|
150
|
+
export async function clearChatSession(
|
|
151
|
+
session: ChatSession,
|
|
152
|
+
): Promise<{ previousThreadId: string; newThreadId: string }> {
|
|
153
|
+
const previousThreadId = session.threadId;
|
|
154
|
+
const newThreadId = await withDb(session.dbPath, async (conn) => {
|
|
155
|
+
await endThread(conn, previousThreadId);
|
|
156
|
+
return createThread(conn, "chat_session", undefined, "New chat");
|
|
157
|
+
});
|
|
158
|
+
session.threadId = newThreadId;
|
|
159
|
+
session.messages.length = 0;
|
|
160
|
+
return { previousThreadId, newThreadId };
|
|
161
|
+
}
|
package/src/skills/commands.ts
CHANGED
|
@@ -4,11 +4,13 @@ import { renderSkill } from "./parser.ts";
|
|
|
4
4
|
export interface SlashCommand {
|
|
5
5
|
name: string;
|
|
6
6
|
description: string;
|
|
7
|
+
takesArgs?: boolean;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
export const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
|
10
11
|
{ name: "help", description: "Show command reference and shortcuts" },
|
|
11
12
|
{ name: "skills", description: "List available skills" },
|
|
13
|
+
{ name: "clear", description: "End current thread and start a new one" },
|
|
12
14
|
{ name: "exit", description: "End the chat session" },
|
|
13
15
|
];
|
|
14
16
|
|
|
@@ -17,6 +19,7 @@ export interface SlashCommandContext {
|
|
|
17
19
|
addSystemMessage: (content: string) => void;
|
|
18
20
|
queueUserMessage: (content: string) => void;
|
|
19
21
|
exit: () => void;
|
|
22
|
+
clearChat?: () => void;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
/**
|
|
@@ -38,6 +41,15 @@ export function handleSlashCommand(
|
|
|
38
41
|
return true;
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
if (name === "clear") {
|
|
45
|
+
if (ctx.clearChat) {
|
|
46
|
+
ctx.clearChat();
|
|
47
|
+
} else {
|
|
48
|
+
ctx.addSystemMessage("/clear is only available in the chat TUI.");
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
41
53
|
if (name === "skills") {
|
|
42
54
|
if (ctx.skills.size === 0) {
|
|
43
55
|
ctx.addSystemMessage(
|
package/src/tui/App.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { Box, Static, Text, useApp, useInput } from "ink";
|
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import {
|
|
4
4
|
type ChatSession,
|
|
5
|
+
clearChatSession,
|
|
5
6
|
endChatSession,
|
|
6
7
|
sendMessage,
|
|
7
8
|
startChatSession,
|
|
@@ -126,6 +127,7 @@ export function App({
|
|
|
126
127
|
}: AppProps) {
|
|
127
128
|
const { exit } = useApp();
|
|
128
129
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
130
|
+
const [messagesEpoch, setMessagesEpoch] = useState(0);
|
|
129
131
|
const [inputValue, setInputValue] = useState("");
|
|
130
132
|
const [inputHistory, setInputHistory] = useState<string[]>([]);
|
|
131
133
|
const [isLoading, setIsLoading] = useState(false);
|
|
@@ -490,7 +492,8 @@ export function App({
|
|
|
490
492
|
" ⌥+Enter Insert newline",
|
|
491
493
|
" ↑/↓ Browse input history",
|
|
492
494
|
" / Open slash-command autocomplete",
|
|
493
|
-
"
|
|
495
|
+
" Enter Run highlighted command / insert if it takes args (popup open)",
|
|
496
|
+
" Tab Insert highlighted command without submitting (popup open)",
|
|
494
497
|
" ↑/↓ Move highlight (popup open)",
|
|
495
498
|
" Esc Close popup",
|
|
496
499
|
"",
|
|
@@ -534,6 +537,7 @@ export function App({
|
|
|
534
537
|
"Commands:",
|
|
535
538
|
" /help Show this help",
|
|
536
539
|
" /skills List available skills",
|
|
540
|
+
" /clear End current thread and start a new one",
|
|
537
541
|
" /exit End the chat session",
|
|
538
542
|
...skillLines,
|
|
539
543
|
].join("\n"),
|
|
@@ -563,6 +567,42 @@ export function App({
|
|
|
563
567
|
processQueue();
|
|
564
568
|
},
|
|
565
569
|
exit,
|
|
570
|
+
clearChat: () => {
|
|
571
|
+
const session = sessionRef.current;
|
|
572
|
+
if (!session) return;
|
|
573
|
+
// Drain any queued messages so they don't leak into the new thread.
|
|
574
|
+
queueRef.current.length = 0;
|
|
575
|
+
syncQueue();
|
|
576
|
+
clearChatSession(session)
|
|
577
|
+
.then(({ previousThreadId, newThreadId }) => {
|
|
578
|
+
// Ink's <Static> writes messages to terminal scrollback and
|
|
579
|
+
// can't un-write them, so setMessages alone leaves the old
|
|
580
|
+
// lines visible. Clear the terminal (including scrollback)
|
|
581
|
+
// and bump the epoch key on <Static> to force a fresh mount.
|
|
582
|
+
process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
|
|
583
|
+
setMessages([
|
|
584
|
+
{
|
|
585
|
+
id: msgId(),
|
|
586
|
+
role: "system",
|
|
587
|
+
content: `Started a new chat thread (${newThreadId}). Previous thread saved — resume with: botholomew chat --thread-id ${previousThreadId}`,
|
|
588
|
+
timestamp: new Date(),
|
|
589
|
+
},
|
|
590
|
+
]);
|
|
591
|
+
setMessagesEpoch((n) => n + 1);
|
|
592
|
+
setChatTitle(undefined);
|
|
593
|
+
})
|
|
594
|
+
.catch((err) => {
|
|
595
|
+
setMessages((prev) => [
|
|
596
|
+
...prev,
|
|
597
|
+
{
|
|
598
|
+
id: msgId(),
|
|
599
|
+
role: "system",
|
|
600
|
+
content: `Failed to clear chat: ${err}`,
|
|
601
|
+
timestamp: new Date(),
|
|
602
|
+
},
|
|
603
|
+
]);
|
|
604
|
+
});
|
|
605
|
+
},
|
|
566
606
|
});
|
|
567
607
|
if (handled) return;
|
|
568
608
|
}
|
|
@@ -595,6 +635,10 @@ export function App({
|
|
|
595
635
|
? Array.from(sessionSkills.values()).map((s) => ({
|
|
596
636
|
name: s.name,
|
|
597
637
|
description: s.description,
|
|
638
|
+
takesArgs:
|
|
639
|
+
s.arguments.length > 0 ||
|
|
640
|
+
/\$ARGUMENTS\b/.test(s.body) ||
|
|
641
|
+
/\$[1-9]\b/.test(s.body),
|
|
598
642
|
}))
|
|
599
643
|
: [];
|
|
600
644
|
return buildSlashCommands(BUILTIN_SLASH_COMMANDS, skillList);
|
|
@@ -640,7 +684,7 @@ export function App({
|
|
|
640
684
|
node always has proper terminal width in its Yoga layout.
|
|
641
685
|
Otherwise Ink's border renderer crashes with a negative
|
|
642
686
|
contentWidth when tool-call boxes are rendered at width 0. */}
|
|
643
|
-
<Static items={messages}>
|
|
687
|
+
<Static key={messagesEpoch} items={messages}>
|
|
644
688
|
{(msg) => <MessageBubble key={msg.id} message={msg} />}
|
|
645
689
|
</Static>
|
|
646
690
|
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
useState,
|
|
10
10
|
} from "react";
|
|
11
11
|
import type { SlashCommand } from "../../skills/commands.ts";
|
|
12
|
-
import { getSlashMatches } from "../slashCompletion.ts";
|
|
12
|
+
import { getSlashMatches, shouldSubmitOnEnter } from "../slashCompletion.ts";
|
|
13
13
|
import { SlashCommandPopup } from "./SlashCommandPopup.tsx";
|
|
14
14
|
|
|
15
15
|
interface InputBarProps {
|
|
@@ -128,11 +128,23 @@ export const InputBar = memo(function InputBar({
|
|
|
128
128
|
? getSlashMatches(val, slashCommandsRef.current ?? [])
|
|
129
129
|
: null;
|
|
130
130
|
|
|
131
|
-
const acceptSelection = () => {
|
|
131
|
+
const acceptSelection = (mode: "insert" | "submit") => {
|
|
132
132
|
if (!popupOpen) return false;
|
|
133
133
|
const chosen =
|
|
134
134
|
popupOpen[Math.min(selectedIndexRef.current, popupOpen.length - 1)];
|
|
135
135
|
if (!chosen) return false;
|
|
136
|
+
if (mode === "submit") {
|
|
137
|
+
const completed = `/${chosen.name}`;
|
|
138
|
+
valueRef.current = completed;
|
|
139
|
+
cursorPosRef.current = 0;
|
|
140
|
+
onChangeRef.current(completed);
|
|
141
|
+
setCursorPos(0);
|
|
142
|
+
historyIndexRef.current = -1;
|
|
143
|
+
setHistoryIndex(-1);
|
|
144
|
+
savedInput.current = "";
|
|
145
|
+
onSubmitRef.current(completed);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
136
148
|
const completed = `/${chosen.name} `;
|
|
137
149
|
valueRef.current = completed;
|
|
138
150
|
cursorPosRef.current = completed.length;
|
|
@@ -152,11 +164,16 @@ export const InputBar = memo(function InputBar({
|
|
|
152
164
|
return;
|
|
153
165
|
}
|
|
154
166
|
|
|
155
|
-
// Enter: if popup is open, accept
|
|
156
|
-
//
|
|
167
|
+
// Enter: if popup is open, accept the highlighted entry. No-arg
|
|
168
|
+
// commands submit in one keystroke; commands that take args insert
|
|
169
|
+
// `/<name> ` and wait for the user to finish typing.
|
|
157
170
|
if (key.return) {
|
|
158
171
|
if (popupOpen && !key.shift && !key.meta) {
|
|
159
|
-
|
|
172
|
+
const chosen =
|
|
173
|
+
popupOpen[Math.min(selectedIndexRef.current, popupOpen.length - 1)];
|
|
174
|
+
acceptSelection(
|
|
175
|
+
chosen && shouldSubmitOnEnter(chosen) ? "submit" : "insert",
|
|
176
|
+
);
|
|
160
177
|
return;
|
|
161
178
|
}
|
|
162
179
|
if (key.shift || key.meta) {
|
|
@@ -179,10 +196,10 @@ export const InputBar = memo(function InputBar({
|
|
|
179
196
|
return;
|
|
180
197
|
}
|
|
181
198
|
|
|
182
|
-
// Tab:
|
|
199
|
+
// Tab: insert the highlighted completion so the user can keep editing.
|
|
183
200
|
if (key.tab) {
|
|
184
201
|
if (popupOpen) {
|
|
185
|
-
acceptSelection();
|
|
202
|
+
acceptSelection("insert");
|
|
186
203
|
}
|
|
187
204
|
return;
|
|
188
205
|
}
|
|
@@ -28,11 +28,25 @@ export function getSlashMatches(
|
|
|
28
28
|
|
|
29
29
|
export function buildSlashCommands(
|
|
30
30
|
builtins: SlashCommand[],
|
|
31
|
-
skills: Iterable<{ name: string; description: string }>,
|
|
31
|
+
skills: Iterable<{ name: string; description: string; takesArgs?: boolean }>,
|
|
32
32
|
): SlashCommand[] {
|
|
33
33
|
const out: SlashCommand[] = [...builtins];
|
|
34
34
|
for (const s of skills) {
|
|
35
|
-
out.push({
|
|
35
|
+
out.push({
|
|
36
|
+
name: s.name,
|
|
37
|
+
description: s.description,
|
|
38
|
+
takesArgs: s.takesArgs,
|
|
39
|
+
});
|
|
36
40
|
}
|
|
37
41
|
return out;
|
|
38
42
|
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Decide whether pressing Enter on a highlighted popup entry should both
|
|
46
|
+
* accept the completion and immediately submit. True for no-argument
|
|
47
|
+
* commands (single-Enter runs them); false for commands that take args,
|
|
48
|
+
* where we insert `/<name> ` and wait for the user to finish typing.
|
|
49
|
+
*/
|
|
50
|
+
export function shouldSubmitOnEnter(cmd: SlashCommand): boolean {
|
|
51
|
+
return !cmd.takesArgs;
|
|
52
|
+
}
|