Skip to content

Commit 5382d83

Browse files
PurpleDoubleDclaude
andcommitted
feat: Codex coding agent, realtime counter, light mode, UI polish
Codex Mode: - Three-tab system in sidebar (LU | Codex | OpenClaw coming soon) - Codex coding agent with dedicated useCodex hook - File tree panel with folder navigation (back, refresh, click-to-browse) - Working directory injection for shell/file tools - Separate conversations per mode (LU chats ≠ Codex chats) - CodexView with tool blocks, thinking, token counter, memory - Mode switch clears active chat from other mode UI Polish: - Realtime elapsed time counter during generation (both modes) - Code blocks collapse by default (>4 lines) - Tool call blocks: monochrome, no dropdown arrows, click to expand - Message bubbles: compact, minimal borders, light mode support - Light mode fixes across all new components - Strip Gemma 4 <channel> tags from output Thinking: - Provider-agnostic: Ollama native think:true, OpenAI/Anthropic via system prompt with <think> tag parser fallback - Think toggle button in both LU and Codex headers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 28122f0 commit 5382d83

18 files changed

Lines changed: 1019 additions & 90 deletions

src/components/chat/ChatInput.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ export function ChatInput({ onSend, onStop, isGenerating, pendingApproval, onApp
117117
className={`flex flex-col rounded-lg border px-2.5 py-2 transition-colors ${
118118
isDragOver
119119
? 'bg-blue-500/5 border-blue-500/30'
120-
: 'bg-white/[0.03] border-white/[0.06]'
120+
: 'bg-gray-50 dark:bg-white/[0.03] border-gray-200 dark:border-white/[0.06]'
121121
}`}
122122
onDragOver={handleDragOver}
123123
onDragLeave={handleDragLeave}
@@ -181,7 +181,7 @@ export function ChatInput({ onSend, onStop, isGenerating, pendingApproval, onApp
181181
onPaste={handlePaste}
182182
placeholder={isDragOver ? "Drop images here..." : isTranscribing ? "Transcribing..." : isVoiceRecording ? "Recording..." : "Message..."}
183183
rows={1}
184-
className="flex-1 bg-transparent resize-none text-gray-200 placeholder-gray-600 focus:outline-none text-[0.75rem] leading-relaxed max-h-[200px]"
184+
className="flex-1 bg-transparent resize-none text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-600 focus:outline-none text-[0.75rem] leading-relaxed max-h-[200px]"
185185
/>
186186

187187
{isGenerating ? (
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { MessageSquare, Code, Bot } from 'lucide-react'
2+
import { useCodexStore } from '../../stores/codexStore'
3+
import type { ChatMode } from '../../types/codex'
4+
5+
const TABS: { mode: ChatMode; label: string; icon: typeof Code; disabled?: boolean; tag?: string }[] = [
6+
{ mode: 'lu', label: 'LU', icon: MessageSquare },
7+
{ mode: 'codex', label: 'Codex', icon: Code },
8+
{ mode: 'openclaw', label: 'OpenClaw', icon: Bot, disabled: true, tag: 'Soon' },
9+
]
10+
11+
export function ChatModeTabs() {
12+
const chatMode = useCodexStore((s) => s.chatMode)
13+
const setChatMode = useCodexStore((s) => s.setChatMode)
14+
15+
return (
16+
<div className="flex items-center gap-0.5 px-2 py-0.5">
17+
{TABS.map(({ mode, label, icon: Icon, disabled, tag }) => {
18+
const isActive = chatMode === mode
19+
return (
20+
<button
21+
key={mode}
22+
onClick={() => !disabled && setChatMode(mode)}
23+
disabled={disabled}
24+
className={`flex items-center gap-1 px-2 py-0.5 rounded text-[0.55rem] font-medium transition-all ${
25+
isActive
26+
? 'bg-white/10 text-white border border-white/15'
27+
: disabled
28+
? 'text-gray-700 cursor-default'
29+
: 'text-gray-500 hover:text-gray-300 hover:bg-white/5 border border-transparent'
30+
}`}
31+
>
32+
<Icon size={9} />
33+
<span>{label}</span>
34+
{tag && <span className="text-[0.4rem] text-gray-600 ml-0.5">{tag}</span>}
35+
</button>
36+
)
37+
})}
38+
</div>
39+
)
40+
}

src/components/chat/ChatView.tsx

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ import { ABCompare } from './ABCompare'
2121
import { useCompareStore } from '../../stores/compareStore'
2222
import { exportConversation } from '../../lib/chat-export'
2323
import { PermissionOverrideBar } from './PermissionOverrideBar'
24+
import { RealtimeCounter } from './RealtimeCounter'
25+
import { CodexView } from './CodexView'
26+
import { useCodexStore } from '../../stores/codexStore'
2427

2528
export function ChatView() {
2629
const { sendMessage, stopGeneration, isGenerating, isLoadingModel, regenerateMessage, editAndResend, pendingApproval, approveToolCall, rejectToolCall } = useChat()
@@ -37,6 +40,7 @@ export function ChatView() {
3740
const allPersonas = useSettingsStore((s) => s.personas)
3841
const thinkingEnabled = useSettingsStore((s) => s.settings.thinkingEnabled)
3942
const updateSettings = useSettingsStore((s) => s.updateSettings)
43+
const chatMode = useCodexStore((s) => s.chatMode)
4044

4145
const docCount = useRAGStore((s) =>
4246
activeConversationId ? (s.documents[activeConversationId] || []).length : 0
@@ -85,15 +89,23 @@ export function ChatView() {
8589
animate={{ opacity: 1, y: 0 }}
8690
transition={{ duration: 0.25, ease: 'easeOut' }}
8791
>
88-
<div className="flex-1 flex flex-col min-w-0">
89-
{/* Top bar — compact */}
92+
<div className="flex-1 flex flex-col min-w-0 relative">
93+
{/* Codex mode */}
94+
{chatMode === 'codex' ? (
95+
<CodexView />
96+
) : chatMode === 'openclaw' ? (
97+
<div className="flex-1 flex items-center justify-center">
98+
<p className="text-[0.7rem] text-gray-600">OpenClaw — Coming Soon</p>
99+
</div>
100+
) : (<>
101+
{/* Top bar — compact (LU mode) */}
90102
<div className="flex items-center gap-1.5 px-2 pt-0.5">
91103
{/* Left: Tools Active dropdown (only when agent is active) */}
92104
{isAgentActive && (
93105
<div className="relative">
94106
<button
95107
onClick={() => setToolsDropdownOpen(!toolsDropdownOpen)}
96-
className="flex items-center gap-1 px-2 py-0.5 rounded border border-white/[0.06] text-gray-500 hover:border-white/15 transition-colors text-[0.55rem]"
108+
className="flex items-center gap-1 px-2 py-0.5 rounded border border-gray-200 dark:border-white/[0.06] text-gray-500 hover:border-gray-400 dark:hover:border-white/15 transition-colors text-[0.55rem]"
97109
>
98110
<Wrench size={9} className="text-green-400" />
99111
<span>Tools</span>
@@ -102,7 +114,7 @@ export function ChatView() {
102114
{toolsDropdownOpen && (
103115
<>
104116
<div className="fixed inset-0 z-40" onClick={() => setToolsDropdownOpen(false)} />
105-
<div className="absolute left-0 top-full mt-0.5 z-50 w-28 rounded-md bg-[#1a1a1a] border border-white/10 shadow-xl py-0.5 px-0.5">
117+
<div className="absolute left-0 top-full mt-0.5 z-50 w-28 rounded-md bg-white dark:bg-[#1a1a1a] border border-gray-200 dark:border-white/10 shadow-xl py-0.5 px-0.5">
106118
<PermissionOverrideBar />
107119
</div>
108120
</>
@@ -116,7 +128,7 @@ export function ChatView() {
116128
className={`flex items-center gap-1 px-2 py-0.5 rounded border transition-colors text-[0.55rem] ${
117129
thinkingEnabled
118130
? 'border-blue-500/30 text-blue-400'
119-
: 'border-white/[0.06] text-gray-600'
131+
: 'border-gray-200 dark:border-white/[0.06] text-gray-600'
120132
}`}
121133
title="Toggle thinking mode — model reasons before answering"
122134
>
@@ -137,15 +149,15 @@ export function ChatView() {
137149
<div className="relative">
138150
<button
139151
onClick={() => setExportOpen(!exportOpen)}
140-
className="flex items-center gap-1 px-2 py-0.5 rounded border border-white/[0.06] hover:border-white/15 text-gray-500 transition-colors text-[0.55rem]"
152+
className="flex items-center gap-1 px-2 py-0.5 rounded border border-gray-200 dark:border-white/[0.06] hover:border-gray-400 dark:hover:border-white/15 text-gray-500 transition-colors text-[0.55rem]"
141153
title="Export chat"
142154
>
143155
<Download size={10} />
144156
</button>
145157
{exportOpen && (
146158
<>
147159
<div className="fixed inset-0 z-40" onClick={() => setExportOpen(false)} />
148-
<div className="absolute right-0 top-full mt-1 z-50 w-32 rounded-lg bg-[#1a1a1a] border border-white/10 shadow-xl py-1">
160+
<div className="absolute right-0 top-full mt-1 z-50 w-32 rounded-lg bg-white dark:bg-[#1a1a1a] border border-gray-200 dark:border-white/10 shadow-xl py-1">
149161
{(['markdown', 'json'] as const).map(fmt => (
150162
<button
151163
key={fmt}
@@ -168,7 +180,7 @@ export function ChatView() {
168180
<div className="relative">
169181
<button
170182
onClick={() => setPersonaOpen(!personaOpen)}
171-
className="flex items-center gap-1 px-2 py-0.5 rounded border border-white/[0.06] hover:border-white/15 text-gray-500 transition-colors text-[0.55rem]"
183+
className="flex items-center gap-1 px-2 py-0.5 rounded border border-gray-200 dark:border-white/[0.06] hover:border-gray-400 dark:hover:border-white/15 text-gray-500 transition-colors text-[0.55rem]"
172184
>
173185
<User size={10} />
174186
<span className="max-w-[60px] truncate">{activePersona?.name || 'No Filter'}</span>
@@ -177,9 +189,9 @@ export function ChatView() {
177189
{personaOpen && (
178190
<>
179191
<div className="fixed inset-0 z-40" onClick={() => setPersonaOpen(false)} />
180-
<div className="absolute right-0 top-full mt-1 z-50 w-44 max-h-[220px] overflow-y-auto scrollbar-thin rounded-lg bg-[#1a1a1a] border border-white/10 shadow-xl py-1">
192+
<div className="absolute right-0 top-full mt-1 z-50 w-44 max-h-[220px] overflow-y-auto scrollbar-thin rounded-lg bg-white dark:bg-[#1a1a1a] border border-gray-200 dark:border-white/10 shadow-xl py-1">
181193
{activePersona && (
182-
<div className="px-2 pb-1 mb-1 border-b border-white/[0.06]">
194+
<div className="px-2 pb-1 mb-1 border-b border-gray-200 dark:border-white/[0.06]">
183195
<div className="px-2 py-1 rounded-md bg-white/[0.06] border border-white/10 text-[0.55rem] text-white font-medium flex items-center gap-1.5">
184196
<div className="w-1 h-1 rounded-full bg-green-400 shrink-0" />
185197
{activePersona.name}
@@ -207,7 +219,7 @@ export function ChatView() {
207219
'flex items-center gap-1 px-2 py-0.5 rounded border transition-colors text-[0.55rem] ' +
208220
(ragPanelOpen || ragEnabled
209221
? 'border-green-500/30 text-green-400'
210-
: 'border-white/[0.06] hover:border-white/15 text-gray-500')
222+
: 'border-gray-200 dark:border-white/[0.06] hover:border-gray-400 dark:hover:border-white/15 text-gray-500')
211223
}
212224
title="Document Chat (RAG)"
213225
>
@@ -231,7 +243,7 @@ export function ChatView() {
231243
? 'border-green-500/30 text-green-400'
232244
: activeModel && !isAgentCompatible(activeModel)
233245
? 'border-white/[0.04] text-gray-600 opacity-50'
234-
: 'border-white/[0.06] text-gray-500')
246+
: 'border-gray-200 dark:border-white/[0.06] text-gray-500')
235247
}>
236248
<Bot size={10} />
237249
<div className="flex flex-col items-start leading-none">
@@ -249,6 +261,7 @@ export function ChatView() {
249261
onRegenerate={regenerateMessage}
250262
onEdit={editAndResend}
251263
/>
264+
<RealtimeCounter isRunning={isGenerating} />
252265
<ChatInput
253266
onSend={sendMessage}
254267
onStop={stopGeneration}
@@ -257,6 +270,7 @@ export function ChatView() {
257270
onApprove={approveToolCall}
258271
onReject={rejectToolCall}
259272
/>
273+
</>)}
260274
</div>
261275

262276
{/* RAG Panel */}

src/components/chat/CodeBlock.tsx

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,39 @@
11
import { useState } from 'react'
22
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
33
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
4-
import { Copy, Check } from 'lucide-react'
4+
import { Copy, Check, ChevronDown } from 'lucide-react'
55

66
interface Props {
77
code: string
88
language?: string
99
}
1010

11+
const COLLAPSE_THRESHOLD = 4 // lines — always start collapsed unless very short
12+
1113
export function CodeBlock({ code, language }: Props) {
1214
const [copied, setCopied] = useState(false)
15+
const lineCount = code.split('\n').length
16+
const isLong = lineCount > COLLAPSE_THRESHOLD
17+
const [expanded, setExpanded] = useState(!isLong)
1318

1419
const handleCopy = () => {
1520
navigator.clipboard.writeText(code)
1621
setCopied(true)
1722
setTimeout(() => setCopied(false), 2000)
1823
}
1924

25+
const displayCode = expanded ? code : code.split('\n').slice(0, COLLAPSE_THRESHOLD).join('\n')
26+
2027
return (
21-
<div className="relative group rounded-lg overflow-hidden my-3 border border-white/5">
22-
<div className="flex items-center justify-between px-4 py-1.5 bg-white/5 border-b border-white/5">
23-
<span className="text-xs text-gray-400 font-mono">{language || 'code'}</span>
28+
<div className="relative group rounded-lg overflow-hidden my-1.5 border border-gray-200 dark:border-white/5">
29+
<div className="flex items-center justify-between px-3 py-1 bg-gray-50 dark:bg-white/5 border-b border-gray-200 dark:border-white/5">
30+
<span className="text-[0.6rem] text-gray-400 font-mono">{language || 'code'}</span>
2431
<button
2532
onClick={handleCopy}
26-
className="flex items-center gap-1 text-xs text-gray-400 hover:text-white transition-colors"
33+
className="flex items-center gap-1 text-[0.6rem] text-gray-400 hover:text-gray-700 dark:hover:text-white transition-colors"
2734
aria-label="Copy code"
2835
>
29-
{copied ? <Check size={14} /> : <Copy size={14} />}
36+
{copied ? <Check size={11} /> : <Copy size={11} />}
3037
{copied ? 'Copied' : 'Copy'}
3138
</button>
3239
</div>
@@ -35,13 +42,22 @@ export function CodeBlock({ code, language }: Props) {
3542
style={oneDark}
3643
customStyle={{
3744
margin: 0,
38-
padding: '1rem',
45+
padding: '0.75rem',
3946
background: 'rgba(0, 0, 0, 0.3)',
40-
fontSize: '0.85rem',
47+
fontSize: '0.75rem',
4148
}}
4249
>
43-
{code}
50+
{displayCode}
4451
</SyntaxHighlighter>
52+
{isLong && (
53+
<button
54+
onClick={() => setExpanded(!expanded)}
55+
className="w-full flex items-center justify-center gap-1 py-1 bg-gray-50 dark:bg-white/5 text-[0.55rem] text-gray-400 hover:text-gray-700 dark:hover:text-white border-t border-gray-200 dark:border-white/5 transition-colors"
56+
>
57+
<ChevronDown size={9} className={`transition-transform ${expanded ? 'rotate-180' : ''}`} />
58+
{expanded ? 'Collapse' : `Show all ${lineCount} lines`}
59+
</button>
60+
)}
4561
</div>
4662
)
4763
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { Terminal, FileEdit, Brain, AlertCircle, CheckCircle, ChevronDown } from 'lucide-react'
2+
import { useState } from 'react'
3+
import type { CodexEvent } from '../../types/codex'
4+
5+
interface Props {
6+
event: CodexEvent
7+
}
8+
9+
export function CodexEventBlock({ event }: Props) {
10+
const [open, setOpen] = useState(event.type === 'error')
11+
12+
if (event.type === 'instruction' || event.type === 'done') return null
13+
14+
const icons = {
15+
file_change: FileEdit,
16+
terminal_output: Terminal,
17+
reasoning: Brain,
18+
error: AlertCircle,
19+
}
20+
21+
const colors = {
22+
file_change: 'text-amber-400',
23+
terminal_output: 'text-green-400',
24+
reasoning: 'text-blue-400',
25+
error: 'text-red-400',
26+
}
27+
28+
const labels = {
29+
file_change: event.filePath || 'File changed',
30+
terminal_output: 'Terminal',
31+
reasoning: 'Thinking',
32+
error: 'Error',
33+
}
34+
35+
const Icon = icons[event.type as keyof typeof icons] || CheckCircle
36+
const color = colors[event.type as keyof typeof colors] || 'text-gray-400'
37+
const label = labels[event.type as keyof typeof labels] || event.type
38+
39+
return (
40+
<div className="mb-0.5">
41+
<button
42+
onClick={() => setOpen(!open)}
43+
className="flex items-center gap-1.5 py-0.5 text-left hover:opacity-80 transition-opacity w-full"
44+
>
45+
<Icon size={10} className={color} />
46+
<span className={`text-[0.6rem] ${color}`}>{label}</span>
47+
<ChevronDown size={8} className={`text-gray-600 ml-auto transition-transform ${open ? 'rotate-180' : ''}`} />
48+
</button>
49+
50+
{open && (
51+
<div className="pl-4 pb-1">
52+
<pre className={`text-[0.58rem] leading-relaxed rounded px-2 py-1 overflow-auto scrollbar-thin max-h-[250px] ${
53+
event.type === 'terminal_output'
54+
? 'bg-black/20 text-green-300/70'
55+
: event.type === 'error'
56+
? 'bg-red-500/5 text-red-400/80'
57+
: event.type === 'reasoning'
58+
? 'text-blue-200/40 italic'
59+
: 'bg-white/[0.02] text-gray-400'
60+
}`}>
61+
{event.content}
62+
</pre>
63+
</div>
64+
)}
65+
</div>
66+
)
67+
}

0 commit comments

Comments
 (0)