feat(hooks): add hook management and session timeline features

- Add hook quick templates component with configurable templates
- Refactor NativeSessionPanel to use new SessionTimeline component
- Add OpenCode session parser for parsing OpenCode CLI sessions
- Enhance API with session-related endpoints
- Add locale translations for hooks and native session features
- Update hook commands and routes for better hook management
This commit is contained in:
catlog22
2026-02-25 23:21:35 +08:00
parent 25f442b329
commit 519efe9783
15 changed files with 1543 additions and 435 deletions

View File

@@ -182,6 +182,15 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [
'-e',
'const p=JSON.parse(process.env.HOOK_INPUT||"{}");const cp=require("child_process");const payload=JSON.stringify({type:"SESSION_SUMMARY",transcript:p.transcript_path||"",project:process.env.CLAUDE_PROJECT_DIR||process.cwd(),timestamp:Date.now()});cp.spawnSync("curl",["-s","-X","POST","-H","Content-Type: application/json","-d",payload,"http://localhost:3456/api/hook"],{stdio:"inherit",shell:true})'
]
},
{
id: 'project-state-inject',
name: 'Project State Inject',
description: 'Inject project guidelines and recent dev history at session start',
category: 'indexing',
trigger: 'SessionStart',
command: 'ccw',
args: ['hook', 'project-state', '--stdin']
}
] as const;
@@ -205,6 +214,7 @@ const TEMPLATE_ICONS: Record<string, typeof Bell> = {
'git-auto-stage': GitBranch,
'post-edit-index': Database,
'session-end-summary': FileBarChart,
'project-state-inject': FileBarChart,
};
// ========== Category Names ==========

View File

@@ -6,25 +6,16 @@
import * as React from 'react';
import { useIntl } from 'react-intl';
import {
User,
Bot,
Brain,
Wrench,
Copy,
Clock,
Hash,
FolderOpen,
FileJson,
Coins,
ArrowDownUp,
Archive,
Loader2,
AlertCircle,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Card } from '@/components/ui/Card';
import {
Dialog,
DialogContent,
@@ -32,11 +23,7 @@ import {
DialogTitle,
} from '@/components/ui/Dialog';
import { useNativeSession } from '@/hooks/useNativeSession';
import type {
NativeSessionTurn,
NativeToolCall,
NativeTokenInfo,
} from '@/lib/api';
import { SessionTimeline } from './SessionTimeline';
// ========== Types ==========
@@ -46,21 +33,6 @@ export interface NativeSessionPanelProps {
onOpenChange: (open: boolean) => void;
}
interface TurnCardProps {
turn: NativeSessionTurn;
isLatest: boolean;
}
interface TokenDisplayProps {
tokens: NativeTokenInfo;
className?: string;
}
interface ToolCallItemProps {
toolCall: NativeToolCall;
index: number;
}
// ========== Helpers ==========
/**
@@ -76,16 +48,6 @@ function getToolVariant(tool: string): 'default' | 'secondary' | 'outline' | 'su
return variants[tool?.toLowerCase()] || 'secondary';
}
/**
* Format token number with compact notation
*/
function formatTokenCount(count: number | undefined): string {
if (count == null || count === 0) return '0';
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
return count.toLocaleString();
}
/**
* Truncate a string to a max length with ellipsis
*/
@@ -106,173 +68,6 @@ async function copyToClipboard(text: string): Promise<boolean> {
}
}
// ========== Sub-Components ==========
/**
* TokenDisplay - Compact token info line
*/
function TokenDisplay({ tokens, className }: TokenDisplayProps) {
const { formatMessage } = useIntl();
return (
<div className={cn('flex items-center gap-3 text-xs text-muted-foreground', className)}>
<span className="flex items-center gap-1" title={formatMessage({ id: 'nativeSession.tokens.total', defaultMessage: 'Total tokens' })}>
<Coins className="h-3 w-3" />
{formatTokenCount(tokens.total)}
</span>
{tokens.input != null && (
<span className="flex items-center gap-1" title={formatMessage({ id: 'nativeSession.tokens.input', defaultMessage: 'Input tokens' })}>
<ArrowDownUp className="h-3 w-3" />
{formatTokenCount(tokens.input)}
</span>
)}
{tokens.output != null && (
<span title={formatMessage({ id: 'nativeSession.tokens.output', defaultMessage: 'Output tokens' })}>
out: {formatTokenCount(tokens.output)}
</span>
)}
{tokens.cached != null && tokens.cached > 0 && (
<span className="flex items-center gap-1" title={formatMessage({ id: 'nativeSession.tokens.cached', defaultMessage: 'Cached tokens' })}>
<Archive className="h-3 w-3" />
{formatTokenCount(tokens.cached)}
</span>
)}
</div>
);
}
/**
* ToolCallItem - Single tool call display with collapsible details
*/
function ToolCallItem({ toolCall, index }: ToolCallItemProps) {
const { formatMessage } = useIntl();
return (
<details className="group/tool border border-border/50 rounded-md overflow-hidden">
<summary className="flex items-center gap-2 px-3 py-2 text-xs cursor-pointer hover:bg-muted/50 select-none">
<Wrench className="h-3 w-3 text-muted-foreground flex-shrink-0" />
<span className="font-mono font-medium">{toolCall.name}</span>
<span className="text-muted-foreground">#{index + 1}</span>
</summary>
<div className="border-t border-border/50 divide-y divide-border/50">
{toolCall.arguments && (
<div className="p-3">
<p className="text-xs font-medium text-muted-foreground mb-1">{formatMessage({ id: 'nativeSession.toolCall.input' })}</p>
<pre className="p-2 bg-muted/30 rounded text-xs whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-60 overflow-y-auto">
{toolCall.arguments}
</pre>
</div>
)}
{toolCall.output && (
<div className="p-3">
<p className="text-xs font-medium text-muted-foreground mb-1">{formatMessage({ id: 'nativeSession.toolCall.output' })}</p>
<pre className="p-2 bg-muted/30 rounded text-xs whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-60 overflow-y-auto">
{toolCall.output}
</pre>
</div>
)}
</div>
</details>
);
}
/**
* TurnCard - Single conversation turn
*/
function TurnCard({ turn, isLatest }: TurnCardProps) {
const { formatMessage } = useIntl();
const isUser = turn.role === 'user';
const RoleIcon = isUser ? User : Bot;
return (
<Card
className={cn(
'overflow-hidden transition-all',
isUser
? 'bg-muted/30'
: 'bg-blue-500/5 dark:bg-blue-500/10',
isLatest && 'ring-2 ring-primary/50 shadow-md'
)}
>
{/* Turn Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50">
<div className="flex items-center gap-2">
<RoleIcon
className={cn(
'h-4 w-4',
isUser ? 'text-primary' : 'text-blue-500'
)}
/>
<span className="font-semibold text-sm capitalize">{turn.role}</span>
<span className="text-xs text-muted-foreground">
#{turn.turnNumber}
</span>
{isLatest && (
<Badge variant="default" className="text-xs h-5 px-1.5">
{formatMessage({ id: 'nativeSession.turn.latest', defaultMessage: 'Latest' })}
</Badge>
)}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{turn.timestamp && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{new Date(turn.timestamp).toLocaleTimeString()}
</span>
)}
{turn.tokens && (
<TokenDisplay tokens={turn.tokens} />
)}
</div>
</div>
{/* Turn Content */}
<div className="p-4 space-y-3">
{turn.content && (
<pre className="p-3 bg-background/50 rounded-lg text-sm whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-96 overflow-y-auto">
{turn.content}
</pre>
)}
{/* Thoughts Section */}
{turn.thoughts && turn.thoughts.length > 0 && (
<details className="group/thoughts">
<summary className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground text-muted-foreground select-none py-1">
<Brain className="h-4 w-4" />
<span className="font-medium">
{formatMessage({ id: 'nativeSession.turn.thoughts', defaultMessage: 'Thoughts' })}
</span>
<span className="text-xs">({turn.thoughts.length})</span>
</summary>
<ul className="mt-2 space-y-1 pl-6 text-sm text-muted-foreground list-disc">
{turn.thoughts.map((thought, i) => (
<li key={i} className="leading-relaxed">{thought}</li>
))}
</ul>
</details>
)}
{/* Tool Calls Section */}
{turn.toolCalls && turn.toolCalls.length > 0 && (
<details className="group/calls">
<summary className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground text-muted-foreground select-none py-1">
<Wrench className="h-4 w-4" />
<span className="font-medium">
{formatMessage({ id: 'nativeSession.turn.toolCalls', defaultMessage: 'Tool Calls' })}
</span>
<span className="text-xs">({turn.toolCalls.length})</span>
</summary>
<div className="mt-2 space-y-2">
{turn.toolCalls.map((tc, i) => (
<ToolCallItem key={i} toolCall={tc} index={i} />
))}
</div>
</details>
)}
</div>
</Card>
);
}
// ========== Main Component ==========
/**
@@ -350,17 +145,7 @@ export function NativeSessionPanel({
)}
</DialogHeader>
{/* Token Summary Bar */}
{session?.totalTokens && (
<div className="flex items-center gap-4 px-6 py-2.5 border-b bg-muted/30 shrink-0">
<span className="text-xs font-medium text-foreground">
{formatMessage({ id: 'nativeSession.tokenSummary', defaultMessage: 'Total Tokens' })}
</span>
<TokenDisplay tokens={session.totalTokens} />
</div>
)}
{/* Content Area */}
{/* Content Area with SessionTimeline */}
{isLoading ? (
<div className="flex-1 flex items-center justify-center py-16">
<div className="flex items-center gap-2 text-muted-foreground">
@@ -375,24 +160,9 @@ export function NativeSessionPanel({
<span>{formatMessage({ id: 'nativeSession.error', defaultMessage: 'Failed to load session' })}</span>
</div>
</div>
) : session && session.turns.length > 0 ? (
) : session ? (
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="space-y-4">
{session.turns.map((turn, idx) => (
<React.Fragment key={turn.turnNumber}>
<TurnCard
turn={turn}
isLatest={idx === session.turns.length - 1}
/>
{/* Connector line between turns */}
{idx < session.turns.length - 1 && (
<div className="flex justify-center" aria-hidden="true">
<div className="w-px h-4 bg-border" />
</div>
)}
</React.Fragment>
))}
</div>
<SessionTimeline session={session} />
</div>
) : (
<div className="flex-1 flex items-center justify-center py-16 text-muted-foreground">

View File

@@ -0,0 +1,416 @@
// ========================================
// SessionTimeline Component
// ========================================
// Timeline visualization for native CLI session turns, tokens, and tool calls
import * as React from 'react';
import { useIntl } from 'react-intl';
import {
User,
Bot,
Brain,
Wrench,
Coins,
Clock,
ChevronDown,
ChevronRight,
Archive,
ArrowDownUp,
CheckCircle,
XCircle,
Loader2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/Badge';
import type {
NativeSession,
NativeSessionTurn,
NativeTokenInfo,
NativeToolCall,
} from '@/lib/api';
// ========== Types ==========
export interface SessionTimelineProps {
session: NativeSession;
className?: string;
}
interface TurnNodeProps {
turn: NativeSessionTurn;
isLatest: boolean;
isLast: boolean;
}
interface TokenBarProps {
tokens: NativeTokenInfo;
className?: string;
}
interface ToolCallPanelProps {
toolCall: NativeToolCall;
index: number;
}
// ========== Helpers ==========
/**
* Format token number with compact notation
*/
function formatTokenCount(count: number | undefined): string {
if (count == null || count === 0) return '0';
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
return count.toLocaleString();
}
/**
* Get status icon for tool call
*/
function getToolStatusIcon(status?: string): React.ReactNode {
switch (status) {
case 'completed':
case 'success':
return <CheckCircle className="h-3.5 w-3.5 text-green-500" />;
case 'error':
case 'failed':
return <XCircle className="h-3.5 w-3.5 text-destructive" />;
case 'running':
case 'pending':
return <Loader2 className="h-3.5 w-3.5 text-blue-500 animate-spin" />;
default:
return <Wrench className="h-3.5 w-3.5 text-muted-foreground" />;
}
}
/**
* Get badge variant for tool call status
*/
function getStatusVariant(status?: string): 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'info' {
switch (status) {
case 'completed':
case 'success':
return 'success';
case 'error':
case 'failed':
return 'warning';
case 'running':
case 'pending':
return 'info';
default:
return 'secondary';
}
}
// ========== Sub-Components ==========
/**
* TokenBar - Horizontal stacked bar for token usage
*/
function TokenBar({ tokens, className }: TokenBarProps) {
const { formatMessage } = useIntl();
const total = tokens.total || 0;
const input = tokens.input || 0;
const output = tokens.output || 0;
const cached = tokens.cached || 0;
// Calculate percentages
const inputPercent = total > 0 ? (input / total) * 100 : 0;
const outputPercent = total > 0 ? (output / total) * 100 : 0;
const cachedPercent = total > 0 ? (cached / total) * 100 : 0;
return (
<div className={cn('space-y-1.5', className)}>
{/* Visual bar */}
<div className="flex h-2 w-full rounded-full overflow-hidden bg-muted">
{input > 0 && (
<div
className="bg-blue-500/80"
style={{ width: `${inputPercent}%` }}
title={formatMessage({ id: 'nativeSession.timeline.tokens.input', defaultMessage: 'Input: {count}' }, { count: formatTokenCount(input) })}
/>
)}
{output > 0 && (
<div
className="bg-green-500/80"
style={{ width: `${outputPercent}%` }}
title={formatMessage({ id: 'nativeSession.timeline.tokens.output', defaultMessage: 'Output: {count}' }, { count: formatTokenCount(output) })}
/>
)}
{cached > 0 && (
<div
className="bg-amber-500/80"
style={{ width: `${cachedPercent}%` }}
title={formatMessage({ id: 'nativeSession.timeline.tokens.cached', defaultMessage: 'Cached: {count}' }, { count: formatTokenCount(cached) })}
/>
)}
</div>
{/* Labels */}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Coins className="h-3 w-3" />
{formatTokenCount(total)}
</span>
{input > 0 && (
<span className="flex items-center gap-1">
<ArrowDownUp className="h-3 w-3 text-blue-500" />
{formatTokenCount(input)}
</span>
)}
{output > 0 && (
<span className="text-green-600 dark:text-green-400">
out: {formatTokenCount(output)}
</span>
)}
{cached > 0 && (
<span className="flex items-center gap-1">
<Archive className="h-3 w-3 text-amber-500" />
{formatTokenCount(cached)}
</span>
)}
</div>
</div>
);
}
/**
* ToolCallPanel - Collapsible panel for tool call details
*/
function ToolCallPanel({ toolCall, index }: ToolCallPanelProps) {
const { formatMessage } = useIntl();
const [isExpanded, setIsExpanded] = React.useState(false);
return (
<div className="border border-border/50 rounded-lg overflow-hidden">
{/* Header */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
{getToolStatusIcon(toolCall.output ? 'completed' : undefined)}
<span className="font-mono font-medium">{toolCall.name}</span>
<span className="text-muted-foreground text-xs">#{index + 1}</span>
</div>
<Badge variant={getStatusVariant(toolCall.output ? 'completed' : undefined)} className="text-xs">
{formatMessage({ id: 'nativeSession.timeline.toolCall.completed', defaultMessage: 'completed' })}
</Badge>
</button>
{/* Collapsible content */}
{isExpanded && (
<div className="border-t border-border/50 divide-y divide-border/50">
{toolCall.arguments && (
<div className="p-3">
<p className="text-xs font-medium text-muted-foreground mb-1.5">
{formatMessage({ id: 'nativeSession.toolCall.input', defaultMessage: 'Input' })}
</p>
<pre className="p-2.5 bg-muted/30 rounded-md text-xs whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-48 overflow-y-auto">
{toolCall.arguments}
</pre>
</div>
)}
{toolCall.output && (
<div className="p-3">
<p className="text-xs font-medium text-muted-foreground mb-1.5">
{formatMessage({ id: 'nativeSession.toolCall.output', defaultMessage: 'Output' })}
</p>
<pre className="p-2.5 bg-muted/30 rounded-md text-xs whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-48 overflow-y-auto">
{toolCall.output}
</pre>
</div>
)}
{!toolCall.arguments && !toolCall.output && (
<div className="p-3 text-xs text-muted-foreground italic">
{formatMessage({ id: 'nativeSession.timeline.toolCall.noData', defaultMessage: 'No data available' })}
</div>
)}
</div>
)}
</div>
);
}
/**
* TurnNode - Single conversation turn on the timeline
*/
function TurnNode({ turn, isLatest, isLast }: TurnNodeProps) {
const { formatMessage } = useIntl();
const isUser = turn.role === 'user';
const RoleIcon = isUser ? User : Bot;
return (
<div className="flex gap-4">
{/* Timeline column */}
<div className="flex flex-col items-center">
{/* Node dot */}
<div
className={cn(
'flex items-center justify-center w-8 h-8 rounded-full border-2 shrink-0',
isUser
? 'bg-primary/10 border-primary text-primary'
: 'bg-blue-500/10 border-blue-500 text-blue-500',
isLatest && 'ring-2 ring-offset-2 ring-offset-background',
isLatest && (isUser ? 'ring-primary/50' : 'ring-blue-500/50')
)}
>
<RoleIcon className="h-4 w-4" />
</div>
{/* Vertical connector line */}
{!isLast && (
<div className="w-0.5 flex-1 bg-border min-h-4" aria-hidden="true" />
)}
</div>
{/* Content column */}
<div className="flex-1 pb-6">
{/* Header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className={cn(
'font-semibold text-sm capitalize',
isUser ? 'text-primary' : 'text-blue-500'
)}>
{turn.role}
</span>
<span className="text-xs text-muted-foreground">
{formatMessage(
{ id: 'nativeSession.timeline.turnNumber', defaultMessage: 'Turn #{number}' },
{ number: turn.turnNumber }
)}
</span>
{isLatest && (
<Badge variant="default" className="text-xs h-5 px-1.5">
{formatMessage({ id: 'nativeSession.turn.latest', defaultMessage: 'Latest' })}
</Badge>
)}
</div>
{turn.timestamp && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{new Date(turn.timestamp).toLocaleTimeString()}
</span>
)}
</div>
{/* Content card */}
<div
className={cn(
'rounded-lg border overflow-hidden',
isUser
? 'bg-muted/30 border-border'
: 'bg-blue-500/5 dark:bg-blue-500/10 border-blue-500/20'
)}
>
{/* Message content */}
{turn.content && (
<div className="p-4">
<pre className="text-sm whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-64 overflow-y-auto">
{turn.content}
</pre>
</div>
)}
{/* Thoughts section */}
{turn.thoughts && turn.thoughts.length > 0 && (
<details className="group/thoughts border-t border-border/50">
<summary className="flex items-center gap-2 px-4 py-2.5 text-sm cursor-pointer hover:bg-muted/30 select-none">
<Brain className="h-4 w-4 text-purple-500" />
<span className="font-medium text-muted-foreground">
{formatMessage({ id: 'nativeSession.turn.thoughts', defaultMessage: 'Thoughts' })}
</span>
<span className="text-xs text-muted-foreground">
({turn.thoughts.length})
</span>
</summary>
<ul className="px-4 pb-3 space-y-1 text-sm text-muted-foreground list-disc list-inside">
{turn.thoughts.map((thought, i) => (
<li key={i} className="leading-relaxed pl-2">{thought}</li>
))}
</ul>
</details>
)}
{/* Tool calls section */}
{turn.toolCalls && turn.toolCalls.length > 0 && (
<div className="border-t border-border/50 p-4 space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<Wrench className="h-4 w-4" />
<span className="font-medium">
{formatMessage({ id: 'nativeSession.turn.toolCalls', defaultMessage: 'Tool Calls' })}
</span>
<span className="text-xs">({turn.toolCalls.length})</span>
</div>
{turn.toolCalls.map((tc, i) => (
<ToolCallPanel key={i} toolCall={tc} index={i} />
))}
</div>
)}
{/* Token usage bar */}
{turn.tokens && (
<div className="border-t border-border/50 px-4 py-3">
<TokenBar tokens={turn.tokens} />
</div>
)}
</div>
</div>
</div>
);
}
// ========== Main Component ==========
/**
* SessionTimeline - Timeline visualization for native CLI sessions
*
* Displays conversation turns in a vertical timeline layout with:
* - Left side: Timeline nodes with role icons
* - Right side: Content cards with messages, thoughts, and tool calls
* - Token usage bars with stacked input/output/cached visualization
* - Collapsible tool call panels
*/
export function SessionTimeline({ session, className }: SessionTimelineProps) {
const { formatMessage } = useIntl();
const turns = session.turns || [];
return (
<div className={cn('space-y-0', className)}>
{/* Session token summary bar */}
{session.totalTokens && (
<div className="mb-6 p-4 rounded-lg bg-muted/30 border">
<p className="text-xs font-medium text-muted-foreground mb-2">
{formatMessage({ id: 'nativeSession.tokenSummary', defaultMessage: 'Total Tokens' })}
</p>
<TokenBar tokens={session.totalTokens} />
</div>
)}
{/* Timeline turns */}
{turns.length > 0 ? (
<div className="relative">
{turns.map((turn, idx) => (
<TurnNode
key={turn.turnNumber}
turn={turn}
isLatest={idx === turns.length - 1}
isLast={idx === turns.length - 1}
/>
))}
</div>
) : (
<div className="flex items-center justify-center py-12 text-muted-foreground">
{formatMessage({ id: 'nativeSession.empty', defaultMessage: 'No session data available' })}
</div>
)}
</div>
);
}
export default SessionTimeline;

View File

@@ -2064,7 +2064,30 @@ export interface NativeSession {
}
/**
* Fetch native CLI session content by execution ID
* Options for fetching native session
*/
export interface FetchNativeSessionOptions {
executionId?: string;
projectPath?: string;
/** Direct file path to session file (bypasses ccw execution ID lookup) */
filePath?: string;
/** Tool type for file path query: claude | opencode | codex | qwen | gemini | auto */
tool?: 'claude' | 'opencode' | 'codex' | 'qwen' | 'gemini' | 'auto';
/** Output format: json (default) | text | pairs */
format?: 'json' | 'text' | 'pairs';
/** Include thoughts in text format */
thoughts?: boolean;
/** Include tool calls in text format */
tools?: boolean;
/** Include token counts in text format */
tokens?: boolean;
}
/**
* Fetch native CLI session content by execution ID or file path
* @param executionId - CCW execution ID (backward compatible)
* @param projectPath - Optional project path
* @deprecated Use fetchNativeSessionWithOptions for new features
*/
export async function fetchNativeSession(
executionId: string,
@@ -2077,6 +2100,88 @@ export async function fetchNativeSession(
);
}
/**
* Fetch native CLI session content with full options
* Supports both execution ID lookup and direct file path query
*/
export async function fetchNativeSessionWithOptions(
options: FetchNativeSessionOptions
): Promise<NativeSession | string | Array<{ turn: number; userPrompt: string; assistantResponse: string; timestamp: string }>> {
const params = new URLSearchParams();
// Priority: filePath > executionId
if (options.filePath) {
params.set('filePath', options.filePath);
if (options.tool) params.set('tool', options.tool);
} else if (options.executionId) {
params.set('id', options.executionId);
} else {
throw new Error('Either executionId or filePath is required');
}
if (options.projectPath) params.set('path', options.projectPath);
if (options.format) params.set('format', options.format);
if (options.thoughts) params.set('thoughts', 'true');
if (options.tools) params.set('tools', 'true');
if (options.tokens) params.set('tokens', 'true');
const url = `/api/cli/native-session?${params.toString()}`;
// Text format returns string, others return JSON
if (options.format === 'text') {
const response = await fetch(url, { credentials: 'same-origin' });
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Request failed' }));
throw new Error(error.error || response.statusText);
}
return response.text();
}
return fetchApi<NativeSession | Array<{ turn: number; userPrompt: string; assistantResponse: string; timestamp: string }>>(url);
}
// ========== Native Sessions List API ==========
/**
* Native session metadata for list endpoint
*/
export interface NativeSessionListItem {
id: string;
tool: string;
path: string;
title?: string;
startTime: string;
updatedAt: string;
projectHash?: string;
}
/**
* Native sessions list response
*/
export interface NativeSessionsListResponse {
sessions: NativeSessionListItem[];
count: number;
}
/**
* Fetch list of native CLI sessions
* @param tool - Filter by tool type (optional)
* @param project - Filter by project path (optional)
*/
export async function fetchNativeSessions(
tool?: 'gemini' | 'qwen' | 'codex' | 'claude' | 'opencode',
project?: string
): Promise<NativeSessionsListResponse> {
const params = new URLSearchParams();
if (tool) params.set('tool', tool);
if (project) params.set('project', project);
const query = params.toString();
return fetchApi<NativeSessionsListResponse>(
`/api/cli/native-sessions${query ? `?${query}` : ''}`
);
}
// ========== CLI Tools Config API ==========
export interface CliToolsConfigResponse {

View File

@@ -112,6 +112,10 @@
"session-end-summary": {
"name": "Session End Summary",
"description": "Send session summary to dashboard on session end"
},
"project-state-inject": {
"name": "Project State Inject",
"description": "Inject project guidelines and recent dev history at session start"
}
},
"actions": {

View File

@@ -6,6 +6,20 @@
"output": "Output tokens",
"cached": "Cached tokens"
},
"timeline": {
"turnNumber": "Turn #{number}",
"tokens": {
"input": "Input: {count}",
"output": "Output: {count}",
"cached": "Cached: {count}"
},
"toolCall": {
"completed": "completed",
"running": "running",
"error": "error",
"noData": "No data available"
}
},
"turn": {
"latest": "Latest",
"thoughts": "Thoughts",

View File

@@ -112,6 +112,10 @@
"session-end-summary": {
"name": "会话结束摘要",
"description": "会话结束时发送摘要到仪表盘"
},
"project-state-inject": {
"name": "项目状态注入",
"description": "会话启动时注入项目约束和最近开发历史"
}
},
"actions": {

View File

@@ -6,6 +6,20 @@
"output": "输出 Token",
"cached": "缓存 Token"
},
"timeline": {
"turnNumber": "第 {number} 轮",
"tokens": {
"input": "输入: {count}",
"output": "输出: {count}",
"cached": "缓存: {count}"
},
"toolCall": {
"completed": "已完成",
"running": "运行中",
"error": "错误",
"noData": "无数据"
}
},
"turn": {
"latest": "最新",
"thoughts": "思考过程",