summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/components/prompt-input
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-26 18:23:04 -0600
committerGitHub <[email protected]>2026-02-26 18:23:04 -0600
commitfc52e4b2d3a41efde772e6de8fb2e01f27821701 (patch)
treecf23af294a00a10e55f230232585344c111f0bb9 /packages/app/src/components/prompt-input
parent9a6bfeb782766099d4ce3a98bb9e7b4e79f8bfe6 (diff)
downloadopencode-fc52e4b2d3a41efde772e6de8fb2e01f27821701.tar.gz
opencode-fc52e4b2d3a41efde772e6de8fb2e01f27821701.zip
feat(app): better diff/code comments (#14621)
Co-authored-by: adamelmore <[email protected]> Co-authored-by: David Hill <[email protected]>
Diffstat (limited to 'packages/app/src/components/prompt-input')
-rw-r--r--packages/app/src/components/prompt-input/build-request-parts.test.ts9
-rw-r--r--packages/app/src/components/prompt-input/build-request-parts.ts22
-rw-r--r--packages/app/src/components/prompt-input/history.test.ts52
-rw-r--r--packages/app/src/components/prompt-input/history.ts125
4 files changed, 175 insertions, 33 deletions
diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts
index 72bdecc01..4c2e2d8be 100644
--- a/packages/app/src/components/prompt-input/build-request-parts.test.ts
+++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts
@@ -35,6 +35,15 @@ describe("buildRequestParts", () => {
result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
).toBe(true)
expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
+ expect(
+ result.requestParts.some(
+ (part) =>
+ part.type === "text" &&
+ part.synthetic &&
+ part.metadata?.opencodeComment &&
+ (part.metadata.opencodeComment as { comment?: string }).comment === "check this",
+ ),
+ ).toBe(true)
expect(result.optimisticParts).toHaveLength(result.requestParts.length)
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts
index 0cc54dc2b..4146fb484 100644
--- a/packages/app/src/components/prompt-input/build-request-parts.ts
+++ b/packages/app/src/components/prompt-input/build-request-parts.ts
@@ -4,6 +4,7 @@ import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { Identifier } from "@/utils/id"
+import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
@@ -41,18 +42,6 @@ const fileQuery = (selection: FileSelection | undefined) =>
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
-const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
- const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
- const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
- const range =
- start === undefined || end === undefined
- ? "this file"
- : start === end
- ? `line ${start}`
- : `lines ${start} through ${end}`
- return `The user made the following comment regarding ${range} of ${path}: ${comment}`
-}
-
const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => {
if (part.type === "text") {
return {
@@ -153,8 +142,15 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
{
id: Identifier.ascending("part"),
type: "text",
- text: commentNote(item.path, item.selection, comment),
+ text: formatCommentNote({ path: item.path, selection: item.selection, comment }),
synthetic: true,
+ metadata: createCommentMetadata({
+ path: item.path,
+ selection: item.selection,
+ comment,
+ preview: item.preview,
+ origin: item.commentOrigin,
+ }),
} satisfies PromptRequestPart,
filePart,
]
diff --git a/packages/app/src/components/prompt-input/history.test.ts b/packages/app/src/components/prompt-input/history.test.ts
index b7a4f896b..37b5ce196 100644
--- a/packages/app/src/components/prompt-input/history.test.ts
+++ b/packages/app/src/components/prompt-input/history.test.ts
@@ -3,25 +3,42 @@ import type { Prompt } from "@/context/prompt"
import {
canNavigateHistoryAtCursor,
clonePromptParts,
+ normalizePromptHistoryEntry,
navigatePromptHistory,
prependHistoryEntry,
promptLength,
+ type PromptHistoryComment,
} from "./history"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
+const comment = (id: string, value = "note"): PromptHistoryComment => ({
+ id,
+ path: "src/a.ts",
+ selection: { start: 2, end: 4 },
+ comment: value,
+ time: 1,
+ origin: "review",
+ preview: "const a = 1",
+})
describe("prompt-input history", () => {
test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => {
const first = prependHistoryEntry([], DEFAULT_PROMPT)
expect(first).toEqual([])
+ const commentsOnly = prependHistoryEntry([], DEFAULT_PROMPT, [comment("c1")])
+ expect(commentsOnly).toHaveLength(1)
+
const withOne = prependHistoryEntry([], text("hello"))
expect(withOne).toHaveLength(1)
const deduped = prependHistoryEntry(withOne, text("hello"))
expect(deduped).toBe(withOne)
+
+ const dedupedComments = prependHistoryEntry(commentsOnly, DEFAULT_PROMPT, [comment("c1")])
+ expect(dedupedComments).toBe(commentsOnly)
})
test("navigatePromptHistory restores saved prompt when moving down from newest", () => {
@@ -31,24 +48,57 @@ describe("prompt-input history", () => {
entries,
historyIndex: -1,
currentPrompt: text("draft"),
+ currentComments: [comment("draft")],
savedPrompt: null,
})
expect(up.handled).toBe(true)
if (!up.handled) throw new Error("expected handled")
expect(up.historyIndex).toBe(0)
expect(up.cursor).toBe("start")
+ expect(up.entry.comments).toEqual([])
const down = navigatePromptHistory({
direction: "down",
entries,
historyIndex: up.historyIndex,
currentPrompt: text("ignored"),
+ currentComments: [],
savedPrompt: up.savedPrompt,
})
expect(down.handled).toBe(true)
if (!down.handled) throw new Error("expected handled")
expect(down.historyIndex).toBe(-1)
- expect(down.prompt[0]?.type === "text" ? down.prompt[0].content : "").toBe("draft")
+ expect(down.entry.prompt[0]?.type === "text" ? down.entry.prompt[0].content : "").toBe("draft")
+ expect(down.entry.comments).toEqual([comment("draft")])
+ })
+
+ test("navigatePromptHistory keeps entry comments when moving through history", () => {
+ const entries = [
+ {
+ prompt: text("with comment"),
+ comments: [comment("c1")],
+ },
+ ]
+
+ const up = navigatePromptHistory({
+ direction: "up",
+ entries,
+ historyIndex: -1,
+ currentPrompt: text("draft"),
+ currentComments: [],
+ savedPrompt: null,
+ })
+
+ expect(up.handled).toBe(true)
+ if (!up.handled) throw new Error("expected handled")
+ expect(up.entry.prompt[0]?.type === "text" ? up.entry.prompt[0].content : "").toBe("with comment")
+ expect(up.entry.comments).toEqual([comment("c1")])
+ })
+
+ test("normalizePromptHistoryEntry supports legacy prompt arrays", () => {
+ const entry = normalizePromptHistoryEntry(text("legacy"))
+ expect(entry.prompt[0]?.type === "text" ? entry.prompt[0].content : "").toBe("legacy")
+ expect(entry.comments).toEqual([])
})
test("helpers clone prompt and count text content length", () => {
diff --git a/packages/app/src/components/prompt-input/history.ts b/packages/app/src/components/prompt-input/history.ts
index c279a3ed5..de6265321 100644
--- a/packages/app/src/components/prompt-input/history.ts
+++ b/packages/app/src/components/prompt-input/history.ts
@@ -1,9 +1,27 @@
import type { Prompt } from "@/context/prompt"
+import type { SelectedLineRange } from "@/context/file"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
export const MAX_HISTORY = 100
+export type PromptHistoryComment = {
+ id: string
+ path: string
+ selection: SelectedLineRange
+ comment: string
+ time: number
+ origin?: "review" | "file"
+ preview?: string
+}
+
+export type PromptHistoryEntry = {
+ prompt: Prompt
+ comments: PromptHistoryComment[]
+}
+
+export type PromptHistoryStoredEntry = Prompt | PromptHistoryEntry
+
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
const position = Math.max(0, Math.min(cursor, text.length))
const atStart = position === 0
@@ -25,29 +43,82 @@ export function clonePromptParts(prompt: Prompt): Prompt {
})
}
+function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
+ return {
+ start: selection.start,
+ end: selection.end,
+ ...(selection.side ? { side: selection.side } : {}),
+ ...(selection.endSide ? { endSide: selection.endSide } : {}),
+ }
+}
+
+export function clonePromptHistoryComments(comments: PromptHistoryComment[]) {
+ return comments.map((comment) => ({
+ ...comment,
+ selection: cloneSelection(comment.selection),
+ }))
+}
+
+export function normalizePromptHistoryEntry(entry: PromptHistoryStoredEntry): PromptHistoryEntry {
+ if (Array.isArray(entry)) {
+ return {
+ prompt: clonePromptParts(entry),
+ comments: [],
+ }
+ }
+ return {
+ prompt: clonePromptParts(entry.prompt),
+ comments: clonePromptHistoryComments(entry.comments),
+ }
+}
+
export function promptLength(prompt: Prompt) {
return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
}
-export function prependHistoryEntry(entries: Prompt[], prompt: Prompt, max = MAX_HISTORY) {
+export function prependHistoryEntry(
+ entries: PromptHistoryStoredEntry[],
+ prompt: Prompt,
+ comments: PromptHistoryComment[] = [],
+ max = MAX_HISTORY,
+) {
const text = prompt
.map((part) => ("content" in part ? part.content : ""))
.join("")
.trim()
const hasImages = prompt.some((part) => part.type === "image")
- if (!text && !hasImages) return entries
+ const hasComments = comments.some((comment) => !!comment.comment.trim())
+ if (!text && !hasImages && !hasComments) return entries
- const entry = clonePromptParts(prompt)
+ const entry = {
+ prompt: clonePromptParts(prompt),
+ comments: clonePromptHistoryComments(comments),
+ } satisfies PromptHistoryEntry
const last = entries[0]
if (last && isPromptEqual(last, entry)) return entries
return [entry, ...entries].slice(0, max)
}
-function isPromptEqual(promptA: Prompt, promptB: Prompt) {
- if (promptA.length !== promptB.length) return false
- for (let i = 0; i < promptA.length; i++) {
- const partA = promptA[i]
- const partB = promptB[i]
+function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryComment) {
+ return (
+ commentA.path === commentB.path &&
+ commentA.comment === commentB.comment &&
+ commentA.origin === commentB.origin &&
+ commentA.preview === commentB.preview &&
+ commentA.selection.start === commentB.selection.start &&
+ commentA.selection.end === commentB.selection.end &&
+ commentA.selection.side === commentB.selection.side &&
+ commentA.selection.endSide === commentB.selection.endSide
+ )
+}
+
+function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistoryStoredEntry) {
+ const entryA = normalizePromptHistoryEntry(promptA)
+ const entryB = normalizePromptHistoryEntry(promptB)
+ if (entryA.prompt.length !== entryB.prompt.length) return false
+ for (let i = 0; i < entryA.prompt.length; i++) {
+ const partA = entryA.prompt[i]
+ const partB = entryB.prompt[i]
if (partA.type !== partB.type) return false
if (partA.type === "text" && partA.content !== (partB.type === "text" ? partB.content : "")) return false
if (partA.type === "file") {
@@ -67,28 +138,35 @@ function isPromptEqual(promptA: Prompt, promptB: Prompt) {
if (partA.type === "agent" && partA.name !== (partB.type === "agent" ? partB.name : "")) return false
if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
}
+ if (entryA.comments.length !== entryB.comments.length) return false
+ for (let i = 0; i < entryA.comments.length; i++) {
+ const commentA = entryA.comments[i]
+ const commentB = entryB.comments[i]
+ if (!commentA || !commentB || !isCommentEqual(commentA, commentB)) return false
+ }
return true
}
type HistoryNavInput = {
direction: "up" | "down"
- entries: Prompt[]
+ entries: PromptHistoryStoredEntry[]
historyIndex: number
currentPrompt: Prompt
- savedPrompt: Prompt | null
+ currentComments: PromptHistoryComment[]
+ savedPrompt: PromptHistoryEntry | null
}
type HistoryNavResult =
| {
handled: false
historyIndex: number
- savedPrompt: Prompt | null
+ savedPrompt: PromptHistoryEntry | null
}
| {
handled: true
historyIndex: number
- savedPrompt: Prompt | null
- prompt: Prompt
+ savedPrompt: PromptHistoryEntry | null
+ entry: PromptHistoryEntry
cursor: "start" | "end"
}
@@ -103,22 +181,27 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
}
if (input.historyIndex === -1) {
+ const entry = normalizePromptHistoryEntry(input.entries[0])
return {
handled: true,
historyIndex: 0,
- savedPrompt: clonePromptParts(input.currentPrompt),
- prompt: input.entries[0],
+ savedPrompt: {
+ prompt: clonePromptParts(input.currentPrompt),
+ comments: clonePromptHistoryComments(input.currentComments),
+ },
+ entry,
cursor: "start",
}
}
if (input.historyIndex < input.entries.length - 1) {
const next = input.historyIndex + 1
+ const entry = normalizePromptHistoryEntry(input.entries[next])
return {
handled: true,
historyIndex: next,
savedPrompt: input.savedPrompt,
- prompt: input.entries[next],
+ entry,
cursor: "start",
}
}
@@ -132,11 +215,12 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
if (input.historyIndex > 0) {
const next = input.historyIndex - 1
+ const entry = normalizePromptHistoryEntry(input.entries[next])
return {
handled: true,
historyIndex: next,
savedPrompt: input.savedPrompt,
- prompt: input.entries[next],
+ entry,
cursor: "end",
}
}
@@ -147,7 +231,7 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
handled: true,
historyIndex: -1,
savedPrompt: null,
- prompt: input.savedPrompt,
+ entry: input.savedPrompt,
cursor: "end",
}
}
@@ -156,7 +240,10 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
handled: true,
historyIndex: -1,
savedPrompt: null,
- prompt: DEFAULT_PROMPT,
+ entry: {
+ prompt: DEFAULT_PROMPT,
+ comments: [],
+ },
cursor: "end",
}
}