opencode-top 3.0.0 → 3.1.1

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": "opencode-top",
3
- "version": "3.0.0",
3
+ "version": "3.1.1",
4
4
  "description": "Monitor OpenCode AI coding sessions - Token usage, costs, and agent analytics",
5
5
  "type": "module",
6
6
  "bin": {
package/src/ui/App.tsx CHANGED
@@ -108,7 +108,7 @@ export function App({ refreshInterval = 2000 }: AppProps) {
108
108
  const contentHeight = terminalHeight - 3;
109
109
 
110
110
  return (
111
- <Box flexDirection="column" width={terminalWidth}>
111
+ <Box flexDirection="column" width={terminalWidth} height={terminalHeight}>
112
112
  <TabBar activeScreen={screen} lastRefresh={lastRefresh} />
113
113
  <Box width={terminalWidth} height={contentHeight}>
114
114
  {screen === "sessions" && (
@@ -30,14 +30,17 @@ function AgentTreeInner({ workflows, selectedId, flatNodes, maxHeight = 20 }: Ag
30
30
  const visibleCount = Math.max(1, maxHeight - headerHeight);
31
31
  const selectedIndex = flatNodes.findIndex((n) => n.id === selectedId);
32
32
 
33
+ // Keep selected item visible with some context above
33
34
  let startIndex = 0;
34
- if (selectedIndex >= visibleCount - 1) {
35
- startIndex = Math.max(0, selectedIndex - visibleCount + 2);
35
+ if (selectedIndex >= 0) {
36
+ // Try to show 2 items above the selected one
37
+ const ideal = selectedIndex - 2;
38
+ startIndex = Math.max(0, Math.min(ideal, flatNodes.length - visibleCount));
36
39
  }
37
40
  const visibleNodes = flatNodes.slice(startIndex, startIndex + visibleCount);
38
41
 
39
42
  return (
40
- <Box flexDirection="column" paddingX={1}>
43
+ <Box flexDirection="column" paddingX={1} height={maxHeight} overflow="hidden">
41
44
  <Box marginBottom={1} flexDirection="row">
42
45
  <Text color={colors.accent} bold>Sessions</Text>
43
46
  <Box flexGrow={1} />
@@ -14,6 +14,7 @@ import { AgentChainGraph } from "./AgentChainGraph";
14
14
 
15
15
  interface DetailsPanelProps {
16
16
  workflow: Workflow | null;
17
+ height?: number;
17
18
  }
18
19
 
19
20
  function StatRow({
@@ -73,7 +74,7 @@ function formatTokens(n: number): string {
73
74
  return n.toString();
74
75
  }
75
76
 
76
- function DetailsPanelInner({ workflow }: DetailsPanelProps) {
77
+ function DetailsPanelInner({ workflow, height }: DetailsPanelProps) {
77
78
  const data = useMemo(() => {
78
79
  if (!workflow) return null;
79
80
 
@@ -121,14 +122,14 @@ function DetailsPanelInner({ workflow }: DetailsPanelProps) {
121
122
 
122
123
  if (!data) {
123
124
  return (
124
- <Box flexDirection="column" paddingX={1}>
125
+ <Box flexDirection="column" paddingX={1} height={height}>
125
126
  <Text color={colors.textDim}>Select a session</Text>
126
127
  </Box>
127
128
  );
128
129
  }
129
130
 
130
131
  return (
131
- <Box flexDirection="column" paddingX={1}>
132
+ <Box flexDirection="column" paddingX={1} height={height} overflow="hidden">
132
133
  <Box marginBottom={1}>
133
134
  <Text color={colors.accent} bold>
134
135
  Details
@@ -1,59 +1,111 @@
1
- import React, { useMemo, memo } from "react";
2
- import { Box, Text } from "ink";
1
+ import React, { useMemo, memo, useEffect, useState } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
3
  import { colors } from "../theme";
4
4
  import type { Session, Interaction, MessagePart } from "../../core/types";
5
5
 
6
6
  interface MessagesPanelProps {
7
7
  session: Session | null;
8
- scrollOffset: number;
9
- onScrollOffsetChange: (offset: number) => void;
10
8
  maxHeight: number;
9
+ isActive: boolean;
11
10
  }
12
11
 
13
12
  type MsgLine =
14
- | { kind: "interaction-header"; interaction: Interaction }
15
- | { kind: "tool-call"; part: MessagePart & { type: "tool" } }
16
- | { kind: "text"; text: string }
17
- | { kind: "reasoning"; text: string }
18
- | { kind: "spacer" };
13
+ | { id: string; kind: "header"; modelId: string; agent: string | null; duration: string; time: string }
14
+ | { id: string; kind: "tool"; callId: string; icon: string; iconColor: string; name: string; title: string; right: string; expanded: boolean }
15
+ | { id: string; kind: "tool-detail"; label: string; value: string; isSection: boolean }
16
+ | { id: string; kind: "text"; text: string }
17
+ | { id: string; kind: "reasoning"; text: string };
19
18
 
20
- function buildLines(session: Session): MsgLine[] {
19
+ function buildLines(session: Session, contentWidth: number, expandedIds: Set<string>): MsgLine[] {
21
20
  const lines: MsgLine[] = [];
22
21
  for (const interaction of session.interactions) {
23
22
  if (interaction.role !== "assistant") continue;
24
- lines.push({ kind: "interaction-header", interaction });
23
+
24
+ const dur =
25
+ interaction.time.completed && interaction.time.created
26
+ ? formatDuration(interaction.time.completed - interaction.time.created)
27
+ : "";
28
+
29
+ lines.push({
30
+ id: `h-${interaction.id}`,
31
+ kind: "header",
32
+ modelId: interaction.modelId,
33
+ agent: interaction.agent ?? null,
34
+ duration: dur,
35
+ time: formatTime(interaction.time.created),
36
+ });
37
+
25
38
  for (const part of interaction.parts) {
26
39
  if (part.type === "tool") {
27
- lines.push({ kind: "tool-call", part: part as MessagePart & { type: "tool" } });
40
+ const p = part as MessagePart & { type: "tool" };
41
+ const icon = p.status === "completed" ? "✓" : p.status === "error" ? "✗" : "◌";
42
+ const iconColor =
43
+ p.status === "completed" ? colors.success : p.status === "error" ? colors.error : colors.warning;
44
+ const dur2 = p.timeEnd > 0 && p.timeStart > 0 ? formatDuration(p.timeEnd - p.timeStart) : "";
45
+ const exitStr = p.exitCode !== null && p.exitCode !== 0 ? `exit:${p.exitCode} ` : "";
46
+ const expanded = expandedIds.has(p.callId);
47
+
48
+ lines.push({
49
+ id: `t-${p.callId}`,
50
+ kind: "tool",
51
+ callId: p.callId,
52
+ icon,
53
+ iconColor,
54
+ name: truncate(p.toolName, 18),
55
+ title: p.title ? truncate(p.title, 28) : "",
56
+ right: exitStr + dur2,
57
+ expanded,
58
+ });
59
+
60
+ if (expanded) {
61
+ const inputKeys = Object.keys(p.input);
62
+ if (inputKeys.length > 0) {
63
+ lines.push({ id: `td-${p.callId}-in`, kind: "tool-detail", label: "input", value: "", isSection: true });
64
+ for (const key of inputKeys) {
65
+ const val = formatParamValue(p.input[key], contentWidth - key.length - 6);
66
+ lines.push({ id: `td-${p.callId}-in-${key}`, kind: "tool-detail", label: key, value: val, isSection: false });
67
+ }
68
+ }
69
+ if (p.output?.trim()) {
70
+ lines.push({ id: `td-${p.callId}-out`, kind: "tool-detail", label: "output", value: "", isSection: true });
71
+ const outLines = p.output.trim().split("\n").slice(0, 40);
72
+ let outIdx = 0;
73
+ for (const ol of outLines) {
74
+ for (const wrapped of wrapText(ol === "" ? " " : ol, contentWidth - 5)) {
75
+ lines.push({ id: `td-${p.callId}-out-${outIdx++}`, kind: "tool-detail", label: "", value: wrapped, isSection: false });
76
+ }
77
+ }
78
+ }
79
+ }
28
80
  } else if (part.type === "text" && part.text.trim()) {
29
- // Split long text into display lines of ~wrap length
30
- const wrapped = wrapText(part.text.trim(), 200);
31
- for (const line of wrapped) {
32
- lines.push({ kind: "text", text: line });
81
+ let txtIdx = 0;
82
+ for (const row of wrapText(part.text.trim(), contentWidth - 3)) {
83
+ lines.push({ id: `tx-${interaction.id}-${txtIdx++}`, kind: "text", text: row });
33
84
  }
34
85
  } else if (part.type === "reasoning" && part.text.trim()) {
35
- const wrapped = wrapText(part.text.trim(), 200);
36
- for (const line of wrapped) {
37
- lines.push({ kind: "reasoning", text: line });
86
+ let rIdx = 0;
87
+ for (const row of wrapText(part.text.trim(), contentWidth - 5)) {
88
+ lines.push({ id: `r-${interaction.id}-${rIdx++}`, kind: "reasoning", text: row });
38
89
  }
39
90
  }
40
91
  }
41
- lines.push({ kind: "spacer" });
42
92
  }
43
93
  return lines;
44
94
  }
45
95
 
96
+ function formatParamValue(val: unknown, maxLen: number): string {
97
+ if (val === null || val === undefined) return "null";
98
+ if (typeof val === "string") return truncate(val.split("\n")[0], Math.max(20, maxLen));
99
+ if (typeof val === "number" || typeof val === "boolean") return String(val);
100
+ try { return truncate(JSON.stringify(val), Math.max(20, maxLen)); }
101
+ catch { return String(val); }
102
+ }
103
+
46
104
  function wrapText(text: string, maxLen: number): string[] {
47
- const rawLines = text.split("\n");
48
105
  const out: string[] = [];
49
- for (const raw of rawLines) {
50
- if (raw.length <= maxLen) {
51
- out.push(raw);
52
- } else {
53
- for (let i = 0; i < raw.length; i += maxLen) {
54
- out.push(raw.slice(i, i + maxLen));
55
- }
56
- }
106
+ for (const raw of text.split("\n")) {
107
+ if (raw.length <= maxLen) { out.push(raw); }
108
+ else { for (let i = 0; i < raw.length; i += maxLen) out.push(raw.slice(i, i + maxLen)); }
57
109
  }
58
110
  return out;
59
111
  }
@@ -69,87 +121,104 @@ function formatDuration(ms: number): string {
69
121
  return `${(ms / 1000).toFixed(1)}s`;
70
122
  }
71
123
 
72
- function renderLine(line: MsgLine, idx: number): React.ReactElement {
73
- switch (line.kind) {
74
- case "interaction-header": {
75
- const { interaction: i } = line;
76
- const dur =
77
- i.time.completed && i.time.created
78
- ? i.time.completed - i.time.created
79
- : 0;
80
- return (
81
- <Box key={idx} flexDirection="row" marginTop={1}>
82
- <Text color={colors.purple} bold>◆ </Text>
83
- <Text color={colors.info}>{truncate(i.modelId, 28)}</Text>
84
- {i.agent && <Text color={colors.cyan}> [{i.agent}]</Text>}
85
- <Box flexGrow={1} />
86
- {dur > 0 && <Text color={colors.textDim}>{formatDuration(dur)} </Text>}
87
- <Text color={colors.textDim}>{formatTime(i.time.created)}</Text>
88
- </Box>
89
- );
90
- }
124
+ function truncate(s: string, max: number): string {
125
+ if (!s || s.length <= max) return s;
126
+ return s.slice(0, max - 1) + "…";
127
+ }
91
128
 
92
- case "tool-call": {
93
- const p = line.part;
94
- const statusIcon = p.status === "completed" ? "✓" : p.status === "error" ? "✗" : "◌";
95
- const statusColor =
96
- p.status === "completed" ? colors.success : p.status === "error" ? colors.error : colors.warning;
97
- const dur = p.timeEnd > 0 && p.timeStart > 0 ? p.timeEnd - p.timeStart : 0;
98
- const exitStr = p.exitCode !== null && p.exitCode !== 0 ? ` exit:${p.exitCode}` : "";
99
- return (
100
- <Box key={idx} flexDirection="column" paddingLeft={2}>
101
- <Box flexDirection="row">
102
- <Text color={statusColor}>{statusIcon} </Text>
103
- <Text color={colors.text} bold>{truncate(p.toolName, 20)}</Text>
104
- {p.title && <Text color={colors.textDim}> {truncate(p.title, 30)}</Text>}
105
- <Box flexGrow={1} />
106
- {exitStr && <Text color={colors.error}>{exitStr} </Text>}
107
- {dur > 0 && <Text color={colors.textDim}>{formatDuration(dur)}</Text>}
108
- </Box>
109
- {/* Show output preview for errors or short outputs */}
110
- {(p.status === "error" || (p.output && p.output.length < 200 && p.output.trim())) && (
111
- <Box paddingLeft={2}>
112
- <Text color={p.status === "error" ? colors.error : colors.textDim}>
113
- {truncate(p.output.trim(), 120)}
114
- </Text>
115
- </Box>
116
- )}
117
- </Box>
118
- );
119
- }
129
+ function MessagesPanelInner({ session, maxHeight, isActive }: MessagesPanelProps) {
130
+ const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
131
+ const [scrollOffset, setScrollOffset] = useState(0);
132
+ const [cursor, setCursor] = useState(0);
120
133
 
121
- case "text":
122
- return (
123
- <Box key={idx} paddingLeft={2}>
124
- <Text color={colors.text}>{line.text}</Text>
125
- </Box>
126
- );
127
-
128
- case "reasoning":
129
- return (
130
- <Box key={idx} paddingLeft={2}>
131
- <Text color={colors.accentDim}>⚡ {line.text}</Text>
132
- </Box>
133
- );
134
-
135
- case "spacer":
136
- return <Box key={idx} />;
137
- }
138
- }
134
+ // Reset all state when session changes
135
+ useEffect(() => {
136
+ setExpandedIds(new Set());
137
+ setScrollOffset(0);
138
+ setCursor(0);
139
+ }, [session?.id]);
140
+
141
+ const contentWidth = 80;
142
+ const viewHeight = maxHeight - 1; // minus the counter row
143
+
144
+ const allLines = useMemo(
145
+ () => (session ? buildLines(session, contentWidth, expandedIds) : []),
146
+ [session, expandedIds]
147
+ );
148
+
149
+ const maxOffset = Math.max(0, allLines.length - viewHeight);
150
+
151
+ // Keep cursor in view: scroll to follow cursor
152
+ const clampedCursor = Math.min(cursor, Math.max(0, allLines.length - 1));
153
+ const clampedOffset = Math.max(
154
+ 0,
155
+ Math.min(
156
+ scrollOffset,
157
+ Math.min(maxOffset, Math.max(scrollOffset, clampedCursor - viewHeight + 1))
158
+ )
159
+ );
139
160
 
140
- function MessagesPanelInner({
141
- session,
142
- scrollOffset,
143
- maxHeight,
144
- }: MessagesPanelProps) {
145
- const allLines = useMemo(() => {
146
- if (!session) return [];
147
- return buildLines(session);
148
- }, [session]);
161
+ useInput((input, key) => {
162
+ if (key.downArrow || input === "j") {
163
+ setCursor((c) => Math.min(allLines.length - 1, c + 1));
164
+ setScrollOffset((o) => {
165
+ const newCursor = Math.min(allLines.length - 1, clampedCursor + 1);
166
+ // Scroll down if cursor goes below view
167
+ if (newCursor >= o + viewHeight) return Math.min(maxOffset, o + 1);
168
+ return o;
169
+ });
170
+ return;
171
+ }
172
+ if (key.upArrow || input === "k") {
173
+ setCursor((c) => Math.max(0, c - 1));
174
+ setScrollOffset((o) => {
175
+ const newCursor = Math.max(0, clampedCursor - 1);
176
+ // Scroll up if cursor goes above view
177
+ if (newCursor < o) return Math.max(0, o - 1);
178
+ return o;
179
+ });
180
+ return;
181
+ }
182
+ if (key.pageDown || input === "d") {
183
+ const half = Math.floor(viewHeight / 2);
184
+ setScrollOffset((o) => Math.min(maxOffset, o + half));
185
+ setCursor((c) => Math.min(allLines.length - 1, c + half));
186
+ return;
187
+ }
188
+ if (key.pageUp || input === "u") {
189
+ const half = Math.floor(viewHeight / 2);
190
+ setScrollOffset((o) => Math.max(0, o - half));
191
+ setCursor((c) => Math.max(0, c - half));
192
+ return;
193
+ }
194
+ if (input === "g") {
195
+ setScrollOffset(0);
196
+ setCursor(0);
197
+ return;
198
+ }
199
+ if (input === "G") {
200
+ setScrollOffset(maxOffset);
201
+ setCursor(allLines.length - 1);
202
+ return;
203
+ }
204
+ if (key.return) {
205
+ const line = allLines[clampedCursor];
206
+ if (line?.kind === "tool") {
207
+ const id = line.callId;
208
+ setExpandedIds((prev) => {
209
+ const next = new Set(prev);
210
+ if (next.has(id)) next.delete(id);
211
+ else next.add(id);
212
+ return next;
213
+ });
214
+ }
215
+ return;
216
+ }
217
+ }, { isActive });
149
218
 
150
219
  if (!session) {
151
220
  return (
152
- <Box paddingX={1}>
221
+ <Box height={maxHeight} paddingX={1}>
153
222
  <Text color={colors.textDim}>Select a session</Text>
154
223
  </Box>
155
224
  );
@@ -157,32 +226,98 @@ function MessagesPanelInner({
157
226
 
158
227
  if (allLines.length === 0) {
159
228
  return (
160
- <Box paddingX={1}>
229
+ <Box height={maxHeight} paddingX={1}>
161
230
  <Text color={colors.textDim}>No messages</Text>
162
231
  </Box>
163
232
  );
164
233
  }
165
234
 
166
- const clampedOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, allLines.length - maxHeight)));
167
- const visibleLines = allLines.slice(clampedOffset, clampedOffset + maxHeight);
235
+ const visibleLines = allLines.slice(clampedOffset, clampedOffset + viewHeight);
168
236
 
169
237
  return (
170
- <Box flexDirection="column" paddingX={1} overflow="hidden">
171
- <Box flexDirection="row">
238
+ <Box flexDirection="column" height={maxHeight} paddingX={1}>
239
+ {/* Counter row */}
240
+ <Box flexDirection="row" height={1}>
172
241
  <Text color={colors.textDim}>
173
- {clampedOffset + 1}–{Math.min(clampedOffset + maxHeight, allLines.length)}/{allLines.length}
242
+ {clampedOffset + 1}–{Math.min(clampedOffset + viewHeight, allLines.length)}/{allLines.length}
174
243
  </Text>
175
244
  <Box flexGrow={1} />
176
- <Text color={colors.textDim}>{session.interactions.length} turns</Text>
245
+ <Text color={colors.textDim}>
246
+ {session.interactions.filter((i) => i.role === "assistant").length} turns
247
+ </Text>
248
+ {clampedOffset > 0 && <Text color={colors.textDim}> ↑</Text>}
249
+ {clampedOffset < maxOffset && <Text color={colors.textDim}> ↓</Text>}
177
250
  </Box>
178
- {visibleLines.map((line, i) => renderLine(line, clampedOffset + i))}
251
+
252
+ {visibleLines.map((line, i) => {
253
+ const absIdx = clampedOffset + i;
254
+ const isCursor = absIdx === clampedCursor && isActive;
255
+
256
+ switch (line.kind) {
257
+ case "header":
258
+ return (
259
+ <Box key={line.id} flexDirection="row" height={1}>
260
+ <Text color={isCursor ? colors.accent : colors.textDim}>{isCursor ? "›" : " "}</Text>
261
+ <Text color={isCursor ? colors.accent : colors.purple} bold>◆ </Text>
262
+ <Text color={isCursor ? colors.accent : colors.info}>{truncate(line.modelId, 26)}</Text>
263
+ {line.agent && <Text color={colors.cyan}> [{line.agent}]</Text>}
264
+ <Box flexGrow={1} />
265
+ {line.duration && <Text color={colors.textDim}>{line.duration} </Text>}
266
+ <Text color={colors.textDim}>{line.time}</Text>
267
+ </Box>
268
+ );
269
+
270
+ case "tool":
271
+ return (
272
+ <Box key={line.id} flexDirection="row" height={1} paddingLeft={1}>
273
+ <Text color={isCursor ? colors.accent : colors.textDim}>{isCursor ? "›" : " "}</Text>
274
+ <Text color={line.iconColor}>{line.icon} </Text>
275
+ <Text color={isCursor ? colors.accent : colors.text} bold={isCursor}>{line.name}</Text>
276
+ {line.title && <Text color={colors.textDim}> {line.title}</Text>}
277
+ <Box flexGrow={1} />
278
+ <Text color={colors.textDim}>{line.expanded ? "▼ " : "▶ "}</Text>
279
+ {line.right && <Text color={colors.textDim}>{line.right}</Text>}
280
+ </Box>
281
+ );
282
+
283
+ case "tool-detail":
284
+ if (line.isSection) {
285
+ return (
286
+ <Box key={line.id} height={1} paddingLeft={3}>
287
+ <Text color={isCursor ? colors.accent : colors.textDim}>{isCursor ? "›" : " "}</Text>
288
+ <Text color={colors.purple}>── {line.label} </Text>
289
+ </Box>
290
+ );
291
+ }
292
+ return (
293
+ <Box key={line.id} height={1} paddingLeft={4}>
294
+ <Text color={isCursor ? colors.accent : colors.textDim}>{isCursor ? "›" : " "}</Text>
295
+ {line.label
296
+ ? <><Text color={colors.cyan}>{line.label}</Text><Text color={colors.textDim}>: </Text><Text color={isCursor ? colors.accent : colors.text}>{line.value}</Text></>
297
+ : <Text color={isCursor ? colors.accent : colors.textDim}>{line.value}</Text>
298
+ }
299
+ </Box>
300
+ );
301
+
302
+ case "text":
303
+ return (
304
+ <Box key={line.id} flexDirection="row" height={1} paddingLeft={1}>
305
+ <Text color={isCursor ? colors.accent : colors.textDim}>{isCursor ? "›" : " "}</Text>
306
+ <Text color={isCursor ? colors.accent : colors.text}> {line.text}</Text>
307
+ </Box>
308
+ );
309
+
310
+ case "reasoning":
311
+ return (
312
+ <Box key={line.id} flexDirection="row" height={1} paddingLeft={1}>
313
+ <Text color={isCursor ? colors.accent : colors.textDim}>{isCursor ? "›" : " "}</Text>
314
+ <Text color={isCursor ? colors.accent : colors.accentDim}> ⚡ {line.text}</Text>
315
+ </Box>
316
+ );
317
+ }
318
+ })}
179
319
  </Box>
180
320
  );
181
321
  }
182
322
 
183
323
  export const MessagesPanel = memo(MessagesPanelInner);
184
-
185
- function truncate(s: string, max: number): string {
186
- if (s.length <= max) return s;
187
- return s.slice(0, max - 1) + "…";
188
- }
@@ -5,7 +5,7 @@ import { StatusBar } from "../components/StatusBar";
5
5
  import { AgentTree } from "../components/AgentTree";
6
6
  import { DetailsPanel } from "../components/DetailsPanel";
7
7
  import { MessagesPanel } from "../components/MessagesPanel";
8
- import type { Workflow, Session, AgentNode, FlatNode } from "../../core/types";
8
+ import type { Workflow, AgentNode, FlatNode } from "../../core/types";
9
9
 
10
10
  interface SessionsScreenProps {
11
11
  workflows: Workflow[];
@@ -18,7 +18,6 @@ type RightMode = "stats" | "messages";
18
18
 
19
19
  function flattenWorkflow(workflow: Workflow, workflowIndex: number): FlatNode[] {
20
20
  const nodes: FlatNode[] = [];
21
-
22
21
  function walk(node: AgentNode) {
23
22
  nodes.push({
24
23
  id: node.session.id,
@@ -28,11 +27,8 @@ function flattenWorkflow(workflow: Workflow, workflowIndex: number): FlatNode[]
28
27
  hasChildren: node.children.length > 0,
29
28
  agentNode: node,
30
29
  });
31
- for (const child of node.children) {
32
- walk(child);
33
- }
30
+ for (const child of node.children) walk(child);
34
31
  }
35
-
36
32
  walk(workflow.agentTree);
37
33
  return nodes;
38
34
  }
@@ -45,9 +41,7 @@ function SessionsScreenInner({
45
41
  }: SessionsScreenProps) {
46
42
  const [selectedIndex, setSelectedIndex] = useState(0);
47
43
  const [rightMode, setRightMode] = useState<RightMode>("stats");
48
- const [msgScrollOffset, setMsgScrollOffset] = useState(0);
49
44
 
50
- // Flat list of all nodes (root workflows + sub-agents in tree order)
51
45
  const flatNodes = useMemo(() => {
52
46
  return workflows.flatMap((w, i) => flattenWorkflow(w, i));
53
47
  }, [workflows]);
@@ -55,12 +49,10 @@ function SessionsScreenInner({
55
49
  const clampedIndex = Math.min(selectedIndex, Math.max(0, flatNodes.length - 1));
56
50
  const selectedNode = flatNodes[clampedIndex] ?? null;
57
51
 
58
- // For DetailsPanel we need the full Workflow — use the root workflow of the selected node
59
52
  const selectedWorkflow = useMemo(() => {
60
53
  if (!selectedNode) return null;
61
54
  const w = workflows[selectedNode.workflowIndex];
62
55
  if (!w) return null;
63
- // If we selected a sub-agent, wrap it as a single-session workflow for DetailsPanel
64
56
  if (selectedNode.session.id !== w.mainSession.id) {
65
57
  return {
66
58
  id: selectedNode.session.id,
@@ -73,56 +65,49 @@ function SessionsScreenInner({
73
65
  }, [selectedNode, workflows]);
74
66
 
75
67
  const leftWidth = Math.floor(terminalWidth * 0.35);
76
- const rightWidth = terminalWidth - leftWidth - 2; // 2 for borders
68
+ const rightWidth = terminalWidth - leftWidth - 2;
69
+
70
+ const statusBarHeight = 1;
71
+ const borderRows = 2;
72
+ const innerHeight = contentHeight - statusBarHeight - borderRows;
73
+ const panelHeight = contentHeight - statusBarHeight;
74
+ const msgHeight = innerHeight - 1; // minus tab header row
77
75
 
78
- // Message scroll: reset when selection changes
79
76
  const handleSelect = useCallback((index: number) => {
80
77
  setSelectedIndex(index);
81
- setMsgScrollOffset(0);
82
78
  }, []);
83
79
 
80
+ // SessionsScreen only handles: tab switch, tree nav (stats mode), session switch (messages mode)
84
81
  useInput(
85
82
  (input, key) => {
86
- if (key.upArrow || input === "k") {
87
- handleSelect(Math.max(0, clampedIndex - 1));
88
- return;
89
- }
90
- if (key.downArrow || input === "j") {
91
- handleSelect(Math.min(flatNodes.length - 1, clampedIndex + 1));
92
- return;
93
- }
94
83
  if (key.tab) {
95
84
  setRightMode((m) => (m === "stats" ? "messages" : "stats"));
96
- setMsgScrollOffset(0);
97
85
  return;
98
86
  }
99
- // Message scroll (only when in messages mode)
100
- if (rightMode === "messages") {
101
- if (input === "u" || key.pageUp) {
102
- setMsgScrollOffset((o) => Math.max(0, o - 10));
103
- return;
104
- }
105
- if (input === "d" || key.pageDown) {
106
- setMsgScrollOffset((o) => o + 10);
107
- return;
108
- }
87
+ if (rightMode === "stats") {
88
+ if (key.upArrow || input === "k") { handleSelect(Math.max(0, clampedIndex - 1)); return; }
89
+ if (key.downArrow || input === "j") { handleSelect(Math.min(flatNodes.length - 1, clampedIndex + 1)); return; }
90
+ if (input === "g") { handleSelect(0); return; }
91
+ if (input === "G") { handleSelect(flatNodes.length - 1); return; }
92
+ } else {
93
+ // In messages mode, [ and ] switch session
94
+ if (input === "[") { handleSelect(Math.max(0, clampedIndex - 1)); return; }
95
+ if (input === "]") { handleSelect(Math.min(flatNodes.length - 1, clampedIndex + 1)); return; }
109
96
  }
110
97
  },
111
98
  { isActive }
112
99
  );
113
100
 
114
- const panelHeight = contentHeight - 2; // leave room for status bar
115
-
116
101
  return (
117
102
  <Box flexDirection="column" width={terminalWidth} height={contentHeight}>
118
- <Box flexDirection="row" flexGrow={1}>
103
+ <Box flexDirection="row" height={panelHeight}>
119
104
  {/* Left: agent/session tree */}
120
105
  <Box
121
106
  width={leftWidth}
107
+ height={panelHeight}
122
108
  borderStyle="single"
123
109
  borderColor={colors.border}
124
110
  flexDirection="column"
125
- overflow="hidden"
126
111
  >
127
112
  <AgentTree
128
113
  workflows={workflows}
@@ -132,32 +117,25 @@ function SessionsScreenInner({
132
117
  const idx = flatNodes.findIndex((n) => n.id === id);
133
118
  if (idx >= 0) handleSelect(idx);
134
119
  }}
135
- maxHeight={panelHeight}
120
+ maxHeight={innerHeight}
136
121
  />
137
122
  </Box>
138
123
 
139
124
  {/* Right: details or messages */}
140
125
  <Box
141
- flexGrow={1}
142
126
  width={rightWidth}
127
+ height={panelHeight}
143
128
  borderStyle="single"
144
129
  borderColor={colors.border}
145
130
  flexDirection="column"
146
- overflow="hidden"
147
131
  >
148
- {/* Mode tab header */}
149
- <Box paddingX={1} flexDirection="row">
150
- <Text
151
- color={rightMode === "stats" ? colors.accent : colors.textDim}
152
- bold={rightMode === "stats"}
153
- >
132
+ {/* Tab header: 1 row */}
133
+ <Box paddingX={1} height={1} flexDirection="row">
134
+ <Text color={rightMode === "stats" ? colors.accent : colors.textDim} bold={rightMode === "stats"}>
154
135
  [Stats]
155
136
  </Text>
156
137
  <Text color={colors.textDim}> </Text>
157
- <Text
158
- color={rightMode === "messages" ? colors.accent : colors.textDim}
159
- bold={rightMode === "messages"}
160
- >
138
+ <Text color={rightMode === "messages" ? colors.accent : colors.textDim} bold={rightMode === "messages"}>
161
139
  [Messages]
162
140
  </Text>
163
141
  <Box flexGrow={1} />
@@ -165,13 +143,12 @@ function SessionsScreenInner({
165
143
  </Box>
166
144
 
167
145
  {rightMode === "stats" ? (
168
- <DetailsPanel workflow={selectedWorkflow} />
146
+ <DetailsPanel workflow={selectedWorkflow} height={innerHeight - 1} />
169
147
  ) : (
170
148
  <MessagesPanel
171
149
  session={selectedNode?.session ?? null}
172
- scrollOffset={msgScrollOffset}
173
- onScrollOffsetChange={setMsgScrollOffset}
174
- maxHeight={panelHeight - 2}
150
+ maxHeight={msgHeight}
151
+ isActive={isActive && rightMode === "messages"}
175
152
  />
176
153
  )}
177
154
  </Box>
@@ -180,8 +157,8 @@ function SessionsScreenInner({
180
157
  <StatusBar
181
158
  hints={
182
159
  rightMode === "messages"
183
- ? "j/k:nav Tab:stats u/d:scroll 2:tools 3:overview r:refresh q:quit"
184
- : "j/k:nav Tab:messages 2:tools 3:overview r:refresh q:quit"
160
+ ? "j/k:scroll d/u:½page g/G:top/bot Enter:expand [:prev ]:next Tab:stats q:quit"
161
+ : "j/k:nav g/G:top/bot Tab:messages 2:tools 3:overview r:refresh q:quit"
185
162
  }
186
163
  />
187
164
  </Box>