Skip to content

Commit 2c23482

Browse files
committed
Refine CLI feedback plumbing
1 parent 3b19010 commit 2c23482

File tree

7 files changed

+293
-261
lines changed

7 files changed

+293
-261
lines changed

cli/src/chat.tsx

Lines changed: 62 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2-
import { useKeyboard } from '@opentui/react'
32
import { useShallow } from 'zustand/react/shallow'
43

54
import { routeUserPrompt } from './commands/router'
65
import { AgentModeToggle } from './components/agent-mode-toggle'
76
import { MessageWithAgents } from './components/message-with-agents'
87
import { FeedbackInputMode } from './components/feedback-input-mode'
8+
import { FeedbackUiProvider } from './contexts/feedback-ui-context'
99
import {
1010
MultilineInput,
1111
type MultilineInputHandle,
@@ -44,7 +44,7 @@ import { computeInputLayoutMetrics } from './utils/text-layout'
4444
import { createMarkdownPalette } from './utils/theme-system'
4545
import { BORDER_CHARS } from './utils/ui-constants'
4646

47-
import type { ContentBlock } from './types/chat'
47+
import type { ChatMessage, ContentBlock } from './types/chat'
4848
import type { SendMessageFn } from './types/contracts/send-message'
4949
import type { User } from './utils/auth'
5050
import type { FileTreeNode } from '@codebuff/common/util/file'
@@ -458,6 +458,15 @@ export const Chat = ({
458458
const [messagesWithFeedback, setMessagesWithFeedback] = useState<Set<string>>(new Set())
459459
const [messageFeedbackCategories, setMessageFeedbackCategories] = useState<Map<string, string>>(new Map())
460460

461+
const inputValueRef = useRef(inputValue)
462+
const cursorPositionRef = useRef(cursorPosition)
463+
useEffect(() => {
464+
inputValueRef.current = inputValue
465+
}, [inputValue])
466+
useEffect(() => {
467+
cursorPositionRef.current = cursorPosition
468+
}, [cursorPosition])
469+
461470
const resetFeedbackForm = useCallback(() => {
462471
setFeedbackText('')
463472
setFeedbackCursor(0)
@@ -466,38 +475,33 @@ export const Chat = ({
466475

467476
const openFeedbackForMessage = useCallback((id: string) => {
468477
// Save current input state
469-
setSavedInputValue(inputValue)
470-
setSavedCursorPosition(cursorPosition)
478+
setSavedInputValue(inputValueRef.current)
479+
setSavedCursorPosition(cursorPositionRef.current)
471480

472481
// Enter feedback mode
473482
setFeedbackMessageId(id)
474483
setFeedbackMode(true)
475484
resetFeedbackForm()
476-
}, [inputValue, cursorPosition, resetFeedbackForm])
477-
478-
const openFeedbackForLatestMessage = useCallback(() => {
479-
const latest = [...messages]
480-
.reverse()
481-
.find((m) => m.variant === 'ai' && m.isComplete)
482-
if (!latest) {
483-
return false
484-
}
485-
openFeedbackForMessage(latest.id)
486-
return true
487-
}, [messages, openFeedbackForMessage])
485+
}, [resetFeedbackForm])
488486

489487
const handleFeedbackSubmit = useCallback(() => {
490488
const text = feedbackText.trim()
491489
if (text.length === 0) return
492490

493-
const target = feedbackMessageId ? messages.find((m) => m.id === feedbackMessageId) : null
494-
const recent = messages.slice(Math.max(0, messages.length - 5)).map((m) => ({
491+
const target = feedbackMessageId
492+
? messages.find((m) => m.id === feedbackMessageId)
493+
: null
494+
const summarizeMessage = (m: ChatMessage) => ({
495495
id: m.id,
496496
variant: m.variant,
497497
timestamp: m.timestamp,
498498
hasBlocks: !!m.blocks,
499499
contentPreview: (m.content || '').slice(0, 400),
500-
}))
500+
})
501+
const recent =
502+
feedbackMessageId && target
503+
? [summarizeMessage(target)]
504+
: messages.slice(Math.max(0, messages.length - 5)).map(summarizeMessage)
501505

502506
logger.info(
503507
{
@@ -582,7 +586,7 @@ export const Chat = ({
582586
})
583587

584588
// Handle /feedback command
585-
if (result && 'openFeedbackMode' in result && result.openFeedbackMode) {
589+
if (result?.openFeedbackMode) {
586590
setSavedInputValue('')
587591
setSavedCursorPosition(0)
588592
setFeedbackMessageId(null) // General feedback, not tied to a message
@@ -734,24 +738,6 @@ export const Chat = ({
734738
const shouldShowStatusLine =
735739
!feedbackMode && (hasStatusIndicatorContent || shouldShowQueuePreview || !isAtBottom)
736740

737-
// Ctrl+F to open feedback for latest completed AI message
738-
useKeyboard(
739-
useCallback(
740-
(key) => {
741-
// Don't handle if already in feedback mode
742-
if (feedbackMode) return
743-
744-
if (key?.ctrl && key.name === 'f') {
745-
if ('preventDefault' in key && typeof key.preventDefault === 'function') {
746-
key.preventDefault()
747-
}
748-
openFeedbackForLatestMessage()
749-
}
750-
},
751-
[openFeedbackForLatestMessage, feedbackMode],
752-
),
753-
)
754-
755741
const validationBanner = useValidationBanner({
756742
liveValidationErrors: validationErrors,
757743
loadedAgentsData,
@@ -802,40 +788,43 @@ export const Chat = ({
802788
>
803789
{headerContent}
804790
{virtualizationNotice}
805-
{topLevelMessages.map((message, idx) => {
806-
const isLast = idx === topLevelMessages.length - 1
807-
return (
808-
<MessageWithAgents
809-
key={message.id}
810-
message={message}
811-
depth={0}
812-
isLastMessage={isLast}
813-
theme={theme}
814-
markdownPalette={markdownPalette}
815-
collapsedAgents={collapsedAgents}
816-
autoCollapsedAgents={autoCollapsedAgents}
817-
streamingAgents={streamingAgents}
818-
messageTree={messageTree}
819-
messages={messages}
820-
availableWidth={separatorWidth}
821-
setCollapsedAgents={setCollapsedAgents}
822-
addAutoCollapsedAgent={addAutoCollapsedAgent}
823-
setUserOpenedAgents={setUserOpenedAgents}
824-
setFocusedAgentId={setFocusedAgentId}
825-
isWaitingForResponse={isWaitingForResponse}
826-
timerStartTime={timerStartTime}
827-
onToggleCollapsed={handleCollapseToggle}
828-
onBuildFast={handleBuildFast}
829-
onBuildMax={handleBuildMax}
830-
onFeedback={openFeedbackForMessage}
831-
feedbackOpenMessageId={feedbackMessageId}
832-
feedbackMode={feedbackMode}
833-
onCloseFeedback={handleFeedbackCancel}
834-
messagesWithFeedback={messagesWithFeedback}
835-
messageFeedbackCategories={messageFeedbackCategories}
836-
/>
837-
)
838-
})}
791+
<FeedbackUiProvider
792+
onFeedback={openFeedbackForMessage}
793+
onClose={handleFeedbackCancel}
794+
isFeedbackMode={feedbackMode}
795+
openMessageId={feedbackMessageId}
796+
submittedMessageIds={messagesWithFeedback}
797+
categorySelections={messageFeedbackCategories}
798+
>
799+
{topLevelMessages.map((message, idx) => {
800+
const isLast = idx === topLevelMessages.length - 1
801+
return (
802+
<MessageWithAgents
803+
key={message.id}
804+
message={message}
805+
depth={0}
806+
isLastMessage={isLast}
807+
theme={theme}
808+
markdownPalette={markdownPalette}
809+
collapsedAgents={collapsedAgents}
810+
autoCollapsedAgents={autoCollapsedAgents}
811+
streamingAgents={streamingAgents}
812+
messageTree={messageTree}
813+
messages={messages}
814+
availableWidth={separatorWidth}
815+
setCollapsedAgents={setCollapsedAgents}
816+
addAutoCollapsedAgent={addAutoCollapsedAgent}
817+
setUserOpenedAgents={setUserOpenedAgents}
818+
setFocusedAgentId={setFocusedAgentId}
819+
isWaitingForResponse={isWaitingForResponse}
820+
timerStartTime={timerStartTime}
821+
onToggleCollapsed={handleCollapseToggle}
822+
onBuildFast={handleBuildFast}
823+
onBuildMax={handleBuildMax}
824+
/>
825+
)
826+
})}
827+
</FeedbackUiProvider>
839828
</scrollbox>
840829

841830
<box
@@ -987,4 +976,4 @@ export const Chat = ({
987976
{validationBanner}
988977
</box>
989978
)
990-
}
979+
}

cli/src/components/feedback-icon-button.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,12 @@ import React, { useRef } from 'react'
44
import { useHoverToggle } from './agent-mode-toggle'
55
import { Button } from './button'
66
import { useTheme } from '../hooks/use-theme'
7-
import { BORDER_CHARS } from '../utils/ui-constants'
87
import { logger } from '../utils/logger'
98
import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events'
109

1110
interface FeedbackIconButtonProps {
12-
onClick?: () => void
13-
onClose?: () => void
11+
onClick: () => void
12+
onClose: () => void
1413
isOpen?: boolean
1514
messageId?: string
1615
selectedCategory?: string
@@ -26,6 +25,10 @@ export const FeedbackIconButton: React.FC<FeedbackIconButtonProps> = ({
2625
const theme = useTheme()
2726
const hover = useHoverToggle()
2827
const hoveredOnceRef = useRef(false)
28+
const handleClick = () => {
29+
const action = isOpen ? onClose : onClick
30+
action()
31+
}
2932

3033
const handleMouseOver = () => {
3134
hover.clearCloseTimer()
@@ -65,7 +68,7 @@ export const FeedbackIconButton: React.FC<FeedbackIconButtonProps> = ({
6568
paddingLeft: 0,
6669
paddingRight: 0,
6770
}}
68-
onClick={() => (isOpen ? onClose?.() : onClick?.())}
71+
onClick={handleClick}
6972
onMouseOver={handleMouseOver}
7073
onMouseOut={handleMouseOut}
7174
>

cli/src/components/feedback-input-mode.tsx

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,45 @@ import { Button } from './button'
77
import { useTheme } from '../hooks/use-theme'
88
import { BORDER_CHARS } from '../utils/ui-constants'
99

10+
type CategoryHighlightKey = 'success' | 'error' | 'warning' | 'info'
11+
12+
type CategoryOption = {
13+
id: 'good_result' | 'bad_result' | 'app_bug' | 'other'
14+
label: string
15+
highlightKey: CategoryHighlightKey
16+
placeholder: string
17+
}
18+
19+
const CATEGORY_OPTIONS: readonly CategoryOption[] = [
20+
{
21+
id: 'good_result',
22+
label: 'Good result',
23+
highlightKey: 'success',
24+
placeholder:
25+
'What did you like? (e.g., "Fast and accurate", "Great explanation")',
26+
},
27+
{
28+
id: 'bad_result',
29+
label: 'Bad result',
30+
highlightKey: 'error',
31+
placeholder:
32+
'What went wrong? (e.g., "Incorrect changes", "Missed the requirement")',
33+
},
34+
{
35+
id: 'app_bug',
36+
label: 'App bug',
37+
highlightKey: 'warning',
38+
placeholder:
39+
'Report a problem with Codebuff (crashes, errors, UI issues, etc.)',
40+
},
41+
{
42+
id: 'other',
43+
label: 'Other',
44+
highlightKey: 'info',
45+
placeholder: 'Tell us more (what happened, what you expected)...',
46+
},
47+
] as const
48+
1049
interface FeedbackInputModeProps {
1150
feedbackText: string
1251
feedbackCursor: number
@@ -62,13 +101,6 @@ export const FeedbackInputMode: React.FC<FeedbackInputModeProps> = ({
62101
)
63102
)
64103

65-
const categoryOptions = [
66-
{ id: 'good_result', label: 'Good result', highlight: theme.success, placeholder: 'What did you like? (e.g., "Fast and accurate", "Great explanation")' },
67-
{ id: 'bad_result', label: 'Bad result', highlight: theme.error, placeholder: 'What went wrong? (e.g., "Incorrect changes", "Missed the requirement")' },
68-
{ id: 'app_bug', label: 'App bug', highlight: theme.warning, placeholder: 'Report a problem with Codebuff (crashes, errors, UI issues, etc.)' },
69-
{ id: 'other', label: 'Other', highlight: theme.info, placeholder: 'Tell us more (what happened, what you expected)...' },
70-
] as const
71-
72104
return (
73105
<box
74106
border
@@ -99,7 +131,8 @@ export const FeedbackInputMode: React.FC<FeedbackInputModeProps> = ({
99131

100132
{/* Category buttons */}
101133
<box style={{ flexDirection: 'row', gap: 1, paddingTop: 0, paddingBottom: 0 }}>
102-
{categoryOptions.map((option) => {
134+
{CATEGORY_OPTIONS.map((option) => {
135+
const optionHighlight = theme[option.highlightKey]
103136
const isSelected = category === option.id
104137
return (
105138
<Button
@@ -114,12 +147,13 @@ export const FeedbackInputMode: React.FC<FeedbackInputModeProps> = ({
114147
paddingTop: 0,
115148
paddingBottom: 0,
116149
borderStyle: 'single',
117-
borderColor: isSelected ? option.highlight : theme.border,
118-
customBorderChars: BORDER_CHARS, backgroundColor: 'transparent',
150+
borderColor: isSelected ? optionHighlight : theme.border,
151+
customBorderChars: BORDER_CHARS,
152+
backgroundColor: 'transparent',
119153
}}
120154
>
121155
<text style={{ wrapMode: 'none' }}>
122-
<span fg={isSelected ? option.highlight : theme.muted}>
156+
<span fg={isSelected ? optionHighlight : theme.muted}>
123157
{isSelected ? '◉' : '◯'}
124158
</span>
125159
<span fg={isSelected ? theme.foreground : theme.secondary}>
@@ -142,11 +176,8 @@ export const FeedbackInputMode: React.FC<FeedbackInputModeProps> = ({
142176
<box style={{ paddingTop: 0, paddingBottom: 0 }}>
143177
<MultilineInput
144178
value={feedbackText}
145-
onChange={(next: { text: string; cursorPosition: number; lastEditDueToNav: boolean } | ((prev: { text: string; cursorPosition: number; lastEditDueToNav: boolean }) => { text: string; cursorPosition: number; lastEditDueToNav: boolean })) => {
146-
const v = typeof next === 'function'
147-
? next({ text: feedbackText, cursorPosition: feedbackCursor, lastEditDueToNav: false })
148-
: next
149-
onFeedbackTextChange(v.text, v.cursorPosition)
179+
onChange={({ text, cursorPosition }) => {
180+
onFeedbackTextChange(text, cursorPosition)
150181
}}
151182
onSubmit={onSubmit}
152183
onKeyIntercept={(key) => {
@@ -157,7 +188,10 @@ export const FeedbackInputMode: React.FC<FeedbackInputModeProps> = ({
157188
onFeedbackTextChange(newText, feedbackCursor + 1)
158189
return true
159190
}}
160-
placeholder={categoryOptions.find(opt => opt.id === category)?.placeholder || 'Tell us more (what happened, what you expected)...'}
191+
placeholder={
192+
CATEGORY_OPTIONS.find((opt) => opt.id === category)?.placeholder ||
193+
'Tell us more (what happened, what you expected)...'
194+
}
161195
focused={true}
162196
maxHeight={5}
163197
minHeight={3}
@@ -185,7 +219,7 @@ export const FeedbackInputMode: React.FC<FeedbackInputModeProps> = ({
185219
gap: 2
186220
}}>
187221
<text style={{ wrapMode: 'none' }}>
188-
<span fg={theme.muted}>Auto-attached: message • trace • session</span>
222+
<span fg={theme.muted}>Session details are auto-attached</span>
189223
</text>
190224
<Button
191225
onClick={() => {

0 commit comments

Comments
 (0)