refactor: made this change configurable

This commit is contained in:
Catriel Müller
2026-06-17 09:24:00 -03:00
parent 1b25a4363d
commit 7b97e4624c
18 changed files with 223 additions and 82 deletions
+3 -2
View File
@@ -1,5 +1,6 @@
---
"@kilocode/cli": patch
"@kilocode/cli": minor
"@kilocode/sdk": minor
---
Show terminal title status indicators when sessions are working, need attention, or have finished.
Add opt-in Unicode or emoji terminal title indicators for sessions that are working, need attention, or have finished.
@@ -1,7 +1,14 @@
import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
import { CustomSelect, type SelectOption } from "../../components/CustomSelect"
import { ConfigPage, ConfigTag as Tag } from "./ConfigPage"
import { useTuiNotificationSettings } from "./state/ui"
import { type TitleIcon, useTuiNotificationSettings } from "./state/ui"
const icons = [
{ value: "none", label: "None" },
{ value: "unicode", label: "Unicode" },
{ value: "emojis", label: "Emojis" },
] satisfies SelectOption<TitleIcon>[]
function Toggle(props: {
label: string
@@ -34,57 +41,82 @@ export function CliNotificationsRoute() {
return (
<ConfigPage
title="CLI Notifications"
description="Configure TUI attention alerts, desktop notifications, and sound defaults."
description="Configure terminal title indicators, TUI attention alerts, desktop notifications, and sound defaults."
actions={
<Button variant="primary" disabled={Boolean(state.ctx.saving()) || !state.dirty()} onClick={state.save}>
Save
</Button>
}
>
<Card class="ui-card" padding={0}>
<header class="ui-card-header">
<div>
<h2>Attention</h2>
<p>Control when the TUI asks for attention and how it notifies you.</p>
<div class="ui-settings">
<Card class="ui-card" padding={0}>
<header class="ui-card-header">
<div>
<h2>Terminal title</h2>
<p>Choose how session status appears in the terminal tab title.</p>
</div>
</header>
<div class="ui-form">
<div class="ui-field">
<span>Title Icon</span>
<CustomSelect
class="title-icon-select"
label="Title Icon"
value={state.icon()}
options={icons}
disabled={Boolean(state.ctx.saving())}
onSelect={state.setIcon}
/>
<small>None hides status icons. Unicode and Emojis show working, attention, and finished states.</small>
</div>
</div>
</header>
<div class="ui-form attention-form">
<Toggle
label="Attention alerts"
description="Turn on TUI attention events."
checked={state.alert()}
disabled={Boolean(state.ctx.saving())}
onChange={() => state.setAlert(!state.alert())}
/>
<Toggle
label="Desktop notifications"
description="Show desktop notifications when attention alerts fire."
checked={state.notify()}
disabled={Boolean(state.ctx.saving()) || !state.alert()}
onChange={() => state.setNotify(!state.notify())}
/>
<Toggle
label="Sound"
description="Play an attention sound when alerts fire."
checked={state.sound()}
disabled={Boolean(state.ctx.saving()) || !state.alert()}
onChange={() => state.setSound(!state.sound())}
/>
<label class="ui-field">
<span>Volume</span>
<input
type="number"
min="0"
max="1"
step="0.05"
value={state.volume()}
disabled={!state.alert()}
onInput={(event) => state.setVolume(event.currentTarget.value)}
</Card>
<Card class="ui-card" padding={0}>
<header class="ui-card-header">
<div>
<h2>Attention</h2>
<p>Control when the TUI asks for attention and how it notifies you.</p>
</div>
</header>
<div class="ui-form attention-form">
<Toggle
label="Attention alerts"
description="Turn on TUI attention events."
checked={state.alert()}
disabled={Boolean(state.ctx.saving())}
onChange={() => state.setAlert(!state.alert())}
/>
<small>Use a value from 0 to 1. The docs example uses 0.4.</small>
</label>
</div>
</Card>
<Toggle
label="Desktop notifications"
description="Show desktop notifications when attention alerts fire."
checked={state.notify()}
disabled={Boolean(state.ctx.saving()) || !state.alert()}
onChange={() => state.setNotify(!state.notify())}
/>
<Toggle
label="Sound"
description="Play an attention sound when alerts fire."
checked={state.sound()}
disabled={Boolean(state.ctx.saving()) || !state.alert()}
onChange={() => state.setSound(!state.sound())}
/>
<label class="ui-field">
<span>Volume</span>
<input
type="number"
min="0"
max="1"
step="0.05"
value={state.volume()}
disabled={!state.alert()}
onInput={(event) => state.setVolume(event.currentTarget.value)}
/>
<small>Use a value from 0 to 1. The docs example uses 0.4.</small>
</label>
</div>
</Card>
</div>
</ConfigPage>
)
}
@@ -10,6 +10,7 @@ export type Theme = {
}
type Diff = "auto" | "stacked"
export type TitleIcon = NonNullable<TuiPatch["title_icon"]>
const fallback = ["#0c0a09", "#fafaf9", "#f9f76f", "#a6a09b", "#3794ff", "#44403b"]
@@ -204,15 +205,18 @@ export function useTuiNotificationSettings() {
const [notify, setNotify] = createSignal(true)
const [sound, setSound] = createSignal(true)
const [volume, setVolume] = createSignal("0.4")
const [icon, setIcon] = createSignal<TitleIcon>("none")
const [dirty, setDirty] = createSignal(false)
createEffect(() => {
if (dirty()) return
const cfg = ctx.data()?.tui.attention
const tui = ctx.data()?.tui
const cfg = tui?.attention
setAlert(bool(cfg?.enabled, false))
setNotify(bool(cfg?.notifications, true))
setSound(bool(cfg?.sound, true))
setVolume(String(cfg?.volume ?? 0.4))
setIcon(tui?.title_icon ?? "none")
})
function change(run: () => void) {
@@ -228,6 +232,7 @@ export function useTuiNotificationSettings() {
}
ctx.tui({
title_icon: icon(),
attention: {
enabled: alert(),
notifications: notify(),
@@ -248,6 +253,8 @@ export function useTuiNotificationSettings() {
setSound: (value: boolean) => change(() => setSound(value)),
volume,
setVolume: (value: string) => change(() => setVolume(value)),
icon,
setIcon: (value: TitleIcon) => change(() => setIcon(value)),
dirty,
save,
}
+6 -3
View File
@@ -8,17 +8,20 @@
background: var(--card);
}
.kilo-console .ui-card:has(.console-diff-select[open]) {
.kilo-console .ui-card:has(.console-diff-select[open]),
.kilo-console .ui-card:has(.title-icon-select[open]) {
position: relative;
z-index: 40;
overflow: visible;
}
.kilo-console .console-diff-select[open] {
.kilo-console .console-diff-select[open],
.kilo-console .title-icon-select[open] {
z-index: 41;
}
.kilo-console .console-diff-select .models-select-menu {
.kilo-console .console-diff-select .models-select-menu,
.kilo-console .title-icon-select .models-select-menu {
z-index: 42;
}
@@ -371,6 +371,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
base: titleDefault,
sync,
done: untrack(done),
icon: tuiConfig.title_icon,
})
if (kiloTitle) {
const id = kiloTitle.id
@@ -4,6 +4,7 @@ import { Schema } from "effect"
import { isRecord } from "@/util/record"
import { Filesystem } from "@/util/filesystem"
import { TuiAttentionSoundNames, type TuiAttentionSoundName } from "@kilocode/plugin/tui"
import { KiloTitleIcon } from "@/kilocode/cli/cmd/tui/title-icon" // kilocode_change
export type TuiAttentionSoundPaths = Partial<Record<TuiAttentionSoundName, string>>
@@ -69,6 +70,7 @@ export const TuiInfo = Schema.Struct({
plugin_enabled: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
leader_timeout: Schema.optional(KeymapLeaderTimeout),
attention: Schema.optional(Attention),
title_icon: Schema.optional(KiloTitleIcon.Value), // kilocode_change
scroll_speed: Schema.optional(ScrollSpeed).annotate({
description: "TUI scroll speed",
}),
@@ -25,6 +25,7 @@ import { initializeTUIDependencies } from "@kilocode/kilo-gateway/tui"
import { DialogProcessList } from "@/kilocode/cli/cmd/tui/component/dialog-process-list"
import { useIndexingWarnings } from "@/kilocode/cli/cmd/tui/indexing-warning"
import { KiloTerminalTitle } from "./terminal-title"
import type { KiloTitleIcon } from "./title-icon"
import { Session as SessionApi } from "@/session/session"
// Re-export so upstream can render the route without importing directly
@@ -122,10 +123,11 @@ export function getTerminalTitle(input: {
base: string
sync: ReturnType<typeof useSync>
done: Record<string, true>
icon?: KiloTitleIcon.Value
}): KiloTerminalTitle.Result | undefined {
if (input.route.data.type === "home") {
return {
title: KiloTerminalTitle.format({ base: input.base, indicator: "none" }),
title: KiloTerminalTitle.format({ base: input.base, indicator: "none", icon: input.icon }),
active: false,
indicator: "none",
}
@@ -137,18 +139,24 @@ export function getTerminalTitle(input: {
id: input.route.data.sessionID,
data: input.sync.data,
done: input.done,
icon: input.icon,
})
const session = input.sync.session.get(input.route.data.sessionID)
const title = !session || SessionApi.isDefaultTitle(session.title) ? undefined : session.title
return {
...state,
title: KiloTerminalTitle.format({ base: input.base, title, indicator: state.indicator }),
title: KiloTerminalTitle.format({ base: input.base, title, indicator: state.indicator, icon: input.icon }),
}
}
if (input.route.data.type === "plugin") {
return {
title: KiloTerminalTitle.format({ base: input.base, title: input.route.data.id, indicator: "none" }),
title: KiloTerminalTitle.format({
base: input.base,
title: input.route.data.id,
indicator: "none",
icon: input.icon,
}),
active: false,
indicator: "none",
}
@@ -156,7 +164,7 @@ export function getTerminalTitle(input: {
if (input.route.data.type === "kiloclaw") {
return {
title: KiloTerminalTitle.format({ base: input.base, title: "KiloClaw", indicator: "none" }),
title: KiloTerminalTitle.format({ base: input.base, title: "KiloClaw", indicator: "none", icon: input.icon }),
active: false,
indicator: "none",
}
@@ -11,6 +11,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { TuiKeybind } from "@/cli/cmd/tui/config/keybind"
import { KeymapLeaderTimeoutDefault } from "@/cli/cmd/tui/config/tui-schema"
import { createBindingLookup } from "@opentui/keymap/extras"
import { KiloTitleIcon } from "@/kilocode/cli/cmd/tui/title-icon"
export type SetTuiConfig = (next: TuiConfig.Info) => void
@@ -25,6 +26,7 @@ export namespace KiloTuiConfig {
const keybinds = TuiKeybind.parse(next.keybinds ?? {})
const config: TuiConfig.Resolved = {
...next,
title_icon: next.title_icon ?? KiloTitleIcon.Default,
attention: {
enabled: next.attention?.enabled ?? false,
notifications: next.attention?.notifications ?? true,
@@ -1,3 +1,5 @@
import { KiloTitleIcon } from "./title-icon"
type Session = {
id: string
title: string
@@ -44,28 +46,48 @@ export namespace KiloTerminalTitle {
indicator: Indicator
}
const icon = {
none: "",
working: "",
attention: "",
finished: "",
} satisfies Record<Indicator, string>
const icons = {
none: {
none: "",
working: "",
attention: "",
finished: "",
},
unicode: {
none: "",
working: "◔",
attention: "⚠",
finished: "✓",
},
emojis: {
none: "",
working: "💭",
attention: "🔶",
finished: "✅",
},
} satisfies Record<KiloTitleIcon.Value, Record<Indicator, string>>
export function format(input: { base: string; title?: string; indicator: Indicator }) {
export function format(input: { base: string; title?: string; indicator: Indicator; icon?: KiloTitleIcon.Value }) {
const text = input.title ? `${input.base} | ${truncate(input.title)}` : input.base
const prefix = icon[input.indicator]
const prefix = icons[input.icon ?? KiloTitleIcon.Default][input.indicator]
if (!prefix) return text
return `${prefix} ${text}`
}
export function session(input: { base: string; id: string; data: Data; done: Record<string, true> }): Result {
export function session(input: {
base: string
id: string
data: Data
done: Record<string, true>
icon?: KiloTitleIcon.Value
}): Result {
const info = input.data.session.find((item) => item.id === input.id)
const id = root(input.data.session, input.id)
const ids = family(input.data.session, id)
const indicator = state({ data: input.data, ids, done: input.done[id] === true })
return {
title: format({ base: input.base, title: info?.title, indicator }),
title: format({ base: input.base, title: info?.title, indicator, icon: input.icon }),
id,
active: indicator === "working" || indicator === "attention",
indicator,
@@ -0,0 +1,9 @@
import { Schema } from "effect"
export namespace KiloTitleIcon {
export const Value = Schema.Literals(["none", "unicode", "emojis"]).annotate({
description: "Status icon style shown in terminal titles",
})
export type Value = Schema.Schema.Type<typeof Value>
export const Default = "none" satisfies Value
}
@@ -1,6 +1,7 @@
import { Config } from "@/config/config"
import { ConfigPlugin } from "@/config/plugin"
import { KilocodeKeybinds } from "@/kilocode/tui/keybinds"
import { KiloTitleIcon } from "@/kilocode/cli/cmd/tui/title-icon"
import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization"
import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context"
import {
@@ -108,6 +109,7 @@ const TuiConfigShape = {
keybinds: Schema.optional(Schema.Record(Schema.String, Schema.String)),
plugin: Schema.optional(Schema.Array(ConfigPlugin.Spec)),
plugin_enabled: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
title_icon: Schema.optional(KiloTitleIcon.Value),
scroll_speed: Schema.optional(Schema.Number),
scroll_acceleration: Schema.optional(Schema.Struct({ enabled: Schema.Boolean })),
diff_style: Schema.optional(Schema.Literals(["auto", "stacked"])),
@@ -9,6 +9,7 @@ import { createBindingLookup } from "@opentui/keymap/extras"
import { TuiKeybind } from "@/cli/cmd/tui/config/keybind"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { KiloTuiConfig } from "@/kilocode/cli/cmd/tui/context/tui-config"
import { KiloTerminalTitle } from "@/kilocode/cli/cmd/tui/terminal-title"
function cfg(input: Partial<TuiConfig.Info>): TuiConfig.Info {
return input as TuiConfig.Info
@@ -18,6 +19,7 @@ function resolve(input: TuiConfig.Info): TuiConfig.Resolved {
const keybinds = TuiKeybind.parse(input.keybinds ?? {})
return {
...input,
title_icon: input.title_icon ?? "none",
attention: {
enabled: input.attention?.enabled ?? false,
notifications: input.attention?.notifications ?? true,
@@ -40,31 +42,52 @@ describe("KiloTuiConfig.makeStore", () => {
const exits: Array<string | undefined> = []
const themes: Array<string | undefined> = []
const icons: Array<string | undefined> = []
const titles: string[] = []
let dispose!: () => void
createRoot((d) => {
dispose = d
createEffect(() => exits.push(store.config.keybinds.get("app.exit")[0]?.key as string | undefined))
createEffect(() => themes.push(store.config.theme))
createEffect(() => icons.push(store.config.title_icon))
createEffect(() =>
titles.push(
KiloTerminalTitle.format({ base: "Kilo CLI", indicator: "working", icon: store.config.title_icon }),
),
)
})
// Initial tracked reads.
expect(exits).toEqual(["ctrl+c"])
expect(themes).toEqual(["kilo"])
expect(icons).toEqual(["none"])
expect(titles).toEqual(["Kilo CLI"])
store.set(cfg({ keybinds: { app_exit: "ctrl+q", leader: "ctrl+x" }, theme: "nord" }))
store.set(cfg({ keybinds: { app_exit: "ctrl+q", leader: "ctrl+x" }, theme: "nord", title_icon: "emojis" }))
// Direct store reads reflect the update synchronously.
expect(store.config.keybinds.get("app.exit")[0]?.key).toBe("ctrl+q")
expect(store.config.keybinds.get("leader")[0]?.key).toBe("ctrl+x")
expect(store.config.theme).toBe("nord")
expect(store.config.title_icon).toBe("emojis")
// Tracked reactive reads re-ran with the new values (the hot-reload contract).
expect(exits).toEqual(["ctrl+c", "ctrl+q"])
expect(themes).toEqual(["kilo", "nord"])
expect(icons).toEqual(["none", "emojis"])
expect(titles).toEqual(["Kilo CLI", "💭 Kilo CLI"])
dispose()
})
test("set() restores the default title icon when the setting is removed", () => {
const store = KiloTuiConfig.makeStore(resolve(cfg({ title_icon: "unicode" })))
store.set(cfg({}))
expect(store.config.title_icon).toBe("none")
})
test("set() does not re-notify a tracked read when its value is unchanged", () => {
const store = KiloTuiConfig.makeStore(resolve(cfg({ keybinds: { app_exit: "ctrl+c" }, theme: "kilo" })))
@@ -74,15 +74,16 @@ describe("TUI config routes", () => {
"content-type": "application/json",
"x-kilo-directory": tmp.path,
},
body: JSON.stringify({ theme: "nord" }),
body: JSON.stringify({ theme: "nord", title_icon: "emojis" }),
})
expect(response.status).toBe(200)
const body = (await response.json()) as { theme?: string }
const body = (await response.json()) as { theme?: string; title_icon?: string }
expect(body.theme).toBe("nord")
expect(body.title_icon).toBe("emojis")
const saved = await Bun.file(path.join(tmp.path, ".kilo", "tui.json")).json()
expect(saved).toEqual({ theme: "nord" })
expect(saved).toEqual({ theme: "nord", title_icon: "emojis" })
})
test("patches attention config without dropping advanced notification settings", async () => {
@@ -18,26 +18,26 @@ function data(input: Partial<KiloTerminalTitle.Data> = {}): KiloTerminalTitle.Da
}
describe("KiloTerminalTitle", () => {
test("format_noIndicator_returnsBaseTitle", () => {
expect(KiloTerminalTitle.format({ base, indicator: "none" })).toBe("Kilo CLI")
})
test("format_workingPrefix_prependsThinkingIcon", () => {
test("format_noneStyle_hidesStatusIcon", () => {
expect(KiloTerminalTitle.format({ base, title: "Build status", indicator: "working" })).toBe(
"Kilo CLI | Build status",
"Kilo CLI | Build status",
)
})
test("format_attentionPrefix_prependsWarningIcon", () => {
expect(KiloTerminalTitle.format({ base, title: "Build status", indicator: "attention" })).toBe(
"⚠ Kilo CLI | Build status",
)
test("format_noIndicator_returnsBaseTitle", () => {
expect(KiloTerminalTitle.format({ base, indicator: "none", icon: "unicode" })).toBe("Kilo CLI")
})
test("format_finishedPrefix_prependsCheckIcon", () => {
expect(KiloTerminalTitle.format({ base, title: "Build status", indicator: "finished" })).toBe(
"✓ Kilo CLI | Build status",
)
test("format_unicodeStyle_usesUnicodeIcons", () => {
expect(KiloTerminalTitle.format({ base, indicator: "working", icon: "unicode" })).toBe("◔ Kilo CLI")
expect(KiloTerminalTitle.format({ base, indicator: "attention", icon: "unicode" })).toBe("⚠ Kilo CLI")
expect(KiloTerminalTitle.format({ base, indicator: "finished", icon: "unicode" })).toBe("✓ Kilo CLI")
})
test("format_emojiStyle_usesEmojiIcons", () => {
expect(KiloTerminalTitle.format({ base, indicator: "working", icon: "emojis" })).toBe("💭 Kilo CLI")
expect(KiloTerminalTitle.format({ base, indicator: "attention", icon: "emojis" })).toBe("🔶 Kilo CLI")
expect(KiloTerminalTitle.format({ base, indicator: "finished", icon: "emojis" })).toBe("✅ Kilo CLI")
})
test("format_longSessionTitle_truncatesToExistingLimit", () => {
@@ -46,6 +46,7 @@ describe("KiloTerminalTitle", () => {
base,
title: "12345678901234567890123456789012345678901234567890",
indicator: "working",
icon: "unicode",
}),
).toBe("◔ Kilo CLI | 1234567890123456789012345678901234567...")
})
@@ -68,6 +69,7 @@ describe("KiloTerminalTitle", () => {
id: "parent",
data: data({ session_status: { parent: { type: "busy" } } }),
done: {},
icon: "unicode",
}),
).toEqual({ title: "◔ Kilo CLI | Build status", id: "parent", active: true, indicator: "working" })
})
@@ -11,6 +11,12 @@ describe("terminal title done tracking", () => {
expect(content).toContain("done: untrack(done)")
})
test("app.tsx reads the reactive title icon setting", () => {
const content = fs.readFileSync(APP_FILE, "utf-8")
expect(content).toContain("icon: tuiConfig.title_icon")
})
test("app.tsx untracks the done guard before setDone", () => {
const content = fs.readFileSync(APP_FILE, "utf-8")
+2
View File
@@ -5267,6 +5267,7 @@ export class Config3 extends HeyApiClient {
plugin_enabled?: {
[key: string]: boolean
}
title_icon?: "none" | "unicode" | "emojis"
scroll_speed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
scroll_acceleration?: {
enabled: boolean
@@ -5295,6 +5296,7 @@ export class Config3 extends HeyApiClient {
{ in: "body", key: "keybinds" },
{ in: "body", key: "plugin" },
{ in: "body", key: "plugin_enabled" },
{ in: "body", key: "title_icon" },
{ in: "body", key: "scroll_speed" },
{ in: "body", key: "scroll_acceleration" },
{ in: "body", key: "diff_style" },
+8
View File
@@ -2243,6 +2243,10 @@ export type TuiConfigGetResponse = {
plugin_enabled?: {
[key: string]: boolean
}
/**
* Status icon style shown in terminal titles
*/
title_icon?: "none" | "unicode" | "emojis"
scroll_speed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
scroll_acceleration?: {
enabled: boolean
@@ -8644,6 +8648,10 @@ export type TuiConfigUpdateData = {
plugin_enabled?: {
[key: string]: boolean
}
/**
* Status icon style shown in terminal titles
*/
title_icon?: "none" | "unicode" | "emojis"
scroll_speed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
scroll_acceleration?: {
enabled: boolean
+10
View File
@@ -10912,6 +10912,11 @@
"type": "boolean"
}
},
"title_icon": {
"type": "string",
"enum": ["none", "unicode", "emojis"],
"description": "Status icon style shown in terminal titles"
},
"scroll_speed": {
"anyOf": [
{
@@ -21217,6 +21222,11 @@
"type": "boolean"
}
},
"title_icon": {
"type": "string",
"enum": ["none", "unicode", "emojis"],
"description": "Status icon style shown in terminal titles"
},
"scroll_speed": {
"anyOf": [
{