Skip to content

Commit f297a98

Browse files
committed
feat: embed queue preview in input box top border
Replace standard border with custom-drawn border that embeds the queue preview directly in the top border line
1 parent 65ef34d commit f297a98

File tree

3 files changed

+128
-109
lines changed

3 files changed

+128
-109
lines changed

cli/src/chat.tsx

Lines changed: 93 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { loadLocalAgents } from './utils/local-agent-registry'
3939
import { buildMessageTree } from './utils/message-tree-utils'
4040
import { createMarkdownPalette } from './utils/theme-system'
4141
import { BORDER_CHARS } from './utils/ui-constants'
42+
import { computeInputLayoutMetrics } from './utils/text-layout'
4243

4344
import type { SendMessageTimerEvent } from './hooks/use-send-message'
4445
import type { ContentBlock } from './types/chat'
@@ -535,6 +536,36 @@ export const Chat = ({
535536
) : null
536537

537538
const shouldShowQueuePreview = queuedMessages.length > 0
539+
const queuePreviewTitle = useMemo(() => {
540+
if (!shouldShowQueuePreview) return undefined
541+
const previewWidth = Math.max(30, separatorWidth - 20)
542+
return formatQueuedPreview(queuedMessages, previewWidth)
543+
}, [queuedMessages, separatorWidth, shouldShowQueuePreview])
544+
const hasSlashSuggestions = slashContext.active && slashSuggestionItems.length > 0
545+
const hasMentionSuggestions =
546+
!slashContext.active && mentionContext.active && agentSuggestionItems.length > 0
547+
const hasSuggestionMenu = hasSlashSuggestions || hasMentionSuggestions
548+
const showAgentStatusLine = showAgentDisplayName && loadedAgentsData
549+
550+
const inputLayoutMetrics = useMemo(() => {
551+
const text = inputValue ?? ''
552+
const layoutContent = text.length > 0 ? text : ' '
553+
const safeCursor = Math.max(0, Math.min(cursorPosition, layoutContent.length))
554+
const cursorProbe =
555+
safeCursor >= layoutContent.length
556+
? layoutContent
557+
: layoutContent.slice(0, safeCursor)
558+
const cols = Math.max(1, inputWidth - 4)
559+
return computeInputLayoutMetrics({
560+
layoutContent,
561+
cursorProbe,
562+
cols,
563+
maxHeight: 5,
564+
})
565+
}, [inputValue, cursorPosition, inputWidth])
566+
const isMultilineInput = inputLayoutMetrics.heightLines > 1
567+
const shouldCenterInputVertically =
568+
!hasSuggestionMenu && !showAgentStatusLine && !isMultilineInput
538569
const shouldShowStatusLine =
539570
streamStatus !== 'idle' ||
540571
shouldShowQueuePreview ||
@@ -711,46 +742,35 @@ export const Chat = ({
711742
</box>
712743
</box>
713744

714-
{/* Queue preview line - separate row */}
715-
{shouldShowQueuePreview && (
716-
<box
717-
style={{
718-
flexDirection: 'row',
719-
width: '100%',
720-
justifyContent: 'center',
721-
}}
722-
>
723-
<text style={{ wrapMode: 'none' }}>
724-
<span fg={theme.secondary} bg={theme.inputFocusedBg}>
725-
{` ${formatQueuedPreview(
726-
queuedMessages,
727-
Math.max(30, terminalWidth - 10),
728-
)} `}
729-
</span>
730-
</text>
731-
</box>
732-
)}
733745
</box>
734746
)}
747+
735748
<box
749+
title={queuePreviewTitle ? ` ${queuePreviewTitle} ` : undefined}
750+
titleAlignment="center"
736751
style={{
737752
width: '100%',
738753
borderStyle: 'single',
739754
borderColor: theme.secondary,
755+
focusedBorderColor: theme.foreground,
740756
customBorderChars: BORDER_CHARS,
757+
paddingLeft: 1,
758+
paddingRight: 1,
759+
paddingTop: 0,
760+
paddingBottom: 0,
761+
flexDirection: 'column',
762+
gap: hasSuggestionMenu ? 1 : 0,
741763
}}
742764
>
743-
{slashContext.active && slashSuggestionItems.length > 0 ? (
765+
{hasSlashSuggestions ? (
744766
<SuggestionMenu
745767
items={slashSuggestionItems}
746768
selectedIndex={slashSelectedIndex}
747769
maxVisible={10}
748770
prefix="/"
749771
/>
750772
) : null}
751-
{!slashContext.active &&
752-
mentionContext.active &&
753-
agentSuggestionItems.length > 0 ? (
773+
{hasMentionSuggestions ? (
754774
<SuggestionMenu
755775
items={agentSuggestionItems}
756776
selectedIndex={agentSelectedIndex}
@@ -760,55 +780,65 @@ export const Chat = ({
760780
) : null}
761781
<box
762782
style={{
763-
flexDirection: 'row',
764-
alignItems: 'center',
765-
width: '100%',
783+
flexDirection: 'column',
784+
justifyContent: shouldCenterInputVertically
785+
? 'center'
786+
: 'flex-start',
787+
minHeight: shouldCenterInputVertically ? 3 : undefined,
788+
gap: showAgentStatusLine ? 1 : 0,
766789
}}
767790
>
768-
<box style={{ flexGrow: 1, minWidth: 0 }}>
769-
<MultilineInput
770-
value={inputValue}
771-
onChange={setInputValue}
772-
onSubmit={handleSubmit}
773-
placeholder="Enter a coding task or / for commands"
774-
focused={inputFocused}
775-
maxHeight={5}
776-
width={inputWidth}
777-
onKeyIntercept={handleSuggestionMenuKey}
778-
textAttributes={theme.messageTextAttributes}
779-
ref={inputRef}
780-
cursorPosition={cursorPosition}
781-
/>
782-
</box>
783791
<box
784792
style={{
785-
flexShrink: 0,
786-
paddingLeft: 2,
793+
flexDirection: 'row',
794+
alignItems: shouldCenterInputVertically ? 'center' : 'flex-start',
795+
width: '100%',
787796
}}
788797
>
789-
<AgentModeToggle
790-
mode={agentMode}
791-
onToggle={toggleAgentMode}
792-
onSelectMode={setAgentMode}
793-
/>
798+
<box style={{ flexGrow: 1, minWidth: 0 }}>
799+
<MultilineInput
800+
value={inputValue}
801+
onChange={setInputValue}
802+
onSubmit={handleSubmit}
803+
placeholder="Enter a coding task or / for commands"
804+
focused={inputFocused}
805+
maxHeight={5}
806+
width={inputWidth}
807+
onKeyIntercept={handleSuggestionMenuKey}
808+
textAttributes={theme.messageTextAttributes}
809+
ref={inputRef}
810+
cursorPosition={cursorPosition}
811+
/>
812+
</box>
813+
<box
814+
style={{
815+
flexShrink: 0,
816+
paddingLeft: 2,
817+
}}
818+
>
819+
<AgentModeToggle
820+
mode={agentMode}
821+
onToggle={toggleAgentMode}
822+
onSelectMode={setAgentMode}
823+
/>
824+
</box>
794825
</box>
826+
{/* Agent status line - right-aligned under toggle */}
827+
{showAgentStatusLine && (
828+
<box
829+
style={{
830+
flexDirection: 'row',
831+
justifyContent: 'flex-end',
832+
paddingTop: 0,
833+
}}
834+
>
835+
<text>
836+
<span fg={theme.muted}>Agent: {agentDisplayName}</span>
837+
</text>
838+
</box>
839+
)}
795840
</box>
796841
</box>
797-
{/* Agent status line - right-aligned under toggle */}
798-
{showAgentDisplayName && loadedAgentsData && (
799-
<box
800-
style={{
801-
flexDirection: 'row',
802-
justifyContent: 'flex-end',
803-
paddingRight: 1,
804-
paddingTop: 0,
805-
}}
806-
>
807-
<text>
808-
<span fg={theme.muted}>Agent: {agentDisplayName}</span>
809-
</text>
810-
</box>
811-
)}
812842
</box>
813843

814844
{/* Login Modal Overlay - show when not authenticated and done checking */}

cli/src/components/agent-mode-toggle.tsx

Lines changed: 21 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import React, { useEffect, useRef, useState } from 'react'
2-
import stringWidth from 'string-width'
32

43
import { SegmentedControl } from './segmented-control'
54
import { useTheme } from '../hooks/use-theme'
5+
import { BORDER_CHARS } from '../utils/ui-constants'
66

77
import type { Segment } from './segmented-control'
88
import type { AgentMode } from '../utils/constants'
@@ -165,26 +165,14 @@ export const AgentModeToggle = ({
165165
const [isCollapsedHovered, setIsCollapsedHovered] = useState(false)
166166
const hoverToggle = useHoverToggle()
167167

168-
const handleCollapsedClick = () => {
169-
hoverToggle.clearAllTimers()
170-
if (hoverToggle.isOpen) {
171-
hoverToggle.closeNow(true)
172-
} else {
173-
hoverToggle.openNow()
174-
}
175-
}
176-
177168
const handleMouseOver = () => {
178-
if (!hoverToggle.isOpen) setIsCollapsedHovered(true)
179-
// Cancel any pending close and schedule open with delay
180169
hoverToggle.clearCloseTimer()
181170
hoverToggle.scheduleOpen()
182171
}
183172

184173
const handleMouseOut = () => {
185-
setIsCollapsedHovered(false)
186-
// Schedule close using the hook's configured delay
187174
hoverToggle.scheduleClose()
175+
setIsCollapsedHovered(false)
188176
}
189177

190178
const handleSegmentClick = (id: string) => {
@@ -204,45 +192,39 @@ export const AgentModeToggle = ({
204192
hoverToggle.closeNow(true)
205193
}
206194

207-
const renderCollapsedState = () => {
208-
const label = MODE_LABELS[mode]
209-
const arrow = '< '
210-
const contentText = ` ${arrow}${label} `
211-
const contentWidth = stringWidth(contentText)
212-
const horizontal = '─'.repeat(contentWidth)
213-
214-
const borderColor = isCollapsedHovered ? theme.foreground : theme.border
215-
195+
if (!hoverToggle.isOpen) {
216196
return (
217197
<box
218198
style={{
219-
flexDirection: 'column',
220-
gap: 0,
221-
backgroundColor: 'transparent',
199+
flexDirection: 'row',
200+
alignItems: 'center',
201+
paddingLeft: 1,
202+
paddingRight: 1,
203+
borderStyle: 'single',
204+
borderColor: isCollapsedHovered ? theme.foreground : theme.border,
205+
customBorderChars: BORDER_CHARS,
206+
}}
207+
onMouseDown={() => {
208+
hoverToggle.clearAllTimers()
209+
hoverToggle.openNow()
210+
}}
211+
onMouseOver={() => {
212+
setIsCollapsedHovered(true)
213+
handleMouseOver()
222214
}}
223-
onMouseDown={handleCollapsedClick}
224-
onMouseOver={handleMouseOver}
225215
onMouseOut={handleMouseOut}
226216
>
227-
<text fg={borderColor}>{`╭${horizontal}╮`}</text>
228-
<text fg={theme.foreground}>
229-
<span fg={borderColor}></span>
217+
<text wrapMode="none">
230218
{isCollapsedHovered ? (
231-
<b>{` ${arrow}${label} `}</b>
219+
<b>{`< ${MODE_LABELS[mode]}`}</b>
232220
) : (
233-
` ${arrow}${label} `
221+
`< ${MODE_LABELS[mode]}`
234222
)}
235-
<span fg={borderColor}></span>
236223
</text>
237-
<text fg={borderColor}>{`╰${horizontal}╯`}</text>
238224
</box>
239225
)
240226
}
241227

242-
if (!hoverToggle.isOpen) {
243-
return renderCollapsedState()
244-
}
245-
246228
// Expanded state: delegate rendering to SegmentedControl
247229
const segments: Segment[] = buildExpandedSegments(mode)
248230

cli/src/hooks/use-chat-input.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,23 @@ export const useChatInput = ({
2828
}: UseChatInputOptions) => {
2929
const hasAutoSubmittedRef = useRef(false)
3030

31-
// Estimate the actual collapsed toggle width as rendered by AgentModeToggle
32-
// Collapsed content is: " < " + LABEL + " " inside a bordered box.
33-
// Full width = contentWidth + 2 (vertical borders). We also include the
34-
// inter-element gap (the right container has paddingLeft: 2).
31+
// Estimate the collapsed toggle width as rendered by AgentModeToggle.
32+
// Collapsed content is "< LABEL" with 1 column of padding on each side and
33+
// a vertical border on each edge. Include the inter-element gap (the right
34+
// container has paddingLeft: 2).
3535
const MODE_LABELS = { DEFAULT: 'DEFAULT', MAX: 'MAX', PLAN: 'PLAN' } as const
36-
const collapsedContentWidth = stringWidth(` < ${MODE_LABELS[agentMode]} `)
37-
const collapsedBoxWidth = collapsedContentWidth + 2 // account for │ │
36+
const collapsedLabelWidth = stringWidth(`< ${MODE_LABELS[agentMode]}`)
37+
const horizontalPadding = 2 // one column padding on each side
38+
const collapsedBoxWidth = collapsedLabelWidth + horizontalPadding + 2 // include │ │
3839
const gapWidth = 2 // paddingLeft on the toggle container
3940
const estimatedToggleWidth = collapsedBoxWidth + gapWidth
40-
const inputWidth = Math.max(1, separatorWidth - estimatedToggleWidth)
41+
42+
// The content box that wraps the input row has paddingLeft/paddingRight = 1
43+
// (see cli/src/chat.tsx). Subtract those columns so our MultilineInput width
44+
// matches the true drawable area between the borders.
45+
const contentPadding = 2 // 1 left + 1 right padding
46+
const availableContentWidth = Math.max(1, separatorWidth - contentPadding)
47+
const inputWidth = Math.max(1, availableContentWidth - estimatedToggleWidth)
4148

4249
const handleBuildFast = useCallback(() => {
4350
setAgentMode('DEFAULT')

0 commit comments

Comments
 (0)