Resolved merge conflicts

This commit is contained in:
ShayBC 2025-03-01 01:10:27 +02:00
commit 41ddb7d583
30 changed files with 1022 additions and 634 deletions

View file

@ -0,0 +1,5 @@
---
"roo-cline": patch
---
Delete task confirmation enhancements

View file

@ -0,0 +1,5 @@
---
"roo-cline": patch
---
Fix maxTokens defaults for Claude 3.7 Sonnet models

View file

@ -0,0 +1,5 @@
---
"roo-cline": patch
---
Prettier thinking blocks

View file

@ -1,37 +1,35 @@
<!-- **Note:** Consider creating PRs as a DRAFT. For early feedback and self-review. -->
## Context
## Description
<!-- Brief description of WHAT youre doing and WHY. -->
## Type of change
## Implementation
<!-- Please ignore options that are not relevant -->
<!--
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update
Some description of HOW you achieved it. Perhaps give a high level description of the program flow. Did you need to refactor something? What tradeoffs did you take? Are there things in here which youd particularly like people to pay close attention to?
## How Has This Been Tested?
-->
<!-- Please describe the tests that you ran to verify your changes -->
## Screenshots
## Checklist:
| before | after |
| ------ | ----- |
| | |
<!-- Go over all the following points, and put an `x` in all the boxes that apply -->
## How to Test
- [ ] My code follows the patterns of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
<!--
## Additional context
A straightforward scenario of how to test your changes will help reviewers that are not familiar with the part of the code that you are changing but want to see it in action. This section can include a description or step-by-step instructions of how to get to the state of v2 that your change affects.
<!-- Add any other context or screenshots about the pull request here -->
A "How To Test" section can look something like this:
## Related Issues
- Sign in with a user with tracks
- Activate `show_awesome_cat_gifs` feature (add `?feature.show_awesome_cat_gifs=1` to your URL)
- You should see a GIF with cats dancing
<!-- List any related issues here. Use the GitHub issue linking syntax: #issue-number -->
-->
## Reviewers
## Get in Touch
<!-- @mention specific team members or individuals who should review this PR -->
<!-- We'd love to have a way to chat with you about your changes if necessary. If you're in the [Roo Code Discord](https://discord.gg/roocode), please share your handle here. -->

View file

@ -278,7 +278,7 @@ export async function getOpenRouterModels() {
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 3.75
modelInfo.cacheReadsPrice = 0.3
modelInfo.maxTokens = 64_000
modelInfo.maxTokens = rawModel.id === "anthropic/claude-3.7-sonnet:thinking" ? 64_000 : 16_384
break
case rawModel.id.startsWith("anthropic/claude-3.5-sonnet-20240620"):
modelInfo.supportsPromptCache = true

View file

@ -22,7 +22,7 @@ import {
everyLineHasLineNumbers,
truncateOutput,
} from "../integrations/misc/extract-text"
import { TerminalManager } from "../integrations/terminal/TerminalManager"
import { TerminalManager, ExitCodeDetails } from "../integrations/terminal/TerminalManager"
import { UrlContentFetcher } from "../services/browser/UrlContentFetcher"
import { listFiles } from "../services/glob/list-files"
import { regexSearchFiles } from "../services/ripgrep"
@ -159,7 +159,7 @@ export class Cline {
throw new Error("Either historyItem or task/images must be provided")
}
this.taskId = crypto.randomUUID()
this.taskId = historyItem ? historyItem.id : crypto.randomUUID()
this.taskNumber = -1
this.apiConfiguration = apiConfiguration
this.api = buildApiHandler(apiConfiguration)
@ -173,10 +173,6 @@ export class Cline {
this.diffViewProvider = new DiffViewProvider(cwd)
this.enableCheckpoints = enableCheckpoints ?? false
if (historyItem) {
this.taskId = historyItem.id
}
// Initialize diffStrategy based on current state
this.updateDiffStrategy(Experiments.isEnabled(experiments ?? {}, EXPERIMENT_IDS.DIFF_STRATEGY))
@ -927,10 +923,21 @@ export class Cline {
})
let completed = false
process.once("completed", () => {
let exitDetails: ExitCodeDetails | undefined
process.once("completed", (output?: string) => {
// Use provided output if available, otherwise keep existing result.
if (output) {
lines = output.split("\n")
}
completed = true
})
process.once("shell_execution_complete", (id: number, details: ExitCodeDetails) => {
if (id === terminalInfo.id) {
exitDetails = details
}
})
process.once("no_shell_integration", async () => {
await this.say("shell_integration_warning")
})
@ -962,7 +969,18 @@ export class Cline {
}
if (completed) {
return [false, `Command executed.${result.length > 0 ? `\nOutput:\n${result}` : ""}`]
let exitStatus = "No exit code available"
if (exitDetails !== undefined) {
if (exitDetails.signal) {
exitStatus = `Process terminated by signal ${exitDetails.signal} (${exitDetails.signalName})`
if (exitDetails.coreDumpPossible) {
exitStatus += " - core dump possible"
}
} else {
exitStatus = `Exit code: ${exitDetails.exitCode}`
}
}
return [false, `Command executed. ${exitStatus}${result.length > 0 ? `\nOutput:\n${result}` : ""}`]
} else {
return [
false,

View file

@ -70,6 +70,15 @@ Interestingly, some environments like Cursor enable these APIs even without the
This approach allows us to leverage advanced features when available while ensuring broad compatibility.
*/
declare module "vscode" {
// https://github.com/microsoft/vscode/blob/f0417069c62e20f3667506f4b7e53ca0004b4e3e/src/vscode-dts/vscode.d.ts#L7442
// interface Terminal {
// shellIntegration?: {
// cwd?: vscode.Uri
// executeCommand?: (command: string) => {
// read: () => AsyncIterable<string>
// }
// }
// }
// https://github.com/microsoft/vscode/blob/f0417069c62e20f3667506f4b7e53ca0004b4e3e/src/vscode-dts/vscode.d.ts#L10794
interface Window {
onDidStartTerminalShellExecution?: (
@ -77,17 +86,19 @@ declare module "vscode" {
thisArgs?: any,
disposables?: vscode.Disposable[],
) => vscode.Disposable
onDidEndTerminalShellExecution?: (
listener: (e: { terminal: vscode.Terminal; exitCode?: number; shellType?: string }) => any,
thisArgs?: any,
disposables?: vscode.Disposable[],
) => vscode.Disposable
}
}
// Extend the Terminal type to include our custom properties
type ExtendedTerminal = vscode.Terminal & {
shellIntegration?: {
cwd?: vscode.Uri
executeCommand?: (command: string) => {
read: () => AsyncIterable<string>
}
}
export interface ExitCodeDetails {
exitCode: number | undefined
signal?: number | undefined
signalName?: string
coreDumpPossible?: boolean
}
export class TerminalManager {
@ -95,18 +106,156 @@ export class TerminalManager {
private processes: Map<number, TerminalProcess> = new Map()
private disposables: vscode.Disposable[] = []
private interpretExitCode(exitCode: number | undefined): ExitCodeDetails {
if (exitCode === undefined) {
return { exitCode }
}
if (exitCode <= 128) {
return { exitCode }
}
const signal = exitCode - 128
const signals: Record<number, string> = {
// Standard signals
1: "SIGHUP",
2: "SIGINT",
3: "SIGQUIT",
4: "SIGILL",
5: "SIGTRAP",
6: "SIGABRT",
7: "SIGBUS",
8: "SIGFPE",
9: "SIGKILL",
10: "SIGUSR1",
11: "SIGSEGV",
12: "SIGUSR2",
13: "SIGPIPE",
14: "SIGALRM",
15: "SIGTERM",
16: "SIGSTKFLT",
17: "SIGCHLD",
18: "SIGCONT",
19: "SIGSTOP",
20: "SIGTSTP",
21: "SIGTTIN",
22: "SIGTTOU",
23: "SIGURG",
24: "SIGXCPU",
25: "SIGXFSZ",
26: "SIGVTALRM",
27: "SIGPROF",
28: "SIGWINCH",
29: "SIGIO",
30: "SIGPWR",
31: "SIGSYS",
// Real-time signals base
34: "SIGRTMIN",
// SIGRTMIN+n signals
35: "SIGRTMIN+1",
36: "SIGRTMIN+2",
37: "SIGRTMIN+3",
38: "SIGRTMIN+4",
39: "SIGRTMIN+5",
40: "SIGRTMIN+6",
41: "SIGRTMIN+7",
42: "SIGRTMIN+8",
43: "SIGRTMIN+9",
44: "SIGRTMIN+10",
45: "SIGRTMIN+11",
46: "SIGRTMIN+12",
47: "SIGRTMIN+13",
48: "SIGRTMIN+14",
49: "SIGRTMIN+15",
// SIGRTMAX-n signals
50: "SIGRTMAX-14",
51: "SIGRTMAX-13",
52: "SIGRTMAX-12",
53: "SIGRTMAX-11",
54: "SIGRTMAX-10",
55: "SIGRTMAX-9",
56: "SIGRTMAX-8",
57: "SIGRTMAX-7",
58: "SIGRTMAX-6",
59: "SIGRTMAX-5",
60: "SIGRTMAX-4",
61: "SIGRTMAX-3",
62: "SIGRTMAX-2",
63: "SIGRTMAX-1",
64: "SIGRTMAX",
}
// These signals may produce core dumps:
// SIGQUIT, SIGILL, SIGABRT, SIGBUS, SIGFPE, SIGSEGV
const coreDumpPossible = new Set([3, 4, 6, 7, 8, 11])
return {
exitCode,
signal,
signalName: signals[signal] || `Unknown Signal (${signal})`,
coreDumpPossible: coreDumpPossible.has(signal),
}
}
constructor() {
let disposable: vscode.Disposable | undefined
let startDisposable: vscode.Disposable | undefined
let endDisposable: vscode.Disposable | undefined
try {
disposable = (vscode.window as vscode.Window).onDidStartTerminalShellExecution?.(async (e) => {
// Creating a read stream here results in a more consistent output. This is most obvious when running the `date` command.
e?.execution?.read()
// onDidStartTerminalShellExecution
startDisposable = (vscode.window as vscode.Window).onDidStartTerminalShellExecution?.(async (e) => {
// Get a handle to the stream as early as possible:
const stream = e?.execution.read()
const terminalInfo = TerminalRegistry.getTerminalInfoByTerminal(e.terminal)
if (stream && terminalInfo) {
const process = this.processes.get(terminalInfo.id)
if (process) {
terminalInfo.stream = stream
terminalInfo.running = true
terminalInfo.streamClosed = false
process.emit("stream_available", terminalInfo.id, stream)
}
} else {
console.error("[TerminalManager] Stream failed, not registered for terminal")
}
console.info("[TerminalManager] Shell execution started:", {
hasExecution: !!e?.execution,
command: e?.execution?.commandLine?.value,
terminalId: terminalInfo?.id,
})
})
// onDidEndTerminalShellExecution
endDisposable = (vscode.window as vscode.Window).onDidEndTerminalShellExecution?.(async (e) => {
const exitDetails = this.interpretExitCode(e?.exitCode)
console.info("[TerminalManager] Shell execution ended:", {
...exitDetails,
})
// Signal completion to any waiting processes
for (const id of this.terminalIds) {
const info = TerminalRegistry.getTerminal(id)
if (info && info.terminal === e.terminal) {
info.running = false
const process = this.processes.get(id)
if (process) {
process.emit("shell_execution_complete", id, exitDetails)
}
break
}
}
})
} catch (error) {
// console.error("Error setting up onDidEndTerminalShellExecution", error)
console.error("[TerminalManager] Error setting up shell execution handlers:", error)
}
if (disposable) {
this.disposables.push(disposable)
if (startDisposable) {
this.disposables.push(startDisposable)
}
if (endDisposable) {
this.disposables.push(endDisposable)
}
}
@ -140,19 +289,16 @@ export class TerminalManager {
})
// if shell integration is already active, run the command immediately
const terminal = terminalInfo.terminal as ExtendedTerminal
if (terminal.shellIntegration) {
if (terminalInfo.terminal.shellIntegration) {
process.waitForShellIntegration = false
process.run(terminal, command)
process.run(terminalInfo.terminal, command)
} else {
// docs recommend waiting 3s for shell integration to activate
pWaitFor(() => (terminalInfo.terminal as ExtendedTerminal).shellIntegration !== undefined, {
timeout: 4000,
}).finally(() => {
pWaitFor(() => terminalInfo.terminal.shellIntegration !== undefined, { timeout: 4000 }).finally(() => {
const existingProcess = this.processes.get(terminalInfo.id)
if (existingProcess && existingProcess.waitForShellIntegration) {
existingProcess.waitForShellIntegration = false
existingProcess.run(terminal, command)
existingProcess.run(terminalInfo.terminal, command)
}
})
}
@ -168,8 +314,7 @@ export class TerminalManager {
if (t.busy) {
return false
}
const terminal = t.terminal as ExtendedTerminal
const terminalCwd = terminal.shellIntegration?.cwd // one of cline's commands could have changed the cwd of the terminal
const terminalCwd = t.terminal.shellIntegration?.cwd // one of cline's commands could have changed the cwd of the terminal
if (!terminalCwd) {
return false
}

View file

@ -1,13 +1,24 @@
import { EventEmitter } from "events"
import stripAnsi from "strip-ansi"
import * as vscode from "vscode"
import { inspect } from "util"
import { ExitCodeDetails } from "./TerminalManager"
import { TerminalInfo, TerminalRegistry } from "./TerminalRegistry"
export interface TerminalProcessEvents {
line: [line: string]
continue: []
completed: []
completed: [output?: string]
error: [error: Error]
no_shell_integration: []
/**
* Emitted when a shell execution completes
* @param id The terminal ID
* @param exitDetails Contains exit code and signal information if process was terminated by signal
*/
shell_execution_complete: [id: number, exitDetails: ExitCodeDetails]
stream_available: [id: number, stream: AsyncIterable<string>]
}
// how long to wait after a process outputs anything before we consider it "cool" again
@ -17,104 +28,99 @@ const PROCESS_HOT_TIMEOUT_COMPILING = 15_000
export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
waitForShellIntegration: boolean = true
private isListening: boolean = true
private buffer: string = ""
private terminalInfo: TerminalInfo | undefined
private lastEmitTime_ms: number = 0
private fullOutput: string = ""
private lastRetrievedIndex: number = 0
isHot: boolean = false
private hotTimer: NodeJS.Timeout | null = null
// constructor() {
// super()
async run(terminal: vscode.Terminal, command: string) {
if (terminal.shellIntegration && terminal.shellIntegration.executeCommand) {
const execution = terminal.shellIntegration.executeCommand(command)
const stream = execution.read()
// todo: need to handle errors
let isFirstChunk = true
let didOutputNonCommand = false
let didEmitEmptyLine = false
// Get terminal info to access stream
const terminalInfo = TerminalRegistry.getTerminalInfoByTerminal(terminal)
if (!terminalInfo) {
console.error("[TerminalProcess] Terminal not found in registry")
this.emit("no_shell_integration")
this.emit("completed")
this.emit("continue")
return
}
// When executeCommand() is called, onDidStartTerminalShellExecution will fire in TerminalManager
// which creates a new stream via execution.read() and emits 'stream_available'
const streamAvailable = new Promise<AsyncIterable<string>>((resolve) => {
this.once("stream_available", (id: number, stream: AsyncIterable<string>) => {
if (id === terminalInfo.id) {
resolve(stream)
}
})
})
// Create promise that resolves when shell execution completes for this terminal
const shellExecutionComplete = new Promise<ExitCodeDetails>((resolve) => {
this.once("shell_execution_complete", (id: number, exitDetails: ExitCodeDetails) => {
if (id === terminalInfo.id) {
resolve(exitDetails)
}
})
})
// getUnretrievedOutput needs to know if streamClosed, so store this for later
this.terminalInfo = terminalInfo
// Execute command
terminal.shellIntegration.executeCommand(command)
this.isHot = true
// Wait for stream to be available
const stream = await streamAvailable
let preOutput = ""
let commandOutputStarted = false
/*
* Extract clean output from raw accumulated output. FYI:
* ]633 is a custom sequence number used by VSCode shell integration:
* - OSC 633 ; A ST - Mark prompt start
* - OSC 633 ; B ST - Mark prompt end
* - OSC 633 ; C ST - Mark pre-execution (start of command output)
* - OSC 633 ; D [; <exitcode>] ST - Mark execution finished with optional exit code
* - OSC 633 ; E ; <commandline> [; <nonce>] ST - Explicitly set command line with optional nonce
*/
// Process stream data
for await (let data of stream) {
// 1. Process chunk and remove artifacts
if (isFirstChunk) {
/*
The first chunk we get from this stream needs to be processed to be more human readable, ie remove vscode's custom escape sequences and identifiers, removing duplicate first char bug, etc.
*/
// bug where sometimes the command output makes its way into vscode shell integration metadata
/*
]633 is a custom sequence number used by VSCode shell integration:
- OSC 633 ; A ST - Mark prompt start
- OSC 633 ; B ST - Mark prompt end
- OSC 633 ; C ST - Mark pre-execution (start of command output)
- OSC 633 ; D [; <exitcode>] ST - Mark execution finished with optional exit code
- OSC 633 ; E ; <commandline> [; <nonce>] ST - Explicitly set command line with optional nonce
*/
// if you print this data you might see something like "eecho hello worldo hello world;5ba85d14-e92a-40c4-b2fd-71525581eeb0]633;C" but this is actually just a bunch of escape sequences, ignore up to the first ;C
/* ddateb15026-6a64-40db-b21f-2a621a9830f0]633;CTue Sep 17 06:37:04 EDT 2024 % ]633;D;0]633;P;Cwd=/Users/saoud/Repositories/test */
// Gets output between ]633;C (command start) and ]633;D (command end)
const outputBetweenSequences = this.removeLastLineArtifacts(
data.match(/\]633;C([\s\S]*?)\]633;D/)?.[1] || "",
).trim()
// Once we've retrieved any potential output between sequences, we can remove everything up to end of the last sequence
// https://code.visualstudio.com/docs/terminal/shell-integration#_vs-code-custom-sequences-osc-633-st
const vscodeSequenceRegex = /\x1b\]633;.[^\x07]*\x07/g
const lastMatch = [...data.matchAll(vscodeSequenceRegex)].pop()
if (lastMatch && lastMatch.index !== undefined) {
data = data.slice(lastMatch.index + lastMatch[0].length)
// Check for command output start marker
if (!commandOutputStarted) {
preOutput += data
const match = this.matchAfterVsceStartMarkers(data)
if (match !== undefined) {
commandOutputStarted = true
data = match
this.fullOutput = "" // Reset fullOutput when command actually starts
} else {
continue
}
// Place output back after removing vscode sequences
if (outputBetweenSequences) {
data = outputBetweenSequences + "\n" + data
}
// remove ansi
data = stripAnsi(data)
// Split data by newlines
let lines = data ? data.split("\n") : []
// Remove non-human readable characters from the first line
if (lines.length > 0) {
lines[0] = lines[0].replace(/[^\x20-\x7E]/g, "")
}
// Check if first two characters are the same, if so remove the first character
if (lines.length > 0 && lines[0].length >= 2 && lines[0][0] === lines[0][1]) {
lines[0] = lines[0].slice(1)
}
// Remove everything up to the first alphanumeric character for first two lines
if (lines.length > 0) {
lines[0] = lines[0].replace(/^[^a-zA-Z0-9]*/, "")
}
if (lines.length > 1) {
lines[1] = lines[1].replace(/^[^a-zA-Z0-9]*/, "")
}
// Join lines back
data = lines.join("\n")
isFirstChunk = false
} else {
data = stripAnsi(data)
}
// first few chunks could be the command being echoed back, so we must ignore
// note this means that 'echo' commands wont work
if (!didOutputNonCommand) {
const lines = data.split("\n")
for (let i = 0; i < lines.length; i++) {
if (command.includes(lines[i].trim())) {
lines.splice(i, 1)
i-- // Adjust index after removal
} else {
didOutputNonCommand = true
break
}
}
data = lines.join("\n")
// Command output started, accumulate data without filtering.
// notice to future programmers: do not add escape sequence
// filtering here: fullOutput cannot change in length (see getUnretrievedOutput),
// and chunks may not be complete so you cannot rely on detecting or removing escape sequences mid-stream.
this.fullOutput += data
// For non-immediately returning commands we want to show loading spinner
// right away but this wouldnt happen until it emits a line break, so
// as soon as we get any output we emit to let webview know to show spinner
const now = Date.now()
if (this.isListening && (now - this.lastEmitTime_ms > 100 || this.lastEmitTime_ms === 0)) {
this.emitRemainingBufferIfListening()
this.lastEmitTime_ms = now
}
// FIXME: right now it seems that data chunks returned to us from the shell integration stream contains random commas, which from what I can tell is not the expected behavior. There has to be a better solution here than just removing all commas.
data = data.replace(/,/g, "")
// 2. Set isHot depending on the command
// Set to hot to stall API requests until terminal is cool again
// 2. Set isHot depending on the command.
// This stalls API requests until terminal is cool again.
this.isHot = true
if (this.hotTimer) {
clearTimeout(this.hotTimer)
@ -144,21 +150,37 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
},
isCompiling ? PROCESS_HOT_TIMEOUT_COMPILING : PROCESS_HOT_TIMEOUT_NORMAL,
)
// For non-immediately returning commands we want to show loading spinner right away but this wouldnt happen until it emits a line break, so as soon as we get any output we emit "" to let webview know to show spinner
if (!didEmitEmptyLine && !this.fullOutput && data) {
this.emit("line", "") // empty line to indicate start of command output stream
didEmitEmptyLine = true
}
this.fullOutput += data
if (this.isListening) {
this.emitIfEol(data)
this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length
}
}
this.emitRemainingBufferIfListening()
// Set streamClosed immediately after stream ends
if (this.terminalInfo) {
this.terminalInfo.streamClosed = true
}
// Wait for shell execution to complete and handle exit details
const exitDetails = await shellExecutionComplete
this.isHot = false
if (commandOutputStarted) {
// Emit any remaining output before completing
this.emitRemainingBufferIfListening()
} else {
console.error(
"[Terminal Process] VSCE output start escape sequence (]633;C or ]133;C) not received! VSCE Bug? preOutput: " +
inspect(preOutput, { colors: false, breakLength: Infinity }),
)
}
// console.debug("[Terminal Process] raw output: " + inspect(output, { colors: false, breakLength: Infinity }))
// fullOutput begins after C marker so we only need to trim off D marker
// (if D exists, see VSCode bug# 237208):
const match = this.matchBeforeVsceEndMarkers(this.fullOutput)
if (match !== undefined) {
this.fullOutput = match
}
// console.debug(`[Terminal Process] processed output via ${matchSource}: ` + inspect(output, { colors: false, breakLength: Infinity }))
// for now we don't want this delaying requests since we don't send diagnostics automatically anymore (previous: "even though the command is finished, we still want to consider it 'hot' in case so that api request stalls to let diagnostics catch up")
if (this.hotTimer) {
@ -166,7 +188,7 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
}
this.isHot = false
this.emit("completed")
this.emit("completed", this.removeEscapeSequences(this.fullOutput))
this.emit("continue")
} else {
terminal.sendText(command, true)
@ -182,29 +204,12 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
}
}
// Inspired by https://github.com/sindresorhus/execa/blob/main/lib/transform/split.js
private emitIfEol(chunk: string) {
this.buffer += chunk
let lineEndIndex: number
while ((lineEndIndex = this.buffer.indexOf("\n")) !== -1) {
let line = this.buffer.slice(0, lineEndIndex).trimEnd() // removes trailing \r
// Remove \r if present (for Windows-style line endings)
// if (line.endsWith("\r")) {
// line = line.slice(0, -1)
// }
this.emit("line", line)
this.buffer = this.buffer.slice(lineEndIndex + 1)
}
}
private emitRemainingBufferIfListening() {
if (this.buffer && this.isListening) {
const remainingBuffer = this.removeLastLineArtifacts(this.buffer)
if (remainingBuffer) {
if (this.isListening) {
const remainingBuffer = this.getUnretrievedOutput()
if (remainingBuffer !== "") {
this.emit("line", remainingBuffer)
}
this.buffer = ""
this.lastRetrievedIndex = this.fullOutput.length
}
}
@ -215,22 +220,180 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
this.emit("continue")
}
// Returns complete lines with their carriage returns.
// The final line may lack a carriage return if the program didn't send one.
getUnretrievedOutput(): string {
const unretrieved = this.fullOutput.slice(this.lastRetrievedIndex)
this.lastRetrievedIndex = this.fullOutput.length
return this.removeLastLineArtifacts(unretrieved)
// Get raw unretrieved output
let outputToProcess = this.fullOutput.slice(this.lastRetrievedIndex)
// Check for VSCE command end markers
const index633 = outputToProcess.indexOf("\x1b]633;D")
const index133 = outputToProcess.indexOf("\x1b]133;D")
let endIndex = -1
if (index633 !== -1 && index133 !== -1) {
endIndex = Math.min(index633, index133)
} else if (index633 !== -1) {
endIndex = index633
} else if (index133 !== -1) {
endIndex = index133
}
// If no end markers were found yet (possibly due to VSCode bug#237208):
// For active streams: return only complete lines (up to last \n).
// For closed streams: return all remaining content.
if (endIndex === -1) {
if (!this.terminalInfo?.streamClosed) {
// Stream still running - only process complete lines
endIndex = outputToProcess.lastIndexOf("\n")
if (endIndex === -1) {
// No complete lines
return ""
}
// Include carriage return
endIndex++
} else {
// Stream closed - process all remaining output
endIndex = outputToProcess.length
}
}
// Update index and slice output
this.lastRetrievedIndex += endIndex
outputToProcess = outputToProcess.slice(0, endIndex)
// Clean and return output
return this.removeEscapeSequences(outputToProcess)
}
// some processing to remove artifacts like '%' at the end of the buffer (it seems that since vsode uses % at the beginning of newlines in terminal, it makes its way into the stream)
// This modification will remove '%', '$', '#', or '>' followed by optional whitespace
removeLastLineArtifacts(output: string) {
const lines = output.trimEnd().split("\n")
if (lines.length > 0) {
const lastLine = lines[lines.length - 1]
// Remove prompt characters and trailing whitespace from the last line
lines[lines.length - 1] = lastLine.replace(/[%$#>]\s*$/, "")
private stringIndexMatch(
data: string,
prefix?: string,
suffix?: string,
bell: string = "\x07",
): string | undefined {
let startIndex: number
let endIndex: number
let prefixLength: number
if (prefix === undefined) {
startIndex = 0
prefixLength = 0
} else {
startIndex = data.indexOf(prefix)
if (startIndex === -1) {
return undefined
}
if (bell.length > 0) {
// Find the bell character after the prefix
const bellIndex = data.indexOf(bell, startIndex + prefix.length)
if (bellIndex === -1) {
return undefined
}
const distanceToBell = bellIndex - startIndex
prefixLength = distanceToBell + bell.length
} else {
prefixLength = prefix.length
}
}
return lines.join("\n").trimEnd()
const contentStart = startIndex + prefixLength
if (suffix === undefined) {
// When suffix is undefined, match to end
endIndex = data.length
} else {
endIndex = data.indexOf(suffix, contentStart)
if (endIndex === -1) {
return undefined
}
}
return data.slice(contentStart, endIndex)
}
// Removes ANSI escape sequences and VSCode-specific terminal control codes from output.
// While stripAnsi handles most ANSI codes, VSCode's shell integration adds custom
// escape sequences (OSC 633) that need special handling. These sequences control
// terminal features like marking command start/end and setting prompts.
//
// This method could be extended to handle other escape sequences, but any additions
// should be carefully considered to ensure they only remove control codes and don't
// alter the actual content or behavior of the output stream.
private removeEscapeSequences(str: string): string {
return stripAnsi(str.replace(/\x1b\]633;[^\x07]+\x07/gs, "").replace(/\x1b\]133;[^\x07]+\x07/gs, ""))
}
/**
* Helper function to match VSCode shell integration start markers (C).
* Looks for content after ]633;C or ]133;C markers.
* If both exist, takes the content after the last marker found.
*/
private matchAfterVsceStartMarkers(data: string): string | undefined {
return this.matchVsceMarkers(data, "\x1b]633;C", "\x1b]133;C", undefined, undefined)
}
/**
* Helper function to match VSCode shell integration end markers (D).
* Looks for content before ]633;D or ]133;D markers.
* If both exist, takes the content before the first marker found.
*/
private matchBeforeVsceEndMarkers(data: string): string | undefined {
return this.matchVsceMarkers(data, undefined, undefined, "\x1b]633;D", "\x1b]133;D")
}
/**
* Handles VSCode shell integration markers for command output:
*
* For C (Command Start):
* - Looks for content after ]633;C or ]133;C markers
* - These markers indicate the start of command output
* - If both exist, takes the content after the last marker found
* - This ensures we get the actual command output after any shell integration prefixes
*
* For D (Command End):
* - Looks for content before ]633;D or ]133;D markers
* - These markers indicate command completion
* - If both exist, takes the content before the first marker found
* - This ensures we don't include shell integration suffixes in the output
*
* In both cases, checks 633 first since it's more commonly used in VSCode shell integration
*
* @param data The string to search for markers in
* @param prefix633 The 633 marker to match after (for C markers)
* @param prefix133 The 133 marker to match after (for C markers)
* @param suffix633 The 633 marker to match before (for D markers)
* @param suffix133 The 133 marker to match before (for D markers)
* @returns The content between/after markers, or undefined if no markers found
*
* Note: Always makes exactly 2 calls to stringIndexMatch regardless of match results.
* Using string indexOf matching is ~500x faster than regular expressions, so even
* matching twice is still very efficient comparatively.
*/
private matchVsceMarkers(
data: string,
prefix633: string | undefined,
prefix133: string | undefined,
suffix633: string | undefined,
suffix133: string | undefined,
): string | undefined {
// Support both VSCode shell integration markers (633 and 133)
// Check 633 first since it's more commonly used in VSCode shell integration
let match133: string | undefined
const match633 = this.stringIndexMatch(data, prefix633, suffix633)
// Must check explicitly for undefined because stringIndexMatch can return empty strings
// that are valid matches (e.g., when a marker exists but has no content between markers)
if (match633 !== undefined) {
match133 = this.stringIndexMatch(match633, prefix133, suffix133)
} else {
match133 = this.stringIndexMatch(data, prefix133, suffix133)
}
return match133 !== undefined ? match133 : match633
}
}

View file

@ -5,6 +5,9 @@ export interface TerminalInfo {
busy: boolean
lastCommand: string
id: number
stream?: AsyncIterable<string>
running: boolean
streamClosed: boolean
}
// Although vscode.window.terminals provides a list of all open terminals, there's no way to know whether they're busy or not (exitStatus does not provide useful information for most commands). In order to prevent creating too many terminals, we need to keep track of terminals through the life of the extension, as well as session specific terminals for the life of a task (to get latest unretrieved output).
@ -20,34 +23,61 @@ export class TerminalRegistry {
iconPath: new vscode.ThemeIcon("rocket"),
env: {
PAGER: "cat",
// VSCode bug#237208: Command output can be lost due to a race between completion
// sequences and consumers. Add 50ms delay via PROMPT_COMMAND to ensure the
// \x1b]633;D escape sequence arrives after command output is processed.
PROMPT_COMMAND: "sleep 0.050",
// VTE must be disabled because it prevents the prompt command above from executing
// See https://wiki.gnome.org/Apps/Terminal/VTE
VTE_VERSION: "0",
},
})
const newInfo: TerminalInfo = {
terminal,
busy: false,
lastCommand: "",
id: this.nextTerminalId++,
running: false,
streamClosed: false,
}
this.terminals.push(newInfo)
return newInfo
}
static getTerminal(id: number): TerminalInfo | undefined {
const terminalInfo = this.terminals.find((t) => t.id === id)
if (terminalInfo && this.isTerminalClosed(terminalInfo.terminal)) {
this.removeTerminal(id)
return undefined
}
return terminalInfo
}
static updateTerminal(id: number, updates: Partial<TerminalInfo>) {
const terminal = this.getTerminal(id)
if (terminal) {
Object.assign(terminal, updates)
}
}
static getTerminalInfoByTerminal(terminal: vscode.Terminal): TerminalInfo | undefined {
const terminalInfo = this.terminals.find((t) => t.terminal === terminal)
if (terminalInfo && this.isTerminalClosed(terminalInfo.terminal)) {
this.removeTerminal(terminalInfo.id)
return undefined
}
return terminalInfo
}
static removeTerminal(id: number) {
this.terminals = this.terminals.filter((t) => t.id !== id)
}

View file

@ -1,9 +1,24 @@
import { TerminalProcess, mergePromise } from "../TerminalProcess"
import * as vscode from "vscode"
import { EventEmitter } from "events"
// npx jest src/integrations/terminal/__tests__/TerminalProcess.test.ts
// Mock vscode
jest.mock("vscode")
import * as vscode from "vscode"
import { TerminalProcess, mergePromise } from "../TerminalProcess"
import { TerminalInfo, TerminalRegistry } from "../TerminalRegistry"
// Mock vscode.window.createTerminal
const mockCreateTerminal = jest.fn()
jest.mock("vscode", () => ({
window: {
createTerminal: (...args: any[]) => {
mockCreateTerminal(...args)
return {
exitStatus: undefined,
}
},
},
ThemeIcon: jest.fn(),
}))
describe("TerminalProcess", () => {
let terminalProcess: TerminalProcess
@ -14,6 +29,7 @@ describe("TerminalProcess", () => {
}
}
>
let mockTerminalInfo: TerminalInfo
let mockExecution: any
let mockStream: AsyncIterableIterator<string>
@ -25,7 +41,7 @@ describe("TerminalProcess", () => {
shellIntegration: {
executeCommand: jest.fn(),
},
name: "Mock Terminal",
name: "Roo Code",
processId: Promise.resolve(123),
creationOptions: {},
exitStatus: undefined,
@ -42,27 +58,39 @@ describe("TerminalProcess", () => {
}
>
mockTerminalInfo = {
terminal: mockTerminal,
busy: false,
lastCommand: "",
id: 1,
running: false,
streamClosed: false,
}
TerminalRegistry["terminals"].push(mockTerminalInfo)
// Reset event listeners
terminalProcess.removeAllListeners()
})
describe("run", () => {
it("handles shell integration commands correctly", async () => {
const lines: string[] = []
terminalProcess.on("line", (line) => {
// Skip empty lines used for loading spinner
if (line !== "") {
lines.push(line)
let lines: string[] = []
terminalProcess.on("completed", (output) => {
if (output) {
lines = output.split("\n")
}
})
// Mock stream data with shell integration sequences
// Mock stream data with shell integration sequences.
mockStream = (async function* () {
// The first chunk contains the command start sequence
yield "\x1b]633;C\x07" // The first chunk contains the command start sequence with bell character.
yield "Initial output\n"
yield "More output\n"
// The last chunk contains the command end sequence
yield "Final output"
yield "\x1b]633;D\x07" // The last chunk contains the command end sequence with bell character.
terminalProcess.emit("shell_execution_complete", mockTerminalInfo.id, { exitCode: 0 })
})()
mockExecution = {
@ -71,12 +99,9 @@ describe("TerminalProcess", () => {
mockTerminal.shellIntegration.executeCommand.mockReturnValue(mockExecution)
const completedPromise = new Promise<void>((resolve) => {
terminalProcess.once("completed", resolve)
})
await terminalProcess.run(mockTerminal, "test command")
await completedPromise
const runPromise = terminalProcess.run(mockTerminal, "test command")
terminalProcess.emit("stream_available", mockTerminalInfo.id, mockStream)
await runPromise
expect(lines).toEqual(["Initial output", "More output", "Final output"])
expect(terminalProcess.isHot).toBe(false)
@ -99,95 +124,41 @@ describe("TerminalProcess", () => {
})
it("sets hot state for compiling commands", async () => {
const lines: string[] = []
terminalProcess.on("line", (line) => {
if (line !== "") {
lines.push(line)
let lines: string[] = []
terminalProcess.on("completed", (output) => {
if (output) {
lines = output.split("\n")
}
})
// Create a promise that resolves when the first chunk is processed
const firstChunkProcessed = new Promise<void>((resolve) => {
terminalProcess.on("line", () => resolve())
const completePromise = new Promise<void>((resolve) => {
terminalProcess.on("shell_execution_complete", () => resolve())
})
mockStream = (async function* () {
yield "\x1b]633;C\x07" // The first chunk contains the command start sequence with bell character.
yield "compiling...\n"
// Wait to ensure hot state check happens after first chunk
await new Promise((resolve) => setTimeout(resolve, 10))
yield "still compiling...\n"
yield "done"
yield "\x1b]633;D\x07" // The last chunk contains the command end sequence with bell character.
terminalProcess.emit("shell_execution_complete", mockTerminalInfo.id, { exitCode: 0 })
})()
mockExecution = {
mockTerminal.shellIntegration.executeCommand.mockReturnValue({
read: jest.fn().mockReturnValue(mockStream),
}
mockTerminal.shellIntegration.executeCommand.mockReturnValue(mockExecution)
// Start the command execution
const runPromise = terminalProcess.run(mockTerminal, "npm run build")
// Wait for the first chunk to be processed
await firstChunkProcessed
// Hot state should be true while compiling
expect(terminalProcess.isHot).toBe(true)
// Complete the execution
const completedPromise = new Promise<void>((resolve) => {
terminalProcess.once("completed", resolve)
})
const runPromise = terminalProcess.run(mockTerminal, "npm run build")
terminalProcess.emit("stream_available", mockTerminalInfo.id, mockStream)
expect(terminalProcess.isHot).toBe(true)
await runPromise
await completedPromise
expect(lines).toEqual(["compiling...", "still compiling...", "done"])
})
})
describe("buffer processing", () => {
it("correctly processes and emits lines", () => {
const lines: string[] = []
terminalProcess.on("line", (line) => lines.push(line))
// Simulate incoming chunks
terminalProcess["emitIfEol"]("first line\n")
terminalProcess["emitIfEol"]("second")
terminalProcess["emitIfEol"](" line\n")
terminalProcess["emitIfEol"]("third line")
expect(lines).toEqual(["first line", "second line"])
// Process remaining buffer
terminalProcess["emitRemainingBufferIfListening"]()
expect(lines).toEqual(["first line", "second line", "third line"])
})
it("handles Windows-style line endings", () => {
const lines: string[] = []
terminalProcess.on("line", (line) => lines.push(line))
terminalProcess["emitIfEol"]("line1\r\nline2\r\n")
expect(lines).toEqual(["line1", "line2"])
})
})
describe("removeLastLineArtifacts", () => {
it("removes terminal artifacts from output", () => {
const cases = [
["output%", "output"],
["output$ ", "output"],
["output#", "output"],
["output> ", "output"],
["multi\nline%", "multi\nline"],
["no artifacts", "no artifacts"],
]
for (const [input, expected] of cases) {
expect(terminalProcess["removeLastLineArtifacts"](input)).toBe(expected)
}
await completePromise
expect(terminalProcess.isHot).toBe(false)
})
})
@ -205,13 +176,13 @@ describe("TerminalProcess", () => {
describe("getUnretrievedOutput", () => {
it("returns and clears unretrieved output", () => {
terminalProcess["fullOutput"] = "previous\nnew output"
terminalProcess["lastRetrievedIndex"] = 9 // After "previous\n"
terminalProcess["fullOutput"] = `\x1b]633;C\x07previous\nnew output\x1b]633;D\x07`
terminalProcess["lastRetrievedIndex"] = 17 // After "previous\n"
const unretrieved = terminalProcess.getUnretrievedOutput()
expect(unretrieved).toBe("new output")
expect(terminalProcess["lastRetrievedIndex"]).toBe(terminalProcess["fullOutput"].length)
expect(terminalProcess["lastRetrievedIndex"]).toBe(terminalProcess["fullOutput"].length - "previous".length)
})
})

View file

@ -1,4 +1,5 @@
import * as vscode from "vscode"
// npx jest src/integrations/terminal/__tests__/TerminalRegistry.test.ts
import { TerminalRegistry } from "../TerminalRegistry"
// Mock vscode.window.createTerminal
@ -30,6 +31,8 @@ describe("TerminalRegistry", () => {
iconPath: expect.any(Object),
env: {
PAGER: "cat",
PROMPT_COMMAND: "sleep 0.050",
VTE_VERSION: "0",
},
})
})

View file

@ -111,7 +111,7 @@ export const anthropicModels = {
thinking: true,
},
"claude-3-7-sonnet-20250219": {
maxTokens: 64_000,
maxTokens: 16_384,
contextWindow: 200_000,
supportsImages: true,
supportsComputerUse: true,
@ -437,7 +437,7 @@ export type VertexModelId = keyof typeof vertexModels
export const vertexDefaultModelId: VertexModelId = "claude-3-7-sonnet@20250219"
export const vertexModels = {
"claude-3-7-sonnet@20250219:thinking": {
maxTokens: 64000,
maxTokens: 64_000,
contextWindow: 200_000,
supportsImages: true,
supportsComputerUse: true,
@ -449,7 +449,7 @@ export const vertexModels = {
thinking: true,
},
"claude-3-7-sonnet@20250219": {
maxTokens: 8192,
maxTokens: 16_384,
contextWindow: 200_000,
supportsImages: true,
supportsComputerUse: true,

View file

@ -44,7 +44,7 @@ export function combineCommandSequences(messages: ClineMessage[]): ClineMessage[
// handle cases where we receive empty command_output (ie when extension is relinquishing control over exit command button)
const output = messages[j].text || ""
if (output.length > 0) {
combinedText += "\n" + output
combinedText += output
}
}
j++

View file

@ -16,7 +16,7 @@ import { vscode } from "../../utils/vscode"
import CodeAccordian, { removeLeadingNonAlphanumeric } from "../common/CodeAccordian"
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
import MarkdownBlock from "../common/MarkdownBlock"
import ReasoningBlock from "./ReasoningBlock"
import { ReasoningBlock } from "./ReasoningBlock"
import Thumbnails from "../common/Thumbnails"
import McpResourceRow from "../mcp/McpResourceRow"
import McpToolRow from "../mcp/McpToolRow"
@ -25,12 +25,12 @@ import { CheckpointSaved } from "./checkpoints/CheckpointSaved"
interface ChatRowProps {
message: ClineMessage
isExpanded: boolean
onToggleExpand: () => void
lastModifiedMessage?: ClineMessage
isExpanded: boolean
isLast: boolean
onHeightChange: (isTaller: boolean) => void
isStreaming: boolean
onToggleExpand: () => void
onHeightChange: (isTaller: boolean) => void
}
interface ChatRowContentProps extends Omit<ChatRowProps, "onHeightChange"> {}
@ -43,10 +43,7 @@ const ChatRow = memo(
const prevHeightRef = useRef(0)
const [chatrow, { height }] = useSize(
<div
style={{
padding: "10px 6px 10px 15px",
}}>
<div className="px-[15px] py-[10px] pr-[6px]">
<ChatRowContent {...props} />
</div>,
)
@ -75,33 +72,32 @@ export default ChatRow
export const ChatRowContent = ({
message,
isExpanded,
onToggleExpand,
lastModifiedMessage,
isExpanded,
isLast,
isStreaming,
onToggleExpand,
}: ChatRowContentProps) => {
const { mcpServers, alwaysAllowMcp, currentCheckpoint } = useExtensionState()
const [reasoningCollapsed, setReasoningCollapsed] = useState(false)
const [reasoningCollapsed, setReasoningCollapsed] = useState(true)
// Auto-collapse reasoning when new messages arrive
useEffect(() => {
if (!isLast && message.say === "reasoning") {
setReasoningCollapsed(true)
}
}, [isLast, message.say])
const [cost, apiReqCancelReason, apiReqStreamingFailedMessage] = useMemo(() => {
if (message.text !== null && message.text !== undefined && message.say === "api_req_started") {
const info: ClineApiReqInfo = JSON.parse(message.text)
return [info.cost, info.cancelReason, info.streamingFailedMessage]
}
return [undefined, undefined, undefined]
}, [message.text, message.say])
// when resuming task, last wont be api_req_failed but a resume_task message, so api_req_started will show loading spinner. that's why we just remove the last api_req_started that failed without streaming anything
// When resuming task, last wont be api_req_failed but a resume_task
// message, so api_req_started will show loading spinner. That's why we just
// remove the last api_req_started that failed without streaming anything.
const apiRequestFailedMessage =
isLast && lastModifiedMessage?.ask === "api_req_failed" // if request is retried then the latest message is a api_req_retried
? lastModifiedMessage?.text
: undefined
const isCommandExecuting =
isLast && lastModifiedMessage?.ask === "command" && lastModifiedMessage?.text?.includes(COMMAND_OUTPUT_STRING)
@ -428,32 +424,6 @@ export const ChatRowContent = ({
/>
</>
)
// case "inspectSite":
// const isInspecting =
// isLast && lastModifiedMessage?.say === "inspect_site_result" && !lastModifiedMessage?.images
// return (
// <>
// <div style={headerStyle}>
// {isInspecting ? <ProgressIndicator /> : toolIcon("inspect")}
// <span style={{ fontWeight: "bold" }}>
// {message.type === "ask" ? (
// <>Roo wants to inspect this website:</>
// ) : (
// <>Roo is inspecting this website:</>
// )}
// </span>
// </div>
// <div
// style={{
// borderRadius: 3,
// border: "1px solid var(--vscode-editorGroup-border)",
// overflow: "hidden",
// backgroundColor: CODE_BLOCK_BG_COLOR,
// }}>
// <CodeBlock source={`${"```"}shell\n${tool.path}\n${"```"}`} forceWrap={true} />
// </div>
// </>
// )
case "switchMode":
return (
<>
@ -501,6 +471,7 @@ export const ChatRowContent = ({
return (
<ReasoningBlock
content={message.text || ""}
elapsed={isLast && isStreaming ? Date.now() - message.ts : undefined}
isCollapsed={reasoningCollapsed}
onToggleCollapse={() => setReasoningCollapsed(!reasoningCollapsed)}
/>

View file

@ -187,10 +187,12 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
display: "flex",
alignItems: "center",
justifyContent: "space-between",
backgroundColor:
index === selectedIndex && isOptionSelectable(option)
? "var(--vscode-list-activeSelectionBackground)"
: "",
...(index === selectedIndex && isOptionSelectable(option)
? {
backgroundColor: "var(--vscode-list-activeSelectionBackground)",
color: "var(--vscode-list-activeSelectionForeground)",
}
: {}),
}}
onMouseEnter={() => isOptionSelectable(option) && setSelectedIndex(index)}>
<div

View file

@ -1,70 +1,97 @@
import React, { useEffect, useRef } from "react"
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
import { useCallback, useEffect, useRef, useState } from "react"
import { CaretDownIcon, CaretUpIcon, CounterClockwiseClockIcon } from "@radix-ui/react-icons"
import MarkdownBlock from "../common/MarkdownBlock"
import { useMount } from "react-use"
interface ReasoningBlockProps {
content: string
elapsed?: number
isCollapsed?: boolean
onToggleCollapse?: () => void
autoHeight?: boolean
}
const ReasoningBlock: React.FC<ReasoningBlockProps> = ({
content,
isCollapsed = false,
onToggleCollapse,
autoHeight = false,
}) => {
export const ReasoningBlock = ({ content, elapsed, isCollapsed = false, onToggleCollapse }: ReasoningBlockProps) => {
const contentRef = useRef<HTMLDivElement>(null)
const elapsedRef = useRef<number>(0)
const [thought, setThought] = useState<string>()
const [prevThought, setPrevThought] = useState<string>("Thinking")
const [isTransitioning, setIsTransitioning] = useState<boolean>(false)
const cursorRef = useRef<number>(0)
const queueRef = useRef<string[]>([])
// Scroll to bottom when content updates
useEffect(() => {
if (contentRef.current && !isCollapsed) {
contentRef.current.scrollTop = contentRef.current.scrollHeight
}
}, [content, isCollapsed])
useEffect(() => {
if (elapsed) {
elapsedRef.current = elapsed
}
}, [elapsed])
// Process the transition queue.
const processNextTransition = useCallback(() => {
const nextThought = queueRef.current.pop()
queueRef.current = []
if (nextThought) {
setIsTransitioning(true)
}
setTimeout(() => {
if (nextThought) {
setPrevThought(nextThought)
setIsTransitioning(false)
}
setTimeout(() => processNextTransition(), 500)
}, 200)
}, [])
useMount(() => {
processNextTransition()
})
useEffect(() => {
if (content.length - cursorRef.current > 160) {
setThought("... " + content.slice(cursorRef.current))
cursorRef.current = content.length
}
}, [content])
useEffect(() => {
if (thought && thought !== prevThought) {
queueRef.current.push(thought)
}
}, [thought, prevThought])
return (
<div
style={{
backgroundColor: CODE_BLOCK_BG_COLOR,
border: "1px solid var(--vscode-editorGroup-border)",
borderRadius: "3px",
overflow: "hidden",
}}>
<div className="bg-vscode-editor-background border border-vscode-border rounded-xs overflow-hidden">
<div
onClick={onToggleCollapse}
style={{
padding: "8px 12px",
cursor: "pointer",
userSelect: "none",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderBottom: isCollapsed ? "none" : "1px solid var(--vscode-editorGroup-border)",
}}>
<span style={{ fontWeight: "bold" }}>Reasoning</span>
<span className={`codicon codicon-chevron-${isCollapsed ? "right" : "down"}`}></span>
className="flex items-center justify-between gap-1 px-3 py-2 cursor-pointer text-muted-foreground"
onClick={onToggleCollapse}>
<div
className={`truncate flex-1 transition-opacity duration-200 ${isTransitioning ? "opacity-0" : "opacity-100"}`}>
{prevThought}
</div>
<div className="flex flex-row items-center gap-1">
{elapsedRef.current > 1000 && (
<>
<CounterClockwiseClockIcon className="scale-80" />
<div>{Math.round(elapsedRef.current / 1000)}s</div>
</>
)}
{isCollapsed ? <CaretDownIcon /> : <CaretUpIcon />}
</div>
</div>
{!isCollapsed && (
<div
ref={contentRef}
style={{
padding: "8px 12px",
maxHeight: autoHeight ? "none" : "160px",
overflowY: "auto",
}}>
<div
style={{
fontSize: "13px",
opacity: 0.9,
}}>
<MarkdownBlock markdown={content} />
</div>
<div ref={contentRef} className="px-3 max-h-[160px] overflow-y-auto">
<MarkdownBlock markdown={content} />
</div>
)}
</div>
)
}
export default ReasoningBlock

View file

@ -3,16 +3,19 @@ import { useWindowSize } from "react-use"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import prettyBytes from "pretty-bytes"
import { vscode } from "@/utils/vscode"
import { formatLargeNumber } from "@/utils/format"
import { Button } from "@/components/ui"
import { ClineMessage } from "../../../../src/shared/ExtensionMessage"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import Thumbnails from "../common/Thumbnails"
import { mentionRegexGlobal } from "../../../../src/shared/context-mentions"
import { formatLargeNumber } from "../../utils/format"
import { normalizeApiConfiguration } from "../settings/ApiOptions"
import { Button } from "../ui"
import { HistoryItem } from "../../../../src/shared/HistoryItem"
import { useExtensionState } from "../../context/ExtensionStateContext"
import Thumbnails from "../common/Thumbnails"
import { normalizeApiConfiguration } from "../settings/ApiOptions"
import { DeleteTaskDialog } from "../history/DeleteTaskDialog"
interface TaskHeaderProps {
task: ClineMessage
tokensIn: number
@ -46,7 +49,21 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
const contextWindow = selectedModelInfo?.contextWindow || 1
/*
When dealing with event listeners in React components that depend on state variables, we face a challenge. We want our listener to always use the most up-to-date version of a callback function that relies on current state, but we don't want to constantly add and remove event listeners as that function updates. This scenario often arises with resize listeners or other window events. Simply adding the listener in a useEffect with an empty dependency array risks using stale state, while including the callback in the dependencies can lead to unnecessary re-registrations of the listener. There are react hook libraries that provide a elegant solution to this problem by utilizing the useRef hook to maintain a reference to the latest callback function without triggering re-renders or effect re-runs. This approach ensures that our event listener always has access to the most current state while minimizing performance overhead and potential memory leaks from multiple listener registrations.
When dealing with event listeners in React components that depend on state
variables, we face a challenge. We want our listener to always use the most
up-to-date version of a callback function that relies on current state, but
we don't want to constantly add and remove event listeners as that function
updates. This scenario often arises with resize listeners or other window
events. Simply adding the listener in a useEffect with an empty dependency
array risks using stale state, while including the callback in the
dependencies can lead to unnecessary re-registrations of the listener. There
are react hook libraries that provide a elegant solution to this problem by
utilizing the useRef hook to maintain a reference to the latest callback
function without triggering re-renders or effect re-runs. This approach
ensures that our event listener always has access to the most current state
while minimizing performance overhead and potential memory leaks from
multiple listener registrations.
Sources
- https://usehooks-ts.com/react-hook/use-event-listener
- https://streamich.github.io/react-use/?path=/story/sensors-useevent--docs
@ -353,27 +370,48 @@ export const highlightMentions = (text?: string, withShadow = true) => {
})
}
const TaskActions = ({ item }: { item: HistoryItem | undefined }) => (
<div className="flex flex-row gap-1">
<Button
variant="ghost"
size="sm"
title="Export task history"
onClick={() => vscode.postMessage({ type: "exportCurrentTask" })}>
<span className="codicon codicon-cloud-download" />
</Button>
{!!item?.size && item.size > 0 && (
const TaskActions = ({ item }: { item: HistoryItem | undefined }) => {
const [deleteTaskId, setDeleteTaskId] = useState<string | null>(null)
return (
<div className="flex flex-row gap-1">
<Button
variant="ghost"
size="sm"
title="Delete task from history"
onClick={() => vscode.postMessage({ type: "deleteTaskWithId", text: item.id })}>
<span className="codicon codicon-trash" />
{prettyBytes(item.size)}
title="Export task history"
onClick={() => vscode.postMessage({ type: "exportCurrentTask" })}>
<span className="codicon codicon-cloud-download" />
</Button>
)}
</div>
)
{!!item?.size && item.size > 0 && (
<>
<Button
variant="ghost"
size="sm"
title="Delete Task (Shift + Click to skip confirmation)"
onClick={(e) => {
e.stopPropagation()
if (e.shiftKey) {
vscode.postMessage({ type: "deleteTaskWithId", text: item.id })
} else {
setDeleteTaskId(item.id)
}
}}>
<span className="codicon codicon-trash" />
{prettyBytes(item.size)}
</Button>
{deleteTaskId && (
<DeleteTaskDialog
taskId={deleteTaskId}
onOpenChange={(open) => !open && setDeleteTaskId(null)}
open
/>
)}
</>
)}
</div>
)
}
const ContextWindowProgress = ({ contextWindow, contextTokens }: { contextWindow: number; contextTokens: number }) => (
<>

View file

@ -0,0 +1,32 @@
import { useCallback } from "react"
import { useClipboard } from "@/components/ui/hooks"
import { Button } from "@/components/ui"
import { cn } from "@/lib/utils"
type CopyButtonProps = {
itemTask: string
}
export const CopyButton = ({ itemTask }: CopyButtonProps) => {
const { isCopied, copy } = useClipboard()
const onCopy = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
!isCopied && copy(itemTask)
},
[isCopied, copy, itemTask],
)
return (
<Button
variant="ghost"
size="icon"
title="Copy Prompt"
onClick={onCopy}
className="opacity-50 hover:opacity-100">
<span className={cn("codicon scale-80", { "codicon-check": isCopied, "codicon-copy": !isCopied })} />
</Button>
)
}

View file

@ -1,4 +1,7 @@
import React from "react"
import { useCallback, useEffect } from "react"
import { useKeyPress } from "react-use"
import { AlertDialogProps } from "@radix-ui/react-alert-dialog"
import {
AlertDialog,
AlertDialogAction,
@ -8,25 +11,36 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui"
Button,
} from "@/components/ui"
import { vscode } from "@/utils/vscode"
interface DeleteTaskDialogProps {
interface DeleteTaskDialogProps extends AlertDialogProps {
taskId: string
open: boolean
onOpenChange: (open: boolean) => void
}
export const DeleteTaskDialog = ({ taskId, open, onOpenChange }: DeleteTaskDialogProps) => {
const handleDelete = () => {
vscode.postMessage({ type: "deleteTaskWithId", text: taskId })
onOpenChange(false)
}
export const DeleteTaskDialog = ({ taskId, ...props }: DeleteTaskDialogProps) => {
const [isEnterPressed] = useKeyPress("Enter")
const { onOpenChange } = props
const onDelete = useCallback(() => {
if (taskId) {
vscode.postMessage({ type: "deleteTaskWithId", text: taskId })
onOpenChange?.(false)
}
}, [taskId, onOpenChange])
useEffect(() => {
if (taskId && isEnterPressed) {
onDelete()
}
}, [taskId, isEnterPressed, onDelete])
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialog {...props}>
<AlertDialogContent onEscapeKeyDown={() => onOpenChange?.(false)}>
<AlertDialogHeader>
<AlertDialogTitle>Delete Task</AlertDialogTitle>
<AlertDialogDescription>
@ -38,7 +52,7 @@ export const DeleteTaskDialog = ({ taskId, open, onOpenChange }: DeleteTaskDialo
<Button variant="secondary">Cancel</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button variant="destructive" onClick={handleDelete}>
<Button variant="destructive" onClick={onDelete}>
Delete
</Button>
</AlertDialogAction>

View file

@ -0,0 +1,16 @@
import { vscode } from "@/utils/vscode"
import { Button } from "@/components/ui"
export const ExportButton = ({ itemId }: { itemId: string }) => (
<Button
data-testid="export"
variant="ghost"
size="icon"
title="Export Task"
onClick={(e) => {
e.stopPropagation()
vscode.postMessage({ type: "exportTaskWithId", text: itemId })
}}>
<span className="codicon codicon-cloud-download" />
</Button>
)

View file

@ -1,9 +1,11 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import { memo } from "react"
import { formatLargeNumber } from "../../utils/format"
import { useCopyToClipboard } from "../../utils/clipboard"
import { vscode } from "@/utils/vscode"
import { formatLargeNumber, formatDate } from "@/utils/format"
import { Button } from "@/components/ui"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { CopyButton } from "./CopyButton"
type HistoryPreviewProps = {
showHistoryView: () => void
@ -11,52 +13,15 @@ type HistoryPreviewProps = {
const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
const { taskHistory } = useExtensionState()
const { showCopyFeedback, copyWithFeedback } = useCopyToClipboard()
const handleHistorySelect = (id: string) => {
vscode.postMessage({ type: "showTaskWithId", text: id })
}
const formatDate = (timestamp: number) => {
const date = new Date(timestamp)
return date
?.toLocaleString("en-US", {
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
})
.replace(", ", " ")
.replace(" at", ",")
.toUpperCase()
}
return (
<div style={{ flexShrink: 0 }}>
{showCopyFeedback && <div className="copy-modal">Prompt Copied to Clipboard</div>}
<style>
{`
.copy-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: var(--vscode-notifications-background);
color: var(--vscode-notifications-foreground);
padding: 12px 20px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 1000;
transition: opacity 0.2s ease-in-out;
}
.copy-button {
opacity: 0;
pointer-events: none;
}
.history-preview-item:hover .copy-button {
opacity: 1;
pointer-events: auto;
}
.history-preview-item {
background-color: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 65%, transparent);
border-radius: 4px;
@ -73,7 +38,6 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
}
`}
</style>
<div
style={{
color: "var(--vscode-descriptionForeground)",
@ -81,20 +45,10 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
display: "flex",
alignItems: "center",
}}>
<span
className="codicon codicon-comment-discussion"
style={{ marginRight: "4px", transform: "scale(0.9)" }}></span>
<span
style={{
fontWeight: 500,
fontSize: "0.85em",
textTransform: "uppercase",
}}>
Recent Tasks
</span>
<span className="codicon codicon-comment-discussion scale-90 mr-1" />
<span className="font-medium text-xs uppercase">Recent Tasks</span>
</div>
<div style={{ padding: "0px 20px 0 20px" }}>
<div className="px-5">
{taskHistory
.filter((item) => item.ts && item.task)
.slice(0, 3)
@ -103,21 +57,9 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
key={item.id}
className="history-preview-item"
onClick={() => handleHistorySelect(item.id)}>
<div style={{ padding: "12px", position: "relative" }}>
<div
style={{
marginBottom: "8px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}>
<span
style={{
color: "var(--vscode-descriptionForeground)",
fontWeight: 500,
fontSize: "0.85em",
textTransform: "uppercase",
}}>
<div className="flex flex-col gap-2 p-3 pt-1">
<div className="flex justify-between items-center">
<span className="text-xs font-medium text-vscode-descriptionForeground uppercase">
{formatDate(item.ts)}
</span>
<span
@ -126,31 +68,20 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
}}>
({item.number === 0 ? "Main" : item.number})
</span>
<button
title="Copy Prompt"
aria-label="Copy Prompt"
className="copy-button"
data-appearance="icon"
onClick={(e) => copyWithFeedback(item.task, e)}>
<span className="codicon codicon-copy"></span>
</button>
<CopyButton itemTask={item.task} />
</div>
<div
className="text-vscode-descriptionForeground overflow-hidden whitespace-pre-wrap"
style={{
fontSize: "var(--vscode-font-size)",
color: "var(--vscode-descriptionForeground)",
marginBottom: "8px",
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
overflow: "hidden",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
overflowWrap: "anywhere",
}}>
{item.task}
</div>
<div style={{ fontSize: "0.85em", color: "var(--vscode-descriptionForeground)" }}>
<div className="text-xs text-vscode-descriptionForeground">
<span>
Tokens: {formatLargeNumber(item.tokensIn || 0)}
{formatLargeNumber(item.tokensOut || 0)}
@ -174,21 +105,14 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
</div>
</div>
))}
<div style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
<VSCodeButton
appearance="icon"
<div className="flex justify-center">
<Button
variant="ghost"
size="sm"
onClick={() => showHistoryView()}
style={{
opacity: 0.9,
}}>
<div
style={{
fontSize: "var(--vscode-font-size)",
color: "var(--vscode-descriptionForeground)",
}}>
View all history
</div>
</VSCodeButton>
className="font-normal text-vscode-descriptionForeground">
View all history
</Button>
</div>
</div>
</div>

View file

@ -5,12 +5,14 @@ import prettyBytes from "pretty-bytes"
import { Virtuoso } from "react-virtuoso"
import { VSCodeButton, VSCodeTextField, VSCodeRadioGroup, VSCodeRadio } from "@vscode/webview-ui-toolkit/react"
import { vscode } from "@/utils/vscode"
import { formatLargeNumber, formatDate } from "@/utils/format"
import { highlightFzfMatch } from "@/utils/highlight"
import { Button } from "@/components/ui"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import { formatLargeNumber } from "../../utils/format"
import { highlightFzfMatch } from "../../utils/highlight"
import { useCopyToClipboard } from "../../utils/clipboard"
import { Button } from "../ui"
import { ExportButton } from "./ExportButton"
import { CopyButton } from "./CopyButton"
type HistoryViewProps = {
onDone: () => void
@ -38,28 +40,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
vscode.postMessage({ type: "showTaskWithId", text: id })
}
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [taskToDelete, setTaskToDelete] = useState<string | null>(null)
const handleDeleteHistoryItem = (id: string) => {
setTaskToDelete(id)
setDeleteDialogOpen(true)
}
const formatDate = (timestamp: number) => {
const date = new Date(timestamp)
return date
?.toLocaleString("en-US", {
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
})
.replace(", ", " ")
.replace(" at", ",")
.toUpperCase()
}
const [deleteTaskId, setDeleteTaskId] = useState<string | null>(null)
const presentableTasks = useMemo(() => {
return taskHistory.filter((item) => item.ts && item.task)
@ -230,10 +211,15 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
<Button
variant="ghost"
size="sm"
title="Delete Task"
title="Delete Task (Shift + Click to skip confirmation)"
onClick={(e) => {
e.stopPropagation()
handleDeleteHistoryItem(item.id)
if (e.shiftKey) {
vscode.postMessage({ type: "deleteTaskWithId", text: item.id })
} else {
setDeleteTaskId(item.id)
}
}}>
<span className="codicon codicon-trash" />
{item.size && prettyBytes(item.size)}
@ -403,44 +389,11 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
)}
/>
</div>
{taskToDelete && (
<DeleteTaskDialog
taskId={taskToDelete}
open={deleteDialogOpen}
onOpenChange={(open) => {
setDeleteDialogOpen(open)
if (!open) {
setTaskToDelete(null)
}
}}
/>
{deleteTaskId && (
<DeleteTaskDialog taskId={deleteTaskId} onOpenChange={(open) => !open && setDeleteTaskId(null)} open />
)}
</div>
)
}
const CopyButton = ({ itemTask }: { itemTask: string }) => {
const { showCopyFeedback, copyWithFeedback } = useCopyToClipboard()
return (
<Button variant="ghost" size="icon" title="Copy Prompt" onClick={(e) => copyWithFeedback(itemTask, e)}>
{showCopyFeedback ? <span className="codicon codicon-check" /> : <span className="codicon codicon-copy" />}
</Button>
)
}
const ExportButton = ({ itemId }: { itemId: string }) => (
<Button
data-testid="export"
variant="ghost"
size="icon"
title="Export Task"
onClick={(e) => {
e.stopPropagation()
vscode.postMessage({ type: "exportTaskWithId", text: itemId })
}}>
<span className="codicon codicon-cloud-download" />
</Button>
)
export default memo(HistoryView)

View file

@ -137,26 +137,54 @@ describe("HistoryView", () => {
})
})
it("handles task deletion", async () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
describe("task deletion", () => {
it("shows confirmation dialog on regular click", () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Find and hover over first task
const taskContainer = screen.getByTestId("virtuoso-item-1")
fireEvent.mouseEnter(taskContainer)
// Find and hover over first task
const taskContainer = screen.getByTestId("virtuoso-item-1")
fireEvent.mouseEnter(taskContainer)
// Click delete button to open confirmation dialog
const deleteButton = within(taskContainer).getByTitle("Delete Task")
fireEvent.click(deleteButton)
// Click delete button to open confirmation dialog
const deleteButton = within(taskContainer).getByTitle("Delete Task (Shift + Click to skip confirmation)")
fireEvent.click(deleteButton)
// Find and click the confirm delete button in the dialog
const confirmDeleteButton = screen.getByRole("button", { name: /delete/i })
fireEvent.click(confirmDeleteButton)
// Verify dialog is shown
const dialog = screen.getByRole("alertdialog")
expect(dialog).toBeInTheDocument()
// Verify vscode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "deleteTaskWithId",
text: "1",
// Find and click the confirm delete button in the dialog
const confirmDeleteButton = within(dialog).getByRole("button", { name: /delete/i })
fireEvent.click(confirmDeleteButton)
// Verify vscode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "deleteTaskWithId",
text: "1",
})
})
it("deletes immediately on shift-click without confirmation", () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Find and hover over first task
const taskContainer = screen.getByTestId("virtuoso-item-1")
fireEvent.mouseEnter(taskContainer)
// Shift-click delete button
const deleteButton = within(taskContainer).getByTitle("Delete Task (Shift + Click to skip confirmation)")
fireEvent.click(deleteButton, { shiftKey: true })
// Verify no dialog is shown
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument()
// Verify vscode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "deleteTaskWithId",
text: "1",
})
})
})

View file

@ -7,7 +7,6 @@ import * as vscodemodels from "vscode"
import {
ApiConfiguration,
ModelInfo,
ApiProvider,
anthropicDefaultModelId,
anthropicModels,
azureOpenAiDefaultApiVersion,
@ -1385,7 +1384,6 @@ const ApiOptions = ({
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
modelInfo={selectedModelInfo}
provider={selectedProvider as ApiProvider}
/>
<ModelInfoView
selectedModelId={selectedModelId}

View file

@ -1,5 +1,5 @@
import { useEffect, useMemo } from "react"
import { ApiProvider } from "../../../../src/shared/api"
import { Slider } from "@/components/ui"
import { ApiConfiguration, ModelInfo } from "../../../../src/shared/api"
@ -8,16 +8,10 @@ interface ThinkingBudgetProps {
apiConfiguration: ApiConfiguration
setApiConfigurationField: <K extends keyof ApiConfiguration>(field: K, value: ApiConfiguration[K]) => void
modelInfo?: ModelInfo
provider?: ApiProvider
}
export const ThinkingBudget = ({
apiConfiguration,
setApiConfigurationField,
modelInfo,
provider,
}: ThinkingBudgetProps) => {
const tokens = apiConfiguration?.modelMaxTokens || modelInfo?.maxTokens || 64_000
export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, modelInfo }: ThinkingBudgetProps) => {
const tokens = apiConfiguration?.modelMaxTokens || 16_384
const tokensMin = 8192
const tokensMax = modelInfo?.maxTokens || 64_000

View file

@ -92,7 +92,6 @@ describe("ApiOptions", () => {
})
expect(screen.getByTestId("thinking-budget")).toBeInTheDocument()
expect(screen.getByTestId("thinking-budget")).toHaveAttribute("data-provider", "anthropic")
})
it("should show ThinkingBudget for Vertex models that support thinking", () => {
@ -104,7 +103,6 @@ describe("ApiOptions", () => {
})
expect(screen.getByTestId("thinking-budget")).toBeInTheDocument()
expect(screen.getByTestId("thinking-budget")).toHaveAttribute("data-provider", "vertex")
})
it("should not show ThinkingBudget for models that don't support thinking", () => {

View file

@ -1,7 +1,6 @@
import React from "react"
import { render, screen, fireEvent } from "@testing-library/react"
import { ThinkingBudget } from "../ThinkingBudget"
import { ApiProvider, ModelInfo } from "../../../../../src/shared/api"
import { ModelInfo } from "../../../../../src/shared/api"
// Mock Slider component
jest.mock("@/components/ui", () => ({
@ -25,11 +24,11 @@ describe("ThinkingBudget", () => {
supportsPromptCache: true,
supportsImages: true,
}
const defaultProps = {
apiConfiguration: {},
setApiConfigurationField: jest.fn(),
modelInfo: mockModelInfo,
provider: "anthropic" as ApiProvider,
}
beforeEach(() => {
@ -60,7 +59,7 @@ describe("ThinkingBudget", () => {
expect(screen.getAllByTestId("slider")).toHaveLength(2)
})
it("should use modelMaxThinkingTokens field for Anthropic provider", () => {
it("should update modelMaxThinkingTokens", () => {
const setApiConfigurationField = jest.fn()
render(
@ -68,25 +67,6 @@ describe("ThinkingBudget", () => {
{...defaultProps}
apiConfiguration={{ modelMaxThinkingTokens: 4096 }}
setApiConfigurationField={setApiConfigurationField}
provider="anthropic"
/>,
)
const sliders = screen.getAllByTestId("slider")
fireEvent.change(sliders[1], { target: { value: "5000" } })
expect(setApiConfigurationField).toHaveBeenCalledWith("modelMaxThinkingTokens", 5000)
})
it("should use modelMaxThinkingTokens field for Vertex provider", () => {
const setApiConfigurationField = jest.fn()
render(
<ThinkingBudget
{...defaultProps}
apiConfiguration={{ modelMaxThinkingTokens: 4096 }}
setApiConfigurationField={setApiConfigurationField}
provider="vertex"
/>,
)

View file

@ -23,6 +23,8 @@
@theme {
--font-display: var(--vscode-font-family);
--text-xs: calc(var(--vscode-font-size) * 0.85);
--text-sm: calc(var(--vscode-font-size) * 0.9);
--text-base: var(--vscode-font-size);
--text-lg: calc(var(--vscode-font-size) * 1.1);
@ -64,6 +66,8 @@
--color-vscode-editor-foreground: var(--vscode-editor-foreground);
--color-vscode-editor-background: var(--vscode-editor-background);
--color-vscode-editorGroup-border: var(--vscode-editorGroup-border);
--color-vscode-button-foreground: var(--vscode-button-foreground);
--color-vscode-button-background: var(--vscode-button-background);
--color-vscode-button-secondaryForeground: var(--vscode-button-secondaryForeground);

View file

@ -0,0 +1,51 @@
// npx jest src/utils/__tests__/format.test.ts
import { formatDate } from "../format"
describe("formatDate", () => {
it("formats a timestamp correctly", () => {
// January 15, 2023, 10:30 AM
const timestamp = new Date(2023, 0, 15, 10, 30).getTime()
const result = formatDate(timestamp)
expect(result).toBe("JANUARY 15, 10:30 AM")
})
it("handles different months correctly", () => {
// February 28, 2023, 3:45 PM
const timestamp1 = new Date(2023, 1, 28, 15, 45).getTime()
expect(formatDate(timestamp1)).toBe("FEBRUARY 28, 3:45 PM")
// December 31, 2023, 11:59 PM
const timestamp2 = new Date(2023, 11, 31, 23, 59).getTime()
expect(formatDate(timestamp2)).toBe("DECEMBER 31, 11:59 PM")
})
it("handles AM/PM correctly", () => {
// Morning time - 7:05 AM
const morningTimestamp = new Date(2023, 5, 15, 7, 5).getTime()
expect(formatDate(morningTimestamp)).toBe("JUNE 15, 7:05 AM")
// Noon - 12:00 PM
const noonTimestamp = new Date(2023, 5, 15, 12, 0).getTime()
expect(formatDate(noonTimestamp)).toBe("JUNE 15, 12:00 PM")
// Evening time - 8:15 PM
const eveningTimestamp = new Date(2023, 5, 15, 20, 15).getTime()
expect(formatDate(eveningTimestamp)).toBe("JUNE 15, 8:15 PM")
})
it("handles single-digit minutes with leading zeros", () => {
// 9:05 AM
const timestamp = new Date(2023, 3, 10, 9, 5).getTime()
expect(formatDate(timestamp)).toBe("APRIL 10, 9:05 AM")
})
it("converts the result to uppercase", () => {
const timestamp = new Date(2023, 8, 21, 16, 45).getTime()
const result = formatDate(timestamp)
expect(result).toBe(result.toUpperCase())
expect(result).toBe("SEPTEMBER 21, 4:45 PM")
})
})

View file

@ -10,3 +10,18 @@ export function formatLargeNumber(num: number): string {
}
return num.toString()
}
export const formatDate = (timestamp: number) => {
const date = new Date(timestamp)
return date
.toLocaleString("en-US", {
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
})
.replace(", ", " ")
.replace(" at", ",")
.toUpperCase()
}