fix(cli): consolidate TUI notifications

This commit is contained in:
marius-kilocode
2026-06-11 11:52:00 +02:00
parent 5bc8df843a
commit 8a727084ae
8 changed files with 274 additions and 58 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Prevent duplicate CLI attention alerts and route Kilo prompts through the configurable notification system.
-11
View File
@@ -776,17 +776,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
dialog.clear()
},
},
// kilocode_change start - expose the existing terminal notification preference in the command palette
{
name: "app.toggle.notifications",
title: kv.get("bell_enabled", true) ? "Disable notifications" : "Enable notifications",
category: "System",
run: () => {
kv.set("bell_enabled", !kv.get("bell_enabled", true))
dialog.clear()
},
},
// kilocode_change end
{
name: "app.toggle.animations",
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
@@ -3,6 +3,7 @@ import HomeTips from "../feature-plugins/home/tips"
// kilocode_change start
import HomeNews from "@/kilocode/plugins/home-news"
import HomeOnboarding from "@/kilocode/plugins/home-onboarding"
import KiloAttention from "@/kilocode/plugins/attention"
import KiloHomeFooter from "@/kilocode/plugins/home-footer"
import KiloSidebarFooter from "@/kilocode/plugins/sidebar-footer"
import KiloSidebarBackgroundProcesses from "@/kilocode/plugins/sidebar-background-processes"
@@ -33,6 +34,7 @@ export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalE
return [
HomeNews, // kilocode_change
HomeOnboarding, // kilocode_change
KiloAttention, // kilocode_change
KiloHomeFooter, // kilocode_change
KiloSidebarFooter, // kilocode_change
KiloSidebarBackgroundProcesses, // kilocode_change
@@ -97,7 +97,6 @@ import { splitDiffHunks } from "@/kilocode/tui/diff"
import { session as banner } from "@/kilocode/cli/logo"
import { formatMarkdownTables } from "../../util/markdown"
import { bell } from "@/kilocode/bell"
import { submitFeedback } from "@/kilocode/cli/cmd/tui/feedback"
// kilocode_change end
import { getScrollAcceleration } from "../../util/scroll"
@@ -258,6 +257,7 @@ export function Session() {
blockingSuggestions().length > 0 ||
network().length > 0,
)
// kilocode_change end
const pending = createMemo(() => {
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
@@ -267,45 +267,6 @@ export function Session() {
return messages().findLast((x) => x.role === "assistant")
})
createEffect(
on(
() => [route.sessionID, sync.data.session_status?.[route.sessionID]?.type] as const,
([id, type], prev) => {
if (!prev || prev[0] !== id) return
if (prev[1] && prev[1] !== "idle" && type === "idle" && bellEnabled()) bell()
},
),
)
createEffect(
on(
() => [route.sessionID, permissions().length] as const,
([id, len], prev) => {
if (!prev || prev[0] !== id) return
if (len > prev[1] && bellEnabled()) bell()
},
),
)
createEffect(
on(
() => [route.sessionID, questions().length] as const,
([id, len], prev) => {
if (!prev || prev[0] !== id) return
if (len > prev[1] && bellEnabled()) bell()
},
),
)
createEffect(
on(
() => [route.sessionID, suggestions().length + network().length] as const,
([id, len], prev) => {
if (!prev || prev[0] !== id) return
if (len > prev[1] && bellEnabled()) bell()
},
),
)
// kilocode_change end
const dimensions = useTerminalDimensions()
const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto")
const [sidebarOpen, setSidebarOpen] = createSignal(false)
@@ -317,7 +278,6 @@ export function Session() {
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
const [_animationsEnabled, _setAnimationsEnabled] = kv.signal("animations_enabled", true)
const [bellEnabled, _setBellEnabled] = kv.signal("bell_enabled", true) // kilocode_change - terminal bell toggle (toggled via kv.set in app.tsx command)
const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false)
const wide = createMemo(() => dimensions().width > 120)
-6
View File
@@ -1,6 +0,0 @@
// kilocode_change - new file
export function bell() {
if (process.stdout.isTTY) {
process.stdout.write("\x07")
}
}
@@ -0,0 +1,53 @@
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@kilocode/plugin/tui"
const id = "internal:kilo-attention"
function notify(api: TuiPluginApi, sessionID: string, message: string) {
const session = api.state.session.get(sessionID)
void api.attention.notify({
title: session?.title,
message,
notification: session?.parentID ? false : { when: "blurred" },
sound: { name: "question", when: "always" },
})
}
const tui: TuiPlugin = async (api) => {
const suggestions = new Set<string>()
const network = new Set<string>()
api.event.on("suggestion.shown", (event) => {
if (suggestions.has(event.properties.id)) return
suggestions.add(event.properties.id)
notify(api, event.properties.sessionID, "Suggestion needs input")
})
api.event.on("suggestion.accepted", (event) => {
suggestions.delete(event.properties.requestID)
})
api.event.on("suggestion.dismissed", (event) => {
suggestions.delete(event.properties.requestID)
})
api.event.on("session.network.asked", (event) => {
if (network.has(event.properties.id)) return
network.add(event.properties.id)
notify(api, event.properties.sessionID, "Network connection needs input")
})
api.event.on("session.network.replied", (event) => {
network.delete(event.properties.requestID)
})
api.event.on("session.network.rejected", (event) => {
network.delete(event.properties.requestID)
})
}
const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
export default plugin
@@ -0,0 +1,166 @@
import { describe, expect, test } from "bun:test"
import Attention from "@/kilocode/plugins/attention"
import type { Event, Session, SessionNetworkWait, SuggestionRequest } from "@kilocode/sdk/v2"
import type { TuiAttentionNotifyInput } from "@kilocode/plugin/tui"
import { createTuiPluginApi } from "../../../../fixture/tui-plugin"
async function setup() {
const notifications: TuiAttentionNotifyInput[] = []
const handlers = new Map<Event["type"], ((event: Event) => void)[]>()
const session = (id: string, title: string, parentID?: string): Session => ({
id,
title,
slug: id,
projectID: "project",
directory: "/workspace",
...(parentID && { parentID }),
version: "0.0.0-test",
time: { created: 0, updated: 0 },
})
const sessions: Record<string, Session> = {
session: session("session", "Demo session"),
subagent: session("subagent", "Subagent session", "session"),
}
await Attention.tui(
createTuiPluginApi({
attention: {
async notify(input) {
notifications.push(input)
return { ok: true, notification: true, sound: true }
},
},
event: {
on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => {
const list = handlers.get(type) ?? []
const wrapped = handler as (event: Event) => void
list.push(wrapped)
handlers.set(type, list)
return () => {
handlers.set(
type,
(handlers.get(type) ?? []).filter((item) => item !== wrapped),
)
}
},
},
state: {
session: {
get: (sessionID: string) => sessions[sessionID],
},
},
}),
undefined,
{} as never,
)
return {
notifications,
emit(event: Event) {
for (const handler of handlers.get(event.type) ?? []) handler(event)
},
}
}
function suggestion(id: string, sessionID = "session"): SuggestionRequest {
return {
id,
sessionID,
text: "Review the changes",
actions: [{ label: "Review", prompt: "/local-review-uncommitted" }],
}
}
function wait(id: string, sessionID = "session"): SessionNetworkWait {
return {
id,
sessionID,
message: "Network connection failed",
restored: false,
time: { created: 0 },
}
}
const suggestionNotification: TuiAttentionNotifyInput = {
title: "Demo session",
message: "Suggestion needs input",
notification: { when: "blurred" },
sound: { name: "question", when: "always" },
}
const networkNotification: TuiAttentionNotifyInput = {
title: "Demo session",
message: "Network connection needs input",
notification: { when: "blurred" },
sound: { name: "question", when: "always" },
}
describe("Kilo attention TUI plugin", () => {
test("requests upstream attention for suggestions and network prompts", async () => {
const harness = await setup()
harness.emit({ id: "event-1", type: "suggestion.shown", properties: suggestion("suggestion-1") })
harness.emit({ id: "event-2", type: "session.network.asked", properties: wait("network-1") })
expect(harness.notifications).toEqual([suggestionNotification, networkNotification])
})
test("dedupes pending prompts until they are resolved", async () => {
const harness = await setup()
harness.emit({ id: "event-1", type: "suggestion.shown", properties: suggestion("suggestion-1") })
harness.emit({ id: "event-2", type: "suggestion.shown", properties: suggestion("suggestion-1") })
harness.emit({
id: "event-3",
type: "suggestion.dismissed",
properties: { sessionID: "session", requestID: "suggestion-1" },
})
harness.emit({ id: "event-4", type: "suggestion.shown", properties: suggestion("suggestion-1") })
harness.emit({ id: "event-5", type: "session.network.asked", properties: wait("network-1") })
harness.emit({ id: "event-6", type: "session.network.asked", properties: wait("network-1") })
harness.emit({
id: "event-7",
type: "session.network.replied",
properties: { sessionID: "session", requestID: "network-1" },
})
harness.emit({ id: "event-8", type: "session.network.asked", properties: wait("network-1") })
expect(harness.notifications).toEqual([
suggestionNotification,
suggestionNotification,
networkNotification,
networkNotification,
])
})
test("uses sound-only attention for subagent prompts", async () => {
const harness = await setup()
harness.emit({
id: "event-1",
type: "suggestion.shown",
properties: suggestion("suggestion-1", "subagent"),
})
harness.emit({
id: "event-2",
type: "session.network.asked",
properties: wait("network-1", "subagent"),
})
expect(harness.notifications).toEqual([
{
title: "Subagent session",
message: "Suggestion needs input",
notification: false,
sound: { name: "question", when: "always" },
},
{
title: "Subagent session",
message: "Network connection needs input",
notification: false,
sound: { name: "question", when: "always" },
},
])
})
})
@@ -85,6 +85,53 @@ describe("TUI config routes", () => {
expect(saved).toEqual({ theme: "nord" })
})
test("patches attention config without dropping advanced notification settings", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const cfg = path.join(dir, ".kilo")
await fs.mkdir(cfg, { recursive: true })
await Bun.write(
path.join(cfg, "tui.json"),
JSON.stringify(
{
attention: {
enabled: false,
sound_pack: "custom.pack",
sounds: { question: "./question.mp3" },
},
},
null,
2,
),
)
},
})
const response = await Server.Default().app.request("/tui/config?scope=project", {
method: "PATCH",
headers: {
"content-type": "application/json",
"x-kilo-directory": tmp.path,
},
body: JSON.stringify({
attention: { enabled: true, notifications: false, sound: true, volume: 0.25 },
}),
})
expect(response.status).toBe(200)
const saved = await Bun.file(path.join(tmp.path, ".kilo", "tui.json")).json()
expect(saved).toEqual({
attention: {
enabled: true,
notifications: false,
sound: true,
volume: 0.25,
sound_pack: "custom.pack",
sounds: { question: "./question.mp3" },
},
})
})
test("emits global.config.updated when patching TUI config so open TUIs hot-reload", async () => {
await using tmp = await tmpdir()