Skip to content

Commit e2dce76

Browse files
committed
feat: enhance terminal functionality and commit message generation
- Added support for terminal refresh behavior on pane open or mode changes, improving user experience in TUI mode. - Introduced new props in TerminalPanel and TerminalPane for managing terminal refresh and startup commands. - Implemented AI-driven commit message generation in the AgentPanel and GitView, allowing users to generate commit messages based on detected changes. - Updated keyboard shortcuts and UI elements to reflect the new AI commit message generation feature. - Refactored code for better maintainability and clarity in commit handling and terminal management.
1 parent 56cfd0b commit e2dce76

File tree

6 files changed

+1436
-357
lines changed

6 files changed

+1436
-357
lines changed

app/page.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ export default function EditorLayout() {
109109
const terminalVisible = layout.isVisible('terminal')
110110
const terminalHeight = layout.getSize('terminal')
111111
const terminalFloating = layout.isFloating('terminal')
112+
const terminalRefreshToken = mode
113+
const terminalStartupCommand = modeSpec.terminalCenter ? 'openclaw tui' : undefined
112114

113115
// ─── Minimal state ──────────────────────────────────
114116
const [isTauriDesktop, setIsTauriDesktop] = useState(false)
@@ -133,6 +135,16 @@ export default function EditorLayout() {
133135
const [onboardingOpen, setOnboardingOpen] = useState(false)
134136

135137
const dirtyCount = useMemo(() => files.filter((f) => f.dirty).length, [files])
138+
const ensureTuiTerminalVisible = useCallback(() => {
139+
layout.setFloating('terminal', false)
140+
layout.show('terminal')
141+
}, [layout])
142+
143+
// Entering TUI should always surface the terminal view.
144+
useEffect(() => {
145+
if (!modeSpec.terminalCenter) return
146+
ensureTuiTerminalVisible()
147+
}, [modeSpec.terminalCenter, ensureTuiTerminalVisible])
136148

137149
// ─── Tauri detection ───────────────────────────────────
138150
useEffect(() => {
@@ -542,7 +554,14 @@ export default function EditorLayout() {
542554
{/* TUI mode: terminal fills center */}
543555
{modeSpec.terminalCenter && terminalVisible ? (
544556
<div className="flex-1 flex min-h-0 min-w-0 overflow-hidden">
545-
<TerminalPanel visible={true} height={9999} onHeightChange={() => {}} />
557+
<TerminalPanel
558+
visible={true}
559+
height={9999}
560+
onHeightChange={() => {}}
561+
refreshOnOpenOrMode={true}
562+
refreshToken={terminalRefreshToken}
563+
startupCommand={terminalStartupCommand}
564+
/>
546565
</div>
547566
) : (
548567
<ViewRouter />
@@ -586,6 +605,9 @@ export default function EditorLayout() {
586605
onHeightChange={(h: number) => layout.resize('terminal', h)}
587606
floating={terminalFloating}
588607
onToggleFloating={() => layout.setFloating('terminal', !terminalFloating)}
608+
refreshOnOpenOrMode={true}
609+
refreshToken={terminalRefreshToken}
610+
startupCommand={terminalStartupCommand}
589611
/>
590612
</div>
591613
)}
@@ -642,6 +664,9 @@ export default function EditorLayout() {
642664
onHeightChange={(h: number) => layout.resize('terminal', h)}
643665
floating={terminalFloating}
644666
onToggleFloating={() => layout.setFloating('terminal', !terminalFloating)}
667+
refreshOnOpenOrMode={true}
668+
refreshToken={terminalRefreshToken}
669+
startupCommand={terminalStartupCommand}
645670
/>
646671
</div>
647672
</motion.div>
@@ -669,6 +694,9 @@ export default function EditorLayout() {
669694
onHeightChange={(h: number) => layout.resize('terminal', h)}
670695
floating={terminalFloating}
671696
onToggleFloating={() => layout.setFloating('terminal', !terminalFloating)}
697+
refreshOnOpenOrMode={true}
698+
refreshToken={terminalRefreshToken}
699+
startupCommand={terminalStartupCommand}
672700
/>
673701
</FloatingPanel>
674702
)}

components/agent-panel.tsx

Lines changed: 138 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ import { parseEditProposals, type EditProposal } from '@/lib/edit-parser'
1616
import { showInlineDiff, type InlineDiffResult } from '@/lib/inline-diff'
1717
import { diffEngine } from '@/lib/streaming-diff'
1818
import { handleChatEvent, type ChatMessage, type StreamState } from '@/lib/chat-stream'
19+
import {
20+
buildEditorPatchSnippet,
21+
generateCommitMessageWithGateway,
22+
type CommitMessageChange,
23+
} from '@/lib/gateway-commit-message'
1924
import { MessageList } from '@/components/chat/message-list'
2025
import { ChatInputBar } from '@/components/chat/chat-input-bar'
2126
import { emit, on } from '@/lib/events'
@@ -575,6 +580,59 @@ export function AgentPanel() {
575580
})
576581
}, [])
577582

583+
const collectCommitChangesForGeneration = useCallback(async (): Promise<
584+
CommitMessageChange[]
585+
> => {
586+
const changes: CommitMessageChange[] = []
587+
const seenPaths = new Set<string>()
588+
589+
if (local.localMode && local.rootPath && local.gitInfo?.is_repo) {
590+
const gitStatuses = local.gitInfo.status ?? []
591+
for (const statusEntry of gitStatuses) {
592+
seenPaths.add(statusEntry.path)
593+
const hasStaged = statusEntry.index_status !== ' ' && statusEntry.index_status !== '?'
594+
const hasWorktree = statusEntry.worktree_status !== ' '
595+
const stagedOnly = hasStaged && !hasWorktree
596+
let patch = ''
597+
try {
598+
patch = await local.getDiff(statusEntry.path, stagedOnly)
599+
if (!patch && hasStaged) {
600+
patch = await local.getDiff(statusEntry.path, true)
601+
}
602+
} catch {}
603+
604+
const summaryBits: string[] = []
605+
if (statusEntry.status === '??') summaryBits.push('untracked')
606+
if (hasStaged) summaryBits.push('staged')
607+
if (hasWorktree) summaryBits.push('unstaged')
608+
609+
changes.push({
610+
path: statusEntry.path,
611+
status:
612+
statusEntry.status?.trim() ||
613+
`${statusEntry.index_status}${statusEntry.worktree_status}`.trim() ||
614+
'M',
615+
summary: summaryBits.join(', ') || undefined,
616+
patch: patch || undefined,
617+
})
618+
}
619+
}
620+
621+
for (const file of files) {
622+
if (!file.dirty || file.kind !== 'text') continue
623+
if (seenPaths.has(file.path)) continue
624+
const snippet = buildEditorPatchSnippet(file.originalContent, file.content)
625+
changes.push({
626+
path: file.path,
627+
status: 'M',
628+
summary: 'unsaved editor changes',
629+
patch: snippet,
630+
})
631+
}
632+
633+
return changes
634+
}, [files, local])
635+
578636
// ─── Commit result listener ──────────────────────────────────
579637
useEffect(() => {
580638
return on('agent-commit-result', (detail) => {
@@ -617,31 +675,92 @@ export function AgentPanel() {
617675
// ─── Slash command interception ───────────────────────────
618676
if (text.startsWith('/commit')) {
619677
const commitMsg = text.replace(/^\/commit\s*/, '').trim()
620-
if (!commitMsg) {
678+
appendMessage({
679+
id: crypto.randomUUID(),
680+
role: 'user',
681+
type: 'text',
682+
content: text,
683+
timestamp: Date.now(),
684+
})
685+
686+
if (commitMsg) {
687+
emit('agent-commit', { message: commitMsg })
621688
appendMessage({
622689
id: crypto.randomUUID(),
623690
role: 'system',
624691
type: 'status',
625-
content: 'Usage: /commit <message>',
692+
content: 'Committing...',
626693
timestamp: Date.now(),
627694
})
628695
return
629696
}
630-
appendMessage({
631-
id: crypto.randomUUID(),
632-
role: 'user',
633-
type: 'text',
634-
content: text,
635-
timestamp: Date.now(),
636-
})
637-
emit('agent-commit', { message: commitMsg })
697+
698+
if (!isConnected) {
699+
appendMessage({
700+
id: crypto.randomUUID(),
701+
role: 'system',
702+
type: 'error',
703+
content: 'Gateway disconnected — cannot generate commit message.',
704+
timestamp: Date.now(),
705+
})
706+
return
707+
}
708+
638709
appendMessage({
639710
id: crypto.randomUUID(),
640711
role: 'system',
641712
type: 'status',
642-
content: 'Committing...',
713+
content: 'Generating commit message with gateway AI...',
643714
timestamp: Date.now(),
644715
})
716+
717+
try {
718+
await ensureSessionInit()
719+
const changes = await collectCommitChangesForGeneration()
720+
if (changes.length === 0) {
721+
appendMessage({
722+
id: crypto.randomUUID(),
723+
role: 'system',
724+
type: 'status',
725+
content: 'No changes detected to commit.',
726+
timestamp: Date.now(),
727+
})
728+
return
729+
}
730+
731+
const generatedCommitMsg = await generateCommitMessageWithGateway({
732+
sendRequest,
733+
onEvent,
734+
sessionKey,
735+
repoFullName: repo?.fullName ?? local.remoteRepo ?? undefined,
736+
branch: repo?.branch ?? local.gitInfo?.branch ?? undefined,
737+
changes,
738+
})
739+
740+
appendMessage({
741+
id: crypto.randomUUID(),
742+
role: 'system',
743+
type: 'status',
744+
content: `Generated commit message: ${generatedCommitMsg}`,
745+
timestamp: Date.now(),
746+
})
747+
emit('agent-commit', { message: generatedCommitMsg })
748+
appendMessage({
749+
id: crypto.randomUUID(),
750+
role: 'system',
751+
type: 'status',
752+
content: 'Committing...',
753+
timestamp: Date.now(),
754+
})
755+
} catch (err) {
756+
appendMessage({
757+
id: crypto.randomUUID(),
758+
role: 'system',
759+
type: 'error',
760+
content: `Generate commit message failed: ${err instanceof Error ? err.message : String(err)}`,
761+
timestamp: Date.now(),
762+
})
763+
}
645764
return
646765
}
647766
if (text === '/changes') {
@@ -1009,11 +1128,14 @@ export function AgentPanel() {
10091128
contextAttachments,
10101129
imageAttachments,
10111130
local,
1131+
repo,
10121132
files,
10131133
sendRequest,
1134+
onEvent,
10141135
buildContext,
10151136
appendMessage,
10161137
ensureSessionInit,
1138+
collectCommitChangesForGeneration,
10171139
logChatDebug,
10181140
])
10191141

@@ -1221,7 +1343,11 @@ export function AgentPanel() {
12211343
{ cmd: '/refactor', desc: 'Refactor code', icon: 'lucide:refresh-cw' },
12221344
{ cmd: '/generate', desc: 'Generate new code', icon: 'lucide:plus' },
12231345
{ cmd: '/search', desc: 'Search across repo', icon: 'lucide:search' },
1224-
{ cmd: '/commit', desc: 'Commit changes', icon: 'lucide:git-commit-horizontal' },
1346+
{
1347+
cmd: '/commit',
1348+
desc: 'Commit changes (AI if empty)',
1349+
icon: 'lucide:git-commit-horizontal',
1350+
},
12251351
{ cmd: '/diff', desc: 'Show changes', icon: 'lucide:git-compare' },
12261352
{ cmd: '/changes', desc: 'Pre-commit review', icon: 'lucide:eye' },
12271353
{ cmd: '/unstage', desc: 'Unstage all staged files', icon: 'lucide:minus-circle' },

components/shortcuts-overlay.tsx

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ const STATIC_SECTIONS = [
4545
{ keys: ['/refactor'], desc: 'Refactor code' },
4646
{ keys: ['/generate'], desc: 'Generate new code' },
4747
{ keys: ['/search'], desc: 'Search repo' },
48-
{ keys: ['/commit'], desc: 'Commit changes' },
48+
{ keys: ['/commit'], desc: 'Commit changes (AI if empty)' },
4949
{ keys: ['/diff'], desc: 'Show changes' },
5050
{ keys: ['/unstage'], desc: 'Unstage all staged files' },
5151
{ keys: ['/undo'], desc: 'Undo last commit' },
@@ -55,15 +55,20 @@ const STATIC_SECTIONS = [
5555

5656
export function ShortcutsOverlay({ open, onClose }: ShortcutsOverlayProps) {
5757
const [isDesktop, setIsDesktop] = useState(false)
58-
useEffect(() => { setIsDesktop(isTauri()) }, [])
58+
useEffect(() => {
59+
setIsDesktop(isTauri())
60+
}, [])
5961

60-
const sections = useMemo(() => [
61-
{
62-
title: 'Navigation',
63-
shortcuts: isDesktop ? [...NAV_SHORTCUTS, ...NAV_TERMINAL_SHORTCUTS] : NAV_SHORTCUTS,
64-
},
65-
...STATIC_SECTIONS,
66-
], [isDesktop])
62+
const sections = useMemo(
63+
() => [
64+
{
65+
title: 'Navigation',
66+
shortcuts: isDesktop ? [...NAV_SHORTCUTS, ...NAV_TERMINAL_SHORTCUTS] : NAV_SHORTCUTS,
67+
},
68+
...STATIC_SECTIONS,
69+
],
70+
[isDesktop],
71+
)
6772

6873
useEffect(() => {
6974
if (!open) return
@@ -80,17 +85,22 @@ export function ShortcutsOverlay({ open, onClose }: ShortcutsOverlayProps) {
8085
if (!open) return null
8186

8287
return (
83-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={onClose}>
88+
<div
89+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
90+
onClick={onClose}
91+
>
8492
<div
8593
className="w-full max-w-[520px] rounded-xl border border-[var(--border)] bg-[var(--bg-elevated)] shadow-2xl overflow-hidden animate-scale-in"
86-
onClick={e => e.stopPropagation()}
94+
onClick={(e) => e.stopPropagation()}
8795
>
8896
{/* Header */}
8997
<div className="flex items-center justify-between px-5 py-3 border-b border-[var(--border)] relative">
9098
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-[var(--brand)] to-transparent opacity-30" />
9199
<div className="flex items-center gap-2">
92100
<Icon icon="lucide:keyboard" width={16} height={16} className="text-[var(--brand)]" />
93-
<span className="text-[14px] font-semibold text-[var(--text-primary)]">Keyboard Shortcuts</span>
101+
<span className="text-[14px] font-semibold text-[var(--text-primary)]">
102+
Keyboard Shortcuts
103+
</span>
94104
</div>
95105
<button
96106
onClick={onClose}
@@ -102,16 +112,21 @@ export function ShortcutsOverlay({ open, onClose }: ShortcutsOverlayProps) {
102112

103113
{/* Sections */}
104114
<div className="p-5 space-y-5 max-h-[70vh] overflow-y-auto">
105-
{sections.map(section => (
115+
{sections.map((section) => (
106116
<div key={section.title}>
107117
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-[var(--text-tertiary)] mb-2.5 flex items-center gap-2">
108118
{section.title}
109119
<span className="flex-1 h-px bg-[var(--border)]" />
110120
</h3>
111121
<div className="space-y-0.5">
112-
{section.shortcuts.map(s => (
113-
<div key={s.desc} className="flex items-center justify-between py-1.5 px-2 rounded-md hover:bg-[var(--bg-subtle)] transition-colors group">
114-
<span className="text-[12px] text-[var(--text-secondary)] group-hover:text-[var(--text-primary)] transition-colors">{s.desc}</span>
122+
{section.shortcuts.map((s) => (
123+
<div
124+
key={s.desc}
125+
className="flex items-center justify-between py-1.5 px-2 rounded-md hover:bg-[var(--bg-subtle)] transition-colors group"
126+
>
127+
<span className="text-[12px] text-[var(--text-secondary)] group-hover:text-[var(--text-primary)] transition-colors">
128+
{s.desc}
129+
</span>
115130
<div className="flex items-center gap-1">
116131
{s.keys.map((key, i) => (
117132
<kbd

0 commit comments

Comments
 (0)