tui(cli): render non-blocking suggestions inline in the conversation

Moves the 'Run review?' suggestion picker out of the footer bar above the
prompt and into the conversation itself, at the position of the suggest
tool call. Frees up vertical space for reading while scrolling and
matches where the VS Code extension shows the same picker. Clicking an
option still accepts, digit keys 1/2 fast-accept when the prompt isn't
focused, and Esc dismisses. Blocking suggestions keep the above-prompt
overlay.
This commit is contained in:
Alex Alecu
2026-04-22 09:59:02 +03:00
parent 1ca0683cf5
commit 8d06a083bc
5 changed files with 161 additions and 38 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
CLI suggestions now render inline in the conversation at the position of the suggest tool call, instead of as a separate bar above the prompt input. This matches the VS Code extension and leaves more room for reading while scrolling. Blocking suggestions still use the above-prompt overlay.
@@ -159,8 +159,9 @@ export function Session() {
const nonBlockingQuestions = createMemo(() => questions().filter((q) => q.blocking === false))
const question = createMemo(() => blockingQuestions()[0] ?? nonBlockingQuestions()[0])
const blockingSuggestions = createMemo(() => suggestions().filter((s) => s.blocking !== false))
const nonBlockingSuggestions = createMemo(() => suggestions().filter((s) => s.blocking === false))
const suggestion = createMemo(() => blockingSuggestions()[0] ?? nonBlockingSuggestions()[0])
// Footer overlay only hosts blocking suggestions now; non-blocking ones
// render inline at the tool-part slot via `SuggestBar`.
const blockingSuggestion = createMemo(() => blockingSuggestions()[0])
const visible = createMemo(
() =>
!session()?.parentID &&
@@ -1285,14 +1286,8 @@ export function Session() {
<Show when={permissions().length === 0 && !question()}>
{/* kilocode_change end */}
{/* kilocode_change start */}
<Show when={suggestion()} keyed>
{(request) => (
<SuggestPrompt
request={request}
nonBlocking={request.blocking === false}
inputFocused={() => prompt?.focused ?? false}
/>
)}
<Show when={blockingSuggestion()} keyed>
{(request) => <SuggestPrompt request={request} />}
</Show>
</Show>
<Show when={session()?.parentID}>
@@ -1640,6 +1635,7 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMessage }) {
const ctx = use()
const sync = useSync()
const promptRef = usePromptRef() // kilocode_change - for suggest tool inline bar
// Hide tool if showDetails is false and tool completed successfully
const shouldHide = createMemo(() => {
@@ -1718,7 +1714,26 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
</Match>
{/* kilocode_change start */}
<Match when={props.part.tool === "suggest"}>
<Suggest {...toolprops} InlineTool={InlineTool} BlockTool={BlockTool} />
{(() => {
const pending = createMemo(() => {
const requests = sync.data.suggestion[props.message.sessionID] ?? []
return requests.find(
(r) =>
r.blocking === false &&
r.tool?.callID === props.part.callID &&
r.tool?.messageID === props.part.messageID,
)
})
return (
<Suggest
{...toolprops}
InlineTool={InlineTool}
BlockTool={BlockTool}
pendingRequest={pending()}
inputFocused={() => promptRef.current?.focused ?? false}
/>
)
})()}
</Match>
{/* kilocode_change end */}
<Match when={props.part.tool === "skill"}>
@@ -0,0 +1,110 @@
/** @jsxImportSource @opentui/solid */
// kilocode_change - new file
import { useKeyboard } from "@opentui/solid"
import type { SuggestionRequest } from "@kilocode/sdk/v2"
import { createMemo, createSignal, For, Show } from "solid-js"
import { useKeybind } from "../../../cli/cmd/tui/context/keybind"
import { useSDK } from "../../../cli/cmd/tui/context/sdk"
import { tint, useTheme } from "../../../cli/cmd/tui/context/theme"
import { useDialog } from "../../../cli/cmd/tui/ui/dialog"
export function SuggestBar(props: { request: SuggestionRequest; inputFocused?: () => boolean }) {
const sdk = useSDK()
const { theme } = useTheme()
const keybind = useKeybind()
const dialog = useDialog()
const options = createMemo(() => props.request.actions)
const [selected, setSelected] = createSignal(0)
const [busy, setBusy] = createSignal(false)
function accept(index: number) {
if (busy()) return
setBusy(true)
sdk.client.suggestion
.accept({
requestID: props.request.id,
index,
})
.catch(() => setBusy(false))
}
function reject() {
if (busy()) return
setBusy(true)
sdk.client.suggestion
.dismiss({
requestID: props.request.id,
})
.catch(() => setBusy(false))
}
useKeyboard((evt) => {
if (dialog.stack.length > 0) return
if (evt.defaultPrevented) return
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
evt.preventDefault()
reject()
return
}
// Skip digit shortcuts when the prompt has focus so typed digits go into
// the prompt rather than triggering the bar. Matches the non-blocking
// suppression pattern from the old footer overlay.
if (props.inputFocused?.()) return
const max = Math.min(options().length, 2)
const digit = Number(evt.name)
if (!Number.isNaN(digit) && digit >= 1 && digit <= max) {
evt.preventDefault()
accept(digit - 1)
}
})
return (
<box
marginTop={1}
paddingLeft={2}
paddingTop={0}
paddingBottom={0}
gap={1}
border={["left"]}
borderColor={theme.secondary}
>
<box paddingLeft={1}>
<text fg={theme.text}>{props.request.text}</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={1}>
<For each={options()}>
{(opt, i) => {
const active = () => i() === selected()
return (
<box
flexDirection="row"
onMouseOver={() => setSelected(i())}
onMouseDown={() => setSelected(i())}
onMouseUp={() => accept(i())}
>
<box backgroundColor={active() ? theme.backgroundElement : undefined} paddingRight={1}>
<text fg={active() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
{`${i() + 1}.`}
</text>
</box>
<box backgroundColor={active() ? theme.backgroundElement : undefined} paddingRight={1}>
<text fg={active() ? theme.secondary : theme.text}>{opt.label}</text>
</box>
</box>
)
}}
</For>
<text fg={theme.textMuted}>
<Show when={busy()} fallback={<>esc dismiss</>}>
Waiting...
</Show>
</text>
</box>
</box>
)
}
@@ -2,18 +2,18 @@
import { useKeyboard } from "@opentui/solid"
import type { SuggestionRequest } from "@kilocode/sdk/v2"
import { createMemo, createSignal, For, Show } from "solid-js"
import { createMemo, createSignal, For } from "solid-js"
import { SplitBorder } from "../../../cli/cmd/tui/component/border"
import { useKeybind } from "../../../cli/cmd/tui/context/keybind"
import { useSDK } from "../../../cli/cmd/tui/context/sdk"
import { tint, useTheme } from "../../../cli/cmd/tui/context/theme"
import { useDialog } from "../../../cli/cmd/tui/ui/dialog"
export function SuggestPrompt(props: {
request: SuggestionRequest
nonBlocking?: boolean
inputFocused?: () => boolean
}) {
// The footer-mounted overlay only ever hosts blocking suggestions now; the
// built-in suggest tool emits non-blocking requests that render inline at
// the tool-part slot via `SuggestBar`. See `./bar.tsx` and the dispatch in
// `cli/cmd/tui/routes/session/index.tsx`.
export function SuggestPrompt(props: { request: SuggestionRequest }) {
const sdk = useSDK()
const { theme } = useTheme()
const keybind = useKeybind()
@@ -55,7 +55,6 @@ export function SuggestPrompt(props: {
useKeyboard((evt) => {
if (dialog.stack.length > 0) return
if (evt.defaultPrevented) return
const suppressed = props.nonBlocking && props.inputFocused?.()
if (evt.name === "escape") {
evt.preventDefault()
@@ -63,8 +62,6 @@ export function SuggestPrompt(props: {
return
}
if (suppressed) return
const total = options().length
const max = Math.min(total, 9)
const digit = Number(evt.name)
@@ -155,23 +152,12 @@ export function SuggestPrompt(props: {
justifyContent="space-between"
>
<box flexDirection="row" gap={2}>
<Show
when={props.nonBlocking && props.inputFocused?.()}
fallback={
<>
<text fg={theme.text}>
{"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
</text>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>choose</span>
</text>
</>
}
>
<text fg={theme.text}>
click <span style={{ fg: theme.textMuted }}>choose</span>
</text>
</Show>
<text fg={theme.text}>
{"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
</text>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>choose</span>
</text>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>dismiss</span>
</text>
@@ -2,7 +2,8 @@
import { createMemo, Show, type JSX } from "solid-js"
import { useTheme } from "../../../cli/cmd/tui/context/theme"
import type { ToolPart as MessageToolPart } from "@kilocode/sdk/v2"
import type { SuggestionRequest, ToolPart as MessageToolPart } from "@kilocode/sdk/v2"
import { SuggestBar } from "./bar"
type InlineProps = {
icon: string
@@ -31,6 +32,8 @@ export function Suggest(props: {
part: MessageToolPart
InlineTool: (props: InlineProps) => JSX.Element
BlockTool: (props: BlockProps) => JSX.Element
pendingRequest?: SuggestionRequest
inputFocused?: () => boolean
}) {
const { theme } = useTheme()
const accepted = createMemo(() => props.metadata.accepted)
@@ -54,6 +57,10 @@ export function Suggest(props: {
})
}
if (props.pendingRequest) {
return <SuggestBar request={props.pendingRequest} inputFocused={props.inputFocused} />
}
return props.InlineTool({
icon: "→",
pending: "Suggesting next step...",