fix: Per-agent terminal isolation, floating terminal z-index, and char-by-char input

- Add :key to PromptBar to force remount on agent switch, fixing shared terminal session bug
- Raise AgentTerminal z-index above PromptBar backdrop so floating terminal is visible/clickable
- Send prompt text char-by-char (15ms delay) matching FloatingVoice pattern for Claude Code compat
- Guard xterm dispose against unloaded addons to prevent errors on agent switch
- Widen PromptBar panel from 360px to 420px to fit all ChatInput buttons
This commit is contained in:
2026-02-16 00:41:38 -06:00
parent 59cc8ee87e
commit 55265d5145
18 changed files with 2308 additions and 96 deletions

View File

@@ -40,6 +40,7 @@ export interface TranscriptAnalysis {
thinkingBlocks: number
errors: number
}
lastStopReason: string
}
export interface TranscriptMessage {
@@ -83,6 +84,49 @@ export interface SessionInfo {
model: string
}
export interface ClaudeStats {
today: {
date: string
messageCount: number
sessionCount: number
toolCallCount: number
tokensByModel: Record<string, number>
} | null
modelUsage: Record<string, {
inputTokens: number
outputTokens: number
cacheReadInputTokens: number
cacheCreationInputTokens: number
costUSD: number
}>
totalSessions: number
totalMessages: number
firstSessionDate: string
}
export interface ClaudeUsage {
subscription: { type: string; tier: string; label: string; multiplier: number }
today: { messages: number; outputTokens: number; sessions: number }
daily: { used: number; limit: number; percent: number }
weekly: { used: number; limit: number; percent: number }
status: 'normal' | 'elevated' | 'extended' | 'limit_approaching'
}
const TIER_LIMITS: Record<string, { windowMessages: number; dailyEstimate: number; weeklyEstimate: number }> = {
pro: { windowMessages: 45, dailyEstimate: 500, weeklyEstimate: 3500 },
max_5x: { windowMessages: 225, dailyEstimate: 2500, weeklyEstimate: 17500 },
max_20x: { windowMessages: 900, dailyEstimate: 10000, weeklyEstimate: 70000 },
}
function parseTier(rateLimitTier: string): { key: string; label: string; multiplier: number } {
const match = rateLimitTier.match(/max_(\d+)x/)
if (match) {
const n = parseInt(match[1])
return { key: `max_${n}x`, label: `Max ${n}x`, multiplier: n }
}
return { key: 'pro', label: 'Pro', multiplier: 1 }
}
// ── Module-level cache ──
const cache = new Map<string, {
@@ -90,6 +134,16 @@ const cache = new Map<string, {
lastModified: number
}>()
// ── Active session tracking (for real-time chat) ──
const activeAgentSessions = new Map<string, {
sessionId: string
transcriptPath: string
}>()
// Byte offset per session (how far we've read)
const readOffsets = new Map<string, number>()
// ── Project hash ──
function getProjectHash(): string {
@@ -214,6 +268,7 @@ function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAn
toolNames: string[]
hasThinking: boolean
usage: any
stopReason: string
pendingToolCalls: { name: string; input: any; id: string; timestamp: string }[]
}>()
@@ -293,6 +348,7 @@ function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAn
toolNames: [],
hasThinking: false,
usage: null,
stopReason: '',
pendingToolCalls: []
}
assistantChunks.set(msgId, chunk)
@@ -300,6 +356,7 @@ function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAn
// Take latest usage (streaming chunks repeat usage, last is most accurate)
if (msg.usage) chunk.usage = msg.usage
if (msg.stop_reason) chunk.stopReason = msg.stop_reason
// Process content blocks (each JSONL line typically has one block)
if (Array.isArray(msg.content)) {
@@ -362,7 +419,9 @@ function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAn
// ── Second pass: assemble assistant messages and finalize tool calls ──
let turnIndex = 0
let lastStopReason = ''
for (const [, chunk] of assistantChunks) {
if (chunk.stopReason) lastStopReason = chunk.stopReason
const text = chunk.textParts.join('\n').trim()
if (text || chunk.toolNames.length > 0) {
@@ -477,14 +536,17 @@ function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAn
toolCallCount: toolCalls.length,
thinkingBlocks,
errors
}
},
lastStopReason
}
}
// ── Exported API ──
export function getTranscriptAnalysis(sessionId?: string): TranscriptAnalysis | null {
const filePath = resolveTranscriptPath(sessionId)
export function getTranscriptAnalysis(sessionId?: string, transcriptPath?: string): TranscriptAnalysis | null {
const filePath = transcriptPath && existsSync(transcriptPath)
? transcriptPath
: resolveTranscriptPath(sessionId)
if (!filePath) return null
const sid = sessionIdFromPath(filePath)
@@ -511,6 +573,283 @@ export function getTranscriptAnalysis(sessionId?: string): TranscriptAnalysis |
}
}
// ── Path normalization (Windows compat) ──
function normalizeTranscriptPath(tp: string): string {
tp = tp.replace(/\\/g, '/')
if (/^\/[a-zA-Z]\//.test(tp)) {
tp = tp[1].toUpperCase() + ':' + tp.slice(2)
}
return tp
}
// ── Active session API (for real-time chat) ──
export function setActiveSession(agent: string, sessionId: string, transcriptPath: string): void {
const normalizedPath = normalizeTranscriptPath(transcriptPath)
activeAgentSessions.set(agent, { sessionId, transcriptPath: normalizedPath })
// Initialize offset to current file size (don't replay old messages as "new")
if (!readOffsets.has(sessionId)) {
try {
const stat = statSync(normalizedPath)
readOffsets.set(sessionId, stat.size)
} catch {
readOffsets.set(sessionId, 0)
}
}
}
export function getActiveSession(agent: string): { sessionId: string; transcriptPath: string } | null {
return activeAgentSessions.get(agent) || null
}
export function resetSessionOffset(sessionId: string): void {
readOffsets.set(sessionId, 0)
}
export function getIncrementalMessages(sessionId: string, transcriptPath?: string): TranscriptMessage[] {
// Resolve path
let filePath = transcriptPath ? normalizeTranscriptPath(transcriptPath) : null
if (!filePath) {
const session = [...activeAgentSessions.values()].find(s => s.sessionId === sessionId)
filePath = session?.transcriptPath || null
}
if (!filePath) {
filePath = resolveTranscriptPath(sessionId)
}
if (!filePath || !existsSync(filePath)) return []
try {
const stat = statSync(filePath)
const fileSize = stat.size
const offset = readOffsets.get(sessionId) || 0
if (offset >= fileSize) return []
// Read only new bytes
const buffer = readFileSync(filePath)
const newContent = buffer.slice(offset, fileSize).toString('utf8')
// Update offset
readOffsets.set(sessionId, fileSize)
// Parse new lines
const rawLines = newContent.split('\n').filter(l => l.trim())
const messages: TranscriptMessage[] = []
// Track assistant chunks by message.id for consolidation
const assistantChunks = new Map<string, {
uuid: string
timestamp: string
textParts: string[]
toolNames: string[]
hasThinking: boolean
}>()
for (const line of rawLines) {
try {
const obj = JSON.parse(line)
if (obj.type === 'user') {
const msg = obj.message
if (!msg) continue
const isMeta = !!obj.isMeta
const text = extractText(msg.content)
const hasToolResult = Array.isArray(msg.content) &&
msg.content.some((c: any) => c.type === 'tool_result')
if (text && !hasToolResult && !isMeta) {
messages.push({
uuid: obj.uuid || crypto.randomUUID(),
role: 'user',
content: text,
timestamp: obj.timestamp || new Date().toISOString(),
isMeta: false,
hasThinking: false
})
}
} else if (obj.type === 'assistant') {
const msg = obj.message
if (!msg || msg.role !== 'assistant') continue
const msgId = msg.id || obj.uuid || 'unknown'
let chunk = assistantChunks.get(msgId)
if (!chunk) {
chunk = {
uuid: obj.uuid || crypto.randomUUID(),
timestamp: obj.timestamp || new Date().toISOString(),
textParts: [],
toolNames: [],
hasThinking: false
}
assistantChunks.set(msgId, chunk)
}
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === 'text' && block.text?.trim()) {
chunk.textParts.push(block.text)
} else if (block.type === 'thinking') {
chunk.hasThinking = true
} else if (block.type === 'tool_use') {
chunk.toolNames.push(block.name)
}
}
}
}
// Ignore progress, file-history-snapshot, summary
} catch {
// Skip unparseable lines
}
}
// Assemble assistant messages from consolidated chunks
for (const [, chunk] of assistantChunks) {
const text = chunk.textParts.join('\n').trim()
if (text || chunk.toolNames.length > 0) {
messages.push({
uuid: chunk.uuid,
role: 'assistant',
content: text || `[Tool calls: ${chunk.toolNames.join(', ')}]`,
timestamp: chunk.timestamp,
isMeta: false,
toolCalls: chunk.toolNames.length > 0 ? chunk.toolNames : undefined,
hasThinking: chunk.hasThinking
})
}
}
// Sort chronologically
messages.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
return messages
} catch (e) {
console.error('[transcript-engine] Incremental read error:', e)
return []
}
}
export function getClaudeStats(): ClaudeStats | null {
const statsPath = join(homedir(), '.claude', 'stats-cache.json')
if (!existsSync(statsPath)) return null
try {
const raw = JSON.parse(readFileSync(statsPath, 'utf8'))
const todayStr = new Date().toISOString().slice(0, 10)
// Find today's daily activity
const todayActivity = raw.dailyActivity?.find((d: any) => d.date === todayStr) || null
const todayTokens = raw.dailyModelTokens?.find((d: any) => d.date === todayStr) || null
const today = todayActivity ? {
date: todayStr,
messageCount: todayActivity.messageCount || 0,
sessionCount: todayActivity.sessionCount || 0,
toolCallCount: todayActivity.toolCallCount || 0,
tokensByModel: todayTokens?.tokensByModel || {}
} : null
// Model usage
const modelUsage: ClaudeStats['modelUsage'] = {}
if (raw.modelUsage) {
for (const [model, usage] of Object.entries(raw.modelUsage as Record<string, any>)) {
modelUsage[model] = {
inputTokens: usage.inputTokens || 0,
outputTokens: usage.outputTokens || 0,
cacheReadInputTokens: usage.cacheReadInputTokens || 0,
cacheCreationInputTokens: usage.cacheCreationInputTokens || 0,
costUSD: usage.costUSD || 0
}
}
}
return {
today,
modelUsage,
totalSessions: raw.totalSessions || 0,
totalMessages: raw.totalMessages || 0,
firstSessionDate: raw.firstSessionDate || ''
}
} catch (e) {
console.error('[transcript-engine] Error reading stats-cache.json:', e)
return null
}
}
export function getClaudeUsage(): ClaudeUsage | null {
const credentialsPath = join(homedir(), '.claude', '.credentials.json')
const statsPath = join(homedir(), '.claude', 'stats-cache.json')
if (!existsSync(credentialsPath)) return null
try {
// 1. Read credentials (never expose tokens)
const creds = JSON.parse(readFileSync(credentialsPath, 'utf8'))
const oauth = creds.claudeAiOauth || {}
const subType = oauth.subscriptionType || 'pro'
const rawTier = oauth.rateLimitTier || ''
const tier = parseTier(rawTier)
const limits = TIER_LIMITS[tier.key] || TIER_LIMITS.pro
// 2. Read stats-cache for daily + weekly data
let todayMessages = 0
let todayOutputTokens = 0
let todaySessions = 0
let weeklyMessages = 0
if (existsSync(statsPath)) {
const raw = JSON.parse(readFileSync(statsPath, 'utf8'))
const todayStr = new Date().toISOString().slice(0, 10)
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
for (const day of raw.dailyActivity || []) {
if (day.date === todayStr) {
todayMessages = day.messageCount || 0
todaySessions = day.sessionCount || 0
}
if (day.date >= sevenDaysAgo) {
weeklyMessages += day.messageCount || 0
}
}
// Output tokens for today
const todayTokens = (raw.dailyModelTokens || []).find((d: any) => d.date === todayStr)
if (todayTokens?.tokensByModel) {
todayOutputTokens = Object.values(todayTokens.tokensByModel as Record<string, number>)
.reduce((sum: number, v: number) => sum + v, 0)
}
}
// 3. Calculate percentages
const dailyPercent = limits.dailyEstimate > 0
? Math.round(todayMessages / limits.dailyEstimate * 100)
: 0
const weeklyPercent = limits.weeklyEstimate > 0
? Math.round(weeklyMessages / limits.weeklyEstimate * 100)
: 0
// 4. Determine status based on highest usage (daily or weekly)
const maxPercent = Math.max(dailyPercent, weeklyPercent)
let status: ClaudeUsage['status'] = 'normal'
if (maxPercent >= 100) status = 'extended'
else if (maxPercent >= 80) status = 'limit_approaching'
else if (maxPercent >= 50) status = 'elevated'
return {
subscription: { type: subType, tier: tier.key, label: tier.label, multiplier: tier.multiplier },
today: { messages: todayMessages, outputTokens: todayOutputTokens, sessions: todaySessions },
daily: { used: todayMessages, limit: limits.dailyEstimate, percent: dailyPercent },
weekly: { used: weeklyMessages, limit: limits.weeklyEstimate, percent: weeklyPercent },
status
}
} catch (e) {
console.error('[transcript-engine] Error reading claude usage:', e)
return null
}
}
export function listSessions(): SessionInfo[] {
const projectDir = getProjectDir()
if (!existsSync(projectDir)) return []