Skip to content

Commit 8c4b5ba

Browse files
committed
feat: change the keyboard shortcut for navigating "+ x agents" hints
(tab-based)
1 parent d5263e9 commit 8c4b5ba

File tree

2 files changed

+165
-42
lines changed

2 files changed

+165
-42
lines changed

knowledge.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,18 @@ Codebuff is a tool for editing codebases via natural language instruction to Buf
5555
- ESC key to toggle menu or stop AI response
5656
- CTRL+C to exit the application
5757

58+
### Terminal Key Compatibility
59+
60+
**Cross-Platform Navigation**: For maximum compatibility across terminal emulators:
61+
62+
- Use `Ctrl+Tab/Ctrl+Shift+Tab` for hint navigation - works reliably everywhere and avoids conflicts with normal Tab behavior
63+
- Use `Enter` to send messages or expand nodes (context-aware) - prioritizes chat functionality
64+
- Use `Backspace` to delete characters or collapse nodes (context-aware) - prioritizes chat functionality
65+
- `Ctrl+Up/Down` is unreliable on macOS
66+
- `Shift+Arrow` combinations have mixed compatibility
67+
- Emacs/readline shortcuts (`Ctrl+A`, `Ctrl+E`, etc.) are the most reliable cross-platform
68+
- Avoid `Option/Meta` keys when possible as they require terminal configuration on macOS
69+
5870
## Package Management
5971

6072
- Use Bun for all package management operations
@@ -270,6 +282,7 @@ The `.bin/bun` script automatically wraps bun commands with infisical when secre
270282
**Worktree Support**: The wrapper automatically detects and loads `.env.worktree` files when present, allowing worktrees to override Infisical environment variables (like ports) for local development. This enables multiple worktrees to run simultaneously on different ports without conflicts.
271283

272284
The wrapper also loads environment variables in the correct precedence order:
285+
273286
1. Infisical secrets are loaded first (if needed)
274287
2. `.env.worktree` is loaded second to override any conflicting variables
275288
3. This ensures worktree-specific overrides (like custom ports) always take precedence over cached Infisical defaults

npm-app/src/cli-handlers/chat.ts

Lines changed: 152 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ import {
1717
// Constants
1818
const SIDE_PADDING = 2
1919
const HEADER_TEXT = '💬 Codebuff Chat'
20-
const STATUS_TEXT = 'Shift + →/← to view agent traces • ESC or Ctrl+C to exit'
20+
const STATUS_TEXT =
21+
'Ctrl+Tab/Ctrl+Shift+Tab to navigate hints • Enter to expand/send • Backspace to collapse/delete • ESC or Ctrl+C to exit'
2122
const PLACEHOLDER_TEXT = 'Type your message...'
2223
const WELCOME_MESSAGE =
2324
'Welcome to Codebuff Chat! Type your messages below and press Enter to send. This is a dedicated chat interface for conversations with your AI assistant.'
@@ -68,7 +69,6 @@ interface ChatState {
6869
messageQueue: string[]
6970
userHasScrolled: boolean
7071
currentStreamingMessageId?: string
71-
navigationMode: boolean // New: track if we're in navigation mode
7272
}
7373

7474
// State
@@ -83,7 +83,6 @@ let chatState: ChatState = {
8383
messageQueue: [],
8484
userHasScrolled: false,
8585
currentStreamingMessageId: undefined,
86-
navigationMode: false, // Initialize navigation mode
8786
}
8887

8988
// Cached date formatter for performance
@@ -202,7 +201,6 @@ function resetChatState(): void {
202201
messageQueue: [],
203202
userHasScrolled: false,
204203
currentStreamingMessageId: undefined,
205-
navigationMode: false,
206204
}
207205
}
208206

@@ -669,24 +667,19 @@ function setupChatKeyHandler(rl: any, onExit: () => void) {
669667
return
670668
}
671669

672-
// Check for Shift+Right to enter navigation mode
673-
if (key && key.shift && key.name === 'right' && !chatState.navigationMode) {
674-
initializeNavigationMode()
670+
// Handle Ctrl+Tab hint navigation (no mode needed!)
671+
if (handleCtrlTabNavigation(key)) {
675672
return
676673
}
677674

678-
// ESC exits navigation mode
679-
if (key && key.name === 'escape' && chatState.navigationMode) {
680-
chatState.navigationMode = false
681-
// Clear focus from all messages
682-
chatState.messages.forEach((message) => {
683-
if (message.subagentUIState) {
684-
message.subagentUIState.focusNodeId = null
685-
}
686-
})
687-
updateContentLines()
688-
renderChat()
689-
return
675+
// ESC clears hint focus
676+
if (key && key.name === 'escape') {
677+
const hadFocus = clearAllHintFocus()
678+
if (hadFocus) {
679+
updateContentLines()
680+
renderChat()
681+
return
682+
}
690683
}
691684

692685
// Handle Enter - send message (always allow queuing)
@@ -702,18 +695,11 @@ function setupChatKeyHandler(rl: any, onExit: () => void) {
702695
}
703696
chatState.currentInput = ''
704697
}
705-
// Exit navigation mode when sending a message
706-
chatState.navigationMode = false
707-
chatState.messages.forEach((message) => {
708-
if (message.subagentUIState) {
709-
message.subagentUIState.focusNodeId = null
710-
}
711-
})
712698
renderChat()
713699
return
714700
}
715701

716-
// Handle backspace
702+
// Handle backspace for text input
717703
if (key && key.name === 'backspace') {
718704
chatState.currentInput = chatState.currentInput.slice(0, -1)
719705
renderChat()
@@ -839,8 +825,8 @@ async function sendMessage(message: string, addToChat: boolean = true) {
839825

840826
// Auto-focus the latest assistant message if it has subagents
841827
const latestMessageId = findLatestAssistantMessageWithChildren()
842-
if (latestMessageId && !chatState.navigationMode) {
843-
initializeNavigationMode()
828+
if (latestMessageId) {
829+
autoFocusLatestHint()
844830
}
845831

846832
renderChat()
@@ -1519,8 +1505,56 @@ function handleTabExpansion(): boolean {
15191505
return false
15201506
}
15211507

1508+
function handleCtrlTabNavigation(key: any): boolean {
1509+
// Only handle Ctrl+Tab combinations
1510+
if (!key || key.name !== 'tab' || !key.ctrl) return false
1511+
1512+
const allHintNodes = getAllHintNodes()
1513+
if (allHintNodes.length === 0) return false
1514+
1515+
const currentFocusId = getCurrentFocusedHintNodeId()
1516+
let currentIndex = currentFocusId
1517+
? allHintNodes.findIndex((h) => h.nodeId === currentFocusId)
1518+
: -1
1519+
1520+
if (key.shift) {
1521+
// Ctrl+Shift+Tab: backward (previous hint)
1522+
currentIndex =
1523+
currentIndex <= 0 ? allHintNodes.length - 1 : currentIndex - 1
1524+
} else {
1525+
// Ctrl+Tab: forward (next hint)
1526+
currentIndex =
1527+
currentIndex >= allHintNodes.length - 1 ? 0 : currentIndex + 1
1528+
}
1529+
1530+
const targetHint = allHintNodes[currentIndex]
1531+
if (targetHint) {
1532+
// Clear focus from all messages first
1533+
clearAllHintFocus()
1534+
1535+
// Set focus to the target hint
1536+
const targetMessage = chatState.messages.find(
1537+
(m) => m.id === targetHint.messageId,
1538+
)
1539+
if (targetMessage && targetMessage.subagentUIState) {
1540+
targetMessage.subagentUIState.focusNodeId = targetHint.nodeId
1541+
}
1542+
1543+
updateContentLines()
1544+
renderChat()
1545+
return true
1546+
}
1547+
1548+
return false
1549+
}
1550+
15221551
function handleSubagentNavigation(key: any): boolean {
1523-
if (!chatState.navigationMode) return false
1552+
// Only handle navigation if a hint is currently focused
1553+
const currentFocusId = getCurrentFocusedHintNodeId()
1554+
if (!currentFocusId) return false
1555+
1556+
// Don't handle navigation if user is typing - prioritize chat functionality
1557+
if (chatState.currentInput.length > 0) return false
15241558

15251559
// Find the currently focused message
15261560
const focusedMessage = chatState.messages.find(
@@ -1538,8 +1572,8 @@ function handleSubagentNavigation(key: any): boolean {
15381572
const tree = focusedMessage.subagentTree
15391573
const uiState = focusedMessage.subagentUIState
15401574

1541-
if (key.shift && key.name === 'right') {
1542-
// Shift+Right: Simple expansion - just expand current node and focus first child
1575+
if (key.name === 'return') {
1576+
// Enter: Expand current node and focus first child
15431577
if (!uiState.focusNodeId) return true
15441578

15451579
// Handle hint node focus - if focused on hint, expand that node
@@ -1574,21 +1608,20 @@ function handleSubagentNavigation(key: any): boolean {
15741608
const isExpanded = uiState.expanded.has(uiState.focusNodeId)
15751609

15761610
if (!isExpanded && hasChildren) {
1577-
// Expand the node and focus on its hint line (like the reverse of Shift+Left)
1611+
// Expand the node and focus on first child
15781612
uiState.expanded.add(uiState.focusNodeId)
1579-
// Focus on first child
15801613
const firstChildPath = [...path, 0]
15811614
const firstChildNodeId = createNodeId(focusedMessage.id, firstChildPath)
15821615
uiState.focusNodeId = firstChildNodeId
15831616
}
1584-
// If already expanded or no children, do nothing (no jumping around)
1617+
// If already expanded or no children, do nothing
15851618

15861619
updateContentLines()
15871620
renderChat()
15881621
return true
15891622
}
1590-
if (key.shift && key.name === 'left') {
1591-
// Shift+Left: Toggle-style collapse - just collapse the currently focused node
1623+
if (key.name === 'backspace') {
1624+
// Backspace: Collapse current node and move to parent
15921625
if (!uiState.focusNodeId) return true
15931626

15941627
// Handle hint node focus - can't collapse a hint, so move to parent
@@ -1654,9 +1687,88 @@ function countTotalAgents(tree: SubagentNode): number {
16541687
return count
16551688
}
16561689

1657-
function initializeNavigationMode(): void {
1658-
if (chatState.navigationMode) return
1690+
function getAllHintNodes(): Array<{
1691+
messageId: string
1692+
nodeId: string
1693+
depth: number
1694+
}> {
1695+
const hintNodes: Array<{ messageId: string; nodeId: string; depth: number }> =
1696+
[]
1697+
1698+
chatState.messages.forEach((message) => {
1699+
if (
1700+
message.role === 'assistant' &&
1701+
message.subagentTree &&
1702+
message.subagentUIState
1703+
) {
1704+
// Collect hint nodes from this message's tree
1705+
collectHintNodesFromTree(
1706+
message.subagentTree,
1707+
message.id,
1708+
message.subagentUIState,
1709+
hintNodes,
1710+
0,
1711+
)
1712+
}
1713+
})
1714+
1715+
return hintNodes
1716+
}
1717+
1718+
function collectHintNodesFromTree(
1719+
node: SubagentNode,
1720+
messageId: string,
1721+
uiState: SubagentUIState,
1722+
hintNodes: Array<{ messageId: string; nodeId: string; depth: number }>,
1723+
depth: number,
1724+
path: number[] = [],
1725+
): void {
1726+
const nodeId = createNodeId(messageId, path)
1727+
const hasChildren = node.children && node.children.length > 0
1728+
const isExpanded = uiState.expanded.has(nodeId)
1729+
1730+
// If this node has children but is not expanded, it has a hint line
1731+
if (hasChildren && !isExpanded) {
1732+
const hintNodeId = nodeId + '/hint'
1733+
hintNodes.push({ messageId, nodeId: hintNodeId, depth })
1734+
}
1735+
1736+
// If expanded, recurse into children
1737+
if (hasChildren && isExpanded) {
1738+
node.children.forEach((child, index) => {
1739+
collectHintNodesFromTree(
1740+
child,
1741+
messageId,
1742+
uiState,
1743+
hintNodes,
1744+
depth + 1,
1745+
[...path, index],
1746+
)
1747+
})
1748+
}
1749+
}
1750+
1751+
function getCurrentFocusedHintNodeId(): string | null {
1752+
for (const message of chatState.messages) {
1753+
if (message.subagentUIState?.focusNodeId?.endsWith('/hint')) {
1754+
return message.subagentUIState.focusNodeId
1755+
}
1756+
}
1757+
return null
1758+
}
1759+
1760+
function clearAllHintFocus(): boolean {
1761+
let hadFocus = false
1762+
chatState.messages.forEach((message) => {
1763+
if (message.subagentUIState?.focusNodeId) {
1764+
message.subagentUIState.focusNodeId = null
1765+
hadFocus = true
1766+
}
1767+
})
1768+
return hadFocus
1769+
}
16591770

1771+
function autoFocusLatestHint(): void {
16601772
const latestMessageId = findLatestAssistantMessageWithChildren()
16611773
if (!latestMessageId) return
16621774

@@ -1672,11 +1784,9 @@ function initializeNavigationMode(): void {
16721784
}
16731785
}
16741786

1675-
// Always focus on the hint line when entering navigation mode
1676-
// This ensures the hint gets highlighted immediately
1787+
// Focus on the hint line to show users there are expandable items
16771788
message.subagentUIState.focusNodeId = createNodeId(message.id, []) + '/hint'
16781789

1679-
chatState.navigationMode = true
16801790
updateContentLines()
16811791
renderChat()
16821792
}

0 commit comments

Comments
 (0)