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:
@@ -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;
|
||||||
|
|||||||
25
frontend/src/components/transcript-debug/MarkdownContent.vue
Normal file
25
frontend/src/components/transcript-debug/MarkdownContent.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
164
frontend/src/components/transcript-debug/toolCards/BashCard.vue
Normal file
164
frontend/src/components/transcript-debug/toolCards/BashCard.vue
Normal 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>
|
||||||
286
frontend/src/components/transcript-debug/toolCards/EditCard.vue
Normal file
286
frontend/src/components/transcript-debug/toolCards/EditCard.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
170
frontend/src/components/transcript-debug/toolCards/GlobCard.vue
Normal file
170
frontend/src/components/transcript-debug/toolCards/GlobCard.vue
Normal 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>
|
||||||
228
frontend/src/components/transcript-debug/toolCards/GrepCard.vue
Normal file
228
frontend/src/components/transcript-debug/toolCards/GrepCard.vue
Normal 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>
|
||||||
169
frontend/src/components/transcript-debug/toolCards/ReadCard.vue
Normal file
169
frontend/src/components/transcript-debug/toolCards/ReadCard.vue
Normal 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>
|
||||||
357
frontend/src/components/transcript-debug/toolCards/TaskCard.vue
Normal file
357
frontend/src/components/transcript-debug/toolCards/TaskCard.vue
Normal 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>
|
||||||
210
frontend/src/components/transcript-debug/toolCards/WriteCard.vue
Normal file
210
frontend/src/components/transcript-debug/toolCards/WriteCard.vue
Normal 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 · {{ 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>
|
||||||
459
frontend/src/utils/markdown.ts
Normal file
459
frontend/src/utils/markdown.ts
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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(/(<\/?)([\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(/(<!--[\s\S]*?-->)/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 
|
||||||
|
.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; }
|
||||||
|
`
|
||||||
Reference in New Issue
Block a user