spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
import * as React from 'react'
import { useMutation } from '@tanstack/react-query'
import { useConfig } from '@/state/config'
import { ipc, isTauriHost } from '@/lib/api/desktop'
import { asEnvelope } from '@/lib/error'
import type { OutputFormat, TargetTool } from '@/lib/types/desktop'
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { ErrorBanner } from '@/components/error-banner'
import { Loader2, Sparkles } from 'lucide-react'

const TARGETS: TargetTool[] = ['claude', 'codex', 'opencode']
const FORMATS: OutputFormat[] = ['prompt', 'markdown', 'json']

export function ContextPage() {
  const cfg = useConfig()
  const [task, setTask] = React.useState('')
  const [filesRaw, setFilesRaw] = React.useState('')
  const [target, setTarget] = React.useState<TargetTool>('claude')
  const [format, setFormat] = React.useState<OutputFormat>('prompt')

  const runMutation = useMutation({
    mutationFn: () => {
      const files = filesRaw
        .split(/[\n,]/)
        .map((s) => s.trim())
        .filter(Boolean)
      return ipc.runContext({
        config_path: cfg.configPath,
        vault_root_override: cfg.vaultRoot.trim() || null,
        cwd: cfg.cwd,
        task,
        files,
        target,
        format,
      })
    },
  })

  const result = runMutation.data ?? null
  const loading = runMutation.isPending
  const errorEnvelope = runMutation.error ? asEnvelope(runMutation.error) : null

  return (
    <div className="grid gap-4 lg:grid-cols-[1fr_1.2fr]">
      <Card>
        <CardHeader>
          <CardTitle className="flex items-center gap-2 text-base">
            <Sparkles className="h-4 w-4" />
            上下文注入
          </CardTitle>
          <CardDescription>
            输入任务描述与相关文件,生成带路由解释的上下文包。
          </CardDescription>
        </CardHeader>
        <CardContent className="space-y-4">
          <div className="grid gap-2">
            <Label htmlFor="task">任务描述</Label>
            <Textarea
              id="task"
              value={task}
              onChange={(e) => setTask(e.target.value)}
              placeholder="例如:实现 repo_path route 规划"
              rows={4}
            />
          </div>
          <div className="grid gap-2">
            <Label htmlFor="files">相关文件(逗号或换行分隔)</Label>
            <Textarea
              id="files"
              value={filesRaw}
              onChange={(e) => setFilesRaw(e.target.value)}
              placeholder="src/engine/project_matcher.rs"
              rows={3}
            />
          </div>
          <div className="grid grid-cols-2 gap-3">
            <div className="grid gap-2">
              <Label>目标</Label>
              <Select value={target} onValueChange={(v) => setTarget(v as TargetTool)}>
                <SelectTrigger>
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
                  {TARGETS.map((t) => (
                    <SelectItem key={t} value={t}>
                      {t}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </div>
            <div className="grid gap-2">
              <Label>格式</Label>
              <Select value={format} onValueChange={(v) => setFormat(v as OutputFormat)}>
                <SelectTrigger>
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
                  {FORMATS.map((f) => (
                    <SelectItem key={f} value={f}>
                      {f}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </div>
          </div>
          <div className="flex items-center gap-3 pt-2">
            <Button
              onClick={() => runMutation.mutate()}
              disabled={loading || !task.trim() || !cfg.cwd.trim()}
            >
              {loading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
              生成上下文
            </Button>
            {!isTauriHost() && (
              <Badge variant="muted">
                Bridge 未就绪(请在 Tauri 窗口内运行)
              </Badge>
            )}
          </div>
          {errorEnvelope && <ErrorBanner envelope={errorEnvelope} />}
        </CardContent>
      </Card>

      <Card>
        <CardHeader>
          <CardTitle className="text-base">输出</CardTitle>
          {result && (
            <CardDescription>
              format={result.used_format} · vault={result.used_vault_root}
            </CardDescription>
          )}
        </CardHeader>
        <CardContent className="space-y-4">
          {!result && !loading && (
            <p className="text-sm text-muted-foreground">尚未生成。</p>
          )}
          {result && (
            <>
              <div>
                <Label className="mb-1 block">Rendered</Label>
                <pre className="max-h-[420px] overflow-auto rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
                  {result.rendered}
                </pre>
              </div>
              <div>
                <Label className="mb-1 block">Explain</Label>
                <pre className="max-h-[200px] overflow-auto rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
                  {result.explain}
                </pre>
              </div>
            </>
          )}
        </CardContent>
      </Card>
    </div>
  )
}