Skip to content

Commit e1b8fbc

Browse files
author
Chris Scott
committed
Add /details and /export slash commands
1 parent fc65862 commit e1b8fbc

4 files changed

Lines changed: 188 additions & 6 deletions

File tree

‎frontend/src/components/message/PromptInput.tsx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ interface PromptInputProps {
3434
onShowSessionsDialog?: () => void
3535
onShowModelsDialog?: () => void
3636
onShowHelpDialog?: () => void
37+
onToggleDetails?: () => boolean
38+
onExportSession?: () => void
3739
}
3840

3941
export function PromptInput({
@@ -45,7 +47,9 @@ export function PromptInput({
4547
onScrollToBottom,
4648
onShowSessionsDialog,
4749
onShowModelsDialog,
48-
onShowHelpDialog
50+
onShowHelpDialog,
51+
onToggleDetails,
52+
onExportSession
4953
}: PromptInputProps) {
5054
const [prompt, setPrompt] = useState('')
5155
const [modelName, setModelName] = useState<string>('')
@@ -73,7 +77,9 @@ export function PromptInput({
7377
directory,
7478
onShowSessionsDialog,
7579
onShowModelsDialog,
76-
onShowHelpDialog
80+
onShowHelpDialog,
81+
onToggleDetails,
82+
onExportSession
7783
})
7884

7985
const { files: searchResults } = useFileSearch(

‎frontend/src/hooks/useCommandHandler.ts‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useState, useCallback } from 'react'
22
import { useNavigate } from 'react-router-dom'
33
import { createOpenCodeClient } from '@/api/opencode'
44
import { useCreateSession } from '@/hooks/useOpenCode'
5+
import { showToast } from '@/lib/toast'
56
import type { components } from '@/api/opencode-types'
67

78
type CommandType = components['schemas']['Command']
@@ -13,6 +14,8 @@ interface CommandHandlerProps {
1314
onShowSessionsDialog?: () => void
1415
onShowModelsDialog?: () => void
1516
onShowHelpDialog?: () => void
17+
onToggleDetails?: () => boolean
18+
onExportSession?: () => void
1619
}
1720

1821
export function useCommandHandler({
@@ -21,7 +24,9 @@ export function useCommandHandler({
2124
directory,
2225
onShowSessionsDialog,
2326
onShowModelsDialog,
24-
onShowHelpDialog
27+
onShowHelpDialog,
28+
onToggleDetails,
29+
onExportSession
2530
}: CommandHandlerProps) {
2631
const navigate = useNavigate()
2732
const createSession = useCreateSession(opcodeUrl, directory)
@@ -79,14 +84,25 @@ export function useCommandHandler({
7984
}
8085
break
8186

87+
case 'details':
88+
if (onToggleDetails) {
89+
const expanded = onToggleDetails()
90+
showToast.success(expanded ? 'Tool details expanded' : 'Tool details collapsed')
91+
}
92+
break
93+
94+
case 'export':
95+
if (onExportSession) {
96+
onExportSession()
97+
}
98+
break
99+
82100
case 'share':
83101
case 'unshare':
84-
case 'export':
85102
case 'compact':
86103
case 'summarize':
87104
case 'undo':
88105
case 'redo':
89-
case 'details':
90106
case 'editor':
91107
case 'init':
92108
await client.sendCommand(sessionID, {
@@ -106,7 +122,7 @@ export function useCommandHandler({
106122
} finally {
107123
setLoading(false)
108124
}
109-
}, [sessionID, opcodeUrl, onShowSessionsDialog, onShowModelsDialog, onShowHelpDialog, createSession, navigate])
125+
}, [sessionID, opcodeUrl, directory, onShowSessionsDialog, onShowModelsDialog, onShowHelpDialog, onToggleDetails, onExportSession, createSession, navigate])
110126

111127
return {
112128
executeCommand,

‎frontend/src/lib/exportSession.ts‎

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import type { MessageWithParts, Session, Part } from '@/api/types'
2+
3+
function formatDate(timestamp: number): string {
4+
return new Date(timestamp).toLocaleString()
5+
}
6+
7+
function sanitizeFilename(name: string): string {
8+
return name
9+
.replace(/[^a-zA-Z0-9-_\s]/g, '')
10+
.replace(/\s+/g, '-')
11+
.toLowerCase()
12+
.slice(0, 50)
13+
}
14+
15+
function formatPartContent(part: Part): string {
16+
switch (part.type) {
17+
case 'text':
18+
return part.text || ''
19+
case 'tool':
20+
return formatToolPart(part)
21+
case 'reasoning':
22+
return `<details>\n<summary>Reasoning</summary>\n\n${part.text || ''}\n\n</details>\n`
23+
case 'patch':
24+
return formatPatchPart(part)
25+
case 'file':
26+
return `*Attached file: ${part.filename || 'unknown'}*`
27+
case 'step-finish':
28+
return `*Cost: $${part.cost?.toFixed(4) || '0'} | Tokens: ${(part.tokens?.input || 0) + (part.tokens?.output || 0)}*`
29+
case 'snapshot':
30+
return `*Snapshot: ${part.snapshot || 'unknown'}*`
31+
case 'agent':
32+
return `**Agent: ${part.name || 'unknown'}**`
33+
default:
34+
return ''
35+
}
36+
}
37+
38+
function formatToolPart(part: Part): string {
39+
if (part.type !== 'tool') return ''
40+
41+
const toolName = part.tool || 'Unknown Tool'
42+
const state = part.state
43+
const status = state?.status || 'unknown'
44+
45+
let content = `### Tool: ${toolName}\n`
46+
content += `*Status: ${status}*\n\n`
47+
48+
if (state && 'input' in state && state.input) {
49+
content += '**Input:**\n```json\n'
50+
content += JSON.stringify(state.input, null, 2)
51+
content += '\n```\n\n'
52+
}
53+
54+
if (state && 'output' in state && state.output) {
55+
content += '**Output:**\n```\n'
56+
content += state.output
57+
content += '\n```\n'
58+
}
59+
60+
if (state && 'error' in state && state.error) {
61+
content += '**Error:**\n```\n'
62+
content += state.error
63+
content += '\n```\n'
64+
}
65+
66+
return content
67+
}
68+
69+
function formatPatchPart(part: Part): string {
70+
if (part.type !== 'patch') return ''
71+
72+
let content = `### File Changes\n`
73+
74+
if (part.files && part.files.length > 0) {
75+
content += `*Files: ${part.files.join(', ')}*\n\n`
76+
}
77+
78+
return content
79+
}
80+
81+
export function generateSessionMarkdown(
82+
messages: MessageWithParts[],
83+
session: Session
84+
): string {
85+
const lines: string[] = []
86+
87+
lines.push(`# ${session.title || 'Untitled Session'}`)
88+
lines.push('')
89+
lines.push(`**Session ID:** ${session.id}`)
90+
lines.push(`**Created:** ${formatDate(session.time.created)}`)
91+
if (session.share?.url) {
92+
lines.push(`**Shared:** [View](${session.share.url})`)
93+
}
94+
lines.push('')
95+
lines.push('---')
96+
lines.push('')
97+
98+
for (const message of messages) {
99+
const role = message.info.role === 'user' ? 'User' : 'Assistant'
100+
lines.push(`## ${role}`)
101+
lines.push('')
102+
103+
for (const part of message.parts) {
104+
const content = formatPartContent(part)
105+
if (content) {
106+
lines.push(content)
107+
lines.push('')
108+
}
109+
}
110+
111+
lines.push('---')
112+
lines.push('')
113+
}
114+
115+
return lines.join('\n')
116+
}
117+
118+
export function downloadMarkdown(content: string, filename: string): void {
119+
const blob = new Blob([content], { type: 'text/markdown' })
120+
const url = URL.createObjectURL(blob)
121+
const link = document.createElement('a')
122+
link.href = url
123+
link.download = filename
124+
document.body.appendChild(link)
125+
link.click()
126+
document.body.removeChild(link)
127+
URL.revokeObjectURL(url)
128+
}
129+
130+
export function exportSession(
131+
messages: MessageWithParts[],
132+
session: Session
133+
): { filename: string; content: string } {
134+
const markdown = generateSessionMarkdown(messages, session)
135+
const titlePart = session.title ? sanitizeFilename(session.title) : 'session'
136+
const filename = `${titlePart}-${session.id}.md`
137+
138+
return { filename, content: markdown }
139+
}

‎frontend/src/pages/SessionDetail.tsx‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import { usePermissionRequests } from "@/hooks/usePermissionRequests";
2121
import { useAutoScroll } from "@/hooks/useAutoScroll";
2222
import { useEffect, useRef, useCallback } from "react";
2323
import { MessageSkeleton } from "@/components/message/MessageSkeleton";
24+
import { exportSession, downloadMarkdown } from "@/lib/exportSession";
25+
import { showToast } from "@/lib/toast";
2426
import type { PermissionResponse } from "@/api/types";
2527

2628
export function SessionDetail() {
@@ -135,6 +137,23 @@ export function SessionDetail() {
135137
await openCodeClient.respondToPermission(permissionSessionID, permissionID, response)
136138
}, [openCodeClient]);
137139

140+
const handleToggleDetails = useCallback(() => {
141+
const newValue = !preferences?.expandToolCalls
142+
updateSettings({ expandToolCalls: newValue })
143+
return newValue
144+
}, [preferences?.expandToolCalls, updateSettings]);
145+
146+
const handleExportSession = useCallback(() => {
147+
if (!messages || !session) {
148+
showToast.error('No session data to export')
149+
return
150+
}
151+
152+
const { filename, content } = exportSession(messages, session)
153+
downloadMarkdown(content, filename)
154+
showToast.success(`Exported to ${filename}`)
155+
}, [messages, session]);
156+
138157

139158

140159
if (!sessionId) {
@@ -200,6 +219,8 @@ export function SessionDetail() {
200219
onShowHelpDialog={() => {
201220
openSettings()
202221
}}
222+
onToggleDetails={handleToggleDetails}
223+
onExportSession={handleExportSession}
203224
/>
204225
</div>
205226
)}

0 commit comments

Comments
 (0)