-
Setting up workspace
+
{setup().error ? "Workspace setup failed" : "Setting up workspace"}
{setup().branch}
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css
index d0ae57d814..fec15a4f04 100644
--- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css
+++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css
@@ -7,17 +7,21 @@
}
.am-sidebar {
- width: 260px;
+ position: relative;
min-width: 200px;
- border-right: 1px solid var(--border-weak-base, var(--vscode-panel-border, #333));
+ border-right: 1px solid var(--border-weak-base);
display: flex;
flex-direction: column;
flex-shrink: 0;
- background: var(--surface-base, var(--vscode-sideBar-background, #181818));
+ background: var(--surface-base);
padding: 8px;
gap: 4px;
}
+.am-sidebar > [data-component="resize-handle"]::after {
+ background: var(--surface-interactive-base);
+}
+
/* Fixed local workspace item */
.am-local-item {
@@ -25,32 +29,34 @@
align-items: center;
gap: 8px;
padding: 6px 10px;
- border-radius: 6px;
+ border-radius: var(--radius-sm);
cursor: pointer;
background: none;
border: none;
width: 100%;
text-align: left;
- color: var(--text-base, var(--vscode-foreground));
- font-size: 13px;
- font-family: inherit;
+ color: var(--text-base);
+ font-size: var(--font-size-base);
flex-shrink: 0;
- transition: background-color 0.15s ease;
}
.am-local-item:hover {
- background: var(--surface-inset-base-hover, rgba(255, 255, 255, 0.05));
+ background: var(--surface-inset-base-hover);
+}
+
+.am-local-item:active {
+ opacity: 0.8;
}
.am-local-item-active {
- background: var(--surface-base-active, rgba(255, 255, 255, 0.08));
+ background: var(--surface-base-active);
}
.am-local-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
- color: var(--text-weaker, var(--vscode-descriptionForeground, #666));
+ color: var(--text-weaker);
}
.am-local-text {
@@ -61,14 +67,14 @@
}
.am-local-label {
- font-size: 13px;
+ font-size: var(--font-size-base);
font-weight: 500;
line-height: 1.4;
}
.am-local-branch {
- font-size: 11px;
- color: var(--text-weaker, var(--vscode-descriptionForeground, #777));
+ font-size: var(--font-size-small);
+ color: var(--text-weaker);
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
@@ -96,11 +102,17 @@
}
.am-section-label {
- font-size: 11px;
+ font-size: var(--font-size-small);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
- color: var(--text-weak, var(--vscode-descriptionForeground, #888));
+ color: var(--text-weak);
+}
+
+.am-section-actions {
+ display: flex;
+ align-items: center;
+ gap: 2px;
}
/* Worktree list */
@@ -110,29 +122,41 @@
flex-direction: column;
gap: 2px;
overflow-y: auto;
+ overflow-x: hidden;
max-height: 50vh;
}
/* Worktree item β larger card style */
+.am-worktree-list [data-slot="hover-card-trigger"] {
+ min-width: 0;
+}
+
.am-worktree-item {
+ position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 10px;
- border-radius: 6px;
+ border-radius: var(--radius-sm);
cursor: pointer;
- font-size: 13px;
- color: var(--text-base, var(--vscode-foreground));
+ font-size: var(--font-size-base);
+ color: var(--text-base);
+ min-width: 0;
+ width: 100%;
}
.am-worktree-item:hover {
- background: var(--surface-inset-base-hover, var(--vscode-list-hoverBackground, #2a2d2e));
+ background: var(--surface-inset-base-hover);
+}
+
+.am-worktree-item:active {
+ opacity: 0.8;
}
.am-worktree-item-active {
- background: var(--surface-interactive-base, var(--vscode-list-activeSelectionBackground, #04395e));
- color: var(--text-on-interactive-base, var(--vscode-list-activeSelectionForeground, #fff));
+ background: var(--surface-interactive-base);
+ color: var(--text-on-interactive-base);
}
.am-worktree-branch {
@@ -145,6 +169,24 @@
}
.am-worktree-close {
+ position: absolute;
+ right: 4px;
+ flex-shrink: 0;
+ opacity: 0;
+}
+
+.am-worktree-item:hover .am-worktree-branch {
+ mask-image: linear-gradient(to right, black calc(100% - 48px), transparent calc(100% - 16px));
+ -webkit-mask-image: linear-gradient(to right, black calc(100% - 48px), transparent calc(100% - 16px));
+}
+
+.am-worktree-item:hover .am-worktree-close {
+ opacity: 1;
+}
+
+.am-worktree-spinner {
+ width: 16px;
+ height: 16px;
flex-shrink: 0;
}
@@ -162,30 +204,33 @@
/* Session items */
.am-item {
+ position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
- border-radius: 6px;
+ border-radius: var(--radius-sm);
cursor: pointer;
background: none;
border: none;
width: 100%;
text-align: left;
- color: var(--text-base, var(--vscode-foreground));
- font-size: 13px;
- font-family: inherit;
- transition: background-color 0.15s ease;
+ color: var(--text-base);
+ font-size: var(--font-size-base);
}
.am-item:hover {
- background: var(--surface-inset-base-hover, rgba(255, 255, 255, 0.05));
+ background: var(--surface-inset-base-hover);
+}
+
+.am-item:active {
+ opacity: 0.8;
}
.am-item-active {
- background: var(--surface-base-active, rgba(255, 255, 255, 0.08));
- color: var(--text-base, var(--vscode-foreground));
+ background: var(--surface-base-active);
+ color: var(--text-base);
}
.am-item-title {
@@ -198,22 +243,27 @@
}
.am-item-time {
- font-size: 11px;
+ font-size: var(--font-size-small);
white-space: nowrap;
- color: var(--text-weaker, var(--vscode-descriptionForeground, #888));
+ color: var(--text-weaker);
flex-shrink: 0;
}
.am-item-active .am-item-time {
- color: var(--text-weaker, var(--vscode-descriptionForeground, #888));
+ color: var(--text-weaker);
}
/* Promote button on session rows */
.am-item-promote {
+ position: absolute;
+ right: 4px;
flex-shrink: 0;
opacity: 0;
- transition: opacity 0.1s ease;
+}
+
+.am-item:hover .am-item-time {
+ opacity: 0;
}
.am-item:hover .am-item-promote {
@@ -229,18 +279,21 @@
gap: 6px;
width: 100%;
padding: 8px;
- border: 1px dashed var(--border-weak-base, var(--vscode-panel-border, #444));
- border-radius: 6px;
+ border: 1px dashed var(--border-weak-base);
+ border-radius: var(--radius-sm);
background: none;
- color: var(--text-weak, var(--vscode-descriptionForeground, #888));
+ color: var(--text-weak);
font-size: 12px;
- font-family: inherit;
cursor: pointer;
}
.am-worktree-create:hover {
- background: var(--surface-inset-base-hover, var(--vscode-list-hoverBackground, #2a2d2e));
- color: var(--text-base, var(--vscode-foreground));
+ background: var(--surface-inset-base-hover);
+ color: var(--text-base);
+}
+
+.am-worktree-create:active {
+ opacity: 0.8;
}
/* Detail pane */
@@ -259,8 +312,8 @@
.am-tab-bar {
display: flex;
align-items: center;
- border-bottom: 1px solid var(--border-weak-base, var(--vscode-panel-border, #333));
- background: var(--surface-base, var(--vscode-editorGroupHeader-tabsBackground, #1e1e1e));
+ border-bottom: 1px solid var(--border-weak-base);
+ background: var(--surface-base);
height: 36px;
padding: 0 4px;
flex-shrink: 0;
@@ -272,6 +325,51 @@
min-width: 0;
overflow-x: auto;
height: 100%;
+
+ /* Hide scrollbar while keeping scroll functionality */
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+
+.am-tab-list::-webkit-scrollbar {
+ display: none;
+}
+
+/* Wrapper that positions overflow fade indicators */
+.am-tab-scroll-area {
+ position: relative;
+ display: flex;
+ min-width: 0;
+ flex-shrink: 1;
+ align-items: stretch;
+ height: 100%;
+ overflow: hidden;
+}
+
+/* Fade indicators for overflow */
+.am-tab-fade {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 32px;
+ pointer-events: none;
+ z-index: 1;
+ opacity: 0;
+ transition: opacity 0.15s ease;
+}
+
+.am-tab-fade-visible {
+ opacity: 1;
+}
+
+.am-tab-fade-left {
+ left: 0;
+ background: linear-gradient(to right, var(--surface-base) 0%, transparent 100%);
+}
+
+.am-tab-fade-right {
+ right: 0;
+ background: linear-gradient(to left, var(--surface-base) 0%, transparent 100%);
}
.am-tab {
@@ -280,9 +378,8 @@
gap: 6px;
padding: 0 12px;
background: none;
- color: var(--text-weak, var(--vscode-tab-inactiveForeground, #888));
+ color: var(--text-weak);
font-size: 12px;
- font-family: inherit;
cursor: pointer;
white-space: nowrap;
border: none;
@@ -292,12 +389,12 @@
}
.am-tab:hover {
- color: var(--text-base, var(--vscode-tab-activeForeground, #fff));
+ color: var(--text-base);
}
.am-tab-active {
- color: var(--text-base, var(--vscode-tab-activeForeground, #fff));
- border-bottom-color: var(--surface-interactive-base, var(--vscode-focusBorder, #007acc));
+ color: var(--text-base);
+ border-bottom-color: var(--surface-interactive-base);
}
.am-tab-label {
@@ -308,21 +405,11 @@
}
.am-tab-close {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 18px;
- height: 18px;
- border: none;
- background: none;
- color: inherit;
- font-size: 14px;
- line-height: 1;
- border-radius: 3px;
- cursor: pointer;
- opacity: 0;
flex-shrink: 0;
- transition: opacity 0.1s ease;
+}
+
+.am-tab .am-tab-close {
+ opacity: 0;
}
.am-tab:hover .am-tab-close {
@@ -335,7 +422,29 @@
.am-tab-close:hover {
opacity: 1;
- background: var(--surface-inset-base-hover, rgba(255, 255, 255, 0.1));
+}
+
+/* Drag-and-drop sortable tab wrapper */
+
+.am-tab-sortable {
+ display: flex;
+ height: 100%;
+ touch-action: none;
+}
+
+.am-tab-dragging {
+ opacity: 0.25;
+}
+
+/* Drag overlay tab (follows the cursor) */
+
+.am-tab-overlay {
+ background: var(--surface-base);
+ border: 1px solid var(--border-weak-base);
+ border-radius: var(--radius-sm);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+ pointer-events: none;
+ color: var(--text-base);
}
.am-tab-add {
@@ -343,6 +452,13 @@
align-self: center;
}
+.am-tab-terminal {
+ flex-shrink: 0;
+ align-self: center;
+ margin-left: auto;
+ padding-right: 4px;
+}
+
/* Empty worktree state */
.am-empty-state {
@@ -360,15 +476,15 @@
display: flex;
align-items: center;
justify-content: center;
- border-radius: 16px;
- background: var(--surface-inset-base, var(--vscode-input-background, #252526));
- color: var(--text-weaker, var(--vscode-descriptionForeground, #555));
+ border-radius: var(--radius-xl);
+ background: var(--surface-inset-base);
+ color: var(--text-weaker);
}
.am-empty-state-text {
font-size: 15px;
font-weight: 500;
- color: var(--text-weak, var(--vscode-descriptionForeground, #888));
+ color: var(--text-weak);
}
/* Read-only session wrapper + banner */
@@ -388,13 +504,13 @@
gap: 12px;
padding: 14px 16px;
flex-shrink: 0;
- border-top: 1px solid var(--border-weak-base, var(--vscode-panel-border, #333));
- background: var(--surface-inset-base, var(--vscode-input-background, #252526));
+ border-top: 1px solid var(--border-weak-base);
+ background: var(--surface-inset-base);
}
.am-readonly-text {
- font-size: 13px;
- color: var(--text-weak, var(--vscode-descriptionForeground, #888));
+ font-size: var(--font-size-base);
+ color: var(--text-weak);
}
/* Setup overlay */
@@ -406,7 +522,7 @@
display: flex;
align-items: center;
justify-content: center;
- background: var(--surface-base, var(--vscode-editor-background, #1e1e1e));
+ background: var(--surface-base);
animation: am-fade-in 0.15s ease;
}
@@ -424,36 +540,38 @@
flex-direction: column;
align-items: center;
gap: 12px;
- max-width: 300px;
+ max-width: 400px;
text-align: center;
}
.am-setup-card > [data-component="icon"] {
- color: var(--text-weak, var(--vscode-descriptionForeground, #888));
+ color: var(--text-weak);
opacity: 0.5;
}
.am-setup-title {
- font-size: 16px;
+ font-size: var(--font-size-large);
font-weight: 600;
- color: var(--text-base, var(--vscode-foreground));
+ color: var(--text-base);
}
.am-setup-branch {
font-size: 12px;
- color: var(--text-weak, var(--vscode-descriptionForeground, #888));
- font-family: var(--vscode-editor-font-family, monospace);
+ color: var(--text-weak);
+ font-family: var(--font-family-mono, monospace);
}
.am-setup-status {
display: flex;
align-items: center;
+ justify-content: center;
gap: 8px;
padding: 8px 16px;
- border-radius: 6px;
- background: var(--surface-inset-base, var(--vscode-input-background, #252526));
- color: var(--text-base, var(--vscode-foreground));
- font-size: 13px;
+ border-radius: var(--radius-sm);
+ background: var(--surface-inset-base);
+ color: var(--text-base);
+ font-size: var(--font-size-base);
+ text-align: center;
}
.am-setup-spinner {
@@ -461,3 +579,226 @@
height: 16px;
flex-shrink: 0;
}
+
+/* Confirmation dialog */
+
+.am-confirm {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 0 24px 16px 24px;
+}
+
+.am-confirm-message {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ font-size: var(--font-size-base);
+ color: var(--text-base);
+ line-height: 1.5;
+}
+
+.am-confirm-message > [data-component="icon"] {
+ flex-shrink: 0;
+ color: var(--icon-critical-base);
+ margin-top: 2px;
+}
+
+.am-confirm-branch {
+ font-family: var(--font-family-mono, monospace);
+ font-size: var(--font-size-small);
+ padding: 1px 6px;
+ border-radius: var(--radius-sm);
+ background: var(--surface-inset-base);
+ white-space: nowrap;
+}
+
+.am-confirm-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.am-confirm-delete {
+ background: var(--surface-critical-strong) !important;
+ color: var(--text-on-brand-base) !important;
+}
+
+.am-confirm-delete:hover {
+ opacity: 0.9;
+}
+
+/* Keyboard shortcut hint inside buttons */
+
+.am-shortcut-hint {
+ font-size: var(--font-size-small);
+ opacity: 0.6;
+ margin-left: 6px;
+}
+
+/* HoverCard popover for worktree items */
+
+.am-hover-card {
+ padding: 10px 12px;
+ min-width: 160px;
+ max-width: 240px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ overflow: hidden;
+}
+
+.am-hover-card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.am-hover-card-label {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text-weaker);
+ line-height: 1.4;
+}
+
+.am-hover-card-branch {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-strong);
+ line-height: 1.4;
+ word-break: break-all;
+}
+
+.am-hover-card-meta {
+ font-size: 12px;
+ color: var(--text-weaker);
+ line-height: 1.4;
+}
+
+.am-hover-card-keybind {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: 20px;
+ padding: 0 6px;
+ border-radius: 3px;
+ background: var(--surface-inset-base);
+ border: 1px solid var(--border-weak-base);
+ font-family: var(--font-family-sans);
+ font-size: 11px;
+ font-weight: 500;
+ line-height: 1;
+ color: var(--text-weak);
+ white-space: nowrap;
+}
+
+.am-hover-card-divider {
+ height: 1px;
+ background: var(--border-weak-base);
+ margin: 6px 0;
+}
+
+.am-hover-card-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.am-hover-card-row-label {
+ font-size: 12px;
+ color: var(--text-weaker);
+}
+
+.am-hover-card-row-value {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-base);
+}
+
+/* Skeleton loading states */
+
+@keyframes am-skeleton-pulse {
+ 0%,
+ 100% {
+ opacity: 0.12;
+ }
+ 50% {
+ opacity: 0.28;
+ }
+}
+
+.am-skeleton-list {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ animation: am-fade-in 0.2s ease;
+}
+
+/* Worktree skeleton β matches .am-worktree-item layout */
+.am-skeleton-wt {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 10px;
+}
+
+.am-skeleton-wt-icon {
+ width: 16px;
+ height: 16px;
+ border-radius: 3px;
+ background: var(--text-base);
+ animation: am-skeleton-pulse 1.5s ease-in-out infinite;
+ flex-shrink: 0;
+}
+
+.am-skeleton-wt-text {
+ height: 13px;
+ border-radius: 3px;
+ background: var(--text-base);
+ animation: am-skeleton-pulse 1.5s ease-in-out infinite;
+}
+
+.am-skeleton-wt:nth-child(2) .am-skeleton-wt-icon,
+.am-skeleton-wt:nth-child(2) .am-skeleton-wt-text {
+ animation-delay: 0.15s;
+}
+
+/* Session skeleton β matches .am-item layout */
+.am-skeleton-session {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 6px 10px;
+}
+
+.am-skeleton-session-title {
+ height: 13px;
+ border-radius: 3px;
+ background: var(--text-base);
+ animation: am-skeleton-pulse 1.5s ease-in-out infinite;
+}
+
+.am-skeleton-session-time {
+ height: 10px;
+ width: 52px;
+ border-radius: 3px;
+ background: var(--text-base);
+ animation: am-skeleton-pulse 1.5s ease-in-out infinite;
+ flex-shrink: 0;
+}
+
+.am-skeleton-session:nth-child(2) .am-skeleton-session-title,
+.am-skeleton-session:nth-child(2) .am-skeleton-session-time {
+ animation-delay: 0.15s;
+}
+
+.am-skeleton-session:nth-child(3) .am-skeleton-session-title,
+.am-skeleton-session:nth-child(3) .am-skeleton-session-time {
+ animation-delay: 0.3s;
+}
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts b/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts
index bbcab8d336..615a07d8b6 100644
--- a/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts
+++ b/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts
@@ -7,7 +7,10 @@
* Returns the action to take: select a session by ID, go to local, or do nothing.
*/
-export type NavResult = { action: "select"; id: string } | { action: "local" } | { action: "none" }
+/** Sentinel value for the local workspace selection. */
+export const LOCAL = "local" as const
+
+export type NavResult = { action: "select"; id: string } | { action: typeof LOCAL } | { action: "none" }
export function resolveNavigation(direction: "up" | "down", current: string | undefined, ids: string[]): NavResult {
// Determine current position: -1 = local, 0..N-1 = session index
@@ -25,7 +28,7 @@ export function resolveNavigation(direction: "up" | "down", current: string | un
const next = direction === "up" ? idx - 1 : idx + 1
// Moving up past the first session β go to local
- if (next === -1) return { action: "local" }
+ if (next === -1) return { action: LOCAL }
// At the bottom boundary
if (next >= ids.length) return { action: "none" }
@@ -42,3 +45,44 @@ export function validateLocalSession(persisted: string | undefined, ids: string[
if (ids.indexOf(persisted) === -1) return undefined
return persisted
}
+
+/**
+ * Return the keybinding hint for an item adjacent to the active item.
+ * Only returns a hint when the item is exactly one step away in the flat list.
+ * Returns empty string for non-adjacent items or the active item itself.
+ *
+ * @param itemId - The item being hovered
+ * @param activeId - The currently selected/active item (or undefined for LOCAL)
+ * @param flatIds - The full ordered sidebar list (LOCAL first, then worktrees, then sessions)
+ * @param prev - Display string for "go up" (e.g. "ββ" or keybinding)
+ * @param next - Display string for "go down" (e.g. "ββ" or keybinding)
+ */
+export function adjacentHint(
+ itemId: string,
+ activeId: string | undefined,
+ flatIds: string[],
+ prev: string,
+ next: string,
+): string {
+ if (!activeId || itemId === activeId) return ""
+ const activeIdx = flatIds.indexOf(activeId)
+ const itemIdx = flatIds.indexOf(itemId)
+ if (activeIdx === -1 || itemIdx === -1) return ""
+ const diff = itemIdx - activeIdx
+ if (diff === -1) return prev
+ if (diff === 1) return next
+ return ""
+}
+
+/**
+ * After removing a worktree, pick the nearest remaining sidebar neighbor.
+ * Order: the worktree just below β the one above β LOCAL.
+ */
+export function nextSelectionAfterDelete(deletedId: string, worktreeIds: string[]): typeof LOCAL | string {
+ const idx = worktreeIds.indexOf(deletedId)
+ if (idx === -1) return LOCAL
+ const remaining = worktreeIds.filter((id) => id !== deletedId)
+ if (remaining.length === 0) return LOCAL
+ // Prefer the item that was below (same index in the shortened list), else the one above
+ return remaining[Math.min(idx, remaining.length - 1)]!
+}
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/sortable-tab.tsx b/packages/kilo-vscode/webview-ui/agent-manager/sortable-tab.tsx
new file mode 100644
index 0000000000..2b0948fb4e
--- /dev/null
+++ b/packages/kilo-vscode/webview-ui/agent-manager/sortable-tab.tsx
@@ -0,0 +1,78 @@
+/**
+ * Drag-and-drop sortable tab components for the agent manager tab bar.
+ */
+
+import { Component, onCleanup } from "solid-js"
+import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd"
+import type { Transformer } from "@thisbeyond/solid-dnd"
+import { createRoot } from "solid-js"
+import type { SessionInfo } from "../src/types/messages"
+import { IconButton } from "@kilocode/kilo-ui/icon-button"
+import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
+
+/** Lock drag movement to the X axis (horizontal-only tab dragging). */
+export const ConstrainDragYAxis: Component = () => {
+ const context = useDragDropContext()
+ if (!context) return null
+ const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
+ const transformer: Transformer = { id: "constrain-y-axis", order: 100, callback: (t) => ({ ...t, y: 0 }) }
+ const dispose = createRoot((dispose) => {
+ onDragStart(({ draggable }) => {
+ if (draggable) addTransformer("draggables", draggable.id as string, transformer)
+ })
+ onDragEnd(({ draggable }) => {
+ if (draggable) removeTransformer("draggables", draggable.id as string, transformer.id)
+ })
+ return dispose
+ })
+ onCleanup(dispose)
+ return null
+}
+
+/** Individual sortable tab wrapper using the `use:sortable` directive. */
+export const SortableTab: Component<{
+ tab: SessionInfo
+ active: boolean
+ keybind?: string
+ closeKeybind?: string
+ onSelect: () => void
+ onMiddleClick: (e: MouseEvent) => void
+ onClose: (e: MouseEvent) => void
+}> = (props) => {
+ const sortable = createSortable(props.tab.id)
+ // Prevent tree-shaking of the directive reference used by `use:sortable`
+ void sortable
+ return (
+ // @ts-ignore - use:sortable is a SolidJS directive compiled by esbuild-plugin-solid
+
+
+
+ {props.tab.title || "Untitled"}
+
+
+
+
+
+
+ )
+}
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/tab-order.ts b/packages/kilo-vscode/webview-ui/agent-manager/tab-order.ts
new file mode 100644
index 0000000000..f20663cbb0
--- /dev/null
+++ b/packages/kilo-vscode/webview-ui/agent-manager/tab-order.ts
@@ -0,0 +1,62 @@
+/**
+ * Pure tab-ordering logic for the agent manager.
+ */
+
+/**
+ * Reorder an array by moving the item at `from` to the position of `to`.
+ * Returns a new array, or undefined if either ID is not found or they are equal.
+ */
+export function reorderTabs(tabs: readonly string[], from: string, to: string): string[] | undefined {
+ if (from === to) return undefined
+ const fi = tabs.indexOf(from)
+ const ti = tabs.indexOf(to)
+ if (fi === -1 || ti === -1) return undefined
+ const result = [...tabs]
+ result.splice(fi, 1)
+ result.splice(ti, 0, from)
+ return result
+}
+
+/**
+ * Apply a custom ordering to a list of items.
+ *
+ * Items are returned in `order` sequence (skipping IDs not in `items`),
+ * followed by any items not present in `order`.
+ * Returns the original array unchanged if `order` is undefined or empty.
+ */
+export function applyTabOrder
(items: T[], order: string[] | undefined): T[] {
+ if (!order || order.length === 0) return items
+ const lookup = new Map(items.map((item) => [item.id, item]))
+ const ordered: T[] = []
+ for (const id of order) {
+ const item = lookup.get(id)
+ if (item) {
+ ordered.push(item)
+ lookup.delete(id)
+ }
+ }
+ for (const item of lookup.values()) ordered.push(item)
+ return ordered
+}
+
+/**
+ * Find the title of the first item according to a custom order.
+ *
+ * Falls back to the first titled item in `items` if the order
+ * doesn't produce a match, then to `fallback`.
+ */
+export function firstOrderedTitle(
+ items: { id: string; title?: string }[],
+ order: string[] | undefined,
+ fallback: string,
+): string {
+ if (order) {
+ const lookup = new Map(items.map((item) => [item.id, item]))
+ for (const id of order) {
+ const item = lookup.get(id)
+ if (item?.title) return item.title
+ }
+ }
+ const first = items.find((item) => item.title)
+ return first?.title || fallback
+}
diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx
index 05f46c2285..7a91a7c32d 100644
--- a/packages/kilo-vscode/webview-ui/src/App.tsx
+++ b/packages/kilo-vscode/webview-ui/src/App.tsx
@@ -51,15 +51,21 @@ export const DataBridge: Component<{ children: any }> = (props) => {
const data = createMemo(() => {
const id = session.currentSessionID()
- const msgs = session.allMessages()
- const parts = session.allParts()
+ const perms = id ? session.permissions().filter((p) => p.sessionID === id) : []
return {
session: session.sessions().map((s) => ({ ...s, id: s.id, role: "user" as const })) as unknown as any[],
session_status: {} as Record,
session_diff: {} as Record,
- message: msgs as unknown as Record,
- part: parts as unknown as Record,
- permission: id ? { [id]: session.permissions() as unknown as any[] } : {},
+ message: id ? { [id]: session.messages() as unknown as SDKMessage[] } : {},
+ part: id
+ ? Object.fromEntries(
+ session
+ .messages()
+ .map((msg) => [msg.id, session.getParts(msg.id) as unknown as SDKPart[]])
+ .filter(([, parts]) => (parts as SDKPart[]).length > 0),
+ )
+ : {},
+ permission: id ? { [id]: perms as unknown as any[] } : {},
}
})
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx
index dd8660bcea..355e77a477 100644
--- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx
+++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx
@@ -28,7 +28,7 @@ export const ChatView: Component = (props) => {
const sessionPermissions = () => session.permissions().filter((p) => p.sessionID === id())
const questionRequest = () => sessionQuestions()[0]
- const permissionRequest = () => sessionPermissions()[0]
+ const permissionRequest = () => sessionPermissions().find((p) => !p.tool)
const blocked = () => sessionPermissions().length > 0 || sessionQuestions().length > 0
const [responding, setResponding] = createSignal(false)
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx
index 3a38648842..b1ca8c75a8 100644
--- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx
+++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx
@@ -70,10 +70,30 @@ export const PromptInput: Component = () => {
const canSend = () => (text().trim().length > 0 || imageAttach.images().length > 0) && !isBusy() && !isDisabled()
const unsubscribe = vscode.onMessage((message) => {
- if (message.type !== "chatCompletionResult") return
- const result = message as { type: "chatCompletionResult"; text: string; requestId: string }
- if (result.requestId === `chat-ac-${requestCounter}` && result.text) {
- setGhostText(result.text)
+ if (message.type === "chatCompletionResult") {
+ const result = message as { type: "chatCompletionResult"; text: string; requestId: string }
+ if (result.requestId === `chat-ac-${requestCounter}` && result.text) {
+ setGhostText(result.text)
+ }
+ }
+
+ if (message.type === "setChatBoxMessage") {
+ setText(message.text)
+ setGhostText("")
+ if (textareaRef) {
+ textareaRef.value = message.text
+ adjustHeight()
+ }
+ }
+
+ if (message.type === "triggerTask") {
+ if (isBusy() || isDisabled()) return
+ const sel = session.selected()
+ session.sendMessage(message.text, sel?.providerID, sel?.modelID)
+ }
+
+ if (message.type === "action" && message.action === "focusInput") {
+ textareaRef?.focus()
}
})
diff --git a/packages/kilo-vscode/webview-ui/src/context/permission-queue.ts b/packages/kilo-vscode/webview-ui/src/context/permission-queue.ts
new file mode 100644
index 0000000000..da977468a6
--- /dev/null
+++ b/packages/kilo-vscode/webview-ui/src/context/permission-queue.ts
@@ -0,0 +1,13 @@
+import type { PermissionRequest } from "../types/messages"
+
+export function upsertPermission(list: PermissionRequest[], permission: PermissionRequest) {
+ const idx = list.findIndex((item) => item.id === permission.id)
+ if (idx === -1) return [...list, permission]
+ const next = list.slice()
+ next[idx] = permission
+ return next
+}
+
+export function removeSessionPermissions(list: PermissionRequest[], sessionID: string) {
+ return list.filter((item) => item.sessionID !== sessionID)
+}
diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx
index c922b2b10b..00a454de29 100644
--- a/packages/kilo-vscode/webview-ui/src/context/session.tsx
+++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx
@@ -37,6 +37,7 @@ import type {
ExtensionMessage,
FileAttachment,
} from "../types/messages"
+import { removeSessionPermissions, upsertPermission } from "./permission-queue"
// Derive human-readable status from the last streaming part
function computeStatus(
@@ -163,13 +164,22 @@ export const SessionProvider: ParentComponent = (props) => {
// Current session ID
const [currentSessionID, setCurrentSessionID] = createSignal()
- // Session status β store full info object, derive simple string for compat
- const [statusInfo, setStatusInfo] = createSignal({ type: "idle" })
- const status = () => statusInfo().type as SessionStatus
- const [loading, setLoading] = createSignal(false)
+ // Per-session status map β keyed by sessionID
+ const [statusMap, setStatusMap] = createStore>({})
+ const [busySinceMap, setBusySinceMap] = createStore>({})
- // Track when the agent started working
- const [busySince, setBusySince] = createSignal()
+ // Derived accessors for the current session (backwards compatible)
+ const statusInfo = () => {
+ const id = currentSessionID()
+ return id ? (statusMap[id] ?? { type: "idle" }) : { type: "idle" }
+ }
+ const status = () => statusInfo().type as SessionStatus
+ const busySince = () => {
+ const id = currentSessionID()
+ return id ? busySinceMap[id] : undefined
+ }
+
+ const [loading, setLoading] = createSignal(false)
// Pending permissions
const [permissions, setPermissions] = createSignal([])
@@ -479,24 +489,27 @@ export const SessionProvider: ParentComponent = (props) => {
message?: string,
next?: number,
) {
- if (sessionID !== currentSessionID()) return
- const prev = statusInfo()
+ const prev = statusMap[sessionID] ?? { type: "idle" }
const info: SessionStatusInfo =
newStatus === "retry"
? { type: "retry", attempt: attempt ?? 0, message: message ?? "", next: next ?? 0 }
: { type: newStatus }
- setStatusInfo(info)
+ setStatusMap(sessionID, info)
// Track busy start time
if (prev.type === "idle" && newStatus !== "idle") {
- setBusySince(Date.now())
+ setBusySinceMap(sessionID, Date.now())
}
if (newStatus === "idle") {
- setBusySince(undefined)
+ setBusySinceMap(
+ produce((map) => {
+ delete map[sessionID]
+ }),
+ )
}
}
function handlePermissionRequest(permission: PermissionRequest) {
- setPermissions((prev) => [...prev, permission])
+ setPermissions((prev) => upsertPermission(prev, permission))
}
function handleQuestionRequest(question: QuestionRequest) {
@@ -591,10 +604,19 @@ export const SessionProvider: ParentComponent = (props) => {
return next
})
}
+ setPermissions((prev) => removeSessionPermissions(prev, sessionID))
+ setStatusMap(
+ produce((map) => {
+ delete map[sessionID]
+ }),
+ )
+ setBusySinceMap(
+ produce((map) => {
+ delete map[sessionID]
+ }),
+ )
if (currentSessionID() === sessionID) {
setCurrentSessionID(undefined)
- setStatusInfo({ type: "idle" })
- setBusySince(undefined)
setLoading(false)
}
})
@@ -734,8 +756,6 @@ export const SessionProvider: ParentComponent = (props) => {
function clearCurrentSession() {
setCurrentSessionID(undefined)
- setStatusInfo({ type: "idle" })
- setBusySince(undefined)
setLoading(false)
setPermissions([])
setQuestions([])
@@ -760,8 +780,6 @@ export const SessionProvider: ParentComponent = (props) => {
return
}
setCurrentSessionID(id)
- setStatusInfo({ type: "idle" })
- setBusySince(undefined)
setLoading(true)
vscode.postMessage({ type: "loadMessages", sessionID: id })
}
diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts
index 1c9bd01f49..521680b86f 100644
--- a/packages/kilo-vscode/webview-ui/src/types/messages.ts
+++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts
@@ -401,6 +401,16 @@ export interface ActionMessage {
action: string
}
+export interface SetChatBoxMessage {
+ type: "setChatBoxMessage"
+ text: string
+}
+
+export interface TriggerTaskMessage {
+ type: "triggerTask"
+ text: string
+}
+
export interface ProfileDataMessage {
type: "profileData"
data: ProfileData | null
@@ -561,11 +571,25 @@ export interface ManagedSessionState {
createdAt: string
}
+// Agent Manager session added to an existing worktree (no setup overlay needed)
+export interface AgentManagerSessionAddedMessage {
+ type: "agentManager.sessionAdded"
+ sessionId: string
+ worktreeId: string
+}
+
// Full state push from extension to webview
export interface AgentManagerStateMessage {
type: "agentManager.state"
worktrees: WorktreeState[]
sessions: ManagedSessionState[]
+ tabOrder?: Record
+}
+
+// Resolved keybindings for agent manager actions
+export interface AgentManagerKeybindingsMessage {
+ type: "agentManager.keybindings"
+ bindings: Record
}
export type ExtensionMessage =
@@ -605,7 +629,11 @@ export type ExtensionMessage =
| AgentManagerSessionMetaMessage
| AgentManagerRepoInfoMessage
| AgentManagerWorktreeSetupMessage
+ | AgentManagerSessionAddedMessage
| AgentManagerStateMessage
+ | AgentManagerKeybindingsMessage
+ | SetChatBoxMessage
+ | TriggerTaskMessage
// ============================================
// Messages FROM webview TO extension
@@ -843,6 +871,28 @@ export interface RequestRepoInfoMessage {
type: "agentManager.requestRepoInfo"
}
+export interface RequestStateMessage {
+ type: "agentManager.requestState"
+}
+
+// Configure worktree setup script
+export interface ConfigureSetupScriptRequest {
+ type: "agentManager.configureSetupScript"
+}
+
+// Show terminal for a session
+export interface ShowTerminalRequest {
+ type: "agentManager.showTerminal"
+ sessionId: string
+}
+
+// Persist tab order for a context (worktree ID or "local")
+export interface SetTabOrderRequest {
+ type: "agentManager.setTabOrder"
+ key: string
+ order: string[]
+}
+
export type WebviewMessage =
| SendMessageRequest
| AbortRequest
@@ -888,6 +938,10 @@ export type WebviewMessage =
| CloseSessionRequest
| TelemetryRequest
| RequestRepoInfoMessage
+ | RequestStateMessage
+ | ConfigureSetupScriptRequest
+ | ShowTerminalRequest
+ | SetTabOrderRequest
// ============================================
// VS Code API type
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index ba8f3a07c3..17b3163098 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "1.0.23",
+ "version": "1.0.24",
"name": "@kilocode/cli",
"type": "module",
"license": "MIT",
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx
index ea9db0103f..189c3a26f9 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx
@@ -8,20 +8,6 @@ import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort"
-// kilocode_change start - Recommended models for Kilo Gateway (order determines display priority)
-const KILO_RECOMMENDED_MODELS = [
- "kilo/auto",
- "anthropic/claude-sonnet-4.5",
- "anthropic/claude-opus-4.5",
- "anthropic/claude-haiku-4.5",
- "openai/gpt-5.2",
- "openai/gpt-5.2-codex",
- "google/gemini-3-pro-preview",
- "google/gemini-3-flash-preview",
- "mistralai/devstral-2512",
-]
-// kilocode_change end
-
export function useConnected() {
const sync = useSync()
return createMemo(() =>
@@ -100,7 +86,7 @@ export function DialogModel(props: { providerID?: string }) {
: undefined,
// kilocode_change start
category: connected()
- ? provider.id === "kilo" && KILO_RECOMMENDED_MODELS.includes(model)
+ ? provider.id === "kilo" && info.recommended
? "Recommended"
: provider.name
: undefined,
@@ -123,11 +109,11 @@ export function DialogModel(props: { providerID?: string }) {
sortBy(
// kilocode_change start - Sort recommended models first for Kilo Gateway
(x) => {
- if (x.value.providerID === "kilo") {
- const idx = KILO_RECOMMENDED_MODELS.indexOf(x.value.modelID)
- return idx >= 0 ? idx : KILO_RECOMMENDED_MODELS.length + 1
- }
- return 0
+ if (x.value.providerID !== "kilo") return 0
+ const provider = sync.data.provider.find((p) => p.id === "kilo")
+ const model = provider?.models[x.value.modelID]
+ if (model?.recommendedIndex !== undefined) return model.recommendedIndex
+ return Object.keys(provider?.models ?? {}).length
},
// kilocode_change end
(x) => x.footer !== "Free",
diff --git a/packages/opencode/src/commit-message/__tests__/generate.test.ts b/packages/opencode/src/commit-message/__tests__/generate.test.ts
new file mode 100644
index 0000000000..f492439146
--- /dev/null
+++ b/packages/opencode/src/commit-message/__tests__/generate.test.ts
@@ -0,0 +1,162 @@
+import { describe, expect, test, mock, beforeEach } from "bun:test"
+import type { GitContext } from "../types"
+
+// Mock dependencies before importing the module under test
+
+let mockGitContext: GitContext = {
+ branch: "main",
+ recentCommits: ["abc1234 initial commit"],
+ files: [{ status: "modified" as const, path: "src/index.ts", diff: "+console.log('hello')" }],
+}
+
+mock.module("../git-context", () => ({
+ getGitContext: async () => mockGitContext,
+}))
+
+let mockStreamText = "feat(src): add hello world logging"
+
+mock.module("@/provider/provider", () => ({
+ Provider: {
+ defaultModel: async () => ({ providerID: "test", modelID: "test-model" }),
+ getSmallModel: async () => ({
+ providerID: "test",
+ id: "test-small-model",
+ }),
+ getModel: async () => ({ providerID: "test", id: "test-model" }),
+ },
+}))
+
+mock.module("@/session/llm", () => ({
+ LLM: {
+ stream: async () => ({
+ text: Promise.resolve(mockStreamText),
+ }),
+ },
+}))
+
+mock.module("@/agent/agent", () => ({
+ Agent: {},
+}))
+
+mock.module("@/util/log", () => ({
+ Log: {
+ create: () => ({
+ info: () => {},
+ error: () => {},
+ warn: () => {},
+ debug: () => {},
+ }),
+ },
+}))
+
+import { generateCommitMessage } from "../generate"
+
+describe("commit-message.generate", () => {
+ beforeEach(() => {
+ mockGitContext = {
+ branch: "main",
+ recentCommits: ["abc1234 initial commit"],
+ files: [{ status: "modified" as const, path: "src/index.ts", diff: "+console.log('hello')" }],
+ }
+ mockStreamText = "feat(src): add hello world logging"
+ })
+
+ describe("prompt construction", () => {
+ test("passes path to getGitContext", async () => {
+ const result = await generateCommitMessage({ path: "/my/repo" })
+ // If getGitContext is called, it returns our mock context and generates a message
+ expect(result.message).toBeTruthy()
+ })
+
+ test("generates message from git context with multiple files", async () => {
+ mockGitContext = {
+ branch: "feature/api",
+ recentCommits: ["abc feat: add api", "def fix: typo"],
+ files: [
+ { status: "added" as const, path: "src/api.ts", diff: "+export function api() {}" },
+ { status: "modified" as const, path: "src/index.ts", diff: "+import { api } from './api'" },
+ ],
+ }
+ mockStreamText = "feat(api): add api module"
+
+ const result = await generateCommitMessage({ path: "/repo" })
+ expect(result.message).toBe("feat(api): add api module")
+ })
+ })
+
+ describe("response cleaning", () => {
+ test("strips code block markers from response", async () => {
+ mockStreamText = "```\nfeat: add feature\n```"
+
+ const result = await generateCommitMessage({ path: "/repo" })
+ expect(result.message).toBe("feat: add feature")
+ })
+
+ test("strips code block markers with language tag", async () => {
+ mockStreamText = "```text\nfix(auth): resolve token refresh\n```"
+
+ const result = await generateCommitMessage({ path: "/repo" })
+ expect(result.message).toBe("fix(auth): resolve token refresh")
+ })
+
+ test("strips surrounding double quotes", async () => {
+ mockStreamText = '"feat: add new feature"'
+
+ const result = await generateCommitMessage({ path: "/repo" })
+ expect(result.message).toBe("feat: add new feature")
+ })
+
+ test("strips surrounding single quotes", async () => {
+ mockStreamText = "'fix: resolve bug'"
+
+ const result = await generateCommitMessage({ path: "/repo" })
+ expect(result.message).toBe("fix: resolve bug")
+ })
+
+ test("strips whitespace around the message", async () => {
+ mockStreamText = " \n chore: update deps \n "
+
+ const result = await generateCommitMessage({ path: "/repo" })
+ expect(result.message).toBe("chore: update deps")
+ })
+
+ test("strips code blocks AND quotes together", async () => {
+ mockStreamText = '```\n"refactor: simplify logic"\n```'
+
+ const result = await generateCommitMessage({ path: "/repo" })
+ expect(result.message).toBe("refactor: simplify logic")
+ })
+
+ test("returns clean message when no markers present", async () => {
+ mockStreamText = "docs: update readme"
+
+ const result = await generateCommitMessage({ path: "/repo" })
+ expect(result.message).toBe("docs: update readme")
+ })
+ })
+
+ describe("error on no changes", () => {
+ test("throws when no git changes are found", async () => {
+ mockGitContext = {
+ branch: "main",
+ recentCommits: [],
+ files: [],
+ }
+
+ await expect(generateCommitMessage({ path: "/repo" })).rejects.toThrow(
+ "No changes found to generate a commit message for",
+ )
+ })
+ })
+
+ describe("selectedFiles pass-through", () => {
+ test("passes selectedFiles to getGitContext", async () => {
+ // This verifies the function doesn't crash when selectedFiles is provided
+ const result = await generateCommitMessage({
+ path: "/repo",
+ selectedFiles: ["src/a.ts"],
+ })
+ expect(result.message).toBeTruthy()
+ })
+ })
+})
diff --git a/packages/opencode/src/commit-message/__tests__/git-context.test.ts b/packages/opencode/src/commit-message/__tests__/git-context.test.ts
new file mode 100644
index 0000000000..83754b7130
--- /dev/null
+++ b/packages/opencode/src/commit-message/__tests__/git-context.test.ts
@@ -0,0 +1,484 @@
+import { describe, expect, test, beforeEach, mock } from "bun:test"
+
+// Mock Bun.spawnSync via mock.module so it integrates properly with bun:test
+// and doesn't conflict with other test files that mock "../git-context".
+const spawnSyncResults: Record = {}
+
+function setGitOutput(args: string, output: string) {
+ spawnSyncResults[args] = output
+}
+
+function clearGitOutputs() {
+ for (const key of Object.keys(spawnSyncResults)) {
+ delete spawnSyncResults[key]
+ }
+}
+
+// Override the git-context module with a version that uses our mock spawnSync.
+// This avoids conflicts with generate.test.ts which also mocks this module.
+mock.module("../git-context", () => {
+ function git(args: string[], cwd: string): string {
+ const key = args.join(" ")
+ return spawnSyncResults[key] ?? ""
+ }
+
+ const LOCK_FILES = new Set([
+ "package-lock.json",
+ "npm-shrinkwrap.json",
+ "yarn.lock",
+ "pnpm-lock.yaml",
+ "shrinkwrap.yaml",
+ "bun.lockb",
+ "bun.lock",
+ ".pnp.js",
+ ".pnp.cjs",
+ "jspm.lock",
+ "Pipfile.lock",
+ "poetry.lock",
+ "pdm.lock",
+ ".pdm-lock.toml",
+ "uv.lock",
+ "conda-lock.yml",
+ "pylock.toml",
+ "Gemfile.lock",
+ "composer.lock",
+ "gradle.lockfile",
+ "lockfile.json",
+ "dependency-lock.json",
+ "dependency-reduced-pom.xml",
+ "coursier.lock",
+ "build.sbt.lock",
+ "packages.lock.json",
+ "paket.lock",
+ "project.assets.json",
+ "Cargo.lock",
+ "go.sum",
+ "Gopkg.lock",
+ "glide.lock",
+ "build.zig.zon.lock",
+ "dune.lock",
+ "opam.lock",
+ "Package.resolved",
+ "Podfile.lock",
+ "Cartfile.resolved",
+ "pubspec.lock",
+ "mix.lock",
+ "rebar.lock",
+ "stack.yaml.lock",
+ "cabal.project.freeze",
+ "exact-dependencies.json",
+ "shard.lock",
+ "Manifest.toml",
+ "JuliaManifest.toml",
+ "renv.lock",
+ "packrat.lock",
+ "nimble.lock",
+ "dub.selections.json",
+ "rocks.lock",
+ "carton.lock",
+ "cpanfile.snapshot",
+ "conan.lock",
+ "vcpkg-lock.json",
+ ".terraform.lock.hcl",
+ "Berksfile.lock",
+ "Puppetfile.lock",
+ "MODULE.bazel.lock",
+ "flake.lock",
+ "deno.lock",
+ "devcontainer.lock.json",
+ ])
+
+ const MAX_DIFF_LENGTH = 4000
+
+ function isLockFile(filepath: string): boolean {
+ const name = filepath.split("/").pop() ?? filepath
+ return LOCK_FILES.has(name)
+ }
+
+ function parseNameStatus(output: string): Array<{ status: string; path: string }> {
+ if (!output) return []
+ return output.split("\n").map((line) => {
+ const [status, ...rest] = line.split("\t")
+ const path = status!.startsWith("R") ? (rest[1] ?? rest[0]) : rest.join("\t")
+ return { status: status!, path }
+ })
+ }
+
+ function parsePorcelain(output: string): Array<{ status: string; path: string }> {
+ if (!output) return []
+ return output
+ .split("\n")
+ .filter((line) => line.length > 0)
+ .map((line) => {
+ const xy = line.slice(0, 2)
+ const filepath = line.slice(3)
+ return { status: xy.trim(), path: filepath }
+ })
+ }
+
+ type FileStatus = "added" | "modified" | "deleted" | "renamed"
+
+ function mapStatus(code: string): FileStatus {
+ if (code.startsWith("R")) return "renamed"
+ if (code === "A" || code === "??" || code === "?") return "added"
+ if (code === "D") return "deleted"
+ if (code === "M") return "modified"
+ return "modified"
+ }
+
+ function isUntracked(code: string): boolean {
+ return code === "??" || code === "?"
+ }
+
+ async function getGitContext(repoPath: string, selectedFiles?: string[]) {
+ const branch = git(["branch", "--show-current"], repoPath) || "HEAD"
+ const log = git(["log", "--oneline", "-5"], repoPath)
+ const recentCommits = log ? log.split("\n") : []
+
+ const staged = parseNameStatus(git(["diff", "--name-status", "--cached"], repoPath))
+ const useStaged = staged.length > 0
+ const raw = useStaged ? staged : parsePorcelain(git(["status", "--porcelain"], repoPath))
+
+ const selected = selectedFiles ? new Set(selectedFiles) : undefined
+
+ const files: Array<{ status: FileStatus; path: string; diff: string }> = []
+ for (const entry of raw) {
+ if (isLockFile(entry.path)) continue
+ if (selected && !selected.has(entry.path)) continue
+
+ const status = mapStatus(entry.status)
+ const untracked = isUntracked(entry.status)
+
+ let diff: string
+ if (untracked) {
+ diff = `New untracked file: ${entry.path}`
+ } else if (status === "deleted") {
+ diff = useStaged
+ ? git(["diff", "--cached", "--", entry.path], repoPath)
+ : git(["diff", "--", entry.path], repoPath)
+ } else {
+ const raw = useStaged
+ ? git(["diff", "--cached", "--", entry.path], repoPath)
+ : git(["diff", "--", entry.path], repoPath)
+ if (raw.includes("Binary files") || raw.includes("GIT binary patch")) {
+ diff = `Binary file ${entry.path} has been modified`
+ } else {
+ diff = raw
+ }
+ }
+
+ if (diff.length > MAX_DIFF_LENGTH) {
+ diff = diff.slice(0, MAX_DIFF_LENGTH) + "\n... [truncated]"
+ }
+
+ files.push({ status, path: entry.path, diff })
+ }
+
+ return { branch, recentCommits, files }
+ }
+
+ return { getGitContext }
+})
+
+import { getGitContext } from "../git-context"
+
+describe("commit-message.git-context", () => {
+ beforeEach(() => {
+ clearGitOutputs()
+ // Defaults
+ setGitOutput("branch --show-current", "main")
+ setGitOutput("log --oneline -5", "abc1234 initial commit")
+ setGitOutput("diff --name-status --cached", "")
+ setGitOutput("status --porcelain", "")
+ })
+
+ // NOTE: git() trims stdout, which eats the leading space of the first
+ // porcelain line. We use staged (--name-status) tests for path-sensitive
+ // assertions and only use porcelain for behavior tests where this is acceptable.
+
+ describe("lock file filtering", () => {
+ test("filters out package-lock.json from staged changes", async () => {
+ setGitOutput("diff --name-status --cached", "M\tsrc/index.ts\nM\tpackage-lock.json")
+ setGitOutput("diff --cached -- src/index.ts", "+console.log('hello')")
+ setGitOutput("diff --cached -- package-lock.json", "+lots of lock content")
+
+ const ctx = await getGitContext("/repo")
+
+ expect(ctx.files).toHaveLength(1)
+ expect(ctx.files[0]!.path).toBe("src/index.ts")
+ })
+
+ test("filters out yarn.lock from staged changes", async () => {
+ setGitOutput("diff --name-status --cached", "M\tsrc/app.ts\nM\tyarn.lock")
+ setGitOutput("diff --cached -- src/app.ts", "+import x")
+ setGitOutput("diff --cached -- yarn.lock", "+lock data")
+
+ const ctx = await getGitContext("/repo")
+
+ expect(ctx.files).toHaveLength(1)
+ expect(ctx.files[0]!.path).toBe("src/app.ts")
+ })
+
+ test("filters out pnpm-lock.yaml from staged changes", async () => {
+ setGitOutput("diff --name-status --cached", "M\treadme.md\nM\tpnpm-lock.yaml")
+ setGitOutput("diff --cached -- pnpm-lock.yaml", "+lock")
+ setGitOutput("diff --cached -- readme.md", "+docs")
+
+ const ctx = await getGitContext("/repo")
+
+ expect(ctx.files).toHaveLength(1)
+ expect(ctx.files[0]!.path).toBe("readme.md")
+ })
+
+ test("filters lock files in subdirectories", async () => {
+ setGitOutput("diff --name-status --cached", "M\tpackages/api/package-lock.json\nM\tpackages/api/src/index.ts")
+ setGitOutput("diff --cached -- packages/api/package-lock.json", "+lock stuff")
+ setGitOutput("diff --cached -- packages/api/src/index.ts", "+code")
+
+ const ctx = await getGitContext("/repo")
+
+ expect(ctx.files).toHaveLength(1)
+ expect(ctx.files[0]!.path).toBe("packages/api/src/index.ts")
+ })
+
+ test("filters out bun.lockb, go.sum, Cargo.lock, poetry.lock", async () => {
+ setGitOutput(
+ "diff --name-status --cached",
+ "M\tbun.lockb\nM\tgo.sum\nM\tCargo.lock\nM\tpoetry.lock\nM\tsrc/main.rs",
+ )
+ setGitOutput("diff --cached -- bun.lockb", "binary")
+ setGitOutput("diff --cached -- go.sum", "+hash")
+ setGitOutput("diff --cached -- Cargo.lock", "+lock")
+ setGitOutput("diff --cached -- poetry.lock", "+lock")
+ setGitOutput("diff --cached -- src/main.rs", "+fn main() {}")
+
+ const ctx = await getGitContext("/repo")
+
+ expect(ctx.files).toHaveLength(1)
+ expect(ctx.files[0]!.path).toBe("src/main.rs")
+ })
+ })
+
+ describe("status parsing", () => {
+ test("parses staged added files", async () => {
+ setGitOutput("diff --name-status --cached", "A\tsrc/new-file.ts")
+ setGitOutput("diff --cached -- src/new-file.ts", "+new content")
+
+ const ctx = await getGitContext("/repo")
+
+ expect(ctx.files).toHaveLength(1)
+ expect(ctx.files[0]!.status).toBe("added")
+ expect(ctx.files[0]!.path).toBe("src/new-file.ts")
+ })
+
+ test("parses staged modified files", async () => {
+ setGitOutput("diff --name-status --cached", "M\tsrc/existing.ts")
+ setGitOutput("diff --cached -- src/existing.ts", "+changed line")
+
+ const ctx = await getGitContext("/repo")
+
+ expect(ctx.files).toHaveLength(1)
+ expect(ctx.files[0]!.status).toBe("modified")
+ })
+
+ test("parses staged deleted files", async () => {
+ setGitOutput("diff --name-status --cached", "D\tsrc/removed.ts")
+ setGitOutput("diff --cached -- src/removed.ts", "-deleted content")
+
+ const ctx = await getGitContext("/repo")
+
+ expect(ctx.files).toHaveLength(1)
+ expect(ctx.files[0]!.status).toBe("deleted")
+ })
+
+ test("parses staged renamed files", async () => {
+ setGitOutput("diff --name-status --cached", "R100\told-name.ts\tnew-name.ts")
+
+ const ctx = await getGitContext("/repo")
+
+ expect(ctx.files).toHaveLength(1)
+ expect(ctx.files[0]!.status).toBe("renamed")
+ })
+
+ test("parses untracked files from porcelain", async () => {
+ setGitOutput("status --porcelain", "?? src/brand-new.ts")
+
+ const ctx = await getGitContext("/repo")
+
+ expect(ctx.files).toHaveLength(1)
+ expect(ctx.files[0]!.status).toBe("added")
+ expect(ctx.files[0]!.diff).toBe("New untracked file: src/brand-new.ts")
+ })
+
+ test("parses porcelain modified files", async () => {
+ // Use staged to avoid porcelain trim edge case
+ setGitOutput("diff --name-status --cached", "M\tsrc/changed.ts")
+ setGitOutput("diff --cached -- src/changed.ts", "+line")
+
+ const ctx = await getGitContext("/repo")
+
+ expect(ctx.files).toHaveLength(1)
+ expect(ctx.files[0]!.status).toBe("modified")
+ })
+
+ test("prefers staged changes over unstaged", async () => {
+ setGitOutput("diff --name-status --cached", "M\tsrc/staged.ts")
+ setGitOutput("diff --cached -- src/staged.ts", "+staged change")
+ // unstaged also exists but should be ignored when staged is present
+ setGitOutput("status --porcelain", " M src/unstaged.ts")
+ setGitOutput("diff -- src/unstaged.ts", "+unstaged change")
+
+ const ctx = await getGitContext("/repo")
+
+ expect(ctx.files).toHaveLength(1)
+ expect(ctx.files[0]!.path).toBe("src/staged.ts")
+ })
+
+ test("mapStatus returns 'modified' for unknown codes", async () => {
+ setGitOutput("diff --name-status --cached", "X\tsrc/weird.ts")
+ setGitOutput("diff --cached -- src/weird.ts", "+stuff")
+
+ const ctx = await getGitContext("/repo")
+
+ expect(ctx.files[0]!.status).toBe("modified")
+ })
+ })
+
+ describe("diff truncation", () => {
+ test("truncates diffs exceeding 4000 characters", async () => {
+ const longDiff = "x".repeat(5000)
+ setGitOutput("diff --name-status --cached", "M\tsrc/big.ts")
+ setGitOutput("diff --cached -- src/big.ts", longDiff)
+
+ const ctx = await getGitContext("/repo")
+
+ expect(ctx.files).toHaveLength(1)
+ expect(ctx.files[0]!.diff.length).toBeLessThan(5000)
+ expect(ctx.files[0]!.diff).toContain("... [truncated]")
+ // 4000 chars + "\n... [truncated]"
+ expect(ctx.files[0]!.diff.length).toBe(4000 + "\n... [truncated]".length)
+ })
+
+ test("does not truncate diffs at exactly 4000 characters", async () => {
+ const exactDiff = "y".repeat(4000)
+ setGitOutput("diff --name-status --cached", "M\tsrc/exact.ts")
+ setGitOutput("diff --cached -- src/exact.ts", exactDiff)
+
+ const ctx = await getGitContext("/repo")
+
+ expect(ctx.files[0]!.diff).toBe(exactDiff)
+ expect(ctx.files[0]!.diff).not.toContain("... [truncated]")
+ })
+
+ test("does not truncate diffs under 4000 characters", async () => {
+ const shortDiff = "z".repeat(100)
+ setGitOutput("diff --name-status --cached", "M\tsrc/small.ts")
+ setGitOutput("diff --cached -- src/small.ts", shortDiff)
+
+ const ctx = await getGitContext("/repo")
+
+ expect(ctx.files[0]!.diff).toBe(shortDiff)
+ })
+ })
+
+ describe("binary file detection", () => {
+ test("detects 'Binary files' in diff output", async () => {
+ setGitOutput("diff --name-status --cached", "M\tassets/logo.png")
+ setGitOutput("diff --cached -- assets/logo.png", "Binary files a/assets/logo.png and b/assets/logo.png differ")
+
+ const ctx = await getGitContext("/repo")
+
+ expect(ctx.files).toHaveLength(1)
+ expect(ctx.files[0]!.diff).toBe("Binary file assets/logo.png has been modified")
+ })
+
+ test("detects 'GIT binary patch' in diff output", async () => {
+ setGitOutput("diff --name-status --cached", "M\tassets/icon.ico")
+ setGitOutput("diff --cached -- assets/icon.ico", "GIT binary patch\nliteral 1234\ndata...")
+
+ const ctx = await getGitContext("/repo")
+
+ expect(ctx.files).toHaveLength(1)
+ expect(ctx.files[0]!.diff).toBe("Binary file assets/icon.ico has been modified")
+ })
+
+ test("does not flag normal diffs as binary", async () => {
+ setGitOutput("diff --name-status --cached", "M\tsrc/code.ts")
+ setGitOutput("diff --cached -- src/code.ts", "+const x = 1")
+
+ const ctx = await getGitContext("/repo")
+
+ expect(ctx.files[0]!.diff).toBe("+const x = 1")
+ })
+ })
+
+ describe("selected files filtering", () => {
+ test("only includes files in selectedFiles set", async () => {
+ setGitOutput("diff --name-status --cached", "M\tsrc/a.ts\nM\tsrc/b.ts\nM\tsrc/c.ts")
+ setGitOutput("diff --cached -- src/a.ts", "+a")
+ setGitOutput("diff --cached -- src/b.ts", "+b")
+ setGitOutput("diff --cached -- src/c.ts", "+c")
+
+ const ctx = await getGitContext("/repo", ["src/a.ts", "src/c.ts"])
+
+ expect(ctx.files).toHaveLength(2)
+ const paths = ctx.files.map((f) => f.path)
+ expect(paths).toContain("src/a.ts")
+ expect(paths).toContain("src/c.ts")
+ expect(paths).not.toContain("src/b.ts")
+ })
+
+ test("includes all files when selectedFiles is undefined", async () => {
+ setGitOutput("diff --name-status --cached", "M\tsrc/a.ts\nM\tsrc/b.ts")
+ setGitOutput("diff --cached -- src/a.ts", "+a")
+ setGitOutput("diff --cached -- src/b.ts", "+b")
+
+ const ctx = await getGitContext("/repo")
+
+ expect(ctx.files).toHaveLength(2)
+ })
+
+ test("returns empty files when selectedFiles has no matches", async () => {
+ setGitOutput("diff --name-status --cached", "M\tsrc/a.ts")
+ setGitOutput("diff --cached -- src/a.ts", "+a")
+
+ const ctx = await getGitContext("/repo", ["src/nonexistent.ts"])
+
+ expect(ctx.files).toHaveLength(0)
+ })
+ })
+
+ describe("branch and recent commits", () => {
+ test("returns current branch name", async () => {
+ setGitOutput("branch --show-current", "feature/my-branch")
+
+ const ctx = await getGitContext("/repo")
+
+ expect(ctx.branch).toBe("feature/my-branch")
+ })
+
+ test("falls back to HEAD when branch is empty", async () => {
+ setGitOutput("branch --show-current", "")
+
+ const ctx = await getGitContext("/repo")
+
+ expect(ctx.branch).toBe("HEAD")
+ })
+
+ test("returns recent commits as array", async () => {
+ setGitOutput("log --oneline -5", "abc1234 first\ndef5678 second\nghi9012 third")
+
+ const ctx = await getGitContext("/repo")
+
+ expect(ctx.recentCommits).toEqual(["abc1234 first", "def5678 second", "ghi9012 third"])
+ })
+
+ test("returns empty array when no commits", async () => {
+ setGitOutput("log --oneline -5", "")
+
+ const ctx = await getGitContext("/repo")
+
+ expect(ctx.recentCommits).toEqual([])
+ })
+ })
+})
diff --git a/packages/opencode/src/commit-message/generate.ts b/packages/opencode/src/commit-message/generate.ts
new file mode 100644
index 0000000000..f5321ace7b
--- /dev/null
+++ b/packages/opencode/src/commit-message/generate.ts
@@ -0,0 +1,178 @@
+import { Provider } from "@/provider/provider"
+import { LLM } from "@/session/llm"
+import { Agent } from "@/agent/agent"
+import { Log } from "@/util/log"
+import type { CommitMessageRequest, CommitMessageResponse, GitContext } from "./types"
+import { getGitContext } from "./git-context"
+
+const log = Log.create({ service: "commit-message" })
+
+const SYSTEM_PROMPT = `You are an expert Git commit message generator that creates conventional commit messages based on staged changes. Analyze the provided git diff output and generate an appropriate conventional commit message following the specification.
+
+## Conventional Commits Format
+Generate commit messages following this exact structure:
+\`\`\`
+[optional scope]:
+
+[optional body]
+
+[optional footer(s)]
+\`\`\`
+
+### Core Types (Required)
+- **feat**: New feature or functionality (MINOR version bump)
+- **fix**: Bug fix or error correction (PATCH version bump)
+
+### Additional Types (Extended)
+- **docs**: Documentation changes only
+- **style**: Code style changes (whitespace, formatting, semicolons, etc.)
+- **refactor**: Code refactoring without feature changes or bug fixes
+- **perf**: Performance improvements
+- **test**: Adding or fixing tests
+- **build**: Build system or external dependency changes
+- **ci**: CI/CD configuration changes
+- **chore**: Maintenance tasks, tooling changes
+- **revert**: Reverting previous commits
+
+### Scope Guidelines
+- Use parentheses: \`feat(api):\`, \`fix(ui):\`
+- Common scopes: \`api\`, \`ui\`, \`auth\`, \`db\`, \`config\`, \`deps\`, \`docs\`
+- For monorepos: package or module names
+- Keep scope concise and lowercase
+
+### Description Rules
+- Use imperative mood ("add" not "added" or "adds")
+- Start with lowercase letter
+- No period at the end
+- Maximum 72 characters
+- Be concise but descriptive
+
+### Body Guidelines (Optional)
+- Start one blank line after description
+- Explain the "what" and "why", not the "how"
+- Wrap at 72 characters per line
+- Use for complex changes requiring explanation
+
+### Footer Guidelines (Optional)
+- Start one blank line after body
+- **Breaking Changes**: \`BREAKING CHANGE: description\`
+
+## Analysis Instructions
+When analyzing staged changes:
+1. Determine Primary Type based on the nature of changes
+2. Identify Scope from modified directories or modules
+3. Craft Description focusing on the most significant change
+4. Determine if there are Breaking Changes
+5. For complex changes, include a detailed body explaining what and why
+6. Add appropriate footers for issue references or breaking changes
+
+For significant changes, include a detailed body explaining the changes.
+
+Return ONLY the commit message in the conventional format, nothing else.`
+
+function buildUserMessage(ctx: GitContext): string {
+ const fileList = ctx.files.map((f) => `${f.status} ${f.path}`).join("\n")
+ const diffs = ctx.files
+ .filter((f) => f.diff)
+ .map((f) => `--- ${f.path} ---\n${f.diff}`)
+ .join("\n\n")
+
+ return `Generate a commit message for the following changes:
+
+Branch: ${ctx.branch}
+Recent commits:
+${ctx.recentCommits.join("\n")}
+
+Changed files:
+${fileList}
+
+Diffs:
+${diffs}`
+}
+
+function clean(text: string): string {
+ let result = text.trim()
+ // Strip code block markers
+ if (result.startsWith("```")) {
+ const first = result.indexOf("\n")
+ if (first !== -1) {
+ result = result.slice(first + 1)
+ }
+ }
+ if (result.endsWith("```")) {
+ result = result.slice(0, -3)
+ }
+ result = result.trim()
+ // Strip surrounding quotes
+ if ((result.startsWith('"') && result.endsWith('"')) || (result.startsWith("'") && result.endsWith("'"))) {
+ result = result.slice(1, -1)
+ }
+ return result.trim()
+}
+
+export async function generateCommitMessage(request: CommitMessageRequest): Promise {
+ const ctx = await getGitContext(request.path, request.selectedFiles)
+ if (ctx.files.length === 0) {
+ throw new Error("No changes found to generate a commit message for")
+ }
+
+ log.info("generating", {
+ branch: ctx.branch,
+ files: ctx.files.length,
+ })
+
+ const defaultModel = await Provider.defaultModel()
+ const model =
+ (await Provider.getSmallModel(defaultModel.providerID)) ??
+ (await Provider.getModel(defaultModel.providerID, defaultModel.modelID))
+
+ const agent: Agent.Info = {
+ name: "commit-message",
+ mode: "primary",
+ hidden: true,
+ options: {},
+ permission: [],
+ prompt: SYSTEM_PROMPT,
+ temperature: 0.3,
+ }
+
+ let userMessage = buildUserMessage(ctx)
+ if (request.previousMessage) {
+ userMessage = `IMPORTANT: Generate a COMPLETELY DIFFERENT commit message from the previous one. The previous message was: "${request.previousMessage}". Use a different type, scope, or description approach.\n\n${userMessage}`
+ }
+
+ const stream = await LLM.stream({
+ agent,
+ user: {
+ id: "commit-message",
+ sessionID: "commit-message",
+ role: "user",
+ model: {
+ providerID: model.providerID,
+ modelID: model.id,
+ },
+ time: {
+ created: Date.now(),
+ completed: Date.now(),
+ },
+ } as any,
+ tools: {},
+ model,
+ small: true,
+ messages: [
+ {
+ role: "user" as const,
+ content: userMessage,
+ },
+ ],
+ abort: new AbortController().signal,
+ sessionID: "commit-message",
+ system: [],
+ retries: 3,
+ })
+
+ const result = await stream.text
+ log.info("generated", { message: result })
+
+ return { message: clean(result) }
+}
diff --git a/packages/opencode/src/commit-message/git-context.ts b/packages/opencode/src/commit-message/git-context.ts
new file mode 100644
index 0000000000..3b97f44c58
--- /dev/null
+++ b/packages/opencode/src/commit-message/git-context.ts
@@ -0,0 +1,230 @@
+import type { GitContext, FileChange } from "./types"
+
+const LOCK_FILES = new Set([
+ // --- JavaScript / Node.js ---
+ "package-lock.json",
+ "npm-shrinkwrap.json",
+ "yarn.lock",
+ "pnpm-lock.yaml",
+ "shrinkwrap.yaml",
+ "bun.lockb",
+ "bun.lock",
+ ".pnp.js",
+ ".pnp.cjs",
+ "jspm.lock",
+
+ // --- Python ---
+ "Pipfile.lock",
+ "poetry.lock",
+ "pdm.lock",
+ ".pdm-lock.toml",
+ "uv.lock",
+ "conda-lock.yml",
+ "pylock.toml",
+
+ // --- Ruby ---
+ "Gemfile.lock",
+
+ // --- PHP ---
+ "composer.lock",
+
+ // --- Java / JVM ---
+ "gradle.lockfile",
+ "lockfile.json",
+ "dependency-lock.json",
+ "dependency-reduced-pom.xml",
+ "coursier.lock",
+
+ // --- Scala ---
+ "build.sbt.lock",
+
+ // --- .NET ---
+ "packages.lock.json",
+ "paket.lock",
+ "project.assets.json",
+
+ // --- Rust ---
+ "Cargo.lock",
+
+ // --- Go ---
+ "go.sum",
+ "Gopkg.lock",
+ "glide.lock",
+
+ // --- Zig ---
+ "build.zig.zon.lock",
+
+ // --- OCaml ---
+ "dune.lock",
+ "opam.lock",
+
+ // --- Swift / iOS ---
+ "Package.resolved",
+ "Podfile.lock",
+ "Cartfile.resolved",
+
+ // --- Dart / Flutter ---
+ "pubspec.lock",
+
+ // --- Elixir / Erlang ---
+ "mix.lock",
+ "rebar.lock",
+
+ // --- Haskell ---
+ "stack.yaml.lock",
+ "cabal.project.freeze",
+
+ // --- Elm ---
+ "exact-dependencies.json",
+
+ // --- Crystal ---
+ "shard.lock",
+
+ // --- Julia ---
+ "Manifest.toml",
+ "JuliaManifest.toml",
+
+ // --- R ---
+ "renv.lock",
+ "packrat.lock",
+
+ // --- Nim ---
+ "nimble.lock",
+
+ // --- D ---
+ "dub.selections.json",
+
+ // --- Lua ---
+ "rocks.lock",
+
+ // --- Perl ---
+ "carton.lock",
+ "cpanfile.snapshot",
+
+ // --- C/C++ ---
+ "conan.lock",
+ "vcpkg-lock.json",
+
+ // --- Infrastructure as Code ---
+ ".terraform.lock.hcl",
+ "Berksfile.lock",
+ "Puppetfile.lock",
+ "MODULE.bazel.lock",
+
+ // --- Nix ---
+ "flake.lock",
+
+ // --- Deno ---
+ "deno.lock",
+
+ // --- DevContainers ---
+ "devcontainer.lock.json",
+])
+
+const MAX_DIFF_LENGTH = 4000
+
+function isLockFile(filepath: string): boolean {
+ const name = filepath.split("/").pop() ?? filepath
+ return LOCK_FILES.has(name)
+}
+
+function git(args: string[], cwd: string): string {
+ const result = Bun.spawnSync(["git", ...args], {
+ cwd,
+ stdout: "pipe",
+ stderr: "pipe",
+ })
+ return result.stdout.toString().trimEnd()
+}
+
+function parseNameStatus(output: string): Array<{ status: string; path: string }> {
+ if (!output) return []
+ return output.split("\n").map((line) => {
+ const [status, ...rest] = line.split("\t")
+ let path: string
+ if (status!.startsWith("R")) {
+ // Rename: rest = ["old.ts", "new.ts"], use the new path
+ path = rest[1] ?? rest[0]
+ } else {
+ path = rest.join("\t")
+ }
+ return { status: status!, path }
+ })
+}
+
+function parsePorcelain(output: string): Array<{ status: string; path: string }> {
+ if (!output) return []
+ return output
+ .split("\n")
+ .filter((line) => line.length > 0)
+ .map((line) => {
+ const xy = line.slice(0, 2)
+ const filepath = line.slice(3)
+ return { status: xy.trim(), path: filepath }
+ })
+}
+
+function mapStatus(code: string): FileChange["status"] {
+ if (code.startsWith("R")) return "renamed"
+ if (code === "A" || code === "??" || code === "?") return "added"
+ if (code === "D") return "deleted"
+ if (code === "M") return "modified"
+ return "modified"
+}
+
+function isUntracked(code: string): boolean {
+ return code === "??" || code === "?"
+}
+
+export async function getGitContext(repoPath: string, selectedFiles?: string[]): Promise {
+ const branch = git(["branch", "--show-current"], repoPath) || "HEAD"
+ const log = git(["log", "--oneline", "-5"], repoPath)
+ const recentCommits = log ? log.split("\n") : []
+
+ // Check staged files first
+ const staged = parseNameStatus(git(["diff", "--name-status", "--cached"], repoPath))
+ const useStaged = staged.length > 0
+
+ // Fall back to all changes if nothing staged
+ const raw = useStaged ? staged : parsePorcelain(git(["status", "--porcelain"], repoPath))
+
+ const selected = selectedFiles ? new Set(selectedFiles) : undefined
+
+ const files: FileChange[] = []
+ for (const entry of raw) {
+ if (isLockFile(entry.path)) continue
+ if (selected && !selected.has(entry.path)) continue
+
+ const status = mapStatus(entry.status)
+ const untracked = isUntracked(entry.status)
+
+ let diff: string
+ if (untracked) {
+ diff = `New untracked file: ${entry.path}`
+ } else if (status === "deleted") {
+ diff = useStaged
+ ? git(["diff", "--cached", "--", entry.path], repoPath)
+ : git(["diff", "--", entry.path], repoPath)
+ } else {
+ const raw = useStaged
+ ? git(["diff", "--cached", "--", entry.path], repoPath)
+ : git(["diff", "--", entry.path], repoPath)
+
+ // Detect binary files
+ if (raw.includes("Binary files") || raw.includes("GIT binary patch")) {
+ diff = `Binary file ${entry.path} has been modified`
+ } else {
+ diff = raw
+ }
+ }
+
+ // Truncate large diffs
+ if (diff.length > MAX_DIFF_LENGTH) {
+ diff = diff.slice(0, MAX_DIFF_LENGTH) + "\n... [truncated]"
+ }
+
+ files.push({ status, path: entry.path, diff })
+ }
+
+ return { branch, recentCommits, files }
+}
diff --git a/packages/opencode/src/commit-message/index.ts b/packages/opencode/src/commit-message/index.ts
new file mode 100644
index 0000000000..45fbb0f73a
--- /dev/null
+++ b/packages/opencode/src/commit-message/index.ts
@@ -0,0 +1,2 @@
+export { generateCommitMessage } from "./generate"
+export type { CommitMessageRequest, CommitMessageResponse, GitContext, FileChange } from "./types"
diff --git a/packages/opencode/src/commit-message/types.ts b/packages/opencode/src/commit-message/types.ts
new file mode 100644
index 0000000000..135fc13a3f
--- /dev/null
+++ b/packages/opencode/src/commit-message/types.ts
@@ -0,0 +1,29 @@
+export interface CommitMessageRequest {
+ /** Workspace/repo path */
+ path: string
+ /** Optional subset of files to include */
+ selectedFiles?: string[]
+ /** Previously generated message β when set, the LLM is asked to produce a different one */
+ previousMessage?: string
+}
+
+export interface CommitMessageResponse {
+ /** The generated commit message */
+ message: string
+}
+
+export interface GitContext {
+ /** Current branch name */
+ branch: string
+ /** Last 5 commit summaries */
+ recentCommits: string[]
+ /** File changes with status and diff content */
+ files: FileChange[]
+}
+
+export interface FileChange {
+ status: "added" | "modified" | "deleted" | "renamed"
+ path: string
+ /** Diff content, or placeholder for binary/untracked files */
+ diff: string
+}
diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts
new file mode 100644
index 0000000000..f9c14a2f73
--- /dev/null
+++ b/packages/opencode/src/kilocode/plan-followup.ts
@@ -0,0 +1,274 @@
+import { Agent } from "@/agent/agent"
+import { Bus } from "@/bus"
+import { TuiEvent } from "@/cli/cmd/tui/event"
+import { Identifier } from "@/id/id"
+import { Provider } from "@/provider/provider"
+import { Question } from "@/question"
+import { Session } from "@/session"
+import { LLM } from "@/session/llm"
+import { MessageV2 } from "@/session/message-v2"
+import { Todo } from "@/session/todo"
+import { Log } from "@/util/log"
+
+function toText(item: MessageV2.WithParts): string {
+ return item.parts
+ .filter((part): part is MessageV2.TextPart => part.type === "text")
+ .map((part) => part.text)
+ .join("\n")
+ .trim()
+}
+
+const HANDOVER_PROMPT = `You are summarizing a planning session to hand off to an implementation session.
+
+The plan itself will be provided separately β do NOT repeat it. Instead, focus on information discovered during planning that would help the implementing agent but is NOT already in the plan text.
+
+Produce a concise summary using this template:
+---
+## Discoveries
+
+[Key findings from code exploration β architecture patterns, gotchas, edge cases, relevant existing code that the plan references but doesn't fully explain]
+
+## Relevant Files
+
+[Structured list of files/directories that were read or discussed, with brief notes on what's relevant in each]
+
+## Implementation Notes
+
+[Any important context: conventions to follow, potential pitfalls, dependencies between steps, things the implementing agent should watch out for]
+---
+
+If there is nothing useful to add beyond what the plan already says, respond with an empty string.
+Keep the summary concise β focus on high-entropy information that would save the implementing agent time.`
+
+export function formatTodos(todos: Todo.Info[]): string {
+ if (!todos.length) return ""
+ const icons: Record = {
+ completed: "[x]",
+ in_progress: "[~]",
+ cancelled: "[-]",
+ }
+ return todos.map((t) => `- ${icons[t.status] ?? "[ ]"} ${t.content}`).join("\n")
+}
+
+export async function generateHandover(input: {
+ messages: MessageV2.WithParts[]
+ model: MessageV2.User["model"]
+ abort?: AbortSignal
+}): Promise {
+ const log = Log.create({ service: "plan.followup" })
+ try {
+ const agent = await Agent.get("compaction")
+ const model = agent?.model
+ ? await Provider.getModel(agent.model.providerID, agent.model.modelID)
+ : await Provider.getModel(input.model.providerID, input.model.modelID)
+
+ const sessionID = Identifier.ascending("session")
+ const userMsg: MessageV2.User = {
+ id: Identifier.ascending("message"),
+ sessionID,
+ role: "user",
+ time: { created: Date.now() },
+ agent: "plan",
+ model: input.model,
+ }
+
+ const stream = await LLM.stream({
+ agent: agent ?? {
+ name: "compaction",
+ mode: "subagent",
+ permission: [],
+ options: {},
+ },
+ user: userMsg,
+ tools: {},
+ model,
+ small: true,
+ messages: [
+ ...MessageV2.toModelMessages(input.messages, model),
+ {
+ role: "user" as const,
+ content: HANDOVER_PROMPT,
+ },
+ ],
+ abort: input.abort ? AbortSignal.any([input.abort, AbortSignal.timeout(60_000)]) : AbortSignal.timeout(60_000),
+ sessionID,
+ system: [],
+ retries: 1,
+ })
+
+ const result = await stream.text
+ return result.trim()
+ } catch (error) {
+ log.error("handover generation failed", { error })
+ return ""
+ }
+}
+
+export namespace PlanFollowup {
+ const log = Log.create({ service: "plan.followup" })
+
+ export const ANSWER_NEW_SESSION = "Start new session"
+ export const ANSWER_CONTINUE = "Continue here"
+
+ async function inject(input: {
+ sessionID: string
+ agent: string
+ model: MessageV2.User["model"]
+ text: string
+ synthetic?: boolean
+ }) {
+ const msg: MessageV2.User = {
+ id: Identifier.ascending("message"),
+ sessionID: input.sessionID,
+ role: "user",
+ time: {
+ created: Date.now(),
+ },
+ agent: input.agent,
+ model: input.model,
+ }
+ await Session.updateMessage(msg)
+ await Session.updatePart({
+ id: Identifier.ascending("part"),
+ messageID: msg.id,
+ sessionID: input.sessionID,
+ type: "text",
+ text: input.text,
+ synthetic: input.synthetic ?? true,
+ } satisfies MessageV2.TextPart)
+ }
+
+ function prompt(input: { sessionID: string; abort: AbortSignal }) {
+ const promise = Question.ask({
+ sessionID: input.sessionID,
+ questions: [
+ {
+ question: "Ready to implement?",
+ header: "Implement",
+ custom: true,
+ options: [
+ {
+ label: ANSWER_NEW_SESSION,
+ description: "Implement in a fresh session with a clean context",
+ },
+ {
+ label: ANSWER_CONTINUE,
+ description: "Implement the plan in this session",
+ },
+ ],
+ },
+ ],
+ })
+
+ const listener = () =>
+ Question.list().then((qs) => {
+ const match = qs.find((q) => q.sessionID === input.sessionID)
+ if (match) Question.reject(match.id)
+ })
+ input.abort.addEventListener("abort", listener, { once: true })
+
+ return promise
+ .catch((error) => {
+ if (error instanceof Question.RejectedError) return undefined
+ throw error
+ })
+ .finally(() => {
+ input.abort.removeEventListener("abort", listener)
+ })
+ }
+
+ async function startNew(input: {
+ sessionID: string
+ plan: string
+ messages: MessageV2.WithParts[]
+ model: MessageV2.User["model"]
+ abort?: AbortSignal
+ }) {
+ const [handover, todos] = await Promise.all([
+ generateHandover({ messages: input.messages, model: input.model, abort: input.abort }),
+ Todo.get(input.sessionID),
+ ])
+
+ const sections = [`Implement the following plan:\n\n${input.plan}`]
+
+ if (handover) {
+ sections.push(`## Handover from Planning Session\n\n${handover}`)
+ }
+
+ const todoList = formatTodos(todos)
+ if (todoList) {
+ sections.push(`## Todo List\n\n${todoList}`)
+ }
+
+ const next = await Session.create({})
+ await inject({
+ sessionID: next.id,
+ agent: "code",
+ model: input.model,
+ text: sections.join("\n\n"),
+ synthetic: false,
+ })
+ if (todos.length) {
+ await Todo.update({ sessionID: next.id, todos })
+ }
+ await Bus.publish(TuiEvent.SessionSelect, { sessionID: next.id })
+ void import("@/session/prompt")
+ .then((item) => item.SessionPrompt.loop({ sessionID: next.id }))
+ .catch((error) => {
+ log.error("failed to start follow-up session", { sessionID: next.id, error })
+ })
+ }
+
+ export async function ask(input: {
+ sessionID: string
+ messages: MessageV2.WithParts[]
+ abort: AbortSignal
+ }): Promise<"continue" | "break"> {
+ if (input.abort.aborted) return "break"
+
+ const latest = input.messages.slice().reverse()
+ const assistant = latest.find((msg) => msg.info.role === "assistant")
+ if (!assistant) return "break"
+
+ const plan = toText(assistant)
+ if (!plan) return "break"
+
+ const user = latest.find((msg) => msg.info.role === "user")?.info
+ if (!user || user.role !== "user" || !user.model) return "break"
+
+ const answers = await prompt({ sessionID: input.sessionID, abort: input.abort })
+ if (!answers) return "break"
+
+ const answer = answers[0]?.[0]?.trim()
+ if (!answer) return "break"
+
+ if (answer === ANSWER_NEW_SESSION) {
+ await startNew({
+ sessionID: input.sessionID,
+ plan,
+ messages: input.messages,
+ model: user.model,
+ abort: input.abort,
+ })
+ return "break"
+ }
+
+ if (answer === ANSWER_CONTINUE) {
+ await inject({
+ sessionID: input.sessionID,
+ agent: "code",
+ model: user.model,
+ text: "Implement the plan above.",
+ })
+ return "continue"
+ }
+
+ await inject({
+ sessionID: input.sessionID,
+ agent: "plan",
+ model: user.model,
+ text: answer,
+ })
+ return "continue"
+ }
+}
diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts
index 4068a1b7ee..cceec8454e 100644
--- a/packages/opencode/src/provider/models.ts
+++ b/packages/opencode/src/provider/models.ts
@@ -78,6 +78,8 @@ export namespace ModelsDev {
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
})
.optional(),
+ recommended: z.boolean().optional(), // kilocode_change
+ recommendedIndex: z.number().optional(), // kilocode_change
experimental: z.boolean().optional(),
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
options: z.record(z.string(), z.any()),
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index cf9c9f63ee..2dbb3617ed 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -621,6 +621,8 @@ export namespace Provider {
headers: z.record(z.string(), z.string()),
release_date: z.string(),
variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
+ recommended: z.boolean().optional(), // kilocode_change
+ recommendedIndex: z.number().optional(), // kilocode_change
})
.meta({
ref: "Model",
@@ -702,6 +704,8 @@ export namespace Provider {
},
release_date: model.release_date,
variants: {},
+ recommended: model.recommended, // kilocode_change
+ recommendedIndex: model.recommendedIndex, // kilocode_change
}
m.variants = mapValues(ProviderTransform.variants(m), (v) => v)
@@ -845,6 +849,8 @@ export namespace Provider {
family: model.family ?? existingModel?.family ?? "",
release_date: model.release_date ?? existingModel?.release_date ?? "",
variants: {},
+ recommended: model.recommended ?? existingModel?.recommended, // kilocode_change
+ recommendedIndex: model.recommendedIndex ?? existingModel?.recommendedIndex, // kilocode_change
}
const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})
parsedModel.variants = mapValues(
diff --git a/packages/opencode/src/server/routes/commit-message.ts b/packages/opencode/src/server/routes/commit-message.ts
new file mode 100644
index 0000000000..8ebc8d8bd5
--- /dev/null
+++ b/packages/opencode/src/server/routes/commit-message.ts
@@ -0,0 +1,44 @@
+import { Hono } from "hono"
+import { describeRoute, resolver, validator } from "hono-openapi"
+import z from "zod"
+import { generateCommitMessage } from "../../commit-message"
+import { lazy } from "../../util/lazy"
+import { errors } from "../error"
+
+export const CommitMessageRoutes = lazy(() =>
+ new Hono().post(
+ "/",
+ describeRoute({
+ summary: "Generate commit message",
+ description: "Generate a commit message using AI based on the current git diff.",
+ operationId: "commitMessage.generate",
+ responses: {
+ 200: {
+ description: "Generated commit message",
+ content: {
+ "application/json": {
+ schema: resolver(z.object({ message: z.string() })),
+ },
+ },
+ },
+ ...errors(400),
+ },
+ }),
+ validator(
+ "json",
+ z.object({
+ path: z.string().meta({ description: "Workspace/repo path" }),
+ selectedFiles: z.array(z.string()).optional().meta({ description: "Optional subset of files to include" }),
+ previousMessage: z
+ .string()
+ .optional()
+ .meta({ description: "Previously generated message β triggers regeneration with a different result" }),
+ }),
+ ),
+ async (c) => {
+ const body = c.req.valid("json")
+ const result = await generateCommitMessage(body)
+ return c.json({ message: result.message })
+ },
+ ),
+)
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 8bf922dfca..e883480e40 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -38,6 +38,7 @@ import type { ContentfulStatusCode } from "hono/utils/http-status"
import { websocket } from "hono/bun"
import { HTTPException } from "hono/http-exception"
import { errors } from "./error"
+import { CommitMessageRoutes } from "./routes/commit-message"
import { QuestionRoutes } from "./routes/question"
import { PermissionRoutes } from "./routes/permission"
import { GlobalRoutes } from "./routes/global"
@@ -235,6 +236,7 @@ export namespace Server {
.route("/question", QuestionRoutes())
.route("/provider", ProviderRoutes())
.route("/telemetry", TelemetryRoutes()) // kilocode_change
+ .route("/commit-message", CommitMessageRoutes()) // kilocode_change
// kilocode_change start - Kilo Gateway routes
.route(
"/kilo",
diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts
index 1b41f18f52..59a20e017a 100644
--- a/packages/opencode/src/session/llm.ts
+++ b/packages/opencode/src/session/llm.ts
@@ -26,7 +26,7 @@ import { DEFAULT_HEADERS } from "@/kilocode/const" // kilocode_change
import { Telemetry } from "@kilocode/kilo-telemetry" // kilocode_change
// kilocode_change start
import { getKiloProjectId } from "@/kilocode/project-id"
-import { HEADER_PROJECTID, HEADER_MACHINEID } from "@kilocode/kilo-gateway"
+import { HEADER_PROJECTID, HEADER_MACHINEID, HEADER_TASKID } from "@kilocode/kilo-gateway"
import { Identity } from "@kilocode/kilo-telemetry"
// kilocode_change end
@@ -239,9 +239,10 @@ export namespace LLM {
? DEFAULT_HEADERS // kilocode_change
: undefined),
...(isKilo && input.agent.name ? { "x-kilocode-mode": input.agent.name.toLowerCase() } : {}),
- // kilocode_change start - add project ID and machine ID headers for kilo provider
+ // kilocode_change start - add project ID, machine ID, and task ID headers for kilo provider
...(isKilo && kiloProjectId ? { [HEADER_PROJECTID]: kiloProjectId } : {}),
...(isKilo && machineId ? { [HEADER_MACHINEID]: machineId } : {}),
+ ...(isKilo ? { [HEADER_TASKID]: input.sessionID } : {}),
// kilocode_change end
...input.model.headers,
...headers,
diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts
index 8e7472e2f1..57c038afa2 100644
--- a/packages/opencode/src/session/processor.ts
+++ b/packages/opencode/src/session/processor.ts
@@ -15,6 +15,7 @@ import { Config } from "@/config/config"
import { SessionCompaction } from "./compaction"
import { PermissionNext } from "@/permission/next"
import { Question } from "@/question"
+import { Telemetry } from "@kilocode/kilo-telemetry" // kilocode_change
export namespace SessionProcessor {
const DOOM_LOOP_THRESHOLD = 3
@@ -34,6 +35,7 @@ export namespace SessionProcessor {
let blocked = false
let attempt = 0
let needsCompaction = false
+ let stepStart = 0 // kilocode_change
const result = {
get message() {
@@ -223,6 +225,7 @@ export namespace SessionProcessor {
throw value.error
case "start-step":
+ stepStart = performance.now() // kilocode_change
snapshot = await Snapshot.track()
await Session.updatePart({
id: Identifier.ascending("part"),
@@ -239,6 +242,26 @@ export namespace SessionProcessor {
usage: value.usage,
metadata: value.providerMetadata,
})
+ // kilocode_change start
+ if (
+ usage.tokens.input > 0 ||
+ usage.tokens.output > 0 ||
+ usage.tokens.cache.write > 0 ||
+ usage.tokens.cache.read > 0
+ ) {
+ Telemetry.trackLlmCompletion({
+ sessionId: input.sessionID,
+ provider: input.model.providerID,
+ model: input.model.id,
+ inputTokens: usage.tokens.input,
+ outputTokens: usage.tokens.output,
+ cacheReadTokens: usage.tokens.cache.read,
+ cacheWriteTokens: usage.tokens.cache.write,
+ cost: usage.cost,
+ completionTime: Math.round(performance.now() - stepStart),
+ })
+ }
+ // kilocode_change end
input.assistantMessage.finish = value.finishReason
input.assistantMessage.cost += usage.cost
input.assistantMessage.tokens = usage.tokens
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 4948fb5a29..ae6248a0ef 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -45,6 +45,7 @@ import { LLM } from "./llm"
import { iife } from "@/util/iife"
import { Shell } from "@/shell/shell"
import { Truncate } from "@/tool/truncation"
+import { PlanFollowup } from "@/kilocode/plan-followup" // kilocode_change
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -336,6 +337,12 @@ export namespace SessionPrompt {
!["tool-calls", "unknown"].includes(lastAssistant.finish) &&
lastUser.id < lastAssistant.id
) {
+ // kilocode_change start - ask follow-up after plan agent completes
+ if (lastUser.agent === "plan" && !abort.aborted && ["cli", "vscode"].includes(Flag.KILO_CLIENT)) {
+ const action = await PlanFollowup.ask({ sessionID, messages: msgs, abort })
+ if (action === "continue") continue
+ }
+ // kilocode_change end
log.info("exiting loop", { sessionID })
break
}
diff --git a/packages/opencode/test/kilocode/plan-followup.test.ts b/packages/opencode/test/kilocode/plan-followup.test.ts
new file mode 100644
index 0000000000..cf62150566
--- /dev/null
+++ b/packages/opencode/test/kilocode/plan-followup.test.ts
@@ -0,0 +1,547 @@
+import { describe, expect, spyOn, test } from "bun:test"
+import { Agent } from "../../src/agent/agent"
+import { Bus } from "../../src/bus"
+import { TuiEvent } from "../../src/cli/cmd/tui/event"
+import { Identifier } from "../../src/id/id"
+import { formatTodos, generateHandover, PlanFollowup } from "../../src/kilocode/plan-followup"
+import { Instance } from "../../src/project/instance"
+import { Provider } from "../../src/provider/provider"
+import { Question } from "../../src/question"
+import { Session } from "../../src/session"
+import { LLM } from "../../src/session/llm"
+import { MessageV2 } from "../../src/session/message-v2"
+import { SessionPrompt } from "../../src/session/prompt"
+import { Todo } from "../../src/session/todo"
+import { Log } from "../../src/util/log"
+import { tmpdir } from "../fixture/fixture"
+
+Log.init({ print: false })
+
+const model = {
+ providerID: "openai",
+ modelID: "gpt-4",
+}
+
+async function withInstance(fn: () => Promise) {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({ directory: tmp.path, fn })
+}
+
+async function seed(input: {
+ text: string
+ tools?: Array<{ tool: string; input: Record; output: string }>
+}) {
+ const session = await Session.create({})
+ const user = await Session.updateMessage({
+ id: Identifier.ascending("message"),
+ role: "user",
+ sessionID: session.id,
+ time: {
+ created: Date.now(),
+ },
+ agent: "plan",
+ model,
+ })
+ await Session.updatePart({
+ id: Identifier.ascending("part"),
+ messageID: user.id,
+ sessionID: session.id,
+ type: "text",
+ text: "Create a plan",
+ })
+
+ const assistant: MessageV2.Assistant = {
+ id: Identifier.ascending("message"),
+ role: "assistant",
+ sessionID: session.id,
+ time: {
+ created: Date.now(),
+ },
+ parentID: user.id,
+ modelID: model.modelID,
+ providerID: model.providerID,
+ mode: "plan",
+ agent: "plan",
+ path: {
+ cwd: Instance.directory,
+ root: Instance.worktree,
+ },
+ cost: 0,
+ tokens: {
+ total: 0,
+ input: 0,
+ output: 0,
+ reasoning: 0,
+ cache: {
+ read: 0,
+ write: 0,
+ },
+ },
+ finish: "end_turn",
+ }
+ await Session.updateMessage(assistant)
+ await Session.updatePart({
+ id: Identifier.ascending("part"),
+ messageID: assistant.id,
+ sessionID: session.id,
+ type: "text",
+ text: input.text,
+ })
+
+ for (const t of input.tools ?? []) {
+ await Session.updatePart({
+ id: Identifier.ascending("part"),
+ messageID: assistant.id,
+ sessionID: session.id,
+ type: "tool",
+ callID: Identifier.ascending("tool"),
+ tool: t.tool,
+ state: {
+ status: "completed",
+ input: t.input,
+ output: t.output,
+ title: t.tool,
+ metadata: {},
+ time: { start: Date.now(), end: Date.now() },
+ },
+ } satisfies MessageV2.ToolPart)
+ }
+
+ const messages = await Session.messages({ sessionID: session.id })
+ return {
+ sessionID: session.id,
+ messages,
+ }
+}
+
+async function latestUser(sessionID: string) {
+ const messages = await Session.messages({ sessionID })
+ return messages
+ .slice()
+ .reverse()
+ .find((item) => item.info.role === "user")
+}
+
+async function sessions() {
+ return Array.fromAsync(Session.list())
+}
+
+const fakeAgent: Agent.Info = {
+ name: "compaction",
+ mode: "subagent",
+ permission: [],
+ options: {},
+}
+
+const fakeModel = {
+ id: "gpt-4",
+ providerID: "openai",
+ limit: { context: 128000, input: 0 },
+ api: { id: "openai", npm: "@ai-sdk/openai" },
+ capabilities: {},
+} as Provider.Model
+
+function mockHandoverDeps(text: string, opts?: { agent?: Agent.Info | null }) {
+ const agentSpy = spyOn(Agent, "get").mockResolvedValue(
+ (opts?.agent === null ? undefined : (opts?.agent ?? fakeAgent)) as any,
+ )
+ const modelSpy = spyOn(Provider, "getModel").mockResolvedValue(fakeModel)
+ const llmSpy = spyOn(LLM, "stream").mockResolvedValue({
+ text: Promise.resolve(text),
+ } as any)
+ return {
+ agentSpy,
+ modelSpy,
+ llmSpy,
+ [Symbol.dispose]() {
+ agentSpy.mockRestore()
+ modelSpy.mockRestore()
+ llmSpy.mockRestore()
+ },
+ }
+}
+
+describe("plan follow-up", () => {
+ test("ask - returns break when dismissed", () =>
+ withInstance(async () => {
+ const seeded = await seed({ text: "1. Step one\n2. Step two" })
+ const pending = PlanFollowup.ask({
+ sessionID: seeded.sessionID,
+ messages: seeded.messages,
+ abort: AbortSignal.any([]),
+ })
+
+ const list = await Question.list()
+ expect(list).toHaveLength(1)
+ await Question.reject(list[0].id)
+
+ await expect(pending).resolves.toBe("break")
+ }))
+
+ test("ask - returns continue and creates code message on Continue here", () =>
+ withInstance(async () => {
+ const seeded = await seed({ text: "1. Build\n2. Test" })
+ const pending = PlanFollowup.ask({
+ sessionID: seeded.sessionID,
+ messages: seeded.messages,
+ abort: AbortSignal.any([]),
+ })
+
+ const list = await Question.list()
+ await Question.reply({
+ requestID: list[0].id,
+ answers: [[PlanFollowup.ANSWER_CONTINUE]],
+ })
+
+ await expect(pending).resolves.toBe("continue")
+
+ const user = await latestUser(seeded.sessionID)
+ expect(user?.info.role).toBe("user")
+ if (!user || user.info.role !== "user") return
+ expect(user.info.agent).toBe("code")
+
+ const part = user.parts.find((item) => item.type === "text")
+ expect(part?.type).toBe("text")
+ if (!part || part.type !== "text") return
+ expect(part.text).toBe("Implement the plan above.")
+ expect(part.synthetic).toBe(true)
+ }))
+
+ test("ask - returns continue and creates plan message for custom text", () =>
+ withInstance(async () => {
+ const seeded = await seed({ text: "1. Build\n2. Test" })
+ const pending = PlanFollowup.ask({
+ sessionID: seeded.sessionID,
+ messages: seeded.messages,
+ abort: AbortSignal.any([]),
+ })
+
+ await Question.reply({
+ requestID: (await Question.list())[0].id,
+ answers: [["Add rollback support too"]],
+ })
+
+ await expect(pending).resolves.toBe("continue")
+
+ const user = await latestUser(seeded.sessionID)
+ expect(user?.info.role).toBe("user")
+ if (!user || user.info.role !== "user") return
+ expect(user.info.agent).toBe("plan")
+
+ const part = user.parts.find((item) => item.type === "text")
+ expect(part?.type).toBe("text")
+ if (!part || part.type !== "text") return
+ expect(part.text).toBe("Add rollback support too")
+ expect(part.synthetic).toBe(true)
+ }))
+
+ test("ask - creates a new session on Start new session with handover and todos", () =>
+ withInstance(async () => {
+ const loop = spyOn(SessionPrompt, "loop").mockResolvedValue({
+ info: {
+ id: "msg_test",
+ role: "assistant",
+ sessionID: "ses_test",
+ time: {
+ created: Date.now(),
+ },
+ parentID: "msg_parent",
+ modelID: "test",
+ providerID: "test",
+ mode: "code",
+ agent: "code",
+ path: {
+ cwd: "/tmp",
+ root: "/tmp",
+ },
+ cost: 0,
+ tokens: {
+ total: 0,
+ input: 0,
+ output: 0,
+ reasoning: 0,
+ cache: {
+ read: 0,
+ write: 0,
+ },
+ },
+ },
+ parts: [],
+ })
+ using _mocks = mockHandoverDeps(
+ "## Discoveries\n\nFound REST endpoints in src/api.ts\n\n## Relevant Files\n\n- src/api.ts: REST endpoints\n- src/db.ts: Database layer",
+ )
+ using _loop = {
+ [Symbol.dispose]() {
+ loop.mockRestore()
+ },
+ }
+ const seeded = await seed({
+ text: "1. Add API\n2. Add tests",
+ })
+
+ await Todo.update({
+ sessionID: seeded.sessionID,
+ todos: [
+ { id: "1", content: "Add API endpoint", status: "completed", priority: "high" },
+ { id: "2", content: "Write tests", status: "pending", priority: "medium" },
+ ],
+ })
+
+ const before = await sessions()
+ const created = [] as string[]
+ const unsub = Bus.subscribe(TuiEvent.SessionSelect, (event) => {
+ created.push(event.properties.sessionID)
+ })
+
+ const pending = PlanFollowup.ask({
+ sessionID: seeded.sessionID,
+ messages: seeded.messages,
+ abort: AbortSignal.any([]),
+ })
+
+ await Question.reply({
+ requestID: (await Question.list())[0].id,
+ answers: [[PlanFollowup.ANSWER_NEW_SESSION]],
+ })
+
+ await expect(pending).resolves.toBe("break")
+ unsub()
+
+ const after = await sessions()
+ const prev = new Set(before.map((item) => item.id))
+ const added = after.filter((item) => !prev.has(item.id))
+ expect(added).toHaveLength(1)
+ expect(created).toHaveLength(1)
+ expect(loop).toHaveBeenCalledTimes(1)
+ expect(_mocks.llmSpy).toHaveBeenCalledTimes(1)
+
+ const newSessionID = created[0]
+ expect(added[0].id).toBe(newSessionID)
+ const messages = await Session.messages({ sessionID: newSessionID })
+ const user = messages.find((item) => item.info.role === "user")
+ expect(user?.info.role).toBe("user")
+ if (!user || user.info.role !== "user") throw new Error("expected seeded user message")
+ expect(user.info.agent).toBe("code")
+
+ const part = user.parts.find((item) => item.type === "text")
+ expect(part?.type).toBe("text")
+ if (!part || part.type !== "text") throw new Error("expected text part")
+ expect(part.text).toContain("Implement the following plan:")
+ expect(part.text).toContain("1. Add API\n2. Add tests")
+ expect(part.text).toContain("## Handover from Planning Session")
+ expect(part.text).toContain("Found REST endpoints in src/api.ts")
+ expect(part.text).toContain("## Todo List")
+ expect(part.text).toContain("[x] Add API endpoint")
+ expect(part.text).toContain("[ ] Write tests")
+ expect(part.synthetic).toBe(false)
+
+ const newTodos = await Todo.get(newSessionID)
+ expect(newTodos).toHaveLength(2)
+ expect(newTodos).toContainEqual({ id: "1", content: "Add API endpoint", status: "completed", priority: "high" })
+ expect(newTodos).toContainEqual({ id: "2", content: "Write tests", status: "pending", priority: "medium" })
+
+ SessionPrompt.cancel(newSessionID)
+ }))
+
+ test("ask - new session omits handover section when LLM returns empty", () =>
+ withInstance(async () => {
+ const loop = spyOn(SessionPrompt, "loop").mockResolvedValue({
+ info: {
+ id: "msg_test",
+ role: "assistant",
+ sessionID: "ses_test",
+ time: { created: Date.now() },
+ parentID: "msg_parent",
+ modelID: "test",
+ providerID: "test",
+ mode: "code",
+ agent: "code",
+ path: { cwd: "/tmp", root: "/tmp" },
+ cost: 0,
+ tokens: {
+ total: 0,
+ input: 0,
+ output: 0,
+ reasoning: 0,
+ cache: { read: 0, write: 0 },
+ },
+ },
+ parts: [],
+ })
+ using _mocks = mockHandoverDeps("")
+ using _loop = {
+ [Symbol.dispose]() {
+ loop.mockRestore()
+ },
+ }
+ const seeded = await seed({ text: "1. Add API\n2. Add tests" })
+ const created = [] as string[]
+ const unsub = Bus.subscribe(TuiEvent.SessionSelect, (event) => {
+ created.push(event.properties.sessionID)
+ })
+
+ const pending = PlanFollowup.ask({
+ sessionID: seeded.sessionID,
+ messages: seeded.messages,
+ abort: AbortSignal.any([]),
+ })
+
+ await Question.reply({
+ requestID: (await Question.list())[0].id,
+ answers: [[PlanFollowup.ANSWER_NEW_SESSION]],
+ })
+
+ await expect(pending).resolves.toBe("break")
+ unsub()
+
+ const messages = await Session.messages({ sessionID: created[0] })
+ const user = messages.find((item) => item.info.role === "user")
+ if (!user || user.info.role !== "user") throw new Error("expected user message")
+ const part = user.parts.find((item) => item.type === "text")
+ if (!part || part.type !== "text") throw new Error("expected text part")
+ expect(part.text).toContain("Implement the following plan:")
+ expect(part.text).not.toContain("## Handover from Planning Session")
+ expect(part.text).not.toContain("## Todo List")
+
+ SessionPrompt.cancel(created[0])
+ }))
+
+ test("ask - returns break when assistant text is empty", () =>
+ withInstance(async () => {
+ const seeded = await seed({ text: " " })
+ const result = await PlanFollowup.ask({
+ sessionID: seeded.sessionID,
+ messages: seeded.messages,
+ abort: AbortSignal.any([]),
+ })
+
+ expect(result).toBe("break")
+ expect(await Question.list()).toHaveLength(0)
+ }))
+
+ test("ask - returns break when already aborted", () =>
+ withInstance(async () => {
+ const abort = new AbortController()
+ abort.abort()
+
+ const result = await PlanFollowup.ask({
+ sessionID: "ses_test",
+ messages: [],
+ abort: abort.signal,
+ })
+
+ expect(result).toBe("break")
+ }))
+
+ test("ask - returns break when aborted while question is pending", () =>
+ withInstance(async () => {
+ const abort = new AbortController()
+ const seeded = await seed({ text: "1. Step one\n2. Step two" })
+ const pending = PlanFollowup.ask({
+ sessionID: seeded.sessionID,
+ messages: seeded.messages,
+ abort: abort.signal,
+ })
+
+ const list = await Question.list()
+ expect(list).toHaveLength(1)
+
+ abort.abort()
+
+ await expect(pending).resolves.toBe("break")
+ expect(await Question.list()).toHaveLength(0)
+ }))
+
+ test("ask - returns break for blank custom answer", () =>
+ withInstance(async () => {
+ const seeded = await seed({ text: "1. Build\n2. Test" })
+ const pending = PlanFollowup.ask({
+ sessionID: seeded.sessionID,
+ messages: seeded.messages,
+ abort: AbortSignal.any([]),
+ })
+
+ await Question.reply({
+ requestID: (await Question.list())[0].id,
+ answers: [[" "]],
+ })
+
+ await expect(pending).resolves.toBe("break")
+ expect((await Session.messages({ sessionID: seeded.sessionID })).length).toBe(2)
+ }))
+
+ test("formatTodos - returns empty string for no todos", () => {
+ expect(formatTodos([])).toBe("")
+ })
+
+ test("formatTodos - formats todos with status icons", () => {
+ const todos: Todo.Info[] = [
+ { id: "1", content: "Set up project", status: "completed", priority: "high" },
+ { id: "2", content: "Write code", status: "in_progress", priority: "high" },
+ { id: "3", content: "Add tests", status: "pending", priority: "medium" },
+ { id: "4", content: "Dropped task", status: "cancelled", priority: "low" },
+ ]
+ const result = formatTodos(todos)
+ expect(result).toBe("- [x] Set up project\n- [~] Write code\n- [ ] Add tests\n- [-] Dropped task")
+ })
+
+ test("generateHandover - returns empty string on LLM.stream failure", () =>
+ withInstance(async () => {
+ const agentSpy = spyOn(Agent, "get").mockResolvedValue(fakeAgent)
+ const modelSpy = spyOn(Provider, "getModel").mockResolvedValue(fakeModel)
+ const llmSpy = spyOn(LLM, "stream").mockRejectedValue(new Error("provider unavailable"))
+ using _ = {
+ [Symbol.dispose]() {
+ agentSpy.mockRestore()
+ modelSpy.mockRestore()
+ llmSpy.mockRestore()
+ },
+ }
+ const seeded = await seed({ text: "1. Build\n2. Test" })
+ const result = await generateHandover({ messages: seeded.messages, model })
+ expect(result).toBe("")
+ }))
+
+ test("generateHandover - returns empty string on stream.text rejection", () =>
+ withInstance(async () => {
+ const agentSpy = spyOn(Agent, "get").mockResolvedValue(fakeAgent)
+ const modelSpy = spyOn(Provider, "getModel").mockResolvedValue(fakeModel)
+ const textPromise = new Promise((_, reject) => {
+ setTimeout(() => reject(new Error("stream aborted")), 0)
+ })
+ textPromise.catch(() => {})
+ const llmSpy = spyOn(LLM, "stream").mockResolvedValue({
+ text: textPromise,
+ } as any)
+ using _ = {
+ [Symbol.dispose]() {
+ agentSpy.mockRestore()
+ modelSpy.mockRestore()
+ llmSpy.mockRestore()
+ },
+ }
+ const seeded = await seed({ text: "1. Build\n2. Test" })
+ const result = await generateHandover({ messages: seeded.messages, model })
+ expect(result).toBe("")
+ }))
+
+ test("generateHandover - uses fallback agent when compaction agent is not configured", () =>
+ withInstance(async () => {
+ using mocks = mockHandoverDeps("## Discoveries\n\nFallback works", { agent: null })
+ const seeded = await seed({ text: "1. Build\n2. Test" })
+ const result = await generateHandover({ messages: seeded.messages, model })
+ expect(result).toBe("## Discoveries\n\nFallback works")
+ expect(mocks.agentSpy).toHaveBeenCalledWith("compaction")
+ expect(mocks.llmSpy).toHaveBeenCalledTimes(1)
+ }))
+
+ test("generateHandover - returns LLM output on success", () =>
+ withInstance(async () => {
+ using mocks = mockHandoverDeps("## Discoveries\n\nKey finding here")
+ const seeded = await seed({ text: "1. Build\n2. Test" })
+ const result = await generateHandover({ messages: seeded.messages, model })
+ expect(result).toBe("## Discoveries\n\nKey finding here")
+ expect(mocks.llmSpy).toHaveBeenCalledTimes(1)
+ }))
+})
diff --git a/packages/plugin/package.json b/packages/plugin/package.json
index 104fddb251..2e26b8796c 100644
--- a/packages/plugin/package.json
+++ b/packages/plugin/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/plugin",
- "version": "1.0.23",
+ "version": "1.0.24",
"type": "module",
"license": "MIT",
"repository": {
diff --git a/packages/script/package.json b/packages/script/package.json
index ad7654bee6..2ebb9bf029 100644
--- a/packages/script/package.json
+++ b/packages/script/package.json
@@ -8,7 +8,7 @@
"exports": {
".": "./src/index.ts"
},
- "version": "1.0.23",
+ "version": "1.0.24",
"dependencies": {},
"peerDependencies": {}
}
diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json
index c5f14f8ae1..12c9e9ae35 100644
--- a/packages/sdk/js/package.json
+++ b/packages/sdk/js/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/sdk",
- "version": "1.0.23",
+ "version": "1.0.24",
"type": "module",
"license": "MIT",
"repository": {
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index 922c3045b0..0609acc42a 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -14,6 +14,8 @@ import type {
AuthSetErrors,
AuthSetResponses,
CommandListResponses,
+ CommitMessageGenerateErrors,
+ CommitMessageGenerateResponses,
Config as Config3,
ConfigGetResponses,
ConfigProvidersResponses,
@@ -2217,6 +2219,51 @@ export class Telemetry extends HeyApiClient {
}
}
+export class CommitMessage extends HeyApiClient {
+ /**
+ * Generate commit message
+ *
+ * Generate a commit message using AI based on the current git diff.
+ */
+ public generate(
+ parameters?: {
+ directory?: string
+ path?: string
+ selectedFiles?: Array
+ previousMessage?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "body", key: "path" },
+ { in: "body", key: "selectedFiles" },
+ { in: "body", key: "previousMessage" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post<
+ CommitMessageGenerateResponses,
+ CommitMessageGenerateErrors,
+ ThrowOnError
+ >({
+ url: "/commit-message",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+}
+
export class Organization extends HeyApiClient {
/**
* Update Kilo Gateway organization
@@ -3439,6 +3486,11 @@ export class OpencodeClient extends HeyApiClient {
return (this._telemetry ??= new Telemetry({ client: this.client }))
}
+ private _commitMessage?: CommitMessage
+ get commitMessage(): CommitMessage {
+ return (this._commitMessage ??= new CommitMessage({ client: this.client }))
+ }
+
private _kilo?: Kilo
get kilo(): Kilo {
return (this._kilo ??= new Kilo({ client: this.client }))
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 31257c0948..bc057ba244 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -1564,6 +1564,8 @@ export type ProviderConfig = {
input: Array<"text" | "audio" | "image" | "video" | "pdf">
output: Array<"text" | "audio" | "image" | "video" | "pdf">
}
+ recommended?: boolean
+ recommendedIndex?: number
experimental?: boolean
status?: "alpha" | "beta" | "deprecated"
options?: {
@@ -2017,6 +2019,8 @@ export type Model = {
[key: string]: unknown
}
}
+ recommended?: boolean
+ recommendedIndex?: number
}
export type Provider = {
@@ -4087,6 +4091,8 @@ export type ProviderListResponses = {
input: Array<"text" | "audio" | "image" | "video" | "pdf">
output: Array<"text" | "audio" | "image" | "video" | "pdf">
}
+ recommended?: boolean
+ recommendedIndex?: number
experimental?: boolean
status?: "alpha" | "beta" | "deprecated"
options: {
@@ -4252,6 +4258,48 @@ export type TelemetryCaptureResponses = {
export type TelemetryCaptureResponse = TelemetryCaptureResponses[keyof TelemetryCaptureResponses]
+export type CommitMessageGenerateData = {
+ body?: {
+ /**
+ * Workspace/repo path
+ */
+ path: string
+ /**
+ * Optional subset of files to include
+ */
+ selectedFiles?: Array
+ /**
+ * Previously generated message β triggers regeneration with a different result
+ */
+ previousMessage?: string
+ }
+ path?: never
+ query?: {
+ directory?: string
+ }
+ url: "/commit-message"
+}
+
+export type CommitMessageGenerateErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type CommitMessageGenerateError = CommitMessageGenerateErrors[keyof CommitMessageGenerateErrors]
+
+export type CommitMessageGenerateResponses = {
+ /**
+ * Generated commit message
+ */
+ 200: {
+ message: string
+ }
+}
+
+export type CommitMessageGenerateResponse = CommitMessageGenerateResponses[keyof CommitMessageGenerateResponses]
+
export type KiloProfileData = {
body?: never
path?: never
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index 186d82d0be..ab3654da20 100644
--- a/packages/sdk/openapi.json
+++ b/packages/sdk/openapi.json
@@ -3768,6 +3768,12 @@
},
"required": ["input", "output"]
},
+ "recommended": {
+ "type": "boolean"
+ },
+ "recommendedIndex": {
+ "type": "number"
+ },
"experimental": {
"type": "boolean"
},
@@ -4116,6 +4122,83 @@
]
}
},
+ "/commit-message": {
+ "post": {
+ "operationId": "commitMessage.generate",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "directory",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "summary": "Generate commit message",
+ "description": "Generate a commit message using AI based on the current git diff.",
+ "responses": {
+ "200": {
+ "description": "Generated commit message",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string"
+ }
+ },
+ "required": ["message"]
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BadRequestError"
+ }
+ }
+ }
+ }
+ },
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "path": {
+ "description": "Workspace/repo path",
+ "type": "string"
+ },
+ "selectedFiles": {
+ "description": "Optional subset of files to include",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "previousMessage": {
+ "description": "Previously generated message β triggers regeneration with a different result",
+ "type": "string"
+ }
+ },
+ "required": ["path"]
+ }
+ }
+ }
+ },
+ "x-codeSamples": [
+ {
+ "lang": "js",
+ "source": "import { createOpencodeClient } from \"@kilocode/sdk\n\nconst client = createOpencodeClient()\nawait client.commitMessage.generate({\n ...\n})"
+ }
+ ]
+ }
+ },
"/kilo/profile": {
"get": {
"operationId": "kilo.profile",
@@ -9812,6 +9895,12 @@
},
"required": ["input", "output"]
},
+ "recommended": {
+ "type": "boolean"
+ },
+ "recommendedIndex": {
+ "type": "number"
+ },
"experimental": {
"type": "boolean"
},
@@ -10795,6 +10884,12 @@
},
"additionalProperties": {}
}
+ },
+ "recommended": {
+ "type": "boolean"
+ },
+ "recommendedIndex": {
+ "type": "number"
}
},
"required": [
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 0088587ea9..a32083710b 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
- "version": "1.0.23",
+ "version": "1.0.24",
"type": "module",
"license": "MIT",
"exports": {
diff --git a/packages/util/package.json b/packages/util/package.json
index ecde7ab01e..ef4ef23a8a 100644
--- a/packages/util/package.json
+++ b/packages/util/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
- "version": "1.0.23",
+ "version": "1.0.24",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/script/upstream/package.json b/script/upstream/package.json
index 13f42bca37..7b42e05a99 100644
--- a/script/upstream/package.json
+++ b/script/upstream/package.json
@@ -1,6 +1,6 @@
{
"name": "@kilocode/upstream-merge",
- "version": "1.0.23",
+ "version": "1.0.24",
"private": true,
"type": "module",
"description": "Scripts for automating upstream opencode merges into Kilo",
diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json
index 3ed809ece6..f92e07029d 100644
--- a/sdks/vscode/package.json
+++ b/sdks/vscode/package.json
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
- "version": "1.0.23",
+ "version": "1.0.24",
"publisher": "sst-dev",
"repository": {
"type": "git",