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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.8.6",
3
+ "version": "0.8.7",
4
4
  "description": "An autonomous AI agent for knowledge work — works your task queue while you sleep.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
+ }
@@ -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
- " Tab/Enter Accept highlighted command (popup open)",
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 selection (do not submit).
156
- // Otherwise submit as before.
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
- acceptSelection();
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: accept popup selection if open. No-op otherwise.
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({ name: s.name, description: s.description });
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
+ }