Skip to content

Commit e2c99c7

Browse files
committed
feat: Codex-inspired chat improvements — exec cards, elapsed timer, context %, mode cycling
Closes gap with Codex TUI on 4 key fronts: 1. Exec Command Cards: shell commands render as discrete cards with syntax-highlighted command text, expandable stdout/stderr output, exit code badges (✓/✗), and per-command duration timers. 2. Elapsed Time Tracker: shows total turn duration in activity feed summary bar, updates at 100ms intervals during streaming. 3. Context Window % in Header: always-visible token count (e.g. '24K/128K') with color coding at 80%/95% thresholds. Previously only showed during agent runs. 4. Shift+Tab Mode Cycling: keyboard shortcut cycles Ask → Agent → Plan in the composer, matching Codex's Shift+Tab collaboration mode cycling. Also: enhanced AgentActivity with command/output/exitCode/durationMs fields, completeCommandActivity() helper, formatDuration() utility.
1 parent 0b194c9 commit e2c99c7

File tree

5 files changed

+219
-106
lines changed

5 files changed

+219
-106
lines changed

components/agent-panel.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,8 @@ export function AgentPanel() {
387387
const [isStreaming, setIsStreaming] = useState(false)
388388
const [thinkingTrail, setThinkingTrail] = useState<string[]>([])
389389
const [agentActivities, setAgentActivities] = useState<import('@/lib/agent-activity').AgentActivity[]>([])
390+
const [turnStartTime, setTurnStartTime] = useState<number | null>(null)
391+
const [turnElapsedMs, setTurnElapsedMs] = useState(0)
390392
const [activeDiff, setActiveDiff] = useState<{
391393
proposal: EditProposal
392394
messageId: string
@@ -536,6 +538,22 @@ export function AgentPanel() {
536538
streamStateRef.current.isSending = sending
537539
}, [sending])
538540

541+
// ─── Elapsed time tracker ─────────────────────────────────────
542+
useEffect(() => {
543+
if (isStreaming || sending) {
544+
if (!turnStartTime) setTurnStartTime(Date.now())
545+
const timer = setInterval(() => {
546+
setTurnElapsedMs(turnStartTime ? Date.now() - turnStartTime : 0)
547+
}, 100)
548+
return () => clearInterval(timer)
549+
} else {
550+
if (turnStartTime) {
551+
setTurnElapsedMs(Date.now() - turnStartTime)
552+
setTurnStartTime(null)
553+
}
554+
}
555+
}, [isStreaming, sending, turnStartTime])
556+
539557
// ─── Listen for chat events (streaming replies) ───────────────
540558
useEffect(() => {
541559
const callbacks = {
@@ -1923,6 +1941,16 @@ export function AgentPanel() {
19231941
return
19241942
}
19251943
}
1944+
// Shift+Tab: cycle agent mode (Ask → Agent → Plan → Ask)
1945+
if (e.key === 'Tab' && e.shiftKey) {
1946+
e.preventDefault()
1947+
setAgentMode((prev) => {
1948+
const modes: AgentMode[] = ['ask', 'agent', 'plan']
1949+
const idx = modes.indexOf(prev)
1950+
return modes[(idx + 1) % modes.length]
1951+
})
1952+
return
1953+
}
19261954
if (e.key === 'Enter' && !e.shiftKey) {
19271955
e.preventDefault()
19281956
sendMessage()
@@ -2157,6 +2185,7 @@ export function AgentPanel() {
21572185
isStreaming={isStreaming}
21582186
thinkingTrail={thinkingTrail}
21592187
agentActivities={agentActivities}
2188+
turnElapsedMs={turnElapsedMs}
21602189
agentMode={agentMode}
21612190
onShowDiff={handleShowDiff}
21622191
onQuickApply={handleQuickApply}

components/chat-header.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,18 @@ export function ChatHeader({
105105
)}
106106
</span>
107107
)}
108+
{/* Context token count */}
109+
{contextTokens > 0 && (
110+
<span className={`inline-flex items-center gap-1 whitespace-nowrap text-[9px] tabular-nums ${
111+
contextPct > 80 ? 'text-amber-400' : contextPct > 95 ? 'text-red-400' : 'text-[var(--text-disabled)]'
112+
}`}>
113+
<Icon icon="lucide:database" width={9} height={9} />
114+
{contextTokens >= 1000 ? `${(contextTokens / 1000).toFixed(0)}K` : contextTokens}
115+
<span className="text-[8px]">/{maxContextTokens >= 1000 ? `${(maxContextTokens / 1000).toFixed(0)}K` : maxContextTokens}</span>
116+
</span>
117+
)}
108118
<span className="whitespace-nowrap text-[11px] text-[var(--text-disabled)]">
109-
{messageCount} messages
119+
{messageCount} msg
110120
</span>
111121
</div>
112122
</div>

components/chat/agent-activity-feed.tsx

Lines changed: 126 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -4,149 +4,109 @@ import { useState } from 'react'
44
import { Icon } from '@iconify/react'
55
import {
66
type AgentActivity,
7-
summarizeActivities,
87
activityIcon,
98
activityColor,
9+
formatDuration,
10+
summarizeActivities,
1011
} from '@/lib/agent-activity'
1112

1213
interface Props {
1314
activities: AgentActivity[]
1415
isRunning: boolean
16+
elapsedMs?: number
1517
}
1618

17-
export function AgentActivityFeed({ activities, isRunning }: Props) {
19+
/**
20+
* Codex-inspired activity feed — collapsible summary bar + expandable timeline.
21+
* Exec commands render as discrete cards with command text, output, and exit code.
22+
*/
23+
export function AgentActivityFeed({ activities, isRunning, elapsedMs }: Props) {
1824
const [expanded, setExpanded] = useState(false)
1925
const summary = summarizeActivities(activities)
2026

21-
if (activities.length === 0) return null
27+
if (activities.length === 0 && !isRunning) return null
2228

2329
const lastActivity = activities[activities.length - 1]
2430

2531
return (
26-
<div className="rounded-xl border border-[var(--border)] bg-[var(--bg-subtle)] overflow-hidden my-1.5">
27-
{/* Compact summary bar */}
32+
<div className="mx-2 mb-2">
33+
{/* Summary bar — always visible */}
2834
<button
29-
onClick={() => setExpanded(v => !v)}
30-
className="w-full flex items-center gap-2 px-3 py-2 text-left cursor-pointer hover:bg-[color-mix(in_srgb,var(--text-primary)_3%,transparent)] transition-colors"
35+
onClick={() => setExpanded(!expanded)}
36+
className="w-full flex items-center gap-2 px-3 py-1.5 rounded-lg text-[11px] transition-colors cursor-pointer
37+
bg-[color-mix(in_srgb,var(--bg-elevated)_80%,transparent)]
38+
border border-[var(--border)]
39+
hover:bg-[color-mix(in_srgb,var(--text-primary)_4%,transparent)]"
3140
>
32-
{isRunning && (
41+
{/* Status indicator */}
42+
{isRunning ? (
3343
<span className="relative flex h-2 w-2 shrink-0">
3444
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[var(--brand)] opacity-50" />
3545
<span className="relative inline-flex rounded-full h-2 w-2 bg-[var(--brand)]" />
3646
</span>
37-
)}
38-
{!isRunning && (
39-
<Icon icon="lucide:check-circle" width={12} className="text-[var(--color-additions)] shrink-0" />
47+
) : (
48+
<Icon icon="lucide:check-circle-2" width={12} className="text-[color-mix(in_srgb,#34d399_80%,var(--brand))] shrink-0" />
4049
)}
4150

4251
{/* Current action or summary */}
43-
<span className="text-[11px] text-[var(--text-secondary)] flex-1 truncate">
44-
{isRunning
52+
<span className="text-[var(--text-secondary)] truncate flex-1 text-left">
53+
{isRunning && lastActivity
4554
? lastActivity.label
46-
: `${summary.totalActions} actions`}
55+
: `${summary.totalActions} action${summary.totalActions !== 1 ? 's' : ''} completed`}
4756
</span>
4857

49-
{/* File change badges */}
50-
<div className="flex items-center gap-1.5">
58+
{/* Badges */}
59+
<span className="flex items-center gap-1.5 shrink-0">
5160
{summary.filesEdited.length > 0 && (
52-
<span className="inline-flex items-center gap-0.5 text-[9px] font-medium text-amber-400">
53-
<Icon icon="lucide:file-pen-line" width={10} />
61+
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-[color-mix(in_srgb,#fbbf24_10%,transparent)] text-[color-mix(in_srgb,#fbbf24_80%,var(--brand))]">
62+
<Icon icon="lucide:file-pen-line" width={9} />
5463
{summary.filesEdited.length}
5564
</span>
5665
)}
5766
{summary.filesCreated.length > 0 && (
58-
<span className="inline-flex items-center gap-0.5 text-[9px] font-medium text-green-400">
59-
<Icon icon="lucide:file-plus" width={10} />
67+
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-[color-mix(in_srgb,#34d399_10%,transparent)] text-[color-mix(in_srgb,#34d399_80%,var(--brand))]">
68+
<Icon icon="lucide:file-plus" width={9} />
6069
{summary.filesCreated.length}
6170
</span>
6271
)}
63-
{summary.filesRead.length > 0 && (
64-
<span className="inline-flex items-center gap-0.5 text-[9px] font-medium text-blue-400">
65-
<Icon icon="lucide:file-search" width={10} />
66-
{summary.filesRead.length}
67-
</span>
68-
)}
6972
{summary.commandsRun > 0 && (
70-
<span className="inline-flex items-center gap-0.5 text-[9px] font-medium text-cyan-400">
71-
<Icon icon="lucide:terminal" width={10} />
73+
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-[color-mix(in_srgb,#22d3ee_10%,transparent)] text-[color-mix(in_srgb,#22d3ee_80%,var(--brand))]">
74+
<Icon icon="lucide:terminal" width={9} />
7275
{summary.commandsRun}
7376
</span>
7477
)}
75-
</div>
78+
{/* Elapsed time */}
79+
{elapsedMs != null && elapsedMs > 0 && (
80+
<span className="text-[10px] text-[var(--text-disabled)] tabular-nums">
81+
{formatDuration(elapsedMs)}
82+
</span>
83+
)}
84+
</span>
7685

77-
<Icon
78-
icon={expanded ? 'lucide:chevron-up' : 'lucide:chevron-down'}
79-
width={11}
80-
className="text-[var(--text-disabled)] shrink-0"
81-
/>
86+
<Icon icon={expanded ? 'lucide:chevron-up' : 'lucide:chevron-down'} width={12} className="text-[var(--text-disabled)] shrink-0" />
8287
</button>
8388

8489
{/* Expanded timeline */}
8590
{expanded && (
86-
<div className="border-t border-[var(--border)] px-3 py-2 max-h-[240px] overflow-y-auto">
87-
<div className="relative flex flex-col gap-0">
88-
{/* Timeline line */}
89-
<div className="absolute left-[5px] top-2 bottom-2 w-px bg-[color-mix(in_srgb,var(--brand)_15%,var(--border))]" />
90-
91-
{activities.map((act, i) => {
92-
const isLast = i === activities.length - 1
93-
return (
94-
<div
95-
key={act.id}
96-
className="flex items-start gap-2.5 py-1 relative"
97-
>
98-
{/* Timeline dot */}
99-
<div className="relative z-[1] shrink-0 mt-0.5">
100-
{isLast && isRunning ? (
101-
<span className="relative flex h-[9px] w-[9px]">
102-
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[var(--brand)] opacity-50" />
103-
<span className="relative inline-flex rounded-full h-[9px] w-[9px] bg-[var(--brand)]" />
104-
</span>
105-
) : (
106-
<span className={`block w-[9px] h-[9px] rounded-full border-2 ${
107-
act.status === 'error'
108-
? 'border-[var(--color-deletions)] bg-[var(--color-deletions)]'
109-
: 'border-[color-mix(in_srgb,var(--brand)_40%,var(--border))] bg-[var(--bg-subtle)]'
110-
}`} />
111-
)}
112-
</div>
113-
114-
{/* Activity content */}
115-
<div className="flex items-center gap-1.5 min-w-0 flex-1">
116-
<Icon
117-
icon={activityIcon(act.type)}
118-
width={11}
119-
className={`shrink-0 ${activityColor(act.type)}`}
120-
/>
121-
<span className={`text-[10px] truncate ${
122-
isLast && isRunning ? 'text-[var(--text-primary)] font-medium' : 'text-[var(--text-disabled)]'
123-
}`}>
124-
{act.label}
125-
</span>
126-
</div>
127-
128-
{/* File chip */}
129-
{act.file && (
130-
<span className="text-[8px] font-mono text-[var(--text-disabled)] truncate max-w-[100px]">
131-
{act.file.split('/').pop()}
132-
</span>
133-
)}
134-
</div>
135-
)
136-
})}
91+
<div className="mt-1 rounded-lg border border-[var(--border)] bg-[color-mix(in_srgb,var(--bg-elevated)_90%,transparent)] overflow-hidden">
92+
<div className="max-h-64 overflow-y-auto">
93+
{activities.map((activity, idx) => (
94+
<ActivityItem key={activity.id} activity={activity} isLast={idx === activities.length - 1 && isRunning} />
95+
))}
13796
</div>
13897

13998
{/* Changed files summary */}
140-
{!isRunning && summary.filesEdited.length > 0 && (
141-
<div className="mt-2 pt-2 border-t border-[var(--border)]">
142-
<p className="text-[9px] uppercase tracking-wider text-[var(--text-disabled)] font-medium mb-1">Changed Files</p>
99+
{!isRunning && (summary.filesEdited.length > 0 || summary.filesCreated.length > 0) && (
100+
<div className="border-t border-[var(--border)] px-3 py-2">
101+
<div className="text-[10px] font-medium text-[var(--text-disabled)] uppercase tracking-wider mb-1">Changed Files</div>
143102
<div className="flex flex-wrap gap-1">
144103
{[...summary.filesEdited, ...summary.filesCreated].map(f => (
145-
<span
146-
key={f}
147-
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-mono bg-[color-mix(in_srgb,var(--brand)_6%,transparent)] border border-[color-mix(in_srgb,var(--brand)_20%,var(--border))] text-[var(--text-secondary)]"
148-
>
149-
<Icon icon={summary.filesCreated.includes(f) ? 'lucide:file-plus' : 'lucide:file-pen-line'} width={9} />
104+
<span key={f} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-[color-mix(in_srgb,var(--text-primary)_5%,transparent)] text-[10px] text-[var(--text-secondary)] font-mono">
105+
{summary.filesCreated.includes(f) ? (
106+
<span className="w-1.5 h-1.5 rounded-full bg-[color-mix(in_srgb,#34d399_80%,var(--brand))]" />
107+
) : (
108+
<span className="w-1.5 h-1.5 rounded-full bg-[color-mix(in_srgb,#fbbf24_80%,var(--brand))]" />
109+
)}
150110
{f.split('/').pop()}
151111
</span>
152112
))}
@@ -158,3 +118,76 @@ export function AgentActivityFeed({ activities, isRunning }: Props) {
158118
</div>
159119
)
160120
}
121+
122+
/** Single activity item — exec commands get a card treatment */
123+
function ActivityItem({ activity, isLast }: { activity: AgentActivity; isLast: boolean }) {
124+
const [outputExpanded, setOutputExpanded] = useState(false)
125+
const isCommand = activity.type === 'command'
126+
const color = activityColor(activity.type)
127+
128+
return (
129+
<div className="flex gap-2 px-3 py-1.5 relative">
130+
{/* Timeline dot */}
131+
<div className="flex flex-col items-center shrink-0 pt-0.5">
132+
<div
133+
className="w-3 h-3 rounded-full flex items-center justify-center"
134+
style={{ backgroundColor: `color-mix(in srgb, ${color} 15%, transparent)` }}
135+
>
136+
{isLast && activity.status === 'running' ? (
137+
<span className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ backgroundColor: color }} />
138+
) : (
139+
<Icon icon={activityIcon(activity.type)} width={8} style={{ color }} />
140+
)}
141+
</div>
142+
</div>
143+
144+
{/* Content */}
145+
<div className="flex-1 min-w-0">
146+
{isCommand ? (
147+
/* Exec command card */
148+
<div className="rounded-md border border-[var(--border)] bg-[color-mix(in_srgb,var(--bg)_95%,transparent)] overflow-hidden">
149+
{/* Command header */}
150+
<div className="flex items-center gap-2 px-2 py-1 bg-[color-mix(in_srgb,var(--text-primary)_3%,transparent)]">
151+
<Icon icon="lucide:terminal" width={10} style={{ color }} />
152+
<code className="text-[10px] text-[var(--text-primary)] font-mono truncate flex-1">{activity.label}</code>
153+
<span className="flex items-center gap-1.5 shrink-0">
154+
{activity.durationMs != null && (
155+
<span className="text-[9px] text-[var(--text-disabled)] tabular-nums">{formatDuration(activity.durationMs)}</span>
156+
)}
157+
{activity.status === 'running' ? (
158+
<span className="text-[9px] text-[var(--brand)] animate-pulse">running</span>
159+
) : activity.exitCode === 0 || activity.exitCode === undefined ? (
160+
<Icon icon="lucide:check" width={10} className="text-[color-mix(in_srgb,#34d399_80%,var(--brand))]" />
161+
) : (
162+
<span className="text-[9px] text-red-400 font-mono">exit {activity.exitCode}</span>
163+
)}
164+
</span>
165+
</div>
166+
{/* Output preview */}
167+
{activity.output && (
168+
<button
169+
onClick={() => setOutputExpanded(!outputExpanded)}
170+
className="w-full text-left px-2 py-1 border-t border-[var(--border)] cursor-pointer hover:bg-[color-mix(in_srgb,var(--text-primary)_2%,transparent)]"
171+
>
172+
<pre className={`text-[9px] text-[var(--text-tertiary)] font-mono whitespace-pre-wrap ${outputExpanded ? '' : 'line-clamp-3'}`}>
173+
{activity.output}
174+
</pre>
175+
{!outputExpanded && activity.output.split('\n').length > 3 && (
176+
<span className="text-[9px] text-[var(--text-disabled)]">click to expand…</span>
177+
)}
178+
</button>
179+
)}
180+
</div>
181+
) : (
182+
/* Standard activity row */
183+
<div className="flex items-center gap-1.5">
184+
<span className="text-[11px] text-[var(--text-secondary)] truncate">{activity.label}</span>
185+
{activity.durationMs != null && (
186+
<span className="text-[9px] text-[var(--text-disabled)] tabular-nums shrink-0">{formatDuration(activity.durationMs)}</span>
187+
)}
188+
</div>
189+
)}
190+
</div>
191+
</div>
192+
)
193+
}

components/chat/message-list.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ interface MessageListProps {
1919
isStreaming: boolean
2020
thinkingTrail: string[]
2121
agentActivities?: import('@/lib/agent-activity').AgentActivity[]
22+
turnElapsedMs?: number
2223
agentMode: string
2324
onShowDiff: (proposal: EditProposal, messageId: string) => void
2425
onQuickApply: (proposal: EditProposal) => void
@@ -185,6 +186,7 @@ export function MessageList({
185186
isStreaming,
186187
thinkingTrail,
187188
agentActivities = [],
189+
turnElapsedMs = 0,
188190
agentMode,
189191
onShowDiff,
190192
onQuickApply,
@@ -605,7 +607,7 @@ export function MessageList({
605607
{/* Agent activity feed (structured) or inline tool badges (fallback) */}
606608
{agentActivities.length > 0 && !streamBuffer && (
607609
<div className="w-full mb-1.5">
608-
<AgentActivityFeed activities={agentActivities} isRunning={isStreaming} />
610+
<AgentActivityFeed activities={agentActivities} isRunning={isStreaming} elapsedMs={turnElapsedMs} />
609611
</div>
610612
)}
611613
{agentActivities.length === 0 && thinkingTrail.length > 0 && !streamBuffer && (

0 commit comments

Comments
 (0)