🎉 Initial commit: PiAgentUI — AI Agent Desktop & Web UI
Build & Push Frontend Docker Image / build-and-push (push) Canceled after 0s
Build Validation / validate (push) Canceled after 6s
Build & Push Backend Docker Image / build-and-push (push) Failing after 10m10s
Build & Push Gateway Docker Image / build-and-push (push) Failing after 10m9s

A professional workspace for AI coding agents with:
- Real-time streaming chat with SSE
- Session management (fork, clone, tree navigation)
- MCP tools browser & metadata panel
- Skills browser (global, project, package sources)
- Voice input & STT provider configuration
- Integrated terminal & file explorer
- Model selector with capability metadata
- Keyboard-first UI with command palette
- 8 languages (en, pt-BR, es, ja, zh-CN, ar, bn, hi)
- Tauri 2 desktop app
- React 19 + TypeScript + Vite 8 + Tailwind CSS 4
- Pi Agent extension runtime

Originally forked from OpenCodeUI, now an independent project.
License: GPL-3.0-only
This commit is contained in:
Evandro committed 2026-06-01 00:50:06 -03:00
commit b3489e1ede
983 files changed
+158475

No files matched your search

+534
View File
@@ -0,0 +1,534 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { diffLines } from 'diff'
import { ChevronDownIcon, ChevronRightIcon } from '../../../components/Icons'
import type { ToolPart } from '../../../types/message'
import { useDelayedRender } from '../../../hooks'
import { useNow } from '../../../hooks/useNow'
import { serverStore } from '../../../store/serverStore'
import { useTheme } from '../../../hooks/useTheme'
import { formatToolName, formatDuration } from '../../../utils/formatUtils'
import {
useInlineToolRequests,
findPermissionRequestForTool,
findQuestionRequestForTool,
} from '../../chat/InlineToolRequestContext'
import { InlinePermission } from '../../chat/InlinePermission'
import { InlineQuestion } from '../../chat/InlineQuestion'
import {
getToolIcon,
extractToolData,
getToolConfig,
DefaultRenderer,
TodoRenderer,
TaskRenderer,
hasTodos,
} from '../tools'
// ============================================
// ToolPartView - 单个工具调用
// ============================================
interface ToolPartViewProps {
part: ToolPart
isFirst?: boolean
isLast?: boolean
/** Compact layout: icon inline with text (14px column), no timeline connectors.
* Used for single-tool groups to align with ReasoningPartView. */
compact?: boolean
/** Descriptive steps mode: no icon/timeline, flat rows aligned with step summary. */
descriptive?: boolean
/** Parent assistant message is still streaming. */
isStreaming?: boolean
}
export const ToolPartView = memo(function ToolPartView({
part,
isFirst = false,
isLast = false,
compact = false,
descriptive = false,
isStreaming = false,
}: ToolPartViewProps) {
const { t } = useTranslation('message')
const { state, tool: toolName } = part
const status = state?.status || 'pending'
const title = state?.title || getInputDescription(part) || ''
const isActive = status === 'running' || status === 'pending'
const isError = status === 'error'
const now = useNow(250, isActive)
const startTime = state?.time?.start
const calibratedNow = isActive ? serverStore.getActiveCalibratedNow() : undefined
const endTime = state?.time?.end ?? (isActive ? (calibratedNow ?? now) : undefined)
const rawDuration = startTime !== undefined && endTime !== undefined ? endTime - startTime : undefined
const duration = rawDuration !== undefined && isActive ? Math.max(0, rawDuration) : rawDuration
const { inlineToolRequests, immersiveMode, compactInlinePermission } = useTheme()
const { pendingPermissions, pendingQuestions, onPermissionReply, onQuestionReply, onQuestionReject, isReplying } =
useInlineToolRequests()
const childSessionId = getTaskChildSessionId(part)
const permissionRequest = inlineToolRequests
? findPermissionRequestForTool(pendingPermissions, part.callID, childSessionId)
: undefined
const questionRequest = inlineToolRequests
? findQuestionRequestForTool(pendingQuestions, part.callID, childSessionId)
: undefined
const toolDone = status === 'completed' || status === 'error'
// ── 延迟卸载 edit/write 权限组件 ──
// 用户授权后 permissionRequest 会立即消失,但工具结果可能还没到,
// 为了避免 "权限消失→空白→结果出现" 的跳动,缓存最后一次权限请求,
// 在工具完成之前继续渲染(以 resolved 状态)
const [cachedPermissionRequest, setCachedPermissionRequest] = useState(permissionRequest)
useEffect(() => {
let frameId: number | null = null
if (permissionRequest) {
frameId = requestAnimationFrame(() => {
setCachedPermissionRequest(permissionRequest)
})
} else if (toolDone) {
frameId = requestAnimationFrame(() => {
setCachedPermissionRequest(undefined)
})
}
return () => {
if (frameId !== null) cancelAnimationFrame(frameId)
}
}, [permissionRequest, toolDone])
const effectivePermissionRequest = permissionRequest || cachedPermissionRequest
const isFilePermission =
effectivePermissionRequest?.permission === 'edit' || effectivePermissionRequest?.permission === 'write'
// 权限已批准但工具还没完成 → 保留渲染
const permissionResolved = !permissionRequest && !!cachedPermissionRequest && isFilePermission && !toolDone
const hasPendingInteraction = !!permissionRequest || !!questionRequest
// 精简模式:非 edit/write 权限时不隐藏 ToolBody(ToolBody 已经渲染了命令内容)
const isEditWritePermission =
permissionRequest?.permission === 'edit' || permissionRequest?.permission === 'write' || permissionResolved
const hideToolBodyForPermission = isEditWritePermission
// 精简模式:ToolBody 已渲染时,InlinePermission 只显示按钮
// task 工具除外:task renderer 无法显示详细的工具请求内容,需要完整展示权限信息
const isTaskTool = toolName.toLowerCase() === 'task'
const permissionContentHidden =
compactInlinePermission && !isEditWritePermission && !isTaskTool && !!permissionRequest
const isReadable = isReadableTool(toolName)
const shouldStartExpanded =
isActive ||
hasPendingInteraction ||
permissionResolved ||
(immersiveMode && descriptive && isStreaming && isReadable)
const [expanded, setExpanded] = useState(() => shouldStartExpanded)
const hasAutoExpandedReadableRef = useRef(shouldStartExpanded && immersiveMode && descriptive && isReadable)
const effectiveExpanded = expanded || hasPendingInteraction || permissionResolved
const shouldRenderBody = useDelayedRender(effectiveExpanded)
useEffect(() => {
let frameId: number | null = null
if (isActive || hasPendingInteraction || permissionResolved) {
if (immersiveMode && descriptive && isReadable) {
hasAutoExpandedReadableRef.current = true
}
frameId = requestAnimationFrame(() => {
setExpanded(true)
})
} else if (immersiveMode && descriptive && !isReadable) {
frameId = requestAnimationFrame(() => {
setExpanded(false)
})
} else if (immersiveMode && descriptive && isStreaming && isReadable && !hasAutoExpandedReadableRef.current) {
hasAutoExpandedReadableRef.current = true
frameId = requestAnimationFrame(() => {
setExpanded(true)
})
}
return () => {
if (frameId !== null) cancelAnimationFrame(frameId)
}
}, [isActive, hasPendingInteraction, permissionResolved, immersiveMode, descriptive, isStreaming, isReadable])
// Shared icon element
const toolIcon = (
<div
className={`
relative flex items-center justify-center transition-colors duration-200
\${isActive ? 'text-text-300' : ''}
\${isError ? 'text-danger-100' : ''}
\${state?.status === 'completed' ? 'text-text-400 group-hover:text-text-300' : ''}
`}
>
{getToolIcon(toolName)}
</div>
)
// 需要渲染权限组件的请求对象:优先用活跃的,否则用缓存的(resolved 态)
const displayPermission = permissionRequest || (permissionResolved ? cachedPermissionRequest : undefined)
// Memoize once — shared by both the descriptive header (diffStats) and ToolBody.
const toolData = useMemo(() => extractToolData(part), [part])
const bodyContent = (
<>
{!hideToolBodyForPermission && <ToolBody part={part} data={toolData} />}
{displayPermission && (
<div className={hideToolBodyForPermission && !permissionContentHidden ? '' : 'pt-2'}>
<InlinePermission
request={displayPermission}
onReply={onPermissionReply}
isReplying={isReplying}
resolved={permissionResolved}
contentHidden={permissionContentHidden}
/>
</div>
)}
{questionRequest && (
<div className="pt-2">
<InlineQuestion
request={questionRequest}
onReply={onQuestionReply}
onReject={onQuestionReject}
isReplying={isReplying}
/>
</div>
)}
</>
)
if (descriptive) {
const hasDiffFiles = !!toolData.files?.length
// diffStats 可能从 metadata 来,也可能需要从 diff 数据计算
const diffStats = toolData.diffStats || computeDiffStatsFromData(toolData)
return (
<div className="group py-0.5">
<button
type="button"
className="flex w-full items-center gap-3 rounded-md px-0 py-1 text-left hover:bg-bg-200/30 transition-colors group/header"
onClick={() => setExpanded(!expanded)}
>
<div className="flex min-w-0 flex-1 items-baseline gap-2 overflow-hidden">
<span
className={`shrink-0 font-medium text-[length:var(--fs-md)] leading-tight ${
isActive
? 'reasoning-shimmer-text'
: isError
? 'text-danger-100'
: 'text-text-200 group-hover/header:text-text-100'
}`}
>
{formatToolName(toolName)}
</span>
{title && (
<span
className={`min-w-0 truncate font-mono text-[length:var(--fs-code)] ${
isActive ? 'reasoning-shimmer-text' : isError ? 'text-danger-100/80' : 'text-text-400'
}`}
>
{title}
</span>
)}
{/* Diff stats — 紧跟 title,收起时且非失败时显示 */}
{!effectiveExpanded && !isActive && !isError && (diffStats || hasDiffFiles) && (
<span className="shrink-0 flex items-center gap-1 text-[length:var(--fs-xxs)] font-mono font-medium tabular-nums">
{(diffStats?.additions ?? 0) > 0 && <span className="text-success-100">+{diffStats!.additions}</span>}
{(diffStats?.deletions ?? 0) > 0 && <span className="text-danger-100">-{diffStats!.deletions}</span>}
</span>
)}
</div>
<div className="ml-auto flex shrink-0 items-center gap-2">
{duration !== undefined && (state?.status === 'completed' || isActive) && (
<span
className={`text-[length:var(--fs-xxs)] font-mono tabular-nums \${isError ? 'text-danger-100/70' : isActive ? 'reasoning-shimmer-text' : 'text-text-500'}`}
>
{formatDuration(duration)}
</span>
)}
</div>
</button>
<div
className={`grid transition-[grid-template-rows] duration-300 ease-in-out ${
effectiveExpanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
}`}
>
<div className="overflow-hidden">{shouldRenderBody && <div className="pb-2 pt-1">{bodyContent}</div>}</div>
</div>
</div>
)
}
// ── Compact layout (single-tool, no timeline) ──
// Grid: [14px icon] [gap 6px] [content] — mirrors ReasoningPartView alignment
if (compact) {
return (
<div className="group relative grid grid-cols-[14px_minmax(0,1fr)] gap-x-1.5 items-start py-1">
{/* Icon column — fixed, outside of interactive area */}
<span className="inline-flex h-9 w-[14px] items-center justify-center shrink-0">{toolIcon}</span>
{/* Content column */}
<div className="min-w-0">
<button
type="button"
className="flex items-center gap-2 w-full h-9 text-left pl-2 pr-0 hover:bg-bg-200/40 rounded-sm transition-colors group/header"
onClick={() => setExpanded(!expanded)}
>
<div className="flex items-baseline gap-2 overflow-hidden flex-1 min-w-0">
<span
className={`font-medium text-[length:var(--fs-md)] leading-tight transition-colors duration-300 shrink-0 ${
isActive
? 'reasoning-shimmer-text'
: isError
? 'text-danger-100'
: 'text-text-200 group-hover/header:text-text-100'
}`}
>
{formatToolName(toolName)}
</span>
{title && (
<span
className={`text-[length:var(--fs-sm)] truncate min-w-0 flex-1 font-mono ${
isActive ? 'reasoning-shimmer-text' : 'text-text-400 opacity-70'
}`}
>
{title}
</span>
)}
</div>
<div className="flex items-center gap-2 ml-auto shrink-0">
{duration !== undefined && (state?.status === 'completed' || isActive) && (
<span
className={`text-[length:var(--fs-xxs)] font-mono tabular-nums \${
isActive ? 'reasoning-shimmer-text' : 'text-text-500'
}`}
>
{formatDuration(duration)}
</span>
)}
<span
className={`text-[length:var(--fs-xxs)] font-medium transition-all duration-300 ${
isActive ? 'opacity-100 text-text-400' : 'opacity-0 w-0 overflow-hidden'
}`}
>
{t('toolPart.running')}
</span>
<span
className={`text-[length:var(--fs-xxs)] font-medium transition-all duration-300 ${
isError ? 'opacity-100 text-danger-100' : 'opacity-0 w-0 overflow-hidden'
}`}
>
{t('toolPart.failed')}
</span>
<span className="text-text-500">
{effectiveExpanded ? <ChevronDownIcon size={12} /> : <ChevronRightIcon size={12} />}
</span>
</div>
</button>
{/* Body */}
<div
className={`grid transition-[grid-template-rows] duration-300 ease-in-out ${
effectiveExpanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
}`}
>
<div className="overflow-hidden">
{shouldRenderBody && <div className="pl-2 pr-2.5 pb-2 pt-1">{bodyContent}</div>}
</div>
</div>
</div>
</div>
)
}
// ── Timeline layout (multi-tool groups) ──
return (
<div className="group relative flex min-w-0">
{/* Timeline Column */}
<div className="w-8 shrink-0 relative">
{/* Top connector — 留 4px gap 到 icon */}
{!isFirst && <div className="absolute left-1/2 -translate-x-1/2 top-0 h-[7px] w-px bg-border-300/40" />}
{/* Tool icon — h-9 和右侧 header 等高,flex 自然居中 */}
<div className="h-9 flex items-center justify-center relative z-10">{toolIcon}</div>
{/* Bottom connector — 留 4px gap 到 icon */}
{!isLast && <div className="absolute left-1/2 -translate-x-1/2 top-[29px] bottom-0 w-px bg-border-300/40" />}
</div>
{/* Content Column */}
<div className="flex-1 min-w-0">
{/* Header - h-9 和 timeline 图标行等高 */}
<button
type="button"
className="flex items-center gap-2.5 w-full h-9 text-left pl-2 pr-0 hover:bg-bg-200/40 rounded-sm transition-colors group/header"
onClick={() => setExpanded(!expanded)}
>
<div className="flex items-baseline gap-2 overflow-hidden flex-1 min-w-0">
<span
className={`font-medium text-[length:var(--fs-md)] leading-tight transition-colors duration-300 shrink-0 ${
isActive
? 'reasoning-shimmer-text'
: isError
? 'text-danger-100'
: 'text-text-200 group-hover/header:text-text-100'
}`}
>
{formatToolName(toolName)}
</span>
{title && (
<span
className={`text-[length:var(--fs-sm)] truncate min-w-0 flex-1 font-mono ${
isActive ? 'reasoning-shimmer-text' : 'text-text-400 opacity-70'
}`}
>
{title}
</span>
)}
</div>
<div className="flex items-center gap-2 ml-auto shrink-0">
{duration !== undefined && (state?.status === 'completed' || isActive) && (
<span
className={`text-[length:var(--fs-xxs)] font-mono tabular-nums transition-opacity duration-300 \${
isActive ? 'reasoning-shimmer-text' : 'text-text-500'
}`}
>
{formatDuration(duration)}
</span>
)}
<span
className={`text-[length:var(--fs-xxs)] font-medium transition-all duration-300 ${
isActive ? 'opacity-100 text-text-400' : 'opacity-0 w-0 overflow-hidden'
}`}
>
{t('toolPart.running')}
</span>
<span
className={`text-[length:var(--fs-xxs)] font-medium transition-all duration-300 ${
isError ? 'opacity-100 text-danger-100' : 'opacity-0 w-0 overflow-hidden'
}`}
>
{t('toolPart.failed')}
</span>
<span className="text-text-500">
{effectiveExpanded ? <ChevronDownIcon size={12} /> : <ChevronRightIcon size={12} />}
</span>
</div>
</button>
{/* Body - grid collapse */}
<div
className={`grid transition-[grid-template-rows] duration-300 ease-in-out ${
effectiveExpanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
}`}
>
<div className="overflow-hidden">
{shouldRenderBody && <div className="pl-2 pr-2.5 pb-2 pt-1">{bodyContent}</div>}
</div>
</div>
</div>
</div>
)
})
// ============================================
// Helpers
// ============================================
/** 用户需要阅读/交互的工具 */
const READABLE_TOOL_PATTERNS = /bash|\bsh\b|cmd|terminal|shell|write|save|edit|replace|patch|todo|question|ask/i
function isReadableTool(toolName: string): boolean {
return READABLE_TOOL_PATTERNS.test(toolName.toLowerCase())
}
/** 从 diff 数据计算 diffStats(当 metadata 没给 diffStats 时用) */
function computeDiffStatsFromData(data: {
diff?: { before: string; after: string } | string
files?: Array<{ before?: string; after?: string; additions?: number; deletions?: number }>
}): { additions: number; deletions: number } | undefined {
// 多文件
if (data.files?.length) {
let additions = 0,
deletions = 0
for (const f of data.files) {
if (f.additions !== undefined) additions += f.additions
if (f.deletions !== undefined) deletions += f.deletions
if (f.additions === undefined && f.before !== undefined && f.after !== undefined) {
const s = computeDiffPair(f.before, f.after)
additions += s.additions
deletions += s.deletions
}
}
return additions || deletions ? { additions, deletions } : undefined
}
// 单个 diff
if (data.diff && typeof data.diff === 'object') {
const s = computeDiffPair(data.diff.before, data.diff.after)
return s.additions || s.deletions ? s : undefined
}
return undefined
}
function computeDiffPair(before: string, after: string): { additions: number; deletions: number } {
const changes = diffLines(before, after)
let additions = 0,
deletions = 0
for (const c of changes) {
if (c.added) additions += c.count || 0
if (c.removed) deletions += c.count || 0
}
return { additions, deletions }
}
// ============================================
// ToolBody - 根据工具类型选择渲染器
// ============================================
const ToolBody = memo(function ToolBody({ part, data }: { part: ToolPart; data: ReturnType<typeof extractToolData> }) {
const { tool } = part
const lowerTool = tool.toLowerCase()
if (lowerTool === 'task') {
return <TaskRenderer part={part} data={data} />
}
if (lowerTool.includes('todo') && hasTodos(part)) {
return <TodoRenderer part={part} data={data} />
}
const config = getToolConfig(tool)
if (config?.renderer) {
const CustomRenderer = config.renderer
return <CustomRenderer part={part} data={data} />
}
return <DefaultRenderer part={part} data={data} />
})
function getTaskChildSessionId(part: ToolPart): string | undefined {
if (part.tool.toLowerCase() !== 'task') return undefined
const metadata = part.state?.metadata as Record<string, unknown> | undefined
return metadata?.sessionId as string | undefined
}
/** Extract description from tool input as title fallback (available while running) */
function getInputDescription(part: ToolPart): string | undefined {
const input = part.state?.input as Record<string, unknown> | undefined
return (input?.description as string) || undefined
}
// ============================================
// Helpers
// ============================================