Skip to content

Commit f5f8d52

Browse files
committed
feat(tui): github-style bar and inline box for diff line comments
Hovering a line of an in-chat edit, patch or write block shows a + in the gutter, and dragging the line numbers grows or shrinks a bar there with the + on its top line. Releasing floats a comment box right below the last picked line instead of opening a dialog; enter sends, esc cancels. The sent prompt drops the "Review comment on" lead-in: just the file, the line range, the quoted lines and the comment. The bar and + use the new diff.highlight.selected theme token, which falls back to the interactive hue.
1 parent 7a9c36e commit f5f8d52

12 files changed

Lines changed: 442 additions & 171 deletions

File tree

‎PATCH.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ The vertical tab subtree lists unresolved children (running, pending, permission
105105

106106
## Diff Line Comments (TUI)
107107

108-
In the session transcript, the diffs of `edit` and `apply_patch` tool calls and the numbered content of `write` calls (quoted as added lines) take review comments: pressing or dragging down the line numbers picks lines within one hunk (and one side in split view); text on the code side still selects for copying. Releasing opens a one-line comment prompt, and submitting sends a normal prompt to the viewed session (`Review comment on <file> L<a>-L<b>:`, the picked lines in a `diff` fence, then the comment), so it queues while the session works. Ranges name new line numbers, or old ones marked `(removed)` when only removed lines are picked. Picked lines use the theme token `diff.background.selected`, which defaults to `$hue.interactive.700` for themes that do not set it. `createLineSelection` (`component/line-selection.ts`) owns picking and painting on any `LineNumberRenderable`; `PatchDiff` only uses it when given `onSelectLines`; `useLineComment` (`component/line-comment.tsx`) owns the prompt and message format.
108+
Dragging down the line numbers of an in-chat edit, patch or write block picks lines, GitHub style, and sends a comment on them to the session as a prompt.
109109

110110
## Message Actions Jump (TUI)
111111

‎packages/theme/src/tui/expand.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,13 @@ function expandBackground(definition: BackgroundDefinition | undefined): Backgro
7878
}
7979
}
8080

81-
// Themes from before the selection token pick a tint of their interactive hue.
81+
// Themes from before the selection tokens pick their interactive hue: a tint for picked lines, full for the marker.
8282
function expandDiff(definition: ThemeTokensDefinition["diff"]): ThemeTokensDefinition["diff"] {
8383
if (!definition?.background?.context) return definition
8484
return {
8585
...definition,
8686
background: { ...definition.background, selected: definition.background.selected ?? "$hue.interactive.700" },
87+
highlight: { ...definition.highlight, selected: definition.highlight?.selected ?? "$hue.interactive.200" },
8788
}
8889
}
8990

‎packages/theme/src/tui/schema.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,11 @@ const DiffDefinition = Schema.Struct({
178178
}),
179179
),
180180
highlight: Schema.optional(
181-
Schema.Struct({ added: Schema.optional(ColorValue), removed: Schema.optional(ColorValue) }),
181+
Schema.Struct({
182+
added: Schema.optional(ColorValue),
183+
removed: Schema.optional(ColorValue),
184+
selected: Schema.optional(ColorValue),
185+
}),
182186
),
183187
lineNumber: Schema.optional(
184188
Schema.Struct({
@@ -262,7 +266,7 @@ const CompleteThemeTokensDefinition = Schema.Struct({
262266
context: ColorValue,
263267
selected: Schema.optional(ColorValue),
264268
}),
265-
highlight: Schema.Struct({ added: ColorValue, removed: ColorValue }),
269+
highlight: Schema.Struct({ added: ColorValue, removed: ColorValue, selected: Schema.optional(ColorValue) }),
266270
lineNumber: Schema.Struct({
267271
text: ColorValue,
268272
background: Schema.Struct({ added: ColorValue, removed: ColorValue }),

‎packages/theme/src/tui/types.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,12 @@ export type ResolvedThemeTokens = {
6363
/** Lines picked in a diff, such as the range a review comment covers. */
6464
readonly selected: RGBA
6565
}
66-
readonly highlight: { readonly added: RGBA; readonly removed: RGBA }
66+
readonly highlight: {
67+
readonly added: RGBA
68+
readonly removed: RGBA
69+
/** The bar and `+` marking lines picked in a diff. */
70+
readonly selected: RGBA
71+
}
6772
readonly lineNumber: {
6873
readonly text: RGBA
6974
readonly background: { readonly added: RGBA; readonly removed: RGBA }

‎packages/theme/test/theme.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ test.each(["light", "dark"] as const)("resolves %s themes without status tokens"
1515
})
1616

1717
test.each(["light", "dark"] as const)(
18-
"gives %s themes without a diff selection color a tint of the interactive hue",
18+
"gives %s themes without diff selection colors their interactive hue",
1919
(mode) => {
2020
const theme = resolveThemeDocument(document, mode)
2121
expect(theme.diff.background.selected).toEqual(theme.hue.interactive[700])
22+
expect(theme.diff.highlight.selected).toEqual(theme.hue.interactive[200])
2223
},
2324
)

‎packages/tui/src/component/line-comment.tsx‎

Lines changed: 143 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,153 @@
1-
import { useDialog } from "../ui/dialog"
2-
import { DialogPrompt } from "../ui/dialog-prompt"
1+
import { TextareaRenderable, type BoxRenderable, type MouseEvent } from "@opentui/core"
2+
import { useRenderer } from "@opentui/solid"
3+
import { createSignal, onCleanup, onMount, Show } from "solid-js"
4+
import { useCursorStyle } from "../config"
5+
import { Keymap } from "../context/keymap"
6+
import { useTheme } from "../context/theme"
7+
import { EmptyBorder } from "../ui/border"
38
import { useToast } from "../ui/toast"
49
import type { PatchLine } from "../util/diff"
10+
import type { LineAnchor } from "./line-selection"
11+
12+
type Pending = {
13+
readonly at: string
14+
readonly file: string
15+
readonly lines: readonly PatchLine[]
16+
readonly anchor: LineAnchor
17+
readonly close: () => void
18+
}
519

620
/**
7-
* Returns a function that asks for a comment on lines picked in a diff and sends it with `send`. Prompts steer by
8-
* default, so a comment made while the session works is queued for its next step.
21+
* A box for commenting on lines picked in a diff. `open` shows it floating over the code just below the picked lines,
22+
* from a `Box` with the same `at` placed inside a container of the diff, and settles once the comment is sent or
23+
* dismissed, so the picked lines can stay marked until then. Sent comments go through `send`; prompts steer by default,
24+
* so a comment made while the session works is queued for its next step.
925
*/
10-
export function useLineComment() {
11-
const dialog = useDialog()
26+
export function createLineComment(send: (text: string) => Promise<unknown>) {
1227
const toast = useToast()
13-
return (input: { file: string; lines: readonly PatchLine[]; send: (text: string) => Promise<unknown> }) =>
14-
new Promise<string | undefined>((resolve) =>
15-
dialog.replace(
16-
() => (
17-
<DialogPrompt
18-
title="Comment"
19-
description={() => <text>{`${input.file} ${lineRange(input.lines)}`}</text>}
20-
placeholder="Leave a comment"
21-
onConfirm={(value) => {
22-
resolve(value)
23-
dialog.clear()
24-
}}
28+
const [pending, setPending] = createSignal<Pending>()
29+
30+
const open = (at: string, file: string, lines: readonly PatchLine[], anchor: LineAnchor) =>
31+
new Promise<void>((resolve) => {
32+
pending()?.close()
33+
const entry: Pending = {
34+
at,
35+
file,
36+
lines,
37+
anchor,
38+
close() {
39+
if (pending() === entry) setPending(undefined)
40+
resolve()
41+
},
42+
}
43+
setPending(entry)
44+
})
45+
46+
const submit = (entry: Pending, comment: string) => {
47+
entry.close()
48+
if (!comment.trim()) return
49+
void send(lineCommentPrompt(entry.file, entry.lines, comment))
50+
.then(() => toast.show({ message: "Comment sent", variant: "success" }))
51+
.catch(toast.error)
52+
}
53+
54+
function Box(props: { at: string }) {
55+
return (
56+
<Show when={pending()?.at === props.at ? pending() : undefined} keyed>
57+
{(entry) => (
58+
<LineCommentBox
59+
anchor={entry.anchor}
60+
range={lineRange(entry.lines)}
61+
onSubmit={(comment) => submit(entry, comment)}
62+
onCancel={entry.close}
2563
/>
26-
),
27-
() => resolve(undefined),
28-
),
64+
)}
65+
</Show>
2966
)
30-
.then(async (comment) => {
31-
if (!comment?.trim()) return
32-
await input.send(lineCommentPrompt(input.file, input.lines, comment))
33-
toast.show({ message: "Comment sent", variant: "success" })
34-
})
35-
.catch(toast.error)
67+
}
68+
69+
return { open, Box }
70+
}
71+
72+
function LineCommentBox(props: {
73+
anchor: LineAnchor
74+
range: string
75+
onSubmit: (comment: string) => void
76+
onCancel: () => void
77+
}) {
78+
const theme = useTheme()
79+
const renderer = useRenderer()
80+
const cursorStyle = useCursorStyle()
81+
const [textarea, setTextarea] = createSignal<TextareaRenderable>()
82+
const previous = renderer.currentFocusedRenderable
83+
84+
Keymap.createLayer(() => ({
85+
target: textarea,
86+
enabled: textarea() !== undefined,
87+
// Above the composer's layers, whose Enter and Escape would otherwise submit the prompt or interrupt the session.
88+
priority: 2,
89+
commands: [
90+
{
91+
bind: "return",
92+
title: "Send comment",
93+
group: "Comment",
94+
run: () => props.onSubmit(textarea()?.plainText ?? ""),
95+
},
96+
{ bind: "escape", title: "Cancel comment", group: "Comment", run: props.onCancel },
97+
],
98+
}))
99+
100+
onMount(() => textarea()?.focus())
101+
onCleanup(() => {
102+
if (previous && !previous.isDestroyed) previous.focus()
103+
})
104+
105+
// The box floats inside a transcript row, whose clicks open message actions.
106+
const keep = (event: MouseEvent) => event.stopPropagation()
107+
108+
// Layout moves the anchor's renderable (scrolling, wrapping, rebuilt diffs), so follow it every frame.
109+
const follow = (element: BoxRenderable) => {
110+
element.onLifecyclePass = () => {
111+
if (props.anchor.target.isDestroyed) return
112+
element.translateY = props.anchor.target.y + props.anchor.offset - (element.y - element.translateY)
113+
}
114+
renderer.registerLifecyclePass(element)
115+
onCleanup(() => renderer.unregisterLifecyclePass(element))
116+
}
117+
118+
return (
119+
<box
120+
ref={follow}
121+
position="absolute"
122+
top={0}
123+
left={0}
124+
right={0}
125+
zIndex={10}
126+
border={["left"]}
127+
borderColor={theme.diff.highlight.selected}
128+
customBorderChars={{ ...EmptyBorder, vertical: "▌" }}
129+
paddingLeft={1}
130+
backgroundColor={theme.background.raised.high}
131+
onMouseDown={keep}
132+
onMouseUp={keep}
133+
>
134+
<textarea
135+
ref={setTextarea}
136+
minHeight={1}
137+
maxHeight={6}
138+
wrapMode="word"
139+
placeholder="Leave a comment"
140+
placeholderColor={theme.text.muted}
141+
textColor={theme.text.formfield.base}
142+
focusedTextColor={theme.text.formfield.base}
143+
cursorColor={theme.text.base}
144+
cursorStyle={cursorStyle()}
145+
/>
146+
<text fg={theme.text.muted} wrapMode="none" truncate>
147+
{`${props.range} · enter send · esc cancel`}
148+
</text>
149+
</box>
150+
)
36151
}
37152

38153
/** Names the picked lines by their new line numbers, or by their old ones when only removed lines are picked. */
@@ -50,7 +165,7 @@ function lineCommentPrompt(file: string, lines: readonly PatchLine[], comment: s
50165
const fence = "`".repeat(Math.max(3, ticks + 1))
51166
const sign = { add: "+", remove: "-", context: " " } as const
52167
return [
53-
`Review comment on ${file} ${lineRange(lines)}:`,
168+
`${file} ${lineRange(lines)}`,
54169
"",
55170
`${fence}diff`,
56171
...lines.map((line) => sign[line.kind] + line.text),

0 commit comments

Comments
 (0)