fix(webview): preserve existing text when selecting slash command at start (#11728)

* fix(webview): preserve existing text when selecting slash command at input start

* remove unwanted check not related to pr

* fix(webview): update test mocks for new select() cursor usage and add trailing text tests

* chore: add changeset for slash command trailing text fix

* fix(webview): add slashEnd signal to fix cursor-corruption bug when selecting slash commands

* chore(test): fix redundant double type assertion in use-slash-command.test.ts

* address the pr review comments

* fix(test): use full text length as onInput cursor in review slash test

* fix: preserve trailing text on no-argument /memory commands

* refactor: drop review commit and review pr slash suggestions

* fix(webview): place caret at start when preserving trailing text for action commands

* fix(kilo-memory): preserve trailing text for /memory show like other no-arg commands

* fix(kilo-vscode): update mirrored /memory show fixture for rest field

* fix(kilo-vscode): format prompt-input-utils.ts to satisfy prettier

* fix(kilo-vscode): place caret at start for preserved /memory text
This commit is contained in:
rakshith1928
2026-08-14 18:37:53 +05:30
committed by GitHub
parent e781e72711
commit 2246ef70b5
11 changed files with 360 additions and 22 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Typing extra text after the `/memory show` command (for example `/memory show my notes`) used to clear that text from the chat input when the command ran. The text now stays in the input so you don't lose what you typed.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Preserve existing text when selecting a slash command at the start of the chat input. Previously, typing `/new` or another slash command with text before the cursor would wipe the existing content entirely.
+6 -4
View File
@@ -38,6 +38,7 @@ type Help = {
type Show = {
kind: "show"
rest?: string
}
type Operation =
@@ -64,6 +65,7 @@ type Operation =
| {
kind: "operation"
operation: Exclude<MemoryOperation, "remember" | "correct" | "forget" | "purge" | "auto">
rest?: string
}
type Usage = {
@@ -93,10 +95,10 @@ function usage(reason: string): ParsedMemoryCommand {
}
function operation(verb: string, text: string): ParsedMemoryCommand | undefined {
if (verb === "on" || verb === "enable") return { kind: "operation", operation: "enable" }
if (verb === "off" || verb === "disable") return { kind: "operation", operation: "disable" }
if (verb === "on" || verb === "enable") return { kind: "operation", operation: "enable", rest: text }
if (verb === "off" || verb === "disable") return { kind: "operation", operation: "disable", rest: text }
if (verb === "status" || verb === "inspect" || verb === "rebuild") {
return { kind: "operation", operation: verb }
return { kind: "operation", operation: verb, rest: text }
}
if (verb === "purge") {
if (text.toLowerCase() === "confirm") return { kind: "operation", operation: "purge", confirm: true }
@@ -138,7 +140,7 @@ export function parseMemoryCommand(input: string): ParsedMemoryCommand | undefin
const parts = split(picked.rest)
const verb = parts.head
if (!verb) return { kind: "help" }
if (verb === "show") return { kind: "show" }
if (verb === "show") return { kind: "show", rest: parts.tail }
const op = operation(verb, parts.tail)
if (op) return op
@@ -19,6 +19,12 @@
"input": "/memory project show",
"result": "show"
},
{
"name": "show preserves trailing text",
"input": "/memory show draft notes",
"result": "show",
"rest": "draft notes"
},
{
"name": "status operation",
"input": "/memory status",
@@ -61,6 +67,27 @@
"result": "operation",
"operation": "rebuild"
},
{
"name": "rebuild keeps trailing text",
"input": "/memory rebuild hello",
"result": "operation",
"operation": "rebuild",
"rest": "hello"
},
{
"name": "enable keeps trailing text",
"input": "/memory on hello",
"result": "operation",
"operation": "enable",
"rest": "hello"
},
{
"name": "project scope rebuild keeps trailing text",
"input": "/memory project rebuild hello",
"result": "operation",
"operation": "rebuild",
"rest": "hello"
},
{
"name": "purge requires confirmation",
"input": "/memory purge",
+3 -2
View File
@@ -11,6 +11,7 @@ type Case = {
text?: string
query?: string
reason?: string
rest?: string
}
const cases = (await Bun.file(new URL("./command-cases.json", import.meta.url)).json()) as Case[]
@@ -18,7 +19,7 @@ const cases = (await Bun.file(new URL("./command-cases.json", import.meta.url)).
function expected(item: Case): ParsedMemoryCommand | undefined {
if (item.result === "none") return
if (item.result === "help") return { kind: "help" }
if (item.result === "show") return { kind: "show" }
if (item.result === "show") return { kind: "show", rest: item.rest ?? "" }
if (item.result === "usage") return { kind: "usage", reason: item.reason ?? "" }
if (!item.operation) throw new Error(`Missing operation for fixture: ${item.name}`)
if (item.operation === "remember" || item.operation === "correct") {
@@ -37,7 +38,7 @@ function expected(item: Case): ParsedMemoryCommand | undefined {
if (item.confirm !== true) throw new Error(`Missing confirmation for fixture: ${item.name}`)
return { kind: "operation", operation: item.operation, confirm: true }
}
return { kind: "operation", operation: item.operation }
return { kind: "operation", operation: item.operation, rest: item.rest ?? "" }
}
describe("memory commands", () => {
@@ -22,6 +22,7 @@ type Case = {
text?: string
query?: string
reason?: string
rest?: string
}
const cases = (await Bun.file(
@@ -31,7 +32,7 @@ const cases = (await Bun.file(
function expected(item: Case): ParsedMemoryCommand | undefined {
if (item.result === "none") return
if (item.result === "help") return { kind: "help" }
if (item.result === "show") return { kind: "show" }
if (item.result === "show") return { kind: "show", rest: item.rest ?? "" }
if (item.result === "usage") return { kind: "usage", reason: item.reason ?? "" }
if (!item.operation) throw new Error(`Missing operation for fixture: ${item.name}`)
if (item.operation === "remember" || item.operation === "correct") {
@@ -50,7 +51,7 @@ function expected(item: Case): ParsedMemoryCommand | undefined {
if (item.confirm !== true) throw new Error(`Missing confirmation for fixture: ${item.name}`)
return { kind: "operation", operation: item.operation, confirm: true }
}
return { kind: "operation", operation: item.operation }
return { kind: "operation", operation: item.operation, rest: item.rest ?? "" }
}
describe("parseMemoryCommand", () => {
@@ -12,7 +12,9 @@ import {
isPathMention,
applySandboxState,
applySandboxStates,
memoryRest,
} from "../../webview-ui/src/components/chat/prompt-input-utils"
import { parseMemoryCommand } from "../../webview-ui/src/utils/memory-command"
describe("applySandboxState", () => {
const state = (enabled: boolean, revision: number, sessionID = "ses_1", directory = "/repo") => ({
@@ -329,3 +331,33 @@ describe("isPathMention", () => {
expect(isPathMention("src/foo.ts")).toBe(true)
})
})
describe("memoryRest", () => {
it("keeps trailing text in the input after a no-argument memory command", () => {
// /memory rebuild hello -> rebuild executes, "hello" stays in the input.
// This is the submit-path half of the trailing-text bug: handleSend sets
// the input to memoryRest(parsed), so a regression would drop "hello".
const memory = parseMemoryCommand("/memory rebuild hello")
expect(memory).not.toBeUndefined()
expect(memoryRest(memory!)).toBe("hello")
})
it("keeps trailing text through the project scope", () => {
expect(memoryRest(parseMemoryCommand("/memory project rebuild hello")!)).toBe("hello")
})
it("returns empty string when a no-argument command has no trailing text", () => {
expect(memoryRest(parseMemoryCommand("/memory rebuild")!)).toBe("")
})
it("keeps trailing text in the input after the show command", () => {
// /memory show draft notes -> show executes, "draft notes" stays in the input.
expect(memoryRest(parseMemoryCommand("/memory show draft notes")!)).toBe("draft notes")
})
it("returns empty string for argument-taking operations", () => {
// remember/correct/forget/auto/purge consume their text, so nothing remains.
expect(memoryRest(parseMemoryCommand("/memory remember hello")!)).toBe("")
expect(memoryRest(parseMemoryCommand("/memory auto on")!)).toBe("")
})
})
@@ -60,14 +60,14 @@ describe("useSlashCommand sandbox action", () => {
it("opens project memory actions from the top-level command", () => {
const ctx = setup(() => {})
const state = { text: "/memory" }
const state = { text: "/mem" }
const textarea = {
value: state.text,
setSelectionRange: () => {},
focus: () => {},
} as unknown as HTMLTextAreaElement
ctx.slash.onInput("/mem", 4)
ctx.slash.onInput(state.text, state.text.length)
expect(ctx.slash.results()).toContainEqual(
expect.objectContaining({ name: "memory", description: "Manage project memory", hints: ["mem"] }),
@@ -131,7 +131,11 @@ describe("useSlashCommand sandbox action", () => {
it("runs the sandbox toggle as a client command", () => {
const state = { toggles: 0, text: "/sandbox", prevented: 0 }
const ctx = setup(() => state.toggles++)
const textarea = { value: state.text } as HTMLTextAreaElement
const textarea = {
value: state.text,
selectionStart: state.text.length,
setSelectionRange: () => {},
} as unknown as HTMLTextAreaElement
const event = {
key: "Enter",
isComposing: false,
@@ -153,7 +157,11 @@ describe("useSlashCommand sandbox action", () => {
it("keeps the command text when the sandbox control is disabled", () => {
const state = { toggles: 0, text: "/sandbox" }
const ctx = setup(() => state.toggles++, { enabled: () => false })
const textarea = { value: state.text } as HTMLTextAreaElement
const textarea = {
value: state.text,
selectionStart: state.text.length,
setSelectionRange: () => {},
} as unknown as HTMLTextAreaElement
const event = {
key: "Enter",
isComposing: false,
@@ -198,7 +206,7 @@ describe("useSlashCommand sandbox action", () => {
focus: () => {},
} as unknown as HTMLTextAreaElement
ctx.slash.onInput("/rev", 4)
ctx.slash.onInput(state.text, state.text.length)
expect(ctx.slash.results()).toContainEqual(
expect.objectContaining({ name: "review", description: expect.stringContaining("Review code changes") }),
@@ -261,3 +269,223 @@ describe("useSlashCommand sandbox action", () => {
ctx.dispose()
})
})
describe("select", () => {
it("preserves trailing text for action commands", () => {
let actionCalls = 0
let currentText = "existing text"
const ctx = setup(() => {})
const textarea = {
value: "/newexisting text",
selectionStart: 4,
setSelectionRange: () => {},
} as unknown as HTMLTextAreaElement
const setText = (text: string) => {
currentText = text
}
ctx.slash.select(
{
name: "new",
description: "Start a new session",
hints: [],
action: () => {
actionCalls++
},
},
textarea,
setText,
)
expect(textarea.value).toBe("existing text")
expect(currentText).toBe("existing text")
expect(actionCalls).toBe(1)
ctx.dispose()
})
it("preserves trailing text for server commands and sets cursor", () => {
const ctx = setup(() => {})
let currentText = ""
let selectionStart = 0
const textarea = {
value: "/docmdexisting text",
selectionStart: 6,
setSelectionRange: (start: number, end: number) => {
selectionStart = start
},
focus: () => {},
} as unknown as HTMLTextAreaElement
const setText = (text: string) => {
currentText = text
}
ctx.slash.select({ name: "docmd", description: "Run doc command", hints: [] }, textarea, setText)
expect(textarea.value).toBe("/docmd existing text")
expect(currentText).toBe("/docmd existing text")
expect(selectionStart).toBe("/docmd ".length)
ctx.dispose()
})
it("uses slashEnd for server commands when onInput fired before select", () => {
const ctx = setup(() => {})
let currentText = ""
let selectionStart = 0
ctx.slash.onInput("/docmdexisting text", 6)
const textarea = {
value: "/docmdexisting text",
selectionStart: 2,
setSelectionRange: (start: number, end: number) => {
selectionStart = start
},
focus: () => {},
} as unknown as HTMLTextAreaElement
const setText = (text: string) => {
currentText = text
}
ctx.slash.select({ name: "docmd", description: "Run doc command", hints: [] }, textarea, setText)
expect(textarea.value).toBe("/docmd existing text")
expect(currentText).toBe("/docmd existing text")
expect(selectionStart).toBe("/docmd ".length)
ctx.dispose()
})
it("preserves trailing text even when cursor moves after typing slash command", () => {
let actionCalls = 0
let currentText = "existing text"
const ctx = setup(() => {})
// Type slash command: cursor at 4, slashEnd stored as 4
ctx.slash.onInput("/newexisting text", 4)
// Simulate user moving cursor (e.g. ArrowLeft twice)
const textarea = {
value: "/newexisting text",
selectionStart: 2,
setSelectionRange: () => {},
} as unknown as HTMLTextAreaElement
const setText = (text: string) => {
currentText = text
}
ctx.slash.select(
{
name: "new",
description: "Start a new session",
hints: [],
action: () => {
actionCalls++
},
},
textarea,
setText,
)
// Should preserve trailing text from original slashEnd (4), not stale selectionStart (2)
expect(textarea.value).toBe("existing text")
expect(currentText).toBe("existing text")
expect(actionCalls).toBe(1)
ctx.dispose()
})
it("preserves trailing text for memory commands when cursor moves", () => {
let currentText = ""
const ctx = setup(() => {})
// Type /memory rem: matches memory pattern, slashEnd set to end of text
const typed = "/memory rem"
ctx.slash.onInput(typed, typed.length)
// Simulate user moving cursor (no onInput fires for arrow keys)
const textarea = {
value: typed,
selectionStart: 3,
setSelectionRange: () => {},
focus: () => {},
} as unknown as HTMLTextAreaElement
const setText = (text: string) => {
currentText = text
}
// Find the memory remember command (nested, no action)
const remembered = ctx.slash.results().find((c) => c.name === "memory remember")
expect(remembered).toBeDefined()
ctx.slash.select(remembered!, textarea, setText)
// Trailing text from slashEnd (end of typed text) — empty — should be preserved correctly
expect(currentText).toBe("/memory remember ")
expect(textarea.value).toBe("/memory remember ")
ctx.dispose()
})
it("preserves trailing text through the two-step nested command path", () => {
let currentText = ""
const ctx = setup(() => {})
ctx.slash.onInput("/mem", 4)
const textarea = {
value: "/mem",
selectionStart: 4,
setSelectionRange: () => {},
focus: () => {},
} as unknown as HTMLTextAreaElement
const setText = (text: string) => {
currentText = text
}
const memory = ctx.slash.results().find((c) => c.name === "memory")
expect(memory).toBeDefined()
ctx.slash.select(memory!, textarea, setText)
expect(currentText).toBe("/memory ")
expect(textarea.value).toBe("/memory ")
const remember = ctx.slash.results().find((c) => c.name === "memory remember")
expect(remember).toBeDefined()
ctx.slash.select(remember!, textarea, setText)
expect(currentText).toBe("/memory remember ")
expect(textarea.value).toBe("/memory remember ")
ctx.dispose()
})
it("keeps trailing text and cursor before it through nested selection", () => {
const ctx = setup(() => {})
let currentText = "hello"
const textarea = {
value: "hello",
selectionStart: 0,
setSelectionRange: (start: number, _end: number) => {
textarea.selectionStart = start
},
focus: () => {},
} as unknown as HTMLTextAreaElement
const setText = (text: string) => {
currentText = text
}
textarea.value = "/memhello"
textarea.selectionStart = 4
ctx.slash.onInput("/memhello", 4)
const memory = ctx.slash.results().find((c) => c.name === "memory")
expect(memory).toBeDefined()
ctx.slash.select(memory!, textarea, setText)
expect(textarea.value).toBe("/memory hello")
expect(textarea.selectionStart).toBe("/memory ".length)
const rebuild = ctx.slash.results().find((c) => c.name === "memory rebuild")
expect(rebuild).toBeDefined()
ctx.slash.select(rebuild!, textarea, setText)
expect(textarea.value).toBe("/memory rebuild hello")
expect(textarea.selectionStart).toBe("/memory rebuild ".length)
ctx.dispose()
})
})
@@ -52,6 +52,7 @@ import {
isPromptBusy,
isPathMention,
applySandboxStates,
memoryRest,
type SandboxDefaultState,
type SandboxState,
} from "./prompt-input-utils"
@@ -80,7 +81,7 @@ import {
import { ReviewComments } from "./ReviewComments"
import { partReview, reviewBody } from "../../../../src/shared/review-comments"
import { isEnterKeyCommitNotIme } from "../../utils/ime-enter"
import { parseMemoryCommand } from "../../utils/memory-command"
import { parseMemoryCommand, type ParsedMemoryCommand } from "../../utils/memory-command"
import { useMemory } from "../../context/memory"
function mergeReviewComments(current: ReviewComment[], incoming: ReviewComment[]): ReviewComment[] {
@@ -1124,6 +1125,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return true
}
const setMemoryText = (memory: ParsedMemoryCommand) => {
const rest = memoryRest(memory)
setText(rest)
if (textareaRef) {
textareaRef.value = rest
textareaRef.setSelectionRange(0, 0)
textareaRef.focus()
}
}
const handleSend = async () => {
const draft = text().trim()
@@ -1131,7 +1142,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (memory) {
if (!runMemory(memory)) return
history.append(draft)
setText("")
setMemoryText(memory)
clearReviewComments()
imageAttach.clear()
mention.closeMention()
@@ -1,3 +1,5 @@
import { type ParsedMemoryCommand } from "../../utils/memory-command"
export type SandboxDefaultState = {
desired: boolean
enabled: boolean
@@ -150,3 +152,15 @@ export function isPathMention(text: string): boolean {
const path = text.replace(/^@/, "")
return path !== "terminal" && path !== "git-changes"
}
/**
* The text that should remain in the prompt input after a memory command is
* submitted. No-argument memory operations (e.g. rebuild, on, status, inspect)
* typed with trailing free text (e.g. "/memory rebuild hello") keep that text in
* the input instead of discarding it; the parser reports the unconsumed
* remainder as `rest`. Argument-taking operations (remember, correct, forget,
* auto, purge) consume their text, so nothing remains.
*/
export function memoryRest(cmd: ParsedMemoryCommand): string {
return "rest" in cmd ? (cmd.rest ?? "") : ""
}
@@ -64,6 +64,7 @@ export function useSlashCommand(
const [query, setQuery] = createSignal<string | null>(null)
const [index, setIndex] = createSignal(0)
const [requested, setRequested] = createSignal(false)
const [slashEnd, setSlashEnd] = createSignal<number | null>(null)
const open = (name: string) => {
window.dispatchEvent(new CustomEvent(name, { detail: { source: scope } }))
}
@@ -287,6 +288,7 @@ export function useSlashCommand(
const close = () => {
setQuery(null)
setSlashEnd(null)
}
const onInput = (val: string, cursor: number) => {
@@ -296,6 +298,7 @@ export function useSlashCommand(
request()
setQuery(match[1])
setIndex(0)
setSlashEnd(cursor)
return
}
const memory = before.match(/^\/(?:memory|mem)\s+([^\n]*)$/i)
@@ -305,6 +308,7 @@ export function useSlashCommand(
request()
setQuery(value)
setIndex(0)
setSlashEnd(cursor)
return
}
const review = before.match(/^\/review\s+([^\n]*)$/i)
@@ -314,6 +318,7 @@ export function useSlashCommand(
request()
setQuery(value)
setIndex(0)
setSlashEnd(cursor)
return
}
return close()
@@ -325,24 +330,31 @@ export function useSlashCommand(
setText: (text: string) => void,
onSelect?: () => void,
) => {
const cursor = slashEnd() ?? textarea.selectionStart ?? 0
// trailingText holds text after the slash command.
// slashEnd is the cursor position from onInput when the slash pattern was matched.
const trailingText = textarea.value.substring(cursor)
if (cmd.action) {
if (cmd.enabled && !cmd.enabled()) return
textarea.value = ""
setText("")
textarea.value = trailingText
setText(trailingText)
textarea.setSelectionRange(0, 0)
close()
onSelect?.()
cmd.action()
return
}
const text = `/${cmd.name} `
textarea.value = text
setText(text)
const pos = text.length
textarea.setSelectionRange(pos, pos)
const commandText = `/${cmd.name} `
const updatedText = commandText + trailingText
textarea.value = updatedText
setText(updatedText)
textarea.setSelectionRange(commandText.length, commandText.length)
textarea.focus()
if (cmd.nested) {
setQuery(`${cmd.name} `)
setIndex(0)
setSlashEnd(commandText.length)
}
if (!cmd.nested) close()
onSelect?.()