diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
index 775969bfcb3..e0a688b61d1 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
@@ -2,7 +2,7 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
-import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
+import { createMemo, createSignal, createResource, onMount, Show, createEffect } from "solid-js"
import { Locale } from "@/util/locale"
import { useKeybind } from "../context/keybind"
import { useTheme } from "../context/theme"
@@ -35,7 +35,11 @@ export function DialogSessionList() {
const sessions = createMemo(() => searchResults() ?? sync.data.session)
const options = createMemo(() => {
+ if (!sync.ready) return []
const today = new Date().toDateString()
+ const sessionsListLimit = sync.data.config.experimental?.session_list_limit
+ const limit = sessionsListLimit === "none" ? undefined : sessionsListLimit ?? 150
+
return sessions()
.filter((x) => x.parentID === undefined)
.toSorted((a, b) => b.time.updated - a.time.updated)
@@ -57,6 +61,11 @@ export function DialogSessionList() {
gutter: isWorking ? : undefined,
}
})
+ .slice(0, limit)
+ })
+
+ createEffect(() => {
+ console.log("session count", sync.data.session.length)
})
onMount(() => {
diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
index 269ed7ae0bd..ebb9cfe725a 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
@@ -241,10 +241,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
event.properties.info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
+ const maxMessages = store.config.experimental?.messages_limit
+ const maxMessagesCount = maxMessages === "none" ? Infinity : maxMessages ?? 100
+ if (draft.length > maxMessagesCount) {
+ draft.shift()
+ }
}),
)
const updated = store.message[event.properties.info.sessionID]
- if (updated.length > 100) {
+ const maxMessages = store.config.experimental?.messages_limit
+ const maxMessagesCount = maxMessages === "none" ? Infinity : maxMessages ?? 100
+ if (updated.length > maxMessagesCount) {
const oldest = updated[0]
batch(() => {
setStore(
@@ -348,21 +355,27 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
async function bootstrap() {
console.log("bootstrapping")
- const start = Date.now() - 30 * 24 * 60 * 60 * 1000
+
+ // Fetch config first to get session_list_limit
+ const configResponse = await sdk.client.config.get({}, { throwOnError: true })
+ const config = configResponse.data!
+ const sessionsListLimit = config.experimental?.session_list_limit
+ const unlimited = sessionsListLimit === "none"
+ const sessionsLimit = unlimited ? undefined : sessionsListLimit ?? 150
+
+ const start = unlimited ? undefined : Date.now() - 30 * 24 * 60 * 60 * 1000
const sessionListPromise = sdk.client.session
- .list({ start: start })
+ .list({ start, limit: sessionsLimit })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
// blocking - include session.list when continuing a session
const providersPromise = sdk.client.config.providers({}, { throwOnError: true })
const providerListPromise = sdk.client.provider.list({}, { throwOnError: true })
const agentsPromise = sdk.client.app.agents({}, { throwOnError: true })
- const configPromise = sdk.client.config.get({}, { throwOnError: true })
const blockingRequests: Promise[] = [
providersPromise,
providerListPromise,
agentsPromise,
- configPromise,
...(args.continue ? [sessionListPromise] : []),
]
@@ -371,21 +384,18 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const providersResponse = providersPromise.then((x) => x.data!)
const providerListResponse = providerListPromise.then((x) => x.data!)
const agentsResponse = agentsPromise.then((x) => x.data ?? [])
- const configResponse = configPromise.then((x) => x.data!)
const sessionListResponse = args.continue ? sessionListPromise : undefined
return Promise.all([
providersResponse,
providerListResponse,
agentsResponse,
- configResponse,
...(sessionListResponse ? [sessionListResponse] : []),
]).then((responses) => {
const providers = responses[0]
const providerList = responses[1]
const agents = responses[2]
- const config = responses[3]
- const sessions = responses[4]
+ const sessions = responses[3]
batch(() => {
setStore("provider", reconcile(providers.providers))
@@ -459,9 +469,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
},
async sync(sessionID: string) {
if (fullSyncedSessions.has(sessionID)) return
+ const messagesLimit = store.config.experimental?.messages_limit
+ const limit = messagesLimit === "none" ? undefined : messagesLimit ?? 100
const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }),
- sdk.client.session.messages({ sessionID, limit: 100 }),
+ sdk.client.session.messages({ sessionID, limit }),
sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID }),
])
@@ -475,6 +487,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
for (const message of messages.data!) {
draft.part[message.info.id] = message.parts
}
+
draft.session_diff[sessionID] = diff.data ?? []
}),
)
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index f20267e0820..504532e5d3a 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -181,15 +181,18 @@ export function Session() {
})
createEffect(async () => {
+ const sessionID = route.sessionID
+ const ready = sync.ready
+ if (!ready) return
await sync.session
- .sync(route.sessionID)
+ .sync(sessionID)
.then(() => {
if (scroll) scroll.scrollBy(100_000)
})
.catch((e) => {
console.error(e)
toast.show({
- message: `Session not found: ${route.sessionID}`,
+ message: `Session not found: ${sessionID}`,
variant: "error",
})
return navigate({ type: "home" })
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 28aea4d6777..d415858ad3e 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -909,6 +909,21 @@ export namespace Config {
ref: "KeybindsConfig",
})
+ export const TUI = z.object({
+ scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
+ scroll_acceleration: z
+ .object({
+ enabled: z.boolean().describe("Enable scroll acceleration"),
+ })
+ .optional()
+ .describe("Scroll acceleration settings"),
+ diff_style: z
+ .enum(["auto", "stacked"])
+ .optional()
+ .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
+ })
+ export type TUI = z.infer
+
export const Server = z
.object({
port: z.number().int().positive().optional().describe("Port to listen on"),
@@ -1164,6 +1179,14 @@ export namespace Config {
.positive()
.optional()
.describe("Timeout in milliseconds for model context protocol (MCP) requests"),
+ messages_limit: z
+ .union([z.number().min(1), z.literal("none")])
+ .optional()
+ .describe("Maximum number of message parts to load per session when syncing, or 'none' to load all messages"),
+ session_list_limit: z
+ .union([z.number().min(1), z.literal("none")])
+ .optional()
+ .describe("Maximum number of sessions to display in session list, or 'none' to show all sessions"),
})
.optional(),
})
diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts
index 22de477f8d1..2112fc7ee6d 100644
--- a/packages/opencode/src/session/index.ts
+++ b/packages/opencode/src/session/index.ts
@@ -516,11 +516,23 @@ export namespace Session {
}),
async (input) => {
const result = [] as MessageV2.WithParts[]
+ let totalParts = 0
+
for await (const msg of MessageV2.stream(input.sessionID)) {
- if (input.limit && result.length >= input.limit) break
+ if (input.limit && totalParts + msg.parts.length > input.limit) {
+ // If adding this message would exceed the limit, check if we can fit a partial message
+ if (totalParts < input.limit) {
+ // We have room for some parts of this message, but this would be complex to implement
+ // For now, just break to stay within the limit
+ break
+ }
+ break
+ }
result.push(msg)
+ totalParts += msg.parts.length
}
result.reverse()
+
return result
},
)
@@ -548,17 +560,17 @@ export namespace Session {
conditions.push(like(SessionTable.title, `%${input.search}%`))
}
- const limit = input?.limit ?? 100
-
- const rows = Database.use((db) =>
- db
+ const rows = Database.use((db) => {
+ const baseQuery = db
.select()
.from(SessionTable)
.where(and(...conditions))
.orderBy(desc(SessionTable.time_updated))
- .limit(limit)
- .all(),
- )
+
+ const query = input?.limit !== undefined ? baseQuery.limit(input.limit) : baseQuery
+
+ return query.all()
+ })
for (const row of rows) {
yield fromRow(row)
}
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index f245dc3493d..7f2aa6a455b 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -933,6 +933,27 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
})
})
+test("compaction config defaults to true when not specified", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await Config.get()
+ // When not specified, compaction should be undefined (defaults handled in usage)
+ expect(config.compaction).toBeUndefined()
+ },
+ })
+})
+
// Legacy tools migration tests
test("migrates legacy tools config to permissions - allow", async () => {
@@ -997,6 +1018,28 @@ test("migrates legacy tools config to permissions - deny", async () => {
})
})
+test("validates experimental messages_limit schema - rejects invalid values", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ experimental: {
+ messages_limit: 0, // Invalid: must be >= 1
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await expect(Config.get()).rejects.toThrow()
+ },
+ })
+})
+
test("migrates legacy write tool to edit permission", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -1259,6 +1302,54 @@ test("merges legacy tools with existing permission config", async () => {
})
})
+test("compaction config can disable prune", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ compaction: {
+ prune: false,
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await Config.get()
+ expect(config.compaction?.prune).toBe(false)
+ },
+ })
+})
+
+test("compaction config can disable both auto and prune", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ compaction: {
+ auto: false,
+ prune: false,
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await Config.get()
+ expect(config.compaction?.auto).toBe(false)
+ expect(config.compaction?.prune).toBe(false)
+ },
+ })
+})
+
test("permission config preserves key order", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts
index 8eefe5bfe98..e4c5c153317 100644
--- a/packages/sdk/js/src/gen/types.gen.ts
+++ b/packages/sdk/js/src/gen/types.gen.ts
@@ -1207,6 +1207,14 @@ export type Config = {
* Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column
*/
diff_style?: "auto" | "stacked"
+ /**
+ * Maximum number of sessions to display in session list, or 'none' to show all sessions
+ */
+ session_list_limit?: number | "none"
+ /**
+ * Maximum number of message parts to load per session when syncing, or 'none' to load all messages
+ */
+ messages_limit?: number | "none"
}
/**
* Command configuration, see https://opencode.ai/docs/commands
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index be6c00cf445..15a3b9f704c 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -1478,6 +1478,14 @@ export type Config = {
* Timeout in milliseconds for model context protocol (MCP) requests
*/
mcp_timeout?: number
+ /**
+ * Maximum number of message parts to load per session when syncing, or 'none' to load all messages
+ */
+ messages_limit?: number | "none"
+ /**
+ * Maximum number of sessions to display in session list, or 'none' to show all sessions
+ */
+ session_list_limit?: number | "none"
}
}