Compare commits

..

2 Commits

Author SHA1 Message Date
Test 18e345ea34 Merge remote-tracking branch 'origin/dev' into beta-sync-30303
# Conflicts:
#	packages/tui/src/routes/session/index.tsx
2026-06-25 07:02:20 +00:00
Kit Langton 7e71e37a9b feat(tui): add session render state dump 2026-06-01 20:55:24 -04:00
2 changed files with 69 additions and 66 deletions
@@ -727,67 +727,6 @@ export function Prompt(props: PromptProps) {
)
}
function expandPasteExtmark(extmark: { id: number; start: number; end: number }) {
const partIndex = store.extmarkToPartIndex.get(extmark.id)
const part = partIndex === undefined ? undefined : store.prompt.parts[partIndex]
if (part?.type !== "text" || !part.source?.text) return false
const nextInput = store.prompt.input.slice(0, extmark.start) + part.text + store.prompt.input.slice(extmark.end)
const delta = part.text.length - (extmark.end - extmark.start)
const nextParts = store.prompt.parts
.flatMap((item, index) => {
if (index === partIndex) return []
const next = structuredClone(unwrap(item))
if (next.type === "agent" && next.source && next.source.start >= extmark.end) {
next.source.start += delta
next.source.end += delta
}
if (next.type === "file" && next.source?.text && next.source.text.start >= extmark.end) {
next.source.text.start += delta
next.source.text.end += delta
}
if (next.type === "text" && next.source?.text && next.source.text.start >= extmark.end) {
next.source.text.start += delta
next.source.text.end += delta
}
return [next]
})
.filter((item): item is PromptInfo["parts"][number] => item !== undefined)
input.setText(nextInput)
setStore("prompt", {
input: nextInput,
parts: nextParts,
})
restoreExtmarksFromParts(nextParts)
input.cursorOffset = extmark.start + part.text.length
return true
}
function expandPasteBlockAtMouse(event: MouseEvent) {
if (event.button !== 0) return false
const localX = event.x - input.x
const localY = event.y - input.y
if (localX < 0 || localY < 0 || localX >= input.width || localY >= input.height) return false
const previousOffset = input.cursorOffset
input.editorView.setLocalSelection(localX, localY, localX, localY, undefined, undefined, true, false)
input.editorView.resetLocalSelection()
const offset = input.cursorOffset
const extmark = input.extmarks.getAllForTypeId(promptPartTypeId).find((item) => {
const partIndex = store.extmarkToPartIndex.get(item.id)
const part = partIndex === undefined ? undefined : store.prompt.parts[partIndex]
if (part?.type !== "text") return false
return (offset >= item.start && offset <= item.end) || (offset + 1 >= item.start && offset + 1 <= item.end)
})
if (!extmark) {
input.cursorOffset = previousOffset
return false
}
return expandPasteExtmark(extmark)
}
const stashCommands = createMemo(() =>
[
{
@@ -1490,11 +1429,6 @@ export function Prompt(props: PromptProps) {
}, 0)
}}
onMouseDown={(r: MouseEvent) => r.target?.focus()}
onMouseUp={(event: MouseEvent) => {
if (!expandPasteBlockAtMouse(event)) return
event.preventDefault()
event.stopPropagation()
}}
focusedBackgroundColor={theme.backgroundElement}
cursorColor={props.disabled ? theme.backgroundElement : theme.text}
syntaxStyle={syntax()}
+69
View File
@@ -82,6 +82,7 @@ import { getRevertDiffFiles } from "../../util/revert-diff"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap"
import { usePathFormatter } from "../../context/path-format"
import { LocationProvider } from "../../context/location"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
addDefaultParsers(parsers.parsers)
@@ -91,6 +92,7 @@ const GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT = "go_upsell_account_rate_limit_
const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_dont_show"
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])
const SESSION_RENDER_STATE_CHANNELS = new Set(["local", "dev", "beta"])
export const alwaysSeparate = new WeakSet<BoxRenderable>()
@@ -420,6 +422,56 @@ export function Session() {
}, 50)
}
async function dumpRenderState() {
const timestamp = Date.now()
const maxTop = Math.max(0, scroll.scrollHeight - scroll.viewport.height)
const filename = `session-${route.sessionID.slice(0, 8)}-render-state-${timestamp}.json`
renderer.dumpBuffers(timestamp)
await writeExport(
path.join(process.cwd(), filename),
JSON.stringify(
{
capturedAt: timestamp,
channel: InstallationChannel,
version: InstallationVersion,
sessionID: route.sessionID,
scroll: {
top: scroll.scrollTop,
height: scroll.scrollHeight,
viewportHeight: scroll.viewport.height,
maxTop,
atBottom: scroll.scrollTop >= maxTop,
},
messages: messages().map((message) => ({
id: message.id,
role: message.role,
parts: (sync.data.part[message.id] ?? []).map((part) => {
const renderable = scroll.findDescendantById(`text-${part.id}`)
return {
id: part.id,
type: part.type,
...((part.type === "text" || part.type === "reasoning") && { textLength: part.text.length }),
...(renderable && {
renderable: {
id: renderable.id,
x: renderable.x,
y: renderable.y,
width: renderable.width,
height: renderable.height,
visible: renderable.visible,
},
}),
}
}),
})),
},
null,
2,
),
)
toast.show({ message: `Session render state written to ${filename}`, variant: "success" })
}
const local = useLocal()
function enterChild(sessionID: string) {
@@ -1015,6 +1067,23 @@ export function Session() {
dialog.clear()
},
},
...(SESSION_RENDER_STATE_CHANNELS.has(InstallationChannel)
? [
{
title: "Dump session render state",
value: "session.debug.dump_render_state",
category: "Debug",
run: async () => {
try {
await dumpRenderState()
} catch {
toast.show({ message: "Failed to dump session render state", variant: "error" })
}
dialog.clear()
},
},
]
: []),
{
title: "Background subagents",
value: "session.background",