mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-10-28 02:42:15 +08:00
Merge remote-tracking branch 'origin/main' into puddle-barometer
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Restore Agent Manager sections and worktree drag-and-drop when multiple projects are shown, with ordering and section moves scoped to the owning project.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Start voice input faster on macOS with native AVFoundation capture.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Restore keyboard focus to the prompt or pending question when switching Agent Manager worktrees and sessions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Add bounded, context-aware signal-to-noise controls to grep searches.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Update model search results instantly and keep the active match visible while typing.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Allow Agent Manager sessions to move their worktree between sections or ungroup it through the `agent_manager` tool.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Bind voice input to Cmd/Ctrl+K in Kilo prompt and review comment fields, with hold-to-talk and release-to-send support.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Preserve prompt or Agent Manager terminal focus independently for each session when switching sessions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Start a fresh shell in the same Agent Manager terminal tab when the user types after the terminal ends.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
---
|
||||
|
||||
A second identical large paste expands its collapsed prompt placeholder.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Configure a model and reasoning variant for each workflow from Agent Behaviour settings.
|
||||
@@ -0,0 +1,157 @@
|
||||
---
|
||||
name: chart
|
||||
description: Use when the user asks to visualize data with charts, graphs, or plots using the `chart` tool (bar, line, scatter, pie, time series, etc.).
|
||||
---
|
||||
|
||||
# Data Visualization
|
||||
|
||||
The `chart` tool is ALWAYS available in this environment. When the user asks to visualize data (charts, graphs, plots), you MUST call the `chart` tool. Never output the config as text, never say the tool is unavailable, never suggest external renderers. Always use the tool call — it is the only correct response for data visualization requests. Do NOT repeat or echo the config JSON in your text response.
|
||||
|
||||
Use the `chart` tool only when the user explicitly asks for a chart, graph, or plot. Only use these supported Chart.js v4 types: `bar`, `bubble`, `pie`, `doughnut`, `line`, `mixed`, `polarArea`, `radar`, `scatter`. For area charts, use `line` with `fill: true` on the dataset — do NOT use `area` as a type.
|
||||
|
||||
Use mermaid fenced code blocks (` ```mermaid `) when:
|
||||
- The user asks for a diagram, flowchart, sequence diagram, ER diagram, or architecture diagram
|
||||
- Visualizing relationships, processes, or structure — not data values
|
||||
|
||||
Mermaid is NOT a tool and is NOT Chart.js — never call the `chart` tool for mermaid diagrams. Just write the mermaid syntax directly in your text response inside a fenced code block. No tool call needed.
|
||||
|
||||
Do not use either for: code, text, or data that is already clear in prose or table form.
|
||||
|
||||
The `chart` tool input accepts:
|
||||
- `title` (string) — short label shown in the tool header
|
||||
- `description` (string, optional) — subtitle shown below the title
|
||||
- `spec` (string) — a Chart.js config object as a JSON string
|
||||
|
||||
The `spec` field must be a Chart.js config JSON string with `type`, `data`, and optionally `options`. Examples:
|
||||
|
||||
Bar chart:
|
||||
```json
|
||||
{
|
||||
"type": "bar",
|
||||
"data": {
|
||||
"labels": ["A", "B", "C"],
|
||||
"datasets": [{ "label": "Value", "data": [10, 20, 15] }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Area chart (line with fill):
|
||||
```json
|
||||
{
|
||||
"type": "line",
|
||||
"data": {
|
||||
"labels": ["Jan", "Feb", "Mar", "Apr"],
|
||||
"datasets": [{ "label": "Value", "data": [10, 28, 19, 45], "fill": true }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Line chart:
|
||||
```json
|
||||
{
|
||||
"type": "line",
|
||||
"data": {
|
||||
"labels": ["Jan", "Feb", "Mar", "Apr"],
|
||||
"datasets": [{ "label": "Value", "data": [10, 28, 19, 45], "fill": false }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Scatter plot:
|
||||
```json
|
||||
{
|
||||
"type": "scatter",
|
||||
"data": {
|
||||
"datasets": [{
|
||||
"label": "Points",
|
||||
"data": [{ "x": 1, "y": 5 }, { "x": 2, "y": 8 }, { "x": 3, "y": 3 }]
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Time series:
|
||||
```json
|
||||
{
|
||||
"type": "line",
|
||||
"data": {
|
||||
"labels": ["2024-01", "2024-02", "2024-03", "2024-04"],
|
||||
"datasets": [{ "label": "Value", "data": [120, 145, 132, 178], "fill": true }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pie chart:
|
||||
```json
|
||||
{
|
||||
"type": "pie",
|
||||
"data": {
|
||||
"labels": ["A", "B", "C"],
|
||||
"datasets": [{ "data": [30, 50, 20] }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Doughnut chart:
|
||||
```json
|
||||
{
|
||||
"type": "doughnut",
|
||||
"data": {
|
||||
"labels": ["A", "B", "C"],
|
||||
"datasets": [{ "data": [30, 50, 20] }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Radar chart:
|
||||
```json
|
||||
{
|
||||
"type": "radar",
|
||||
"data": {
|
||||
"labels": ["Speed", "Power", "Agility", "Stamina"],
|
||||
"datasets": [{ "label": "Player", "data": [80, 60, 90, 70] }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Bubble chart:
|
||||
```json
|
||||
{
|
||||
"type": "bubble",
|
||||
"data": {
|
||||
"datasets": [{
|
||||
"label": "Group A",
|
||||
"data": [{ "x": 10, "y": 20, "r": 8 }, { "x": 15, "y": 10, "r": 5 }]
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Polar area chart:
|
||||
```json
|
||||
{
|
||||
"type": "polarArea",
|
||||
"data": {
|
||||
"labels": ["A", "B", "C", "D"],
|
||||
"datasets": [{ "data": [11, 16, 7, 14] }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Mixed chart (bar + line):
|
||||
```json
|
||||
{
|
||||
"type": "bar",
|
||||
"data": {
|
||||
"labels": ["Jan", "Feb", "Mar"],
|
||||
"datasets": [
|
||||
{ "type": "bar", "label": "Revenue", "data": [100, 120, 90] },
|
||||
{ "type": "line", "label": "Trend", "data": [95, 115, 100] }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You may customize colors by setting `backgroundColor` and `borderColor` arrays on datasets. The renderer handles sizing — do not set width or height.
|
||||
|
||||
Only include `scales` in `options` for cartesian chart types: `bar`, `line`, `scatter`, `bubble`. Do NOT include `scales` for `pie`, `doughnut`, `polarArea`, `radar`, or `mixed` — it will cause them to fail.
|
||||
@@ -362,6 +362,7 @@
|
||||
"@solid-primitives/media": "2.3.3",
|
||||
"@solid-primitives/resize-observer": "2.1.5",
|
||||
"@solid-primitives/rootless": "1.5.2",
|
||||
"chart.js": "4.5.1",
|
||||
"diff": "catalog:",
|
||||
"lucide-solid": "0.576.0",
|
||||
"motion": "12.34.5",
|
||||
@@ -1557,6 +1558,8 @@
|
||||
|
||||
"@kobalte/utils": ["@kobalte/utils@0.9.1", "", { "dependencies": { "@solid-primitives/event-listener": "^2.2.14", "@solid-primitives/keyed": "^1.2.0", "@solid-primitives/map": "^0.4.7", "@solid-primitives/media": "^2.2.4", "@solid-primitives/props": "^3.1.8", "@solid-primitives/refs": "^1.0.5", "@solid-primitives/utils": "^6.2.1" }, "peerDependencies": { "solid-js": "^1.8.8" } }, "sha512-eeU60A3kprIiBDAfv9gUJX1tXGLuZiKMajUfSQURAF2pk4ZoMYiqIzmrMBvzcxP39xnYttgTyQEVLwiTZnrV4w=="],
|
||||
|
||||
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
|
||||
|
||||
"@kwsites/file-exists": ["@kwsites/file-exists@1.1.1", "", { "dependencies": { "debug": "^4.1.1" } }, "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw=="],
|
||||
|
||||
"@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="],
|
||||
@@ -2865,6 +2868,8 @@
|
||||
|
||||
"chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="],
|
||||
|
||||
"chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="],
|
||||
|
||||
"check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
|
||||
|
||||
"cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-sid/FCql4CmiAb1MNGMH33fwNs6esv9UNC819Yb03bQ=",
|
||||
"aarch64-linux": "sha256-1H1873nLqmcKrumAjnjPPts5+EyJLBm1Z5VKsOYANeg=",
|
||||
"aarch64-darwin": "sha256-i7D5RSPIVxXNY1gUnsYdFhVU2FxYEcjqYcQx6zZrJjg=",
|
||||
"x86_64-darwin": "sha256-5lSTqolFVcaKqN6ndKGJLcCUP8JQ8i60np+3eAulnZg="
|
||||
"x86_64-linux": "sha256-gQFDvDbTsTNge6ji5hToanPCbXHUqbx4ccAN/isBl3c=",
|
||||
"aarch64-linux": "sha256-iLfVhjLJoCyudlcdw1tPW0MYt4fLdgxooC9lGb0wTy0=",
|
||||
"aarch64-darwin": "sha256-ohr/Nu0GbHAECsqpB93AaNBbKHWJTjbqQgEwFoim5kw=",
|
||||
"x86_64-darwin": "sha256-vWaL8d4k01J4R9ROd44BJYTsPLGt7yiismBNThlwlOY="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export class Info extends Schema.Class<Info>("CommandV2.Info")({
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
agent: Schema.String.pipe(Schema.optional),
|
||||
model: ModelV2.Ref.pipe(Schema.optional),
|
||||
variant: ModelV2.VariantID.pipe(Schema.optional), // kilocode_change - support variant-only command overrides
|
||||
subtask: Schema.Boolean.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as ConfigCommand from "./command"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigV2.Command")({
|
||||
template: Schema.String,
|
||||
template: Schema.String.pipe(Schema.optional), // kilocode_change - allow partial command overrides
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
agent: Schema.String.pipe(Schema.optional),
|
||||
model: Schema.String.pipe(Schema.optional),
|
||||
|
||||
@@ -29,22 +29,33 @@ export const Plugin = PluginV2.define({
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
|
||||
yield* transform((editor) => {
|
||||
for (const document of documents) {
|
||||
for (const [name, command] of Object.entries(document.commands ?? {})) {
|
||||
editor.update(name, (item) => {
|
||||
item.template = command.template
|
||||
if (command.description !== undefined) item.description = command.description
|
||||
if (command.agent !== undefined) item.agent = command.agent
|
||||
if (command.model !== undefined) {
|
||||
const model = ModelV2.parse(command.model)
|
||||
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
|
||||
}
|
||||
if (command.variant !== undefined && item.model !== undefined) {
|
||||
item.model.variant = ModelV2.VariantID.make(command.variant)
|
||||
}
|
||||
if (command.subtask !== undefined) item.subtask = command.subtask
|
||||
})
|
||||
const items = documents.flatMap((document) => Object.entries(document.commands ?? {}))
|
||||
// Register every template first, preserving the normal source priority for
|
||||
// metadata in the second pass. // kilocode_change
|
||||
for (const [name, command] of items) {
|
||||
if (command.template === undefined) {
|
||||
continue
|
||||
}
|
||||
const template = command.template
|
||||
editor.update(name, (item) => {
|
||||
item.template = template
|
||||
})
|
||||
}
|
||||
for (const [name, command] of items) {
|
||||
if (command.template === undefined && !editor.get(name)) continue // kilocode_change
|
||||
editor.update(name, (item) => {
|
||||
if (command.description !== undefined) item.description = command.description
|
||||
if (command.agent !== undefined) item.agent = command.agent
|
||||
if (command.model !== undefined) {
|
||||
const model = ModelV2.parse(command.model)
|
||||
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
|
||||
}
|
||||
if (command.variant !== undefined) item.variant = ModelV2.VariantID.make(command.variant) // kilocode_change
|
||||
if (command.variant !== undefined && item.model !== undefined) {
|
||||
item.model.variant = ModelV2.VariantID.make(command.variant)
|
||||
}
|
||||
if (command.subtask !== undefined) item.subtask = command.subtask
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Match } from "../filesystem/schema"
|
||||
|
||||
export interface Options {
|
||||
readonly context?: number
|
||||
readonly literal?: boolean
|
||||
readonly ignoreCase?: boolean
|
||||
}
|
||||
|
||||
export type GrepMatch = Match & {
|
||||
readonly context: boolean
|
||||
readonly textTruncated: boolean
|
||||
}
|
||||
|
||||
export const flags = (input: Options) => [
|
||||
...(input.literal ? ["--fixed-strings"] : []),
|
||||
...(input.ignoreCase ? ["--ignore-case"] : []),
|
||||
...(input.context ? [`--context=${input.context}`] : []),
|
||||
]
|
||||
|
||||
export const stop = (limit: number) => {
|
||||
let matches = 0
|
||||
return (row: { readonly context: boolean }) => !row.context && ++matches > limit
|
||||
}
|
||||
|
||||
export const select = <
|
||||
A extends {
|
||||
readonly context: boolean
|
||||
readonly path: { readonly text: string }
|
||||
readonly line_number: number
|
||||
},
|
||||
>(
|
||||
input: { readonly limit: number; readonly context?: number },
|
||||
items: readonly A[],
|
||||
) => {
|
||||
let count = 0
|
||||
const overflow = items.findIndex((row) => !row.context && ++count > input.limit)
|
||||
const selected = items.slice(0, overflow === -1 ? items.length : overflow)
|
||||
const matches = selected.filter((row) => !row.context)
|
||||
return selected.filter(
|
||||
(row) =>
|
||||
!row.context ||
|
||||
matches.some(
|
||||
(match) =>
|
||||
match.path.text === row.path.text && Math.abs(match.line_number - row.line_number) <= (input.context ?? 0),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export const decorate = (match: Match, context: boolean, textTruncated: boolean): GrepMatch => ({
|
||||
...match,
|
||||
context,
|
||||
textTruncated,
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import path from "path"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { Entry, Match } from "./filesystem/schema"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import * as KiloGrep from "./kilocode/ripgrep-grep" // kilocode_change
|
||||
import * as SpawnValidation from "./kilocode/spawn-validation" // kilocode_change
|
||||
import { AppProcess, collectStream, waitForAbort } from "./process"
|
||||
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
||||
@@ -23,7 +24,7 @@ const MAX_RECORD_BYTES = 64 * 1024
|
||||
const MAX_SUBMATCHES = 100
|
||||
|
||||
const RawMatch = Schema.Struct({
|
||||
type: Schema.Literal("match"),
|
||||
type: Schema.Literals(["match", "context"]), // kilocode_change - retain requested context records
|
||||
data: Schema.Struct({
|
||||
path: Schema.Struct({ text: Schema.String }),
|
||||
lines: Schema.Struct({ text: Schema.String }),
|
||||
@@ -39,7 +40,7 @@ const RawMatch = Schema.Struct({
|
||||
}),
|
||||
})
|
||||
|
||||
type RawMatchData = (typeof RawMatch.Type)["data"]
|
||||
type RawMatchData = (typeof RawMatch.Type)["data"] & { readonly context: boolean } // kilocode_change
|
||||
|
||||
export class Error extends Schema.TaggedErrorClass<Error>()("Ripgrep.Error", {
|
||||
message: Schema.String,
|
||||
@@ -71,7 +72,8 @@ export interface GlobInput {
|
||||
readonly validate?: Effect.Effect<void, unknown> // kilocode_change - bind approved searches at spawn
|
||||
}
|
||||
|
||||
export interface GrepInput {
|
||||
export interface GrepInput extends KiloGrep.Options {
|
||||
// kilocode_change
|
||||
readonly cwd: string
|
||||
readonly pattern: string
|
||||
readonly file?: string
|
||||
@@ -84,7 +86,7 @@ export interface GrepInput {
|
||||
export interface Interface {
|
||||
readonly find: (input: FindInput) => Effect.Effect<readonly Entry[], Error>
|
||||
readonly glob: (input: GlobInput) => Effect.Effect<SearchResult<Entry>, Error> // kilocode_change
|
||||
readonly grep: (input: GrepInput) => Effect.Effect<SearchResult<Match>, Error | InvalidPatternError> // kilocode_change
|
||||
readonly grep: (input: GrepInput) => Effect.Effect<SearchResult<KiloGrep.GrepMatch>, Error | InvalidPatternError> // kilocode_change
|
||||
}
|
||||
|
||||
// kilocode_change start - retain truncation state through model-facing tools
|
||||
@@ -116,6 +118,7 @@ export const layer = Layer.effect(
|
||||
readonly parse: (line: string) => Effect.Effect<A | undefined, Error>
|
||||
readonly pattern?: string
|
||||
readonly onItem?: (item: A) => Effect.Effect<void>
|
||||
readonly stop?: (item: A) => boolean // kilocode_change - stop bounded searches at the overflow match
|
||||
readonly validate?: Effect.Effect<void, unknown> // kilocode_change - spawn-bound target validation
|
||||
}) => {
|
||||
const program = Effect.scoped(
|
||||
@@ -136,6 +139,13 @@ export const layer = Layer.effect(
|
||||
Effect.forkScoped,
|
||||
)
|
||||
let observed = 0
|
||||
let stopped = false // kilocode_change
|
||||
const take = input.stop // kilocode_change start
|
||||
? Stream.takeUntil<A>((row) => {
|
||||
stopped = input.stop?.(row) ?? false
|
||||
return stopped
|
||||
})
|
||||
: Stream.take(input.limit + 1) // kilocode_change end
|
||||
const rows = yield* Stream.decodeText(handle.stdout).pipe(
|
||||
Stream.splitLines,
|
||||
Stream.filter((line) => line.length > 0),
|
||||
@@ -145,11 +155,12 @@ export const layer = Layer.effect(
|
||||
if (!input.onItem || observed++ >= input.limit) return Effect.void
|
||||
return input.onItem(row)
|
||||
}),
|
||||
Stream.take(input.limit + 1),
|
||||
take, // kilocode_change
|
||||
Stream.runCollect,
|
||||
Effect.map((chunk) => [...chunk]),
|
||||
)
|
||||
const truncated = rows.length > input.limit
|
||||
if (stopped) return { items: rows, truncated: true, partial: false } // kilocode_change
|
||||
const truncated = input.stop ? false : rows.length > input.limit // kilocode_change - custom stop predicates own truncation
|
||||
if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false }
|
||||
|
||||
const code = yield* handle.exitCode
|
||||
@@ -249,11 +260,13 @@ export const layer = Layer.effect(
|
||||
grep: (input) =>
|
||||
run<RawMatchData>({
|
||||
...input,
|
||||
stop: KiloGrep.stop(input.limit), // kilocode_change
|
||||
args: [
|
||||
"--no-config",
|
||||
"--json",
|
||||
"--hidden",
|
||||
"--no-messages",
|
||||
...KiloGrep.flags(input), // kilocode_change
|
||||
...(input.include ? [`--glob=${input.include}`] : []),
|
||||
"--glob=!**/.git/**",
|
||||
"--",
|
||||
@@ -269,13 +282,19 @@ export const layer = Layer.effect(
|
||||
})
|
||||
).pipe(
|
||||
Effect.flatMap((json) => {
|
||||
if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match")
|
||||
if (
|
||||
!json ||
|
||||
typeof json !== "object" ||
|
||||
!("type" in json) ||
|
||||
(json.type !== "match" && json.type !== "context") // kilocode_change
|
||||
)
|
||||
return Effect.succeed(undefined)
|
||||
return Schema.decodeUnknownEffect(RawMatch)(json).pipe(
|
||||
Effect.map((match) => ({
|
||||
...match.data,
|
||||
path: { text: match.data.path.text.replace(/^\.[\\/]/, "") },
|
||||
submatches: match.data.submatches.slice(0, MAX_SUBMATCHES),
|
||||
context: match.type === "context", // kilocode_change
|
||||
})),
|
||||
Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)),
|
||||
)
|
||||
@@ -285,13 +304,13 @@ export const layer = Layer.effect(
|
||||
// kilocode_change start - retain spawn metadata after mapping matches
|
||||
Effect.map((result) => ({
|
||||
...result,
|
||||
items: result.items.map((match) => {
|
||||
items: KiloGrep.select(input, result.items).map((match) => {
|
||||
const relative = match.path.text
|
||||
.replace(/^(?:\.[\\/])+/u, "")
|
||||
.replace(/^[\\/]+/u, "")
|
||||
.replaceAll("\\", "/")
|
||||
const absolute = path.resolve(input.cwd, relative)
|
||||
return new Match({
|
||||
const item = new Match({
|
||||
entry: new Entry({
|
||||
path: RelativePath.make(relative),
|
||||
type: "file",
|
||||
@@ -306,6 +325,7 @@ export const layer = Layer.effect(
|
||||
end: submatch.end,
|
||||
})),
|
||||
})
|
||||
return KiloGrep.decorate(item, match.context, match.lines.text.length > 2_000)
|
||||
}),
|
||||
})),
|
||||
// kilocode_change end
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as ConfigCommandV1 from "./command"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
template: Schema.String,
|
||||
template: Schema.optional(Schema.String), // kilocode_change - allow global workflow model/variant overrides
|
||||
description: Schema.optional(Schema.String),
|
||||
agent: Schema.optional(Schema.String),
|
||||
model: Schema.optional(Schema.String),
|
||||
|
||||
@@ -207,6 +207,7 @@ export const SubtaskPart = Schema.Struct({
|
||||
modelID: ModelV2.ID,
|
||||
}),
|
||||
),
|
||||
variant: Schema.optional(Schema.String), // kilocode_change - preserve workflow subtask variant
|
||||
command: Schema.optional(Schema.String),
|
||||
}).annotate({ identifier: "SubtaskPart" })
|
||||
export type SubtaskPart = Types.DeepMutable<Schema.Schema.Type<typeof SubtaskPart>>
|
||||
@@ -500,6 +501,7 @@ export const SubtaskPartInput = Schema.Struct({
|
||||
modelID: ModelV2.ID,
|
||||
}),
|
||||
),
|
||||
variant: Schema.optional(Schema.String), // kilocode_change - preserve workflow subtask variant
|
||||
command: Schema.optional(Schema.String),
|
||||
}).annotate({ identifier: "SubtaskPartInput" })
|
||||
export type SubtaskPartInput = Types.DeepMutable<Schema.Schema.Type<typeof SubtaskPartInput>>
|
||||
|
||||
@@ -69,6 +69,7 @@ Review files`,
|
||||
id: ModelV2.ID.make("claude"),
|
||||
variant: ModelV2.VariantID.make("high"),
|
||||
},
|
||||
variant: ModelV2.VariantID.make("high"),
|
||||
subtask: true,
|
||||
}),
|
||||
new CommandV2.Info({ name: "empty", template: "" }),
|
||||
@@ -78,4 +79,38 @@ Review files`,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("applies a global partial override after project command definitions", () =>
|
||||
Effect.gen(function* () {
|
||||
const command = yield* CommandV2.Service
|
||||
yield* ConfigCommandPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(CommandV2.Service, command),
|
||||
Effect.provideService(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({ commands: { review: { model: "anthropic/claude", variant: "high" } } }),
|
||||
}),
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({ commands: { review: { template: "Review files" } } }),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* command.get("review")).toMatchObject({
|
||||
template: "Review files",
|
||||
model: {
|
||||
providerID: ProviderV2.ID.make("anthropic"),
|
||||
id: ModelV2.ID.make("claude"),
|
||||
variant: ModelV2.VariantID.make("high"),
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -11,6 +11,8 @@ import { testEffect } from "../lib/effect"
|
||||
|
||||
type PtyEvent = { type: "created" | "exited" | "deleted"; id: PtyID }
|
||||
|
||||
const PTY_TEST_TIMEOUT = "15 seconds" // kilocode_change - PTY startup can exceed the default test timeout on macOS CI
|
||||
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })),
|
||||
@@ -59,7 +61,7 @@ const waitForEvents = (events: Queue.Queue<PtyEvent>, id: PtyID, count: number)
|
||||
return picked
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "5 seconds",
|
||||
duration: PTY_TEST_TIMEOUT, // kilocode_change
|
||||
orElse: () => Effect.fail(new Error("timeout waiting for pty events")),
|
||||
}),
|
||||
)
|
||||
@@ -73,6 +75,7 @@ const attachCollecting = Effect.fn("PtySessionTest.attachCollecting")(function*
|
||||
onData: (chunk) => Queue.offerUnsafe(output, chunk),
|
||||
onEnd: (event) => Deferred.doneUnsafe(ended, Effect.succeed(event)),
|
||||
})
|
||||
if (attachment.replay) Queue.offerUnsafe(output, attachment.replay)
|
||||
attachment.activate()
|
||||
return { attachment, output, ended }
|
||||
})
|
||||
@@ -84,7 +87,7 @@ const waitForOutput = (output: Queue.Queue<string>, text: string) =>
|
||||
return received
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "5 seconds",
|
||||
duration: PTY_TEST_TIMEOUT, // kilocode_change
|
||||
orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}`)),
|
||||
}),
|
||||
)
|
||||
@@ -144,7 +147,7 @@ describe("pty", () => {
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const marker = "café-über-北京-🚀"
|
||||
const info = yield* createPty("sh", ["-c", `printf '${marker}\\n'`])
|
||||
const info = yield* createPty("sh", ["-c", "printf 'caf\\303\\251-\\303\\274ber-\\345\\214\\227\\344\\272\\254-\\360\\237\\232\\200\\n'"])
|
||||
const attached = yield* attachCollecting(info.id)
|
||||
expect(yield* waitForOutput(attached.output, marker)).toContain(marker)
|
||||
}),
|
||||
@@ -268,7 +271,7 @@ describe("pty", () => {
|
||||
attachment.write("ignored")
|
||||
yield* pty.remove(info.id)
|
||||
attachment.activate()
|
||||
expect(yield* Deferred.await(ended).pipe(Effect.timeout("5 seconds"))).toEqual({ exitCode: 7 })
|
||||
expect(yield* Deferred.await(ended).pipe(Effect.timeout(PTY_TEST_TIMEOUT))).toEqual({ exitCode: 7 })
|
||||
attachment.detach()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -159,9 +159,9 @@ Each request can include 1-20 tasks. Each task must include at least one of `pro
|
||||
|
||||
The companion `agent_manager_models` tool searches models and their supported reasoning variants on demand. Results are grouped by model name (with the offering providers listed for reference) and limited to 20 per call, so the full catalog is never added to the conversation context.
|
||||
|
||||
The same tool also manages existing sessions. It can return a compact overview of sections, worktrees, and local sessions, send a prompt to one managed session, or stop a managed session. Stopping aborts the session's active work and removes it from the panel, just like closing the session tab.
|
||||
The same tool also manages existing sessions. It can return an overview of sections, worktrees, and local sessions, send a prompt to one managed session, stop a managed session, or move a session's worktree into a section. The overview includes section IDs, each section's assigned worktrees, worktree IDs, and session IDs. Use those exact IDs for a subsequent move. Moving accepts a section ID from the overview, or `null` to ungroup the worktree. Moving a session moves its whole worktree, including multi-version siblings. Local sessions cannot be assigned to a section. Stopping aborts the session's active work and removes it from the panel, just like closing the session tab.
|
||||
|
||||
The tool uses the `agent_manager` permission. Approval prompts are scoped to the requested capability, so approving `worktree` does not automatically approve `local`, an overview, or a targeted prompt. Prompting an existing managed session requires an explicit `prompt` approval the first time, even if Agent Manager session creation was previously approved broadly. Stopping a session likewise requires an explicit `stop` approval.
|
||||
The tool uses the `agent_manager` permission. Approval prompts are scoped to the requested capability, so approving `worktree` does not automatically approve `local`, an overview, or a targeted prompt. Prompting an existing managed session requires an explicit `prompt` approval the first time, even if Agent Manager session creation was previously approved broadly. Stopping a session likewise requires an explicit `stop` approval, and moving a worktree requires an explicit `move` approval.
|
||||
|
||||
## Sections
|
||||
|
||||
@@ -217,11 +217,39 @@ Press `Cmd+D` (macOS) / `Ctrl+D` (Windows/Linux) to toggle the diff panel. It sh
|
||||
- Markdown files include an eye/code toggle in the file header to switch between rendered Markdown and the raw diff
|
||||
- **Drag file headers into chat** — drag a file header from the diff panel into the chat input to insert an `@file` mention, giving the agent context about specific changed files
|
||||
|
||||
### Diff Scope
|
||||
|
||||
A scope selector in the diff toolbar (both in the side panel and the full-screen review) chooses which changes the diff shows:
|
||||
|
||||
- **Branch** (default) — the full worktree diff against its parent branch, matching the review behavior above
|
||||
- **Staged** — staged changes in the selected worktree
|
||||
- **Unstaged** — unstaged changes in the selected worktree
|
||||
- **Session** — changes from the selected session
|
||||
|
||||
The Branch scope also has a base-branch picker next to it for overriding the comparison branch. **Apply to local** works only on the Branch scope — switch back to Branch to apply.
|
||||
|
||||
See [Agent Manager Workflows](/docs/automate/agent-manager-workflows#merging-worktree-and-parent-branch) for the full integration story, including when to apply locally vs. merge directly vs. open a pull request.
|
||||
|
||||
## Terminals
|
||||
|
||||
Each session has a dedicated integrated terminal rooted in the session's worktree directory. Press `Cmd+/` (macOS) / `Ctrl+/` (Windows/Linux) to focus the terminal for the active session.
|
||||
Each session has a dedicated terminal rooted in the session's worktree directory. Press `Cmd+/` (macOS) / `Ctrl+/` (Windows/Linux) to focus the terminal for the active session.
|
||||
|
||||
### Choosing the Terminal Destination
|
||||
|
||||
The toolbar's terminal button is a split button: click it to open a terminal, or use its dropdown to choose where terminals open:
|
||||
|
||||
- **VS Code terminal** (default) — opens or focuses the VS Code integrated terminal at the bottom of the window
|
||||
- **Agent Manager panel** — opens an embedded terminal in the side panel that also hosts the diff view, so the shell stays inside the Agent Manager layout
|
||||
|
||||
The dropdown choice is remembered per panel and becomes the default for new panels. You can also set the default directly with the `kilo-code.new.agentManager.terminalButtonDestination` setting (`vscode` or `agentManager`). The `Cmd+/` (macOS) / `Ctrl+/` (Windows/Linux) shortcut follows the same destination.
|
||||
|
||||
With the **Agent Manager panel** destination, the terminal works like the diff panel: press `Cmd+/` to reveal it and press again to hide it. Hiding never stops the terminal — scrollback and running processes continue in the background, and focus returns to the chat input. A terminal stops only when you close its tab in the panel.
|
||||
|
||||
### Multiple Terminals
|
||||
|
||||
The side panel hosts multiple terminals per context (the local workspace or a worktree). The panel header is a tab strip: click a tab to switch, click **+** to open another terminal, and click **X** (or middle-click) to close a single terminal. Drag tabs to reorder them. Closing a terminal no longer hides the panel — closing the last one lands on the empty state. Pressing `Cmd+W` (macOS) / `Ctrl+W` (Windows/Linux) with a focused side terminal closes exactly that terminal.
|
||||
|
||||
New terminals are named "Terminal N" using the lowest free number, and tabs pick up the live title from the shell or running program, so a dev server or editor names its own tab.
|
||||
|
||||
### Switching Between Terminal and Agent Manager
|
||||
|
||||
|
||||
@@ -62,7 +62,31 @@ These tools help Kilo Code run commands:
|
||||
These tools help Kilo Code access web content:
|
||||
|
||||
- `webfetch` - Fetches a URL and returns the content
|
||||
- `websearch` - Searches the web (available to Kilo/OpenRouter users)
|
||||
- `websearch` - Searches the web
|
||||
|
||||
#### Web Search Availability
|
||||
|
||||
`websearch` is available automatically with the Kilo provider. For models from other providers it is off by default; enable it for all providers by setting `web_search` in `kilo.jsonc`:
|
||||
|
||||
```json
|
||||
{
|
||||
"web_search": true
|
||||
}
|
||||
```
|
||||
|
||||
In the VS Code extension, the same option lives under **Settings → Web Tools → Web Search → Enable for All Providers**. The `KILO_ENABLE_EXA` and `KILO_ENABLE_PARALLEL` environment flags also enable it.
|
||||
|
||||
#### Web Search Providers
|
||||
|
||||
`websearch` routes through the Exa or Parallel search providers. When the Exa provider is used and you are signed into Kilo, requests go through the Kilo proxy automatically — no separate Exa API key is required. Setting `EXA_API_KEY` uses your own Exa key instead. Exa searches return at most 10 results.
|
||||
|
||||
Set the `KILO_WEBSEARCH_PROVIDER` environment variable to force a provider:
|
||||
|
||||
| Value | Behavior |
|
||||
|---|---|
|
||||
| `exa` | Use Exa — through the Kilo proxy when signed in, through `EXA_API_KEY` when set |
|
||||
| `parallel` | Use Parallel |
|
||||
| `kilo-exa` | Always route Exa searches through the Kilo proxy (requires Kilo sign-in) |
|
||||
|
||||
### Browser Tools
|
||||
|
||||
|
||||
@@ -43,6 +43,30 @@ The underlying models behind each Auto Model tier are updated server-side as bet
|
||||
|
||||
You get lean costs on routine work and stronger models when the work demands it — with no manual switching.
|
||||
|
||||
### Custom Efficient pools
|
||||
|
||||
You can constrain `kilo-auto/efficient` to the exact models you trust by configuring an **Efficient model pool** on the **Auto routing** card:
|
||||
|
||||
- **Personal** — your [profile page](https://app.kilo.ai/profile)
|
||||
- **Organization** — your organization's **Providers & Models** page. Owners and billing managers can edit; members see a read-only view.
|
||||
|
||||
A pool holds 1–10 exact model and thinking-variant pairs. Variants stay distinct, so the same model with different thinking variants (for example `max` and `xhigh`) counts as separate entries. Leave the pool empty to inherit: an organization without a pool uses each member's personal pool, and a member without a personal pool uses the platform pool.
|
||||
|
||||
New entries are benchmarked on demand before they can serve traffic. Each entry shows a status:
|
||||
|
||||
| Status | Meaning |
|
||||
|---|---|
|
||||
| Benchmarking | The pair is being measured and is not used for routing yet |
|
||||
| Ready | Proven accurate enough and eligible for routing |
|
||||
| Failed | Benchmarking failed — retry the entry |
|
||||
| Unavailable | The model or variant is no longer in your catalog — remove the entry |
|
||||
|
||||
Routing decides only among ready entries. If no pool entry can serve a request, the request falls back to the Balanced tier, so quality never drops below Balanced.
|
||||
|
||||
{% callout type="note" %}
|
||||
You can benchmark up to 10 new or retried pairs per owner per rolling 24 hours. Entries that are already ready or benchmarking don't count against this limit.
|
||||
{% /callout %}
|
||||
|
||||
{% callout type="warning" title="Data handling for Auto Free" %}
|
||||
Auto Free may route your requests to providers that log prompts and outputs and use them to improve their services. Do not submit personal or confidential data when using Auto Free. In particular, it may route to NVIDIA's free endpoints.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Browser Use requires an advanced agentic model. It is typically most reliable wi
|
||||
{% tabs %}
|
||||
{% tab label="VSCode" %}
|
||||
|
||||
Browser automation is built into the extension and requires no manual setup. Enable it from **Settings → Browser** and Kilo handles the rest automatically.
|
||||
Browser automation is built into the extension and requires no manual setup. Enable it from **Settings → Web Tools → Browser Automation** and Kilo handles the rest automatically.
|
||||
|
||||
{% /tab %}
|
||||
{% tab label="CLI" %}
|
||||
@@ -93,7 +93,7 @@ Key characteristics:
|
||||
{% tabs %}
|
||||
{% tab label="VSCode" %}
|
||||
|
||||
Browser automation settings are available under **Settings → Browser**:
|
||||
Browser automation settings are available under **Settings → Web Tools → Browser Automation**:
|
||||
|
||||
- **Enable browser automation**: Toggle to enable or disable browser automation
|
||||
- **Headless mode**: Run the browser without a visible window (default: disabled)
|
||||
|
||||
@@ -54,6 +54,8 @@ When you are signed in to the enabled Kilo provider, a microphone button appears
|
||||
3. Click again to stop recording
|
||||
4. Your speech is transcribed into text
|
||||
|
||||
You can also use **Cmd/Ctrl+K** while a Kilo prompt or review comment field is focused. Tap it to start or stop recording, or hold it while speaking and release to transcribe and submit the focused field. Press it during transcription to cancel.
|
||||
|
||||
The feature includes real-time audio level visualization and voice activity detection to automatically detect when you're speaking.
|
||||
|
||||
---
|
||||
|
||||
@@ -123,6 +123,7 @@ The `kilo console` command and its browser interface are deprecated and will be
|
||||
| `/help` | - | Show help |
|
||||
| `/reload` | - | Reload config, skills, agents, and commands from disk |
|
||||
| `/editor` | - | Open external editor |
|
||||
| `/auto-approve` | `/autoapprove`, `/approve-all`, `/approveall` | Toggle auto-approve mode for all permission prompts (saved to global config) |
|
||||
| `/exit` | `/quit`, `/q` | Exit the app |
|
||||
|
||||
#### Kilo Gateway Commands (when connected)
|
||||
@@ -542,6 +543,8 @@ This instructs the AI to proceed without user input.
|
||||
- `124`: Timeout (task exceeded time limit)
|
||||
- `1`: Error (initialization or execution failure)
|
||||
|
||||
Without `--auto`, a non-interactive run cannot prompt for approval and auto-rejects any permission request it receives. If a run auto-rejected at least one request, it exits `1` with a stderr diagnostic naming the cause, since the task likely did not complete. Pass `--auto` for autonomous use.
|
||||
|
||||
### Example CI/CD Integration
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -124,6 +124,7 @@ When remote mode is enabled in the CLI, your active local sessions appear in the
|
||||
- **Agent questions** appear in both places — answer wherever you are
|
||||
- **Permission requests** route to your active connection
|
||||
- **Full editing capabilities** work remotely
|
||||
- **Session renames** sync in both directions between the CLI and the web or mobile app
|
||||
|
||||
### Enabling Remote Mode
|
||||
|
||||
|
||||
@@ -16,3 +16,13 @@ Open **Settings → Tools → Kilo Code** to configure the plugin. The JetBrains
|
||||
- **Auto-Approve** — set per-tool permission levels (Allow / Ask / Deny) and manage granular command and path exceptions without editing config by hand. Permission prompts offer one-time approvals alongside saved allow/reject rules. See [Auto-Approving Actions](/docs/getting-started/settings/auto-approving-actions) for the shared permission model.
|
||||
- **Context** — toggle auto-compaction, set the auto-compaction limit (the percentage of the model window that triggers compaction), enable pruning of old tool outputs, and manage file watcher ignore patterns. See [Context Condensing](/docs/customize/context/context-condensing) and [.kilocodeignore](/docs/customize/context/kilocodeignore) for what these settings control.
|
||||
- **Agent Behavior → Skills** — inspect loaded skills, add extra skill sources (local paths or remote URLs), edit or remove custom skills, and open skill files in the editor. See [Skills](/docs/customize/skills) for the skill format and discovery rules.
|
||||
|
||||
## Reviewing session changes
|
||||
|
||||
- **Modified files per turn** — each assistant turn that changed files shows a **Modified** card with the affected files and their diff stats. Expand a file to see its diff inline, or open all of the turn's changes in the **Changed files** diff viewer.
|
||||
- **Branch comparison** — when the workspace differs from the base branch, the session header shows a changes badge. Click it (**Compare with base branch**) to open a diff editor with a file tree and per-file navigation.
|
||||
- **Stale diff refresh** — diff views detect when files change on disk and offer a **Refresh** action to reload them instead of showing outdated content.
|
||||
|
||||
## Permission requests
|
||||
|
||||
When the agent asks for several approvals at once, permission requests queue up instead of replacing each other. Resolve the current request to advance to the next one in the queue.
|
||||
|
||||
@@ -21,7 +21,9 @@ The mobile app lets you:
|
||||
- Monitor and view all non-remote sessions in one place.
|
||||
- Create, onboard, and manage KiloClaw instances.
|
||||
- Send follow-up messages while a session is still running — they are queued and processed in order.
|
||||
- Run slash commands (like `/compact`) on connected remote CLI sessions, and start a new session in the same workspace with `/new`. Older CLI versions that do not support remote commands prompt you to upgrade.
|
||||
- Run slash commands (like `/compact`) on connected remote CLI sessions, and start a new session in the same workspace with `/new`. The new session inherits the current session's mode and model. Older CLI versions that do not support remote commands prompt you to upgrade.
|
||||
- Clear the visible transcript of a remote CLI session with `/clear`. Clearing is client-side only, so it works on any CLI version; server history is kept and may reappear when you re-enter the session.
|
||||
- Rename a remote CLI session from the app or the CLI — renames sync in both directions.
|
||||
- Review GitHub pull requests end to end — diffs, checks, comments, and merging.
|
||||
- Start a new session on a connected `kilo remote` CLI instance with the **Run on** picker.
|
||||
|
||||
@@ -50,7 +52,7 @@ The new-session screen includes a **Run on** picker that chooses where your sess
|
||||
- **Cloud Agent** — the managed cloud environment (the default).
|
||||
- **A connected CLI instance** — a `kilo remote` CLI running on your own machine. The picker lists the instances currently connected to your account.
|
||||
|
||||
Remote sessions use the CLI's own defaults, so the composer skips model, mode, and repository selection; you type your first prompt in the chat after the session starts. Sessions started in an organization context always run on the Cloud Agent, so the picker does not appear there.
|
||||
Remote sessions start with the mode and model selected on the new-session screen; older CLI versions that don't accept those fields fall back to their own defaults. The workspace is always the CLI's own checkout, so there is no repository selection — you type your first prompt in the chat after the session starts. The picker also appears in organization context, where the spawned session is attributed to the organization.
|
||||
|
||||
## Queueing follow-up messages
|
||||
|
||||
|
||||
@@ -38,6 +38,10 @@ Key features include:
|
||||
|
||||
Settings apply across extension surfaces, including the sidebar and Agent Manager. The standalone CLI uses the same `~/.config/kilo/kilo.jsonc` (global) and `./kilo.jsonc` (project) files when used directly.
|
||||
|
||||
## Interface Language
|
||||
|
||||
The extension UI follows VS Code's display language by default. Override it with the `kilo-code.new.language` setting (for example `en`, `de`, `ja`, or `fa`). Right-to-left languages such as Arabic and Persian switch the layout direction automatically.
|
||||
|
||||
## Proxy and Certificate Troubleshooting
|
||||
|
||||
Kilo Code for VS Code starts its embedded runtime from the extension and applies the relevant VS Code network settings to that runtime. On managed networks, configure proxy and certificate trust in VS Code settings rather than in a separate CLI install.
|
||||
|
||||
@@ -332,6 +332,29 @@ my-skill/
|
||||
|
||||
These additional files can be referenced from your skill's instructions, allowing the agent to read documentation, execute scripts, or use templates as needed.
|
||||
|
||||
## Shell commands in skills
|
||||
|
||||
A `SKILL.md` body can embed shell commands with the `` !`command` `` syntax. When the agent loads the skill, each command runs and its standard output replaces the placeholder before the skill content reaches the model, grounding the skill in live data:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: repo-status
|
||||
description: Summarize the current state of the repository
|
||||
---
|
||||
|
||||
The working tree currently contains:
|
||||
|
||||
!`git status --short`
|
||||
```
|
||||
|
||||
Because the agent decides when to load a skill, embedded commands never run silently:
|
||||
|
||||
- **Trusted skills only** — commands execute only in skills from trusted locations: global skills (such as `~/.kilo/skills/`, `~/.agents/skills/`, and `~/.claude/skills/`), skills built into Kilo Code, and absolute skill paths declared in global config. Project skills (`.kilo/skills/` in a repository) and skills fetched from remote URLs never execute commands; their placeholders are replaced with a marker noting the skill is untrusted.
|
||||
- **Approval required** — when the agent loads a trusted skill containing commands, every command in the file is listed in a single permission prompt before anything runs. Approving runs all of them; rejecting aborts the skill load. This prompt appears even when bash commands are otherwise auto-approved, and a deny rule on any command still blocks it.
|
||||
- **Kill switch** — set the `KILO_DISABLE_SKILL_SHELL` environment variable to disable embedded command execution entirely.
|
||||
|
||||
Commands run in the project directory with a per-command timeout, and output is truncated before inlining. Placeholders inside fenced code blocks are treated as documentation examples and never execute, and command output is never re-scanned for further placeholders.
|
||||
|
||||
## Example: Creating a Skill
|
||||
|
||||
{% tabs %}
|
||||
|
||||
@@ -105,6 +105,10 @@ Valid values are `expanded` and `collapsed`.
|
||||
|
||||
Markdown files in Kilo diff viewers can be shown as rendered Markdown instead of a raw text diff. Use the eye/code toggle in a Markdown file header, or set `kilo-code.new.diff.renderMarkdown` to `true` to render Markdown files by default.
|
||||
|
||||
### Web Search
|
||||
|
||||
See [Web Search Availability](/docs/automate/tools#web-search-availability) for how to enable the `websearch` tool for models from all providers.
|
||||
|
||||
### Export and Import
|
||||
|
||||
You can export and import settings from the **About Kilo Code** tab in the Settings UI:
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a7d7d047f14440d3dafc8a202131af6e755f80875012fd934a56580de21d36d5
|
||||
size 52887
|
||||
oid sha256:c3863da7e15cc40467319046521fbbfed0ee6ceab9f1f3ad03adb8941f454666
|
||||
size 52895
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bcb4fc19e832e52e4eda969d66be90cd4c6df26544bea0d117f9d3412dfcf9d9
|
||||
size 33866
|
||||
oid sha256:94227c822d88aa83f286136c05d92c8209b2cb08eaf6870cf79172aa004e8402
|
||||
size 39173
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3b1eb6cf15d5c2dc66c769a91fcf09bcf25bc2e8d17696ad0e9ab4181ed3038c
|
||||
size 658647
|
||||
oid sha256:fd794db4d2c038984f9e753ba70b8b0473d97a16713a9ff7255f0d2cf505d41a
|
||||
size 758306
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"exports": {
|
||||
"./font": "./src/components/font.tsx",
|
||||
"./button": "./src/components/button.tsx",
|
||||
"./chart": "./src/components/chart.tsx",
|
||||
"./code": "./src/components/code.tsx",
|
||||
"./diff": "./src/components/diff.tsx",
|
||||
"./diff-ssr": "./src/components/diff-ssr.tsx",
|
||||
@@ -128,6 +129,7 @@
|
||||
"motion": "12.34.5",
|
||||
"motion-dom": "12.34.3",
|
||||
"motion-utils": "12.29.2",
|
||||
"strip-ansi": "7.1.2"
|
||||
"strip-ansi": "7.1.2",
|
||||
"chart.js": "4.5.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[data-component="chart-container"] {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
[data-slot="chart-render"] {
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
[data-slot="chart-error"] {
|
||||
padding: 12px;
|
||||
color: var(--text-weak);
|
||||
font-size: var(--kilo-font-size-12, 12px);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/** @jsxImportSource solid-js */
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import { Chart, registerables } from "chart.js"
|
||||
import { BasicTool } from "./basic-tool"
|
||||
import type { ToolProps } from "./message-part"
|
||||
import { busy } from "./tool-utils"
|
||||
|
||||
Chart.register(...registerables)
|
||||
|
||||
function getThemeColors() {
|
||||
const style = getComputedStyle(document.documentElement)
|
||||
const get = (v: string, fallback: string) => style.getPropertyValue(v).trim() || fallback
|
||||
return {
|
||||
text: get("--text-base", "#FAFAFA"),
|
||||
textWeak: get("--text-weak", "#A3A3A3"),
|
||||
border: get("--border-weak-base", "#FFFFFF1A"),
|
||||
surface: get("--surface-raised-base", "#202020"),
|
||||
series: [
|
||||
get("--vscode-charts-blue", "#3B82F6"),
|
||||
get("--vscode-charts-green", "#22C55E"),
|
||||
get("--vscode-charts-purple", "#A855F7"),
|
||||
get("--vscode-charts-orange", "#F97316"),
|
||||
get("--vscode-charts-red", "#EF4444"),
|
||||
get("--vscode-charts-yellow", "#EAB308"),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
type ChartConfig = {
|
||||
type: string
|
||||
data: {
|
||||
labels?: string[]
|
||||
datasets: {
|
||||
label?: string
|
||||
data: number[] | { x: number | string; y: number; r?: number }[]
|
||||
backgroundColor?: string | string[]
|
||||
borderColor?: string | string[]
|
||||
[key: string]: unknown
|
||||
}[]
|
||||
}
|
||||
options?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function ChartTool(props: ToolProps) {
|
||||
const [canvas, setCanvas] = createSignal<HTMLCanvasElement>()
|
||||
const [error, setError] = createSignal<string>()
|
||||
|
||||
let rendered = false
|
||||
|
||||
createEffect(() => {
|
||||
const el = canvas()
|
||||
const raw = props.output
|
||||
if (!el || !raw || busy(props.status) || rendered) return
|
||||
|
||||
let config: ChartConfig
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!parsed || typeof parsed !== "object" || !parsed.type || !parsed.data) {
|
||||
setError("Invalid chart config — must include type and data")
|
||||
return
|
||||
}
|
||||
config = parsed
|
||||
} catch (e) {
|
||||
// output is not valid JSON — model likely passed an unsupported type
|
||||
console.warn("[Kilo Chart]: could not parse output as JSON", e)
|
||||
return
|
||||
}
|
||||
|
||||
let chart: Chart | undefined
|
||||
onCleanup(() => {
|
||||
chart?.destroy()
|
||||
rendered = false
|
||||
})
|
||||
|
||||
if (!el.isConnected) return
|
||||
|
||||
const colors = getThemeColors()
|
||||
const isPolar = config.type === "pie" || config.type === "doughnut" || config.type === "polarArea"
|
||||
|
||||
const datasets = config.data.datasets.map((dataset, i) => {
|
||||
if (dataset.backgroundColor) return dataset
|
||||
if (isPolar) {
|
||||
const data = dataset.data as unknown[]
|
||||
return {
|
||||
...dataset,
|
||||
backgroundColor: data.map((_, j) => colors.series[j % colors.series.length]),
|
||||
borderColor: data.map((_, j) => colors.series[j % colors.series.length]),
|
||||
}
|
||||
}
|
||||
return {
|
||||
backgroundColor: colors.series[i % colors.series.length],
|
||||
borderColor: colors.series[i % colors.series.length],
|
||||
...dataset,
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
chart = new Chart(el, {
|
||||
type: config.type as any,
|
||||
data: { ...config.data, datasets } as any,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: { color: colors.textWeak },
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: colors.textWeak },
|
||||
grid: { color: colors.border },
|
||||
border: { color: colors.border },
|
||||
},
|
||||
y: {
|
||||
ticks: { color: colors.textWeak },
|
||||
grid: { color: colors.border },
|
||||
border: { color: colors.border },
|
||||
},
|
||||
},
|
||||
...config.options,
|
||||
},
|
||||
})
|
||||
rendered = true
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to render chart")
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="bullet-list"
|
||||
trigger={{
|
||||
title: props.input?.title ?? "Chart",
|
||||
subtitle: props.input?.description ?? undefined,
|
||||
args: [],
|
||||
}}
|
||||
defaultOpen={props.defaultOpen ?? true}
|
||||
>
|
||||
<div data-component="chart-container">
|
||||
{error() ? <div data-slot="chart-error">{error()}</div> : <canvas ref={setCanvas} data-slot="chart-render" />}
|
||||
</div>
|
||||
</BasicTool>
|
||||
)
|
||||
}
|
||||
@@ -3097,3 +3097,9 @@ ToolRegistry.register({
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
import { ChartTool } from "./chart"
|
||||
ToolRegistry.register({
|
||||
name: "chart",
|
||||
render: ChartTool,
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
@import "../components/auto-approve-bar.css";
|
||||
@import "../components/button.css";
|
||||
@import "../components/card.css";
|
||||
@import "../components/chart.css";
|
||||
@import "../components/chat-input.css";
|
||||
@import "../components/checkbox.css";
|
||||
@import "../components/code.css";
|
||||
|
||||
@@ -1191,23 +1191,29 @@
|
||||
},
|
||||
"scripts": {
|
||||
"prepare:cli-binary": "bun script/local-bin.ts",
|
||||
"compile": "bun run prepare:cli-binary -- --force && bun run rebuild-sdk && bun run typecheck && bun run lint && node esbuild.js",
|
||||
"prepare:sdk": "bun script/prepare-sdk.ts",
|
||||
"build:launch": "bun run prepare:cli-binary && bun run prepare:sdk && bun run build:check:production",
|
||||
"compile": "bun run prepare:cli-binary -- --force && bun run rebuild-sdk && bun run build:check",
|
||||
"watch": "bun run rebuild-sdk && bun run --parallel watch:esbuild watch:tsc",
|
||||
"watch:esbuild": "bun run prepare:cli-binary && node esbuild.js --watch",
|
||||
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
|
||||
"watch:cli": "bun script/watch-cli.ts",
|
||||
"package": "bun run prepare:cli-binary && bun run rebuild-sdk && bun run typecheck && bun run lint && node esbuild.js --production",
|
||||
"package": "bun run prepare:cli-binary && bun run rebuild-sdk && bun run build:check:production",
|
||||
"build:check": "bun run --parallel check-types check-types:webview lint bundle",
|
||||
"build:check:production": "bun run --parallel check-types check-types:webview lint bundle:production",
|
||||
"bundle": "bun esbuild.js",
|
||||
"bundle:production": "bun esbuild.js --production",
|
||||
"compile-tests": "tsc -p . --outDir out",
|
||||
"watch-tests": "tsc -p . -w --outDir out",
|
||||
"pretest": "bun run compile-tests && bun run compile && bun run lint",
|
||||
"check-types": "tsc --noEmit",
|
||||
"check-types:webview": "tsc --noEmit --project webview-ui/tsconfig.json",
|
||||
"typecheck": "bun run check-types && bun run check-types:webview",
|
||||
"check-types": "tsgo --noEmit",
|
||||
"check-types:webview": "tsgo --noEmit --project webview-ui/tsconfig.json",
|
||||
"typecheck": "bun run --parallel check-types check-types:webview",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"knip": "knip",
|
||||
"check-kilocode-change": "! grep -rIn 'kilocode_change' . ../kilo-ui/ --exclude='package.json' --exclude='*.md' --exclude-dir='node_modules' --exclude-dir='dist' | grep -v '`kilocode_change`'",
|
||||
"lint": "eslint src webview-ui",
|
||||
"lint": "eslint --cache --cache-strategy content --cache-location node_modules/.cache/eslint src webview-ui",
|
||||
"test": "vscode-test",
|
||||
"test:unit": "bun test tests/unit/ --dots",
|
||||
"rebuild-sdk": "bun run --cwd ../sdk/js build",
|
||||
|
||||
@@ -241,7 +241,7 @@ async function compile() {
|
||||
}
|
||||
|
||||
console.log("[launch] Building extension...")
|
||||
await $`bun run package`.cwd(root).env(cleanEnv(process.env))
|
||||
await $`bun run build:launch`.cwd(root).env(cleanEnv(process.env))
|
||||
console.log("[launch] Build complete")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bun
|
||||
import { $ } from "bun"
|
||||
import { createHash } from "node:crypto"
|
||||
import { existsSync, mkdirSync } from "node:fs"
|
||||
import { dirname, join } from "node:path"
|
||||
|
||||
const root = join(import.meta.dir, "..")
|
||||
const repo = join(root, "..", "..")
|
||||
const sdk = join(repo, "packages", "sdk", "js")
|
||||
const cache = join(root, "node_modules", ".cache", "sdk-build.json")
|
||||
const inputs = [
|
||||
"package.json",
|
||||
"bun.lock",
|
||||
"packages/opencode",
|
||||
"packages/core",
|
||||
"packages/effect-drizzle-sqlite",
|
||||
"packages/effect-sqlite-node",
|
||||
"packages/kilo-gateway",
|
||||
"packages/kilo-indexing",
|
||||
"packages/kilo-memory",
|
||||
"packages/kilo-sandbox",
|
||||
"packages/kilo-telemetry",
|
||||
"packages/llm",
|
||||
"packages/plugin",
|
||||
"packages/plugin-atomic-chat",
|
||||
"packages/server",
|
||||
"packages/util",
|
||||
"packages/sdk/js/package.json",
|
||||
"packages/sdk/js/tsconfig.json",
|
||||
"packages/sdk/js/script",
|
||||
"packages/kilo-vscode/script/prepare-sdk.ts",
|
||||
]
|
||||
const outputs = ["packages/sdk/js/src"]
|
||||
|
||||
function log(msg: string) {
|
||||
console.log(`[prepare-sdk] ${msg}`)
|
||||
}
|
||||
|
||||
async function fingerprint(paths: string[]) {
|
||||
const [tree, diff, extra] = await Promise.all([
|
||||
$`git ls-tree -r HEAD -- ${paths}`.cwd(repo).quiet(),
|
||||
$`git diff --binary HEAD -- ${paths}`.cwd(repo).quiet(),
|
||||
$`git ls-files --others --exclude-standard -z -- ${paths}`.cwd(repo).quiet(),
|
||||
])
|
||||
const hash = createHash("sha256").update(tree.text()).update(diff.text())
|
||||
const files = extra.text().split("\0").filter(Boolean).sort()
|
||||
|
||||
for (const file of files) {
|
||||
hash.update(file)
|
||||
hash.update(new Uint8Array(await Bun.file(join(repo, file)).arrayBuffer()))
|
||||
}
|
||||
|
||||
return hash.digest("hex")
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const file = Bun.file(cache)
|
||||
if (!(await file.exists())) return
|
||||
|
||||
try {
|
||||
const value: unknown = await file.json()
|
||||
if (!value || typeof value !== "object") return
|
||||
const input = Reflect.get(value, "input")
|
||||
const output = Reflect.get(value, "output")
|
||||
if (typeof input === "string" && typeof output === "string") return { input, output }
|
||||
} catch (err) {
|
||||
log(`Ignoring invalid cache: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const input = await fingerprint(inputs)
|
||||
const prior = await load()
|
||||
const ready = existsSync(join(sdk, "dist", "index.js")) && existsSync(join(sdk, "dist", "v2", "index.js"))
|
||||
|
||||
if (prior?.input === input && prior.output === (await fingerprint(outputs)) && ready) {
|
||||
log("SDK inputs and generated output are unchanged")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
log("SDK inputs changed, rebuilding generated client")
|
||||
await $`bun run build`.cwd(sdk)
|
||||
|
||||
mkdirSync(dirname(cache), { recursive: true })
|
||||
await Bun.write(cache, JSON.stringify({ input, output: await fingerprint(outputs) }) + "\n")
|
||||
@@ -242,6 +242,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
},
|
||||
stats: (refresh) => this.statsPoller.snapshot(refresh),
|
||||
prs: () => this.prBridge.snapshot(),
|
||||
push: () => this.pushState(),
|
||||
managed: (id) => this.panelSessions.has(id) || !!this.state?.getSession(id),
|
||||
close: async (id) => {
|
||||
await this.onCloseSession(id)
|
||||
@@ -733,7 +734,11 @@ export class AgentManagerProvider implements Disposable {
|
||||
return null
|
||||
}
|
||||
if (m.type === "agentManager.setWorktreeOrder") {
|
||||
this.state?.setWorktreeOrder(m.order)
|
||||
const state = this.getStateManager()
|
||||
if (state) {
|
||||
state.setWorktreeOrder(m.order)
|
||||
this.pushState()
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (m.type === "agentManager.setSessionsCollapsed") {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { PRStatus } from "./types"
|
||||
import type { WorktreeStateManager } from "./WorktreeStateManager"
|
||||
import {
|
||||
OrchestrationError,
|
||||
move,
|
||||
overview,
|
||||
prompt,
|
||||
sameManagedDirectory,
|
||||
@@ -26,11 +27,13 @@ type Request =
|
||||
| (RequestBase & { operation: "overview"; filter?: OverviewFilter })
|
||||
| (RequestBase & { operation: "prompt"; targetSessionID: string; prompt: string })
|
||||
| (RequestBase & { operation: "stop"; targetSessionID: string })
|
||||
| (RequestBase & { operation: "move"; targetSessionID: string; sectionID: string | null })
|
||||
|
||||
type Result =
|
||||
| { operation: "overview"; overview: Overview }
|
||||
| { operation: "prompt"; sessionID: string; delivered: true }
|
||||
| { operation: "stop"; sessionID: string; stopped: true }
|
||||
| { operation: "move"; sessionID: string; sectionID: string | null; moved: true }
|
||||
|
||||
interface Failure {
|
||||
code: FailureCode | "cancelled" | "disconnected" | "timeout"
|
||||
@@ -43,6 +46,7 @@ interface Options {
|
||||
state(): WorktreeStateManager | undefined
|
||||
stats(refresh?: boolean): Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }>
|
||||
prs(): Map<string, PRStatus>
|
||||
push(): void
|
||||
managed(sessionID: string): boolean
|
||||
close(sessionID: string): Promise<void>
|
||||
log(...args: unknown[]): void
|
||||
@@ -279,6 +283,19 @@ export class AgentManagerOrchestrationBridge {
|
||||
if (this.disposed || active.cancelled) return
|
||||
return { result: { operation: "prompt", sessionID: request.targetSessionID, delivered: true } }
|
||||
}
|
||||
if (request.operation === "move") {
|
||||
move({ state, sessionID: request.targetSessionID, sectionID: request.sectionID })
|
||||
this.options.push()
|
||||
if (this.disposed || active.cancelled) return
|
||||
return {
|
||||
result: {
|
||||
operation: "move",
|
||||
sessionID: request.targetSessionID,
|
||||
sectionID: request.sectionID,
|
||||
moved: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
if (!this.options.managed(request.targetSessionID)) {
|
||||
throw new OrchestrationError("unknown_session", "The session is not managed by this Agent Manager workspace")
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export type FailureCode =
|
||||
| "host_error"
|
||||
| "stale_session"
|
||||
| "unavailable_session"
|
||||
| "unknown_section"
|
||||
| "unknown_session"
|
||||
| "workspace_unavailable"
|
||||
|
||||
@@ -362,3 +363,20 @@ export async function prompt(input: {
|
||||
{ throwOnError: true },
|
||||
)
|
||||
}
|
||||
|
||||
export function move(input: { state: WorktreeStateManager; sessionID: string; sectionID: string | null }): void {
|
||||
const session = input.state.getSession(input.sessionID)
|
||||
if (!session)
|
||||
throw new OrchestrationError("unknown_session", "The session is not managed by this Agent Manager workspace")
|
||||
if (!session.worktreeId) {
|
||||
if (input.sectionID === null) return
|
||||
throw new OrchestrationError(
|
||||
"unavailable_session",
|
||||
"Only sessions attached to a worktree can be assigned to a section",
|
||||
)
|
||||
}
|
||||
if (input.sectionID !== null && !input.state.getSection(input.sectionID)) {
|
||||
throw new OrchestrationError("unknown_section", "The target section is not managed by this Agent Manager workspace")
|
||||
}
|
||||
input.state.moveToSection([session.worktreeId], input.sectionID)
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ function makeTerminalId(): string {
|
||||
|
||||
export class TerminalManager {
|
||||
private readonly entries = new Map<string, Entry>()
|
||||
private readonly restarts = new Map<string, Promise<void>>()
|
||||
|
||||
constructor(private readonly deps: TerminalManagerDeps) {}
|
||||
|
||||
@@ -150,6 +151,24 @@ export class TerminalManager {
|
||||
}
|
||||
}
|
||||
|
||||
async restart(terminalId: string, cols?: number, rows?: number): Promise<string | undefined> {
|
||||
const entry = this.entries.get(terminalId)
|
||||
if (!entry) return
|
||||
const prior = this.restarts.get(terminalId)
|
||||
if (prior) {
|
||||
await prior
|
||||
const current = this.entries.get(terminalId)
|
||||
return current ? this.deps.buildWsUrl(current.ptyID, current.cwd) : undefined
|
||||
}
|
||||
const task = this.restartEntry(entry, cols, rows)
|
||||
this.restarts.set(terminalId, task)
|
||||
await task.finally(() => {
|
||||
if (this.restarts.get(terminalId) === task) this.restarts.delete(terminalId)
|
||||
})
|
||||
const current = this.entries.get(terminalId)
|
||||
return current ? this.deps.buildWsUrl(current.ptyID, current.cwd) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill every managed terminal. Invoked from AgentManagerProvider.dispose()
|
||||
* so PTYs do not outlive a webview drop that bypasses the explicit close
|
||||
@@ -218,4 +237,35 @@ export class TerminalManager {
|
||||
}
|
||||
this.entries.clear()
|
||||
}
|
||||
|
||||
private async restartEntry(entry: Entry, cols?: number, rows?: number): Promise<void> {
|
||||
try {
|
||||
const client = this.deps.getClient()
|
||||
const old = entry.ptyID
|
||||
const created = await client.pty.create({
|
||||
directory: entry.cwd,
|
||||
cwd: entry.cwd,
|
||||
title: entry.title,
|
||||
})
|
||||
const info = created.data
|
||||
if (created.error || !info)
|
||||
throw new Error(created.error ? String(created.error) : "PTY create returned no session")
|
||||
if (cols !== undefined && rows !== undefined) {
|
||||
await client.pty.update({
|
||||
ptyID: info.id,
|
||||
directory: entry.cwd,
|
||||
size: { cols, rows },
|
||||
})
|
||||
}
|
||||
entry.ptyID = info.id
|
||||
await client.pty.remove({ directory: entry.cwd, ptyID: old }).catch((error: unknown) => {
|
||||
this.deps.log(`Failed to remove exited PTY (${old}): ${error instanceof Error ? error.message : String(error)}`)
|
||||
})
|
||||
this.deps.log(`Terminal restarted (${entry.terminalId} pty=${entry.ptyID})`)
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
this.deps.log(`Terminal restart failed (${entry.terminalId}): ${msg}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,8 @@ function isTerminalMessage(
|
||||
return (
|
||||
m.type === "agentManager.terminal.create" ||
|
||||
m.type === "agentManager.terminal.close" ||
|
||||
m.type === "agentManager.terminal.resize"
|
||||
m.type === "agentManager.terminal.resize" ||
|
||||
m.type === "agentManager.terminal.restart"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -96,6 +97,22 @@ export class TerminalRouter {
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (m.type === "agentManager.terminal.restart") {
|
||||
void this.manager
|
||||
.restart(m.terminalId, m.cols, m.rows)
|
||||
.then((wsUrl) => {
|
||||
if (!wsUrl) return
|
||||
this.deps.post({ type: "agentManager.terminal.restarted", terminalId: m.terminalId, wsUrl })
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
this.deps.post({
|
||||
type: "agentManager.terminal.error",
|
||||
terminalId: m.terminalId,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
})
|
||||
return true
|
||||
}
|
||||
// resize
|
||||
void this.manager.resize(m.terminalId, m.cols, m.rows)
|
||||
return true
|
||||
|
||||
@@ -199,6 +199,12 @@ interface TerminalCreatedMessage {
|
||||
font: TerminalFont
|
||||
}
|
||||
|
||||
interface TerminalRestartedMessage {
|
||||
type: "agentManager.terminal.restarted"
|
||||
terminalId: string
|
||||
wsUrl: string
|
||||
}
|
||||
|
||||
interface TerminalClosedMessage {
|
||||
type: "agentManager.terminal.closed"
|
||||
terminalId: string
|
||||
@@ -407,6 +413,7 @@ export type AgentManagerOutMessage =
|
||||
| ActionOutMessage
|
||||
| RunStatusMessage
|
||||
| TerminalCreatedMessage
|
||||
| TerminalRestartedMessage
|
||||
| TerminalClosedMessage
|
||||
| TerminalErrorMessage
|
||||
| TerminalDestinationChangedMessage
|
||||
@@ -619,6 +626,7 @@ interface SetTabOrderIn {
|
||||
|
||||
interface SetWorktreeOrderIn {
|
||||
type: "agentManager.setWorktreeOrder"
|
||||
projectId?: string
|
||||
order: string[]
|
||||
}
|
||||
|
||||
@@ -948,6 +956,13 @@ interface TerminalResizeIn {
|
||||
rows: number
|
||||
}
|
||||
|
||||
interface TerminalRestartIn {
|
||||
type: "agentManager.terminal.restart"
|
||||
terminalId: string
|
||||
cols?: number
|
||||
rows?: number
|
||||
}
|
||||
|
||||
interface TerminalDestinationSelectedIn {
|
||||
type: "agentManager.terminal.destinationSelected"
|
||||
destination: TerminalDestination
|
||||
@@ -1035,4 +1050,5 @@ export type AgentManagerInMessage =
|
||||
| TerminalCloseIn
|
||||
| TerminalStopIn
|
||||
| TerminalResizeIn
|
||||
| TerminalRestartIn
|
||||
| TerminalDestinationSelectedIn
|
||||
|
||||
@@ -34,11 +34,32 @@ type Args = {
|
||||
input: string[]
|
||||
}
|
||||
|
||||
const macScript = `
|
||||
ObjC.import("AVFoundation")
|
||||
ObjC.import("Foundation")
|
||||
function run(args) {
|
||||
const settings = $.NSMutableDictionary.alloc.init
|
||||
settings.setObjectForKey($.NSNumber.numberWithUnsignedInt(1819304813), $.AVFormatIDKey)
|
||||
settings.setObjectForKey($.NSNumber.numberWithDouble(16000), $.AVSampleRateKey)
|
||||
settings.setObjectForKey($.NSNumber.numberWithInt(1), $.AVNumberOfChannelsKey)
|
||||
settings.setObjectForKey($.NSNumber.numberWithInt(16), $.AVLinearPCMBitDepthKey)
|
||||
settings.setObjectForKey($.NSNumber.numberWithBool(false), $.AVLinearPCMIsFloatKey)
|
||||
settings.setObjectForKey($.NSNumber.numberWithBool(false), $.AVLinearPCMIsBigEndianKey)
|
||||
const error = Ref()
|
||||
const url = $.NSURL.fileURLWithPath(args[0])
|
||||
const recorder = $.AVAudioRecorder.alloc.initWithURLSettingsError(url, settings, error)
|
||||
if (!recorder || !recorder.prepareToRecord || !recorder.record) throw new Error("Could not start recording")
|
||||
console.log("ready")
|
||||
$.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile
|
||||
recorder.stop
|
||||
}`
|
||||
|
||||
let active: Recording | undefined
|
||||
let starting: string | undefined
|
||||
let ffmpeg: Promise<string> | undefined
|
||||
|
||||
export async function prewarmSpeechCapture(): Promise<void> {
|
||||
if (useMacCapture(process.platform, process.env)) return
|
||||
await resolveFFmpeg()
|
||||
}
|
||||
|
||||
@@ -47,8 +68,15 @@ export async function startSpeechCapture(input: Input): Promise<boolean> {
|
||||
|
||||
starting = input.requestId
|
||||
try {
|
||||
const bin = await resolveFFmpeg()
|
||||
const file = path.join(os.tmpdir(), `kilo-stt-${process.pid}-${Date.now()}.wav`)
|
||||
if (useMacCapture(process.platform, process.env)) {
|
||||
const result = await startMac(file, input).catch((err: unknown) => {
|
||||
console.warn("[Kilo New] Native macOS speech capture failed, falling back to FFmpeg", err)
|
||||
return undefined
|
||||
})
|
||||
if (result) return !result.stopped
|
||||
}
|
||||
const bin = await resolveFFmpeg()
|
||||
const state = await startWithArgs(bin, file, input, await inputArgSets(bin))
|
||||
return !state.stopped
|
||||
} finally {
|
||||
@@ -112,7 +140,7 @@ async function waitForStart(state: Recording): Promise<void> {
|
||||
onError(new Error(summary(state, "Could not start microphone recording")))
|
||||
}
|
||||
const onData = (data: Buffer) => {
|
||||
if (/Output #0|Press \[q\]|size=\s*\d+/i.test(data.toString())) done()
|
||||
if (/^ready$|Output #0|Press \[q\]|size=\s*\d+/im.test(data.toString())) done()
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
onError(new Error(summary(state, "Timed out starting microphone recording")))
|
||||
@@ -129,6 +157,21 @@ async function waitForStart(state: Recording): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
async function startMac(file: string, input: Input): Promise<Recording> {
|
||||
const proc = spawn("/usr/bin/osascript", macCaptureArgs(file), { stdio: ["pipe", "ignore", "pipe"] })
|
||||
const state = createState(input, file, proc)
|
||||
await waitForStart(state)
|
||||
return state
|
||||
}
|
||||
|
||||
export function macCaptureArgs(file: string): string[] {
|
||||
return ["-l", "JavaScript", "-e", macScript, file]
|
||||
}
|
||||
|
||||
export function useMacCapture(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): boolean {
|
||||
return platform === "darwin" && !env.KILO_FFMPEG_PATH && !env.FFMPEG_PATH
|
||||
}
|
||||
|
||||
async function startWithArgs(bin: string, file: string, input: Input, args: Args[]): Promise<Recording> {
|
||||
const [first, ...rest] = args
|
||||
if (!first) throw new Error(`Unsupported platform for speech input: ${process.platform}`)
|
||||
@@ -138,6 +181,18 @@ async function startWithArgs(bin: string, file: string, input: Input, args: Args
|
||||
: spawn(bin, ["-y", ...first.input, "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", "-f", "wav", file], {
|
||||
stdio: ["pipe", "ignore", "pipe"],
|
||||
})
|
||||
const state = createState(input, file, proc)
|
||||
try {
|
||||
await waitForStart(state)
|
||||
return state
|
||||
} catch (err) {
|
||||
if (state.stopped) return state
|
||||
if (rest.length === 0) throw err
|
||||
return startWithArgs(bin, file, input, rest)
|
||||
}
|
||||
}
|
||||
|
||||
function createState(input: Input, file: string, proc: ChildProcess): Recording {
|
||||
const state: Recording = { ...input, file, proc, stderr: [], stopped: false }
|
||||
active = state
|
||||
|
||||
@@ -152,15 +207,7 @@ async function startWithArgs(bin: string, file: string, input: Input, args: Args
|
||||
state.stderr.push(err.message)
|
||||
if (active === state && !state.stopped) active = undefined
|
||||
})
|
||||
|
||||
try {
|
||||
await waitForStart(state)
|
||||
return state
|
||||
} catch (err) {
|
||||
if (state.stopped) return state
|
||||
if (rest.length === 0) throw err
|
||||
return startWithArgs(bin, file, input, rest)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
function pipeProcess(pipe: string[], bin: string, file: string): ChildProcess {
|
||||
|
||||
@@ -193,6 +193,18 @@ test("large catalogs keep the rendered tree bounded and navigate to distant mode
|
||||
// The window mounts before we measure it, yet stays far smaller than the catalog.
|
||||
await expect.poll(() => tree.getByRole("treeitem").count()).toBeGreaterThan(0)
|
||||
await expect.poll(() => tree.getByRole("treeitem").count()).toBeLessThan(50)
|
||||
await expect(page.getByRole("treeitem", { name: "Model 300" })).toBeVisible()
|
||||
|
||||
// Searching from deep in the catalog scrolls the first active match into view.
|
||||
await tree.getByRole("treeitem").last().hover()
|
||||
await tree.evaluate((el) => el.scrollTo({ top: el.scrollHeight }))
|
||||
await combobox.pressSequentially("Model 5")
|
||||
const first = page.getByRole("treeitem", { name: "Model 500" })
|
||||
await expect(first).toBeVisible()
|
||||
await expect(combobox).toHaveAttribute("aria-activedescendant", await first.getAttribute("id"))
|
||||
const hovered = page.getByRole("treeitem", { name: "Model 501" })
|
||||
await hovered.hover()
|
||||
await expect(combobox).toHaveAttribute("aria-activedescendant", await hovered.getAttribute("id"))
|
||||
|
||||
// Reaching a distant model scrolls it into the mounted window and activates it.
|
||||
await combobox.fill("Model 599")
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { Window } from "happy-dom"
|
||||
import { focusQuestionOption, hasQuestionOption } from "../../webview-ui/agent-manager/focus"
|
||||
|
||||
describe("Agent Manager focus", () => {
|
||||
it("focuses the first enabled question option", () => {
|
||||
const window = new Window()
|
||||
const root = window.document.createElement("div")
|
||||
const dock = window.document.createElement("div")
|
||||
const disabled = window.document.createElement("button")
|
||||
const option = window.document.createElement("button")
|
||||
disabled.setAttribute("data-slot", "question-option")
|
||||
disabled.disabled = true
|
||||
option.setAttribute("data-slot", "question-option")
|
||||
dock.setAttribute("data-component", "question-dock")
|
||||
dock.append(disabled, option)
|
||||
root.append(dock)
|
||||
window.document.body.append(root)
|
||||
|
||||
expect(focusQuestionOption(root)).toBe(true)
|
||||
expect(root.ownerDocument.activeElement).toBe(option)
|
||||
})
|
||||
|
||||
it("ignores collapsed question bodies", () => {
|
||||
const window = new Window()
|
||||
const root = window.document.createElement("div")
|
||||
const dock = window.document.createElement("div")
|
||||
const body = window.document.createElement("div")
|
||||
const option = window.document.createElement("button")
|
||||
dock.setAttribute("data-component", "question-dock")
|
||||
body.setAttribute("inert", "")
|
||||
option.setAttribute("data-slot", "question-option")
|
||||
body.append(option)
|
||||
dock.append(body)
|
||||
root.append(dock)
|
||||
window.document.body.append(root)
|
||||
|
||||
expect(focusQuestionOption(root)).toBe(false)
|
||||
expect(root.ownerDocument.activeElement).not.toBe(option)
|
||||
})
|
||||
|
||||
it("only reports enabled options outside inert bodies", () => {
|
||||
const window = new Window()
|
||||
const root = window.document.createElement("div")
|
||||
const dock = window.document.createElement("div")
|
||||
const option = window.document.createElement("button")
|
||||
dock.setAttribute("data-component", "question-dock")
|
||||
option.setAttribute("data-slot", "question-option")
|
||||
dock.append(option)
|
||||
root.append(dock)
|
||||
|
||||
expect(hasQuestionOption(root)).toBe(true)
|
||||
dock.setAttribute("inert", "")
|
||||
expect(hasQuestionOption(root)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -45,6 +45,7 @@ describe("AgentManagerOrchestrationBridge", () => {
|
||||
const managed = new Set(["ses_target"])
|
||||
const promptAsync = mock(async () => ({ data: undefined }))
|
||||
const close = mock(async () => undefined)
|
||||
const push = mock(() => undefined)
|
||||
const client = {
|
||||
session: {
|
||||
get: mock(async () => ({
|
||||
@@ -97,6 +98,7 @@ describe("AgentManagerOrchestrationBridge", () => {
|
||||
state: () => state,
|
||||
stats: async () => ({ worktrees: [] }),
|
||||
prs: () => new Map(),
|
||||
push,
|
||||
managed: (id) => managed.has(id),
|
||||
close,
|
||||
log: () => undefined,
|
||||
@@ -106,7 +108,7 @@ describe("AgentManagerOrchestrationBridge", () => {
|
||||
{ id: `event-${value.id}`, type: "kilocode.agent_manager.requested", properties: value } as SSEPayload,
|
||||
directory,
|
||||
)
|
||||
return { bridge, client, close, handlers, lists, managed, promptAsync, rejections, replies, request, status }
|
||||
return { bridge, client, close, handlers, lists, managed, promptAsync, push, rejections, replies, request, status }
|
||||
}
|
||||
|
||||
const request: AgentManagerRequest = {
|
||||
@@ -185,6 +187,52 @@ describe("AgentManagerOrchestrationBridge", () => {
|
||||
test.bridge.dispose()
|
||||
})
|
||||
|
||||
it("moves its own worktree into a section and then ungroups it", async () => {
|
||||
const test = harness()
|
||||
const section = state.addSection("Review", null)
|
||||
|
||||
test.request(
|
||||
{
|
||||
id: "amr_move",
|
||||
sessionID: "ses_target",
|
||||
operation: "move",
|
||||
targetSessionID: "ses_target",
|
||||
sectionID: section.id,
|
||||
},
|
||||
dir,
|
||||
)
|
||||
await waitFor(() => test.replies.length === 1)
|
||||
|
||||
const worktreeID = state.getSession("ses_target")!.worktreeId!
|
||||
expect(state.getWorktree(worktreeID)?.sectionId).toBe(section.id)
|
||||
expect(test.push).toHaveBeenCalledTimes(1)
|
||||
expect(test.replies[0]).toEqual({
|
||||
requestID: "amr_move",
|
||||
directory: dir,
|
||||
result: { operation: "move", sessionID: "ses_target", sectionID: section.id, moved: true },
|
||||
})
|
||||
|
||||
test.request(
|
||||
{
|
||||
id: "amr_ungroup",
|
||||
sessionID: "ses_target",
|
||||
operation: "move",
|
||||
targetSessionID: "ses_target",
|
||||
sectionID: null,
|
||||
},
|
||||
dir,
|
||||
)
|
||||
await waitFor(() => test.replies.length === 2)
|
||||
|
||||
expect(state.getWorktree(worktreeID)?.sectionId).toBeUndefined()
|
||||
expect(test.replies[1]).toEqual({
|
||||
requestID: "amr_ungroup",
|
||||
directory: dir,
|
||||
result: { operation: "move", sessionID: "ses_target", sectionID: null, moved: true },
|
||||
})
|
||||
test.bridge.dispose()
|
||||
})
|
||||
|
||||
it("stops a live panel session before it is persisted", async () => {
|
||||
const test = harness()
|
||||
test.managed.add("ses_live")
|
||||
|
||||
@@ -287,4 +287,49 @@ describe("Agent Manager terminal routing", () => {
|
||||
expect(messages[0]).toMatchObject({ type: "agentManager.terminal.created", createId: "real" })
|
||||
await router.dispose()
|
||||
})
|
||||
|
||||
it("lazily replaces an ended PTY in the same logical terminal", async () => {
|
||||
const messages: AgentManagerOutMessage[] = []
|
||||
let seq = 0
|
||||
const client = {
|
||||
pty: {
|
||||
create: async ({ title }: { title: string }) => {
|
||||
seq++
|
||||
return { data: { id: `pty-${seq}`, title } }
|
||||
},
|
||||
remove: async () => ({ data: true }),
|
||||
update: async () => ({ data: true }),
|
||||
},
|
||||
} as unknown as KiloClient
|
||||
const router = new TerminalRouter({
|
||||
getClient: () => client,
|
||||
getClientAsync: async () => client,
|
||||
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }),
|
||||
getRoot: () => "/workspace",
|
||||
getWorktreePath: () => undefined,
|
||||
getProjectId: () => "prj-1",
|
||||
log: () => undefined,
|
||||
post: (message) => messages.push(message),
|
||||
getTerminalFont: () => font,
|
||||
})
|
||||
|
||||
router.handle({ type: "agentManager.terminal.create", createId: "one", placement: "tab", worktreeId: null })
|
||||
await wait()
|
||||
const created = messages.find((message) => message.type === "agentManager.terminal.created")
|
||||
if (!created || created.type !== "agentManager.terminal.created") throw new Error("missing terminal")
|
||||
|
||||
router.handle({
|
||||
type: "agentManager.terminal.restart",
|
||||
terminalId: created.terminalId,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
})
|
||||
await wait()
|
||||
expect(messages.at(-1)).toMatchObject({
|
||||
type: "agentManager.terminal.restarted",
|
||||
terminalId: created.terminalId,
|
||||
wsUrl: expect.stringContaining("pty-2"),
|
||||
})
|
||||
await router.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,6 +22,7 @@ function scene(initial: string | null = LOCAL) {
|
||||
shown: [] as string[],
|
||||
errors: 0,
|
||||
running: [] as Array<{ contextKey: string; terminalId: string }>,
|
||||
sideFocus: [] as boolean[],
|
||||
}
|
||||
const tabs = () => state.current().map((term) => term.id)
|
||||
const handlers = createTerminalHandlers({
|
||||
@@ -49,17 +50,18 @@ function scene(initial: string | null = LOCAL) {
|
||||
},
|
||||
showError: () => events.errors++,
|
||||
postMessage: (message) => posted.push(message as Record<string, unknown>),
|
||||
onSideCreated: (_contextKey, _terminalId, focus) => events.sideFocus.push(focus),
|
||||
onScriptRunning: (contextKey, terminalId) => events.running.push({ contextKey, terminalId }),
|
||||
})
|
||||
return { state, selection, setSelection, posted, events, handlers, dispatch }
|
||||
}
|
||||
|
||||
function createdSide(createId: string, terminalId: string, title = "Terminal 1") {
|
||||
function createdSide(createId: string, terminalId: string, title = "Terminal 1", worktreeId: string | null = null) {
|
||||
return {
|
||||
type: "agentManager.terminal.created",
|
||||
createId,
|
||||
placement: "side",
|
||||
worktreeId: null,
|
||||
worktreeId,
|
||||
terminalId,
|
||||
title,
|
||||
wsUrl: `ws://${terminalId}`,
|
||||
@@ -344,6 +346,20 @@ describe("Agent Manager terminal state", () => {
|
||||
placement: "side",
|
||||
worktreeId: "wt-1",
|
||||
})
|
||||
const createId = String(item.posted[0]!.createId)
|
||||
expect(item.dispatch(createdSide(createId, "terminal:side", "Terminal 1", "wt-1"))).toBe(true)
|
||||
expect(item.events.sideFocus).toEqual([false])
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it("focuses a side terminal only when the user explicitly opens it", () => {
|
||||
createRoot((dispose) => {
|
||||
const item = scene()
|
||||
item.handlers.addSide()
|
||||
const createId = String(item.posted[0]!.createId)
|
||||
expect(item.dispatch(createdSide(createId, "terminal:side"))).toBe(true)
|
||||
expect(item.events.sideFocus).toEqual([true])
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { createProjectStore } from "../../webview-ui/agent-manager/project/store"
|
||||
|
||||
const state = (projectId: string, order: string[]) => ({
|
||||
type: "agentManager.state" as const,
|
||||
projectId,
|
||||
worktrees: order.map((id) => ({
|
||||
id,
|
||||
branch: `${projectId}-${id}`,
|
||||
path: `/repo/${projectId}/${id}`,
|
||||
parentBranch: "main",
|
||||
createdAt: "2026-01-01",
|
||||
})),
|
||||
sessions: [],
|
||||
sections: [],
|
||||
worktreeOrder: order,
|
||||
})
|
||||
|
||||
describe("project stores", () => {
|
||||
it("keeps worktree order isolated between projects", () => {
|
||||
const first = createProjectStore("a")
|
||||
const second = createProjectStore("b")
|
||||
first.applyState(state("a", ["same", "other"]))
|
||||
second.applyState(state("b", ["same", "other"]))
|
||||
|
||||
first.setWorktreeOrder(["other", "same"])
|
||||
|
||||
expect(first.worktreeOrder()).toEqual(["other", "same"])
|
||||
expect(second.worktreeOrder()).toEqual(["same", "other"])
|
||||
})
|
||||
|
||||
it("preserves live run statuses when state omits them", () => {
|
||||
const store = createProjectStore("a")
|
||||
store.applyState(state("a", ["same", "other"]))
|
||||
store.setRunStatuses({
|
||||
same: { worktreeId: "same", state: "running" },
|
||||
})
|
||||
|
||||
store.applyState(state("a", ["other", "same"]))
|
||||
|
||||
expect(store.runStatuses()).toEqual({
|
||||
same: { worktreeId: "same", state: "running" },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
isGrouped,
|
||||
isGroupStart,
|
||||
isGroupEnd,
|
||||
sortWorktrees,
|
||||
} from "../../webview-ui/agent-manager/section-helpers"
|
||||
import type { WorktreeState, SectionState } from "../../webview-ui/src/types/messages"
|
||||
|
||||
@@ -112,6 +113,23 @@ describe("isGrouped", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("sortWorktrees", () => {
|
||||
it("applies persisted order", () => {
|
||||
const all = [wt("a"), wt("b"), wt("c")]
|
||||
expect(sortWorktrees(all, ["c", "a", "b"]).map((item) => item.id)).toEqual(["c", "a", "b"])
|
||||
})
|
||||
|
||||
it("keeps multi-version siblings adjacent at the first group position", () => {
|
||||
const all = [wt("a", { groupId: "g" }), wt("b"), wt("c", { groupId: "g" })]
|
||||
expect(sortWorktrees(all, ["b", "c", "a"]).map((item) => item.id)).toEqual(["b", "c", "a"])
|
||||
})
|
||||
|
||||
it("appends worktrees missing from persisted order", () => {
|
||||
const all = [wt("a"), wt("b"), wt("c")]
|
||||
expect(sortWorktrees(all, ["b"]).map((item) => item.id)).toEqual(["b", "a", "c"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("isGroupStart", () => {
|
||||
const list = [wt("a", { groupId: "g1" }), wt("b", { groupId: "g1" }), wt("c", { groupId: "g2" }), wt("d")]
|
||||
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { cleanOutput, parseDshowAudioDevices } from "../../src/speech-to-text/capture"
|
||||
import { cleanOutput, macCaptureArgs, parseDshowAudioDevices, useMacCapture } from "../../src/speech-to-text/capture"
|
||||
|
||||
describe("macCaptureArgs", () => {
|
||||
it("records 16 kHz mono PCM with the built-in AVFoundation bridge", () => {
|
||||
const args = macCaptureArgs("/tmp/speech.wav")
|
||||
|
||||
expect(args.slice(0, 3)).toEqual(["-l", "JavaScript", "-e"])
|
||||
expect(args.at(-1)).toBe("/tmp/speech.wav")
|
||||
expect(args[3]).toContain("AVAudioRecorder")
|
||||
expect(args[3]).toContain("numberWithDouble(16000)")
|
||||
expect(args[3]).toContain("numberWithInt(1), $.AVNumberOfChannelsKey")
|
||||
expect(args[3]).toContain('console.log("ready")')
|
||||
})
|
||||
})
|
||||
|
||||
describe("useMacCapture", () => {
|
||||
it("preserves explicit FFmpeg overrides", () => {
|
||||
expect(useMacCapture("darwin", {})).toBe(true)
|
||||
expect(useMacCapture("darwin", { KILO_FFMPEG_PATH: "/custom/ffmpeg" })).toBe(false)
|
||||
expect(useMacCapture("darwin", { FFMPEG_PATH: "/custom/ffmpeg" })).toBe(false)
|
||||
expect(useMacCapture("linux", {})).toBe(false)
|
||||
expect(useMacCapture("win32", {})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseDshowAudioDevices", () => {
|
||||
it("extracts Windows dshow audio device names", () => {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { describe, expect, it, mock } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/types/messages"
|
||||
import {
|
||||
createSpeechShortcut,
|
||||
isSpeechShortcut,
|
||||
SPEECH_HOLD_MS,
|
||||
speechShortcutLabel,
|
||||
speechShortcutValue,
|
||||
toggleSpeech,
|
||||
} from "../../webview-ui/src/components/speech-to-text/shortcut"
|
||||
|
||||
type Toast = {
|
||||
actions?: Array<{ onClick: string | (() => void) }>
|
||||
@@ -171,3 +179,151 @@ describe("useSpeechToText", () => {
|
||||
ctx.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe("speech shortcut", () => {
|
||||
const key = (timeStamp: number, repeat = false) => ({
|
||||
key: "k",
|
||||
metaKey: true,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
repeat,
|
||||
timeStamp,
|
||||
})
|
||||
|
||||
it("accepts only the platform modifier with K", () => {
|
||||
expect(isSpeechShortcut({ key: "k", metaKey: true, ctrlKey: false, altKey: false, shiftKey: false }, true)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(isSpeechShortcut({ key: "k", metaKey: false, ctrlKey: true, altKey: false, shiftKey: false }, true)).toBe(
|
||||
false,
|
||||
)
|
||||
expect(isSpeechShortcut({ key: "k", metaKey: false, ctrlKey: true, altKey: false, shiftKey: false }, false)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(isSpeechShortcut({ key: "k", metaKey: true, ctrlKey: false, altKey: false, shiftKey: false }, false)).toBe(
|
||||
false,
|
||||
)
|
||||
expect(isSpeechShortcut({ key: "k", metaKey: true, ctrlKey: false, altKey: true, shiftKey: false }, true)).toBe(
|
||||
false,
|
||||
)
|
||||
expect(isSpeechShortcut({ key: "k", metaKey: true, ctrlKey: false, altKey: false, shiftKey: true }, true)).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it("exposes platform-specific labels for the focused input", () => {
|
||||
expect(speechShortcutLabel(true)).toBe("⌘K")
|
||||
expect(speechShortcutValue(true)).toBe("Meta+K")
|
||||
expect(speechShortcutLabel(false)).toBe("Ctrl+K")
|
||||
expect(speechShortcutValue(false)).toBe("Control+K")
|
||||
})
|
||||
|
||||
it("does not handle a shortcut when speech is unavailable", () => {
|
||||
const ctx = setup()
|
||||
let started = 0
|
||||
expect(toggleSpeech(ctx.speech, true, () => started++)).toBe(false)
|
||||
expect(started).toBe(0)
|
||||
ctx.dispose()
|
||||
})
|
||||
|
||||
it("ignores key repeat and keeps a quick press recording", () => {
|
||||
const ctx = setup()
|
||||
const shortcut = createSpeechShortcut({
|
||||
speech: ctx.speech,
|
||||
disabled: () => false,
|
||||
start: () => ctx.speech.start({ model: "scribe", insert: () => {} }),
|
||||
finish: () => ctx.speech.stop(),
|
||||
mac: true,
|
||||
})
|
||||
|
||||
expect(shortcut.down(key(0))).toBe(true)
|
||||
expect(shortcut.down(key(50, true))).toBe(true)
|
||||
expect(shortcut.down(key(100, true))).toBe(true)
|
||||
expect(shortcut.up(key(SPEECH_HOLD_MS - 1))).toBe(true)
|
||||
expect(ctx.sent).toHaveLength(1)
|
||||
expect(ctx.speech.state()).toBe("starting")
|
||||
|
||||
const start = ctx.sent[0]
|
||||
if (start?.type !== "speechToTextStart") throw new Error("speech start message missing")
|
||||
ctx.fire({ type: "speechToTextStarted", requestId: start.requestId })
|
||||
expect(ctx.speech.state()).toBe("recording")
|
||||
ctx.dispose()
|
||||
})
|
||||
|
||||
it("stops recording on a second quick press", () => {
|
||||
const ctx = setup()
|
||||
const shortcut = createSpeechShortcut({
|
||||
speech: ctx.speech,
|
||||
disabled: () => false,
|
||||
start: () => ctx.speech.start({ model: "scribe", insert: () => {} }),
|
||||
finish: () => ctx.speech.stop(),
|
||||
mac: true,
|
||||
})
|
||||
|
||||
shortcut.down(key(0))
|
||||
shortcut.up(key(100))
|
||||
const start = ctx.sent[0]
|
||||
if (start?.type !== "speechToTextStart") throw new Error("speech start message missing")
|
||||
ctx.fire({ type: "speechToTextStarted", requestId: start.requestId })
|
||||
|
||||
shortcut.down(key(200))
|
||||
shortcut.down(key(250, true))
|
||||
shortcut.up(key(300))
|
||||
expect(ctx.speech.state()).toBe("transcribing")
|
||||
expect(ctx.sent[1]).toEqual({ type: "speechToTextStop", requestId: start.requestId })
|
||||
ctx.dispose()
|
||||
})
|
||||
|
||||
it("queues transcription and submit when a held press is released during startup", () => {
|
||||
const ctx = setup()
|
||||
let submitted = 0
|
||||
const shortcut = createSpeechShortcut({
|
||||
speech: ctx.speech,
|
||||
disabled: () => false,
|
||||
start: () => ctx.speech.start({ model: "scribe", insert: () => {} }),
|
||||
finish: (submit) => ctx.speech.stop(submit ? { done: () => submitted++ } : undefined),
|
||||
mac: true,
|
||||
})
|
||||
|
||||
shortcut.down(key(0))
|
||||
shortcut.up(key(SPEECH_HOLD_MS))
|
||||
expect(ctx.speech.state()).toBe("starting")
|
||||
expect(ctx.sent).toHaveLength(1)
|
||||
|
||||
const start = ctx.sent[0]
|
||||
if (start?.type !== "speechToTextStart") throw new Error("speech start message missing")
|
||||
ctx.fire({ type: "speechToTextStarted", requestId: start.requestId })
|
||||
expect(ctx.speech.state()).toBe("transcribing")
|
||||
expect(ctx.sent[1]).toEqual({ type: "speechToTextStop", requestId: start.requestId })
|
||||
|
||||
ctx.fire({ type: "speechToTextResult", requestId: start.requestId, text: "Held prompt" })
|
||||
expect(submitted).toBe(1)
|
||||
ctx.dispose()
|
||||
})
|
||||
|
||||
it("submits when macOS suppresses K key-up and only reports Command release", () => {
|
||||
const ctx = setup()
|
||||
let submitted = 0
|
||||
const shortcut = createSpeechShortcut({
|
||||
speech: ctx.speech,
|
||||
disabled: () => false,
|
||||
start: () => ctx.speech.start({ model: "scribe", insert: () => {} }),
|
||||
finish: (submit) => ctx.speech.stop(submit ? { done: () => submitted++ } : undefined),
|
||||
mac: true,
|
||||
})
|
||||
|
||||
shortcut.down(key(0))
|
||||
const start = ctx.sent[0]
|
||||
if (start?.type !== "speechToTextStart") throw new Error("speech start message missing")
|
||||
ctx.fire({ type: "speechToTextStarted", requestId: start.requestId })
|
||||
|
||||
expect(shortcut.up({ key: "Meta", timeStamp: SPEECH_HOLD_MS })).toBe(true)
|
||||
expect(ctx.speech.state()).toBe("transcribing")
|
||||
expect(ctx.sent[1]).toEqual({ type: "speechToTextStop", requestId: start.requestId })
|
||||
|
||||
ctx.fire({ type: "speechToTextResult", requestId: start.requestId, text: "Held prompt" })
|
||||
expect(submitted).toBe(1)
|
||||
ctx.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -159,6 +159,7 @@ import {
|
||||
isGrouped,
|
||||
isGroupStart,
|
||||
isGroupEnd,
|
||||
sortWorktrees,
|
||||
type TopLevelItem,
|
||||
} from "./section-helpers"
|
||||
import {} from "./section-dnd"
|
||||
@@ -174,6 +175,7 @@ import { SidebarToggleButton } from "./SidebarToggleButton"
|
||||
import { setTabWidths } from "./tab-widths"
|
||||
import { buildShortcutCategories } from "./shortcuts"
|
||||
import { tracker } from "./telemetry"
|
||||
import { createChatFocus, hasQuestionOption } from "./focus"
|
||||
import "./agent-manager.css"
|
||||
import "./agent-manager-review.css"
|
||||
import { cycleAgent as cycle } from "../src/context/session-agent"
|
||||
@@ -379,7 +381,96 @@ const AgentManagerContent: Component = () => {
|
||||
const sel = selection()
|
||||
return sel === null ? null : nsKey(sel)
|
||||
})
|
||||
const requestChatFocus = createChatFocus({
|
||||
term: () => terms.activeId(),
|
||||
history,
|
||||
review: reviewActive,
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => {
|
||||
const id = session.currentSessionID()
|
||||
return `${id ?? ""}:${session
|
||||
.scopedQuestions(id)
|
||||
.map((question) => question.id)
|
||||
.join(",")}`
|
||||
},
|
||||
() => {
|
||||
requestChatFocus()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
type FocusOwner = "prompt" | { terminal: string }
|
||||
const focusMemory = new Map<string, FocusOwner>()
|
||||
const focusKey = () => {
|
||||
const context = terms.sideKey()
|
||||
const sessionID = session.currentSessionID() ?? activePendingId() ?? "new"
|
||||
return `${context}:${sessionID}`
|
||||
}
|
||||
const forgetSessionFocus = (sessionID: string) => {
|
||||
for (const key of focusMemory.keys()) if (key.endsWith(`:${sessionID}`)) focusMemory.delete(key)
|
||||
}
|
||||
const forgetContextFocus = (context: string) => {
|
||||
for (const key of focusMemory.keys()) if (key.startsWith(`${context}:`)) focusMemory.delete(key)
|
||||
}
|
||||
const forgetTerminalFocus = (terminalID: string) => {
|
||||
for (const [key, owner] of focusMemory) {
|
||||
if (owner !== "prompt" && owner.terminal === terminalID) focusMemory.delete(key)
|
||||
}
|
||||
}
|
||||
const rememberPromptFocus = (focused: boolean) => {
|
||||
if (focused) focusMemory.set(focusKey(), "prompt")
|
||||
}
|
||||
const terminalVisible = () => sidePanel() === "terminal" && !history() && !reviewActive()
|
||||
const focusOnDraftChange = () => {
|
||||
const key = focusKey()
|
||||
const owner = focusMemory.get(key)
|
||||
if (!owner || owner === "prompt") return true
|
||||
if (!terms.sidesForContext(terms.sideKey()).some((term) => term.id === owner.terminal)) {
|
||||
focusMemory.delete(key)
|
||||
return true
|
||||
}
|
||||
return terminalVisible() ? false : true
|
||||
}
|
||||
const restoreFocus = () => {
|
||||
const key = focusKey()
|
||||
const owner = focusMemory.get(key)
|
||||
if (owner && owner !== "prompt") {
|
||||
const context = terms.sideKey()
|
||||
const terminal = terms.sidesForContext(context).find((term) => term.id === owner.terminal)
|
||||
if (terminal && terminalVisible()) {
|
||||
terms.setSideActive(context, terminal.id)
|
||||
terms.requestFocus(terminal.id)
|
||||
return
|
||||
}
|
||||
if (!terminal) focusMemory.delete(key)
|
||||
}
|
||||
requestChatFocus()
|
||||
}
|
||||
createEffect(
|
||||
on(
|
||||
() => terms.focusedId(),
|
||||
(id) => {
|
||||
if (!id) return
|
||||
const key = terms.contextFor(id)
|
||||
if (!key || !terms.sidesForContext(key).some((term) => term.id === id)) return
|
||||
focusMemory.set(focusKey(), { terminal: id })
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
createEffect(
|
||||
on(
|
||||
focusKey,
|
||||
(_key, previous) => {
|
||||
if (previous !== undefined) queueMicrotask(restoreFocus)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
// Ambient setup reveal restores the panel after success unless the user engaged.
|
||||
const ambientSetup = createAmbientSetup({
|
||||
terms,
|
||||
@@ -777,39 +868,7 @@ const AgentManagerContent: Component = () => {
|
||||
const isSessionBusy = (id: string): boolean => isAnySessionBusy([id])
|
||||
|
||||
/** Worktrees sorted so that grouped items are always adjacent, respecting custom order if set. */
|
||||
const sortedWorktrees = createMemo(() => {
|
||||
const ordered = applyTabOrder(worktrees(), sidebarWorktreeOrder())
|
||||
if (ordered.length === 0) return []
|
||||
|
||||
// Collect grouped worktrees by groupId
|
||||
const grouped = new Map<string, WorktreeState[]>()
|
||||
for (const wt of ordered) {
|
||||
if (!wt.groupId) continue
|
||||
const list = grouped.get(wt.groupId) ?? []
|
||||
list.push(wt)
|
||||
grouped.set(wt.groupId, list)
|
||||
}
|
||||
|
||||
// Build output: interleave groups at the position of their earliest member
|
||||
const result: WorktreeState[] = []
|
||||
const placed = new Set<string>()
|
||||
for (const wt of ordered) {
|
||||
if (placed.has(wt.id)) continue
|
||||
if (wt.groupId) {
|
||||
if (placed.has(wt.groupId)) continue
|
||||
placed.add(wt.groupId)
|
||||
const group = grouped.get(wt.groupId) ?? []
|
||||
for (const g of group) {
|
||||
result.push(g)
|
||||
placed.add(g.id)
|
||||
}
|
||||
} else {
|
||||
result.push(wt)
|
||||
placed.add(wt.id)
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
const sortedWorktrees = createMemo(() => sortWorktrees(worktrees(), sidebarWorktreeOrder()))
|
||||
|
||||
const worktreesInSection = (id: string) => sortedWorktrees().filter((wt) => wt.sectionId === id)
|
||||
const ungrouped = createMemo(() => sortedWorktrees().filter((wt) => !wt.sectionId))
|
||||
@@ -845,12 +904,14 @@ const AgentManagerContent: Component = () => {
|
||||
setSelection(null)
|
||||
setReviewActive(false)
|
||||
session.selectSession(id)
|
||||
requestChatFocus(true)
|
||||
}
|
||||
|
||||
const focusSidebarItem = (item: { type: string; id: string }) => {
|
||||
if (item.type === "local") selectLocal()
|
||||
else if (item.type === "wt") selectWorktree(item.id)
|
||||
else selectUnassigned(item.id)
|
||||
requestChatFocus(true)
|
||||
const el = document.querySelector(`[data-sidebar-id="${item.id}"]`)
|
||||
if (el instanceof HTMLElement) scrollIntoView(el)
|
||||
}
|
||||
@@ -883,6 +944,7 @@ const AgentManagerContent: Component = () => {
|
||||
const next = direction === "left" ? idx - 1 : idx + 1
|
||||
if (next < 0 || next >= ids.length) return
|
||||
focusTab(ids[next]!)
|
||||
requestChatFocus(true)
|
||||
}
|
||||
|
||||
const selectionDeps = {
|
||||
@@ -903,10 +965,15 @@ const AgentManagerContent: Component = () => {
|
||||
remembered === REVIEW_TAB_ID && reviewOpenByContext()[sel] === true,
|
||||
}
|
||||
|
||||
const selectLocal = () => selectLocalAction(selectionDeps, localSessions())
|
||||
const selectLocal = () => {
|
||||
selectLocalAction(selectionDeps, localSessions())
|
||||
requestChatFocus()
|
||||
}
|
||||
|
||||
const selectWorktree = (worktreeId: string) =>
|
||||
const selectWorktree = (worktreeId: string) => {
|
||||
selectWorktreeAction(selectionDeps, worktreeId, sessionsForWorktree(worktreeId))
|
||||
requestChatFocus()
|
||||
}
|
||||
|
||||
const addSessionToCurrentWorktree = (sid: string) => {
|
||||
const sel = selection()
|
||||
@@ -926,6 +993,7 @@ const AgentManagerContent: Component = () => {
|
||||
selectWorktree(worktreeId)
|
||||
setHistory(false)
|
||||
session.selectSession(sid)
|
||||
requestChatFocus()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1045,6 +1113,7 @@ const AgentManagerContent: Component = () => {
|
||||
setSelection,
|
||||
setActivePendingId,
|
||||
})
|
||||
requestChatFocus()
|
||||
}
|
||||
// Recover sidebar collapsed state and mark hydrated so transitions enable
|
||||
sidebar.hydrate(state.sidebarCollapsed)
|
||||
@@ -1109,7 +1178,7 @@ const AgentManagerContent: Component = () => {
|
||||
else if (msg.action === "advancedWorktree") showNewWorktreeDialog()
|
||||
else if (msg.action === "closeWorktree") closeSelectedWorktree()
|
||||
else if (msg.action === "showShortcuts") handleShowKeyboardShortcuts()
|
||||
else if (msg.action === "focusInput") window.dispatchEvent(new Event("focusPrompt"))
|
||||
else if (msg.action === "focusInput") requestChatFocus(true)
|
||||
else if (msg.action === "focusSearch")
|
||||
focusChatSearch({ history: setHistory, review: setReviewActive, terminal: () => terms.setActiveId(undefined) })
|
||||
else if (msg.action === "newTerminal") termHandlers.requestNew()
|
||||
@@ -1192,7 +1261,7 @@ const AgentManagerContent: Component = () => {
|
||||
const onWindowFocus = () => {
|
||||
document.body.style.pointerEvents = ""
|
||||
document.body.style.overflow = ""
|
||||
window.dispatchEvent(new Event("focusPrompt"))
|
||||
restoreFocus()
|
||||
}
|
||||
window.addEventListener("focus", onWindowFocus)
|
||||
|
||||
@@ -1238,7 +1307,9 @@ const AgentManagerContent: Component = () => {
|
||||
// Mark sessions loaded as soon as the session context receives data (even if empty)
|
||||
const unsubSessions = vscode.onMessage((msg) => {
|
||||
if (msg.type === "sessionsLoaded" && !sessionsLoaded()) setSessionsLoaded(true)
|
||||
if (msg.type === "agentManager.sessionClosed") handleCloseTab(msg.sessionId, false)
|
||||
if (msg.type === "agentManager.sessionClosed") {
|
||||
handleCloseTab(msg.sessionId, false)
|
||||
}
|
||||
})
|
||||
const unsubRun = vscode.onMessage((msg) =>
|
||||
applyRunStatus(msg, { ensure: (id) => registry.ensure(id), active: () => registry.active() }),
|
||||
@@ -1255,13 +1326,14 @@ const AgentManagerContent: Component = () => {
|
||||
showToast({ variant: "error", title: t("agentManager.terminal.errorTitle"), description: message }),
|
||||
postMessage: (message) => vscode.postMessage(message as never),
|
||||
onCreated: (contextKey, terminalId) => appendToTabOrder(contextKey, terminalId),
|
||||
onSideCreated: (contextKey, terminalId) => {
|
||||
onSideCreated: (contextKey, terminalId, focus) => {
|
||||
// Focus only when the user is still looking at this panel —
|
||||
// a slow create landing after a mode switch must not steal it.
|
||||
if (sidePanel() === "terminal" && !history() && !reviewActive() && terms.sideKey() === contextKey) {
|
||||
if (focus && sidePanel() === "terminal" && !history() && !reviewActive() && terms.sideKey() === contextKey) {
|
||||
terms.requestFocus(terminalId)
|
||||
}
|
||||
},
|
||||
onSideClosed: (_contextKey, terminalId) => forgetTerminalFocus(terminalId),
|
||||
onScriptRunning: (contextKey, terminalId) => {
|
||||
if (terms.sideKey() !== contextKey) return
|
||||
// Setup output is informational: reveal without stealing focus, and
|
||||
@@ -1311,6 +1383,7 @@ const AgentManagerContent: Component = () => {
|
||||
const ms = managedSessions().find((s) => s.id === ev.sessionId)
|
||||
if (ms?.worktreeId) setSelection(ms.worktreeId)
|
||||
evictLocal(ev.sessionId)
|
||||
requestChatFocus(true)
|
||||
}
|
||||
} else {
|
||||
// Track this worktree as setting up and auto-select it in the sidebar
|
||||
@@ -1339,6 +1412,7 @@ const AgentManagerContent: Component = () => {
|
||||
evictLocal(ev.sessionId)
|
||||
drafts.apply(ev.worktreeId, ev.sessionId)
|
||||
session.selectSession(ev.sessionId)
|
||||
requestChatFocus(true)
|
||||
}
|
||||
|
||||
if (msg.type === "agentManager.sessionForked") {
|
||||
@@ -1358,6 +1432,7 @@ const AgentManagerContent: Component = () => {
|
||||
evictLocal(ev.sessionId)
|
||||
}
|
||||
session.selectSession(ev.sessionId)
|
||||
requestChatFocus(true)
|
||||
}
|
||||
|
||||
if (msg.type === "agentManager.keybindings") {
|
||||
@@ -1806,6 +1881,7 @@ const AgentManagerContent: Component = () => {
|
||||
// Second press/click: execute the delete
|
||||
if (pendingDelete() === worktreeId) {
|
||||
cancelPendingDelete()
|
||||
forgetContextFocus(nsKey(worktreeId))
|
||||
setBusyWorktrees((prev) => new Map([...prev, [wt.id, { reason: "deleting" as const }]]))
|
||||
vscode.postMessage({ type: "agentManager.deleteWorktree", worktreeId: wt.id })
|
||||
if (selection() === wt.id) {
|
||||
@@ -1897,6 +1973,7 @@ const AgentManagerContent: Component = () => {
|
||||
setSelection(LOCAL)
|
||||
setReviewActive(false)
|
||||
session.selectSession(sid)
|
||||
requestChatFocus()
|
||||
vscode.postMessage({ type: "agentManager.openLocally", sessionId: sid })
|
||||
}
|
||||
|
||||
@@ -1945,6 +2022,7 @@ const AgentManagerContent: Component = () => {
|
||||
session.clearCurrentSession()
|
||||
}
|
||||
}
|
||||
forgetSessionFocus(sessionId)
|
||||
if (pending || localSet().has(sessionId)) {
|
||||
setLocalSessionIDs((prev) => prev.filter((id) => id !== sessionId))
|
||||
}
|
||||
@@ -2004,7 +2082,7 @@ const AgentManagerContent: Component = () => {
|
||||
cancelAmbientSetup()
|
||||
setSidePanel(null)
|
||||
},
|
||||
refocus: () => window.dispatchEvent(new Event("focusPrompt")),
|
||||
refocus: requestChatFocus,
|
||||
postMessage: (msg) => vscode.postMessage(msg as never),
|
||||
track: (button, surface, properties) => metrics.track(button, surface, properties),
|
||||
// Panel-local pick, immune to cross-window setting echoes (see side.ts).
|
||||
@@ -2100,7 +2178,7 @@ const AgentManagerContent: Component = () => {
|
||||
return activeTabs().find((s) => s.id === id)
|
||||
})
|
||||
|
||||
const focusTab = (id: string) =>
|
||||
const focusTab = (id: string) => {
|
||||
focusCurrentTab({
|
||||
id,
|
||||
terms,
|
||||
@@ -2116,6 +2194,7 @@ const AgentManagerContent: Component = () => {
|
||||
selectSession: session.selectSession,
|
||||
activateTerminal: termHandlers.activate,
|
||||
})
|
||||
}
|
||||
const tabFocus = createTabFocus({ ids: () => tabIds(), select: focusTab })
|
||||
|
||||
// Close the currently active tab via keyboard shortcut.
|
||||
@@ -2255,6 +2334,7 @@ const AgentManagerContent: Component = () => {
|
||||
<ProjectList
|
||||
projects={projectList()}
|
||||
states={projectStates()}
|
||||
store={(id) => registry.ensure(id)}
|
||||
stats={projectLive.stats()}
|
||||
local={projectLive.local()}
|
||||
prs={projectLive.prs()}
|
||||
@@ -2425,6 +2505,7 @@ const AgentManagerContent: Component = () => {
|
||||
saveTabMemory()
|
||||
session.selectSession(id)
|
||||
setSelection(LOCAL)
|
||||
requestChatFocus(true)
|
||||
return
|
||||
}
|
||||
const ms = worktreeSessionIds().has(id) ? managedSessions().find((s) => s.id === id) : undefined
|
||||
@@ -2432,6 +2513,7 @@ const AgentManagerContent: Component = () => {
|
||||
selectWorktree(ms.worktreeId)
|
||||
session.selectSession(id)
|
||||
setReviewActive(false)
|
||||
requestChatFocus()
|
||||
return
|
||||
}
|
||||
openLocally(id)
|
||||
@@ -2486,6 +2568,7 @@ const AgentManagerContent: Component = () => {
|
||||
if (localSessionIDs().includes(id)) {
|
||||
session.selectSession(id)
|
||||
if (selection() === null) setSelection(LOCAL)
|
||||
requestChatFocus()
|
||||
return
|
||||
}
|
||||
// Navigate to owning worktree instead of forcing into local mode
|
||||
@@ -2495,6 +2578,7 @@ const AgentManagerContent: Component = () => {
|
||||
selectWorktree(ms.worktreeId)
|
||||
session.selectSession(id)
|
||||
setReviewActive(false)
|
||||
requestChatFocus()
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -2506,7 +2590,10 @@ const AgentManagerContent: Component = () => {
|
||||
readonly={readOnly()}
|
||||
continueInWorktree={selection() === LOCAL}
|
||||
promptBoxId={`agent-manager:${selection() ?? "unassigned"}`}
|
||||
deferFocusToQuestion={hasQuestionOption}
|
||||
pendingSessionID={selection() === LOCAL ? activePendingId() : undefined}
|
||||
focusOnDraftChange={focusOnDraftChange}
|
||||
onFocusChange={rememberPromptFocus}
|
||||
/>
|
||||
<Show when={readOnly()}>
|
||||
<div class="am-readonly-banner">
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
import { useLanguage } from "../src/context/language"
|
||||
import { useImageAttachments, type ImageAttachment } from "../src/hooks/useImageAttachments"
|
||||
import { useSpeechToText } from "../src/components/speech-to-text/useSpeechToText"
|
||||
import { createSpeechShortcut } from "../src/components/speech-to-text/shortcut"
|
||||
import { convertToMentionPath } from "../src/utils/path-mentions"
|
||||
import { insertSpacedText } from "../src/components/chat/prompt-input-utils"
|
||||
import { WandSparkles } from "@kilocode/kilo-ui/lucide"
|
||||
@@ -380,6 +381,12 @@ export const NewWorktreeDialog: Component<{
|
||||
}
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (shortcut.down(e)) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
// Shift+Tab cycles reasoning effort variants (setting: chat.shiftTabCyclesVariant).
|
||||
// When disabled or no variants exist, fall through to default focus navigation.
|
||||
if (e.key === "Tab" && e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
@@ -429,6 +436,19 @@ export const NewWorktreeDialog: Component<{
|
||||
speech.start({ model: speechModel(), insert: insertSpeechText })
|
||||
}
|
||||
|
||||
const shortcut = createSpeechShortcut({
|
||||
speech,
|
||||
disabled: () => !canUseSpeech() || starting(),
|
||||
start: startSpeech,
|
||||
finish: (submit) => speech.stop(submit ? { done: handleSubmit } : undefined),
|
||||
})
|
||||
const speechUp = (e: KeyboardEvent) => {
|
||||
if (!shortcut.up(e)) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
onCleanup(shortcut.reset)
|
||||
|
||||
const canEnhance = () => !starting() && !enhancing() && !speech.active() && server.isConnected()
|
||||
|
||||
const handleEnhance = () => {
|
||||
@@ -606,6 +626,7 @@ export const NewWorktreeDialog: Component<{
|
||||
adjustHeight()
|
||||
}}
|
||||
onKeyDown={onKey}
|
||||
onKeyUp={speechUp}
|
||||
onPaste={(e) => imageAttach.handlePaste(e)}
|
||||
rows={3}
|
||||
dir="auto"
|
||||
|
||||
@@ -19,11 +19,13 @@ import type { SidebarSearchItem } from "./sidebar-search"
|
||||
import { LOCAL } from "./navigate"
|
||||
import { NewWorktreeDialog } from "./NewWorktreeDialog"
|
||||
import { ProjectBranchDialog } from "./ProjectBranchDialog"
|
||||
import type { ProjectStore } from "./project/store"
|
||||
import type { ModeRouter } from "./mode-router"
|
||||
|
||||
interface Props {
|
||||
projects: AgentProjectSnapshot[]
|
||||
states: Record<string, AgentManagerStateMessage>
|
||||
store?: (projectId: string) => ProjectStore
|
||||
stats: Record<string, Record<string, WorktreeGitStats>>
|
||||
local: Record<string, LocalGitStats>
|
||||
prs: Record<string, Record<string, PRStatus | null>>
|
||||
@@ -208,6 +210,7 @@ export const ProjectList: Component<Props> = (props) => {
|
||||
<ProjectSidebarBody
|
||||
project={project}
|
||||
state={props.states[project.id]}
|
||||
store={props.store?.(project.id)}
|
||||
stats={props.stats[project.id]}
|
||||
local={props.local[project.id]}
|
||||
prs={props.prs[project.id]}
|
||||
|
||||
@@ -1,26 +1,40 @@
|
||||
import { For, Show, createMemo, createSignal, onCleanup, type Component } from "solid-js"
|
||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Component } from "solid-js"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
import { DragDropProvider, DragDropSensors } from "@thisbeyond/solid-dnd"
|
||||
import {
|
||||
DragDropProvider,
|
||||
DragDropSensors,
|
||||
DragOverlay,
|
||||
SortableProvider,
|
||||
createSortable,
|
||||
type DragEvent,
|
||||
} from "@thisbeyond/solid-dnd"
|
||||
import type {
|
||||
AgentManagerStateMessage,
|
||||
AgentProjectSnapshot,
|
||||
LocalGitStats,
|
||||
PRStatus,
|
||||
ProjectSessionInfo,
|
||||
WorktreeState,
|
||||
WorktreeGitStats,
|
||||
} from "../src/types/messages"
|
||||
import type { LanguageContextValue } from "../src/context/language"
|
||||
import { useVSCode } from "../src/context/vscode"
|
||||
import { formatRelativeDate } from "../src/utils/date"
|
||||
import SectionHeader from "./SectionHeader"
|
||||
import { WorktreeItem } from "./WorktreeItem"
|
||||
import { UnassignedSessionsSection } from "./UnassignedSessionsSection"
|
||||
import { ProjectActions } from "./ProjectActions"
|
||||
import { applyTabOrder, firstOrderedTitle, reorderTabs } from "./tab-order"
|
||||
import { buildTopLevelItems, sortWorktrees, isGroupEnd, isGroupStart, isGrouped } from "./section-helpers"
|
||||
import { sectionAwareDetector } from "./section-dnd"
|
||||
import { ConstrainDragXAxis } from "./constrain-drag-x"
|
||||
import { createProjectStore, type ProjectStore } from "./project/store"
|
||||
import { randomColor } from "./section-colors"
|
||||
|
||||
interface Props {
|
||||
project: AgentProjectSnapshot
|
||||
state?: AgentManagerStateMessage
|
||||
store?: ProjectStore
|
||||
busy?: (id: string) => boolean
|
||||
stats?: Record<string, WorktreeGitStats>
|
||||
local?: LocalGitStats
|
||||
@@ -40,8 +54,21 @@ interface Props {
|
||||
/** Permanent real sidebar body for one expanded project. */
|
||||
export const ProjectSidebarBody: Component<Props> = (props) => {
|
||||
const vscode = useVSCode()
|
||||
const store = props.store ?? createProjectStore(props.project.id)
|
||||
if (!props.store) {
|
||||
createEffect(() => {
|
||||
const state = props.state
|
||||
if (state) store.applyState(state)
|
||||
})
|
||||
}
|
||||
const [pending, setPending] = createSignal<string>()
|
||||
const [renaming, setRenaming] = createSignal<string>()
|
||||
const [renamingSection, setRenamingSection] = createSignal<string>()
|
||||
const [pendingSection, setPendingSection] = createSignal<
|
||||
{ ids: Set<string>; state?: AgentManagerStateMessage } | undefined
|
||||
>()
|
||||
const [dragging, setDragging] = createSignal<string>()
|
||||
const [dragOrigin, setDragOrigin] = createSignal<string[]>()
|
||||
const [name, setName] = createSignal("")
|
||||
let pendingTimer: ReturnType<typeof setTimeout> | undefined
|
||||
onCleanup(() => clearTimeout(pendingTimer))
|
||||
@@ -61,14 +88,105 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
|
||||
const sessions = (worktreeId: string | null) =>
|
||||
(props.sessions ?? []).filter((item) => item.worktreeId === worktreeId)
|
||||
const active = () => props.selectedProject === props.project.id
|
||||
const runs = createMemo(() => Object.fromEntries((state()?.runStatuses ?? []).map((run) => [run.worktreeId, run])))
|
||||
const sections = () => state()?.sections ?? []
|
||||
const runs = () => store.runStatuses()
|
||||
const sections = () => store.sections()
|
||||
const worktrees = () => store.worktrees()
|
||||
const order = () => store.worktreeOrder()
|
||||
const localSessions = () => sessions(null)
|
||||
const ungrouped = () => state()?.worktrees.filter((wt) => !wt.sectionId) ?? []
|
||||
const members = (sectionId: string) => state()?.worktrees.filter((wt) => wt.sectionId === sectionId) ?? []
|
||||
const sorted = createMemo(() => sortWorktrees(worktrees(), order()))
|
||||
const members = (sectionId: string) => sorted().filter((wt) => wt.sectionId === sectionId)
|
||||
const ungrouped = createMemo(() => sorted().filter((wt) => !wt.sectionId))
|
||||
const top = createMemo(() => buildTopLevelItems(sections(), ungrouped(), sorted(), order()))
|
||||
const post = (message: Record<string, unknown>) =>
|
||||
vscode.postMessage({ ...message, projectId: props.project.id } as never)
|
||||
|
||||
const scope = (kind: "section" | "worktree", id: string) => `${props.project.id}:${kind}:${id}`
|
||||
const parse = (kind: "section" | "worktree", value: unknown) => {
|
||||
if (typeof value !== "string") return
|
||||
const prefix = `${props.project.id}:${kind}:`
|
||||
return value.startsWith(prefix) ? value.slice(prefix.length) : undefined
|
||||
}
|
||||
|
||||
const createSection = (worktreeIds?: string[]) => {
|
||||
setPendingSection({ ids: new Set(sections().map((section) => section.id)), state: state() })
|
||||
post({
|
||||
type: "agentManager.createSection",
|
||||
name: props.t("agentManager.section.defaultName"),
|
||||
color: randomColor(),
|
||||
worktreeIds,
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const previous = pendingSection()
|
||||
if (!previous) return
|
||||
const current = state()
|
||||
if (current === previous.state) return
|
||||
const created = (current?.sections ?? []).find((section) => !previous.ids.has(section.id))
|
||||
setPendingSection(undefined)
|
||||
if (!created) return
|
||||
setRenamingSection(created.id)
|
||||
})
|
||||
|
||||
const worktreeIds = createMemo(() => new Set(worktrees().map((wt) => wt.id)))
|
||||
const sectionIds = createMemo(() => new Set(sections().map((section) => scope("section", section.id))))
|
||||
const home = createMemo(
|
||||
() =>
|
||||
new Map(
|
||||
worktrees().map(
|
||||
(wt) => [scope("worktree", wt.id), wt.sectionId ? scope("section", wt.sectionId) : undefined] as const,
|
||||
),
|
||||
),
|
||||
)
|
||||
const detector = sectionAwareDetector(sectionIds, home)
|
||||
const dragIds = createMemo(() => sorted().map((wt) => scope("worktree", wt.id)))
|
||||
|
||||
const onDragStart = (event: DragEvent) => {
|
||||
const id = parse("worktree", event.draggable?.id)
|
||||
if (!id || !worktreeIds().has(id)) return
|
||||
setDragging(id)
|
||||
setDragOrigin(order())
|
||||
document.body.classList.add("am-wt-dragging-active")
|
||||
}
|
||||
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
const from = parse("worktree", event.draggable?.id)
|
||||
const to = parse("worktree", event.droppable?.id)
|
||||
if (!from || !to || !worktreeIds().has(from) || !worktreeIds().has(to)) return
|
||||
store.setWorktreeOrder((previous) => {
|
||||
const current = applyTabOrder(
|
||||
sorted().map((wt) => ({ id: wt.id })),
|
||||
previous,
|
||||
).map((item) => item.id)
|
||||
return reorderTabs(current, from, to) ?? previous
|
||||
})
|
||||
}
|
||||
|
||||
const onDragEnd = (event: DragEvent) => {
|
||||
const from = parse("worktree", event.draggable?.id)
|
||||
const section = parse("section", event.droppable?.id)
|
||||
const to = parse("worktree", event.droppable?.id)
|
||||
setDragging(undefined)
|
||||
const origin = dragOrigin()
|
||||
setDragOrigin(undefined)
|
||||
document.body.classList.remove("am-wt-dragging-active")
|
||||
if (!from || !worktreeIds().has(from)) {
|
||||
if (origin) store.setWorktreeOrder(origin)
|
||||
return
|
||||
}
|
||||
if (section && sections().some((item) => item.id === section)) {
|
||||
post({ type: "agentManager.moveToSection", worktreeIds: [from], sectionId: section })
|
||||
return
|
||||
}
|
||||
if (!to || !worktreeIds().has(to)) {
|
||||
if (origin) store.setWorktreeOrder(origin)
|
||||
return
|
||||
}
|
||||
post({ type: "agentManager.setWorktreeOrder", order: order() })
|
||||
}
|
||||
|
||||
onCleanup(() => document.body.classList.remove("am-wt-dragging-active"))
|
||||
|
||||
// Escape unmounts the focused rename input, which fires a synchronous blur
|
||||
// that would re-commit the cancelled value; this flag swallows that blur.
|
||||
let cancelled = false
|
||||
@@ -86,62 +204,64 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
|
||||
setRenaming(undefined)
|
||||
}
|
||||
|
||||
const renderWorktree = (worktree: NonNullable<Props["state"]>["worktrees"][number]) => (
|
||||
<WorktreeItem
|
||||
worktree={worktree}
|
||||
sidebarId={`${props.project.id}:${worktree.id}`}
|
||||
label={worktree.label || worktree.branch}
|
||||
subtitle={worktree.label && worktree.label !== worktree.branch ? worktree.branch : undefined}
|
||||
active={active() && props.selection === worktree.id}
|
||||
pendingDelete={pending() === worktree.id}
|
||||
busy={props.busy?.(worktree.id) ?? false}
|
||||
working={runs()[worktree.id]?.state === "running"}
|
||||
stale={state()?.staleWorktreeIds?.includes(worktree.id) === true}
|
||||
stats={props.stats?.[worktree.id]}
|
||||
sessions={sessions(worktree.id).length}
|
||||
grouped={false}
|
||||
groupStart={false}
|
||||
groupEnd={false}
|
||||
groupSize={0}
|
||||
renaming={renaming() === worktree.id}
|
||||
renameValue={name()}
|
||||
closeKeybind=""
|
||||
openKeybind=""
|
||||
pr={props.prs?.[worktree.id] ?? undefined}
|
||||
runStatus={runs()[worktree.id]}
|
||||
sections={sections()}
|
||||
currentSectionId={worktree.sectionId}
|
||||
onMoveToSection={(sectionId) =>
|
||||
post({ type: "agentManager.moveToSection", worktreeIds: [worktree.id], sectionId })
|
||||
}
|
||||
onMoveToNewSection={() =>
|
||||
post({
|
||||
type: "agentManager.createSection",
|
||||
name: props.t("agentManager.worktree.newSection"),
|
||||
worktreeIds: [worktree.id],
|
||||
})
|
||||
}
|
||||
onClick={() => {
|
||||
if (pending() === worktree.id) return confirmDelete(worktree.id)
|
||||
props.onSelectWorktree(props.project.id, worktree.id)
|
||||
}}
|
||||
onDelete={(event) => {
|
||||
event.stopPropagation()
|
||||
confirmDelete(worktree.id)
|
||||
}}
|
||||
onStartRename={(value) => {
|
||||
setName(value)
|
||||
setRenaming(worktree.id)
|
||||
}}
|
||||
onRenameInput={setName}
|
||||
onCommitRename={() => commitRename(worktree.id)}
|
||||
onCancelRename={cancelRename}
|
||||
onRemoveStale={() => post({ type: "agentManager.removeStaleWorktree", worktreeId: worktree.id })}
|
||||
onCopyPath={() => navigator.clipboard.writeText(worktree.path)}
|
||||
onOpen={() => post({ type: "agentManager.openWorktree", worktreeId: worktree.id })}
|
||||
onOpenPR={() => post({ type: "agentManager.openPR", worktreeId: worktree.id })}
|
||||
/>
|
||||
)
|
||||
const renderWorktree = (worktree: WorktreeState, idx: () => number, list: WorktreeState[]) => {
|
||||
const label = () => firstOrderedTitle(sessions(worktree.id), store.tabOrder()[worktree.id], worktree.branch)
|
||||
const subtitle = () => (label() !== worktree.branch ? worktree.branch : undefined)
|
||||
const sortable = createSortable(scope("worktree", worktree.id))
|
||||
void sortable
|
||||
return (
|
||||
<div use:sortable class={`am-wt-sortable ${sortable.isActiveDraggable ? "am-wt-dragging" : ""}`}>
|
||||
<WorktreeItem
|
||||
worktree={worktree}
|
||||
sidebarId={`${props.project.id}:${worktree.id}`}
|
||||
label={worktree.label || label()}
|
||||
subtitle={worktree.label ? (worktree.label !== worktree.branch ? worktree.branch : undefined) : subtitle()}
|
||||
active={active() && props.selection === worktree.id}
|
||||
pendingDelete={pending() === worktree.id}
|
||||
busy={props.busy?.(worktree.id) ?? false}
|
||||
working={runs()[worktree.id]?.state === "running"}
|
||||
stale={state()?.staleWorktreeIds?.includes(worktree.id) === true}
|
||||
stats={props.stats?.[worktree.id]}
|
||||
sessions={sessions(worktree.id).length}
|
||||
grouped={isGrouped(worktree)}
|
||||
groupStart={isGroupStart(worktree, idx(), list)}
|
||||
groupEnd={isGroupEnd(worktree, idx(), list)}
|
||||
groupSize={worktree.groupId ? sorted().filter((item) => item.groupId === worktree.groupId).length : 0}
|
||||
renaming={renaming() === worktree.id}
|
||||
renameValue={name()}
|
||||
closeKeybind=""
|
||||
openKeybind=""
|
||||
pr={props.prs?.[worktree.id] ?? undefined}
|
||||
runStatus={runs()[worktree.id]}
|
||||
sections={sections()}
|
||||
currentSectionId={worktree.sectionId}
|
||||
onMoveToSection={(sectionId) =>
|
||||
post({ type: "agentManager.moveToSection", worktreeIds: [worktree.id], sectionId })
|
||||
}
|
||||
onMoveToNewSection={() => createSection([worktree.id])}
|
||||
onClick={() => {
|
||||
if (pending() === worktree.id) return confirmDelete(worktree.id)
|
||||
props.onSelectWorktree(props.project.id, worktree.id)
|
||||
}}
|
||||
onDelete={(event) => {
|
||||
event.stopPropagation()
|
||||
confirmDelete(worktree.id)
|
||||
}}
|
||||
onStartRename={(value) => {
|
||||
setName(value)
|
||||
setRenaming(worktree.id)
|
||||
}}
|
||||
onRenameInput={setName}
|
||||
onCommitRename={() => commitRename(worktree.id)}
|
||||
onCancelRename={cancelRename}
|
||||
onRemoveStale={() => post({ type: "agentManager.removeStaleWorktree", worktreeId: worktree.id })}
|
||||
onCopyPath={() => navigator.clipboard.writeText(worktree.path)}
|
||||
onOpen={() => post({ type: "agentManager.openWorktree", worktreeId: worktree.id })}
|
||||
onOpenPR={() => post({ type: "agentManager.openPR", worktreeId: worktree.id })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="am-project-body" data-project-body={props.project.id}>
|
||||
@@ -202,52 +322,73 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
|
||||
t={props.t}
|
||||
onCreate={() => post({ type: "agentManager.createWorktree" })}
|
||||
onNew={() => props.onNewWorktree(props.project.id)}
|
||||
onSection={() =>
|
||||
post({
|
||||
type: "agentManager.createSection",
|
||||
name: props.t("agentManager.section.defaultName"),
|
||||
})
|
||||
}
|
||||
onSection={() => createSection()}
|
||||
onSetup={() => post({ type: "agentManager.configureSetupScript" })}
|
||||
onBranch={() => props.onDefaultBranch(props.project.id, state()?.defaultBaseBranch, props.local?.branch)}
|
||||
/>
|
||||
</div>
|
||||
<div class="am-worktree-list">
|
||||
{/*
|
||||
SectionHeader registers a drop target via solid-dnd, which throws
|
||||
without a DragDropProvider ancestor and kills the whole render.
|
||||
Multi-project has no drag-and-drop yet, so this provider is a
|
||||
no-op context until DnD lands here.
|
||||
*/}
|
||||
<DragDropProvider onDragStart={() => {}} onDragEnd={() => {}}>
|
||||
<DragDropProvider
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnd={onDragEnd}
|
||||
collisionDetector={detector}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<For each={sections()}>
|
||||
{(section, index) => (
|
||||
<SectionHeader
|
||||
section={section}
|
||||
count={members(section.id).length}
|
||||
onToggle={() => post({ type: "agentManager.toggleSectionCollapsed", sectionId: section.id })}
|
||||
onRename={(value: string) =>
|
||||
post({ type: "agentManager.renameSection", sectionId: section.id, name: value })
|
||||
<ConstrainDragXAxis />
|
||||
<SortableProvider ids={dragIds()}>
|
||||
<For each={top()}>
|
||||
{(item, index) => {
|
||||
if (item.kind === "worktree") {
|
||||
const list = ungrouped()
|
||||
return renderWorktree(item.wt, () => list.indexOf(item.wt), list)
|
||||
}
|
||||
onDelete={() => post({ type: "agentManager.deleteSection", sectionId: section.id })}
|
||||
onSetColor={(color: string | null) =>
|
||||
post({ type: "agentManager.setSectionColor", sectionId: section.id, color })
|
||||
}
|
||||
isFirst={index() === 0}
|
||||
isLast={index() === sections().length - 1}
|
||||
onMoveUp={() => post({ type: "agentManager.moveSection", sectionId: section.id, dir: -1 })}
|
||||
onMoveDown={() => post({ type: "agentManager.moveSection", sectionId: section.id, dir: 1 })}
|
||||
>
|
||||
<Show when={!section.collapsed}>
|
||||
<div class="am-section-group-body">
|
||||
<For each={members(section.id)}>{renderWorktree}</For>
|
||||
</div>
|
||||
</Show>
|
||||
</SectionHeader>
|
||||
)}
|
||||
</For>
|
||||
<For each={ungrouped()}>{renderWorktree}</For>
|
||||
const section = item.section
|
||||
const list = members(section.id)
|
||||
return (
|
||||
<SectionHeader
|
||||
section={section}
|
||||
dropId={scope("section", section.id)}
|
||||
count={list.length}
|
||||
autoRename={renamingSection() === section.id}
|
||||
onRenameEnd={() => {
|
||||
if (renamingSection() === section.id) setRenamingSection(undefined)
|
||||
}}
|
||||
onToggle={() => post({ type: "agentManager.toggleSectionCollapsed", sectionId: section.id })}
|
||||
onRename={(value: string) =>
|
||||
post({ type: "agentManager.renameSection", sectionId: section.id, name: value })
|
||||
}
|
||||
onDelete={() => post({ type: "agentManager.deleteSection", sectionId: section.id })}
|
||||
onSetColor={(color: string | null) =>
|
||||
post({ type: "agentManager.setSectionColor", sectionId: section.id, color })
|
||||
}
|
||||
isFirst={index() === 0}
|
||||
isLast={index() === top().length - 1}
|
||||
onMoveUp={() => post({ type: "agentManager.moveSection", sectionId: section.id, dir: -1 })}
|
||||
onMoveDown={() => post({ type: "agentManager.moveSection", sectionId: section.id, dir: 1 })}
|
||||
>
|
||||
<Show when={!section.collapsed}>
|
||||
<div class="am-section-group-body">
|
||||
<For each={list}>{(wt, wtIndex) => renderWorktree(wt, wtIndex, list)}</For>
|
||||
</div>
|
||||
</Show>
|
||||
</SectionHeader>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
<DragOverlay>
|
||||
{(() => {
|
||||
const wt = sorted().find((item) => item.id === dragging())
|
||||
if (!wt) return null
|
||||
return (
|
||||
<div class="am-wt-overlay">
|
||||
<Icon name="branch" size="small" />
|
||||
<span>{wt.label || firstOrderedTitle(sessions(wt.id), store.tabOrder()[wt.id], wt.branch)}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,8 @@ interface Props {
|
||||
children?: JSX.Element
|
||||
/** When true, auto-enter rename mode (e.g. after creation). */
|
||||
autoRename?: boolean
|
||||
/** Scoped drop id used when multiple project DnD providers are mounted. */
|
||||
dropId?: string
|
||||
onToggle: () => void
|
||||
onRename: (name: string) => void
|
||||
onDelete: () => void
|
||||
@@ -59,7 +61,7 @@ const SectionHeader: Component<Props> = (props) => {
|
||||
props.onToggle()
|
||||
}
|
||||
|
||||
const droppable = createDroppable(props.section.id)
|
||||
const droppable = createDroppable(props.dropId ?? props.section.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
const OPTION = '[data-component="question-dock"] button[data-slot="question-option"]'
|
||||
|
||||
export function createChatFocus(deps: {
|
||||
term: () => string | undefined
|
||||
history: () => boolean
|
||||
review: () => boolean
|
||||
}) {
|
||||
const focus = (force: boolean) => {
|
||||
if ((!force && !document.hasFocus()) || deps.term() || deps.history() || deps.review()) return
|
||||
if (!force && document.activeElement?.matches('[role="tab"]')) return
|
||||
if (!force && document.activeElement?.closest('[data-component="question-dock"]')) return
|
||||
if (focusQuestionOption()) return
|
||||
const defer = hasQuestionOption()
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("focusPrompt", {
|
||||
detail: { restore: !defer, deferFocusToQuestion: defer },
|
||||
}),
|
||||
)
|
||||
}
|
||||
return (force = false) => {
|
||||
queueMicrotask(() => focus(force))
|
||||
requestAnimationFrame(() => {
|
||||
focus(force)
|
||||
requestAnimationFrame(() => {
|
||||
focus(force)
|
||||
requestAnimationFrame(() => focus(force))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Return whether the visible question dock has an enabled option to focus. */
|
||||
export function hasQuestionOption(root: ParentNode = document): boolean {
|
||||
for (const option of root.querySelectorAll<HTMLButtonElement>(OPTION)) {
|
||||
if (!option.disabled && !option.closest("[inert]")) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Focus the first enabled option in the visible question dock, if one exists. */
|
||||
export function focusQuestionOption(root: ParentNode = document): boolean {
|
||||
for (const option of root.querySelectorAll<HTMLButtonElement>(OPTION)) {
|
||||
if (option.disabled || option.closest("[inert]")) continue
|
||||
option.focus({ preventScroll: true })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -63,6 +63,7 @@ export const dict = {
|
||||
|
||||
"agentManager.terminal.new": "علامة تبويب جديدة للمحطة الطرفية",
|
||||
"agentManager.terminal.ended": "انتهت المحطة الطرفية — أغلق علامة التبويب للإخفاء",
|
||||
"agentManager.terminal.endedRestartable": "انتهت المحطة الطرفية - اكتب لبدء صدفة جديدة أو أغلق علامة التبويب",
|
||||
"agentManager.terminal.setupFailed": "فشل البرنامج النصي للإعداد",
|
||||
"agentManager.terminal.setupFailedCode": "فشل البرنامج النصي للإعداد برمز الخروج",
|
||||
"agentManager.terminal.stopSetup": "إيقاف البرنامج النصي للإعداد",
|
||||
|
||||
@@ -64,6 +64,7 @@ export const dict = {
|
||||
|
||||
"agentManager.terminal.new": "Nova aba de terminal",
|
||||
"agentManager.terminal.ended": "terminal encerrado — feche a aba para dispensar",
|
||||
"agentManager.terminal.endedRestartable": "terminal encerrado - digite para iniciar um novo shell ou feche a aba",
|
||||
"agentManager.terminal.setupFailed": "falha no script de configuração",
|
||||
"agentManager.terminal.setupFailedCode": "falha no script de configuração com código de saída",
|
||||
"agentManager.terminal.stopSetup": "Parar o script de configuração",
|
||||
|
||||
@@ -63,6 +63,7 @@ export const dict = {
|
||||
|
||||
"agentManager.terminal.new": "Nova kartica terminala",
|
||||
"agentManager.terminal.ended": "terminal je završen — zatvorite karticu da biste odbacili",
|
||||
"agentManager.terminal.endedRestartable": "terminal je završen - kucajte za novu ljusku ili zatvorite karticu",
|
||||
"agentManager.terminal.setupFailed": "skripta za postavljanje nije uspjela",
|
||||
"agentManager.terminal.setupFailedCode": "skripta za postavljanje nije uspjela s izlaznim kodom",
|
||||
"agentManager.terminal.stopSetup": "Zaustavi skriptu za postavljanje",
|
||||
|
||||
@@ -65,6 +65,7 @@ export const dict = {
|
||||
|
||||
"agentManager.terminal.new": "Ny terminalfane",
|
||||
"agentManager.terminal.ended": "terminal afsluttet — luk fanen for at fjerne",
|
||||
"agentManager.terminal.endedRestartable": "terminal afsluttet - skriv for at starte en ny shell, eller luk fanen",
|
||||
"agentManager.terminal.setupFailed": "opsætningsscript mislykkedes",
|
||||
"agentManager.terminal.setupFailedCode": "opsætningsscript mislykkedes med exitkode",
|
||||
"agentManager.terminal.stopSetup": "Stop opsætningsscript",
|
||||
|
||||
@@ -64,6 +64,8 @@ export const dict = {
|
||||
|
||||
"agentManager.terminal.new": "Neuer Terminal-Tab",
|
||||
"agentManager.terminal.ended": "Terminal beendet — Tab schließen zum Verwerfen",
|
||||
"agentManager.terminal.endedRestartable":
|
||||
"Terminal beendet - tippen, um eine neue Shell zu starten, oder Tab schließen",
|
||||
"agentManager.terminal.setupFailed": "Setup-Skript fehlgeschlagen",
|
||||
"agentManager.terminal.setupFailedCode": "Setup-Skript mit Exit-Code fehlgeschlagen",
|
||||
"agentManager.terminal.stopSetup": "Setup-Skript stoppen",
|
||||
|
||||
@@ -68,6 +68,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "New Terminal Tab",
|
||||
"agentManager.terminal.add": "New terminal",
|
||||
"agentManager.terminal.ended": "terminal ended — close tab to dismiss",
|
||||
"agentManager.terminal.endedRestartable": "terminal ended - type to start a new shell or close tab to dismiss",
|
||||
"agentManager.terminal.setupFailed": "setup script failed",
|
||||
"agentManager.terminal.setupFailedCode": "setup script failed with exit code",
|
||||
"agentManager.terminal.stopSetup": "Stop setup script",
|
||||
|
||||
@@ -64,6 +64,8 @@ export const dict = {
|
||||
|
||||
"agentManager.terminal.new": "Nueva pestaña de terminal",
|
||||
"agentManager.terminal.ended": "terminal finalizado — cierra la pestaña para descartar",
|
||||
"agentManager.terminal.endedRestartable":
|
||||
"terminal finalizado - escribe para iniciar un shell nuevo o cierra la pestaña",
|
||||
"agentManager.terminal.setupFailed": "el script de configuración falló",
|
||||
"agentManager.terminal.setupFailedCode": "el script de configuración falló con el código de salida",
|
||||
"agentManager.terminal.stopSetup": "Detener el script de configuración",
|
||||
|
||||
@@ -68,6 +68,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "تب ترمینال جدید",
|
||||
"agentManager.terminal.add": "ترمینال جدید",
|
||||
"agentManager.terminal.ended": "ترمینال پایان یافت — برای بستن، تب را ببندید",
|
||||
"agentManager.terminal.endedRestartable": "ترمینال پایان یافت - برای شروع پوسته جدید تایپ کنید یا تب را ببندید",
|
||||
"agentManager.terminal.setupFailed": "اسکریپت راهاندازی ناموفق بود",
|
||||
"agentManager.terminal.setupFailedCode": "اسکریپت راهاندازی با کد خروجی ناموفق بود",
|
||||
"agentManager.terminal.stopSetup": "توقف اسکریپت راهاندازی",
|
||||
|
||||
@@ -64,6 +64,8 @@ export const dict = {
|
||||
|
||||
"agentManager.terminal.new": "Nouvel onglet de terminal",
|
||||
"agentManager.terminal.ended": "terminal terminé — fermez l'onglet pour ignorer",
|
||||
"agentManager.terminal.endedRestartable":
|
||||
"terminal terminé - saisissez du texte pour démarrer un nouveau shell ou fermez l'onglet",
|
||||
"agentManager.terminal.setupFailed": "échec du script de configuration",
|
||||
"agentManager.terminal.setupFailedCode": "échec du script de configuration avec le code de sortie",
|
||||
"agentManager.terminal.stopSetup": "Arrêter le script de configuration",
|
||||
|
||||
@@ -68,6 +68,8 @@ export const dict = {
|
||||
|
||||
"agentManager.terminal.new": "Nuova scheda terminale",
|
||||
"agentManager.terminal.ended": "terminale terminato - chiudi la scheda per nasconderlo",
|
||||
"agentManager.terminal.endedRestartable":
|
||||
"terminale terminato - digita per avviare una nuova shell o chiudi la scheda",
|
||||
"agentManager.terminal.setupFailed": "script di configurazione non riuscito",
|
||||
"agentManager.terminal.setupFailedCode": "script di configurazione non riuscito con codice di uscita",
|
||||
"agentManager.terminal.stopSetup": "Interrompi lo script di configurazione",
|
||||
|
||||
@@ -64,6 +64,8 @@ export const dict = {
|
||||
|
||||
"agentManager.terminal.new": "新しいターミナルタブ",
|
||||
"agentManager.terminal.ended": "ターミナルが終了しました — タブを閉じて破棄",
|
||||
"agentManager.terminal.endedRestartable":
|
||||
"ターミナルが終了しました - 入力して新しいシェルを開始するか、タブを閉じてください",
|
||||
"agentManager.terminal.setupFailed": "セットアップスクリプトが失敗しました",
|
||||
"agentManager.terminal.setupFailedCode": "セットアップスクリプトが終了コードで失敗しました",
|
||||
"agentManager.terminal.stopSetup": "セットアップスクリプトを停止",
|
||||
|
||||
@@ -63,6 +63,7 @@ export const dict = {
|
||||
|
||||
"agentManager.terminal.new": "새 터미널 탭",
|
||||
"agentManager.terminal.ended": "터미널 종료됨 — 탭을 닫아 해제",
|
||||
"agentManager.terminal.endedRestartable": "터미널 종료됨 - 입력하여 새 셸을 시작하거나 탭을 닫으세요",
|
||||
"agentManager.terminal.setupFailed": "설정 스크립트 실패",
|
||||
"agentManager.terminal.setupFailedCode": "종료 코드로 설정 스크립트 실패",
|
||||
"agentManager.terminal.stopSetup": "설정 스크립트 중지",
|
||||
|
||||
@@ -67,6 +67,8 @@ export const dict = {
|
||||
|
||||
"agentManager.terminal.new": "Nieuw terminaltabblad",
|
||||
"agentManager.terminal.ended": "terminal beëindigd — sluit tabblad om te negeren",
|
||||
"agentManager.terminal.endedRestartable":
|
||||
"terminal beëindigd - typ om een nieuwe shell te starten of sluit het tabblad",
|
||||
"agentManager.terminal.setupFailed": "installatiescript mislukt",
|
||||
"agentManager.terminal.setupFailedCode": "installatiescript mislukt met exitcode",
|
||||
"agentManager.terminal.stopSetup": "Installatiescript stoppen",
|
||||
|
||||
@@ -63,6 +63,7 @@ export const dict = {
|
||||
|
||||
"agentManager.terminal.new": "Ny terminalfane",
|
||||
"agentManager.terminal.ended": "terminal avsluttet — lukk fanen for å avvise",
|
||||
"agentManager.terminal.endedRestartable": "terminal avsluttet - skriv for å starte et nytt skall, eller lukk fanen",
|
||||
"agentManager.terminal.setupFailed": "oppsettskript mislyktes",
|
||||
"agentManager.terminal.setupFailedCode": "oppsettskript mislyktes med avslutningskode",
|
||||
"agentManager.terminal.stopSetup": "Stopp oppsettskriptet",
|
||||
|
||||
@@ -64,6 +64,8 @@ export const dict = {
|
||||
|
||||
"agentManager.terminal.new": "Nowa karta terminala",
|
||||
"agentManager.terminal.ended": "terminal zakończony — zamknij kartę, aby zamknąć",
|
||||
"agentManager.terminal.endedRestartable":
|
||||
"terminal zakończony - wpisz tekst, aby uruchomić nową powłokę, lub zamknij kartę",
|
||||
"agentManager.terminal.setupFailed": "skrypt konfiguracji nie powiódł się",
|
||||
"agentManager.terminal.setupFailedCode": "skrypt konfiguracji nie powiódł się z kodem wyjścia",
|
||||
"agentManager.terminal.stopSetup": "Zatrzymaj skrypt konfiguracji",
|
||||
|
||||
@@ -64,6 +64,8 @@ export const dict = {
|
||||
|
||||
"agentManager.terminal.new": "Новая вкладка терминала",
|
||||
"agentManager.terminal.ended": "терминал завершен — закройте вкладку, чтобы скрыть",
|
||||
"agentManager.terminal.endedRestartable":
|
||||
"терминал завершен - введите текст, чтобы запустить новую оболочку, или закройте вкладку",
|
||||
"agentManager.terminal.setupFailed": "сбой скрипта настройки",
|
||||
"agentManager.terminal.setupFailedCode": "сбой скрипта настройки с кодом выхода",
|
||||
"agentManager.terminal.stopSetup": "Остановить скрипт настройки",
|
||||
|
||||
@@ -63,6 +63,7 @@ export const dict = {
|
||||
|
||||
"agentManager.terminal.new": "แท็บเทอร์มินัลใหม่",
|
||||
"agentManager.terminal.ended": "เทอร์มินัลสิ้นสุด — ปิดแท็บเพื่อยกเลิก",
|
||||
"agentManager.terminal.endedRestartable": "เทอร์มินัลสิ้นสุด - พิมพ์เพื่อเริ่มเชลล์ใหม่หรือปิดแท็บ",
|
||||
"agentManager.terminal.setupFailed": "สคริปต์ติดตั้งล้มเหลว",
|
||||
"agentManager.terminal.setupFailedCode": "สคริปต์ติดตั้งล้มเหลวด้วยรหัสออก",
|
||||
"agentManager.terminal.stopSetup": "หยุดสคริปต์ติดตั้ง",
|
||||
|
||||
@@ -68,6 +68,8 @@ export const dict = {
|
||||
|
||||
"agentManager.terminal.new": "Yeni Terminal Sekmesi",
|
||||
"agentManager.terminal.ended": "terminal sona erdi — kapatmak için sekmeyi kapatın",
|
||||
"agentManager.terminal.endedRestartable":
|
||||
"terminal sona erdi - yeni bir kabuk başlatmak için yazın veya sekmeyi kapatın",
|
||||
"agentManager.terminal.setupFailed": "kurulum betiği başarısız oldu",
|
||||
"agentManager.terminal.setupFailedCode": "kurulum betiği çıkış koduyla başarısız oldu",
|
||||
"agentManager.terminal.stopSetup": "Kurulum betiğini durdur",
|
||||
|
||||
@@ -68,6 +68,8 @@ export const dict = {
|
||||
|
||||
"agentManager.terminal.new": "Нова вкладка термінала",
|
||||
"agentManager.terminal.ended": "термінал завершено — закрийте вкладку, щоб відхилити",
|
||||
"agentManager.terminal.endedRestartable":
|
||||
"термінал завершено - введіть текст, щоб запустити нову оболонку, або закрийте вкладку",
|
||||
"agentManager.terminal.setupFailed": "помилка скрипта налаштування",
|
||||
"agentManager.terminal.setupFailedCode": "помилка скрипта налаштування з кодом виходу",
|
||||
"agentManager.terminal.stopSetup": "Зупинити скрипт налаштування",
|
||||
|
||||
@@ -63,6 +63,7 @@ export const dict = {
|
||||
|
||||
"agentManager.terminal.new": "新建终端标签页",
|
||||
"agentManager.terminal.ended": "终端已结束 — 关闭标签页以消除",
|
||||
"agentManager.terminal.endedRestartable": "终端已结束 - 输入以启动新 shell,或关闭标签页",
|
||||
"agentManager.terminal.setupFailed": "设置脚本失败",
|
||||
"agentManager.terminal.setupFailedCode": "设置脚本失败,退出代码为",
|
||||
"agentManager.terminal.stopSetup": "停止设置脚本",
|
||||
|
||||
@@ -63,6 +63,7 @@ export const dict = {
|
||||
|
||||
"agentManager.terminal.new": "新增終端分頁",
|
||||
"agentManager.terminal.ended": "終端已結束 — 關閉分頁以消除",
|
||||
"agentManager.terminal.endedRestartable": "終端已結束 - 輸入以啟動新的 shell,或關閉分頁",
|
||||
"agentManager.terminal.setupFailed": "設定腳本失敗",
|
||||
"agentManager.terminal.setupFailedCode": "設定腳本失敗,退出代碼為",
|
||||
"agentManager.terminal.stopSetup": "停止設定腳本",
|
||||
|
||||
@@ -78,9 +78,11 @@ export function createProjectStore(id: string, opts: { tabs?: string[] } = {}) {
|
||||
if ("defaultBaseBranch" in state) setDefaultBaseBranch(state.defaultBaseBranch || undefined)
|
||||
setRunScriptConfigured(state.runScriptConfigured === true)
|
||||
if (state.sessionsCollapsed !== undefined) setSessionsCollapsed(state.sessionsCollapsed)
|
||||
const runs: Record<string, RunStatus> = {}
|
||||
for (const item of state.runStatuses ?? []) runs[item.worktreeId] = item
|
||||
setRunStatuses(runs)
|
||||
if (state.runStatuses) {
|
||||
const runs: Record<string, RunStatus> = {}
|
||||
for (const item of state.runStatuses) runs[item.worktreeId] = item
|
||||
setRunStatuses(runs)
|
||||
}
|
||||
// Reconcile busy flags with the worktree list (deleted worktrees drop out).
|
||||
const ids = new Set(state.worktrees.map((wt) => wt.id))
|
||||
setBusy((prev) => {
|
||||
|
||||
@@ -3,11 +3,44 @@
|
||||
* Pure functions — no solid-dnd dependency so they remain testable.
|
||||
*/
|
||||
import type { WorktreeState, SectionState } from "../src/types/messages"
|
||||
import { applyTabOrder } from "./tab-order"
|
||||
|
||||
export type TopLevelItem = { kind: "section"; section: SectionState } | { kind: "worktree"; wt: WorktreeState }
|
||||
|
||||
export type SidebarItem = { type: "local" | "wt" | "session"; id: string }
|
||||
|
||||
/** Apply persisted order while keeping multi-version worktrees adjacent. */
|
||||
export function sortWorktrees(all: WorktreeState[], order: string[]): WorktreeState[] {
|
||||
const ordered = applyTabOrder(all, order)
|
||||
if (ordered.length === 0) return []
|
||||
|
||||
const groups = new Map<string, WorktreeState[]>()
|
||||
for (const wt of ordered) {
|
||||
if (!wt.groupId) continue
|
||||
const group = groups.get(wt.groupId) ?? []
|
||||
group.push(wt)
|
||||
groups.set(wt.groupId, group)
|
||||
}
|
||||
|
||||
const result: WorktreeState[] = []
|
||||
const placed = new Set<string>()
|
||||
for (const wt of ordered) {
|
||||
if (placed.has(wt.id)) continue
|
||||
if (!wt.groupId) {
|
||||
result.push(wt)
|
||||
placed.add(wt.id)
|
||||
continue
|
||||
}
|
||||
if (placed.has(wt.groupId)) continue
|
||||
placed.add(wt.groupId)
|
||||
for (const item of groups.get(wt.groupId) ?? []) {
|
||||
result.push(item)
|
||||
placed.add(item.id)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Build a canonical sidebar order containing section IDs and every worktree ID. */
|
||||
export function completeSidebarOrder(secs: SectionState[], all: WorktreeState[], order: string[]): string[] {
|
||||
const valid = new Set([...secs.map((sec) => sec.id), ...all.map((wt) => wt.id)])
|
||||
|
||||
@@ -38,6 +38,9 @@ interface Props {
|
||||
* an xterm re-paint when the slot transitions back to visible after
|
||||
* sitting behind an occluding layer. */
|
||||
active: boolean
|
||||
/** Side terminals only repaint on activation; focus is restored explicitly
|
||||
* when that context's remembered focus owner is the terminal. */
|
||||
focusOnActivate?: boolean
|
||||
/** Serial of the latest explicit focus request for this terminal
|
||||
* (`state.focusRequest()`), consumed so re-requesting focus on an
|
||||
* already-visible terminal still re-focuses it. */
|
||||
@@ -54,6 +57,7 @@ interface Props {
|
||||
/** Provider-owned script status (Run/Setup), used to annotate the
|
||||
* output when a script ends in failure. */
|
||||
status?: () => ScriptTerminalStatus | undefined
|
||||
restartable?: boolean
|
||||
}
|
||||
|
||||
/** How long the ResizeObserver waits after the last size change before
|
||||
@@ -219,17 +223,22 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
// strips these itself, so this only fires for real title codes.
|
||||
const disposeTitle = term.onTitleChange((title) => props.onTitleChange?.(title))
|
||||
|
||||
const ws = new WebSocket(props.wsUrl)
|
||||
ws.binaryType = "arraybuffer"
|
||||
let ws: WebSocket | undefined
|
||||
let closed = false
|
||||
let pending = ""
|
||||
let restartRequested = false
|
||||
let disconnected = false
|
||||
let readyTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let fallbackTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let streamed = false
|
||||
let socketEnded = false
|
||||
// The failure line must not depend on event ordering: the stream can
|
||||
// close before the exited snapshot lands (fast failures), or stay open
|
||||
// when a background child outlives the script. Write it exactly once,
|
||||
// from whichever signal arrives first.
|
||||
let failureWritten = false
|
||||
const noteFailure = () => {
|
||||
if (failureWritten || (!streamed && !closed)) return
|
||||
if (failureWritten || (!streamed && !socketEnded)) return
|
||||
const status = props.status?.()
|
||||
if (status?.kind !== "setup") return
|
||||
if (status.state === "failed") {
|
||||
@@ -246,32 +255,111 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
props.status?.()
|
||||
noteFailure()
|
||||
})
|
||||
const disposeData = term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(data)
|
||||
})
|
||||
ws.onmessage = (event) => {
|
||||
streamed = true
|
||||
// Text frames carry PTY output; binary frames starting with 0x00
|
||||
// are control metadata (cursor position). See pty/index.ts:46.
|
||||
if (typeof event.data === "string") {
|
||||
term.write(event.data)
|
||||
const requestRestart = () => {
|
||||
if (restartRequested) return
|
||||
restartRequested = true
|
||||
vscode.postMessage({
|
||||
type: "agentManager.terminal.restart",
|
||||
terminalId: props.terminalId,
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
})
|
||||
}
|
||||
const send = (data: string) => {
|
||||
if (disconnected && props.restartable) {
|
||||
pending += data
|
||||
if (pending.length > 256 * 1024) pending = pending.slice(-256 * 1024)
|
||||
requestRestart()
|
||||
return
|
||||
}
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
const bytes = new Uint8Array(event.data)
|
||||
if (bytes.length > 0 && bytes[0] === 0x00) return
|
||||
term.write(bytes)
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(data)
|
||||
return
|
||||
}
|
||||
}
|
||||
ws.onerror = () => {
|
||||
if (closed) return
|
||||
term.writeln(`\r\n\x1b[90m[${t("agentManager.terminal.connectionError")}]\x1b[0m`)
|
||||
const flush = () => {
|
||||
if (ws?.readyState !== WebSocket.OPEN) return
|
||||
if (readyTimer) {
|
||||
clearTimeout(readyTimer)
|
||||
readyTimer = undefined
|
||||
}
|
||||
if (fallbackTimer) {
|
||||
clearTimeout(fallbackTimer)
|
||||
fallbackTimer = undefined
|
||||
}
|
||||
const data = pending
|
||||
pending = ""
|
||||
if (data && /[^\r\n]/.test(data)) ws.send(data)
|
||||
disconnected = false
|
||||
restartRequested = false
|
||||
}
|
||||
ws.onclose = () => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
noteFailure()
|
||||
term.writeln(`\r\n\x1b[90m[${t("agentManager.terminal.ended")}]\x1b[0m`)
|
||||
const scheduleFlush = () => {
|
||||
if (!disconnected) return
|
||||
if (readyTimer) clearTimeout(readyTimer)
|
||||
readyTimer = setTimeout(() => {
|
||||
readyTimer = undefined
|
||||
flush()
|
||||
}, 100)
|
||||
}
|
||||
const open = (url: string) => {
|
||||
if (closed || !url) return
|
||||
const next = new WebSocket(url)
|
||||
next.binaryType = "arraybuffer"
|
||||
ws = next
|
||||
next.onopen = () => {
|
||||
if (closed || ws !== next) return
|
||||
socketEnded = false
|
||||
if (props.restartable && disconnected) {
|
||||
fallbackTimer = setTimeout(() => {
|
||||
fallbackTimer = undefined
|
||||
flush()
|
||||
}, 1_000)
|
||||
}
|
||||
}
|
||||
next.onmessage = (event) => {
|
||||
if (closed || ws !== next) return
|
||||
streamed = true
|
||||
if (typeof event.data === "string") {
|
||||
term.write(event.data)
|
||||
scheduleFlush()
|
||||
return
|
||||
}
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
const bytes = new Uint8Array(event.data)
|
||||
if (bytes.length > 0 && bytes[0] === 0x00) return
|
||||
term.write(bytes)
|
||||
scheduleFlush()
|
||||
}
|
||||
}
|
||||
next.onerror = () => {
|
||||
if (closed || ws !== next) return
|
||||
term.writeln(`\r\n\x1b[90m[${t("agentManager.terminal.connectionError")}]\x1b[0m`)
|
||||
}
|
||||
next.onclose = () => {
|
||||
if (closed || ws !== next) return
|
||||
ws = undefined
|
||||
if (readyTimer) {
|
||||
clearTimeout(readyTimer)
|
||||
readyTimer = undefined
|
||||
}
|
||||
if (fallbackTimer) {
|
||||
clearTimeout(fallbackTimer)
|
||||
fallbackTimer = undefined
|
||||
}
|
||||
socketEnded = true
|
||||
noteFailure()
|
||||
if (props.restartable) {
|
||||
disconnected = true
|
||||
restartRequested = false
|
||||
}
|
||||
const key = props.restartable ? "agentManager.terminal.endedRestartable" : "agentManager.terminal.ended"
|
||||
term.writeln(`\r\n\x1b[90m[${t(key)}]\x1b[0m`)
|
||||
}
|
||||
}
|
||||
const disposeData = term.onData(send)
|
||||
open(props.wsUrl)
|
||||
const restarted = (url: string) => {
|
||||
open(url)
|
||||
}
|
||||
|
||||
// Resize: fit on any host size change and forward new cols/rows to
|
||||
@@ -301,6 +389,8 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
return
|
||||
}
|
||||
clearTimeout(resizeTimer)
|
||||
if (readyTimer) clearTimeout(readyTimer)
|
||||
if (fallbackTimer) clearTimeout(fallbackTimer)
|
||||
resizeTimer = setTimeout(syncSize, RESIZE_DEBOUNCE_MS)
|
||||
})
|
||||
ro.observe(host)
|
||||
@@ -357,6 +447,16 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === "agentManager.terminal.restarted") {
|
||||
if (message.terminalId === props.terminalId) restarted(message.wsUrl)
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === "agentManager.terminal.error" && message.terminalId === props.terminalId) {
|
||||
restartRequested = false
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === "agentManager.terminal.fontChanged") {
|
||||
term.options.fontFamily = message.font.fontFamily
|
||||
term.options.fontSize = message.font.fontSize
|
||||
@@ -382,7 +482,8 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
createEffect(() => {
|
||||
const now = props.active
|
||||
const serial = props.focusSerial ?? 0
|
||||
if (now && (!wasActive || serial !== focusSerial)) scheduleRepaint(true)
|
||||
if (now && (!wasActive || serial !== focusSerial))
|
||||
scheduleRepaint((serial > 0 && serial !== focusSerial) || props.focusOnActivate !== false)
|
||||
if (!now && wasActive) term.blur()
|
||||
wasActive = now
|
||||
focusSerial = serial
|
||||
@@ -422,6 +523,7 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
themeObserver.observe(document.body, { attributes: true, attributeFilter: ["class"] })
|
||||
|
||||
onCleanup(() => {
|
||||
closed = true
|
||||
if (pendingFrame !== null) cancelAnimationFrame(pendingFrame)
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange)
|
||||
window.removeEventListener("focus", onWindowFocus)
|
||||
@@ -434,7 +536,7 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
disposeData.dispose()
|
||||
disposeTitle.dispose()
|
||||
try {
|
||||
ws.close()
|
||||
ws?.close()
|
||||
} catch (err) {
|
||||
// Already closed (ws.close on a closed socket is a no-op in
|
||||
// most browsers; the throw is defensive). Logged so unexpected
|
||||
|
||||
@@ -108,6 +108,7 @@ export function renderTerminalLayer(props: { state: TerminalStateControls }): JS
|
||||
<TerminalTab
|
||||
terminalId={term.id}
|
||||
wsUrl={term.wsUrl}
|
||||
restartable={term.kind === undefined}
|
||||
active={visible()}
|
||||
focusSerial={focusSerial(props.state, term.id)}
|
||||
font={term.font}
|
||||
@@ -152,9 +153,11 @@ export function renderSideTerminalLayer(props: {
|
||||
terminalId={term.id}
|
||||
wsUrl={term.wsUrl}
|
||||
active={active()}
|
||||
focusOnActivate={false}
|
||||
focusSerial={focusSerial(props.state, term.id)}
|
||||
font={term.font}
|
||||
status={() => props.state.scriptStatus(term.id)}
|
||||
restartable={term.kind === undefined}
|
||||
onFocusChange={(focused) => props.state.setFocusedId(focused ? term.id : undefined)}
|
||||
onTitleChange={(title) => props.state.setTitle(term.id, title)}
|
||||
/>
|
||||
|
||||
@@ -64,6 +64,7 @@ export interface TerminalFocusRequest {
|
||||
* Multiple creates can be in flight for the same context at once. */
|
||||
interface SideRequest {
|
||||
contextKey: string
|
||||
focus: boolean
|
||||
}
|
||||
|
||||
export interface TerminalStateControls {
|
||||
@@ -144,7 +145,7 @@ export interface TerminalStateControls {
|
||||
/** Request ids of the in-flight side-terminal creates for a context. */
|
||||
pendingSide(contextKey: string): boolean
|
||||
/** Mark a side-terminal create as in flight for a context. */
|
||||
beginSide(contextKey: string, createId: string): void
|
||||
beginSide(contextKey: string, createId: string, focus?: boolean): void
|
||||
/** Settle a create request; returns it so the caller can validate. */
|
||||
completeSide(createId: string): SideRequest | undefined
|
||||
}
|
||||
@@ -527,8 +528,8 @@ export function createTerminalState(selection: Accessor<string | null>): Termina
|
||||
|
||||
const pendingSide = (key: string) => (pending()[key]?.length ?? 0) > 0
|
||||
|
||||
const beginSide = (key: string, createId: string) => {
|
||||
requests.set(createId, { contextKey: key })
|
||||
const beginSide = (key: string, createId: string, focus = false) => {
|
||||
requests.set(createId, { contextKey: key, focus })
|
||||
setPending((prev) => ({ ...prev, [key]: [...(prev[key] ?? []), createId] }))
|
||||
}
|
||||
|
||||
@@ -637,13 +638,13 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
|
||||
})
|
||||
}
|
||||
|
||||
const createSide = () => {
|
||||
const createSide = (focus = false) => {
|
||||
const key = deps.state.sideKey()
|
||||
// The wire protocol speaks plain worktree ids; `sideKey` is the
|
||||
// project-namespaced state key and must not leak into the message.
|
||||
const sel = deps.getSelection()
|
||||
const id = newId()
|
||||
deps.state.beginSide(key, id)
|
||||
deps.state.beginSide(key, id, focus)
|
||||
deps.postMessage({
|
||||
type: "agentManager.terminal.create",
|
||||
createId: id,
|
||||
@@ -659,7 +660,7 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
|
||||
*/
|
||||
const addSide = () => {
|
||||
deps.onShowSide(deps.state.sideKey())
|
||||
createSide()
|
||||
createSide(true)
|
||||
}
|
||||
|
||||
/** Ensure the current context has a terminal without changing panel mode. */
|
||||
@@ -684,7 +685,8 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
|
||||
deps.state.requestFocus(active)
|
||||
return
|
||||
}
|
||||
ensureSide()
|
||||
if (deps.state.pendingSide(key)) return
|
||||
createSide(true)
|
||||
}
|
||||
|
||||
const closeTerminal = (terminalId: string) => {
|
||||
@@ -826,11 +828,11 @@ export interface TerminalMessageHandlerDeps {
|
||||
*/
|
||||
onCreated?: (contextKey: string, terminalId: string) => void
|
||||
/** Side terminal for a context finished creating. */
|
||||
onSideCreated?: (contextKey: string, terminalId: string) => void
|
||||
onSideCreated?: (contextKey: string, terminalId: string, focus: boolean) => void
|
||||
/** Side terminal create failed for a context. */
|
||||
onSideError?: (contextKey: string) => void
|
||||
/** Side terminal was closed (locally or by the extension). */
|
||||
onSideClosed?: (contextKey: string) => void
|
||||
onSideClosed?: (contextKey: string, terminalId: string) => void
|
||||
/** A newly hydrated running script terminal belongs to the selected context. */
|
||||
onScriptRunning?: (contextKey: string, terminalId: string) => void
|
||||
/** The destination setting changed (live settings sync). */
|
||||
@@ -865,7 +867,7 @@ function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) {
|
||||
deps.state.add(key === LOCAL ? null : key, term)
|
||||
// The newest terminal becomes the visible one in its panel.
|
||||
deps.state.setSideActive(key, msg.terminalId)
|
||||
deps.onSideCreated?.(key, msg.terminalId)
|
||||
deps.onSideCreated?.(key, msg.terminalId, request.focus)
|
||||
return
|
||||
}
|
||||
deps.state.add(key === LOCAL ? null : key, term)
|
||||
@@ -901,7 +903,7 @@ export function createTerminalMessageHandler(deps: TerminalMessageHandlerDeps) {
|
||||
if (msg.type === "agentManager.terminal.closed") {
|
||||
const removed = deps.state.remove(msg.terminalId)
|
||||
if (deps.state.activeId() === msg.terminalId) deps.state.setActiveId(undefined)
|
||||
if (removed?.placement === "side") deps.onSideClosed?.(removed.contextKey)
|
||||
if (removed?.placement === "side") deps.onSideClosed?.(removed.contextKey, msg.terminalId)
|
||||
return true
|
||||
}
|
||||
if (msg.type === "agentManager.terminal.error") {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { render as renderSolid } from "solid-js/web"
|
||||
import { SpeechToTextButton } from "../src/components/speech-to-text/SpeechToTextButton"
|
||||
import { insertSpacedText } from "../src/components/chat/prompt-input-utils"
|
||||
import type { SpeechState, SpeechToText } from "../src/components/speech-to-text/useSpeechToText"
|
||||
import { createSpeechShortcut } from "../src/components/speech-to-text/shortcut"
|
||||
import { reviewAnnotationSpeechKey, type AnnotationMeta } from "./review-annotations"
|
||||
|
||||
type Props = {
|
||||
@@ -13,10 +14,14 @@ type Props = {
|
||||
keys: Accessor<Set<string>>
|
||||
}
|
||||
|
||||
type Event = Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey" | "repeat" | "timeStamp">
|
||||
|
||||
type Node = {
|
||||
host: HTMLDivElement
|
||||
dispose: VoidFunction
|
||||
setTextarea: (textarea: HTMLTextAreaElement) => void
|
||||
down: (event: Event, submit: () => void) => boolean
|
||||
up: (event: Pick<KeyboardEvent, "key" | "timeStamp">) => boolean
|
||||
}
|
||||
|
||||
function insertReviewSpeechText(textarea: HTMLTextAreaElement, value: string): void {
|
||||
@@ -55,9 +60,9 @@ export function createReviewAnnotationSpeechRenderer(props: Props) {
|
||||
error: () => (mine() ? props.speech.error() : undefined),
|
||||
active: () => mine() && props.speech.active(),
|
||||
start: (opts) => start(opts.model),
|
||||
stop: () => {
|
||||
stop: (opts) => {
|
||||
if (!mine()) return
|
||||
props.speech.stop()
|
||||
props.speech.stop(opts)
|
||||
},
|
||||
cancel: () => {
|
||||
if (!mine()) return
|
||||
@@ -71,6 +76,13 @@ export function createReviewAnnotationSpeechRenderer(props: Props) {
|
||||
},
|
||||
}
|
||||
const blocked = () => props.speech.active() && !mine()
|
||||
let submit = () => {}
|
||||
const shortcut = createSpeechShortcut({
|
||||
speech,
|
||||
disabled: () => !props.enabled() || blocked(),
|
||||
start: () => start(props.model()),
|
||||
finish: (send) => speech.stop(send ? { done: submit } : undefined),
|
||||
})
|
||||
|
||||
const dispose = createRoot((root) => {
|
||||
const view = renderSolid(
|
||||
@@ -92,10 +104,18 @@ export function createReviewAnnotationSpeechRenderer(props: Props) {
|
||||
|
||||
const node = {
|
||||
host,
|
||||
dispose,
|
||||
dispose: () => {
|
||||
shortcut.reset()
|
||||
dispose()
|
||||
},
|
||||
setTextarea: (next: HTMLTextAreaElement) => {
|
||||
field = next
|
||||
},
|
||||
down: (event: Event, next: () => void) => {
|
||||
submit = next
|
||||
return shortcut.down(event)
|
||||
},
|
||||
up: shortcut.up,
|
||||
}
|
||||
nodes.set(key, node)
|
||||
return node
|
||||
@@ -134,6 +154,16 @@ export function createReviewAnnotationSpeechRenderer(props: Props) {
|
||||
|
||||
return {
|
||||
active: props.speech.active,
|
||||
down: (meta: AnnotationMeta, event: Event, submit: () => void) => {
|
||||
const key = reviewAnnotationSpeechKey(meta)
|
||||
if (!key) return false
|
||||
return nodes.get(key)?.down(event, submit) ?? false
|
||||
},
|
||||
up: (meta: AnnotationMeta, event: Pick<KeyboardEvent, "key" | "timeStamp">) => {
|
||||
const key = reviewAnnotationSpeechKey(meta)
|
||||
if (!key) return false
|
||||
return nodes.get(key)?.up(event) ?? false
|
||||
},
|
||||
render,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,8 @@ interface AnnotationHandlers {
|
||||
speech?: {
|
||||
active: () => boolean
|
||||
render: (meta: AnnotationMeta, textarea: HTMLTextAreaElement) => HTMLElement | undefined
|
||||
down: (meta: AnnotationMeta, event: KeyboardEvent, submit: () => void) => boolean
|
||||
up: (meta: AnnotationMeta, event: KeyboardEvent) => boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,6 +311,11 @@ export function buildReviewAnnotation(
|
||||
})
|
||||
|
||||
textarea.addEventListener("keydown", (event) => {
|
||||
if (handlers.speech?.down(meta, event, send)) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
handlers.cancelDraft()
|
||||
@@ -319,6 +326,11 @@ export function buildReviewAnnotation(
|
||||
submit()
|
||||
}
|
||||
})
|
||||
textarea.addEventListener("keyup", (event) => {
|
||||
if (!handlers.speech?.up(meta, event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
})
|
||||
textarea.addEventListener("input", update)
|
||||
|
||||
return wrapper
|
||||
@@ -363,15 +375,24 @@ export function buildReviewAnnotation(
|
||||
handlers.setEditing(null)
|
||||
})
|
||||
|
||||
saveButton.addEventListener("click", (event) => {
|
||||
event.stopPropagation()
|
||||
const save = () => {
|
||||
if (handlers.speech?.active()) return
|
||||
const text = textarea.value.trim()
|
||||
if (!text) return
|
||||
handlers.updateComment(comment.id, text)
|
||||
}
|
||||
|
||||
saveButton.addEventListener("click", (event) => {
|
||||
event.stopPropagation()
|
||||
save()
|
||||
})
|
||||
|
||||
textarea.addEventListener("keydown", (event) => {
|
||||
if (handlers.speech?.down(meta, event, save)) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
handlers.setEditing(null)
|
||||
@@ -379,12 +400,14 @@ export function buildReviewAnnotation(
|
||||
}
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
if (handlers.speech?.active()) return
|
||||
const text = textarea.value.trim()
|
||||
if (!text) return
|
||||
handlers.updateComment(comment.id, text)
|
||||
save()
|
||||
}
|
||||
})
|
||||
textarea.addEventListener("keyup", (event) => {
|
||||
if (!handlers.speech?.up(meta, event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
})
|
||||
|
||||
return wrapper
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ declare module "*.svg" {
|
||||
export default src
|
||||
}
|
||||
|
||||
declare module "*.css"
|
||||
declare module "@kilocode/kilo-ui/styles"
|
||||
|
||||
declare module "*?worker&url" {
|
||||
const src: string
|
||||
export default src
|
||||
|
||||
@@ -39,7 +39,10 @@ interface ChatViewProps {
|
||||
/** When true, show the "Continue in Worktree" button. Defaults to true in the sidebar. */
|
||||
continueInWorktree?: boolean
|
||||
promptBoxId?: string
|
||||
deferFocusToQuestion?: () => boolean
|
||||
pendingSessionID?: string
|
||||
focusOnDraftChange?: () => boolean
|
||||
onFocusChange?: (focused: boolean) => void
|
||||
emptyState?: () => JSX.Element
|
||||
}
|
||||
|
||||
@@ -384,7 +387,10 @@ export const ChatView: Component<ChatViewProps> = (props) => {
|
||||
suggesting={suggesting}
|
||||
questioning={questioning}
|
||||
boxId={props.promptBoxId}
|
||||
deferFocusToQuestion={props.deferFocusToQuestion}
|
||||
pendingSessionID={pendingSessionID()}
|
||||
focusOnDraftChange={props.focusOnDraftChange}
|
||||
onFocusChange={props.onFocusChange}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,7 @@ import { hasGitChangesMention } from "../../hooks/git-changes-context-utils"
|
||||
import { useSlashCommand } from "../../hooks/useSlashCommand"
|
||||
import { useGhostText } from "../../hooks/useGhostText"
|
||||
import { useSpeechToText } from "../speech-to-text/useSpeechToText"
|
||||
import { createSpeechShortcut } from "../speech-to-text/shortcut"
|
||||
import { useImageAttachments, type ImageAttachment } from "../../hooks/useImageAttachments"
|
||||
import { convertToMentionPath } from "../../utils/path-mentions"
|
||||
import { SessionMentionPicker } from "./SessionMentionPicker"
|
||||
@@ -108,8 +109,14 @@ interface PromptInputProps {
|
||||
suggesting?: () => boolean
|
||||
/** When true, session is busy only because a question is pending — treat as idle for input */
|
||||
questioning?: () => boolean
|
||||
/** When true, defer prompt focus while switching to a pending question */
|
||||
deferFocusToQuestion?: () => boolean
|
||||
boxId?: string
|
||||
pendingSessionID?: string
|
||||
/** Agent Manager can suppress automatic prompt focus when this session last
|
||||
* used its side terminal instead. Other callers retain the old behavior. */
|
||||
focusOnDraftChange?: () => boolean
|
||||
onFocusChange?: (focused: boolean) => void
|
||||
}
|
||||
|
||||
function MentionItemContent(props: { item: MentionResult }) {
|
||||
@@ -381,7 +388,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
textareaRef.scrollTop = scroll
|
||||
if (highlightRef) highlightRef.scrollTop = scroll
|
||||
}
|
||||
window.dispatchEvent(new Event("focusPrompt"))
|
||||
if (!props.deferFocusToQuestion?.() && (props.focusOnDraftChange?.() ?? true)) {
|
||||
window.dispatchEvent(new Event("focusPrompt"))
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -405,7 +414,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
// Focus textarea when any part of the app requests it
|
||||
const onFocusPrompt = (event: Event) => {
|
||||
const defer = () =>
|
||||
event instanceof CustomEvent && event.detail?.deferFocusToQuestion && props.deferFocusToQuestion?.()
|
||||
const focus = () => {
|
||||
if (defer()) return
|
||||
const ref = textareaRef
|
||||
if (!ref) return
|
||||
ref.focus({ preventScroll: true })
|
||||
@@ -413,6 +425,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
focus()
|
||||
if (!(event instanceof CustomEvent) || !event.detail?.restore) return
|
||||
const restore = () => {
|
||||
if (defer()) return
|
||||
window.focus()
|
||||
focus()
|
||||
}
|
||||
@@ -1024,6 +1037,32 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
})
|
||||
}
|
||||
|
||||
const shortcut = createSpeechShortcut({
|
||||
speech,
|
||||
disabled: () => !canUseSpeech() || isDisabled(),
|
||||
start: startSpeech,
|
||||
finish: (submit) => {
|
||||
if (submit) {
|
||||
transcribeAndSend()
|
||||
return
|
||||
}
|
||||
speech.stop()
|
||||
},
|
||||
})
|
||||
const speechDown = (e: KeyboardEvent): boolean => {
|
||||
if (!shortcut.down(e)) return false
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return true
|
||||
}
|
||||
const speechUp = (e: KeyboardEvent): boolean => {
|
||||
if (!shortcut.up(e)) return false
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return true
|
||||
}
|
||||
onCleanup(shortcut.reset)
|
||||
|
||||
const handleSendClick = () => {
|
||||
if (speech.state() !== "recording" || !canSend()) {
|
||||
void handleSend()
|
||||
@@ -1402,12 +1441,24 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
placeholder={placeholder()}
|
||||
value={text()}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={syncGhost}
|
||||
onKeyDown={(e) => {
|
||||
if (speechDown(e)) return
|
||||
handleKeyDown(e)
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (speechUp(e)) return
|
||||
syncGhost()
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
onClick={syncGhost}
|
||||
onFocus={syncGhost}
|
||||
onBlur={syncGhost}
|
||||
onFocus={() => {
|
||||
syncGhost()
|
||||
props.onFocusChange?.(true)
|
||||
}}
|
||||
onBlur={() => {
|
||||
syncGhost()
|
||||
props.onFocusChange?.(false)
|
||||
}}
|
||||
onSelect={() => {
|
||||
syncGhost()
|
||||
if (textareaRef) mention.snapSelection(textareaRef)
|
||||
|
||||
+71
-1
@@ -4,10 +4,16 @@ import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
|
||||
import { useConfig } from "../../../context/config"
|
||||
import { useLanguage } from "../../../context/language"
|
||||
import { useProvider } from "../../../context/provider"
|
||||
import { ModelSelectorBase } from "../../shared/ModelSelector"
|
||||
import { ThinkingSelectorBase } from "../../shared/ThinkingSelector"
|
||||
import { parseModelString } from "../../../../../src/shared/provider-model"
|
||||
import type { CommandConfig } from "../../../types/messages"
|
||||
|
||||
const WorkflowsTab: Component = () => {
|
||||
const language = useLanguage()
|
||||
const { config } = useConfig()
|
||||
const { config, globalConfig, globalDraft, updateGlobalConfig } = useConfig()
|
||||
const provider = useProvider()
|
||||
|
||||
const cmds = createMemo(() => Object.entries(config().command ?? {}))
|
||||
const [expanded, setExpanded] = createSignal<Record<string, boolean>>({})
|
||||
@@ -16,6 +22,38 @@ const WorkflowsTab: Component = () => {
|
||||
setExpanded((prev) => ({ ...prev, [name]: !prev[name] }))
|
||||
}
|
||||
|
||||
const update = (name: string, patch: Partial<CommandConfig>) => {
|
||||
updateGlobalConfig({ command: { [name]: patch } })
|
||||
}
|
||||
|
||||
const scoped = (cmd: CommandConfig, name: string) => ({
|
||||
...cmd,
|
||||
...globalConfig().command?.[name],
|
||||
...globalDraft().command?.[name],
|
||||
})
|
||||
|
||||
const model = (cmd: CommandConfig, name: string) => {
|
||||
const value = scoped(cmd, name).model
|
||||
return value === null ? null : parseModelString(value ?? undefined)
|
||||
}
|
||||
|
||||
const variant = (cmd: CommandConfig, name: string) => {
|
||||
const value = scoped(cmd, name).variant
|
||||
return value === null ? undefined : (value ?? undefined)
|
||||
}
|
||||
|
||||
const variants = (cmd: CommandConfig, name: string) =>
|
||||
Object.keys(provider.findModel(model(cmd, name))?.variants ?? {})
|
||||
|
||||
const selectModel = (name: string, providerID: string, modelID: string) => {
|
||||
const list = Object.keys(provider.findModel({ providerID, modelID })?.variants ?? {})
|
||||
const current = variant(config().command?.[name] ?? {}, name)
|
||||
update(name, {
|
||||
model: providerID && modelID ? `${providerID}/${modelID}` : null,
|
||||
...(current && !list.includes(current) ? { variant: null } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Description */}
|
||||
@@ -118,6 +156,38 @@ const WorkflowsTab: Component = () => {
|
||||
{cmd.description}
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"flex-wrap": "wrap",
|
||||
gap: "8px",
|
||||
"margin-bottom": "8px",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ModelSelectorBase
|
||||
value={model(cmd, name)}
|
||||
onSelect={(providerID, modelID) => selectModel(name, providerID, modelID)}
|
||||
placement="bottom-start"
|
||||
allowClear
|
||||
clearLabel={language.t("settings.providers.notSet")}
|
||||
label={`${name} ${language.t("settings.agentBehaviour.workflows.model")}`}
|
||||
description={language.t("settings.agentBehaviour.workflows.modelDescription")}
|
||||
/>
|
||||
<Show when={variants(cmd, name).length > 0 || !!variant(cmd, name)}>
|
||||
<ThinkingSelectorBase
|
||||
variants={variants(cmd, name)}
|
||||
value={variant(cmd, name)}
|
||||
onSelect={(variant) => update(name, { variant })}
|
||||
onClear={() => update(name, { variant: null })}
|
||||
allowClear
|
||||
clearLabel={language.t("settings.providers.notSet")}
|
||||
placement="bottom-start"
|
||||
globalTrigger={false}
|
||||
label={`${name} ${language.t("settings.agentBehaviour.workflows.variant")}`}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={cmd.template}>
|
||||
<div>
|
||||
<span style={{ "font-weight": "500" }}>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user