botholomew 0.16.0 → 0.16.3

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.
@@ -1,4 +1,4 @@
1
- import { Box, Text, useInput, useStdout } from "ink";
1
+ import { Box, Text, useInput } from "ink";
2
2
  import { memo, useCallback, useEffect, useMemo, useState } from "react";
3
3
  import { getDbPath } from "../../constants.ts";
4
4
  import {
@@ -24,6 +24,8 @@ import { isMarkdownPath, renderMarkdown } from "../markdown.ts";
24
24
  import { theme } from "../theme.ts";
25
25
  import { useDeleteConfirm } from "../useDeleteConfirm.ts";
26
26
  import { useLatestRef } from "../useLatestRef.ts";
27
+ import { useTerminalSize } from "../useTerminalSize.ts";
28
+ import { wrapDetailLines } from "../wrapDetail.ts";
27
29
  import { DeleteArmedBanner } from "./DeleteArmedBanner.tsx";
28
30
  import { Scrollbar } from "./Scrollbar.tsx";
29
31
 
@@ -39,8 +41,8 @@ export const ContextPanel = memo(function ContextPanel({
39
41
  projectDir,
40
42
  isActive,
41
43
  }: ContextPanelProps) {
42
- const { stdout } = useStdout();
43
- const termRows = stdout?.rows ?? 24;
44
+ const { rows: termRows, cols: termCols } = useTerminalSize();
45
+ const detailWidth = Math.max(1, termCols - SIDEBAR_WIDTH - 5);
44
46
 
45
47
  const [currentPath, setCurrentPath] = useState("");
46
48
  const [entries, setEntries] = useState<ContextEntry[]>([]);
@@ -150,8 +152,8 @@ export const ContextPanel = memo(function ContextPanel({
150
152
  const body = isMarkdownPath(fileContent.path)
151
153
  ? renderMarkdown(fileContent.content)
152
154
  : fileContent.content;
153
- return body.split("\n");
154
- }, [fileContent, selectedEntry]);
155
+ return wrapDetailLines(body, detailWidth);
156
+ }, [fileContent, selectedEntry, detailWidth]);
155
157
 
156
158
  const visibleDetailRows = Math.max(1, visibleRows - 2);
157
159
  const maxDetailScroll = Math.max(0, detailLines.length - visibleDetailRows);
@@ -18,6 +18,9 @@ interface MessageListProps {
18
18
  isLoading: boolean;
19
19
  activeToolCalls: ToolCallData[];
20
20
  preparingTool: { id: string; name: string } | null;
21
+ /** Timestamp the current streaming bubble started. Stable across token flushes
22
+ * so the displayed time doesn't flicker on every re-render. */
23
+ streamStartedAt: Date | null;
21
24
  }
22
25
 
23
26
  function formatTime(date: Date): string {
@@ -124,11 +127,46 @@ export const MessageBubble = memo(function MessageBubble({
124
127
  );
125
128
  });
126
129
 
130
+ const ActiveToolsBox = memo(function ActiveToolsBox({
131
+ toolCalls,
132
+ }: {
133
+ toolCalls: ToolCallData[];
134
+ }) {
135
+ if (toolCalls.length === 0) return null;
136
+ return (
137
+ <Box
138
+ flexDirection="column"
139
+ marginLeft={1}
140
+ borderStyle="round"
141
+ borderColor={theme.accentBorder}
142
+ paddingX={1}
143
+ >
144
+ {toolCalls.map((tc) => (
145
+ <ToolCall key={tc.id} tool={tc} />
146
+ ))}
147
+ </Box>
148
+ );
149
+ });
150
+
151
+ const StreamingMarkdown = memo(function StreamingMarkdown({
152
+ text,
153
+ }: {
154
+ text: string;
155
+ }) {
156
+ const rendered = useMemo(() => renderMarkdown(text), [text]);
157
+ return (
158
+ <Box marginLeft={1}>
159
+ <Text>{rendered}</Text>
160
+ </Box>
161
+ );
162
+ });
163
+
127
164
  export function MessageList({
128
165
  streamingText,
129
166
  isLoading,
130
167
  activeToolCalls,
131
168
  preparingTool,
169
+ streamStartedAt,
132
170
  }: MessageListProps) {
133
171
  return (
134
172
  <>
@@ -139,26 +177,10 @@ export function MessageList({
139
177
  <Text bold color="green">
140
178
  Botholomew
141
179
  </Text>
142
- <Text dimColor> {formatTime(new Date())}</Text>
180
+ <Text dimColor> {formatTime(streamStartedAt ?? new Date())}</Text>
143
181
  </Box>
144
- {activeToolCalls.length > 0 && (
145
- <Box
146
- flexDirection="column"
147
- marginLeft={1}
148
- borderStyle="round"
149
- borderColor={theme.accentBorder}
150
- paddingX={1}
151
- >
152
- {activeToolCalls.map((tc) => (
153
- <ToolCall key={tc.id} tool={tc} />
154
- ))}
155
- </Box>
156
- )}
157
- {streamingText && (
158
- <Box marginLeft={1}>
159
- <Text>{renderMarkdown(streamingText)}</Text>
160
- </Box>
161
- )}
182
+ <ActiveToolsBox toolCalls={activeToolCalls} />
183
+ {streamingText && <StreamingMarkdown text={streamingText} />}
162
184
  </Box>
163
185
  )}
164
186
 
@@ -173,14 +195,15 @@ export function MessageList({
173
195
 
174
196
  {isLoading &&
175
197
  !preparingTool &&
176
- !streamingText &&
177
198
  (activeToolCalls.length === 0 ||
178
199
  activeToolCalls.every((tc) => !tc.running)) && (
179
200
  <Box marginTop={1}>
180
201
  <Text color={theme.accent}>
181
202
  <Spinner type="dots" />
182
203
  </Text>
183
- <Text dimColor> Thinking...</Text>
204
+ <Text dimColor>
205
+ {streamingText ? " Streaming..." : " Thinking..."}
206
+ </Text>
184
207
  </Box>
185
208
  )}
186
209
  </>
@@ -1,4 +1,4 @@
1
- import { Box, Text, useInput, useStdout } from "ink";
1
+ import { Box, Text, useInput } from "ink";
2
2
  import { memo, useCallback, useEffect, useMemo, useState } from "react";
3
3
  import type { Schedule } from "../../schedules/schema.ts";
4
4
  import {
@@ -14,6 +14,8 @@ import {
14
14
  import { ansi, theme } from "../theme.ts";
15
15
  import { useDeleteConfirm } from "../useDeleteConfirm.ts";
16
16
  import { useLatestRef } from "../useLatestRef.ts";
17
+ import { useTerminalSize } from "../useTerminalSize.ts";
18
+ import { wrapDetailLines } from "../wrapDetail.ts";
17
19
  import { DeleteArmedBanner } from "./DeleteArmedBanner.tsx";
18
20
  import { Scrollbar } from "./Scrollbar.tsx";
19
21
 
@@ -88,8 +90,8 @@ export const SchedulePanel = memo(function SchedulePanel({
88
90
  projectDir,
89
91
  isActive,
90
92
  }: SchedulePanelProps) {
91
- const { stdout } = useStdout();
92
- const termRows = stdout?.rows ?? 24;
93
+ const { rows: termRows, cols: termCols } = useTerminalSize();
94
+ const detailWidth = Math.max(1, termCols - SIDEBAR_WIDTH - 5);
93
95
  const [schedules, setSchedules] = useState<Schedule[]>([]);
94
96
  const [selectedIndex, setSelectedIndex] = useState(0);
95
97
  const [detailScroll, setDetailScroll] = useState(0);
@@ -133,8 +135,8 @@ export const SchedulePanel = memo(function SchedulePanel({
133
135
  }, [selectedSchedule]);
134
136
 
135
137
  const detailLines = useMemo(
136
- () => renderedDetail.split("\n"),
137
- [renderedDetail],
138
+ () => wrapDetailLines(renderedDetail, detailWidth),
139
+ [renderedDetail, detailWidth],
138
140
  );
139
141
 
140
142
  const visibleRows = Math.max(1, termRows - 6);
@@ -10,7 +10,7 @@ const TABS: { id: TabId; label: string; key: string }[] = [
10
10
  { id: 2, label: "Tools", key: "^o" },
11
11
  { id: 3, label: "Context", key: "^n" },
12
12
  { id: 4, label: "Tasks", key: "^t" },
13
- { id: 5, label: "Threads", key: "^r" },
13
+ { id: 5, label: "Threads", key: "^e" },
14
14
  { id: 6, label: "Schedules", key: "^s" },
15
15
  { id: 7, label: "Workers", key: "^w" },
16
16
  { id: 8, label: "Help", key: "^g" },
@@ -0,0 +1,108 @@
1
+ import { Box } from "ink";
2
+ import type { ContextUsage } from "../../chat/usage.ts";
3
+ import { ContextPanel } from "./ContextPanel.tsx";
4
+ import { HelpPanel } from "./HelpPanel.tsx";
5
+ import { SchedulePanel } from "./SchedulePanel.tsx";
6
+ import type { TabId } from "./TabBar.tsx";
7
+ import { TaskPanel } from "./TaskPanel.tsx";
8
+ import { ThreadPanel } from "./ThreadPanel.tsx";
9
+ import type { ToolCallData } from "./ToolCall.tsx";
10
+ import { ToolPanel } from "./ToolPanel.tsx";
11
+ import { WorkerPanel } from "./WorkerPanel.tsx";
12
+
13
+ interface TabPanelsProps {
14
+ activeTab: TabId;
15
+ projectDir: string;
16
+ threadId: string;
17
+ allToolCalls: ToolCallData[];
18
+ workerRunning: boolean;
19
+ usage: ContextUsage | null;
20
+ }
21
+
22
+ // Tabs 2–8. The chat tab (1) is structurally different (`maxHeight` clipping,
23
+ // streaming props) and stays inline in App.tsx. All panels stay mounted to
24
+ // avoid expensive remount cycles — `display="none"` hides inactive panels
25
+ // from layout without destroying them.
26
+ //
27
+ // `flexGrow={1}` fills the root (which is pinned to `rows` on these tabs)
28
+ // minus the footer's actual height, so the panel always reaches the top of
29
+ // the viewport — no scrollback leak above the panel regardless of footer
30
+ // height.
31
+ export function TabPanels({
32
+ activeTab,
33
+ projectDir,
34
+ threadId,
35
+ allToolCalls,
36
+ workerRunning,
37
+ usage,
38
+ }: TabPanelsProps) {
39
+ return (
40
+ <>
41
+ <Box
42
+ display={activeTab === 2 ? "flex" : "none"}
43
+ flexDirection="column"
44
+ flexGrow={1}
45
+ overflow="hidden"
46
+ >
47
+ <ToolPanel toolCalls={allToolCalls} isActive={activeTab === 2} />
48
+ </Box>
49
+ <Box
50
+ display={activeTab === 3 ? "flex" : "none"}
51
+ flexDirection="column"
52
+ flexGrow={1}
53
+ overflow="hidden"
54
+ >
55
+ <ContextPanel projectDir={projectDir} isActive={activeTab === 3} />
56
+ </Box>
57
+ <Box
58
+ display={activeTab === 4 ? "flex" : "none"}
59
+ flexDirection="column"
60
+ flexGrow={1}
61
+ overflow="hidden"
62
+ >
63
+ <TaskPanel projectDir={projectDir} isActive={activeTab === 4} />
64
+ </Box>
65
+ <Box
66
+ display={activeTab === 5 ? "flex" : "none"}
67
+ flexDirection="column"
68
+ flexGrow={1}
69
+ overflow="hidden"
70
+ >
71
+ <ThreadPanel
72
+ projectDir={projectDir}
73
+ activeThreadId={threadId}
74
+ isActive={activeTab === 5}
75
+ />
76
+ </Box>
77
+ <Box
78
+ display={activeTab === 6 ? "flex" : "none"}
79
+ flexDirection="column"
80
+ flexGrow={1}
81
+ overflow="hidden"
82
+ >
83
+ <SchedulePanel projectDir={projectDir} isActive={activeTab === 6} />
84
+ </Box>
85
+ <Box
86
+ display={activeTab === 7 ? "flex" : "none"}
87
+ flexDirection="column"
88
+ flexGrow={1}
89
+ overflow="hidden"
90
+ >
91
+ <WorkerPanel projectDir={projectDir} isActive={activeTab === 7} />
92
+ </Box>
93
+ <Box
94
+ display={activeTab === 8 ? "flex" : "none"}
95
+ flexDirection="column"
96
+ flexGrow={1}
97
+ overflow="hidden"
98
+ >
99
+ <HelpPanel
100
+ projectDir={projectDir}
101
+ threadId={threadId}
102
+ workerRunning={workerRunning}
103
+ usage={usage}
104
+ />
105
+ </Box>
106
+ </>
107
+ );
108
+ }
@@ -1,4 +1,4 @@
1
- import { Box, Text, useInput, useStdout } from "ink";
1
+ import { Box, Text, useInput } from "ink";
2
2
  import { memo, useCallback, useEffect, useMemo, useState } from "react";
3
3
  import {
4
4
  TASK_PRIORITIES,
@@ -14,6 +14,8 @@ import {
14
14
  import { ansi, theme } from "../theme.ts";
15
15
  import { useDeleteConfirm } from "../useDeleteConfirm.ts";
16
16
  import { useLatestRef } from "../useLatestRef.ts";
17
+ import { useTerminalSize } from "../useTerminalSize.ts";
18
+ import { wrapDetailLines } from "../wrapDetail.ts";
17
19
  import { DeleteArmedBanner } from "./DeleteArmedBanner.tsx";
18
20
  import { Scrollbar } from "./Scrollbar.tsx";
19
21
 
@@ -121,8 +123,8 @@ export const TaskPanel = memo(function TaskPanel({
121
123
  projectDir,
122
124
  isActive,
123
125
  }: TaskPanelProps) {
124
- const { stdout } = useStdout();
125
- const termRows = stdout?.rows ?? 24;
126
+ const { rows: termRows, cols: termCols } = useTerminalSize();
127
+ const detailWidth = Math.max(1, termCols - SIDEBAR_WIDTH - 5);
126
128
  const [tasks, setTasks] = useState<Task[]>([]);
127
129
  const [selectedIndex, setSelectedIndex] = useState(0);
128
130
  const [detailScroll, setDetailScroll] = useState(0);
@@ -173,8 +175,8 @@ export const TaskPanel = memo(function TaskPanel({
173
175
  }, [selectedTask]);
174
176
 
175
177
  const detailLines = useMemo(
176
- () => renderedDetail.split("\n"),
177
- [renderedDetail],
178
+ () => wrapDetailLines(renderedDetail, detailWidth),
179
+ [renderedDetail, detailWidth],
178
180
  );
179
181
 
180
182
  const visibleRows = Math.max(1, termRows - 6);
@@ -1,4 +1,4 @@
1
- import { Box, Text, useInput, useStdout } from "ink";
1
+ import { Box, Text, useInput } from "ink";
2
2
  import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import {
4
4
  deleteThread,
@@ -17,6 +17,8 @@ import {
17
17
  import { ansi, theme } from "../theme.ts";
18
18
  import { useDeleteConfirm } from "../useDeleteConfirm.ts";
19
19
  import { useLatestRef } from "../useLatestRef.ts";
20
+ import { useTerminalSize } from "../useTerminalSize.ts";
21
+ import { wrapDetailLines } from "../wrapDetail.ts";
20
22
  import { DeleteArmedBanner } from "./DeleteArmedBanner.tsx";
21
23
  import { Scrollbar } from "./Scrollbar.tsx";
22
24
 
@@ -162,8 +164,8 @@ export const ThreadPanel = memo(function ThreadPanel({
162
164
  activeThreadId,
163
165
  isActive,
164
166
  }: ThreadPanelProps) {
165
- const { stdout } = useStdout();
166
- const termRows = stdout?.rows ?? 24;
167
+ const { rows: termRows, cols: termCols } = useTerminalSize();
168
+ const detailWidth = Math.max(1, termCols - SIDEBAR_WIDTH - 5);
167
169
  const [threads, setThreads] = useState<Thread[]>([]);
168
170
  const [selectedIndex, setSelectedIndex] = useState(0);
169
171
  const [detailScroll, setDetailScroll] = useState(0);
@@ -299,8 +301,8 @@ export const ThreadPanel = memo(function ThreadPanel({
299
301
  }, [selectedDetail, activeThreadId]);
300
302
 
301
303
  const detailLines = useMemo(
302
- () => renderedDetail.split("\n"),
303
- [renderedDetail],
304
+ () => wrapDetailLines(renderedDetail, detailWidth),
305
+ [renderedDetail, detailWidth],
304
306
  );
305
307
 
306
308
  const visibleRows = Math.max(1, termRows - 6);
@@ -1,4 +1,5 @@
1
1
  import { Box, Text } from "ink";
2
+ import { memo } from "react";
2
3
  import { theme } from "../theme.ts";
3
4
  import { parseSleepInput, SleepProgress } from "./SleepProgress.tsx";
4
5
 
@@ -48,7 +49,7 @@ interface ToolCallProps {
48
49
  tool: ToolCallData;
49
50
  }
50
51
 
51
- export function ToolCall({ tool }: ToolCallProps) {
52
+ export const ToolCall = memo(function ToolCall({ tool }: ToolCallProps) {
52
53
  const { displayName, displayInput } = resolveToolDisplay(
53
54
  tool.name,
54
55
  tool.input,
@@ -122,4 +123,4 @@ export function ToolCall({ tool }: ToolCallProps) {
122
123
  ))}
123
124
  </Box>
124
125
  );
125
- }
126
+ });
@@ -1,4 +1,4 @@
1
- import { Box, Text, useInput, useStdout } from "ink";
1
+ import { Box, Text, useInput } from "ink";
2
2
  import { memo, useEffect, useMemo, useState } from "react";
3
3
  import {
4
4
  detailPaneBorderProps,
@@ -7,6 +7,8 @@ import {
7
7
  } from "../listDetailKeys.ts";
8
8
  import { ansi, theme } from "../theme.ts";
9
9
  import { useLatestRef } from "../useLatestRef.ts";
10
+ import { useTerminalSize } from "../useTerminalSize.ts";
11
+ import { wrapDetailLines } from "../wrapDetail.ts";
10
12
  import { Scrollbar } from "./Scrollbar.tsx";
11
13
  import { resolveToolDisplay, type ToolCallData } from "./ToolCall.tsx";
12
14
 
@@ -109,8 +111,10 @@ export const ToolPanel = memo(function ToolPanel({
109
111
  toolCalls,
110
112
  isActive,
111
113
  }: ToolPanelProps) {
112
- const { stdout } = useStdout();
113
- const termRows = stdout?.rows ?? 24;
114
+ const { rows: termRows, cols: termCols } = useTerminalSize();
115
+ // Detail-pane content width: total cols minus sidebar, minus 4 chars of
116
+ // border+padding on the right pane, minus 1 col for the scrollbar.
117
+ const detailWidth = Math.max(1, termCols - SIDEBAR_WIDTH - 5);
114
118
  const [selectedIndex, setSelectedIndex] = useState(0);
115
119
  const [detailScroll, setDetailScroll] = useState(0);
116
120
  const [focus, setFocus] = useState<FocusState>("list");
@@ -133,8 +137,8 @@ export const ToolPanel = memo(function ToolPanel({
133
137
  }, [selectedTool]);
134
138
 
135
139
  const detailLines = useMemo(
136
- () => renderedDetail.split("\n"),
137
- [renderedDetail],
140
+ () => wrapDetailLines(renderedDetail, detailWidth),
141
+ [renderedDetail, detailWidth],
138
142
  );
139
143
 
140
144
  // Visible area for sidebar and detail
@@ -0,0 +1,206 @@
1
+ import {
2
+ type Dispatch,
3
+ type MutableRefObject,
4
+ type SetStateAction,
5
+ useCallback,
6
+ } from "react";
7
+ import {
8
+ abortActiveStream,
9
+ type ChatSession,
10
+ clearChatSession,
11
+ } from "../chat/session.ts";
12
+ import type { ContextUsage } from "../chat/usage.ts";
13
+ import { handleSlashCommand } from "../skills/commands.ts";
14
+ import type { ChatMessage } from "./components/MessageList.tsx";
15
+ import type { QueueEntry } from "./hooks/useMessageQueue.ts";
16
+ import { msgId } from "./messages.ts";
17
+
18
+ interface UseChatSubmitParams {
19
+ sessionRef: MutableRefObject<ChatSession | null>;
20
+ queueRef: MutableRefObject<QueueEntry[]>;
21
+ processingRef: MutableRefObject<boolean>;
22
+ clearingRef: MutableRefObject<boolean>;
23
+ syncQueue: () => void;
24
+ processQueue: () => Promise<void>;
25
+ performShutdown: () => Promise<void>;
26
+ clearStreamingState: () => void;
27
+ setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
28
+ setInputValue: Dispatch<SetStateAction<string>>;
29
+ setInputHistory: Dispatch<SetStateAction<string[]>>;
30
+ setMessagesEpoch: Dispatch<SetStateAction<number>>;
31
+ setChatTitle: (t: string | undefined) => void;
32
+ setClearing: Dispatch<SetStateAction<boolean>>;
33
+ setUsage: Dispatch<SetStateAction<ContextUsage | null>>;
34
+ }
35
+
36
+ export function useChatSubmit({
37
+ sessionRef,
38
+ queueRef,
39
+ processingRef,
40
+ clearingRef,
41
+ syncQueue,
42
+ processQueue,
43
+ performShutdown,
44
+ clearStreamingState,
45
+ setMessages,
46
+ setInputValue,
47
+ setInputHistory,
48
+ setMessagesEpoch,
49
+ setChatTitle,
50
+ setClearing,
51
+ setUsage,
52
+ }: UseChatSubmitParams): (text: string) => Promise<void> {
53
+ return useCallback(
54
+ async (text: string) => {
55
+ const trimmed = text.trim();
56
+ if (!trimmed || !sessionRef.current) return;
57
+ // /clear is mid-flight: don't queue against the old thread id.
58
+ if (clearingRef.current) return;
59
+
60
+ setInputValue("");
61
+
62
+ if (trimmed === "/help") {
63
+ const skills = sessionRef.current.skills;
64
+ const lines: string[] = [
65
+ "For the full keyboard reference, switch to the Help tab (`Ctrl+g`) — this message lists chat commands only.",
66
+ "",
67
+ "Slash commands:",
68
+ " /help Show this message",
69
+ " /skills List available skills",
70
+ " /clear End current thread and start a new one",
71
+ " /exit End the chat session",
72
+ ];
73
+ if (skills.size > 0) {
74
+ lines.push("", "Skills:");
75
+ for (const [skillName, skill] of skills) {
76
+ lines.push(
77
+ ` /${skillName.padEnd(14)} ${skill.description || "(no description)"}`,
78
+ );
79
+ }
80
+ } else {
81
+ lines.push("", "Skills:", " (none — add .md files to skills/)");
82
+ }
83
+
84
+ const helpMsg: ChatMessage = {
85
+ id: msgId(),
86
+ role: "system",
87
+ content: lines.join("\n"),
88
+ timestamp: new Date(),
89
+ };
90
+ setMessages((prev) => [...prev, helpMsg]);
91
+ return;
92
+ }
93
+
94
+ if (trimmed.startsWith("/")) {
95
+ const skills = sessionRef.current.skills;
96
+ const handled = handleSlashCommand(trimmed, {
97
+ skills,
98
+ addSystemMessage: (content) => {
99
+ const msg: ChatMessage = {
100
+ id: msgId(),
101
+ role: "system",
102
+ content,
103
+ timestamp: new Date(),
104
+ };
105
+ setMessages((prev) => [...prev, msg]);
106
+ },
107
+ queueUserMessage: (content, opts) => {
108
+ setInputHistory((prev) => [...prev, trimmed]);
109
+ queueRef.current.push({
110
+ display: opts?.display ?? content,
111
+ content,
112
+ });
113
+ syncQueue();
114
+ processQueue();
115
+ },
116
+ exit: () => void performShutdown(),
117
+ clearChat: () => {
118
+ const session = sessionRef.current;
119
+ if (!session) return;
120
+ // Drain any queued messages so they don't leak into the new thread.
121
+ queueRef.current.length = 0;
122
+ syncQueue();
123
+ // Abort any in-flight stream synchronously so its callbacks stop
124
+ // firing before we reset UI state. clearChatSession also calls
125
+ // this, but doing it here lets us start the wait-for-quiesce
126
+ // poll below immediately rather than waiting on the
127
+ // createThread/endThread round trip first.
128
+ abortActiveStream(session);
129
+ // Block new submissions until the new thread id is in place —
130
+ // otherwise the user's first post-/clear message races the
131
+ // async createThread, runs against the old thread id, and is
132
+ // then wiped by setMessages([sys]) below.
133
+ clearingRef.current = true;
134
+ setClearing(true);
135
+ void (async () => {
136
+ // Wait for any in-flight processQueue iteration to finish so
137
+ // its trailing `finalizeSegment` can't race our state reset
138
+ // and re-add the previous thread's assistant message after
139
+ // the UI has been cleared. (Issue #190.)
140
+ while (processingRef.current) {
141
+ await new Promise((r) => setTimeout(r, 10));
142
+ }
143
+ try {
144
+ const { previousThreadId, newThreadId } =
145
+ await clearChatSession(session);
146
+ // Ink's <Static> writes messages to terminal scrollback and
147
+ // can't un-write them, so setMessages alone leaves the old
148
+ // lines visible. Clear the terminal (including scrollback)
149
+ // and bump the epoch key on <Static> to force a fresh mount.
150
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
151
+ setMessages([
152
+ {
153
+ id: msgId(),
154
+ role: "system",
155
+ content: `Started a new chat thread (${newThreadId}). Previous thread saved — resume with: botholomew chat --thread-id ${previousThreadId}`,
156
+ timestamp: new Date(),
157
+ },
158
+ ]);
159
+ setMessagesEpoch((n) => n + 1);
160
+ setChatTitle(undefined);
161
+ clearStreamingState();
162
+ setUsage(null);
163
+ } catch (err) {
164
+ setMessages((prev) => [
165
+ ...prev,
166
+ {
167
+ id: msgId(),
168
+ role: "system",
169
+ content: `Failed to clear chat: ${err}`,
170
+ timestamp: new Date(),
171
+ },
172
+ ]);
173
+ } finally {
174
+ clearingRef.current = false;
175
+ setClearing(false);
176
+ }
177
+ })();
178
+ },
179
+ });
180
+ if (handled) return;
181
+ }
182
+
183
+ setInputHistory((prev) => [...prev, trimmed]);
184
+ queueRef.current.push({ display: trimmed, content: trimmed });
185
+ syncQueue();
186
+ processQueue();
187
+ },
188
+ [
189
+ sessionRef,
190
+ queueRef,
191
+ processingRef,
192
+ clearingRef,
193
+ syncQueue,
194
+ processQueue,
195
+ performShutdown,
196
+ clearStreamingState,
197
+ setMessages,
198
+ setInputValue,
199
+ setInputHistory,
200
+ setMessagesEpoch,
201
+ setChatTitle,
202
+ setClearing,
203
+ setUsage,
204
+ ],
205
+ );
206
+ }