feat: Add specialized tool cards for transcript-debug and glassmorphism bubbles

Add toolCards/ with rich visual cards for 10 tool types:
- AskUserQuestion, ExitPlanMode, EnterPlanMode
- Read, Write, Bash, Edit
- Grep, Glob
- Task/TaskCreate/TaskUpdate/TaskGet/TaskList (unified TaskCard)

Add MarkdownContent component and markdown/syntax highlight utils.
Make user/assistant bubbles transparent with backdrop blur.
This commit is contained in:
2026-02-19 02:45:53 -06:00
parent 159a38e3c2
commit 4ab1d03370
17 changed files with 2925 additions and 18 deletions

View File

@@ -3,6 +3,19 @@ import { computed } from 'vue'
import type { ParsedAssistantMessage } from '@/types/transcript-debug' import type { ParsedAssistantMessage } from '@/types/transcript-debug'
import ThinkingBlock from './ThinkingBlock.vue' import ThinkingBlock from './ThinkingBlock.vue'
import ToolCallBlock from './ToolCallBlock.vue' import ToolCallBlock from './ToolCallBlock.vue'
import MarkdownContent from './MarkdownContent.vue'
import AskUserQuestionCard from './toolCards/AskUserQuestionCard.vue'
import ExitPlanModeCard from './toolCards/ExitPlanModeCard.vue'
import EnterPlanModeCard from './toolCards/EnterPlanModeCard.vue'
import ReadCard from './toolCards/ReadCard.vue'
import WriteCard from './toolCards/WriteCard.vue'
import BashCard from './toolCards/BashCard.vue'
import EditCard from './toolCards/EditCard.vue'
import GrepCard from './toolCards/GrepCard.vue'
import GlobCard from './toolCards/GlobCard.vue'
import TaskCard from './toolCards/TaskCard.vue'
const TASK_TOOLS = new Set(['Task', 'TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList'])
const props = defineProps<{ const props = defineProps<{
message: ParsedAssistantMessage message: ParsedAssistantMessage
@@ -62,24 +75,36 @@ function formatTokens(n?: number): string {
:content="t" :content="t"
/> />
<!-- Text content (filtered) --> <!-- Text content (filtered, markdown-rendered) -->
<div v-for="(text, i) in visibleTextBlocks" :key="'text-' + i" class="text-content"> <MarkdownContent
{{ text }} v-for="(text, i) in visibleTextBlocks"
</div> :key="'text-' + i"
:content="text"
/>
<!-- Tool calls --> <!-- Tool calls -->
<ToolCallBlock <template v-for="tc in message.toolCalls" :key="tc.id">
v-for="tc in message.toolCalls" <AskUserQuestionCard v-if="tc.name === 'AskUserQuestion'" :call="tc" />
:key="tc.id" <ExitPlanModeCard v-else-if="tc.name === 'ExitPlanMode'" :call="tc" />
:call="tc" <EnterPlanModeCard v-else-if="tc.name === 'EnterPlanMode'" :call="tc" />
/> <ReadCard v-else-if="tc.name === 'Read'" :call="tc" />
<WriteCard v-else-if="tc.name === 'Write'" :call="tc" />
<BashCard v-else-if="tc.name === 'Bash'" :call="tc" />
<EditCard v-else-if="tc.name === 'Edit'" :call="tc" />
<GrepCard v-else-if="tc.name === 'Grep'" :call="tc" />
<GlobCard v-else-if="tc.name === 'Glob'" :call="tc" />
<TaskCard v-else-if="TASK_TOOLS.has(tc.name)" :call="tc" />
<ToolCallBlock v-else :call="tc" />
</template>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.assistant-bubble { .assistant-bubble {
background: rgba(34, 197, 94, 0.05); background: rgba(34, 197, 94, 0.03);
border: 1px solid rgba(34, 197, 94, 0.15); backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
border: 1px solid rgba(34, 197, 94, 0.1);
border-radius: 12px; border-radius: 12px;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
margin-right: 2rem; margin-right: 2rem;

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { parseMarkdown, ensureStyles } from '@/utils/markdown'
const props = defineProps<{
content: string
}>()
const html = computed(() => parseMarkdown(props.content))
onMounted(ensureStyles)
</script>
<template>
<div class="md-content" v-html="html"></div>
</template>
<style scoped>
.md-content {
font-size: 13px;
line-height: 1.6;
color: var(--text-primary);
word-break: break-word;
}
</style>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import MarkdownContent from './MarkdownContent.vue'
defineProps<{ defineProps<{
content: string content: string
@@ -21,7 +22,9 @@ const expanded = ref(false)
<span class="thinking-label">Thinking</span> <span class="thinking-label">Thinking</span>
<span class="thinking-length">{{ content.length }} chars</span> <span class="thinking-length">{{ content.length }} chars</span>
</button> </button>
<div v-if="expanded" class="thinking-content">{{ content }}</div> <div v-if="expanded" class="thinking-content">
<MarkdownContent :content="content" />
</div>
</div> </div>
</template> </template>

View File

@@ -2,6 +2,7 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { ParsedToolCall, ParsedProgressEvent } from '@/types/transcript-debug' import type { ParsedToolCall, ParsedProgressEvent } from '@/types/transcript-debug'
import ToolResultBlock from './ToolResultBlock.vue' import ToolResultBlock from './ToolResultBlock.vue'
import { highlightCode } from '@/utils/markdown'
const props = defineProps<{ const props = defineProps<{
call: ParsedToolCall call: ParsedToolCall
@@ -28,6 +29,10 @@ function hookLabel(e: ParsedProgressEvent): string {
const event = e.hookEvent || e.hookName.split(':')[0] const event = e.hookEvent || e.hookName.split(':')[0]
return event return event
} }
const highlightedInput = computed(() =>
highlightCode(JSON.stringify(props.call.input, null, 2), 'json')
)
</script> </script>
<template> <template>
@@ -101,7 +106,7 @@ function hookLabel(e: ParsedProgressEvent): string {
</svg> </svg>
<span>Input</span> <span>Input</span>
</button> </button>
<pre v-if="inputExpanded" class="input-json">{{ JSON.stringify(call.input, null, 2) }}</pre> <pre v-if="inputExpanded" class="input-json" v-html="highlightedInput"></pre>
</div> </div>
<!-- Tool result --> <!-- Tool result -->

View File

@@ -1,12 +1,29 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, computed } from 'vue'
import type { ParsedToolResult } from '@/types/transcript-debug' import type { ParsedToolResult } from '@/types/transcript-debug'
import { highlightCode } from '@/utils/markdown'
defineProps<{ const props = defineProps<{
result: ParsedToolResult result: ParsedToolResult
}>() }>()
const expanded = ref(false) const expanded = ref(false)
const highlightedContent = computed(() => {
const content = props.result.content
// Detect JSON
const trimmed = content.trim()
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'))) {
try {
// Re-format + highlight
const parsed = JSON.parse(trimmed)
return highlightCode(JSON.stringify(parsed, null, 2), 'json')
} catch { /* not valid JSON */ }
}
// Otherwise highlight as generic text
return highlightCode(content)
})
</script> </script>
<template> <template>
@@ -25,7 +42,7 @@ const expanded = ref(false)
<span class="result-label">Result</span> <span class="result-label">Result</span>
<span class="result-size">{{ result.content.length }} chars</span> <span class="result-size">{{ result.content.length }} chars</span>
</button> </button>
<pre v-if="expanded" class="result-content">{{ result.content }}</pre> <pre v-if="expanded" class="result-content" v-html="highlightedContent"></pre>
</div> </div>
</template> </template>

View File

@@ -33,8 +33,10 @@ function formatTime(ts: string): string {
<style scoped> <style scoped>
.user-bubble { .user-bubble {
background: rgba(99, 102, 241, 0.08); background: rgba(99, 102, 241, 0.04);
border: 1px solid rgba(99, 102, 241, 0.2); backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
border: 1px solid rgba(99, 102, 241, 0.12);
border-radius: 12px; border-radius: 12px;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
margin-left: 2rem; margin-left: 2rem;

View File

@@ -0,0 +1,372 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ParsedToolCall } from '@/types/transcript-debug'
const props = defineProps<{
call: ParsedToolCall
}>()
interface QuestionOption {
label: string
description?: string
}
interface QuestionItem {
question: string
header?: string
options?: QuestionOption[]
multiSelect?: boolean
}
const questions = computed<QuestionItem[]>(() => {
const qs = props.call.input?.questions
if (Array.isArray(qs)) return qs as QuestionItem[]
return []
})
// Parse the answer from the result
const answer = computed<Record<string, string>>(() => {
if (!props.call.result?.content) return {}
const raw = props.call.result.content
// Try to extract "answered: X" pattern from result text
// Format: 'User has answered your questions: "question"="answer"'
const answerMap: Record<string, string> = {}
const regex = /"([^"]+)"="([^"]+)"/g
let match
while ((match = regex.exec(raw)) !== null) {
answerMap[match[1]] = match[2]
}
// Also try JSON format { answers: { ... } }
try {
const parsed = JSON.parse(raw)
if (parsed?.answers) {
for (const [k, v] of Object.entries(parsed.answers)) {
answerMap[k] = String(v)
}
}
} catch { /* not JSON */ }
return answerMap
})
// Check if an option was selected for a given question
function isSelected(questionText: string, optionLabel: string): boolean {
const ans = answer.value[questionText]
if (!ans) return false
return ans.split(', ').includes(optionLabel)
}
// Get user notes from result (text after "user notes:")
const userNotes = computed<string>(() => {
if (!props.call.result?.content) return ''
const match = props.call.result.content.match(/user notes:\s*(.+?)\.?\s*(?:You can|$)/i)
return match ? match[1].trim() : ''
})
const isError = computed(() => props.call.result?.isError ?? false)
</script>
<template>
<div :class="['question-card', { error: isError }]">
<div class="card-header">
<span class="card-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
</span>
<span class="card-label">AskUserQuestion</span>
<span v-if="isError" class="error-badge">error</span>
</div>
<div class="card-body">
<div v-for="(q, qi) in questions" :key="qi" class="question-block">
<div class="question-text">
<span v-if="q.header" class="question-tag">{{ q.header }}</span>
{{ q.question }}
<span v-if="q.multiSelect" class="multi-hint">(multiple)</span>
</div>
<div v-if="q.options?.length" class="options-grid">
<div
v-for="opt in q.options"
:key="opt.label"
:class="['option-item', { selected: isSelected(q.question, opt.label) }]"
>
<span class="option-check">
<svg v-if="isSelected(q.question, opt.label)" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<polyline points="20 6 9 17 4 12"/>
</svg>
</span>
<div class="option-content">
<span class="option-label">{{ opt.label }}</span>
<span v-if="opt.description" class="option-desc">{{ opt.description }}</span>
</div>
</div>
</div>
<!-- Show answer if it's custom text (not matching any option) -->
<div v-if="answer[q.question] && !q.options?.some(o => isSelected(q.question, o.label))" class="custom-answer">
<span class="answer-label">Answer:</span>
<span class="answer-text">{{ answer[q.question] }}</span>
</div>
</div>
<!-- User notes -->
<div v-if="userNotes" class="user-notes">
<span class="notes-label">Notes:</span>
<span class="notes-text">{{ userNotes }}</span>
</div>
</div>
<!-- Result status -->
<div v-if="call.result" :class="['card-footer', { error: isError }]">
<span class="result-icon">{{ isError ? '✗' : '✓' }}</span>
<span class="result-label">{{ isError ? 'Error' : 'Answered' }}</span>
</div>
</div>
</template>
<style scoped>
.question-card {
border: 1px solid rgba(14, 165, 233, 0.25);
border-left: 3px solid #0ea5e9;
border-radius: 8px;
overflow: hidden;
margin: 0.5rem 0;
background: var(--bg-primary);
}
.question-card.error {
border-color: rgba(239, 68, 68, 0.25);
border-left-color: #ef4444;
}
.card-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.75rem;
background: rgba(14, 165, 233, 0.06);
border-bottom: 1px solid rgba(14, 165, 233, 0.12);
}
.question-card.error .card-header {
background: rgba(239, 68, 68, 0.06);
border-bottom-color: rgba(239, 68, 68, 0.12);
}
.card-icon {
display: flex;
align-items: center;
color: #0ea5e9;
}
.question-card.error .card-icon {
color: #ef4444;
}
.card-label {
font-size: 11px;
font-weight: 600;
color: #0ea5e9;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.question-card.error .card-label {
color: #ef4444;
}
.error-badge {
font-size: 10px;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
font-weight: 500;
}
.card-body {
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.question-block {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.question-block + .question-block {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border-color);
}
.question-text {
font-size: 13px;
color: var(--text-primary);
font-weight: 500;
line-height: 1.4;
}
.question-tag {
display: inline-block;
background: rgba(14, 165, 233, 0.12);
color: #0ea5e9;
font-size: 10px;
font-weight: 600;
padding: 0.1rem 0.35rem;
border-radius: 4px;
margin-right: 0.3rem;
vertical-align: middle;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.multi-hint {
font-size: 11px;
color: var(--text-muted);
font-weight: 400;
}
.options-grid {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.option-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.4rem 0.6rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
transition: all 0.15s;
}
.option-item.selected {
border-color: #0ea5e9;
background: rgba(14, 165, 233, 0.08);
}
.option-check {
width: 16px;
height: 16px;
min-width: 16px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 1px;
border: 1.5px solid var(--border-color);
border-radius: 4px;
color: transparent;
}
.option-item.selected .option-check {
background: #0ea5e9;
border-color: #0ea5e9;
color: white;
}
.option-content {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.option-label {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.option-desc {
font-size: 11px;
color: var(--text-muted);
line-height: 1.3;
}
.custom-answer {
display: flex;
align-items: baseline;
gap: 0.4rem;
padding: 0.4rem 0.6rem;
background: rgba(14, 165, 233, 0.06);
border: 1px solid rgba(14, 165, 233, 0.15);
border-radius: 6px;
}
.answer-label {
font-size: 11px;
font-weight: 600;
color: #0ea5e9;
white-space: nowrap;
}
.answer-text {
font-size: 12px;
color: var(--text-primary);
font-style: italic;
}
.user-notes {
display: flex;
align-items: baseline;
gap: 0.4rem;
padding: 0.35rem 0.6rem;
background: rgba(251, 191, 36, 0.06);
border: 1px solid rgba(251, 191, 36, 0.15);
border-radius: 6px;
}
.notes-label {
font-size: 11px;
font-weight: 600;
color: #fbbf24;
white-space: nowrap;
}
.notes-text {
font-size: 12px;
color: var(--text-secondary);
font-style: italic;
}
.card-footer {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.75rem;
border-top: 1px solid rgba(14, 165, 233, 0.12);
background: rgba(34, 197, 94, 0.04);
font-size: 11px;
}
.card-footer.error {
background: rgba(239, 68, 68, 0.04);
border-top-color: rgba(239, 68, 68, 0.12);
}
.result-icon {
font-weight: 600;
color: #22c55e;
}
.card-footer.error .result-icon {
color: #ef4444;
}
.result-label {
font-weight: 500;
color: var(--text-muted);
}
</style>

View File

@@ -0,0 +1,164 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { ParsedToolCall } from '@/types/transcript-debug'
import { highlightCode } from '@/utils/markdown'
import ToolResultBlock from '../ToolResultBlock.vue'
const props = defineProps<{
call: ParsedToolCall
}>()
const command = computed(() => (props.call.input?.command as string) || '')
const description = computed(() => (props.call.input?.description as string) || '')
const timeout = computed(() => props.call.input?.timeout as number | undefined)
const runInBackground = computed(() => props.call.input?.run_in_background as boolean | undefined)
const isError = computed(() => props.call.result?.isError ?? false)
const highlightedCommand = computed(() => highlightCode(command.value, 'bash'))
const outputExpanded = ref(false)
</script>
<template>
<div :class="['bash-card', { error: isError }]">
<div class="card-header">
<span class="card-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="4 17 10 11 4 5"/>
<line x1="12" y1="19" x2="20" y2="19"/>
</svg>
</span>
<span class="card-label">Bash</span>
<span v-if="runInBackground" class="bg-badge">background</span>
<span v-if="timeout" class="timeout-badge">{{ timeout }}ms</span>
<span v-if="isError" class="error-badge">error</span>
</div>
<!-- Description -->
<div v-if="description" class="description">{{ description }}</div>
<!-- Command -->
<div class="command-section">
<div class="command-prompt">$</div>
<pre class="command-text" v-html="highlightedCommand"></pre>
</div>
<!-- Result -->
<ToolResultBlock v-if="call.result" :result="call.result" />
</div>
</template>
<style scoped>
.bash-card {
border: 1px solid rgba(245, 158, 11, 0.25);
border-left: 3px solid #f59e0b;
border-radius: 8px;
overflow: hidden;
margin: 0.5rem 0;
background: var(--bg-primary);
}
.bash-card.error {
border-color: rgba(239, 68, 68, 0.25);
border-left-color: #ef4444;
}
.card-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.75rem;
background: rgba(245, 158, 11, 0.06);
border-bottom: 1px solid rgba(245, 158, 11, 0.12);
}
.bash-card.error .card-header {
background: rgba(239, 68, 68, 0.06);
border-bottom-color: rgba(239, 68, 68, 0.12);
}
.card-icon {
display: flex;
align-items: center;
color: #f59e0b;
}
.bash-card.error .card-icon { color: #ef4444; }
.card-label {
font-size: 11px;
font-weight: 600;
color: #f59e0b;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.bash-card.error .card-label { color: #ef4444; }
.bg-badge {
font-size: 10px;
padding: 0.1rem 0.35rem;
border-radius: 4px;
background: rgba(99, 102, 241, 0.12);
color: #818cf8;
font-weight: 500;
}
.timeout-badge {
font-size: 10px;
padding: 0.1rem 0.35rem;
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
margin-left: auto;
}
.error-badge {
font-size: 10px;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
font-weight: 500;
}
.description {
padding: 0.4rem 0.75rem;
font-size: 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
font-style: italic;
}
.command-section {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: rgba(0, 0, 0, 0.15);
border-bottom: 1px solid var(--border-color);
}
.command-prompt {
color: #f59e0b;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 12px;
font-weight: 700;
line-height: 1.5;
flex-shrink: 0;
user-select: none;
}
.command-text {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-all;
font-family: 'SF Mono', 'Fira Code', monospace;
flex: 1;
}
</style>

View File

@@ -0,0 +1,286 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { ParsedToolCall } from '@/types/transcript-debug'
import { highlightCode } from '@/utils/markdown'
import ToolResultBlock from '../ToolResultBlock.vue'
const props = defineProps<{
call: ParsedToolCall
}>()
const filePath = computed(() => (props.call.input?.file_path as string) || '')
const fileName = computed(() => {
const fp = filePath.value
if (!fp) return ''
const parts = fp.replace(/\\/g, '/').split('/')
return parts[parts.length - 1]
})
const oldString = computed(() => (props.call.input?.old_string as string) || '')
const newString = computed(() => (props.call.input?.new_string as string) || '')
const replaceAll = computed(() => props.call.input?.replace_all as boolean | undefined)
const ext = computed(() => {
const name = fileName.value
const dot = name.lastIndexOf('.')
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : ''
})
const isError = computed(() => props.call.result?.isError ?? false)
const diffExpanded = ref(true)
const highlightedOld = computed(() => highlightCode(oldString.value, ext.value || undefined))
const highlightedNew = computed(() => highlightCode(newString.value, ext.value || undefined))
const oldLineCount = computed(() => oldString.value.split('\n').length)
const newLineCount = computed(() => newString.value.split('\n').length)
</script>
<template>
<div :class="['edit-card', { error: isError }]">
<div class="card-header">
<span class="card-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</span>
<span class="card-label">Edit</span>
<span v-if="ext" class="ext-badge">.{{ ext }}</span>
<span v-if="replaceAll" class="replace-all-badge">replace all</span>
<span v-if="isError" class="error-badge">error</span>
</div>
<div class="card-body">
<div class="file-path" :title="filePath">
<span class="path-dir">{{ filePath.replace(/\\/g, '/').split('/').slice(0, -1).join('/') }}/</span>
<span class="path-file">{{ fileName }}</span>
</div>
</div>
<!-- Diff view -->
<div class="diff-section">
<button class="diff-toggle" @click="diffExpanded = !diffExpanded">
<svg
:class="['chevron', { rotated: diffExpanded }]"
width="10" height="10" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span>Changes</span>
<span class="diff-meta">-{{ oldLineCount }} +{{ newLineCount }} lines</span>
</button>
<div v-if="diffExpanded" class="diff-content">
<!-- Old string (removed) -->
<div class="diff-block removed">
<div class="diff-label">
<span class="diff-sign">-</span>
<span>old</span>
</div>
<pre class="diff-code" v-html="highlightedOld"></pre>
</div>
<!-- New string (added) -->
<div class="diff-block added">
<div class="diff-label">
<span class="diff-sign">+</span>
<span>new</span>
</div>
<pre class="diff-code" v-html="highlightedNew"></pre>
</div>
</div>
</div>
<!-- Result -->
<ToolResultBlock v-if="call.result" :result="call.result" />
</div>
</template>
<style scoped>
.edit-card {
border: 1px solid rgba(99, 102, 241, 0.25);
border-left: 3px solid #6366f1;
border-radius: 8px;
overflow: hidden;
margin: 0.5rem 0;
background: var(--bg-primary);
}
.edit-card.error {
border-color: rgba(239, 68, 68, 0.25);
border-left-color: #ef4444;
}
.card-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.75rem;
background: rgba(99, 102, 241, 0.06);
border-bottom: 1px solid rgba(99, 102, 241, 0.12);
}
.edit-card.error .card-header {
background: rgba(239, 68, 68, 0.06);
border-bottom-color: rgba(239, 68, 68, 0.12);
}
.card-icon {
display: flex;
align-items: center;
color: #6366f1;
}
.edit-card.error .card-icon { color: #ef4444; }
.card-label {
font-size: 11px;
font-weight: 600;
color: #6366f1;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.edit-card.error .card-label { color: #ef4444; }
.ext-badge {
font-size: 10px;
padding: 0.1rem 0.35rem;
border-radius: 4px;
background: rgba(99, 102, 241, 0.12);
color: #818cf8;
font-family: 'SF Mono', 'Fira Code', monospace;
font-weight: 500;
}
.replace-all-badge {
font-size: 10px;
padding: 0.1rem 0.35rem;
border-radius: 4px;
background: rgba(251, 191, 36, 0.12);
color: #fbbf24;
font-weight: 500;
}
.error-badge {
font-size: 10px;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
font-weight: 500;
}
.card-body {
padding: 0.5rem 0.75rem;
}
.file-path {
font-size: 12px;
font-family: 'SF Mono', 'Fira Code', monospace;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.path-dir { color: var(--text-muted); }
.path-file { color: var(--text-primary); font-weight: 600; }
/* Diff section */
.diff-section {
border-top: 1px solid var(--border-color);
}
.diff-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.35rem 0.75rem;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-secondary);
font-size: 11px;
text-align: left;
}
.diff-toggle:hover { background: var(--bg-hover); }
.diff-meta {
margin-left: auto;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 10px;
}
.chevron {
transition: transform 0.2s;
flex-shrink: 0;
}
.chevron.rotated { transform: rotate(90deg); }
.diff-content {
border-top: 1px solid var(--border-color);
}
.diff-block {
border-bottom: 1px solid var(--border-color);
}
.diff-block:last-child {
border-bottom: none;
}
.diff-label {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.75rem;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.diff-block.removed .diff-label {
background: rgba(239, 68, 68, 0.06);
color: #ef4444;
}
.diff-block.added .diff-label {
background: rgba(34, 197, 94, 0.06);
color: #22c55e;
}
.diff-sign {
font-size: 14px;
font-weight: 700;
font-family: 'SF Mono', 'Fira Code', monospace;
}
.diff-code {
margin: 0;
padding: 0.5rem 0.75rem;
font-size: 11px;
line-height: 1.5;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
font-family: 'SF Mono', 'Fira Code', monospace;
}
.diff-block.removed .diff-code {
background: rgba(239, 68, 68, 0.03);
}
.diff-block.added .diff-code {
background: rgba(34, 197, 94, 0.03);
}
</style>

View File

@@ -0,0 +1,148 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ParsedToolCall } from '@/types/transcript-debug'
const props = defineProps<{
call: ParsedToolCall
}>()
const isApproved = computed(() => {
if (!props.call.result?.content) return false
return props.call.result.content.toLowerCase().includes('entered plan mode') ||
props.call.result.content.toLowerCase().includes('approved')
})
const isError = computed(() => props.call.result?.isError ?? false)
</script>
<template>
<div :class="['enter-plan-card', { error: isError }]">
<div class="card-header">
<span class="card-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
</span>
<span class="card-label">EnterPlanMode</span>
<span v-if="isApproved && !isError" class="status-badge approved">entered</span>
<span v-else-if="isError" class="status-badge error">error</span>
<span v-else-if="call.result" class="status-badge denied">denied</span>
</div>
<div v-if="call.result" :class="['card-footer', { approved: isApproved && !isError, error: isError }]">
<span class="result-icon">{{ isError ? '✗' : isApproved ? '→' : '⊘' }}</span>
<span class="result-text">{{ isApproved ? 'Entered plan mode' : isError ? 'Error' : 'Denied' }}</span>
</div>
</div>
</template>
<style scoped>
.enter-plan-card {
border: 1px solid rgba(251, 191, 36, 0.25);
border-left: 3px solid #fbbf24;
border-radius: 8px;
overflow: hidden;
margin: 0.5rem 0;
background: var(--bg-primary);
}
.enter-plan-card.error {
border-color: rgba(239, 68, 68, 0.25);
border-left-color: #ef4444;
}
.card-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.75rem;
background: rgba(251, 191, 36, 0.06);
}
.enter-plan-card.error .card-header {
background: rgba(239, 68, 68, 0.06);
}
.card-icon {
display: flex;
align-items: center;
color: #fbbf24;
}
.enter-plan-card.error .card-icon {
color: #ef4444;
}
.card-label {
font-size: 11px;
font-weight: 600;
color: #fbbf24;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.enter-plan-card.error .card-label {
color: #ef4444;
}
.status-badge {
font-size: 10px;
padding: 0.1rem 0.4rem;
border-radius: 4px;
font-weight: 500;
margin-left: auto;
}
.status-badge.approved {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.status-badge.denied {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.status-badge.error {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.card-footer {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.75rem;
border-top: 1px solid var(--border-color);
font-size: 11px;
background: rgba(251, 191, 36, 0.03);
}
.card-footer.approved {
background: rgba(34, 197, 94, 0.04);
}
.card-footer.error {
background: rgba(239, 68, 68, 0.04);
}
.result-icon {
font-weight: 600;
color: var(--text-muted);
}
.card-footer.approved .result-icon {
color: #22c55e;
}
.card-footer.error .result-icon {
color: #ef4444;
}
.result-text {
font-weight: 500;
color: var(--text-muted);
}
</style>

View File

@@ -0,0 +1,267 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { ParsedToolCall } from '@/types/transcript-debug'
import MarkdownContent from '../MarkdownContent.vue'
const props = defineProps<{
call: ParsedToolCall
}>()
const expanded = ref(false)
const planText = computed(() => {
if (!props.call.input) return ''
// ExitPlanMode may have plan in input or allowedPrompts
return (props.call.input.plan as string) || ''
})
const allowedPrompts = computed(() => {
const prompts = props.call.input?.allowedPrompts
if (!Array.isArray(prompts)) return []
return prompts as Array<{ tool: string; prompt: string }>
})
const isApproved = computed(() => {
if (!props.call.result?.content) return false
return props.call.result.content.toLowerCase().includes('approved') ||
props.call.result.content.toLowerCase().includes('allow')
})
const isError = computed(() => props.call.result?.isError ?? false)
</script>
<template>
<div :class="['plan-card', { error: isError }]">
<div class="card-header">
<span class="card-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
</span>
<span class="card-label">ExitPlanMode</span>
<span v-if="isApproved && !isError" class="approved-badge">approved</span>
<span v-if="isError" class="error-badge">error</span>
</div>
<div v-if="planText" class="card-body">
<button class="plan-toggle" @click="expanded = !expanded">
<svg
:class="['chevron', { rotated: expanded }]"
width="10" height="10" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span>Plan</span>
<span class="plan-size">{{ planText.length }} chars</span>
</button>
<div v-if="expanded" class="plan-content">
<MarkdownContent :content="planText" />
</div>
</div>
<!-- Allowed prompts -->
<div v-if="allowedPrompts.length" class="prompts-section">
<div class="prompts-title">Allowed Prompts</div>
<div v-for="(p, i) in allowedPrompts" :key="i" class="prompt-item">
<code class="prompt-tool">{{ p.tool }}</code>
<span class="prompt-text">{{ p.prompt }}</span>
</div>
</div>
<!-- Result status -->
<div v-if="call.result" :class="['card-footer', { error: isError, approved: isApproved && !isError }]">
<span class="result-icon">{{ isError ? '✗' : isApproved ? '✓' : '⊘' }}</span>
<span class="result-label">{{ isError ? 'Error' : isApproved ? 'Plan Approved' : 'Rejected' }}</span>
</div>
</div>
</template>
<style scoped>
.plan-card {
border: 1px solid rgba(139, 92, 246, 0.25);
border-left: 3px solid #8b5cf6;
border-radius: 8px;
overflow: hidden;
margin: 0.5rem 0;
background: var(--bg-primary);
}
.plan-card.error {
border-color: rgba(239, 68, 68, 0.25);
border-left-color: #ef4444;
}
.card-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.75rem;
background: rgba(139, 92, 246, 0.06);
border-bottom: 1px solid rgba(139, 92, 246, 0.12);
}
.plan-card.error .card-header {
background: rgba(239, 68, 68, 0.06);
border-bottom-color: rgba(239, 68, 68, 0.12);
}
.card-icon {
display: flex;
align-items: center;
color: #8b5cf6;
}
.plan-card.error .card-icon {
color: #ef4444;
}
.card-label {
font-size: 11px;
font-weight: 600;
color: #8b5cf6;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.plan-card.error .card-label {
color: #ef4444;
}
.approved-badge {
font-size: 10px;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
font-weight: 500;
}
.error-badge {
font-size: 10px;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
font-weight: 500;
}
.card-body {
border-bottom: 1px solid var(--border-color);
}
.plan-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.35rem 0.75rem;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-secondary);
font-size: 11px;
text-align: left;
}
.plan-toggle:hover {
background: var(--bg-hover);
}
.plan-size {
margin-left: auto;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 10px;
}
.chevron {
transition: transform 0.2s;
flex-shrink: 0;
}
.chevron.rotated {
transform: rotate(90deg);
}
.plan-content {
padding: 0.6rem 0.75rem;
max-height: 300px;
overflow-y: auto;
border-top: 1px solid var(--border-color);
background: var(--bg-primary);
}
.prompts-section {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border-color);
}
.prompts-title {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 0.3rem;
}
.prompt-item {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.2rem 0;
font-size: 11px;
}
.prompt-tool {
font-size: 10px;
padding: 0.1rem 0.3rem;
border-radius: 3px;
background: rgba(139, 92, 246, 0.1);
color: #8b5cf6;
font-family: 'SF Mono', 'Fira Code', monospace;
white-space: nowrap;
}
.prompt-text {
color: var(--text-secondary);
}
.card-footer {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.75rem;
font-size: 11px;
background: rgba(239, 68, 68, 0.04);
border-top: 1px solid var(--border-color);
}
.card-footer.approved {
background: rgba(34, 197, 94, 0.04);
}
.card-footer.error {
background: rgba(239, 68, 68, 0.04);
}
.result-icon {
font-weight: 600;
color: var(--text-muted);
}
.card-footer.approved .result-icon {
color: #22c55e;
}
.card-footer.error .result-icon {
color: #ef4444;
}
.result-label {
font-weight: 500;
color: var(--text-muted);
}
</style>

View File

@@ -0,0 +1,170 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ParsedToolCall } from '@/types/transcript-debug'
import ToolResultBlock from '../ToolResultBlock.vue'
const props = defineProps<{
call: ParsedToolCall
}>()
const pattern = computed(() => (props.call.input?.pattern as string) || '')
const path = computed(() => (props.call.input?.path as string) || '')
const isError = computed(() => props.call.result?.isError ?? false)
// Count files from result
const fileCount = computed(() => {
if (!props.call.result?.content) return null
const content = props.call.result.content.trim()
if (!content) return 0
return content.split('\n').filter(l => l.trim()).length
})
</script>
<template>
<div :class="['glob-card', { error: isError }]">
<div class="card-header">
<span class="card-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</span>
<span class="card-label">Glob</span>
<span v-if="fileCount != null && !isError" class="match-count">{{ fileCount }} files</span>
<span v-if="isError" class="error-badge">error</span>
</div>
<div class="card-body">
<!-- Pattern -->
<div class="pattern-row">
<code class="pattern-text">{{ pattern }}</code>
</div>
<!-- Path scope -->
<div v-if="path" class="scope-row">
<span class="scope-badge">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
{{ path.replace(/\\/g, '/').split('/').slice(-3).join('/') }}
</span>
</div>
</div>
<!-- Result -->
<ToolResultBlock v-if="call.result" :result="call.result" />
</div>
</template>
<style scoped>
.glob-card {
border: 1px solid rgba(251, 191, 36, 0.25);
border-left: 3px solid #fbbf24;
border-radius: 8px;
overflow: hidden;
margin: 0.5rem 0;
background: var(--bg-primary);
}
.glob-card.error {
border-color: rgba(239, 68, 68, 0.25);
border-left-color: #ef4444;
}
.card-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.75rem;
background: rgba(251, 191, 36, 0.06);
border-bottom: 1px solid rgba(251, 191, 36, 0.12);
}
.glob-card.error .card-header {
background: rgba(239, 68, 68, 0.06);
border-bottom-color: rgba(239, 68, 68, 0.12);
}
.card-icon {
display: flex;
align-items: center;
color: #fbbf24;
}
.glob-card.error .card-icon { color: #ef4444; }
.card-label {
font-size: 11px;
font-weight: 600;
color: #fbbf24;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.glob-card.error .card-label { color: #ef4444; }
.match-count {
margin-left: auto;
font-size: 10px;
padding: 0.1rem 0.35rem;
border-radius: 4px;
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
font-family: 'SF Mono', 'Fira Code', monospace;
font-weight: 500;
}
.error-badge {
font-size: 10px;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
font-weight: 500;
}
.card-body {
padding: 0.5rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.pattern-row {
display: flex;
align-items: center;
}
.pattern-text {
font-size: 12px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: #fbbf24;
background: rgba(251, 191, 36, 0.06);
padding: 0.2rem 0.5rem;
border-radius: 4px;
border: 1px solid rgba(251, 191, 36, 0.12);
word-break: break-all;
}
.scope-row {
display: flex;
align-items: center;
gap: 0.35rem;
}
.scope-badge {
display: inline-flex;
align-items: center;
gap: 0.2rem;
font-size: 10px;
padding: 0.1rem 0.35rem;
border-radius: 4px;
font-family: 'SF Mono', 'Fira Code', monospace;
background: rgba(6, 182, 212, 0.08);
color: #06b6d4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 280px;
}
</style>

View File

@@ -0,0 +1,228 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { ParsedToolCall } from '@/types/transcript-debug'
import ToolResultBlock from '../ToolResultBlock.vue'
const props = defineProps<{
call: ParsedToolCall
}>()
const pattern = computed(() => (props.call.input?.pattern as string) || '')
const path = computed(() => (props.call.input?.path as string) || '')
const glob = computed(() => (props.call.input?.glob as string) || '')
const type = computed(() => (props.call.input?.type as string) || '')
const outputMode = computed(() => (props.call.input?.output_mode as string) || 'files_with_matches')
const caseInsensitive = computed(() => props.call.input?.['-i'] as boolean | undefined)
const multiline = computed(() => props.call.input?.multiline as boolean | undefined)
const headLimit = computed(() => props.call.input?.head_limit as number | undefined)
const context = computed(() => {
const c = props.call.input?.context ?? props.call.input?.['-C']
const a = props.call.input?.['-A']
const b = props.call.input?.['-B']
if (c != null) return `C${c}`
if (a != null && b != null) return `B${b}/A${a}`
if (a != null) return `A${a}`
if (b != null) return `B${b}`
return ''
})
const isError = computed(() => props.call.result?.isError ?? false)
// Count matches from result
const matchCount = computed(() => {
if (!props.call.result?.content) return null
const content = props.call.result.content.trim()
if (!content) return 0
return content.split('\n').filter(l => l.trim()).length
})
const detailsExpanded = ref(false)
</script>
<template>
<div :class="['grep-card', { error: isError }]">
<div class="card-header">
<span class="card-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
</span>
<span class="card-label">Grep</span>
<span class="mode-badge">{{ outputMode }}</span>
<span v-if="matchCount != null && !isError" class="match-count">{{ matchCount }} matches</span>
<span v-if="isError" class="error-badge">error</span>
</div>
<div class="card-body">
<!-- Pattern -->
<div class="pattern-row">
<code class="pattern-text">/{{ pattern }}/{{ caseInsensitive ? 'i' : '' }}{{ multiline ? 'm' : '' }}</code>
</div>
<!-- Search scope -->
<div class="scope-row">
<span v-if="path" class="scope-badge path" :title="path">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
{{ path.replace(/\\/g, '/').split('/').slice(-2).join('/') }}
</span>
<span v-if="glob" class="scope-badge glob">{{ glob }}</span>
<span v-if="type" class="scope-badge type">{{ type }}</span>
<span v-if="context" class="scope-badge ctx">{{ context }}</span>
<span v-if="headLimit" class="scope-badge limit">head {{ headLimit }}</span>
</div>
</div>
<!-- Result -->
<ToolResultBlock v-if="call.result" :result="call.result" />
</div>
</template>
<style scoped>
.grep-card {
border: 1px solid rgba(236, 72, 153, 0.25);
border-left: 3px solid #ec4899;
border-radius: 8px;
overflow: hidden;
margin: 0.5rem 0;
background: var(--bg-primary);
}
.grep-card.error {
border-color: rgba(239, 68, 68, 0.25);
border-left-color: #ef4444;
}
.card-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.75rem;
background: rgba(236, 72, 153, 0.06);
border-bottom: 1px solid rgba(236, 72, 153, 0.12);
}
.grep-card.error .card-header {
background: rgba(239, 68, 68, 0.06);
border-bottom-color: rgba(239, 68, 68, 0.12);
}
.card-icon {
display: flex;
align-items: center;
color: #ec4899;
}
.grep-card.error .card-icon { color: #ef4444; }
.card-label {
font-size: 11px;
font-weight: 600;
color: #ec4899;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.grep-card.error .card-label { color: #ef4444; }
.mode-badge {
font-size: 10px;
padding: 0.1rem 0.35rem;
border-radius: 4px;
background: rgba(236, 72, 153, 0.1);
color: #ec4899;
font-family: 'SF Mono', 'Fira Code', monospace;
font-weight: 500;
}
.match-count {
margin-left: auto;
font-size: 10px;
padding: 0.1rem 0.35rem;
border-radius: 4px;
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
font-family: 'SF Mono', 'Fira Code', monospace;
font-weight: 500;
}
.error-badge {
font-size: 10px;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
font-weight: 500;
}
.card-body {
padding: 0.5rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.pattern-row {
display: flex;
align-items: center;
}
.pattern-text {
font-size: 12px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: #f472b6;
background: rgba(236, 72, 153, 0.06);
padding: 0.2rem 0.5rem;
border-radius: 4px;
border: 1px solid rgba(236, 72, 153, 0.12);
word-break: break-all;
}
.scope-row {
display: flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
}
.scope-badge {
display: inline-flex;
align-items: center;
gap: 0.2rem;
font-size: 10px;
padding: 0.1rem 0.35rem;
border-radius: 4px;
font-family: 'SF Mono', 'Fira Code', monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
}
.scope-badge.path {
background: rgba(6, 182, 212, 0.08);
color: #06b6d4;
}
.scope-badge.glob {
background: rgba(251, 191, 36, 0.08);
color: #fbbf24;
}
.scope-badge.type {
background: rgba(99, 102, 241, 0.08);
color: #818cf8;
}
.scope-badge.ctx {
background: var(--bg-secondary);
color: var(--text-muted);
}
.scope-badge.limit {
background: var(--bg-secondary);
color: var(--text-muted);
}
</style>

View File

@@ -0,0 +1,169 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { ParsedToolCall } from '@/types/transcript-debug'
import ToolResultBlock from '../ToolResultBlock.vue'
const props = defineProps<{
call: ParsedToolCall
}>()
const filePath = computed(() => (props.call.input?.file_path as string) || '')
const fileName = computed(() => {
const fp = filePath.value
if (!fp) return ''
const parts = fp.replace(/\\/g, '/').split('/')
return parts[parts.length - 1]
})
const offset = computed(() => props.call.input?.offset as number | undefined)
const limit = computed(() => props.call.input?.limit as number | undefined)
const pages = computed(() => props.call.input?.pages as string | undefined)
const isError = computed(() => props.call.result?.isError ?? false)
// Detect file extension for icon hint
const ext = computed(() => {
const name = fileName.value
const dot = name.lastIndexOf('.')
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : ''
})
const resultExpanded = ref(false)
</script>
<template>
<div :class="['read-card', { error: isError }]">
<div class="card-header">
<span class="card-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
</span>
<span class="card-label">Read</span>
<span v-if="ext" class="ext-badge">.{{ ext }}</span>
<span v-if="isError" class="error-badge">error</span>
</div>
<div class="card-body">
<div class="file-path" :title="filePath">
<span class="path-dir">{{ filePath.replace(/\\/g, '/').split('/').slice(0, -1).join('/') }}/</span>
<span class="path-file">{{ fileName }}</span>
</div>
<div v-if="offset != null || limit != null || pages" class="range-info">
<span v-if="offset != null" class="range-badge">offset: {{ offset }}</span>
<span v-if="limit != null" class="range-badge">limit: {{ limit }}</span>
<span v-if="pages" class="range-badge">pages: {{ pages }}</span>
</div>
</div>
<!-- Result -->
<ToolResultBlock v-if="call.result" :result="call.result" />
</div>
</template>
<style scoped>
.read-card {
border: 1px solid rgba(6, 182, 212, 0.25);
border-left: 3px solid #06b6d4;
border-radius: 8px;
overflow: hidden;
margin: 0.5rem 0;
background: var(--bg-primary);
}
.read-card.error {
border-color: rgba(239, 68, 68, 0.25);
border-left-color: #ef4444;
}
.card-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.75rem;
background: rgba(6, 182, 212, 0.06);
border-bottom: 1px solid rgba(6, 182, 212, 0.12);
}
.read-card.error .card-header {
background: rgba(239, 68, 68, 0.06);
border-bottom-color: rgba(239, 68, 68, 0.12);
}
.card-icon {
display: flex;
align-items: center;
color: #06b6d4;
}
.read-card.error .card-icon { color: #ef4444; }
.card-label {
font-size: 11px;
font-weight: 600;
color: #06b6d4;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.read-card.error .card-label { color: #ef4444; }
.ext-badge {
font-size: 10px;
padding: 0.1rem 0.35rem;
border-radius: 4px;
background: rgba(6, 182, 212, 0.12);
color: #06b6d4;
font-family: 'SF Mono', 'Fira Code', monospace;
font-weight: 500;
}
.error-badge {
font-size: 10px;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
font-weight: 500;
}
.card-body {
padding: 0.5rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.file-path {
font-size: 12px;
font-family: 'SF Mono', 'Fira Code', monospace;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.path-dir {
color: var(--text-muted);
}
.path-file {
color: var(--text-primary);
font-weight: 600;
}
.range-info {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.range-badge {
font-size: 10px;
padding: 0.1rem 0.35rem;
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
}
</style>

View File

@@ -0,0 +1,357 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { ParsedToolCall } from '@/types/transcript-debug'
import ToolResultBlock from '../ToolResultBlock.vue'
const props = defineProps<{
call: ParsedToolCall
}>()
const isError = computed(() => props.call.result?.isError ?? false)
// Detect which Task-family tool this is
const toolName = computed(() => props.call.name)
// Common fields across Task tools
const taskId = computed(() => (props.call.input?.taskId as string) || '')
const subject = computed(() => (props.call.input?.subject as string) || '')
const description = computed(() => (props.call.input?.description as string) || '')
const activeForm = computed(() => (props.call.input?.activeForm as string) || '')
const status = computed(() => (props.call.input?.status as string) || '')
const prompt = computed(() => (props.call.input?.prompt as string) || '')
const subagentType = computed(() => (props.call.input?.subagent_type as string) || '')
// For Task (subagent launcher)
const taskDescription = computed(() => (props.call.input?.description as string) || '')
const model = computed(() => (props.call.input?.model as string) || '')
const runInBackground = computed(() => props.call.input?.run_in_background as boolean | undefined)
// Status styling
const statusColor = computed(() => {
switch (status.value) {
case 'completed': return '#22c55e'
case 'in_progress': return '#f59e0b'
case 'pending': return '#64748b'
case 'deleted': return '#ef4444'
default: return '#64748b'
}
})
const statusIcon = computed(() => {
switch (status.value) {
case 'completed': return '✓'
case 'in_progress': return '→'
case 'pending': return '○'
case 'deleted': return '✗'
default: return '•'
}
})
// Card color based on tool
const cardColor = computed(() => {
switch (toolName.value) {
case 'Task': return '#0ea5e9' // blue - agent launch
case 'TaskCreate': return '#22c55e' // green - create
case 'TaskUpdate': return '#a855f7' // purple - update
case 'TaskGet': return '#06b6d4' // cyan - read
case 'TaskList': return '#64748b' // gray - list
default: return '#64748b'
}
})
const cardIcon = computed(() => toolName.value)
const descExpanded = ref(false)
const promptExpanded = ref(false)
</script>
<template>
<div :class="['task-card', { error: isError }]" :style="{ '--card-color': cardColor }">
<div class="card-header">
<span class="card-icon">
<!-- Task (subagent) -->
<svg v-if="toolName === 'Task'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
<!-- TaskCreate -->
<svg v-else-if="toolName === 'TaskCreate'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="16"/>
<line x1="8" y1="12" x2="16" y2="12"/>
</svg>
<!-- TaskUpdate -->
<svg v-else-if="toolName === 'TaskUpdate'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 11l3 3L22 4"/>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
</svg>
<!-- TaskGet -->
<svg v-else-if="toolName === 'TaskGet'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<!-- TaskList -->
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="8" y1="6" x2="21" y2="6"/>
<line x1="8" y1="12" x2="21" y2="12"/>
<line x1="8" y1="18" x2="21" y2="18"/>
<line x1="3" y1="6" x2="3.01" y2="6"/>
<line x1="3" y1="12" x2="3.01" y2="12"/>
<line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
</span>
<span class="card-label">{{ toolName }}</span>
<span v-if="taskId" class="id-badge">#{{ taskId }}</span>
<span v-if="subagentType" class="agent-badge">{{ subagentType }}</span>
<span v-if="model" class="model-badge">{{ model }}</span>
<span v-if="runInBackground" class="bg-badge">background</span>
<span v-if="isError" class="error-badge">error</span>
</div>
<div class="card-body">
<!-- Status (TaskUpdate) -->
<div v-if="status" class="field-row">
<span class="field-label">Status</span>
<span class="status-badge" :style="{ color: statusColor, borderColor: statusColor, background: statusColor + '15' }">
<span class="status-icon">{{ statusIcon }}</span>
{{ status }}
</span>
</div>
<!-- Subject (TaskCreate/TaskUpdate) -->
<div v-if="subject" class="field-row">
<span class="field-label">Subject</span>
<span class="field-value subject">{{ subject }}</span>
</div>
<!-- Active form -->
<div v-if="activeForm" class="field-row">
<span class="field-label">Active</span>
<span class="field-value active-form">{{ activeForm }}</span>
</div>
<!-- Description (collapsible for long text) -->
<div v-if="description && description.length <= 120" class="field-row">
<span class="field-label">Desc</span>
<span class="field-value desc-text">{{ description }}</span>
</div>
<div v-else-if="description" class="collapsible-section">
<button class="section-toggle" @click="descExpanded = !descExpanded">
<svg :class="['chevron', { rotated: descExpanded }]" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6" />
</svg>
<span>Description</span>
<span class="section-meta">{{ description.length }} chars</span>
</button>
<pre v-if="descExpanded" class="section-content">{{ description }}</pre>
</div>
<!-- Prompt (Task subagent) -->
<div v-if="prompt && prompt.length <= 120" class="field-row">
<span class="field-label">Prompt</span>
<span class="field-value desc-text">{{ prompt }}</span>
</div>
<div v-else-if="prompt" class="collapsible-section">
<button class="section-toggle" @click="promptExpanded = !promptExpanded">
<svg :class="['chevron', { rotated: promptExpanded }]" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6" />
</svg>
<span>Prompt</span>
<span class="section-meta">{{ prompt.length }} chars</span>
</button>
<pre v-if="promptExpanded" class="section-content">{{ prompt }}</pre>
</div>
</div>
<!-- Result -->
<ToolResultBlock v-if="call.result" :result="call.result" />
</div>
</template>
<style scoped>
.task-card {
border: 1px solid color-mix(in srgb, var(--card-color) 25%, transparent);
border-left: 3px solid var(--card-color);
border-radius: 8px;
overflow: hidden;
margin: 0.5rem 0;
background: var(--bg-primary);
}
.task-card.error {
border-color: rgba(239, 68, 68, 0.25);
border-left-color: #ef4444;
}
.card-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.75rem;
background: color-mix(in srgb, var(--card-color) 6%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--card-color) 12%, transparent);
}
.task-card.error .card-header {
background: rgba(239, 68, 68, 0.06);
border-bottom-color: rgba(239, 68, 68, 0.12);
}
.card-icon {
display: flex;
align-items: center;
color: var(--card-color);
}
.task-card.error .card-icon { color: #ef4444; }
.card-label {
font-size: 11px;
font-weight: 600;
color: var(--card-color);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.task-card.error .card-label { color: #ef4444; }
.id-badge {
font-size: 11px;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: color-mix(in srgb, var(--card-color) 12%, transparent);
color: var(--card-color);
font-family: 'SF Mono', 'Fira Code', monospace;
font-weight: 600;
}
.agent-badge {
font-size: 10px;
padding: 0.1rem 0.35rem;
border-radius: 4px;
background: rgba(99, 102, 241, 0.1);
color: #818cf8;
font-weight: 500;
}
.model-badge {
font-size: 10px;
padding: 0.1rem 0.35rem;
border-radius: 4px;
background: rgba(99, 102, 241, 0.1);
color: var(--accent, #6366f1);
font-family: 'SF Mono', 'Fira Code', monospace;
}
.bg-badge {
font-size: 10px;
padding: 0.1rem 0.35rem;
border-radius: 4px;
background: rgba(99, 102, 241, 0.12);
color: #818cf8;
font-weight: 500;
}
.error-badge {
font-size: 10px;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
font-weight: 500;
}
.card-body {
padding: 0.5rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.field-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.field-label {
font-size: 11px;
color: var(--text-muted);
font-weight: 500;
min-width: 45px;
flex-shrink: 0;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 11px;
font-weight: 600;
padding: 0.15rem 0.45rem;
border: 1px solid;
border-radius: 5px;
}
.status-icon { font-size: 12px; }
.field-value {
font-size: 12px;
color: var(--text-primary);
}
.field-value.subject { font-weight: 500; }
.field-value.active-form { font-style: italic; color: var(--text-secondary); }
.field-value.desc-text { font-size: 11px; color: var(--text-muted); line-height: 1.3; }
/* Collapsible sections */
.collapsible-section {
border-top: 1px solid var(--border-color);
margin: 0 -0.75rem;
}
.section-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.3rem 0.75rem;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-secondary);
font-size: 11px;
text-align: left;
}
.section-toggle:hover { background: var(--bg-hover); }
.section-meta {
margin-left: auto;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 10px;
}
.chevron {
transition: transform 0.2s;
flex-shrink: 0;
}
.chevron.rotated { transform: rotate(90deg); }
.section-content {
margin: 0;
padding: 0.5rem 0.75rem;
font-size: 11px;
line-height: 1.5;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
border-top: 1px solid var(--border-color);
background: var(--bg-primary);
font-family: 'SF Mono', 'Fira Code', monospace;
}
</style>

View File

@@ -0,0 +1,210 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { ParsedToolCall } from '@/types/transcript-debug'
import { highlightCode } from '@/utils/markdown'
import ToolResultBlock from '../ToolResultBlock.vue'
const props = defineProps<{
call: ParsedToolCall
}>()
const filePath = computed(() => (props.call.input?.file_path as string) || '')
const fileName = computed(() => {
const fp = filePath.value
if (!fp) return ''
const parts = fp.replace(/\\/g, '/').split('/')
return parts[parts.length - 1]
})
const content = computed(() => (props.call.input?.content as string) || '')
const ext = computed(() => {
const name = fileName.value
const dot = name.lastIndexOf('.')
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : ''
})
const isError = computed(() => props.call.result?.isError ?? false)
const contentExpanded = ref(false)
const highlightedContent = computed(() => highlightCode(content.value, ext.value || undefined))
const lineCount = computed(() => content.value.split('\n').length)
</script>
<template>
<div :class="['write-card', { error: isError }]">
<div class="card-header">
<span class="card-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="12" y1="18" x2="12" y2="12"/>
<polyline points="9 15 12 12 15 15"/>
</svg>
</span>
<span class="card-label">Write</span>
<span v-if="ext" class="ext-badge">.{{ ext }}</span>
<span v-if="isError" class="error-badge">error</span>
</div>
<div class="card-body">
<div class="file-path" :title="filePath">
<span class="path-dir">{{ filePath.replace(/\\/g, '/').split('/').slice(0, -1).join('/') }}/</span>
<span class="path-file">{{ fileName }}</span>
</div>
</div>
<!-- Content preview -->
<div class="content-section">
<button class="content-toggle" @click="contentExpanded = !contentExpanded">
<svg
:class="['chevron', { rotated: contentExpanded }]"
width="10" height="10" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span>Content</span>
<span class="content-meta">{{ lineCount }} lines &middot; {{ content.length }} chars</span>
</button>
<pre v-if="contentExpanded" class="content-pre" v-html="highlightedContent"></pre>
</div>
<!-- Result -->
<ToolResultBlock v-if="call.result" :result="call.result" />
</div>
</template>
<style scoped>
.write-card {
border: 1px solid rgba(34, 197, 94, 0.25);
border-left: 3px solid #22c55e;
border-radius: 8px;
overflow: hidden;
margin: 0.5rem 0;
background: var(--bg-primary);
}
.write-card.error {
border-color: rgba(239, 68, 68, 0.25);
border-left-color: #ef4444;
}
.card-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.75rem;
background: rgba(34, 197, 94, 0.06);
border-bottom: 1px solid rgba(34, 197, 94, 0.12);
}
.write-card.error .card-header {
background: rgba(239, 68, 68, 0.06);
border-bottom-color: rgba(239, 68, 68, 0.12);
}
.card-icon {
display: flex;
align-items: center;
color: #22c55e;
}
.write-card.error .card-icon { color: #ef4444; }
.card-label {
font-size: 11px;
font-weight: 600;
color: #22c55e;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.write-card.error .card-label { color: #ef4444; }
.ext-badge {
font-size: 10px;
padding: 0.1rem 0.35rem;
border-radius: 4px;
background: rgba(34, 197, 94, 0.12);
color: #22c55e;
font-family: 'SF Mono', 'Fira Code', monospace;
font-weight: 500;
}
.error-badge {
font-size: 10px;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
font-weight: 500;
}
.card-body {
padding: 0.5rem 0.75rem;
}
.file-path {
font-size: 12px;
font-family: 'SF Mono', 'Fira Code', monospace;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.path-dir { color: var(--text-muted); }
.path-file { color: var(--text-primary); font-weight: 600; }
/* Content preview */
.content-section {
border-top: 1px solid var(--border-color);
}
.content-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.35rem 0.75rem;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-secondary);
font-size: 11px;
text-align: left;
}
.content-toggle:hover { background: var(--bg-hover); }
.content-meta {
margin-left: auto;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 10px;
}
.chevron {
transition: transform 0.2s;
flex-shrink: 0;
}
.chevron.rotated { transform: rotate(90deg); }
.content-pre {
margin: 0;
padding: 0.6rem 0.75rem;
font-size: 11px;
line-height: 1.5;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-all;
max-height: 300px;
overflow-y: auto;
border-top: 1px solid var(--border-color);
background: var(--bg-primary);
font-family: 'SF Mono', 'Fira Code', monospace;
}
</style>

View File

@@ -0,0 +1,459 @@
/**
* Lightweight markdown parser + syntax highlighter.
* Zero dependencies — regex-based.
*/
// ── HTML escape ──
function esc(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
// ── Syntax highlighting ──
const KEYWORDS_JS = new Set([
'const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while',
'do', 'switch', 'case', 'break', 'continue', 'new', 'delete', 'typeof',
'instanceof', 'void', 'this', 'class', 'extends', 'super', 'import',
'export', 'default', 'from', 'as', 'async', 'await', 'yield', 'try',
'catch', 'finally', 'throw', 'of', 'in', 'with', 'debugger',
// TS extras
'type', 'interface', 'enum', 'namespace', 'declare', 'abstract',
'implements', 'readonly', 'keyof', 'infer', 'extends', 'satisfies',
])
const KEYWORDS_PY = new Set([
'def', 'class', 'return', 'if', 'elif', 'else', 'for', 'while', 'break',
'continue', 'import', 'from', 'as', 'try', 'except', 'finally', 'raise',
'with', 'yield', 'lambda', 'pass', 'del', 'and', 'or', 'not', 'is', 'in',
'True', 'False', 'None', 'async', 'await', 'nonlocal', 'global', 'assert',
])
function highlightJSON(code: string): string {
return esc(code).replace(
/("(?:[^"\\]|\\.)*")\s*(:)|("(?:[^"\\]|\\.)*")|(\b(?:true|false|null)\b)|(\b-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)/g,
(_, key, colon, str, bool, num) => {
if (key) return `<span class="hl-key">${key}</span>${colon}`
if (str) return `<span class="hl-str">${str}</span>`
if (bool) return `<span class="hl-bool">${bool}</span>`
if (num) return `<span class="hl-num">${num}</span>`
return _
}
)
}
function highlightYAML(code: string): string {
return esc(code).split('\n').map(line => {
// Comments
if (/^\s*#/.test(line)) return `<span class="hl-comment">${line}</span>`
// Key: value
return line.replace(
/^(\s*)([\w.-]+)(:)/,
(_, ws, k, c) => `${ws}<span class="hl-key">${k}</span>${c}`
).replace(
/:\s+("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g,
(m, s) => m.replace(s, `<span class="hl-str">${s}</span>`)
).replace(
/\b(true|false|null|~)\b/g,
'<span class="hl-bool">$1</span>'
).replace(
/\b(\d+(?:\.\d+)?)\b/g,
'<span class="hl-num">$1</span>'
)
}).join('\n')
}
function highlightTOML(code: string): string {
return esc(code).split('\n').map(line => {
// Comments
if (/^\s*#/.test(line)) return `<span class="hl-comment">${line}</span>`
// Section headers [section]
if (/^\s*\[/.test(line)) return `<span class="hl-section">${line}</span>`
// Key = value
return line.replace(
/^(\s*)([\w.-]+)(\s*=)/,
(_, ws, k, eq) => `${ws}<span class="hl-key">${k}</span>${eq}`
).replace(
/=\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g,
(m, s) => m.replace(s, `<span class="hl-str">${s}</span>`)
).replace(
/\b(true|false)\b/g,
'<span class="hl-bool">$1</span>'
).replace(
/\b(\d+(?:\.\d+)?)\b/g,
'<span class="hl-num">$1</span>'
)
}).join('\n')
}
function highlightJS(code: string, keywords: Set<string>): string {
const escaped = esc(code)
const tokens: { start: number; end: number; html: string }[] = []
// Collect token positions to avoid overlapping replacements
// Strings (double & single & template)
const strRe = /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/g
let m: RegExpExecArray | null
while ((m = strRe.exec(escaped))) {
tokens.push({ start: m.index, end: m.index + m[0].length, html: `<span class="hl-str">${m[0]}</span>` })
}
// Line comments
const commentRe = /(\/\/.*)/gm
while ((m = commentRe.exec(escaped))) {
// Only if not inside a string token
const s = m.index
if (!tokens.some(t => s >= t.start && s < t.end)) {
tokens.push({ start: s, end: s + m[0].length, html: `<span class="hl-comment">${m[0]}</span>` })
}
}
// Numbers
const numRe = /\b(\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\b/g
while ((m = numRe.exec(escaped))) {
const s = m.index
if (!tokens.some(t => s >= t.start && s < t.end)) {
tokens.push({ start: s, end: s + m[0].length, html: `<span class="hl-num">${m[0]}</span>` })
}
}
// Keywords
const kwRe = /\b([a-zA-Z_]\w*)\b/g
while ((m = kwRe.exec(escaped))) {
const s = m.index
if (keywords.has(m[1]) && !tokens.some(t => s >= t.start && s < t.end)) {
tokens.push({ start: s, end: s + m[0].length, html: `<span class="hl-kw">${m[0]}</span>` })
}
}
// Sort by start position and build output
tokens.sort((a, b) => a.start - b.start)
let result = ''
let cursor = 0
for (const tok of tokens) {
if (tok.start < cursor) continue // overlapping, skip
result += escaped.slice(cursor, tok.start) + tok.html
cursor = tok.end
}
result += escaped.slice(cursor)
return result
}
function highlightBash(code: string): string {
return esc(code).split('\n').map(line => {
// Comments
if (/^\s*#/.test(line)) return `<span class="hl-comment">${line}</span>`
return line
.replace(/("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g, '<span class="hl-str">$1</span>')
.replace(/\b(sudo|apt|npm|bun|yarn|pnpm|git|cd|ls|rm|cp|mv|mkdir|cat|echo|export|source|chmod|curl|wget|docker|pip|python|node)\b/g, '<span class="hl-kw">$1</span>')
}).join('\n')
}
function highlightCSS(code: string): string {
return esc(code)
.replace(/(\/\*[\s\S]*?\*\/)/g, '<span class="hl-comment">$1</span>')
.replace(/([\w-]+)\s*:/g, '<span class="hl-key">$1</span>:')
.replace(/(#[0-9a-fA-F]{3,8})\b/g, '<span class="hl-num">$1</span>')
.replace(/(\d+(?:\.\d+)?(?:px|rem|em|%|vh|vw|s|ms)?)\b/g, '<span class="hl-num">$1</span>')
}
function highlightHTML(code: string): string {
return esc(code)
.replace(/(&lt;\/?)([\w-]+)/g, '$1<span class="hl-kw">$2</span>')
.replace(/\s([\w-]+)(=)/g, ' <span class="hl-key">$1</span>$2')
.replace(/("(?:[^"\\]|\\.)*")/g, '<span class="hl-str">$1</span>')
.replace(/(&lt;!--[\s\S]*?--&gt;)/g, '<span class="hl-comment">$1</span>')
}
function highlightGeneric(code: string): string {
return esc(code)
.replace(/("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g, '<span class="hl-str">$1</span>')
.replace(/(#.*|\/\/.*)/gm, '<span class="hl-comment">$1</span>')
.replace(/\b(\d+(?:\.\d+)?)\b/g, '<span class="hl-num">$1</span>')
}
type LangAlias = string
const LANG_MAP: Record<string, (code: string) => string> = {
json: highlightJSON,
jsonc: highlightJSON,
yaml: highlightYAML,
yml: highlightYAML,
toml: highlightTOML,
javascript: c => highlightJS(c, KEYWORDS_JS),
js: c => highlightJS(c, KEYWORDS_JS),
jsx: c => highlightJS(c, KEYWORDS_JS),
typescript: c => highlightJS(c, KEYWORDS_JS),
ts: c => highlightJS(c, KEYWORDS_JS),
tsx: c => highlightJS(c, KEYWORDS_JS),
vue: c => highlightHTML(c), // close enough
python: c => highlightJS(c, KEYWORDS_PY),
py: c => highlightJS(c, KEYWORDS_PY),
bash: highlightBash,
sh: highlightBash,
shell: highlightBash,
zsh: highlightBash,
css: highlightCSS,
scss: highlightCSS,
html: highlightHTML,
xml: highlightHTML,
svg: highlightHTML,
}
export function highlightCode(code: string, lang?: string): string {
ensureStyles()
const fn = lang ? LANG_MAP[lang.toLowerCase()] : undefined
return fn ? fn(code) : highlightGeneric(code)
}
/** Inject global highlight + markdown styles (idempotent). */
let _stylesInjected = false
export function ensureStyles(): void {
if (_stylesInjected || typeof document === 'undefined') return
_stylesInjected = true
if (document.getElementById('md-highlight-styles')) return
const style = document.createElement('style')
style.id = 'md-highlight-styles'
style.textContent = MARKDOWN_STYLES
document.head.appendChild(style)
}
// ── Markdown parser ──
/**
* Parse a markdown string into HTML.
* Supports: headers, bold, italic, inline code, code blocks (with highlighting),
* links, images, blockquotes, unordered lists, ordered lists, horizontal rules.
*/
export function parseMarkdown(md: string): string {
// Extract fenced code blocks first (protect from inline parsing)
const codeBlocks: string[] = []
let text = md.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang: string, code: string) => {
const highlighted = highlightCode(code.replace(/\n$/, ''), lang || undefined)
const langLabel = lang ? `<span class="code-lang">${esc(lang)}</span>` : ''
const idx = codeBlocks.length
codeBlocks.push(
`<div class="md-code-block">${langLabel}<pre class="md-pre"><code>${highlighted}</code></pre></div>`
)
return `\x00CODE${idx}\x00`
})
// Split into lines for block-level parsing
const lines = text.split('\n')
const out: string[] = []
let i = 0
while (i < lines.length) {
const line = lines[i]
// Code block placeholder
const codeMatch = line.match(/^\x00CODE(\d+)\x00$/)
if (codeMatch) {
out.push(codeBlocks[parseInt(codeMatch[1])])
i++
continue
}
// Horizontal rule
if (/^(\s*[-*_]\s*){3,}$/.test(line)) {
out.push('<hr class="md-hr"/>')
i++
continue
}
// Headers
const hMatch = line.match(/^(#{1,6})\s+(.+)$/)
if (hMatch) {
const level = hMatch[1].length
out.push(`<h${level} class="md-h${level}">${inlineParse(esc(hMatch[2]))}</h${level}>`)
i++
continue
}
// Blockquote
if (/^>\s?/.test(line)) {
const quoteLines: string[] = []
while (i < lines.length && /^>\s?/.test(lines[i])) {
quoteLines.push(lines[i].replace(/^>\s?/, ''))
i++
}
out.push(`<blockquote class="md-blockquote">${parseMarkdown(quoteLines.join('\n'))}</blockquote>`)
continue
}
// Unordered list
if (/^\s*[-*+]\s+/.test(line)) {
const items: string[] = []
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\s*[-*+]\s+/, ''))
i++
}
out.push(`<ul class="md-ul">${items.map(it => `<li>${inlineParse(esc(it))}</li>`).join('')}</ul>`)
continue
}
// Ordered list
if (/^\s*\d+[.)]\s+/.test(line)) {
const items: string[] = []
while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\s*\d+[.)]\s+/, ''))
i++
}
out.push(`<ol class="md-ol">${items.map(it => `<li>${inlineParse(esc(it))}</li>`).join('')}</ol>`)
continue
}
// Empty line
if (line.trim() === '') {
i++
continue
}
// Regular paragraph
const paraLines: string[] = []
while (i < lines.length && lines[i].trim() !== '' && !/^#{1,6}\s/.test(lines[i]) && !/^\x00CODE/.test(lines[i]) && !/^>\s?/.test(lines[i]) && !/^\s*[-*+]\s+/.test(lines[i]) && !/^\s*\d+[.)]\s+/.test(lines[i]) && !/^(\s*[-*_]\s*){3,}$/.test(lines[i])) {
paraLines.push(lines[i])
i++
}
if (paraLines.length) {
out.push(`<p class="md-p">${inlineParse(esc(paraLines.join('\n')))}</p>`)
}
}
return out.join('\n')
}
/** Parse inline markdown elements (bold, italic, code, links, images). */
function inlineParse(html: string): string {
return html
// Images ![alt](src)
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img class="md-img" alt="$1" src="$2"/>')
// Links [text](url)
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a class="md-link" href="$2" target="_blank" rel="noopener">$1</a>')
// Bold + italic ***text***
.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
// Bold **text** or __text__
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/__(.+?)__/g, '<strong>$1</strong>')
// Italic *text* or _text_
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/_(.+?)_/g, '<em>$1</em>')
// Strikethrough ~~text~~
.replace(/~~(.+?)~~/g, '<del>$1</del>')
// Inline code `code`
.replace(/`([^`]+)`/g, '<code class="md-inline-code">$1</code>')
// Line breaks
.replace(/\n/g, '<br/>')
}
// ── CSS for the renderer (inject once) ──
export const MARKDOWN_STYLES = `
/* ── Markdown rendered content ── */
.md-content h1, .md-content h2, .md-content h3,
.md-content h4, .md-content h5, .md-content h6 {
margin: 0.5em 0 0.25em;
color: var(--text-primary);
line-height: 1.3;
}
.md-content .md-h1 { font-size: 1.3em; border-bottom: 1px solid var(--border-color); padding-bottom: 0.2em; }
.md-content .md-h2 { font-size: 1.15em; }
.md-content .md-h3 { font-size: 1.05em; }
.md-content .md-h4, .md-content .md-h5, .md-content .md-h6 { font-size: 1em; }
.md-content .md-p {
margin: 0.3em 0;
line-height: 1.6;
}
.md-content strong { font-weight: 600; color: var(--text-primary); }
.md-content em { font-style: italic; }
.md-content del { text-decoration: line-through; opacity: 0.6; }
.md-content .md-link {
color: #0ea5e9;
text-decoration: none;
border-bottom: 1px solid transparent;
}
.md-content .md-link:hover { border-bottom-color: #0ea5e9; }
.md-content .md-inline-code {
background: rgba(99, 102, 241, 0.1);
color: #e879f9;
padding: 0.1em 0.35em;
border-radius: 4px;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.9em;
}
.md-content .md-code-block {
position: relative;
margin: 0.4em 0;
border: 1px solid var(--border-color);
border-radius: 6px;
overflow: hidden;
background: var(--bg-secondary, #1a1a2e);
}
.md-content .code-lang {
position: absolute;
top: 0;
right: 0;
font-size: 9px;
padding: 0.15em 0.5em;
color: var(--text-muted);
background: rgba(255,255,255,0.04);
border-bottom-left-radius: 4px;
font-family: 'SF Mono', 'Fira Code', monospace;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.md-content .md-pre {
margin: 0;
padding: 0.6em 0.75em;
font-size: 12px;
line-height: 1.55;
overflow-x: auto;
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--text-secondary);
}
.md-content .md-blockquote {
margin: 0.4em 0;
padding: 0.3em 0.75em;
border-left: 3px solid var(--accent, #6366f1);
background: rgba(99, 102, 241, 0.04);
border-radius: 0 4px 4px 0;
color: var(--text-secondary);
}
.md-content .md-ul, .md-content .md-ol {
margin: 0.3em 0;
padding-left: 1.5em;
}
.md-content .md-ul li, .md-content .md-ol li {
margin: 0.15em 0;
line-height: 1.5;
}
.md-content .md-hr {
border: none;
border-top: 1px solid var(--border-color);
margin: 0.6em 0;
}
/* ── Syntax highlighting tokens ── */
.hl-key { color: #c084fc; }
.hl-str { color: #4ade80; }
.hl-num { color: #fb923c; }
.hl-bool { color: #38bdf8; }
.hl-kw { color: #c084fc; font-weight: 500; }
.hl-comment { color: #64748b; font-style: italic; }
.hl-section { color: #f59e0b; font-weight: 600; }
`