opencode-top 3.0.0 → 3.1.0

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.0",
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,106 @@
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" } }
13
+ | { kind: "header"; modelId: string; agent: string | null; duration: string; time: string }
14
+ | { kind: "tool"; callId: string; icon: string; iconColor: string; name: string; title: string; right: string; expanded: boolean }
15
+ | { kind: "tool-detail"; label: string; value: string; isSection: boolean }
16
16
  | { kind: "text"; text: string }
17
- | { kind: "reasoning"; text: string }
18
- | { kind: "spacer" };
17
+ | { 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
+ kind: "header",
31
+ modelId: interaction.modelId,
32
+ agent: interaction.agent ?? null,
33
+ duration: dur,
34
+ time: formatTime(interaction.time.created),
35
+ });
36
+
25
37
  for (const part of interaction.parts) {
26
38
  if (part.type === "tool") {
27
- lines.push({ kind: "tool-call", part: part as MessagePart & { type: "tool" } });
39
+ const p = part as MessagePart & { type: "tool" };
40
+ const icon = p.status === "completed" ? "✓" : p.status === "error" ? "✗" : "◌";
41
+ const iconColor =
42
+ p.status === "completed" ? colors.success : p.status === "error" ? colors.error : colors.warning;
43
+ const dur2 = p.timeEnd > 0 && p.timeStart > 0 ? formatDuration(p.timeEnd - p.timeStart) : "";
44
+ const exitStr = p.exitCode !== null && p.exitCode !== 0 ? `exit:${p.exitCode} ` : "";
45
+ const expanded = expandedIds.has(p.callId);
46
+
47
+ lines.push({
48
+ kind: "tool",
49
+ callId: p.callId,
50
+ icon,
51
+ iconColor,
52
+ name: truncate(p.toolName, 18),
53
+ title: p.title ? truncate(p.title, 28) : "",
54
+ right: exitStr + dur2,
55
+ expanded,
56
+ });
57
+
58
+ if (expanded) {
59
+ const inputKeys = Object.keys(p.input);
60
+ if (inputKeys.length > 0) {
61
+ lines.push({ kind: "tool-detail", label: "input", value: "", isSection: true });
62
+ for (const key of inputKeys) {
63
+ const val = formatParamValue(p.input[key], contentWidth - key.length - 6);
64
+ lines.push({ kind: "tool-detail", label: key, value: val, isSection: false });
65
+ }
66
+ }
67
+ if (p.output?.trim()) {
68
+ lines.push({ kind: "tool-detail", label: "output", value: "", isSection: true });
69
+ const outLines = p.output.trim().split("\n").slice(0, 40);
70
+ for (const ol of outLines) {
71
+ for (const wrapped of wrapText(ol === "" ? " " : ol, contentWidth - 5)) {
72
+ lines.push({ kind: "tool-detail", label: "", value: wrapped, isSection: false });
73
+ }
74
+ }
75
+ }
76
+ }
28
77
  } 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 });
78
+ for (const row of wrapText(part.text.trim(), contentWidth - 3)) {
79
+ lines.push({ kind: "text", text: row });
33
80
  }
34
81
  } 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 });
82
+ for (const row of wrapText(part.text.trim(), contentWidth - 5)) {
83
+ lines.push({ kind: "reasoning", text: row });
38
84
  }
39
85
  }
40
86
  }
41
- lines.push({ kind: "spacer" });
42
87
  }
43
88
  return lines;
44
89
  }
45
90
 
91
+ function formatParamValue(val: unknown, maxLen: number): string {
92
+ if (val === null || val === undefined) return "null";
93
+ if (typeof val === "string") return truncate(val.split("\n")[0], Math.max(20, maxLen));
94
+ if (typeof val === "number" || typeof val === "boolean") return String(val);
95
+ try { return truncate(JSON.stringify(val), Math.max(20, maxLen)); }
96
+ catch { return String(val); }
97
+ }
98
+
46
99
  function wrapText(text: string, maxLen: number): string[] {
47
- const rawLines = text.split("\n");
48
100
  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
- }
101
+ for (const raw of text.split("\n")) {
102
+ if (raw.length <= maxLen) { out.push(raw); }
103
+ else { for (let i = 0; i < raw.length; i += maxLen) out.push(raw.slice(i, i + maxLen)); }
57
104
  }
58
105
  return out;
59
106
  }
@@ -69,87 +116,104 @@ function formatDuration(ms: number): string {
69
116
  return `${(ms / 1000).toFixed(1)}s`;
70
117
  }
71
118
 
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
- }
119
+ function truncate(s: string, max: number): string {
120
+ if (!s || s.length <= max) return s;
121
+ return s.slice(0, max - 1) + "…";
122
+ }
91
123
 
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
- }
124
+ function MessagesPanelInner({ session, maxHeight, isActive }: MessagesPanelProps) {
125
+ const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
126
+ const [scrollOffset, setScrollOffset] = useState(0);
127
+ const [cursor, setCursor] = useState(0);
120
128
 
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
- }
129
+ // Reset all state when session changes
130
+ useEffect(() => {
131
+ setExpandedIds(new Set());
132
+ setScrollOffset(0);
133
+ setCursor(0);
134
+ }, [session?.id]);
135
+
136
+ const contentWidth = 80;
137
+ const viewHeight = maxHeight - 1; // minus the counter row
138
+
139
+ const allLines = useMemo(
140
+ () => (session ? buildLines(session, contentWidth, expandedIds) : []),
141
+ [session, expandedIds]
142
+ );
143
+
144
+ const maxOffset = Math.max(0, allLines.length - viewHeight);
145
+
146
+ // Keep cursor in view: scroll to follow cursor
147
+ const clampedCursor = Math.min(cursor, Math.max(0, allLines.length - 1));
148
+ const clampedOffset = Math.max(
149
+ 0,
150
+ Math.min(
151
+ scrollOffset,
152
+ Math.min(maxOffset, Math.max(scrollOffset, clampedCursor - viewHeight + 1))
153
+ )
154
+ );
139
155
 
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]);
156
+ useInput((input, key) => {
157
+ if (key.downArrow || input === "j") {
158
+ setCursor((c) => Math.min(allLines.length - 1, c + 1));
159
+ setScrollOffset((o) => {
160
+ const newCursor = Math.min(allLines.length - 1, clampedCursor + 1);
161
+ // Scroll down if cursor goes below view
162
+ if (newCursor >= o + viewHeight) return Math.min(maxOffset, o + 1);
163
+ return o;
164
+ });
165
+ return;
166
+ }
167
+ if (key.upArrow || input === "k") {
168
+ setCursor((c) => Math.max(0, c - 1));
169
+ setScrollOffset((o) => {
170
+ const newCursor = Math.max(0, clampedCursor - 1);
171
+ // Scroll up if cursor goes above view
172
+ if (newCursor < o) return Math.max(0, o - 1);
173
+ return o;
174
+ });
175
+ return;
176
+ }
177
+ if (key.pageDown || input === "d") {
178
+ const half = Math.floor(viewHeight / 2);
179
+ setScrollOffset((o) => Math.min(maxOffset, o + half));
180
+ setCursor((c) => Math.min(allLines.length - 1, c + half));
181
+ return;
182
+ }
183
+ if (key.pageUp || input === "u") {
184
+ const half = Math.floor(viewHeight / 2);
185
+ setScrollOffset((o) => Math.max(0, o - half));
186
+ setCursor((c) => Math.max(0, c - half));
187
+ return;
188
+ }
189
+ if (input === "g") {
190
+ setScrollOffset(0);
191
+ setCursor(0);
192
+ return;
193
+ }
194
+ if (input === "G") {
195
+ setScrollOffset(maxOffset);
196
+ setCursor(allLines.length - 1);
197
+ return;
198
+ }
199
+ if (key.return) {
200
+ const line = allLines[clampedCursor];
201
+ if (line?.kind === "tool") {
202
+ const id = line.callId;
203
+ setExpandedIds((prev) => {
204
+ const next = new Set(prev);
205
+ if (next.has(id)) next.delete(id);
206
+ else next.add(id);
207
+ return next;
208
+ });
209
+ }
210
+ return;
211
+ }
212
+ }, { isActive });
149
213
 
150
214
  if (!session) {
151
215
  return (
152
- <Box paddingX={1}>
216
+ <Box height={maxHeight} paddingX={1}>
153
217
  <Text color={colors.textDim}>Select a session</Text>
154
218
  </Box>
155
219
  );
@@ -157,32 +221,98 @@ function MessagesPanelInner({
157
221
 
158
222
  if (allLines.length === 0) {
159
223
  return (
160
- <Box paddingX={1}>
224
+ <Box height={maxHeight} paddingX={1}>
161
225
  <Text color={colors.textDim}>No messages</Text>
162
226
  </Box>
163
227
  );
164
228
  }
165
229
 
166
- const clampedOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, allLines.length - maxHeight)));
167
- const visibleLines = allLines.slice(clampedOffset, clampedOffset + maxHeight);
230
+ const visibleLines = allLines.slice(clampedOffset, clampedOffset + viewHeight);
168
231
 
169
232
  return (
170
- <Box flexDirection="column" paddingX={1} overflow="hidden">
171
- <Box flexDirection="row">
233
+ <Box flexDirection="column" height={maxHeight} paddingX={1}>
234
+ {/* Counter row */}
235
+ <Box flexDirection="row" height={1}>
172
236
  <Text color={colors.textDim}>
173
- {clampedOffset + 1}–{Math.min(clampedOffset + maxHeight, allLines.length)}/{allLines.length}
237
+ {clampedOffset + 1}–{Math.min(clampedOffset + viewHeight, allLines.length)}/{allLines.length}
174
238
  </Text>
175
239
  <Box flexGrow={1} />
176
- <Text color={colors.textDim}>{session.interactions.length} turns</Text>
240
+ <Text color={colors.textDim}>
241
+ {session.interactions.filter((i) => i.role === "assistant").length} turns
242
+ </Text>
243
+ {clampedOffset > 0 && <Text color={colors.textDim}> ↑</Text>}
244
+ {clampedOffset < maxOffset && <Text color={colors.textDim}> ↓</Text>}
177
245
  </Box>
178
- {visibleLines.map((line, i) => renderLine(line, clampedOffset + i))}
246
+
247
+ {visibleLines.map((line, i) => {
248
+ const absIdx = clampedOffset + i;
249
+ const isCursor = absIdx === clampedCursor && isActive;
250
+
251
+ switch (line.kind) {
252
+ case "header":
253
+ return (
254
+ <Box key={absIdx} flexDirection="row" height={1}>
255
+ <Text color={isCursor ? colors.accent : colors.textDim}>{isCursor ? "›" : " "}</Text>
256
+ <Text color={isCursor ? colors.accent : colors.purple} bold>◆ </Text>
257
+ <Text color={isCursor ? colors.accent : colors.info}>{truncate(line.modelId, 26)}</Text>
258
+ {line.agent && <Text color={colors.cyan}> [{line.agent}]</Text>}
259
+ <Box flexGrow={1} />
260
+ {line.duration && <Text color={colors.textDim}>{line.duration} </Text>}
261
+ <Text color={colors.textDim}>{line.time}</Text>
262
+ </Box>
263
+ );
264
+
265
+ case "tool":
266
+ return (
267
+ <Box key={absIdx} flexDirection="row" height={1} paddingLeft={1}>
268
+ <Text color={isCursor ? colors.accent : colors.textDim}>{isCursor ? "›" : " "}</Text>
269
+ <Text color={line.iconColor}>{line.icon} </Text>
270
+ <Text color={isCursor ? colors.accent : colors.text} bold={isCursor}>{line.name}</Text>
271
+ {line.title && <Text color={colors.textDim}> {line.title}</Text>}
272
+ <Box flexGrow={1} />
273
+ <Text color={colors.textDim}>{line.expanded ? "▼ " : "▶ "}</Text>
274
+ {line.right && <Text color={colors.textDim}>{line.right}</Text>}
275
+ </Box>
276
+ );
277
+
278
+ case "tool-detail":
279
+ if (line.isSection) {
280
+ return (
281
+ <Box key={absIdx} height={1} paddingLeft={3}>
282
+ <Text color={isCursor ? colors.accent : colors.textDim}>{isCursor ? "›" : " "}</Text>
283
+ <Text color={colors.purple}>── {line.label} </Text>
284
+ </Box>
285
+ );
286
+ }
287
+ return (
288
+ <Box key={absIdx} height={1} paddingLeft={4}>
289
+ <Text color={isCursor ? colors.accent : colors.textDim}>{isCursor ? "›" : " "}</Text>
290
+ {line.label
291
+ ? <><Text color={colors.cyan}>{line.label}</Text><Text color={colors.textDim}>: </Text><Text color={isCursor ? colors.accent : colors.text}>{line.value}</Text></>
292
+ : <Text color={isCursor ? colors.accent : colors.textDim}>{line.value}</Text>
293
+ }
294
+ </Box>
295
+ );
296
+
297
+ case "text":
298
+ return (
299
+ <Box key={absIdx} flexDirection="row" height={1} paddingLeft={1}>
300
+ <Text color={isCursor ? colors.accent : colors.textDim}>{isCursor ? "›" : " "}</Text>
301
+ <Text color={isCursor ? colors.accent : colors.text}> {line.text}</Text>
302
+ </Box>
303
+ );
304
+
305
+ case "reasoning":
306
+ return (
307
+ <Box key={absIdx} flexDirection="row" height={1} paddingLeft={1}>
308
+ <Text color={isCursor ? colors.accent : colors.textDim}>{isCursor ? "›" : " "}</Text>
309
+ <Text color={isCursor ? colors.accent : colors.accentDim}> ⚡ {line.text}</Text>
310
+ </Box>
311
+ );
312
+ }
313
+ })}
179
314
  </Box>
180
315
  );
181
316
  }
182
317
 
183
318
  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>