Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions packages/tui/perf/tabs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Session Tab Switching

## Run

Install OpenCode Drive, then run from the repository root:

```sh
PERF_RUN=before \
OPENCODE_DRIVE_MEDIA_DIR="$PWD/.cache/tui-switch/media" \
opencode-drive run script/bench-tui-tabs.ts
```

Use a new `PERF_RUN` for each run; existing result directories are not overwritten.
`PERF_TARGET` selects another source worktree and defaults to the working directory.
`PERF_OUTPUT` overrides the result root, which defaults to `<target>/.cache/tui-switch`.
`PERF_CONTENT=prose` replaces the Markdown with equal-byte-size plain text.

Drive checks the script, creates an isolated server/home/project, imports synthetic
sessions through the real API, and launches the real TUI components. It never
connects to the elected background service. Only the final streaming correctness
check prompts a model, and that model is simulated.

Run benchmarks serially, without simultaneous tests or builds. The script records
the target revision and its production TUI/Client diff, every completed action in
`samples.jsonl`, summary statistics, terminal frames, and Drive artifact metadata.
It retains failed runs' completed samples. Result and media directories are local
artifacts, not files to commit.

## Workload

- SHORT: 20 messages, 256 text bytes per assistant.
- LONG: 2,000 messages with the same text sizes and a comparable latest page.
- LARGE: 20 messages, 32 KiB per assistant.
- Every fifth assistant includes a completed synthetic read-tool result.
- Historical fixtures carry creation/completion times but omit stream-end/token
accounting. Correctness tests exercise complete timing and token metadata.
- Markdown deliberately repeats small fenced blocks, about 170 per LARGE
assistant. It is a stress fixture, not a typical-response latency claim.

The TUI starts after import. It opens sessions through the picker, switches with
the real keybindings, loads all LONG history, and returns to both its tail and a
saved head anchor. Each warm category has one explicitly retained warm-up sample
(`sample: 0`) and eight measured observations. Initial opens and first/last-message
navigation are single observations and are not included in warm medians.

Location caveat: this runner supplies `info.location` but omits the import API's
top-level `location`. Imported sessions therefore use the isolated server's
working directory, not the fixture `files` directory. The results describe warm
cross-Location sessions, not same-Location restoration or cold project loading.
Both directories are private synthetic fixtures; no live user Location is used.

`actionMs` is Drive's action RPC duration, including its forced render and UI-tree
inspection. `visibleMs` additionally waits for a destination marker, with 20 ms
polling. Neither is physical-terminal input-to-paint latency or a styled-content
completion guarantee. Fixed inter-action pacing occurs outside the measured
interval and is not used instead of readiness checks.

The final check streams an incomplete ordinary fence, completes it, verifies its
final displayed text, and checks the server's completed assistant projection.
Normal tests separately cover custom Markdown and footer correctness.

## Initial Experiments

Base: `849824efd2`, Bun 1.3.14, OpenTUI 0.5.9, Apple M2 Max, 120x40, source builds,
DevTools disabled. All results below are local action-RPC medians in milliseconds,
eight measured observations per cell. They are not release-binary guarantees.

| Scenario | Base repeat | Markdown ordering | Ordering + footer index | Footer confirmation |
| ------------------------------ | ----------: | ----------------: | ----------------------: | ------------------: |
| Latest 20 of LONG retained | 28.6 | 28.8 | 26.6 | 26.2 |
| All 2,000 retained, tail | 107.7 | 107.9 | 66.9 | 60.5 |
| All 2,000 retained, saved head | 115.3 | 118.7 | 71.9 | 69.3 |
| Dense Markdown | 1,615.5 | 832.1 | 830.4 | 820.6 |

These are the `before-02`, `markdown-02`, `footer-01`, and `footer-02` runs. The earlier
base/Markdown pair independently measured 1,717.3 -> 842.6 ms for dense Markdown;
that earlier runner did not yet include the saved-head category, so its results
are not pooled into the table. Ordinary short/latest-page differences are near
the noise floor and are not claimed as improvements.

### Kept

- Configure Markdown's custom renderer before content. OpenTUI's `renderNode`
setter otherwise clears populated parse/block state and repeats preparation.
Keep content before `streaming` so the completion update retains final tokens.
- Share a reactive message-position index across a Session view's footers. Scan
only from each position to its preceding user/synthetic input instead of
searching and slicing entire history prefixes. This improves both tail and
historical positions rather than shifting work to the newer suffix.

Captured content, geometry, and styling matched across the base, Markdown-only,
and both footer trials after excluding the isolated-project path footer.

The helper dependency test deliberately supplies a known position. It verifies
that the bounded calculation does not read an unrelated prefix, not that the
whole Session ignores structural history changes. A real-App regression checks
the reactive index through prepend/reconcile and a same-length truncate/append.

### Deferred

- The second row reduction after a cache-hit sync still exists. Removing it
cleanly needs an explicit cache-hit/synchronization contract; this pass does
not change the public Client data API or infer freshness from array identity.
- Parsed Markdown caches, mounted-view retention, history eviction, and initial
window changes were not mixed into these experiments.
- No memory-leak or retained-heap improvement is claimed by these latency runs.
15 changes: 10 additions & 5 deletions packages/tui/src/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ const context = createContext<{
groupExploration: () => boolean
diffWrapMode: () => "word" | "none"
models: () => ModelInfo[]
messageIndex: (messageID: string) => number | undefined
config: ReturnType<typeof useConfig>["data"]
mutatePending: (action: PendingAction, inboxID: string) => Promise<boolean>
pendingDelivery: (inboxID: string) => SessionInbox.Delivery | undefined
Expand Down Expand Up @@ -180,6 +181,7 @@ export function Session(props: {
const promptRef = usePromptRef()
const session = createMemo(() => data.session.get(route.sessionID))
const messages = () => data.session.message.list(route.sessionID)
const messageIndexes = createMemo(() => new Map(messages().map((message, index) => [message.id, index])))
const messagesBeforeRevert = () => {
const messageID = session()?.revert?.messageID
if (!messageID) return messages()
Expand Down Expand Up @@ -1349,6 +1351,7 @@ export function Session(props: {
groupExploration,
diffWrapMode,
models,
messageIndex: (messageID) => messageIndexes().get(messageID),
config,
mutatePending,
pendingDelivery: (inboxID) => pendingDeliveries().get(inboxID),
Expand Down Expand Up @@ -2031,8 +2034,10 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
?.name ?? `${props.message.model.providerID}/${props.message.model.id}`,
)
const messages = createMemo(() => data.session.message.list(ctx.sessionID))
const duration = createMemo(() => turnDuration(props.message, messages()))
const tokensPerSecond = createMemo(() => turnTokensPerSecond(props.message, messages()))
const duration = createMemo(() => turnDuration(props.message, messages(), ctx.messageIndex(props.message.id)))
const tokensPerSecond = createMemo(() =>
turnTokensPerSecond(props.message, messages(), ctx.messageIndex(props.message.id)),
)
const interrupted = createMemo(() => props.message.error?.message === "Step interrupted")
return (
<>
Expand Down Expand Up @@ -2211,14 +2216,14 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
<box paddingTop={1} paddingLeft={3}>
<markdown
syntaxStyle={syntax()}
renderNode={plugins.markdown()}
streaming={true}
internalBlockMode="top-level"
content={content()}
tableOptions={{ style: "grid", cellPaddingX: 1 }}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.markdown.text}
bg={theme.background.default}
renderNode={plugins.markdown()}
/>
</box>
</Show>
Expand Down Expand Up @@ -2658,17 +2663,17 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText; mes
return (
<Show when={props.part.text.trim()}>
<box paddingLeft={3} flexShrink={0}>
{/* Apply content before streaming so completion does not freeze the previous Markdown tokens. */}
{/* Configure custom nodes before parsing; apply content before streaming so completion keeps the final tokens. */}
<markdown
syntaxStyle={syntax()}
renderNode={plugins.markdown()}
content={props.part.text.trim()}
streaming={props.message.time.completed === undefined}
internalBlockMode="top-level"
tableOptions={{ style: "grid", cellPaddingX: 1 }}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.markdown.text}
bg={theme.background.default}
renderNode={plugins.markdown()}
/>
</box>
</Show>
Expand Down
29 changes: 19 additions & 10 deletions packages/tui/src/routes/session/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,21 +346,21 @@ export function cacheReuseDrop(previous: CacheUsage | undefined, current: CacheU
return drop > 0 ? drop : undefined
}

export function turnDuration(message: SessionMessageAssistant, messages: SessionMessageInfo[]) {
export function turnDuration(message: SessionMessageAssistant, messages: SessionMessageInfo[], position?: number) {
if (message.time.completed === undefined) return 0
const index = messages.findIndex((item) => item.id === message.id)
const input = messages
.slice(0, index === -1 ? messages.length : index)
.findLast((item) => item.type === "user" || item.type === "synthetic")
const index = position ?? messages.findIndex((item) => item.id === message.id)
const input = messages[inputIndex(messages, index === -1 ? messages.length : index)]
return Math.max(0, message.time.completed - (input?.time.created ?? message.time.created))
}

export function turnTokensPerSecond(message: SessionMessageAssistant, messages: SessionMessageInfo[]) {
const index = messages.findIndex((item) => item.id === message.id)
export function turnTokensPerSecond(
message: SessionMessageAssistant,
messages: SessionMessageInfo[],
position?: number,
) {
const index = position ?? messages.findIndex((item) => item.id === message.id)
const end = index === -1 ? messages.length : index + 1
const start = messages
.slice(0, end)
.findLastIndex((item) => item.type === "user" || item.type === "synthetic")
const start = inputIndex(messages, end)
const steps = messages
.slice(start + 1, end)
.filter((item): item is SessionMessageAssistant => item.type === "assistant")
Expand All @@ -374,6 +374,15 @@ export function turnTokensPerSecond(message: SessionMessageAssistant, messages:
return output / (duration / 1_000)
}

function inputIndex(messages: SessionMessageInfo[], end: number) {
// Reading a sliced prefix subscribes every footer to unrelated historical messages.
for (let index = end - 1; index >= 0; index--) {
const message = messages[index]
if (message.type === "user" || message.type === "synthetic") return index
}
return -1
}

function hasTokenUsage(
message: SessionMessageAssistant,
): message is SessionMessageAssistant & { tokens: NonNullable<SessionMessageAssistant["tokens"]> } {
Expand Down
Loading
Loading