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" } }