Merge pull request #38 from Kilo-Org/feat/add-user-notifications

feat: Add Kilo notifications to home screen
This commit is contained in:
Suresh Kumar Sivasankaran
2026-01-27 19:03:55 +01:00
committed by GitHub
15 changed files with 404 additions and 2 deletions
@@ -0,0 +1,67 @@
// kilocode_change - new file
import { z } from "zod"
/**
* Kilo notification schema
*/
export const KilocodeNotificationSchema = z.object({
id: z.string(),
title: z.string(),
message: z.string(),
action: z
.object({
actionText: z.string(),
actionURL: z.string(),
})
.optional(),
showIn: z.array(z.string()).optional(),
})
export type KilocodeNotification = z.infer<typeof KilocodeNotificationSchema>
const NotificationsResponseSchema = z.object({
notifications: z.array(KilocodeNotificationSchema),
})
const NOTIFICATIONS_TIMEOUT_MS = 5000
/**
* Fetch notifications from Kilo API
*
* @param options - Configuration with token and optional organization ID
* @returns Array of notifications filtered for CLI display
*/
export async function fetchKilocodeNotifications(options: {
kilocodeToken?: string
kilocodeOrganizationId?: string
}): Promise<KilocodeNotification[]> {
const token = options.kilocodeToken
if (!token) return []
const url = "https://api.kilo.ai/api/users/notifications"
try {
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
signal: AbortSignal.timeout(NOTIFICATIONS_TIMEOUT_MS),
})
if (!response.ok) return []
const json = await response.json()
const result = NotificationsResponseSchema.safeParse(json)
if (!result.success) return []
// Filter to show notifications meant for CLI (or no specific target)
// Accept "cli", "extension", or no showIn field (matches old Kilo CLI behavior)
return result.data.notifications.filter(
({ showIn }) => !showIn || showIn.includes("cli") || showIn.includes("extension"),
)
} catch {
return []
}
}
+1
View File
@@ -33,6 +33,7 @@ export {
promptOrganizationSelection,
} from "./api/profile.js"
export { fetchKiloModels } from "./api/models.js"
export { fetchKilocodeNotifications, type KilocodeNotification } from "./api/notifications.js"
// ============================================================================
// Server Routes (optional - requires hono and OpenCode dependencies)
@@ -7,6 +7,7 @@
*/
import { fetchProfile, fetchBalance } from "../api/profile.js"
import { fetchKilocodeNotifications, KilocodeNotificationSchema } from "../api/notifications.js"
// Type definitions for OpenCode dependencies (injected at runtime)
type Hono = any
@@ -161,4 +162,38 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
return c.json(true)
},
)
.get(
"/notifications",
describeRoute({
summary: "Get Kilo notifications",
description: "Fetch notifications from Kilo Gateway for CLI display",
operationId: "kilo.notifications",
responses: {
200: {
description: "Notifications list",
content: {
"application/json": {
schema: resolver(z.array(KilocodeNotificationSchema)),
},
},
},
...errors(400, 401),
},
}),
async (c: any) => {
const auth = await Auth.get("kilo")
if (!auth) return c.json([])
const token = auth.type === "api" ? auth.key : auth.type === "oauth" ? auth.access : undefined
if (!token) return c.json([])
const organizationId = auth.type === "oauth" ? auth.accountId : undefined
const notifications = await fetchKilocodeNotifications({
kilocodeToken: token,
kilocodeOrganizationId: organizationId,
})
return c.json(notifications)
},
)
}
+6
View File
@@ -25,3 +25,9 @@ export { registerKiloCommands } from "./tui/commands/kilo-commands.js"
export { DialogKiloTeamSelect } from "./tui/components/dialog-kilo-team-select.js"
export { DialogKiloOrganization } from "./tui/components/dialog-kilo-organization.js"
export { KiloAutoMethod } from "./tui/components/dialog-kilo-auto-method.js"
export { KiloNews } from "./tui/components/kilo-news.js"
// ============================================================================
// Re-exported Types
// ============================================================================
export type { KilocodeNotification } from "./api/notifications.js"
@@ -0,0 +1,68 @@
// kilocode_change - new file
/**
* Kilo Notifications Dialog
*
* Displays all notifications from Kilo API in a scrollable dialog.
* Each notification shows title, message, and clickable action link.
*/
import { For } from "solid-js"
import { getTUIDependencies } from "../context.js"
import type { KilocodeNotification } from "../../api/notifications.js"
interface DialogKiloNotificationsProps {
notifications: KilocodeNotification[]
}
export function DialogKiloNotifications(props: DialogKiloNotificationsProps) {
const deps = getTUIDependencies()
const Link = deps.Link
const TextAttributes = deps.TextAttributes
const dialog = deps.useDialog()
const { theme } = deps.useTheme()
deps.useKeyboard((evt: any) => {
if (evt.name === "escape" || evt.name === "return") {
dialog.clear()
}
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
News
</text>
<text fg={theme.textMuted}>esc</text>
</box>
<scrollbox maxHeight={15} flexGrow={1}>
<box gap={2} paddingBottom={1}>
<For each={props.notifications}>
{(notification) => (
<box gap={0}>
<box flexDirection="row" gap={1}>
<text fg={theme.info}>*</text>
<text attributes={TextAttributes.BOLD} fg={theme.text}>
{notification.title}
</text>
</box>
<box paddingLeft={2}>
<text fg={theme.textMuted} wrapMode="word">
{notification.message}
</text>
{notification.action && (
<box flexDirection="row" marginTop={1}>
<Link href={notification.action.actionURL} fg={theme.primary}>
[{notification.action.actionText}]
</Link>
</box>
)}
</box>
</box>
)}
</For>
</box>
</scrollbox>
</box>
)
}
@@ -0,0 +1,59 @@
// kilocode_change - new file
/**
* Kilo News Component
*
* Self-contained component that fetches and displays Kilo news/notifications.
* Shows a banner on the home screen; clicking opens a dialog with all news items.
*/
import { createMemo, createSignal, onMount, Show } from "solid-js"
import { getTUIDependencies } from "../context.js"
import type { KilocodeNotification } from "../../api/notifications.js"
import { NotificationBanner } from "./notification-banner.js"
import { DialogKiloNotifications } from "./dialog-kilo-notifications.js"
export function KiloNews() {
const deps = getTUIDependencies()
const sync = deps.useSync()
const sdk = deps.useSDK()
const dialog = deps.useDialog()
const [notifications, setNotifications] = createSignal<KilocodeNotification[]>([])
const isKiloConnected = createMemo(() => sync.data.provider_next.connected.includes("kilo"))
const openNewsDialog = () => {
const items = notifications()
if (items.length > 0) {
dialog.replace(() => <DialogKiloNotifications notifications={items} />)
}
}
onMount(async () => {
// Wait for sync to complete
await new Promise<void>((resolve) => {
const check = () => {
if (sync.status === "complete") resolve()
else setTimeout(check, 100)
}
check()
})
if (!isKiloConnected()) return
const result = await sdk.client.kilo.notifications()
const items = result.data
if (items && items.length > 0) {
setNotifications(items)
}
})
return (
<Show when={notifications().length > 0}>
<NotificationBanner
notification={notifications()[0]}
totalCount={notifications().length}
onClick={openNewsDialog}
/>
</Show>
)
}
@@ -0,0 +1,52 @@
// kilocode_change - new file
/**
* Kilo Notification Banner
*
* Displays a notification teaser on the home screen.
* Clicking opens the full notifications dialog.
*
* Layout:
* * Title (N new)
* Message text with word wrap...
*/
import { Show } from "solid-js"
import { getTUIDependencies } from "../context.js"
import type { KilocodeNotification } from "../../api/notifications.js"
interface NotificationBannerProps {
notification: KilocodeNotification
totalCount: number
onClick?: () => void
}
export function NotificationBanner(props: NotificationBannerProps) {
const deps = getTUIDependencies()
const { theme } = deps.useTheme()
return (
<box flexDirection="column" maxWidth="100%" onMouseUp={props.onClick}>
{/* Line 1: Icon + Title + Count */}
<box flexDirection="row" gap={1}>
<text flexShrink={0} style={{ fg: theme.info }}>
*
</text>
<text flexShrink={0} style={{ fg: theme.text }}>
{props.notification.title}
</text>
<Show when={props.totalCount > 0}>
<text flexShrink={0} style={{ fg: theme.textMuted }}>
({props.totalCount} new)
</text>
</Show>
</box>
{/* Line 2: Message (indented to align under title) */}
<box paddingLeft={2}>
<text style={{ fg: theme.textMuted }} wrapMode="word">
{props.notification.message}
</text>
</box>
</box>
)
}
+1
View File
@@ -10,6 +10,7 @@ export interface TUIDependencies {
useDialog: () => any
useToast: () => any
useTheme: () => any
useSDK: () => any
// UI Components
DialogAlert: any
+1
View File
@@ -11,6 +11,7 @@ declare module "solid-js" {
box: any
text: any
span: any
scrollbox: any
}
}
}
+1
View File
@@ -49,5 +49,6 @@ declare namespace JSX {
box: any
text: any
span: any
scrollbox: any
}
}
+2 -1
View File
@@ -584,6 +584,7 @@ function App() {
useDialog: useDialog,
useToast: useToast,
useTheme: useTheme,
useSDK: useSDK,
DialogAlert: DialogAlert,
DialogSelect: DialogSelect,
Link: Link,
@@ -594,7 +595,7 @@ function App() {
registerKiloCommands(useSDK)
// kilocode_change end
// kilocode_change - Delete OpenRouter Alert
// kilocode_change - Delete OpenRouter Alert
sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
command.trigger(evt.properties.command)
})
@@ -14,6 +14,7 @@ import { usePromptRef } from "../context/prompt"
import { Installation } from "@/installation"
import { useKV } from "../context/kv"
import { useCommandDialog } from "../component/dialog-command"
import { KiloNews } from "@kilocode/kilo-gateway/tui" // kilocode_change
// TODO: what is the best way to do this?
let once = false
@@ -105,7 +106,9 @@ export function Home() {
hint={Hint}
/>
</box>
<box height={3} width="100%" maxWidth={75} alignItems="center" paddingTop={2}>
{/* kilocode_change - KiloNews added */}
<box width="100%" maxWidth={75} alignItems="center" paddingTop={2} gap={1}>
<KiloNews />
<Show when={showTips()}>
<Tips />
</Show>
@@ -9,6 +9,7 @@ This document explains how Kilocode configurations are automatically migrated to
- [Rules Migration](#rules-migration)
- [Workflows Migration](#workflows-migration)
- [MCP Migration](#mcp-migration)
- [Kilo Notifications](#kilo-notifications)
---
@@ -337,3 +338,52 @@ Kilocode MCP server configurations are migrated to Opencode's `mcp` config. See
| Location | Description |
| ----------------------------------------------------------- | ------------------------- |
| VSCode extension storage `settings/cline_mcp_settings.json` | MCP server configurations |
---
# Kilo Notifications
When connected to Kilo Gateway, the CLI fetches and displays notifications from the Kilo API. This allows Kilo to communicate important announcements, feature updates, and tips to users.
## How It Works
1. **On startup**, if the user is authenticated with Kilo Gateway, the CLI fetches notifications from `https://api.kilo.ai/api/users/notifications`
2. **Filtering**: Only notifications with `showIn` containing `"cli"` (or no `showIn` restriction) are displayed
3. **Display**: The first notification is shown as a toast notification after a 2-second delay
## Notification Data Structure
```typescript
interface KilocodeNotification {
id: string // Unique identifier
title: string // Notification title (e.g., "Agent skills now supported!")
message: string // Description text
action?: {
actionText: string // Link text (e.g., "Learn More")
actionURL: string // URL destination
}
showIn?: string[] // Target platforms: ["cli", "vscode"]
}
```
## Example Notification
```
Title: Agent skills now supported!
Message: Define reusable skills and workflows for your AI agent.
Action: Learn More -> https://docs.kilo.ai/skills
```
## Display Conditions
| Condition | Notifications Shown |
| ------------------------- | ------------------- |
| Connected to Kilo Gateway | Yes |
| Not connected to Kilo | No |
| No notifications from API | No |
## Related Files
- [`notifications.ts`](../../../../kilo-gateway/src/api/notifications.ts) - Fetch function and types
- [`routes.ts`](../../../../kilo-gateway/src/server/routes.ts) - Server endpoint `/kilo/notifications`
- [`app.tsx`](../../cli/cmd/tui/app.tsx) - TUI notification display logic
+21
View File
@@ -36,6 +36,8 @@ import type {
GlobalEventResponses,
GlobalHealthResponses,
InstanceDisposeResponses,
KiloNotificationsErrors,
KiloNotificationsResponses,
KiloOrganizationSetErrors,
KiloOrganizationSetResponses,
KiloProfileErrors,
@@ -2120,6 +2122,25 @@ export class Kilo extends HeyApiClient {
})
}
/**
* Get Kilo notifications
*
* Fetch notifications from Kilo Gateway for CLI display
*/
public notifications<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
return (options?.client ?? this.client).get<KiloNotificationsResponses, KiloNotificationsErrors, ThrowOnError>({
url: "/kilo/notifications",
...options,
...params,
})
}
private _organization?: Organization
get organization(): Organization {
return (this._organization ??= new Organization({ client: this.client }))
+36
View File
@@ -4085,6 +4085,42 @@ export type KiloOrganizationSetResponses = {
export type KiloOrganizationSetResponse = KiloOrganizationSetResponses[keyof KiloOrganizationSetResponses]
export type KiloNotificationsData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/kilo/notifications"
}
export type KiloNotificationsErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type KiloNotificationsError = KiloNotificationsErrors[keyof KiloNotificationsErrors]
export type KiloNotificationsResponses = {
/**
* Notifications list
*/
200: Array<{
id: string
title: string
message: string
action?: {
actionText: string
actionURL: string
}
showIn?: Array<string>
}>
}
export type KiloNotificationsResponse = KiloNotificationsResponses[keyof KiloNotificationsResponses]
export type FindTextData = {
body?: never
path?: never