mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
fix(cli): consolidate TUI notifications
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Prevent duplicate CLI attention alerts and route Kilo prompts through the configurable notification system.
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user