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, tool)"></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, tool) {
  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)
    }

    // Diff output (filesystem diff/edit_lines/smart_edit) gets a compact,
    // marker-coloured rendering instead of the roomy per-line result-line divs.
    if (tool && tool.name === 'filesystem'
      && ['diff', 'edit_lines', 'smart_edit'].includes(tool.args?.action)) {
      return renderDiff(val)
    }

    // Filesystem read: header plaque + optional warning + numbered body, with
    // the line number in the accent colour (matches the TUI _render_read).
    if (tool && tool.name === 'filesystem' && tool.args?.action === 'read') {
      return renderRead(val)
    }

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

// Render a unified-diff string. edit_lines/smart_edit produce "summary\n\n<hunks>";
// the diff action is hunks only. The server prefixes each content line with its
// file line number as "{marker} {num}│ {content}". We render the number+marker
// in the marker colour (green/red) and the content neutral — the number is a
// coloured anchor, the code reads plainly (standard, matches the TUI renderer
// in renderers/diff.py). Lines without the prefix (hunk headers, file headers,
// older callers) fall back to whole-line colouring.
function renderDiff(val) {
  let summary = ''
  let body = val
  const sep = val.indexOf('\n\n')
  if (sep !== -1) {
    const after = val.slice(sep + 2)
    if (after.includes('@@') || /^[ +\-]/m.test(after)) {
      summary = val.slice(0, sep)
      body = after
    }
  }
  const parts = []
  if (summary) {
    parts.push(`<div class="result-line diff-summary">${escapeHtml(summary) || '&nbsp;'}</div>`)
  }
  for (const line of body.split('\n')) {
    parts.push(renderDiffLine(line))
  }
  return parts.join('')
}

// "{marker}{leading}{num}│ {content}" — marker is +/-/space, leading is the
// number's right-align padding, num the file line, content the rest. The match
// keeps leading so the number column stays aligned when shown first.
const _DIFF_NUM_RE = /^([ +\-])( +)(\d+)│ ?(.*)$/

function renderDiffLine(line) {
  const m = _DIFF_NUM_RE.exec(line)
  if (m) {
    const marker = m[1]
    const cls = marker === '+' ? 'diff-add' : marker === '-' ? 'diff-del' : 'diff-ctx'
    const anchor = `${m[2]}${m[3]} ${marker}  `
    const content = m[4]
    return `<div class="result-line"><span class="diff-num-marker ${cls}">${escapeHtml(anchor)}</span><span class="diff-content">${escapeHtml(content) || '&nbsp;'}</span></div>`
  }
  const cls = diffLineClass(line)
  return `<div class="result-line ${cls}">${escapeHtml(line) || '&nbsp;'}</div>`
}

function diffLineClass(line) {
  if (line.startsWith('@@')) return 'diff-hunk'
  if (line.startsWith('+')) return 'diff-add'
  if (line.startsWith('-')) return 'diff-del'
  return 'diff-ctx'
}

// Render a filesystem `read` result. Layout: a header plaque
// "[path  |  N lines  |  size]" (or "[path  |  lines A–B of N  |  size]"), an
// optional "⚠ Large file …" warning line, then the numbered body
// "{num:>width}: {line}". The line number is the accent colour (a navigation
// anchor); the content is neutral — the same "number coloured, content neutral"
// standard the diff renderer uses and matching the TUI _render_read.
const _READ_NUM_RE = /^(\s*)(\d+): (.*)$/

function renderRead(val) {
  const lines = val.split('\n')
  const parts = []
  let idx = 0

  // Header plaque: path in accent, the "N lines | size" tail dim.
  if (lines.length && lines[0].startsWith('[')) {
    const header = lines[0]
    const inner = header.replace(/^\[/, '').replace(/\]$/, '')
    const sepIdx = inner.indexOf('  |  ')
    if (sepIdx !== -1) {
      const pathPart = inner.slice(0, sepIdx)
      const rest = inner.slice(sepIdx + 5) // length of "  |  "
      parts.push(`<div class="result-line read-header"><span class="read-header-path">${escapeHtml(pathPart)}</span><span class="read-header-rest">  |  ${escapeHtml(rest)}</span></div>`)
    } else {
      parts.push(`<div class="result-line read-header">${escapeHtml(header) || '&nbsp;'}</div>`)
    }
    idx = 1
  }

  // Optional "⚠ Large file …" warning line (warning colour).
  if (idx < lines.length && lines[idx].startsWith('⚠')) {
    parts.push(`<div class="result-line read-warning">${escapeHtml(lines[idx]) || '&nbsp;'}</div>`)
    idx += 1
  }

  // Body: "{num:>width}: {line}" — number accent, content neutral.
  for (; idx < lines.length; idx++) {
    const ln = lines[idx]
    if (ln === '') {
      parts.push('<div class="result-line read-content-line">&nbsp;</div>')
      continue
    }
    const m = _READ_NUM_RE.exec(ln)
    if (m) {
      parts.push(`<div class="result-line"><span class="read-num">${escapeHtml(m[1] + m[2])}: </span><span class="read-content">${escapeHtml(m[3])}</span></div>`)
    } else {
      parts.push(`<div class="result-line">${escapeHtml(ln)}</div>`)
    }
  }
  return parts.join('')
}

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>