Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/terminal-title-status.md
Original file line number Diff line number Diff line change
@@ -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.
120 changes: 76 additions & 44 deletions packages/kilo-console/src/routes/config/CliNotificationsRoute.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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())}
/>
<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())}
/>
<small>Use a value from 0 to 1. The docs example uses 0.4.</small>
</label>
</div>
</Card>
<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>
)
}
9 changes: 8 additions & 1 deletion packages/kilo-console/src/routes/config/state/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down Expand Up @@ -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) {
Expand All @@ -228,6 +232,7 @@ export function useTuiNotificationSettings() {
}

ctx.tui({
title_icon: icon(),
attention: {
enabled: alert(),
notifications: notify(),
Expand All @@ -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,
}
Expand Down
9 changes: 6 additions & 3 deletions packages/kilo-console/src/styles/cli-ui.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
37 changes: 15 additions & 22 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -349,6 +349,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
renderer.clearSelection()
}
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
const [done, setDone] = createSignal<Record<string, true>>({}) // kilocode_change
const [pasteSummaryEnabled, setPasteSummaryEnabled] = createSignal(
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary),
)
Expand All @@ -364,30 +365,22 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {

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
})

Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/cli/cmd/tui/config/tui-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>>

Expand Down Expand Up @@ -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",
}),
Expand Down
64 changes: 56 additions & 8 deletions packages/opencode/src/kilocode/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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<typeof import("@tui/context/route").useRoute>,
base: string,
): string | undefined {
if (route.data.type === "kiloclaw") return `${base} | KiloClaw`
return undefined
export function getTerminalTitle(input: {
route: ReturnType<typeof import("@tui/context/route").useRoute>
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", 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",
}
}
}

// ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand Down
Loading
Loading