Newer
Older
navi-1 / webclient / src / components / messages / ToolCard.vue
<template>
  <details
    ref="detailsEl"
    class="tool-card"
    :class="{
      'is-success': !tool.pending && tool.success,
      'is-error': !tool.pending && !tool.success
    }"
  >
    <summary>
      <span class="tool-status-icon">
        <span v-if="tool.pending" class="spinner"></span>
        <i v-else-if="tool.success" class="ph ph-check-circle"></i>
        <i v-else class="ph ph-x-circle"></i>
      </span>
      <span class="tool-name">{{ tool.name }}</span>
      <span v-if="tool.pending" class="tool-running-time">{{ elapsedLabel }}</span>
      <i class="ph ph-caret-down tool-chevron"></i>
    </summary>

    <div class="tool-card-body">
      <div v-if="tool.pending && tool.name !== 'spawn_agent'" class="tool-running-banner">
        <span class="spinner"></span>
        <span>{{ runningLabel }}</span>
      </div>
      <details v-if="tool.args" class="tool-args">
        <summary>
          <span class="tool-section-label">Arguments</span>
          <i class="ph ph-caret-down tool-chevron"></i>
        </summary>
        <div class="tool-section">
          <pre class="tool-code" v-html="renderArgs(tool.args)"></pre>
        </div>
      </details>
      <div v-if="tool.result != null" class="tool-section tool-result-section">
        <div class="tool-section-label">Result</div>
        <div class="tool-result" v-html="renderResult(tool.result)"></div>
      </div>
      <!-- Live terminal output stream -->
      <div v-if="tool.name === 'terminal' && tool.terminalOutput" class="tool-section terminal-output-section">
        <div class="tool-section-label">Live output</div>
        <pre class="terminal-output">{{ tool.terminalOutput }}</pre>
      </div>
      <!-- Subagent planning indicator (while subagent is in planning phase) -->
      <div v-if="tool.planningLabel != null" class="subagent-planning-indicator">
        <span class="planning-label">{{ tool.planningLabel }}</span>
      </div>
      <!-- Subagent steps nested inside spawn_agent card -->
      <div v-if="tool.steps?.length" class="subagent-steps">
        <template v-for="(step, i) in tool.steps" :key="i">
          <ThinkingCard v-if="step.kind === 'turn_thinking'" :msg="step" />
          <details v-else-if="step.kind === 'plan'" class="plan-card" open>
            <summary>
              <i class="ph ph-map-trifold"></i>
              Plan
              <i class="ph ph-caret-down plan-chevron"></i>
            </summary>
            <div class="plan-body" v-html="renderMarkdown(step.text)" />
          </details>
          <SubagentStep v-else :tool="step" />
        </template>
      </div>
    </div>
  </details>
</template>

<script setup>
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import SubagentStep from './SubagentStep.vue'
import ThinkingCard from './ThinkingCard.vue'
import { renderMarkdown } from '@/composables/useMarkdown.js'

function looksLikeMarkdown(str) {
  if (typeof str !== 'string') return false
  const mdPatterns = [
    /#{1,6} /,           // headings
    /\*\*|__/,            // bold
    /`[^`]+`/,            // inline code
    /```/,                // code block
    /^\s*[-*+]\s/m,      // list
    /^\s*\d+\.\s/m,       // ordered list
    /\[[^\]]+\]\([^)]+\)/, // link
    /\|.*\|/,             // table
    /^\s*>\s/m,           // blockquote
  ]
  return mdPatterns.some(re => re.test(str))
}

const props = defineProps({ tool: { type: Object, required: true } })

const detailsEl = ref(null)
const now = ref(Date.now())
let timer = null

// Auto-open when tool starts, auto-close when done
watch(
  () => props.tool.pending,
  (pending, wasPending) => {
    if (!detailsEl.value) return
    if (pending) {
      detailsEl.value.setAttribute('open', '')
    } else if (wasPending !== undefined) {
      // Tool just completed — collapse
      detailsEl.value.removeAttribute('open')
    }
  },
  { immediate: true }
)

watch(
  () => props.tool.pending,
  (pending) => {
    if (pending) startTimer()
    else stopTimer()
  },
  { immediate: true }
)

onBeforeUnmount(stopTimer)

const runningLabel = computed(() => {
  if (props.tool.name === 'filesystem' && props.tool.args?.action) {
    return `Running filesystem ${props.tool.args.action}...`
  }
  return `Running ${props.tool.name}...`
})
const elapsedLabel = computed(() => {
  if (!props.tool.startedAt) return ''
  const seconds = Math.max(0, Math.floor((now.value - props.tool.startedAt) / 1000))
  return seconds < 1 ? 'starting' : `${seconds}s`
})

function startTimer() {
  if (timer) return
  now.value = Date.now()
  timer = setInterval(() => { now.value = Date.now() }, 1000)
}

function stopTimer() {
  if (!timer) return
  clearInterval(timer)
  timer = null
}

function renderArgs(val) {
  return formatCompactJson(val)
}

function renderResult(val) {
  if (typeof val === 'string') {
    // Try JSON first
    try {
      const parsed = JSON.parse(val)
      return formatCompactJson(parsed)
    } catch { /* not JSON */ }

    if (looksLikeMarkdown(val)) {
      return renderMarkdown(val)
    }

    return escapeHtml(val)
      .split('\n')
      .map((line, i) => `<div class="result-line">${line || '&nbsp;'}</div>`)
      .join('')
  }
  return formatCompactJson(val)
}

function formatCompactJson(val) {
  if (typeof val === 'string') {
    try { val = JSON.parse(val) } catch { return escapeHtml(val) }
  }
  if (val === null || typeof val !== 'object') return escapeHtml(String(val))
  if (Array.isArray(val)) {
    if (val.length === 0) return '<span class="json-empty">[]</span>'
    const items = val.map((item, i) =>
      `<div class="json-item">
        <span class="json-key">${i}</span>
        <span class="json-sep">:</span>
        <span class="json-value">${formatCompactValue(item)}</span>
      </div>`
    ).join('')
    return `<div class="json-array">${items}</div>`
  }
  const entries = Object.entries(val)
  if (entries.length === 0) return '<span class="json-empty">{}</span>'
  const items = entries.map(([k, v]) =>
    `<div class="json-item">
      <span class="json-key">${escapeHtml(k)}</span>
      <span class="json-sep">:</span>
      <span class="json-value">${formatCompactValue(v)}</span>
    </div>`
  ).join('')
  return `<div class="json-object">${items}</div>`
}

function formatCompactValue(v) {
  if (v === null) return '<span class="json-null">null</span>'
  if (typeof v === 'boolean') return `<span class="json-bool">${v}</span>`
  if (typeof v === 'number') return `<span class="json-number">${v}</span>`
  if (typeof v === 'string') {
    const max = 200
    const s = escapeHtml(v)
    if (s.length <= max) return `<span class="json-string">${s}</span>`
    return `<span class="json-string">${s.slice(0, max)}…</span>`
  }
  if (Array.isArray(v)) return `<span class="json-array">[${v.length} items]</span>`
  return `<span class="json-object">{${Object.keys(v).length} keys}</span>`
}

function escapeHtml(str) {
  if (typeof str !== 'string') return String(str ?? '')
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
}
</script>