Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# kilo-code

## 4.141.0

### Minor Changes

- [#4702](https://github.com/Kilo-Org/kilocode/pull/4702) [`b84a66f`](https://github.com/Kilo-Org/kilocode/commit/b84a66f5923cf2600a6d5c8e2b5fd49759406696) Thanks [@chrarnoldus](https://github.com/chrarnoldus)! - Add support for skills

### Patch Changes

- [#4710](https://github.com/Kilo-Org/kilocode/pull/4710) [`c128319`](https://github.com/Kilo-Org/kilocode/commit/c1283192df1b0e59fef8b9ab2d3442bf4a07abde) Thanks [@sebastiand-cerebras](https://github.com/sebastiand-cerebras)! - Update Cerebras maxTokens from 8192 to 16384 for all models

- [#4718](https://github.com/Kilo-Org/kilocode/pull/4718) [`9a465b0`](https://github.com/Kilo-Org/kilocode/commit/9a465b06fe401f70dd166fb5b320a8070f07c727) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Fix terminal scroll-flicker in CLI by disabling streaming output and enabling Ink incremental rendering

- [#4719](https://github.com/Kilo-Org/kilocode/pull/4719) [`57b0873`](https://github.com/Kilo-Org/kilocode/commit/57b08737788cd504954563d46eb1e6323d619301) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Confirm before exiting the CLI on Ctrl+C/Cmd+C.

## 4.140.3

### Patch Changes
Expand Down
42 changes: 35 additions & 7 deletions cli/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { basename } from "node:path"
import { render, Instance } from "ink"
import { render, Instance, type RenderOptions } from "ink"
import React from "react"
import { createStore } from "jotai"
import { createExtensionService, ExtensionService } from "./services/extension.js"
Expand Down Expand Up @@ -33,6 +33,7 @@ import { getSelectedModelId } from "./utils/providers.js"
import { KiloCodePathProvider, ExtensionMessengerAdapter } from "./services/session-adapters.js"
import { getKiloToken } from "./config/persistence.js"
import { SessionManager } from "../../src/shared/kilocode/cli-sessions/core/SessionManager.js"
import { triggerExitConfirmationAtom } from "./state/atoms/keyboard.js"

/**
* Main application class that orchestrates the CLI lifecycle
Expand Down Expand Up @@ -330,6 +331,13 @@ export class CLI {
// Disable stdin for Ink when in CI mode or when stdin is piped (not a TTY)
// This prevents the "Raw mode is not supported" error
const shouldDisableStdin = this.options.jsonInteractive || this.options.ci || !process.stdin.isTTY
const renderOptions: RenderOptions = {
// Enable Ink's incremental renderer to avoid redrawing the entire screen on every update.
// This reduces flickering for frequently updating UIs.
incrementalRendering: true,
exitOnCtrlC: false,
...(shouldDisableStdin ? { stdout: process.stdout, stderr: process.stderr } : {}),
}

this.ui = render(
React.createElement(App, {
Expand All @@ -349,12 +357,7 @@ export class CLI {
},
onExit: () => this.dispose(),
}),
shouldDisableStdin
? {
stdout: process.stdout,
stderr: process.stderr,
}
: undefined,
renderOptions,
)

// Wait for UI to exit
Expand Down Expand Up @@ -671,6 +674,31 @@ export class CLI {
return this.store
}

/**
* Returns true if the CLI should show an exit confirmation prompt for SIGINT.
*/
shouldConfirmExitOnSigint(): boolean {
return (
!!this.store &&
!this.options.ci &&
!this.options.json &&
!this.options.jsonInteractive &&
process.stdin.isTTY
)
}

/**
* Trigger the exit confirmation prompt. Returns true if handled.
*/
requestExitConfirmation(): boolean {
if (!this.shouldConfirmExitOnSigint()) {
return false
}

this.store?.set(triggerExitConfirmationAtom)
return true
}

/**
* Check if the application is initialized
*/
Expand Down
4 changes: 4 additions & 0 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,10 @@ program

// Handle process termination signals
process.on("SIGINT", async () => {
if (cli?.requestExitConfirmation()) {
return
}

if (cli) {
await cli.dispose("SIGINT")
} else {
Expand Down
29 changes: 28 additions & 1 deletion cli/src/state/atoms/__tests__/keyboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import {
fileMentionSuggestionsAtom,
} from "../ui.js"
import { textBufferStringAtom, textBufferStateAtom } from "../textBuffer.js"
import { keyboardHandlerAtom, submissionCallbackAtom, submitInputAtom } from "../keyboard.js"
import {
exitPromptVisibleAtom,
exitRequestCounterAtom,
keyboardHandlerAtom,
submissionCallbackAtom,
submitInputAtom,
} from "../keyboard.js"
import { pendingApprovalAtom } from "../approval.js"
import { historyDataAtom, historyModeAtom, historyIndexAtom as _historyIndexAtom } from "../history.js"
import { chatMessagesAtom } from "../extension.js"
Expand Down Expand Up @@ -1087,5 +1093,26 @@ describe("keypress atoms", () => {
// When not streaming, ESC should clear the buffer (normal behavior)
expect(store.get(textBufferStringAtom)).toBe("")
})

it("should require confirmation before exiting on Ctrl+C", async () => {
const ctrlCKey: Key = {
name: "c",
sequence: "\u0003",
ctrl: true,
meta: false,
shift: false,
paste: false,
}

await store.set(keyboardHandlerAtom, ctrlCKey)

expect(store.get(exitPromptVisibleAtom)).toBe(true)
expect(store.get(exitRequestCounterAtom)).toBe(0)

await store.set(keyboardHandlerAtom, ctrlCKey)

expect(store.get(exitPromptVisibleAtom)).toBe(false)
expect(store.get(exitRequestCounterAtom)).toBe(1)
})
})
})
38 changes: 37 additions & 1 deletion cli/src/state/atoms/keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,41 @@ export const kittyProtocolEnabledAtom = atom<boolean>(false)
*/
export const debugKeystrokeLoggingAtom = atom<boolean>(false)

// ============================================================================
// Exit Confirmation State
// ============================================================================

const EXIT_CONFIRMATION_WINDOW_MS = 2000

type ExitPromptTimeout = ReturnType<typeof setTimeout>

export const exitPromptVisibleAtom = atom<boolean>(false)
const exitPromptTimeoutAtom = atom<ExitPromptTimeout | null>(null)
export const exitRequestCounterAtom = atom<number>(0)

export const triggerExitConfirmationAtom = atom(null, (get, set) => {
const exitPromptVisible = get(exitPromptVisibleAtom)
const existingTimeout = get(exitPromptTimeoutAtom)

if (existingTimeout) {
clearTimeout(existingTimeout)
set(exitPromptTimeoutAtom, null)
}

if (exitPromptVisible) {
set(exitPromptVisibleAtom, false)
set(exitRequestCounterAtom, (count) => count + 1)
return
}

set(exitPromptVisibleAtom, true)
const timeout = setTimeout(() => {
set(exitPromptVisibleAtom, false)
set(exitPromptTimeoutAtom, null)
}, EXIT_CONFIRMATION_WINDOW_MS)
set(exitPromptTimeoutAtom, timeout)
})

// ============================================================================
// Buffer Atoms
// ============================================================================
Expand Down Expand Up @@ -795,7 +830,8 @@ function handleGlobalHotkeys(get: Getter, set: Setter, key: Key): boolean {
switch (key.name) {
case "c":
if (key.ctrl) {
process.exit(0)
set(triggerExitConfirmationAtom)
return true
}
break
case "x":
Expand Down
2 changes: 1 addition & 1 deletion cli/src/state/atoms/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -696,7 +696,7 @@ export const resetMessageCutoffAtom = atom(null, (get, set) => {
*/
export const splitMessagesAtom = atom((get) => {
const allMessages = get(mergedMessagesAtom)
return splitMessages(allMessages)
return splitMessages(allMessages, { hidePartialMessages: true })
})

/**
Expand Down
13 changes: 13 additions & 0 deletions cli/src/ui/UI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { generateNotificationMessage } from "../utils/notifications.js"
import { notificationsAtom } from "../state/atoms/notifications.js"
import { workspacePathAtom } from "../state/atoms/shell.js"
import { useTerminal } from "../state/hooks/useTerminal.js"
import { exitRequestCounterAtom } from "../state/atoms/keyboard.js"

// Initialize commands on module load
initializeCommands()
Expand Down Expand Up @@ -65,6 +66,7 @@ export const UI: React.FC<UIAppProps> = ({ options, onExit }) => {
const setWorkspacePath = useSetAtom(workspacePathAtom)
const taskResumedViaSession = useAtomValue(taskResumedViaContinueOrSessionAtom)
const { hasActiveTask } = useTaskState()
const exitRequestCounter = useAtomValue(exitRequestCounterAtom)

// Use specialized hooks for command and message handling
const { executeCommand, isExecuting: isExecutingCommand } = useCommandHandler()
Expand Down Expand Up @@ -94,6 +96,17 @@ export const UI: React.FC<UIAppProps> = ({ options, onExit }) => {
onExit: onExit,
})

const handledExitRequestRef = useRef(exitRequestCounter)

useEffect(() => {
if (exitRequestCounter === handledExitRequestRef.current) {
return
}

handledExitRequestRef.current = exitRequestCounter
void executeCommand("/exit", onExit)
}, [exitRequestCounter, executeCommand, onExit])

// Track if prompt has been executed and welcome message shown
const promptExecutedRef = useRef(false)
const welcomeShownRef = useRef(false)
Expand Down
13 changes: 11 additions & 2 deletions cli/src/ui/components/StatusIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ThinkingAnimation } from "./ThinkingAnimation.js"
import { useAtomValue } from "jotai"
import { isStreamingAtom } from "../../state/atoms/ui.js"
import { hasResumeTaskAtom } from "../../state/atoms/extension.js"
import { exitPromptVisibleAtom } from "../../state/atoms/keyboard.js"

export interface StatusIndicatorProps {
/** Whether the indicator is disabled */
Expand All @@ -34,6 +35,8 @@ export const StatusIndicator: React.FC<StatusIndicatorProps> = ({ disabled = fal
const { hotkeys, shouldShow } = useHotkeys()
const isStreaming = useAtomValue(isStreamingAtom)
const hasResumeTask = useAtomValue(hasResumeTaskAtom)
const exitPromptVisible = useAtomValue(exitPromptVisibleAtom)
const exitModifierKey = process.platform === "darwin" ? "Cmd" : "Ctrl"

// Don't render if no hotkeys to show or disabled
if (!shouldShow || disabled) {
Expand All @@ -44,8 +47,14 @@ export const StatusIndicator: React.FC<StatusIndicatorProps> = ({ disabled = fal
<Box borderStyle="round" borderColor={theme.ui.border.default} paddingX={1} justifyContent="space-between">
{/* Status text on the left */}
<Box>
{isStreaming && <ThinkingAnimation />}
{hasResumeTask && <Text color={theme.ui.text.dimmed}>Task ready to resume</Text>}
{exitPromptVisible ? (
<Text color={theme.semantic.warning}>Press {exitModifierKey}+C again to exit.</Text>
) : (
<>
{isStreaming && <ThinkingAnimation />}
{hasResumeTask && <Text color={theme.ui.text.dimmed}>Task ready to resume</Text>}
</>
)}
</Box>

{/* Hotkeys on the right */}
Expand Down
14 changes: 14 additions & 0 deletions cli/src/ui/components/__tests__/StatusIndicator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { createStore } from "jotai"
import { StatusIndicator } from "../StatusIndicator.js"
import { showFollowupSuggestionsAtom } from "../../../state/atoms/ui.js"
import { chatMessagesAtom } from "../../../state/atoms/extension.js"
import { exitPromptVisibleAtom } from "../../../state/atoms/keyboard.js"
import type { ExtensionChatMessage } from "../../../types/messages.js"

// Mock the hooks
Expand Down Expand Up @@ -92,6 +93,19 @@ describe("StatusIndicator", () => {
expect(output).toContain("for commands")
})

it("should show exit confirmation prompt when Ctrl+C is pressed once", () => {
store.set(exitPromptVisibleAtom, true)

const { lastFrame } = render(
<JotaiProvider store={store}>
<StatusIndicator disabled={false} />
</JotaiProvider>,
)

const output = lastFrame()
expect(output).toMatch(/Press (?:Ctrl|Cmd)\+C again to exit\./)
})

it("should not show Thinking status when not streaming", () => {
// Complete message = not streaming
const completeMessage: ExtensionChatMessage = {
Expand Down
57 changes: 16 additions & 41 deletions cli/src/ui/messages/MessageDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,15 @@
* MessageDisplay component - displays chat messages from both CLI and extension state
* Uses Ink Static component to optimize rendering of completed messages
*
* Performance Optimization:
* ------------------------
* Messages are split into two sections:
* 1. Static section: Completed messages that won't change (rendered once with Ink Static)
* 2. Dynamic section: Incomplete/updating messages (re-rendered as needed)
*
* This prevents unnecessary re-renders of completed messages, improving performance
* especially in long conversations.
* Pure Static Mode:
* -----------------
* Partial/streaming messages are filtered out at the atom level (see splitMessagesAtom),
* so this component only ever renders completed messages using Ink Static.
*
* Message Completion Logic:
* -------------------------
* A message is considered complete when:
* - CLI messages: partial !== true
* - Extension messages: depends on type (see messageCompletion.ts)
* - Sequential rule: A message can only be static if all previous messages are complete
* In pure static mode, any message with `partial === true` is hidden and everything else is
* treated as complete for display purposes.
*
* Key Generation Strategy:
* -----------------------
Expand All @@ -40,16 +34,9 @@
import React from "react"
import { Box, Static } from "ink"
import { useAtomValue } from "jotai"
import { type UnifiedMessage, staticMessagesAtom, dynamicMessagesAtom } from "../../state/atoms/ui.js"
import { type UnifiedMessage, staticMessagesAtom } from "../../state/atoms/ui.js"
import { MessageRow } from "./MessageRow.js"

interface MessageDisplayProps {
/** Optional filter to show only specific message types */
filterType?: "ask" | "say"
/** Maximum number of messages to display (default: all) */
maxMessages?: number
}

/**
* Generate a unique key for a unified message
* Uses a composite key strategy to ensure uniqueness even when messages
Expand Down Expand Up @@ -79,34 +66,22 @@ function getMessageKey(msg: UnifiedMessage, index: number): string {
return `${subtypeKey}-${index}`
}

export const MessageDisplay: React.FC<MessageDisplayProps> = () => {
export const MessageDisplay: React.FC = () => {
const staticMessages = useAtomValue(staticMessagesAtom)
const dynamicMessages = useAtomValue(dynamicMessagesAtom)

if (staticMessages.length === 0 && dynamicMessages.length === 0) {
if (staticMessages.length === 0) {
return null
}

return (
<Box flexDirection="column">
{/* Static section for completed messages - won't re-render */}
{/* Key includes resetCounter to force re-mount when messages are replaced */}
{staticMessages.length > 0 && (
<Static items={staticMessages}>
{(message, index) => (
<Box key={getMessageKey(message, index)} paddingX={1}>
<MessageRow unifiedMessage={message} />
</Box>
)}
</Static>
)}

{/* Dynamic section for incomplete/updating messages - will re-render */}
{dynamicMessages.map((unifiedMsg, index) => (
<Box paddingX={1} key={getMessageKey(unifiedMsg, staticMessages.length + index)}>
<MessageRow unifiedMessage={unifiedMsg} />
</Box>
))}
<Static items={staticMessages}>
{(message, index) => (
<Box key={getMessageKey(message, index)} paddingX={1}>
<MessageRow unifiedMessage={message} />
</Box>
)}
</Static>
</Box>
)
}
Loading
Loading