diff --git a/.changeset/terminal-title-status.md b/.changeset/terminal-title-status.md new file mode 100644 index 00000000000..4234657571b --- /dev/null +++ b/.changeset/terminal-title-status.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": minor +"@kilocode/sdk": minor +--- + +Add opt-in Unicode or emoji terminal title indicators for sessions that are working, need attention, or have finished. diff --git a/packages/kilo-console/src/routes/config/CliNotificationsRoute.tsx b/packages/kilo-console/src/routes/config/CliNotificationsRoute.tsx index e8621cffc9a..84b48020eba 100644 --- a/packages/kilo-console/src/routes/config/CliNotificationsRoute.tsx +++ b/packages/kilo-console/src/routes/config/CliNotificationsRoute.tsx @@ -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[] function Toggle(props: { label: string @@ -34,57 +41,82 @@ export function CliNotificationsRoute() { return ( Save } > - -
-
-

Attention

-

Control when the TUI asks for attention and how it notifies you.

+
+ +
+
+

Terminal title

+

Choose how session status appears in the terminal tab title.

+
+
+
+
+ Title Icon + + None hides status icons. Unicode and Emojis show working, attention, and finished states. +
-
-
- state.setAlert(!state.alert())} - /> - state.setNotify(!state.notify())} - /> - state.setSound(!state.sound())} - /> -
+
+
) } diff --git a/packages/kilo-console/src/routes/config/state/ui.ts b/packages/kilo-console/src/routes/config/state/ui.ts index fa70ff062a0..7f94285bf86 100644 --- a/packages/kilo-console/src/routes/config/state/ui.ts +++ b/packages/kilo-console/src/routes/config/state/ui.ts @@ -10,6 +10,7 @@ export type Theme = { } type Diff = "auto" | "stacked" +export type TitleIcon = NonNullable 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("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, } diff --git a/packages/kilo-console/src/styles/cli-ui.css b/packages/kilo-console/src/styles/cli-ui.css index 89c066f8036..c32380c9b44 100644 --- a/packages/kilo-console/src/styles/cli-ui.css +++ b/packages/kilo-console/src/styles/cli-ui.css @@ -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; } diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 80b70b2e837..98c8f1f0cde 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -17,6 +17,7 @@ import { batch, Show, on, + untrack, // kilocode_change } from "solid-js" import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32" // kilocode_change import { Flag } from "@opencode-ai/core/flag/flag" @@ -52,7 +53,6 @@ import { DialogAlert } from "./ui/dialog-alert" import { DialogConfirm } from "./ui/dialog-confirm" import { ToastProvider, useToast } from "./ui/toast" import { ExitProvider, useExit } from "./context/exit" -import { Session as SessionApi } from "@/session/session" // kilocode_change start import { DialogSelect } from "./ui/dialog-select" import { Link } from "./ui/link" @@ -349,6 +349,7 @@ function App(props: { onSnapshot?: () => Promise }) { renderer.clearSelection() } const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true)) + const [done, setDone] = createSignal>({}) // kilocode_change const [pasteSummaryEnabled, setPasteSummaryEnabled] = createSignal( kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary), ) @@ -364,30 +365,22 @@ function App(props: { onSnapshot?: () => Promise }) { const titleDefault = KiloApp.APP_TITLE // kilocode_change - if (route.data.type === "home") { - renderer.setTerminalTitle(titleDefault) // kilocode_change - return - } - - if (route.data.type === "session") { - const session = sync.session.get(route.data.sessionID) - if (!session || SessionApi.isDefaultTitle(session.title)) { - renderer.setTerminalTitle(titleDefault) // kilocode_change - return + // kilocode_change start + const kiloTitle = KiloApp.getTerminalTitle({ + route, + base: titleDefault, + sync, + done: untrack(done), + icon: tuiConfig.title_icon, + }) + if (kiloTitle) { + const id = kiloTitle.id + if (id && kiloTitle.active && untrack(() => done()[id]) !== true) { + setDone((prev) => ({ ...prev, [id]: true })) } - - const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title - renderer.setTerminalTitle(`${titleDefault} | ${title}`) // kilocode_change + renderer.setTerminalTitle(kiloTitle.title) return } - - if (route.data.type === "plugin") { - renderer.setTerminalTitle(`${titleDefault} | ${route.data.id}`) // kilocode_change - } - - // kilocode_change start - const kiloTitle = KiloApp.getTerminalTitle(route, titleDefault) - if (kiloTitle) renderer.setTerminalTitle(kiloTitle) // kilocode_change end }) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts index 1eec1e7a68a..41864d80dec 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -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> @@ -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", }), diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx b/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx index 73d55db3a04..58cf34b2742 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx @@ -24,9 +24,13 @@ import { registerKiloCommands } from "@/kilocode/kilo-commands" 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 export { KiloClawView } from "@/kilocode/claw/view" +export { KiloTerminalTitle } from "./terminal-title" // Hot reload TUI-local settings (keybinds/theme/ui) when changed from the Kilo Console. // Called from the App body (below SDKProvider and the TuiConfig provider). @@ -112,15 +116,59 @@ export function useSessionEffects(deps: { // --------------------------------------------------------------------------- /** - * Returns the terminal title for kiloclaw routes. - * Returns undefined for other routes (caller should handle them). + * Returns the terminal title for supported TUI routes. */ -export function getTerminalTitle( - route: ReturnType, - base: string, -): string | undefined { - if (route.data.type === "kiloclaw") return `${base} | KiloClaw` - return undefined +export function getTerminalTitle(input: { + route: ReturnType + base: string + sync: ReturnType + done: Record + icon?: KiloTitleIcon.Value +}): KiloTerminalTitle.Result | undefined { + if (input.route.data.type === "home") { + return { + title: KiloTerminalTitle.format({ base: input.base, indicator: "none", icon: input.icon }), + active: false, + indicator: "none", + } + } + + if (input.route.data.type === "session") { + const state = KiloTerminalTitle.session({ + base: input.base, + 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, icon: input.icon }), + } + } + + if (input.route.data.type === "plugin") { + return { + title: KiloTerminalTitle.format({ + base: input.base, + title: input.route.data.id, + indicator: "none", + icon: input.icon, + }), + active: false, + indicator: "none", + } + } + + if (input.route.data.type === "kiloclaw") { + return { + title: KiloTerminalTitle.format({ base: input.base, title: "KiloClaw", indicator: "none", icon: input.icon }), + active: false, + indicator: "none", + } + } } // --------------------------------------------------------------------------- diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/context/tui-config.tsx b/packages/opencode/src/kilocode/cli/cmd/tui/context/tui-config.tsx index bb9ac401c62..654efa6d998 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/context/tui-config.tsx +++ b/packages/opencode/src/kilocode/cli/cmd/tui/context/tui-config.tsx @@ -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, diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/terminal-title.ts b/packages/opencode/src/kilocode/cli/cmd/tui/terminal-title.ts new file mode 100644 index 00000000000..8a3959ce86d --- /dev/null +++ b/packages/opencode/src/kilocode/cli/cmd/tui/terminal-title.ts @@ -0,0 +1,137 @@ +import { KiloTitleIcon } from "./title-icon" + +type Session = { + id: string + title: string + parentID?: string | null +} + +type Status = { + type: "idle" | "retry" | "busy" | "offline" +} + +type Message = { + id: string + role: string +} + +type Part = { + type: string + tool?: string + state?: { + status: string + } +} + +type Queue = Record + +export namespace KiloTerminalTitle { + export type Indicator = "none" | "working" | "attention" | "finished" + + export type Data = { + session: readonly Session[] + session_status: Record + permission: Queue + question: Queue + suggestion: Queue + network: Queue + message: Record + part: Record + } + + export type Result = { + title: string + id?: string + active: boolean + indicator: Indicator + } + + const icons = { + none: { + none: "", + working: "", + attention: "", + finished: "", + }, + unicode: { + none: "", + working: "◔", + attention: "⚠", + finished: "✓", + }, + emojis: { + none: "", + working: "💭", + attention: "🔶", + finished: "✅", + }, + } satisfies Record> + + 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 = 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 + 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, icon: input.icon }), + id, + active: indicator === "working" || indicator === "attention", + indicator, + } + } + + function truncate(title: string) { + if (title.length <= 40) return title + return title.slice(0, 37) + "..." + } + + function root(list: readonly Session[], id: string) { + return list.find((item) => item.id === id)?.parentID ?? id + } + + function family(list: readonly Session[], id: string) { + return list.filter((item) => item.id === id || item.parentID === id).map((item) => item.id) + } + + function state(input: { data: Data; ids: readonly string[]; done: boolean }): Indicator { + if (input.ids.some((id) => attention(input.data, id))) return "attention" + if (input.ids.some((id) => working(input.data.session_status[id]))) return "working" + if (input.done) return "finished" + return "none" + } + + function working(status: Status | undefined) { + return status?.type === "busy" || status?.type === "retry" + } + + function attention(data: Data, id: string) { + if (data.session_status[id]?.type === "offline") return true + if ((data.permission[id]?.length ?? 0) > 0) return true + if ((data.question[id]?.length ?? 0) > 0) return true + if ((data.suggestion[id]?.length ?? 0) > 0) return true + if ((data.network[id]?.length ?? 0) > 0) return true + return plan(data, id) + } + + function plan(data: Data, id: string) { + const msg = data.message[id]?.at(-1) + if (msg?.role !== "assistant") return false + return (data.part[msg.id] ?? []).some( + (part) => part.type === "tool" && part.tool === "plan_exit" && part.state?.status === "completed", + ) + } +} diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/title-icon.ts b/packages/opencode/src/kilocode/cli/cmd/tui/title-icon.ts new file mode 100644 index 00000000000..fc5f07b6162 --- /dev/null +++ b/packages/opencode/src/kilocode/cli/cmd/tui/title-icon.ts @@ -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 + export const Default = "none" satisfies Value +} diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/config-console.ts b/packages/opencode/src/kilocode/server/httpapi/groups/config-console.ts index 97713e967b9..be9dad2d42e 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/config-console.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/config-console.ts @@ -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"])), diff --git a/packages/opencode/test/kilocode/cli/cmd/tui/context/tui-config.test.ts b/packages/opencode/test/kilocode/cli/cmd/tui/context/tui-config.test.ts index 80f7c9d7dd3..a17222163a2 100644 --- a/packages/opencode/test/kilocode/cli/cmd/tui/context/tui-config.test.ts +++ b/packages/opencode/test/kilocode/cli/cmd/tui/context/tui-config.test.ts @@ -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 { 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 = [] const themes: Array = [] + const icons: Array = [] + 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" }))) diff --git a/packages/opencode/test/kilocode/server/tui-config.test.ts b/packages/opencode/test/kilocode/server/tui-config.test.ts index 638d2f6dd9d..2bba0a2f92d 100644 --- a/packages/opencode/test/kilocode/server/tui-config.test.ts +++ b/packages/opencode/test/kilocode/server/tui-config.test.ts @@ -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 () => { diff --git a/packages/opencode/test/kilocode/terminal-title.test.ts b/packages/opencode/test/kilocode/terminal-title.test.ts new file mode 100644 index 00000000000..8fef9835556 --- /dev/null +++ b/packages/opencode/test/kilocode/terminal-title.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, test } from "bun:test" +import { KiloTerminalTitle } from "../../src/kilocode/cli/cmd/tui/terminal-title" + +const base = "Kilo CLI" + +function data(input: Partial = {}): KiloTerminalTitle.Data { + return { + session: [{ id: "parent", title: "Build status" }], + session_status: {}, + permission: {}, + question: {}, + suggestion: {}, + network: {}, + message: {}, + part: {}, + ...input, + } +} + +describe("KiloTerminalTitle", () => { + test("format_noneStyle_hidesStatusIcon", () => { + expect(KiloTerminalTitle.format({ base, title: "Build status", indicator: "working" })).toBe( + "Kilo CLI | Build status", + ) + }) + + test("format_noIndicator_returnsBaseTitle", () => { + expect(KiloTerminalTitle.format({ base, indicator: "none", icon: "unicode" })).toBe("Kilo CLI") + }) + + 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", () => { + expect( + KiloTerminalTitle.format({ + base, + title: "12345678901234567890123456789012345678901234567890", + indicator: "working", + icon: "unicode", + }), + ).toBe("◔ Kilo CLI | 1234567890123456789012345678901234567...") + }) + + test("session_newIdleSession_hasNoIndicator", () => { + expect( + KiloTerminalTitle.session({ + base, + id: "parent", + data: data(), + done: {}, + }), + ).toEqual({ title: "Kilo CLI | Build status", id: "parent", active: false, indicator: "none" }) + }) + + test("session_busySession_isWorking", () => { + expect( + KiloTerminalTitle.session({ + base, + id: "parent", + data: data({ session_status: { parent: { type: "busy" } } }), + done: {}, + icon: "unicode", + }), + ).toEqual({ title: "◔ Kilo CLI | Build status", id: "parent", active: true, indicator: "working" }) + }) + + test("session_pendingPermission_overridesBusy", () => { + expect( + KiloTerminalTitle.session({ + base, + id: "parent", + data: data({ + session_status: { parent: { type: "busy" } }, + permission: { parent: [{}] }, + }), + done: {}, + }).indicator, + ).toBe("attention") + }) + + test("session_childQuestion_marksParentAttention", () => { + expect( + KiloTerminalTitle.session({ + base, + id: "parent", + data: data({ + session: [ + { id: "parent", title: "Build status" }, + { id: "child", title: "Child", parentID: "parent" }, + ], + question: { child: [{ blocking: true }] }, + }), + done: {}, + }).indicator, + ).toBe("attention") + }) + + test("session_latestAssistantPlanExit_marksAttention", () => { + expect( + KiloTerminalTitle.session({ + base, + id: "parent", + data: data({ + message: { parent: [{ id: "m1", role: "assistant" }] }, + part: { + m1: [{ type: "tool", tool: "plan_exit", state: { status: "completed" } }], + }, + }), + done: {}, + }).indicator, + ).toBe("attention") + }) + + test("session_latestUserAfterPlanExit_clearsPlanExitAttention", () => { + expect( + KiloTerminalTitle.session({ + base, + id: "parent", + data: data({ + message: { + parent: [ + { id: "m1", role: "assistant" }, + { id: "m2", role: "user" }, + ], + }, + part: { + m1: [{ type: "tool", tool: "plan_exit", state: { status: "completed" } }], + }, + }), + done: {}, + }).indicator, + ).toBe("none") + }) + + test("session_doneIdleSession_isFinished", () => { + expect( + KiloTerminalTitle.session({ + base, + id: "parent", + data: data(), + done: { parent: true }, + }).indicator, + ).toBe("finished") + }) +}) diff --git a/packages/opencode/test/kilocode/tui-terminal-title-reactivity.test.ts b/packages/opencode/test/kilocode/tui-terminal-title-reactivity.test.ts new file mode 100644 index 00000000000..161c4b73c8c --- /dev/null +++ b/packages/opencode/test/kilocode/tui-terminal-title-reactivity.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test" +import fs from "node:fs" +import path from "node:path" + +const APP_FILE = path.resolve(import.meta.dir, "../../src/cli/cmd/tui/app.tsx") + +describe("terminal title done tracking", () => { + test("app.tsx untracks the done snapshot passed to getTerminalTitle", () => { + const content = fs.readFileSync(APP_FILE, "utf-8") + + 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") + + expect(content).toContain("const id = kiloTitle.id") + expect(content).toContain("untrack(() => done()[id]) !== true") + }) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index dbf279c1093..a710dc2418f 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -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" }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b605d88a2cf..7f5e3319be0 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -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 diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 8243bf7d8e9..1fdd9ea41dc 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -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": [ {