botholomew 0.15.6 → 0.16.2

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/src/tui/App.tsx CHANGED
@@ -14,8 +14,7 @@ import {
14
14
  handleSlashCommand,
15
15
  type SlashCommand,
16
16
  } from "../skills/commands.ts";
17
- import { getThread, type Interaction } from "../threads/store.ts";
18
- import { MAX_INLINE_CHARS, PAGE_SIZE_CHARS } from "../worker/large-results.ts";
17
+ import { getThread } from "../threads/store.ts";
19
18
  import { ContextPanel } from "./components/ContextPanel.tsx";
20
19
  import { HelpPanel } from "./components/HelpPanel.tsx";
21
20
  import { InputBar } from "./components/InputBar.tsx";
@@ -35,6 +34,7 @@ import type { ToolCallData } from "./components/ToolCall.tsx";
35
34
  import { ToolPanel } from "./components/ToolPanel.tsx";
36
35
  import { WorkerPanel } from "./components/WorkerPanel.tsx";
37
36
  import { IdleProvider, useIdle } from "./idle.tsx";
37
+ import { restoreMessagesFromInteractions } from "./restoreMessages.ts";
38
38
  import { buildSlashCommands, getSlashMatches } from "./slashCompletion.ts";
39
39
  import { ansi } from "./theme.ts";
40
40
 
@@ -50,6 +50,14 @@ function msgId(): string {
50
50
  return `msg-${++nextMsgId}`;
51
51
  }
52
52
 
53
+ // Conservative line reservation for the bottom chrome — StatusBar (1) +
54
+ // bordered InputBar (3) + multiline hint (1) + TabBar (1) + slack for the
55
+ // SlashCommandPopup or QueuePanel (~4). The chat-tab body's `maxHeight` and
56
+ // the panel boxes' `height` both subtract this from `rows` so the dynamic
57
+ // frame's total output stays strictly below the viewport — see the comment
58
+ // on the `rows` state in `AppInner` for why that matters.
59
+ const FOOTER_RESERVE = 10;
60
+
53
61
  // Tab routing: Ctrl+<letter> jumps to a tab. Chosen for memorability — first
54
62
  // available letter that doesn't collide with other Ctrl bindings (Ctrl+C exit,
55
63
  // Ctrl+J/K/X/E queue ops on Chat).
@@ -73,78 +81,6 @@ const TAB_BY_CTRL_KEY: Record<string, TabId> = {
73
81
  _: 8, // help (terminals that send Ctrl+/ as 0x1F)
74
82
  };
75
83
 
76
- function detectToolError(output: string | undefined): boolean {
77
- if (!output) return false;
78
- try {
79
- const parsed = JSON.parse(output);
80
- if (typeof parsed === "object" && parsed?.is_error === true) return true;
81
- } catch {
82
- /* not JSON */
83
- }
84
- return false;
85
- }
86
-
87
- function restoreMessagesFromInteractions(
88
- interactions: Interaction[],
89
- ): ChatMessage[] {
90
- const result: ChatMessage[] = [];
91
- let pendingTools: ToolCallData[] = [];
92
-
93
- let restoredIdx = 0;
94
- for (const ix of interactions) {
95
- if (ix.kind === "tool_use") {
96
- pendingTools.push({
97
- id: `restored-${restoredIdx++}`,
98
- name: ix.tool_name ?? "unknown",
99
- input: ix.tool_input ?? "{}",
100
- running: false,
101
- timestamp: ix.created_at,
102
- });
103
- } else if (ix.kind === "tool_result") {
104
- const tc = pendingTools.find((t) => t.name === ix.tool_name && !t.output);
105
- if (tc) {
106
- tc.output = ix.content;
107
- tc.isError = detectToolError(ix.content);
108
- if (ix.content.length > MAX_INLINE_CHARS) {
109
- tc.largeResult = {
110
- id: "(restored)",
111
- chars: ix.content.length,
112
- pages: Math.ceil(ix.content.length / PAGE_SIZE_CHARS),
113
- };
114
- }
115
- }
116
- } else if (ix.kind === "message" && ix.role === "user") {
117
- result.push({
118
- id: msgId(),
119
- role: "user",
120
- content: ix.content,
121
- timestamp: ix.created_at,
122
- });
123
- } else if (ix.kind === "message" && ix.role === "assistant") {
124
- result.push({
125
- id: msgId(),
126
- role: "assistant",
127
- content: ix.content,
128
- timestamp: ix.created_at,
129
- toolCalls: pendingTools.length > 0 ? [...pendingTools] : undefined,
130
- });
131
- pendingTools = [];
132
- }
133
- }
134
-
135
- if (pendingTools.length > 0) {
136
- result.push({
137
- id: msgId(),
138
- role: "assistant",
139
- content: "",
140
- timestamp: new Date(),
141
- toolCalls: [...pendingTools],
142
- });
143
- }
144
-
145
- return result;
146
- }
147
-
148
84
  export function App({
149
85
  projectDir,
150
86
  threadId: resumeThreadId,
@@ -176,14 +112,13 @@ function AppInner({
176
112
  const { exit } = useApp();
177
113
  const { stdout } = useStdout();
178
114
  const { markActivity } = useIdle();
179
- // Pin the root box to a known viewport height so the rendered frame size
180
- // never crosses the viewport boundary. Ink 7's renderer wipes scrollback
181
- // (`shouldClearTerminalForFrame` → `ansiEscapes.clearTerminal`) whenever
182
- // the dynamic frame transitions in/out of fullscreen, so a fluctuating
183
- // `outputHeight` (streaming text + tool boxes appearing/disappearing) used
184
- // to delete the chat history on every turn. Ink doesn't pin a height on
185
- // its internal root, so a `height="100%"` on our root collapses to `auto`
186
- // — we have to pass the explicit row count and re-read it on resize.
115
+ // Track the terminal's row count so we can cap the dynamic frame strictly
116
+ // below fullscreen. Ink 7 wipes scrollback (`shouldClearTerminalForFrame`
117
+ // → `ansiEscapes.clearTerminal`) whenever the dynamic frame is overflowing
118
+ // or transitions out of fullscreen so as long as the rendered output
119
+ // height stays < `rows` on every render, scrollback is preserved. The
120
+ // chat-tab body and the seven panel boxes use this value to set explicit
121
+ // height/maxHeight constraints.
187
122
  const [rows, setRows] = useState(stdout?.rows ?? 24);
188
123
  useEffect(() => {
189
124
  if (!stdout) return;
@@ -207,6 +142,7 @@ function AppInner({
207
142
  const [isLoading, setIsLoading] = useState(false);
208
143
  const [streamingText, setStreamingText] = useState("");
209
144
  const [activeToolCalls, setActiveToolCalls] = useState<ToolCallData[]>([]);
145
+ const [streamStartedAt, setStreamStartedAt] = useState<Date | null>(null);
210
146
  const [preparingTool, setPreparingTool] = useState<{
211
147
  id: string;
212
148
  name: string;
@@ -245,7 +181,10 @@ function AppInner({
245
181
  }
246
182
  sessionRef.current = session;
247
183
 
248
- if (session.messages.length > 0) {
184
+ if (resumeThreadId) {
185
+ // Always hydrate on resume so the Tools tab and chat history
186
+ // pick up prior tool_use/tool_result rows from the CSV — even if
187
+ // the thread has no plain message-kind interactions yet.
249
188
  const threadData = await getThread(
250
189
  session.projectDir,
251
190
  session.threadId,
@@ -474,6 +413,7 @@ function AppInner({
474
413
  setStreamingText("");
475
414
  setActiveToolCalls([]);
476
415
  setPreparingTool(null);
416
+ setStreamStartedAt(new Date());
477
417
 
478
418
  const userMsg: ChatMessage = {
479
419
  id: msgId(),
@@ -501,6 +441,7 @@ function AppInner({
501
441
  pendingToolCalls = [];
502
442
  setStreamingText("");
503
443
  setActiveToolCalls([]);
444
+ setStreamStartedAt(new Date());
504
445
  }
505
446
  };
506
447
 
@@ -532,30 +473,39 @@ function AppInner({
532
473
  running: true,
533
474
  timestamp: new Date(),
534
475
  };
535
- pendingToolCalls.push(tc);
536
- setActiveToolCalls([...pendingToolCalls]);
476
+ pendingToolCalls = [...pendingToolCalls, tc];
477
+ setActiveToolCalls(pendingToolCalls);
537
478
  setPreparingTool(null);
538
479
  },
539
480
  onToolEnd: (id, _name, output, isError, meta) => {
540
481
  markActivityRef.current();
541
- const tc = pendingToolCalls.find((t) => t.id === id);
542
- if (tc) {
543
- tc.running = false;
544
- tc.output = output;
545
- tc.isError = isError;
546
- if (meta?.largeResult) {
547
- tc.largeResult = meta.largeResult;
548
- }
549
- }
550
- setActiveToolCalls([...pendingToolCalls]);
482
+ // Replace the matched entry with a new object so its identity
483
+ // changes (memoized ToolCall children rely on this); other entries
484
+ // keep their reference and skip re-render.
485
+ pendingToolCalls = pendingToolCalls.map((t) =>
486
+ t.id === id
487
+ ? {
488
+ ...t,
489
+ running: false,
490
+ output,
491
+ isError,
492
+ ...(meta?.largeResult
493
+ ? { largeResult: meta.largeResult }
494
+ : {}),
495
+ }
496
+ : t,
497
+ );
498
+ setActiveToolCalls(pendingToolCalls);
551
499
  },
552
500
  onToolNotify: (id, message) => {
553
501
  markActivityRef.current();
554
- const tc = pendingToolCalls.find((t) => t.id === id);
555
- if (tc) {
556
- tc.notes = [...(tc.notes ?? []), message];
557
- setActiveToolCalls([...pendingToolCalls]);
558
- }
502
+ let touched = false;
503
+ pendingToolCalls = pendingToolCalls.map((t) => {
504
+ if (t.id !== id) return t;
505
+ touched = true;
506
+ return { ...t, notes: [...(t.notes ?? []), message] };
507
+ });
508
+ if (touched) setActiveToolCalls(pendingToolCalls);
559
509
  },
560
510
  onUsage: (info) => {
561
511
  setUsage(info);
@@ -602,6 +552,7 @@ function AppInner({
602
552
  setStreamingText("");
603
553
  setActiveToolCalls([]);
604
554
  setPreparingTool(null);
555
+ setStreamStartedAt(null);
605
556
  }
606
557
  }
607
558
 
@@ -757,6 +708,7 @@ function AppInner({
757
708
  setStreamingText("");
758
709
  setActiveToolCalls([]);
759
710
  setPreparingTool(null);
711
+ setStreamStartedAt(null);
760
712
  setUsage(null);
761
713
  } catch (err) {
762
714
  setMessages((prev) => [
@@ -848,8 +800,23 @@ function AppInner({
848
800
  const _dbPath = sessionRef.current.dbPath;
849
801
  const threadId = sessionRef.current.threadId;
850
802
 
803
+ const panelHeight = Math.max(1, rows - FOOTER_RESERVE);
804
+ const onChatTab = activeTab === 1;
805
+
851
806
  return (
852
- <Box flexDirection="column" height={rows} overflow="hidden">
807
+ // The root box is auto-sized on the chat tab so the dynamic frame stays
808
+ // small and the static-rendered chat history (in scrollback above the
809
+ // frame) flows directly into the streaming reply with no blank pad.
810
+ //
811
+ // On every other tab we pin the root to `height={rows}` so the dynamic
812
+ // frame fills the entire viewport — without that, the panel + footer
813
+ // are shorter than the terminal and the bottom of the chat scrollback
814
+ // bleeds through above the active panel. Switching chat→panel goes
815
+ // small→rows (no wipe, since `nextOutputHeight === viewportRows` is
816
+ // not "overflowing"). Switching panel→chat goes rows→small, which
817
+ // does trip Ink's `isLeavingFullscreen` clear, but Ink immediately
818
+ // re-emits `fullStaticOutput` so chat history is preserved.
819
+ <Box flexDirection="column" {...(onChatTab ? {} : { height: rows })}>
853
820
  {/* Completed messages — rendered once to terminal scrollback.
854
821
  Must live outside the display="none" tab wrappers so the <Static>
855
822
  node always has proper terminal width in its Yoga layout.
@@ -862,16 +829,23 @@ function AppInner({
862
829
  {/* Tab content area — all panels stay mounted to avoid expensive
863
830
  remount cycles. display="none" hides inactive panels from
864
831
  layout without destroying them.
865
- The chat tab's flexGrow box is overflow-clipped so streaming
866
- content can't push the rendered frame past the viewport — see
867
- the comment on the `rows` state for why that matters.
868
- `justifyContent="flex-end"` keeps active streaming content + the
869
- tool-call card pinned to the bottom of the chat area (just
870
- above the input bar) instead of leaving a tall gap below them. */}
832
+
833
+ Chat tab: `maxHeight={panelHeight}` (not `height`) so the box
834
+ shrinks to its content when streaming is short or absent. When
835
+ streaming overflows, the box stops at `panelHeight`;
836
+ `justifyContent="flex-end"` + `overflow="hidden"` clip the *top*
837
+ so the most-recent tokens stay visible above the input bar.
838
+ The frame stays strictly below `rows`, so Ink never wipes
839
+ scrollback during a turn.
840
+
841
+ Other tabs: `flexGrow={1}` fills the root (which is pinned to
842
+ `rows` on those tabs) minus the footer's actual height, so the
843
+ panel always reaches the top of the viewport — no scrollback
844
+ leak above the panel regardless of footer height. */}
871
845
  <Box
872
- display={activeTab === 1 ? "flex" : "none"}
846
+ display={onChatTab ? "flex" : "none"}
873
847
  flexDirection="column"
874
- flexGrow={1}
848
+ maxHeight={panelHeight}
875
849
  overflow="hidden"
876
850
  justifyContent="flex-end"
877
851
  >
@@ -880,12 +854,14 @@ function AppInner({
880
854
  isLoading={isLoading}
881
855
  activeToolCalls={activeToolCalls}
882
856
  preparingTool={preparingTool}
857
+ streamStartedAt={streamStartedAt}
883
858
  />
884
859
  </Box>
885
860
  <Box
886
861
  display={activeTab === 2 ? "flex" : "none"}
887
862
  flexDirection="column"
888
863
  flexGrow={1}
864
+ overflow="hidden"
889
865
  >
890
866
  <ToolPanel toolCalls={allToolCalls} isActive={activeTab === 2} />
891
867
  </Box>
@@ -893,6 +869,7 @@ function AppInner({
893
869
  display={activeTab === 3 ? "flex" : "none"}
894
870
  flexDirection="column"
895
871
  flexGrow={1}
872
+ overflow="hidden"
896
873
  >
897
874
  <ContextPanel projectDir={projectDir} isActive={activeTab === 3} />
898
875
  </Box>
@@ -900,6 +877,7 @@ function AppInner({
900
877
  display={activeTab === 4 ? "flex" : "none"}
901
878
  flexDirection="column"
902
879
  flexGrow={1}
880
+ overflow="hidden"
903
881
  >
904
882
  <TaskPanel projectDir={projectDir} isActive={activeTab === 4} />
905
883
  </Box>
@@ -907,6 +885,7 @@ function AppInner({
907
885
  display={activeTab === 5 ? "flex" : "none"}
908
886
  flexDirection="column"
909
887
  flexGrow={1}
888
+ overflow="hidden"
910
889
  >
911
890
  <ThreadPanel
912
891
  projectDir={projectDir}
@@ -918,6 +897,7 @@ function AppInner({
918
897
  display={activeTab === 6 ? "flex" : "none"}
919
898
  flexDirection="column"
920
899
  flexGrow={1}
900
+ overflow="hidden"
921
901
  >
922
902
  <SchedulePanel projectDir={projectDir} isActive={activeTab === 6} />
923
903
  </Box>
@@ -925,6 +905,7 @@ function AppInner({
925
905
  display={activeTab === 7 ? "flex" : "none"}
926
906
  flexDirection="column"
927
907
  flexGrow={1}
908
+ overflow="hidden"
928
909
  >
929
910
  <WorkerPanel projectDir={projectDir} isActive={activeTab === 7} />
930
911
  </Box>
@@ -932,6 +913,7 @@ function AppInner({
932
913
  display={activeTab === 8 ? "flex" : "none"}
933
914
  flexDirection="column"
934
915
  flexGrow={1}
916
+ overflow="hidden"
935
917
  >
936
918
  <HelpPanel
937
919
  projectDir={projectDir}
@@ -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);
@@ -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