Merge remote-tracking branch 'origin/johnnyeric/kilo-opencode-v1.18.0' into johnnyeric/kilo-opencode-v1.18.13

# Conflicts:
#	packages/opencode/src/provider/transform.ts
This commit is contained in:
Johnny Eric Amancio
2026-08-10 21:48:08 +02:00
141 changed files with 3913 additions and 893 deletions
@@ -0,0 +1,6 @@
---
"kilo-code": patch
"@kilocode/kilo-ui": patch
---
Improve custom provider edit dialog layout, make advanced configuration action prominent, and add bulk toggle buttons for reasoning and image modalities across all models.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Exclude ChatGPT subscriptions from explicit prompt cache breakpoints.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Prevent project MCP configs from resolving variable-backed headers or inheriting trusted headers when changing endpoints, while preserving unaffected servers.
@@ -0,0 +1,7 @@
---
"@kilocode/cli": patch
"@kilocode/kilo-memory": patch
"kilo-code": patch
---
Reduce noisy memory timeout warnings and retry transient background consolidation failures once.
@@ -0,0 +1,8 @@
---
"@kilocode/kilo-gateway": patch
"@kilocode/cli": patch
"kilo-code": patch
"@kilocode/kilo-jetbrains": patch
---
Ignore negative pricing entries from model catalogs and handle unpriced models gracefully in UI price formatting.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix Mermaid Copy PNG/SVG in VS Code webviews so they put images on the clipboard instead of failing or copying SVG markup.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep model and provider errors visible in VS Code when chat history refreshes.
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f2349f8c0d7049811702e5b8cbeb5fc2f402bdda8630d1a5210a970a13284197
size 3694
oid sha256:33ee6eb7070237487d1931f32ac77a2549e18e664b993895e9a9d6619527d0aa
size 4063
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f2349f8c0d7049811702e5b8cbeb5fc2f402bdda8630d1a5210a970a13284197
size 3694
oid sha256:33ee6eb7070237487d1931f32ac77a2549e18e664b993895e9a9d6619527d0aa
size 4063
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:72a0a206e978250d41b42f3c0585691a78cbe2635c12d320bcefe61cd938ced5
size 280
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:72a0a206e978250d41b42f3c0585691a78cbe2635c12d320bcefe61cd938ced5
size 280
+1 -1
View File
@@ -76,7 +76,7 @@ type OpenRouterModel = z.infer<typeof openRouterModelSchema>
function parseApiPrice(price: string | null | undefined): number | undefined {
if (!price) return undefined
const parsed = parseFloat(price)
if (isNaN(parsed)) return undefined
if (isNaN(parsed) || parsed < 0) return undefined
return parsed * 1_000_000 // Convert $/token → $/M tokens
}
@@ -383,3 +383,66 @@ test("keeps organization catalog errors from silently falling back to personal m
expect(calls[0]).toContain("/api/gateway/transcription-models")
expect(headers[0]?.get("X-KILOCODE-ORGANIZATIONID")).toBe("org-1")
})
test("omits cost when pricing contains negative values (dynamic/auto-routed pricing)", async () => {
const orig = globalThis.fetch
stubFetch(
async () =>
new Response(
JSON.stringify({
data: [
{
id: "openrouter/auto",
name: "Auto Router",
context_length: 128000,
max_completion_tokens: 16384,
architecture: {
input_modalities: ["text"],
output_modalities: ["text"],
},
supported_parameters: ["tools"],
pricing: {
prompt: "-1",
completion: "-1",
},
},
{
id: "test/fixed-price",
name: "Fixed Price Model",
context_length: 128000,
max_completion_tokens: 16384,
architecture: {
input_modalities: ["text"],
output_modalities: ["text"],
},
supported_parameters: ["tools"],
pricing: {
prompt: "0.000003",
completion: "0.000015",
input_cache_read: "0.0000003",
input_cache_write: "-1",
},
},
],
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
),
)
const result = await fetchKiloModels({})
;(globalThis as any).fetch = orig
expect(result.error).toBeUndefined()
expect(result.models["openrouter/auto"]).toBeDefined()
expect(result.models["openrouter/auto"].cost).toBeUndefined()
expect(result.models["test/fixed-price"].cost).toEqual({
input: 3,
output: 15,
cache_read: 0.3,
})
})
@@ -157,14 +157,14 @@ internal class ModelDetailsPanel(
return buildList {
item.releaseDate?.let { add(KiloBundle.message("model.picker.details.released") to date(it)) }
if (!item.free) {
item.cost?.let { cost ->
item.cost?.takeIf { it.input > 0.0 || it.output > 0.0 }?.let { cost ->
add(KiloBundle.message("model.picker.details.input") to price(cost.input))
add(KiloBundle.message("model.picker.details.output") to price(cost.output))
add(KiloBundle.message("model.picker.details.cached") to cached(cost.input, cost.cache?.read))
add(KiloBundle.message("model.picker.details.average") to price(average(cost.input, cost.output, cost.cache?.read)))
} ?: run {
item.inputPrice?.let { add(KiloBundle.message("model.picker.details.input") to price(it)) }
item.outputPrice?.let { add(KiloBundle.message("model.picker.details.output") to price(it)) }
item.inputPrice?.takeIf { it > 0.0 }?.let { add(KiloBundle.message("model.picker.details.input") to price(it)) }
item.outputPrice?.takeIf { it > 0.0 }?.let { add(KiloBundle.message("model.picker.details.output") to price(it)) }
}
}
ctx?.let { add(KiloBundle.message("model.picker.details.context") to context(it)) }
@@ -126,7 +126,7 @@ export namespace MemoryOperations {
const adds = input.ops
.filter((item): item is Add => item.action === "add")
// Redact rejected text too: this filter runs before the secret one, so a rejected op never
// reaches it, and skips flow into the persistent decisions audit (/memory/show, TUI).
// reaches it, and skips remain visible in the operation result.
.filter((op) => {
const item = reject(op)
if (!item) return true
@@ -314,7 +314,7 @@ export namespace MemoryOperations {
}
}
async function persist(input: { root: string; state: MemorySchema.State; count: number; removed: number }) {
async function persist(input: { root: string; state: MemorySchema.State; count: number }) {
const index = await MemoryIndexer.rebuild({ root: input.root, state: input.state })
await MemoryFiles.writeState(input.root, {
...input.state,
@@ -323,7 +323,6 @@ export namespace MemoryOperations {
lastOperationCount: input.count,
},
})
await MemoryFiles.append(input.root, `apply ops=${input.count} removed=${input.removed}`)
return index
}
@@ -365,9 +364,9 @@ export namespace MemoryOperations {
const prepared = prepare({ state, ops: input.ops, max: state.limits.maxLineChars })
const removes = input.ops.filter((item): item is Remove => item.action === "remove")
const plan = planOps({ docs, inventory, removes, adds: prepared.adds, now: Date.now() })
// Commit (IO): write changed documents, then rebuild the index, persist state, and audit.
// Commit (IO): write changed documents, then rebuild the index and persist state.
await writeDocs({ root: input.root, plan })
const index = await persist({ root: input.root, state, count: plan.count, removed: plan.removed })
const index = await persist({ root: input.root, state, count: plan.count })
return {
operationCount: plan.count,
added: plan.added,
+69 -4
View File
@@ -1,9 +1,12 @@
import { MemoryOperations } from "./operations"
import { MemoryRedact } from "./redact"
import { MemoryShared } from "../recall/shared"
import { TRANSIENT } from "../schema"
import type { MemoryFiles } from "../storage/store"
import type { CaptureSkip } from "./parse"
export { TRANSIENT }
export type CaptureSourceItem = {
id: string
text: string
@@ -69,14 +72,75 @@ export function errorReason(err: unknown) {
return MemoryShared.brief(MemoryRedact.text(parts.join(" ")), 500)
}
export function guardReason(input: string) {
const value = input.toLowerCase()
if (/\b(429|rate[_ -]?limit|too many requests)\b/.test(value)) return "rate_limit_guard"
if (/\b(insufficient[_ -]?quota|quota exceeded|exceeded your quota|billing|credits?|credit balance)\b/.test(value))
const RATE_TEXT = /\b(429|rate[_ -]?limit|too many requests)\b/
const QUOTA_TEXT = /\b(insufficient[_ -]?quota|quota exceeded|exceeded your quota|billing|credits?|credit balance)\b/
const TIMEOUT_TEXT =
/\b(timeouterror|etimedout|deadline[ _-]?exceeded|timed out|(connect|headers|body|gateway)[ _-]?time[ -]?out)\b|^timeout$/
const TIMEOUT_CODES = new Set(["ETIMEDOUT", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"])
/** Structural timeout detection over an error's name/status/code, its message text, and its nested
* cause/errors chains (cycle-safe). Message matching is limited to `message` — never the serialized
* body/data blobs — so response payloads that merely mention "timeout" cannot classify as transient. */
function timedOut(item: unknown, seen = new WeakSet<object>()): boolean {
if (!item || typeof item !== "object" || seen.has(item)) return false
seen.add(item)
const err = item as {
name?: unknown
code?: unknown
status?: unknown
statusCode?: unknown
message?: unknown
cause?: unknown
errors?: unknown
}
if (err.name === "TimeoutError") return true
if (err.status === 504 || err.statusCode === 504) return true
if (typeof err.code === "string" && TIMEOUT_CODES.has(err.code)) return true
if (typeof err.message === "string" && TIMEOUT_TEXT.test(err.message.toLowerCase())) return true
if (timedOut(err.cause, seen)) return true
return Array.isArray(err.errors) && err.errors.some((entry) => timedOut(entry, seen))
}
function rateGuarded(item: unknown, seen = new WeakSet<object>()): boolean {
if (!item || typeof item !== "object" || seen.has(item)) return false
seen.add(item)
const err = item as {
status?: unknown
statusCode?: unknown
message?: unknown
cause?: unknown
errors?: unknown
}
if (err.status === 429 || err.statusCode === 429) return true
if (typeof err.message === "string" && RATE_TEXT.test(err.message.toLowerCase())) return true
if (rateGuarded(err.cause, seen)) return true
return Array.isArray(err.errors) && err.errors.some((entry) => rateGuarded(entry, seen))
}
function quotaGuarded(item: unknown, seen = new WeakSet<object>()): boolean {
if (!item || typeof item !== "object" || seen.has(item)) return false
seen.add(item)
const err = item as {
message?: unknown
cause?: unknown
errors?: unknown
}
if (typeof err.message === "string" && QUOTA_TEXT.test(err.message.toLowerCase())) return true
if (quotaGuarded(err.cause, seen)) return true
return Array.isArray(err.errors) && err.errors.some((entry) => quotaGuarded(entry, seen))
}
export function guardReason(input: unknown) {
const value = (typeof input === "string" ? input : errorReason(input)).toLowerCase()
if (typeof input === "string" ? RATE_TEXT.test(value) : rateGuarded(input) || RATE_TEXT.test(value))
return "rate_limit_guard"
if (typeof input === "string" ? QUOTA_TEXT.test(value) : quotaGuarded(input) || QUOTA_TEXT.test(value))
return "quota_guard"
if (typeof input === "string" ? TIMEOUT_TEXT.test(value) : timedOut(input)) return TRANSIENT
return undefined
}
/** @deprecated Memory audit persistence was removed. */
export function skipped(input: { sessionID: string; reason: string }): MemoryFiles.Decision {
return {
kind: "typed",
@@ -94,6 +158,7 @@ export function skipped(input: { sessionID: string; reason: string }): MemoryFil
}
}
/** @deprecated Memory audit persistence was removed. */
export function auditOps(ops: MemoryOperations.Op[]) {
return MemoryShared.audit(ops)
}
+18 -104
View File
@@ -1,6 +1,5 @@
import { Cause, Effect } from "effect"
import {
auditOps,
cap,
capturePlan,
digestPrompt,
@@ -18,9 +17,9 @@ import {
parseJson,
parseOps,
salvageTyped,
skipped,
summarize,
summarizeDiffs,
TRANSIENT,
typedPrompt,
usage,
verifySkips,
@@ -105,11 +104,16 @@ export namespace MemoryCapture {
yield* memory.prepare({ root })
const state = yield* memory.state({ root })
const reported = new Set<string>()
const fail = (reason: string) =>
const fail = (reason: string, detail?: string) =>
Effect.promise(async () => {
const safe = MemoryRedact.text(reason)
if (reported.has(safe)) return
reported.add(safe)
if (reason === TRANSIENT)
MemoryLog.warn("memory capture transient failure", {
reason: safe,
detail: MemoryShared.brief(MemoryRedact.text(detail ?? safe), 160),
})
await MemoryEvents.publish({
event: "error",
payload: MemoryEvents.status({
@@ -123,7 +127,6 @@ export namespace MemoryCapture {
})
const skip = (reason: string, opts?: { idleFlush?: boolean }) =>
Effect.gen(function* () {
if (state.enabled) yield* memory.decide({ root, decision: skipped({ sessionID: input.sessionID, reason }) })
yield* Effect.promise(() =>
MemoryEvents.publish({
event: "status",
@@ -203,23 +206,6 @@ export namespace MemoryCapture {
tokens: 0,
fallback: true,
})
yield* memory.decide({
root,
decision: {
kind: "digest",
trigger: "turn-close",
sessionID: input.sessionID,
result: "fallback",
llm: false,
parsed: false,
fallback: true,
reason: input.reason,
tokens: 0,
operationCount: 1,
skippedCount: 0,
summary: `session digest fallback on ${input.reason ?? "close"}`,
},
})
}
return yield* skip(plan.skipReason, plan.idleFlush ? { idleFlush: true } : undefined)
}
@@ -232,22 +218,12 @@ export namespace MemoryCapture {
const model =
digestDue || typedCall
? yield* Effect.gen(function* () {
const resolution = yield* input.model.resolve({
? (
yield* input.model.resolve({
configured: input.memoryModel,
session: view.sessionModel,
})
if (resolution.fallback) {
yield* memory.append({
root,
text: `memory_model_config reason=${MemoryShared.brief(
MemoryRedact.text(resolution.fallback.reason),
160,
)} fallback=1`,
})
}
return resolution.handle
})
).handle
: undefined
const digestEffect = digestDue
? Effect.gen(function* () {
@@ -277,9 +253,8 @@ export namespace MemoryCapture {
Effect.gen(function* () {
if (signal.aborted) return { ok: false as const, reason: "cancelled" }
const raw = errorReason(err)
const reason = MemoryRedact.text(guardReason(raw) ?? raw)
yield* fail(reason)
yield* memory.append({ root, text: `digest error=${MemoryShared.brief(reason, 160)} fallback=1` })
const reason = MemoryRedact.text(guardReason(err) ?? raw)
yield* fail(reason, raw)
return { ok: false as const, reason }
}),
),
@@ -296,14 +271,9 @@ export namespace MemoryCapture {
try: () => parseJson(digestSchema, result.result.text),
catch: (error) => error,
}).pipe(
Effect.catch((err: unknown) =>
Effect.catch(() =>
Effect.gen(function* () {
const reason = MemoryRedact.text(errorReason(err))
yield* fail("digest parse_error")
yield* memory.append({
root,
text: `digest parse_error=${MemoryShared.brief(reason, 160)} fallback=1`,
})
return undefined
}),
),
@@ -402,9 +372,8 @@ export namespace MemoryCapture {
Effect.gen(function* () {
if (signal.aborted) return { ok: false as const, reason: "cancelled" }
const raw = errorReason(err)
const reason = MemoryRedact.text(guardReason(raw) ?? raw)
yield* fail(reason)
yield* memory.append({ root, text: `consolidate error=${MemoryShared.brief(reason, 160)}` })
const reason = MemoryRedact.text(guardReason(err) ?? raw)
yield* fail(reason, raw)
return { ok: false as const, reason }
}),
),
@@ -423,11 +392,9 @@ export namespace MemoryCapture {
try: () => salvageTyped(result.result.text),
catch: (error) => error,
}).pipe(
Effect.catch((err: unknown) =>
Effect.catch(() =>
Effect.gen(function* () {
const reason = MemoryRedact.text(errorReason(err))
yield* fail("consolidate parse_error")
yield* memory.append({ root, text: `consolidate parse_error=${MemoryShared.brief(reason, 160)}` })
return undefined
}),
),
@@ -475,30 +442,6 @@ export namespace MemoryCapture {
fallback: Boolean(digest.reason),
})
}
if (digestDue) {
yield* memory.decide({
root,
decision: {
kind: "digest",
trigger: "turn-close",
sessionID: input.sessionID,
result: digest.reason ? "fallback" : digest.summary ? "saved" : "skipped",
llm: true,
parsed: Boolean(digest.summary && !digest.reason),
fallback: Boolean(digest.reason),
reason: digest.reason,
tokens: digest.tokens,
operationCount: digest.summary ? 1 : 0,
skippedCount: digest.summary ? 0 : 1,
summary: digest.reason
? `session digest used fallback after ${digest.reason}`
: digest.summary
? "session digest saved"
: "session digest skipped",
},
})
}
// Apply adds only: a same-key add supersedes/updates an existing fact in place. reconcile also
// surfaces exact-key auto-removes, but V0 keeps hard removes explicit-only — auto-capture never
// deletes memory it merely paraphrased (or wrongly flags), so reconciled.removes is not applied.
@@ -506,39 +449,11 @@ export namespace MemoryCapture {
const ops = reconciled.ops.slice(0, state.capture.maxOpsPerRun)
const project =
ops.length > 0 ? yield* memory.apply({ root, ops, trigger: "turn-close", tokens: generated.tokens }) : undefined
// Apply-time skips (content gate + secret, both redacted at creation) surface in the typed audit
// record alongside the model's own declared skips.
const applied: CaptureSkip[] = [...generated.skipped, ...(project?.skipped ?? [])]
const count = project?.operationCount ?? 0
if (typedCall) {
yield* memory.decide({
root,
decision: {
kind: "typed",
trigger: "turn-close",
sessionID: input.sessionID,
result: generated.fallback ? "fallback" : count > 0 ? "saved" : "skipped",
llm: true,
parsed: !generated.fallback,
fallback: generated.fallback,
reason: generated.reason,
tokens: generated.tokens,
operationCount: count,
skippedCount: applied.length,
skipped: applied,
operations: auditOps(ops),
files: [...new Set(ops.flatMap((item) => (item.action === "add" && item.file ? [item.file] : [])))],
summary: generated.fallback
? `typed consolidation skipped after ${generated.reason ?? "model failure"}`
: count > 0
? `typed consolidation saved ${count} ops`
: `typed consolidation skipped ${applied.length} candidates`,
},
})
}
const tokens = digest.tokens + generated.tokens
if (!digest.summary && !typedCall && count === 0) return yield* skip("no_ops")
if ((digestDue || typedCall || count > 0) && (!typedCall || !generated.fallback)) {
if (digestDue || typedCall || count > 0) {
yield* memory.commit({
root,
now,
@@ -546,8 +461,7 @@ export namespace MemoryCapture {
tokens,
count,
digest: Boolean(digest.summary),
typed: typedCall,
skipped: applied,
typed: typedCall && !generated.fallback,
})
}
const updated = yield* memory.state({ root })
+7 -16
View File
@@ -1,5 +1,4 @@
import { Context, Effect, Layer, Semaphore } from "effect"
import { skipLine, type CaptureSkip } from "../capture/capture"
import type { Memory } from "../memory"
import type { MemoryOperations } from "../capture/operations"
import { MemoryRecall } from "../recall/recall"
@@ -104,7 +103,6 @@ type CommitInput = RootInput & {
// shared typed-interval clock (lastTypedConsolidationAt); a digest-only commit must leave it untouched so a
// digest in one session cannot throttle another session's typed capture.
typed: boolean
skipped: CaptureSkip[]
cost?: number
}
@@ -156,10 +154,12 @@ export namespace MemoryService {
readonly recent: (
input: RecentInput,
) => Effect.Effect<Awaited<ReturnType<typeof MemoryFiles.recentSessions>>, Failure>
/** @deprecated Memory audit persistence was removed. */
readonly append: (input: AppendInput) => Effect.Effect<void, Failure>
readonly index: (input: RootInput) => Effect.Effect<Index, Failure>
readonly commit: (input: CommitInput) => Effect.Effect<void, Failure>
readonly recordRecall: (input: RecordRecallInput) => Effect.Effect<void, Failure>
/** @deprecated Memory audit persistence was removed. */
readonly decide: (input: DecideInput) => Effect.Effect<void, Failure>
readonly readSource: (input: ReadSourceInput) => Effect.Effect<string, Failure>
readonly turnLock: (sessionID: SessionID) => Semaphore.Semaphore
@@ -200,7 +200,7 @@ export namespace MemoryService {
return Object.fromEntries(entries) as Sources
}),
recent: (input) => bridge(() => MemoryFiles.recentSessions(input.root, input.limit, input.max)),
append: (input) => bridge(() => MemoryFiles.append(input.root, input.text)),
append: () => Effect.void,
index: (input) =>
bridge(async () => {
const text = await MemoryFiles.readIndex(input.root)
@@ -219,20 +219,11 @@ export namespace MemoryService {
lastSessionSavedAt: input.digest ? input.now : state.stats.lastSessionSavedAt,
lastConsolidatedMessageID: input.messageID,
lastConsolidationCost: input.cost ?? state.stats.lastConsolidationCost,
lastConsolidationTokens: input.tokens,
lastOperationCount: input.count,
lastConsolidationTokens:
input.typed || input.digest ? input.tokens : state.stats.lastConsolidationTokens,
lastOperationCount: input.typed ? input.count : state.stats.lastOperationCount,
},
})
const skip = skipLine(input.skipped)
await MemoryFiles.append(
input.root,
[
`consolidate trigger=turn-close digest=${input.digest ? 1 : 0} ops=${input.count} tokens=${input.tokens}`,
skip,
]
.filter(Boolean)
.join(" "),
)
}),
),
recordRecall: (input) =>
@@ -266,7 +257,7 @@ export namespace MemoryService {
}),
})
}),
decide: (input) => bridge(() => MemoryFiles.decide(input.root, input.decision)),
decide: () => Effect.void,
readSource: (input) => bridge(() => MemoryFiles.readSource(input.root, input.file)),
// Ref-counted so every acquirer — in-flight or queued behind `withPermits` — shares one
// semaphore. Each call must be balanced by exactly one `dropLock`.
+1 -23
View File
@@ -1,23 +1,12 @@
import { MemoryShared } from "./recall/shared"
import type { MemoryOperations } from "./capture/operations"
import { MemoryRedact } from "./capture/redact"
/** Human-facing messages and audit views describing an explicit apply result. */
/** Human-facing messages describing an explicit apply result. */
export namespace MemoryNotice {
export function saved(input: { added: number; removed: number }) {
return input.removed > 0 || input.added > 0
}
export function summary(input: { added: number; removed: number; count: number }) {
if (input.added > 0 && input.removed > 0) {
return `explicit memory operation saved ${input.added} and removed ${input.removed}`
}
if (input.added > 0) return `explicit memory operation saved ${input.added} ops`
if (input.removed > 0) return `explicit memory operation removed ${input.removed} entries`
if (input.count > 0) return "explicit memory operation matched no source memory"
return "explicit memory operation had no accepted ops"
}
export function message(input: { ops: MemoryOperations.Op[]; added: number; removed: number; count: number }) {
const refs = MemoryShared.refs(input.ops)
if (input.added > 0 && input.removed > 0) return `Memory updated · ${input.added} saved, ${input.removed} removed`
@@ -25,15 +14,4 @@ export namespace MemoryNotice {
if (input.removed > 0) return `Memory updated · ${input.removed} removed`
return `Memory unchanged · ${input.count} ops`
}
export function skip(input: MemoryOperations.Rejection[]) {
return input.map((item) => (item.reason === "out_of_scope" ? { reason: item.reason } : item))
}
export function ops(input: { ops: MemoryOperations.Op[]; skipped: MemoryOperations.Rejection[] }) {
const blocked = new Set(input.skipped.filter((item) => item.reason === "out_of_scope").map((item) => item.text))
return MemoryShared.audit(
input.ops.filter((item) => item.action !== "add" || !blocked.has(MemoryRedact.text(item.text))),
)
}
}
-59
View File
@@ -8,7 +8,6 @@ import { MemorySchema } from "./schema"
import { MemoryShared } from "./recall/shared"
import { MemoryToken } from "./recall/token"
import { MemorySlug } from "./slug"
import { MemoryRedact } from "./capture/redact"
/** Root-bound package facade. External Kilo surfaces should derive root from workspace context first. */
export namespace Memory {
@@ -97,7 +96,6 @@ export namespace Memory {
const state = await MemoryFiles.readState(input.root)
const next = { ...state, enabled: false }
await MemoryFiles.writeState(input.root, next)
await MemoryFiles.append(input.root, `disable ${next.scope} source=command`)
return { root: input.root, state: next }
})
}
@@ -124,16 +122,6 @@ export namespace Memory {
...(input.settings.verbose === undefined ? {} : { verbose: input.settings.verbose }),
}
await MemoryFiles.writeState(input.root, next)
await MemoryFiles.append(
input.root,
[
`settings ${next.scope}`,
input.settings.autoConsolidate === undefined ? "" : `autoConsolidate=${next.autoConsolidate}`,
input.settings.verbose === undefined ? "" : `verbose=${next.verbose}`,
]
.filter(Boolean)
.join(" "),
)
return { root: input.root, state: next }
})
}
@@ -216,24 +204,6 @@ export namespace Memory {
}
const state = await MemoryFiles.readState(input.root)
const ok = MemoryNotice.saved({ added: result.added, removed: result.removed })
if (trigger === "explicit") {
await MemoryFiles.decide(input.root, {
kind: "typed",
trigger,
sessionID: input.sessionID,
result: ok ? "saved" : "skipped",
llm: false,
parsed: true,
fallback: false,
tokens: input.tokens ?? 0,
operationCount: result.operationCount,
skippedCount: result.skipped.length || (ok ? 0 : 1),
skipped: MemoryNotice.skip(result.skipped),
operations: MemoryNotice.ops({ ops: accepted, skipped: result.skipped }),
files: MemoryShared.files(accepted),
summary: MemoryNotice.summary({ added: result.added, removed: result.removed, count: result.operationCount }),
})
}
return {
root: input.root,
state,
@@ -318,31 +288,6 @@ export namespace Memory {
const hits = result?.hits ?? []
const files = [...new Set(hits.map((hit) => hit.source))]
const topics = [...new Set(hits.flatMap((hit) => (hit.topics?.length ? hit.topics : [hit.kind])))]
await MemoryFiles.decide(input.root, {
kind: "recall",
trigger: "targeted-recall",
sessionID: input.sessionID,
result: result ? "recalled" : "skipped",
llm: false,
parsed: false,
fallback: false,
reason: result ? undefined : "no_matches",
query: MemoryShared.brief(MemoryRedact.text(input.query), 240),
topics,
files,
tokens: result?.tokens ?? 0,
operationCount: hits.length,
skippedCount: result ? 0 : 1,
summary: result ? `targeted recall matched ${hits.length} memories` : "targeted recall found no matches",
})
if (result) {
await MemoryFiles.queue(input.root, async () => {
await MemoryFiles.append(
input.root,
`recall session=${input.sessionID ?? ""} hits=${result.hits.length} tokens=${result.tokens} files=${files.join(",")}`,
)
})
}
return { root: input.root, state, result, hits, files, topics }
}
@@ -368,10 +313,6 @@ export namespace Memory {
})
await MemoryFiles.pruneSessions(input.root, state.limits.maxSessionFiles)
const index = await MemoryIndexer.rebuild({ root: input.root, state })
await MemoryFiles.append(
input.root,
`session digest session=${input.sessionID} tokens=${input.tokens ?? 0} indexTokens=${index.tokens}`,
)
return { root: input.root, state, skipped: false as const, index }
})
}
@@ -226,7 +226,6 @@ export namespace MemoryIndexer {
return MemoryFiles.queue(input.root, async () => {
const result = await build(input)
await MemoryFiles.writeIndex(input.root, result.text)
await MemoryFiles.append(input.root, `regenerate index.kmem bytes=${result.bytes} tokens=${result.tokens}`)
return result
})
}
@@ -94,6 +94,7 @@ export namespace MemoryShared {
]
}
/** @deprecated Memory audit persistence was removed. */
export function audit(ops: MemoryOperations.Op[]) {
return ops.map((item) =>
item.action === "add"
+6
View File
@@ -1,3 +1,9 @@
/** Wire contract for suppressible background-capture failures: guardReason emits this as the
* `memory.error` reason, and every client surface (TUI toast filter, VS Code provider) matches
* against this same constant rather than a local literal. Lives here (dependency-free module)
* so clients can import it without pulling the capture pipeline into their bundles. */
export const TRANSIENT = "transient" as const
export namespace MemorySchema {
export const VERSION = 1
export const maxStoredDigestSummary = 4_000
+7 -37
View File
@@ -1,15 +1,5 @@
import z from "zod"
import { MemoryFs } from "./fs"
/** Compatibility facade for the removed memory audit log. */
export namespace MemoryAudit {
const Log = z
.object({
kind: z.literal("log"),
summary: z.string(),
time: z.string().optional(),
})
.passthrough()
export type Decision =
| {
kind: "log"
@@ -43,18 +33,14 @@ export namespace MemoryAudit {
}[]
}
function audit(root: string, input: Decision) {
void root
void input
return Promise.resolve()
}
export async function append(root: string, text: string) {
await audit(root, { kind: "log", result: "logged", summary: text })
void root
void text
}
export async function decide(root: string, input: Decision) {
await audit(root, input)
void root
void input
}
export async function readDecisions(root: string) {
@@ -62,24 +48,8 @@ export namespace MemoryAudit {
return ""
}
function record(input: string) {
try {
const data = JSON.parse(input)
const parsed = Log.safeParse(data)
return parsed.success ? parsed.data : undefined
} catch (error) {
if (MemoryFs.parse(error)) return undefined
throw error
}
}
export async function readChanges(root: string) {
const lines = (await readDecisions(root)).split("\n").flatMap((line) => {
const data = record(line)
if (!data) return []
const time = data.time ?? ""
return [`${time} ${data.summary}`.trim()]
})
return lines.join("\n")
void root
return ""
}
}
+3 -8
View File
@@ -1,6 +1,5 @@
import { readdir, rm } from "fs/promises"
import path from "path"
import { MemoryAudit } from "./audit"
import { MemoryFs } from "./fs"
import { MemoryMarkdown } from "./markdown"
import { MemoryPaths } from "./paths"
@@ -19,13 +18,10 @@ export namespace MemoryState {
"corrections.md": "# Corrective Memory\n\n## Corrections\n",
}
async function recover(root: string, file: string, error: unknown) {
async function recover(root: string, file: string) {
await MemoryFs.backup(file)
const state = MemorySchema.missing()
await writeState(root, state)
await MemoryAudit.append(root, `recover state.json error=${MemoryFs.brief(error)}`).catch((err: unknown) =>
MemoryFs.warn("failed to audit memory state recovery", { err, root }),
)
return state
}
@@ -33,14 +29,14 @@ export namespace MemoryState {
const file = MemoryPaths.files(root).state
const data = await MemoryFs.json(file).catch(async (error: unknown) => {
if (MemoryFs.miss(error)) return undefined
if (MemoryFs.parse(error)) return recover(root, file, error)
if (MemoryFs.parse(error)) return recover(root, file)
throw error
})
if (data === undefined) return MemorySchema.missing()
return Promise.resolve()
.then(() => MemorySchema.parse(data))
.catch((error: unknown) => {
if (MemoryFs.parse(error)) return recover(root, file, error)
if (MemoryFs.parse(error)) return recover(root, file)
throw error
})
}
@@ -199,7 +195,6 @@ export namespace MemoryState {
? { ...(await readState(root)), enabled: true, autoInject: true }
: { ...MemorySchema.create(), enabled: true }
await writeState(root, state)
await MemoryAudit.append(root, "enable project source=command")
return state
}
+8 -121
View File
@@ -4,7 +4,6 @@ import { MemoryError, type MemoryError as Failure } from "./effect/errors"
import { MemoryPaths } from "./effect/paths"
import { MemoryService } from "./effect/service"
import { MemoryRecall } from "./recall/recall"
import { MemoryToken } from "./recall/token"
import { MemorySchema } from "./schema"
import recallDescription from "./prompts/tool-memory-recall.txt"
import saveDescription from "./prompts/tool-memory-save.txt"
@@ -115,50 +114,6 @@ export namespace MemoryTool {
}
}
function audit(
memory: MemoryService.Interface,
input: {
root: string
params: RecallParams
current: string
hits: MemoryRecall.Hit[]
skipped?: string
output: string
},
) {
const files = [...new Set(input.hits.map((hit) => hit.source))]
const topics = [...new Set(input.hits.flatMap((hit) => (hit.topics?.length ? hit.topics : [hit.kind])))]
const query =
input.params.query ??
(input.params.sessionID
? `sessionID=${input.params.sessionID}`
: input.params.mode === "digest"
? "recent digests"
: undefined)
return memory.decide({
root: input.root,
decision: {
kind: "recall",
trigger: "targeted-recall",
sessionID: input.current,
result: input.hits.length ? "recalled" : "skipped",
llm: false,
parsed: false,
fallback: false,
reason: input.skipped,
query,
topics,
files,
tokens: MemoryToken.estimate(input.output),
operationCount: input.hits.length,
skippedCount: input.hits.length ? 0 : 1,
summary: input.hits.length
? `memory recall returned ${input.hits.length} ${input.params.mode} hits`
: `memory recall found no ${input.params.mode} hits`,
},
})
}
function miss(input: { params: RecallParams; current: string }) {
const self = input.params.sessionID === input.current
if (self && input.params.mode === "digest") {
@@ -239,35 +194,6 @@ export namespace MemoryTool {
})
}
function catalogAudit(
memory: MemoryService.Interface,
input: {
root: string
current: string
query: string
result: { output: string; count: number; files: string[] }
},
) {
return memory.decide({
root: input.root,
decision: {
kind: "recall",
trigger: "targeted-recall",
sessionID: input.current,
result: input.result.count ? "recalled" : "skipped",
llm: false,
parsed: false,
fallback: false,
query: input.query || "all keys",
files: input.result.files,
tokens: MemoryToken.estimate(input.result.output),
operationCount: input.result.count,
skippedCount: input.result.count ? 0 : 1,
summary: `memory catalog listed ${input.result.count} entries`,
},
})
}
function approvalRecall(input: Recall) {
return input.ask({
permission: "kilo_memory_recall",
@@ -285,7 +211,6 @@ export namespace MemoryTool {
return Effect.gen(function* () {
const result = yield* catalog(input.memory, { root: live.root, query })
const safe = { ...result, output: block(result.output) }
yield* catalogAudit(input.memory, { root: live.root, current: live.current, query, result: safe })
yield* input.memory.recordRecall({ root: live.root, sessionID: live.current, now: Date.now(), count: result.count })
return {
title: `Kilo memory catalog: ${result.count} entr${result.count === 1 ? "y" : "ies"}`,
@@ -296,22 +221,13 @@ export namespace MemoryTool {
}
function recallQuery(input: Recall, live: Live) {
return Effect.gen(function* () {
const output = "Provide a topic query for typed/search memory recall."
yield* audit(input.memory, {
root: live.root,
params: input.params,
current: live.current,
hits: [],
skipped: "missing_query",
output,
})
return {
return Effect.succeed(
{
title: `Kilo memory ${input.params.mode}: no query`,
output,
output: "Provide a topic query for typed/search memory recall.",
metadata: { sources: [], count: 0 },
} satisfies Result
})
} satisfies Result,
)
}
function recallSearch(input: Recall, live: Live, query: string, mode: MemoryRecall.Mode) {
@@ -327,15 +243,7 @@ export namespace MemoryTool {
limit,
})
const hits = result?.hits ?? []
const self = input.params.sessionID === live.current
const skipped =
input.params.sessionID && input.params.mode === "digest" && hits.length === 0
? self
? "current_session_digest"
: "missing_session_digest"
: undefined
const output = hits.length ? result!.block : miss({ params: input.params, current: live.current })
yield* audit(input.memory, { root: live.root, params: input.params, current: live.current, hits, skipped, output })
yield* input.memory.recordRecall({ root: live.root, sessionID: live.current, now: Date.now(), count: hits.length })
if (hits.length === 0) {
@@ -440,29 +348,8 @@ export namespace MemoryTool {
return saved(input)
}
function skip(input: Base & { params: SaveParams }, root: string) {
return Effect.gen(function* () {
const reason = input.params.reason ?? "out_of_scope"
yield* input.memory.decide({
root,
decision: {
kind: "typed",
trigger: "explicit",
sessionID: input.sessionID,
result: "skipped",
llm: false,
parsed: true,
fallback: false,
reason,
tokens: 0,
operationCount: 0,
skippedCount: 1,
skipped: [{ reason }],
summary: `explicit memory save skipped: ${reason}`,
},
})
return skipped({ reason })
})
function skip(input: Base & { params: SaveParams }) {
return Effect.succeed(skipped({ reason: input.params.reason ?? "out_of_scope" }))
}
function forget(input: Save, root: string) {
@@ -497,7 +384,7 @@ export namespace MemoryTool {
const state = yield* input.memory.state({ root })
if (!state.enabled) return disabled()
if (input.params.action === "forget") return yield* forget(input, root)
if (input.params.action === "skip") return yield* skip(input, root)
if (input.params.action === "skip") return yield* skip(input)
return yield* write(input, root)
})
}
+71 -13
View File
@@ -1,8 +1,8 @@
import { describe, expect, test } from "bun:test"
import {
auditOps,
capturePlan,
duplicateOps,
errorReason,
fallbackDigest,
guardReason,
hasSubstantialDiff,
@@ -128,7 +128,7 @@ describe("memory capture parsing", () => {
expect(() => salvageTyped(`{"op":"upsert_project_fact","key":"good_one","value":"Keep this fact."}`)).toThrow()
})
test("redacts secrets in salvaged unsupported ops before they reach the audit", () => {
test("redacts secrets in salvaged unsupported ops before they reach callers", () => {
const parsed = salvageTyped(
`{"operations":[{"op":"not_a_real_op","key":"leak","value":"key is sk-abcdefghijklmnopqrstuvwxyz"}],"skipped":[]}`,
)
@@ -169,17 +169,6 @@ describe("memory capture parsing", () => {
expect(JSON.stringify(parsed.skipped)).not.toContain(secret.slice(0, 20))
})
test("redacts remove audit queries before truncating", () => {
const secret = "sk-" + "a".repeat(40)
const query = "x".repeat(100) + secret
const audit = auditOps([{ action: "remove", query }])
const text = JSON.stringify(audit)
expect(text).toContain("[redacted]")
expect(text).not.toContain(secret)
expect(text).not.toContain(secret.slice(0, 20))
})
test("truncates typed batches beyond the op cap instead of failing", () => {
const ops = Array.from(
{ length: 20 },
@@ -332,6 +321,11 @@ describe("memory capture parsing", () => {
input: base,
expected: { session: true, digestDue: true, typedCall: true, typedWork: true, skipReason: undefined },
},
{
name: "expected work: a persisted null typed clock does not throttle capture",
input: { ...base, lastTypedConsolidationAt: null },
expected: { session: true, digestDue: true, typedCall: true, typedWork: true, skipReason: undefined },
},
{
name: "expected idle flush: completed turn inside interval skips now",
input: { ...base, priorTime: 900, lastTypedConsolidationAt: 900 },
@@ -650,6 +644,70 @@ describe("memory capture parsing", () => {
)
expect(guardReason("429 too many requests")).toBe("rate_limit_guard")
expect(guardReason("billing credits exhausted")).toBe("quota_guard")
expect(guardReason("request timed out")).toBe("transient")
expect(guardReason("deadline exceeded")).toBe("transient")
expect(guardReason("DeadlineExceeded")).toBe("transient")
expect(guardReason('cause={"code":"ETIMEDOUT"}')).toBe("transient")
expect(guardReason("504 Gateway Timeout")).toBe("transient")
expect(guardReason("Connect Timeout Error")).toBe("transient")
expect(guardReason("Headers Timeout Error")).toBe("transient")
expect(guardReason("Body Timeout Error")).toBe("transient")
expect(guardReason("connect_timeout")).toBe("transient")
expect(guardReason('status=400 body={"error":"invalid parameter: timeout"}')).toBeUndefined()
expect(guardReason("set request timeout to 30000")).toBeUndefined()
const timeout = new Error("request aborted")
timeout.name = "TimeoutError"
expect(guardReason(timeout)).toBe("transient")
expect(errorReason(timeout)).toBe("request aborted")
expect(guardReason(Object.assign(new Error("request failed"), { status: 504 }))).toBe("transient")
expect(
guardReason(Object.assign(new Error("request failed"), { cause: { code: "UND_ERR_CONNECT_TIMEOUT" } })),
).toBe("transient")
expect(
guardReason(Object.assign(new Error("request failed"), { cause: { code: "UND_ERR_HEADERS_TIMEOUT" } })),
).toBe("transient")
expect(guardReason(Object.assign(new Error("request failed"), { cause: { code: "ETIMEDOUT" } }))).toBe(
"transient",
)
expect(
guardReason(Object.assign(new Error("failed after 2 attempts"), { errors: [{ statusCode: 504 }] })),
).toBe("transient")
expect(guardReason(new Error("set request timeout to 30000"))).toBeUndefined()
expect(guardReason(new Error("request timed out"))).toBe("transient")
expect(guardReason(Object.assign(new Error("request failed"), { cause: new Error("connect timeout") }))).toBe(
"transient",
)
expect(
guardReason(
Object.assign(new Error("failed after 2 attempts"), {
errors: [Object.assign(new Error("rate limit reached"), { status: 429 }), timeout],
}),
),
).toBe("rate_limit_guard")
expect(
guardReason(
Object.assign(new Error("failed after 2 attempts"), {
errors: [new Error("insufficient quota available"), timeout],
}),
),
).toBe("quota_guard")
expect(
guardReason(
Object.assign(new Error("request failed"), {
cause: Object.assign(new Error("rate limit reached"), { status: 429 }),
}),
),
).toBe("rate_limit_guard")
expect(
guardReason(
Object.assign(new Error("timed out"), {
name: "TimeoutError",
cause: new Error("billing credit balance exhausted"),
}),
),
).toBe("quota_guard")
expect(guardReason("timeout after 429 too many requests")).toBe("rate_limit_guard")
expect(guardReason("timeout after quota exceeded")).toBe("quota_guard")
})
test("redacts common secret token shapes", () => {
+16
View File
@@ -50,6 +50,22 @@ describe("memory core package", () => {
})
})
test("legacy audit compatibility APIs remain no-op", async () => {
await use(async (t) => {
await Memory.enable({ root: t.root })
await MemoryFiles.append(t.root, "provider error with sensitive detail")
await MemoryFiles.decide(t.root, {
kind: "typed",
result: "error",
reason: "provider error with sensitive detail",
})
expect(await MemoryFiles.readChanges(t.root)).toBe("")
expect(await MemoryFiles.readDecisions(t.root)).toBe("")
expect(await Bun.file(path.join(t.root, "decisions.jsonl")).exists()).toBe(false)
})
})
test("prepare removes legacy decisions once from owned memory roots", async () => {
await use(async (t) => {
await Memory.enable({ root: t.root })
@@ -7,6 +7,7 @@ import { digestPrompt, typedPrompt } from "../src/capture/capture"
import { MemoryCapture } from "../src/effect/capture"
import { MemoryEvents } from "../src/effect/events"
import { KiloMemory } from "../src/effect/index"
import { MemoryLog } from "../src/effect/log"
import type { MemoryPorts } from "../src/effect/ports"
import { MemoryService } from "../src/effect/service"
import { MemoryTimers } from "../src/effect/timers"
@@ -49,11 +50,18 @@ function session(turn: MemoryPorts.TurnView | undefined): MemoryPorts.SessionPor
/** Model port that answers digest/typed calls from canned JSON, keyed by system prompt so it is
* order-independent (digest and typed run concurrently). */
function model(input: { digest: string; typed: string; fallback?: string; onRun?: (system: string) => void }): MemoryPorts.ModelPort {
function model(input: {
digest: string
typed: string
fallback?: string
fail?: Error
onRun?: (system: string) => void
}): MemoryPorts.ModelPort {
return {
resolve: () => Effect.succeed({ handle: {}, ...(input.fallback ? { fallback: { reason: input.fallback } } : {}) }),
run: async ({ system }) => {
input.onRun?.(system)
if (system === typedPrompt && input.fail) throw input.fail
const text = system === digestPrompt ? input.digest : system === typedPrompt ? input.typed : "{}"
return { text, usage: USAGE }
},
@@ -66,6 +74,7 @@ function run(input: {
model: MemoryPorts.ModelPort
memoryModel?: string
reason?: "completed" | "interrupted" | "error"
bypassInterval?: boolean
}) {
return Effect.runPromise(
MemoryCapture.turn({
@@ -75,6 +84,7 @@ function run(input: {
model: input.model,
memoryModel: input.memoryModel,
reason: input.reason ?? "completed",
bypassInterval: input.bypassInterval,
}).pipe(Effect.provideService(MemoryService.Service, MemoryService.make())),
)
}
@@ -107,6 +117,121 @@ describe("MemoryCapture (fake ports)", () => {
}
})
test("typed timeout preserves digest progress without advancing the typed clock", async () => {
const t = await tmp()
const events: MemoryEvents.Status[] = []
const logs: string[] = []
try {
await KiloMemory.enable({ root: t.root })
await KiloMemory.configure({ root: t.root, settings: { autoConsolidate: true } })
MemoryLog.setWarn((message, meta) => logs.push(`${message}:${meta?.reason}:${meta?.detail}`))
MemoryEvents.setSink((input) => {
events.push(input.payload)
})
const result = await run({
root: t.root,
session: session(view()),
model: model({
digest: '{"topic":"repo setup","summary":"Explored repo setup commands. Next step: verify memory tests."}',
typed: "{}",
fail: new DOMException("memory model timed out", "TimeoutError"),
}),
})
expect(result).toMatchObject({ skipped: false, operationCount: 0 })
const state = await MemoryFiles.readState(t.root)
expect(state.stats.lastTypedConsolidationAt).toBeNull()
expect(state.stats.lastSessionSavedAt).toEqual(expect.any(Number))
expect(state.stats.lastConsolidatedMessageID).toBe("msg_assistant")
expect(events.find((item) => item.state === "error")?.reason).toBe("transient")
expect(logs).toEqual(["memory capture transient failure:transient:memory model timed out"])
const calls: string[] = []
const retry = await run({
root: t.root,
session: session(view()),
model: model({
digest: "{}",
typed: '{"operations":[],"skipped":[]}',
onRun: (system) => calls.push(system),
}),
bypassInterval: true,
})
expect(retry).toMatchObject({ skipped: true, reason: "no_new_content" })
expect(calls).toEqual([])
const next = await run({
root: t.root,
session: session(
view({
assistant: "Use bun install, then run the package tests and typecheck.",
lastAssistantID: "msg_assistant_next",
}),
),
model: model({
digest: "{}",
typed: '{"operations":[],"skipped":[]}',
onRun: (system) => calls.push(system),
}),
})
expect(next).toMatchObject({ skipped: false, operationCount: 0 })
expect(calls).toEqual([typedPrompt])
const updated = await MemoryFiles.readState(t.root)
expect(updated.stats.lastTypedConsolidationAt).toEqual(expect.any(Number))
expect(updated.stats.lastConsolidatedMessageID).toBe("msg_assistant_next")
} finally {
MemoryLog.setWarn(() => {})
MemoryEvents.setSink(() => {})
await t.done()
}
})
test("fallback commit preserves metrics from the prior successful typed consolidation", async () => {
const t = await tmp()
try {
await KiloMemory.enable({ root: t.root })
await KiloMemory.configure({ root: t.root, settings: { autoConsolidate: true } })
const first = await run({
root: t.root,
session: session(view()),
model: model({
digest: '{"topic":"repo setup","summary":"Explored repo setup."}',
typed:
'{"operations":[{"op":"upsert_environment_fact","section":"Commands","key":"test_cmd","value":"bun test"}],"skipped":[]}',
}),
})
expect(first).toMatchObject({ skipped: false, operationCount: 1 })
const initial = await MemoryFiles.readState(t.root)
expect(initial.stats.lastOperationCount).toBe(1)
expect(initial.stats.lastTypedConsolidationAt).toEqual(expect.any(Number))
const second = await run({
root: t.root,
session: session(
view({
assistant: "Investigated a timeout edge case.",
lastAssistantID: "msg_assistant_timeout",
}),
),
model: model({
digest: '{"topic":"investigation","summary":"Investigated timeouts."}',
typed: "{}",
fail: new DOMException("memory model timed out", "TimeoutError"),
}),
bypassInterval: true,
})
expect(second).toMatchObject({ skipped: false, operationCount: 0 })
const preserved = await MemoryFiles.readState(t.root)
expect(preserved.stats.lastOperationCount).toBe(1)
expect(preserved.stats.lastTypedConsolidationAt).toBe(initial.stats.lastTypedConsolidationAt)
expect(preserved.stats.lastConsolidatedMessageID).toBe("msg_assistant_timeout")
} finally {
await t.done()
}
})
test("turn-close skips a secret-like op and applies the rest of the batch", async () => {
const t = await tmp()
const events: MemoryEvents.Status[] = []
@@ -12,6 +12,24 @@
border: 1px solid var(--border-weak-base);
}
&[data-size="large"] [data-slot="dialog-container"] {
width: min(calc(100vw - 32px), 800px);
height: min(calc(100vh - 32px), 720px);
}
&[data-size="large"] [data-slot="dialog-content"] {
max-width: 800px;
}
&[data-size="x-large"] [data-slot="dialog-container"] {
width: min(calc(100vw - 32px), 980px);
height: min(calc(100vh - 32px), 800px);
}
&[data-size="x-large"] [data-slot="dialog-content"] {
max-width: 980px;
}
[data-slot="dialog-header"] {
font-size: var(--kilo-font-size-14);
}
+12
View File
@@ -2,6 +2,18 @@ import { Icon as Upstream, type IconProps as Props } from "@opencode-ai/ui/icon"
import { splitProps } from "solid-js"
const icons: Record<string, { path: string; viewBox: string }> = {
"circle-x-outline": {
viewBox: "0 0 20 20",
path: `<path d="M7.5 7.5L12.5 12.5M12.5 7.5L7.5 12.5M18.3333 10C18.3333 14.6024 14.6024 18.3333 10 18.3333C5.39763 18.3333 1.66667 14.6024 1.66667 10C1.66667 5.39763 5.39763 1.66667 10 1.66667C14.6024 1.66667 18.3333 5.39763 18.3333 10Z" stroke="currentColor" stroke-linecap="square"/>`,
},
"pull-request": {
viewBox: "0 0 20 20",
path: `<path d="M1.875 4.0625a2.8125 2.8125 0 1 1 3.75 2.6525v6.57a2.8138 2.8138 0 1 1-1.875 0V6.715A2.8125 2.8125 0 0 1 1.875 4.0625Zm7.096-.22125L11.96625.84625A.3125.3125 0 0 1 12.5 1.0675V3.125h1.25A3.125 3.125 0 0 1 16.875 6.25v7.035a2.8138 2.8138 0 1 1-1.875 0V6.25a1.25 1.25 0 0 0-1.25-1.25H12.5v2.0575a.3125.3125 0 0 1-.53375.22125L8.97125 4.34125a.3125.3125 0 0 1 0-.4425ZM4.6875 3.125a.9375.9375 0 1 0 0 1.875.9375.9375 0 0 0 0-1.875Zm0 11.875a.9375.9375 0 1 0 0 1.875.9375.9375 0 0 0 0-1.875Zm10.3125.9375a.9375.9375 0 1 0 1.875 0 .9375.9375 0 0 0-1.875 0Z" fill="currentColor"/>`,
},
refresh: {
viewBox: "0 0 20 20",
path: `<path d="M17.0837 10.0003C17.0837 13.9123 13.9123 17.0837 10.0003 17.0837C6.08833 17.0837 2.91699 13.9123 2.91699 10.0003C2.91699 6.08833 6.08833 2.91699 10.0003 2.91699C12.3717 2.91699 14.4722 4.07428 15.7698 5.83366M15.7698 5.83366V2.91699M15.7698 5.83366H12.8532" stroke="currentColor" stroke-linecap="square"/>`,
},
memory: {
viewBox: "0 0 24 24",
path: `<path d="M2 7h4M2 12h4M2 17h4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><rect width="16" height="20" x="4" y="2" rx="2" stroke="currentColor" stroke-width="2"/>`,
+5 -2
View File
@@ -1,6 +1,7 @@
import * as path from "path"
import { existsSync } from "fs"
import * as vscode from "vscode"
import { TRANSIENT as MEMORY_TRANSIENT } from "@kilocode/kilo-memory/schema"
import type {
KiloClient,
Session,
@@ -4307,8 +4308,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
for (const sid of trackedByDir) targets.add(sid)
if (local && active) targets.add(active)
if (targets.size === 0 && local) targets.add(undefined)
const detail =
props.detail && typeof props.detail === "object"
const transient = event.type === "memory.error" && props.reason === MEMORY_TRANSIENT
const detail = transient
? undefined
: props.detail && typeof props.detail === "object"
? props.detail
: event.type === "memory.error" && typeof props.reason === "string"
? { type: "error", message: props.reason, reason: props.reason }
@@ -1,10 +1,12 @@
import type { ExecFileOptionsWithStringEncoding } from "child_process"
import type { Worktree } from "./WorktreeStateManager"
import type { PRStatus, PRCheck, PRComment, CheckStatus, AggregateCheckStatus, PRState, ReviewDecision } from "./types"
import type { PRStatus, PRCheck, PRComment, PRReviewer, AggregateCheckStatus } from "./types"
import { execWithShellEnv } from "./shell-env"
import { execGhRead } from "./gh"
import { classifyPRError } from "./git-import"
import type { Semaphore } from "./semaphore"
import { parsePRResult, checkStatus, formatCheckDuration, parseComments, parseReviewers } from "./am-pr-utils"
import type { PRResult, GhThread, GhReviewRequest, GhReview } from "./am-pr-types"
interface PRStatusPollerOptions {
getWorktrees: () => Worktree[]
@@ -17,6 +19,7 @@ interface PRStatusPollerOptions {
}
const GH_PROBE_TTL = 300_000 // 5 minutes — gh installation state rarely changes at runtime
const GH_PROBE_FAILURE_TTL = 30_000 // 30 seconds — retry faster after a failed probe
const MAX_BACKOFF = 120_000 // 2 minutes — cap for exponential backoff on repeated errors
const BACKOFF_MULTIPLIER = 2
const PR_LOOKUP_TTL = 10_000 // 10 seconds — short TTL; only the active worktree polls so this stays cheap
@@ -171,7 +174,8 @@ export class PRStatusPoller {
private async probeGh(): Promise<boolean> {
const now = Date.now()
if (this.ghAvailable !== undefined && now - this.ghProbeTime < GH_PROBE_TTL) {
const ttl = this.ghAvailable === false ? GH_PROBE_FAILURE_TTL : GH_PROBE_TTL
if (this.ghAvailable !== undefined && now - this.ghProbeTime < ttl) {
return this.ghAvailable
}
try {
@@ -244,8 +248,9 @@ export class PRStatusPoller {
return
}
const [checks, comments] = await Promise.all([
const [checks, reviewers, comments] = await Promise.all([
this.fetchChecks(pr.number, wt.path),
this.fetchReviewers(pr.number, wt.path),
this.activeWorktreeId === worktreeId ? this.fetchComments(pr.number, wt.path) : undefined,
])
if (this.stale(generation)) return
@@ -253,17 +258,22 @@ export class PRStatusPoller {
const status: PRStatus = {
number: pr.number,
title: pr.title,
body: pr.body,
url: pr.url,
state: pr.state,
review: pr.review,
checks,
...(comments && { comments }),
reviewers,
...(comments && {
comments: { total: comments.total, unresolved: comments.unresolved, comments: comments.comments },
}),
additions: pr.additions,
deletions: pr.deletions,
files: pr.files,
}
const hash = `${worktreeId}:${pr.number}:${pr.state}:${pr.review}:${checks.status}:${checks.passed}/${checks.total}:${comments?.total ?? ""}:${comments?.unresolved ?? ""}`
const reviewersSig = reviewers.map((r) => `${r.login}:${r.state}`).join(",")
const hash = `${worktreeId}:${pr.number}:${pr.title}:${pr.state}:${pr.review}:${checks.status}:${checks.passed}/${checks.total}:${reviewersSig}:${pr.body ?? ""}:${comments?.total ?? ""}:${comments?.unresolved ?? ""}`
if (this.lastHash.get(worktreeId) === hash) return
this.lastHash.set(worktreeId, hash)
@@ -292,7 +302,7 @@ export class PRStatusPoller {
}
private static readonly PR_JSON_FIELDS =
"number,title,url,state,isDraft,reviewDecision,additions,deletions,changedFiles,headRefName,headRefOid"
"number,title,body,url,state,isDraft,reviewDecision,additions,deletions,changedFiles,headRefName,headRefOid"
/** Return a cached PR lookup if still fresh, otherwise fetch and cache.
* Keyed by branch name so multiple worktrees on the same branch share
@@ -374,7 +384,7 @@ export class PRStatusPoller {
passed: number
failed: number
pending: number
items: PRCheck[]
checks: PRCheck[]
}> {
try {
const { stdout } = await this.gh(
@@ -389,24 +399,24 @@ export class PRStatusPoller {
completedAt?: string
}>
const items: PRCheck[] = data.map((c) => ({
const checks: PRCheck[] = data.map((c) => ({
name: c.name,
status: mapCheckStatus(c.state),
status: checkStatus(c.state),
url: c.link,
duration: formatCheckDuration(c.startedAt, c.completedAt),
}))
const total = items.length
const passed = items.filter((c) => c.status === "success").length
const failed = items.filter((c) => c.status === "failure").length
const pending = items.filter((c) => c.status === "pending").length
const total = checks.filter((c) => c.status !== "skipped").length
const passed = checks.filter((c) => c.status === "success").length
const failed = checks.filter((c) => c.status === "failure").length
const pending = checks.filter((c) => c.status === "pending").length
const status: AggregateCheckStatus =
total === 0 ? "none" : failed > 0 ? "failure" : pending > 0 ? "pending" : "success"
return { status, total, passed, failed, pending, items }
return { status, total, passed, failed, pending, checks }
} catch {
return { status: "none", total: 0, passed: 0, failed: 0, pending: 0, items: [] }
return { status: "none", total: 0, passed: 0, failed: 0, pending: 0, checks: [] }
}
}
@@ -424,13 +434,53 @@ export class PRStatusPoller {
return info
}
private async fetchReviewers(prNumber: number, cwd: string): Promise<PRReviewer[]> {
try {
const repo = await this.getRepoInfo(cwd)
const query = `query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
reviewRequests(first: 20) {
nodes { requestedReviewer { ... on User { login avatarUrl } } }
}
reviews(last: 20, states: [APPROVED, CHANGES_REQUESTED, COMMENTED]) {
nodes { author { login avatarUrl } state }
}
}
}
}`
const { stdout } = await this.gh(
[
"api",
"graphql",
"-f",
`query=${query}`,
"-F",
`owner=${repo.owner}`,
"-F",
`repo=${repo.name}`,
"-F",
`number=${prNumber}`,
],
{ cwd, timeout: 15_000 },
)
const pr = JSON.parse(stdout)?.data?.repository?.pullRequest
return parseReviewers(
(pr?.reviewRequests?.nodes ?? []) as GhReviewRequest[],
(pr?.reviews?.nodes ?? []) as GhReview[],
)
} catch (err) {
this.options.log("Failed to fetch PR reviewers:", err)
return []
}
}
private async fetchComments(
prNumber: number,
cwd: string,
): Promise<{ total: number; unresolved: number; items: PRComment[] }> {
): Promise<{ total: number; unresolved: number; comments: PRComment[] }> {
try {
const repo = await this.getRepoInfo(cwd)
const query = `query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
@@ -469,107 +519,16 @@ export class PRStatusPoller {
],
{ cwd, timeout: 15_000 },
)
const result = JSON.parse(stdout)
const threads = result?.data?.repository?.pullRequest?.reviewThreads?.nodes ?? []
const items: PRComment[] = []
for (const thread of threads) {
const first = thread.comments?.nodes?.[0]
if (!first) continue
items.push({
id: first.id,
author: first.author?.login ?? "unknown",
avatar: first.author?.avatarUrl,
body: first.body ?? "",
file: first.path,
line: first.line,
url: first.url,
resolved: thread.isResolved ?? false,
createdAt: first.createdAt ? new Date(first.createdAt).getTime() : undefined,
})
}
const total = items.length
const unresolved = items.filter((c) => !c.resolved).length
return { total, unresolved, items }
const pr = JSON.parse(stdout)?.data?.repository?.pullRequest
const comments = parseComments((pr?.reviewThreads?.nodes ?? []) as GhThread[])
return { total: comments.length, unresolved: comments.filter((c) => !c.resolved).length, comments }
} catch (err) {
this.options.log("Failed to fetch PR comments:", err)
return { total: 0, unresolved: 0, items: [] }
return { total: 0, unresolved: 0, comments: [] }
}
}
}
interface PRResult {
number: number
title: string
url: string
state: PRState
review: ReviewDecision | null
additions: number
deletions: number
files: number
}
function parsePRResult(json: string): PRResult | null {
const data = JSON.parse(json)
if (!data.number) return null
return {
number: data.number,
title: data.title ?? "",
url: data.url ?? "",
state: parsePRState(data.isDraft, data.state),
review: parseReviewDecision(data.reviewDecision),
additions: data.additions ?? 0,
deletions: data.deletions ?? 0,
files: data.changedFiles ?? 0,
}
}
function parsePRState(isDraft: boolean, ghState: string): PRState {
if (isDraft) return "draft"
if (ghState === "MERGED") return "merged"
if (ghState === "CLOSED") return "closed"
return "open"
}
function parseReviewDecision(decision: string | undefined): ReviewDecision | null {
if (decision === "APPROVED") return "approved"
if (decision === "CHANGES_REQUESTED") return "changes_requested"
if (decision === "REVIEW_REQUIRED") return "pending"
return null
}
function mapCheckStatus(state: string): CheckStatus {
switch (state.toUpperCase()) {
case "SUCCESS":
return "success"
case "FAILURE":
case "ERROR":
return "failure"
case "PENDING":
case "QUEUED":
case "IN_PROGRESS":
case "REQUESTED":
case "WAITING":
return "pending"
case "SKIPPED":
return "skipped"
case "CANCELLED":
case "TIMED_OUT":
case "STALE":
case "STARTUP_FAILURE":
return "cancelled"
default:
return "pending"
}
}
function formatCheckDuration(startedAt?: string, completedAt?: string): string | undefined {
if (!startedAt || !completedAt) return undefined
const secs = Math.round((new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 1000)
return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`
}
/** Run async thunks with bounded concurrency, returning settled results. */
async function settled<T>(thunks: (() => Promise<T>)[], concurrency: number): Promise<PromiseSettledResult<T>[]> {
const results: PromiseSettledResult<T>[] = new Array(thunks.length)
@@ -0,0 +1,40 @@
import type { PRState, ReviewDecision } from "./types"
// Raw shapes returned by `gh pr view --json`
export interface GhAuthor {
login?: string
avatarUrl?: string
}
export interface GhComment {
id: string
author?: GhAuthor
body?: string
path?: string
line?: number
url?: string
createdAt?: string
}
export interface GhThread {
isResolved?: boolean
comments?: { nodes?: GhComment[] }
}
export interface GhReviewRequest {
requestedReviewer?: GhAuthor
}
export interface GhReview {
author?: GhAuthor
state?: string
}
export interface PRResult {
number: number
title: string
body: string
url: string
state: PRState
review: ReviewDecision | null
additions: number
deletions: number
files: number
}
@@ -0,0 +1,105 @@
import type { CheckStatus, PRComment, PRReviewer, ReviewerState } from "./types"
import type { PRResult, GhThread, GhReviewRequest, GhReview } from "./am-pr-types"
export function parsePRResult(json: string): PRResult | null {
const data = JSON.parse(json)
if (!data.number) return null
const state = data.isDraft ? "draft" : (data.state?.toLowerCase() ?? "open")
const decision = data.reviewDecision as string | undefined
const review =
decision === "APPROVED"
? "approved"
: decision === "CHANGES_REQUESTED"
? "changes_requested"
: decision === "REVIEW_REQUIRED"
? "pending"
: null
return {
number: data.number,
title: data.title ?? "",
body: data.body ?? "",
url: data.url ?? "",
state,
review,
additions: data.additions ?? 0,
deletions: data.deletions ?? 0,
files: data.changedFiles ?? 0,
}
}
export function checkStatus(state: string): CheckStatus {
switch (state.toUpperCase()) {
case "SUCCESS":
case "NEUTRAL":
return "success"
case "FAILURE":
case "ERROR":
case "ACTION_REQUIRED":
return "failure"
case "PENDING":
case "QUEUED":
case "IN_PROGRESS":
case "REQUESTED":
case "WAITING":
return "pending"
case "SKIPPED":
return "skipped"
case "CANCELLED":
case "TIMED_OUT":
case "STALE":
case "STARTUP_FAILURE":
return "cancelled"
default:
return "pending"
}
}
export function formatCheckDuration(startedAt?: string, completedAt?: string): string | undefined {
if (!startedAt || !completedAt) return undefined
const secs = Math.round((new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 1000)
return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`
}
const REVIEWER_STATE: Record<string, ReviewerState> = {
APPROVED: "approved",
CHANGES_REQUESTED: "changes_requested",
COMMENTED: "commented",
}
export function parseComments(threads: GhThread[]): PRComment[] {
const items: PRComment[] = []
for (const thread of threads) {
const first = thread.comments?.nodes?.[0]
if (!first) continue
items.push({
id: first.id,
author: first.author?.login ?? "unknown",
avatar: first.author?.avatarUrl,
body: first.body ?? "",
file: first.path,
line: first.line,
url: first.url,
resolved: thread.isResolved ?? false,
createdAt: first.createdAt ? new Date(first.createdAt).getTime() : undefined,
})
}
return items
}
export function parseReviewers(requests: GhReviewRequest[], reviews: GhReview[]): PRReviewer[] {
const map = new Map<string, PRReviewer>()
for (const node of requests) {
const user = node.requestedReviewer
if (!user?.login) continue
map.set(user.login, { login: user.login, avatar: user.avatarUrl, state: "pending" })
}
for (const node of reviews) {
const login = node.author?.login
if (!login) continue
const state = REVIEWER_STATE[node.state ?? ""] ?? "pending"
if (!map.has(login) || state !== "commented") {
map.set(login, { login, avatar: node.author?.avatarUrl, state })
}
}
return [...map.values()]
}
@@ -31,6 +31,7 @@ export class PRStatusBridge {
readonly poller: PRStatusPoller
private readonly cache = new Map<string, AgentManagerOutMessage>()
private readonly host: PRBridgeHost
private lastErrorNotified: "gh_missing" | "gh_auth" | "fetch_failed" | undefined
constructor(host: PRBridgeHost) {
this.host = host
@@ -61,6 +62,8 @@ export class PRStatusBridge {
/** Replay cached PR statuses to a freshly-connected webview. */
replay(): void {
this.cache.forEach((msg) => this.host.postToWebview(msg))
if (this.lastErrorNotified === "gh_auth" || this.lastErrorNotified === "gh_missing")
this.host.postToWebview({ type: "agentManager.prError", error: this.lastErrorNotified } as AgentManagerOutMessage)
}
snapshot(): Map<string, PRStatus> {
@@ -78,8 +81,8 @@ export class PRStatusBridge {
return true
}
if (m.type === "agentManager.openPR") {
const wt = this.host.getWorktrees().find((w: Worktree) => w.id === m.worktreeId)
if (wt?.prUrl) this.host.openExternal(wt.prUrl)
const url = (m.url as string) ?? this.host.getWorktrees().find((w: Worktree) => w.id === m.worktreeId)?.prUrl
if (url) this.host.openExternal(url)
return true
}
return false
@@ -93,6 +96,13 @@ export class PRStatusBridge {
reset(): void {
this.poller.stop()
this.cache.clear()
this.lastErrorNotified = undefined
}
notifyError(err: "gh_missing" | "gh_auth" | "fetch_failed"): void {
if (this.lastErrorNotified === err) return
this.lastErrorNotified = err
this.host.postToWebview({ type: "agentManager.prError", error: err } as AgentManagerOutMessage)
}
}
@@ -115,10 +125,15 @@ function bridgePollerOpts(bridge: PRStatusBridge, host: PRBridgeHost) {
pr: null,
error: err,
} as AgentManagerOutMessage)
// Always forward auth/missing errors so the webview can show a toast,
// regardless of whether prior data exists. Deduplicate per error type
// so multiple failing worktrees don't produce multiple toasts.
if (err === "gh_auth" || err === "gh_missing") bridge.notifyError(err)
return
}
const msg = { type: "agentManager.prStatus", worktreeId: id, pr, error: err } as AgentManagerOutMessage
bridge["cache"].set(id, msg)
bridge["lastErrorNotified"] = undefined
host.postToWebview(msg)
host.updateWorktreePR(id, pr?.number, pr?.url, pr?.state)
},
@@ -71,9 +71,18 @@ export interface PRComment {
createdAt?: number
}
export type ReviewerState = "approved" | "changes_requested" | "pending" | "commented"
export interface PRReviewer {
login: string
avatar?: string
state: ReviewerState
}
export interface PRStatus {
number: number
title: string
body?: string
url: string
state: PRState
review: ReviewDecision | null
@@ -83,12 +92,13 @@ export interface PRStatus {
passed: number
failed: number
pending: number
items: PRCheck[]
checks: PRCheck[]
}
reviewers: PRReviewer[]
comments?: {
total: number
unresolved: number
items: PRComment[]
comments: PRComment[]
}
additions: number
deletions: number
@@ -379,6 +389,11 @@ interface PRStatusOutMessage {
error?: "gh_missing" | "gh_auth" | "fetch_failed"
}
interface PRErrorOutMessage {
type: "agentManager.prError"
error: "gh_missing" | "gh_auth" | "fetch_failed"
}
interface ActionOutMessage {
type: "action"
action: string
@@ -418,6 +433,7 @@ export type AgentManagerOutMessage =
| RevertWorktreeFileResultMessage
| DiffBranchesMessage
| PRStatusOutMessage
| PRErrorOutMessage
| ActionOutMessage
| RunStatusMessage
| TerminalCreatedMessage
@@ -739,6 +755,7 @@ interface OpenPRIn {
type: "agentManager.openPR"
projectId?: string
worktreeId: string
url?: string
}
interface OpenSessionsIn {
@@ -450,7 +450,7 @@ export type WebviewMessage =
| { type: "sessionUpdated"; session: ReturnType<typeof sessionToWebview> }
| { type: "sessionDeleted"; sessionID: string }
| { type: "messageRemoved"; sessionID: string; messageID: string }
| { type: "sessionError"; sessionID?: string; error?: unknown }
| { type: "sessionError"; eventID: string; sessionID?: string; error?: unknown }
| {
type: "sandboxStatus"
sessionID: string
@@ -620,6 +620,7 @@ export function mapSSEEventToWebviewMessage(event: StreamEvent, sessionID: strin
case "session.error": {
return {
type: "sessionError",
eventID: event.id,
sessionID: event.properties.sessionID,
error: event.properties.error,
}
@@ -38,6 +38,7 @@ const TSX_FILES = [
path.join(ROOT, "webview-ui/agent-manager/MultiModelSelector.tsx"),
path.join(ROOT, "webview-ui/agent-manager/ApplyDialog.tsx"),
path.join(ROOT, "webview-ui/agent-manager/WorktreeItem.tsx"),
path.join(ROOT, "webview-ui/agent-manager/pr/PRBadge.tsx"),
path.join(ROOT, "webview-ui/agent-manager/SectionHeader.tsx"),
path.join(ROOT, "webview-ui/agent-manager/SidebarSectionHeader.tsx"),
path.join(ROOT, "webview-ui/agent-manager/SidebarSearchMenu.tsx"),
@@ -86,8 +86,8 @@ describe("Agent Manager orchestration domain", () => {
url: "https://example.com/pr/42",
state: "open",
review: "approved",
checks: { status: "success", total: 1, passed: 1, failed: 0, pending: 0, items: [] },
comments: { total: 2, unresolved: 1, items: [] },
checks: { status: "success", total: 1, passed: 1, failed: 0, pending: 0, checks: [] },
comments: { total: 2, unresolved: 1, comments: [], reviewers: [] },
additions: 10,
deletions: 2,
files: 1,
@@ -0,0 +1,188 @@
import { describe, expect, it } from "bun:test"
import { PRStatusBridge } from "../../src/agent-manager/pr-status-bridge"
import type { AgentManagerOutMessage, PRStatus } from "../../src/agent-manager/types"
const pr: PRStatus = {
number: 1,
title: "my PR",
url: "https://github.com/x/y/pull/1",
state: "open",
review: null,
checks: { status: "none", total: 0, passed: 0, failed: 0, pending: 0, checks: [] },
additions: 0,
deletions: 0,
files: 0,
}
function harness(opts: { hasPersisted?: boolean } = {}) {
const sent: AgentManagerOutMessage[] = []
const worktrees: { id: string; prUrl?: string }[] = []
const bridge = PRStatusBridge.create({
getWorktrees: () => worktrees as never,
getWorkspaceRoot: () => "/repo",
postToWebview: (msg) => sent.push(msg),
updateWorktreePR: () => {},
hasPersistedPR: () => opts.hasPersisted ?? false,
openExternal: () => {},
log: () => {},
})
const onStatus = (bridge.poller as unknown as { options: { onStatus: (...a: unknown[]) => void } }).options.onStatus
return { bridge, sent, onStatus }
}
// --- error deduplication ---
describe("PRStatusBridge.notifyError", () => {
it("sends the first error notification", () => {
const { bridge, sent } = harness()
bridge.notifyError("gh_missing")
expect(sent).toHaveLength(1)
expect(sent[0]).toEqual(expect.objectContaining({ type: "agentManager.prError", error: "gh_missing" }))
})
it("deduplicates the same error type", () => {
const { bridge, sent } = harness()
bridge.notifyError("gh_auth")
bridge.notifyError("gh_auth")
expect(sent).toHaveLength(1)
})
it("sends again when error type changes", () => {
const { bridge, sent } = harness()
bridge.notifyError("gh_missing")
bridge.notifyError("gh_auth")
expect(sent).toHaveLength(2)
})
})
// --- onStatus cache suppression ---
describe("PRStatusBridge onStatus", () => {
it("forwards a successful status to the webview", () => {
const { sent, onStatus } = harness()
onStatus("wt1", pr)
expect(sent).toHaveLength(1)
expect(sent[0]).toEqual(expect.objectContaining({ type: "agentManager.prStatus", worktreeId: "wt1", pr }))
})
it("forwards pr:null error when no cache entry and no persisted PR", () => {
const { sent, onStatus } = harness()
onStatus("wt1", null, "fetch_failed")
expect(sent).toHaveLength(1)
expect(sent[0]).toEqual(expect.objectContaining({ type: "agentManager.prStatus", worktreeId: "wt1", pr: null }))
})
it("suppresses pr:null error when cache entry exists", () => {
const { sent, onStatus } = harness()
onStatus("wt1", pr)
sent.length = 0
onStatus("wt1", null, "fetch_failed")
expect(sent).toHaveLength(0)
})
it("suppresses pr:null error when persisted PR exists", () => {
const { sent, onStatus } = harness({ hasPersisted: true })
onStatus("wt1", null, "fetch_failed")
expect(sent).toHaveLength(0)
})
it("forwards gh_auth error even when cache entry exists", () => {
const { sent, onStatus } = harness()
onStatus("wt1", pr)
sent.length = 0
onStatus("wt1", null, "gh_auth")
const errorMsg = sent.find((m) => m.type === "agentManager.prError")
expect(errorMsg).toEqual(expect.objectContaining({ error: "gh_auth" }))
})
it("forwards gh_missing error even when cache entry exists", () => {
const { sent, onStatus } = harness()
onStatus("wt1", pr)
sent.length = 0
onStatus("wt1", null, "gh_missing")
const errorMsg = sent.find((m) => m.type === "agentManager.prError")
expect(errorMsg).toEqual(expect.objectContaining({ error: "gh_missing" }))
})
})
// --- replay ---
describe("PRStatusBridge.replay", () => {
it("replays cached status messages", () => {
const { bridge, sent, onStatus } = harness()
onStatus("wt1", pr)
sent.length = 0
bridge.replay()
expect(sent).toHaveLength(1)
expect(sent[0]).toEqual(expect.objectContaining({ type: "agentManager.prStatus", worktreeId: "wt1" }))
})
it("replays the last auth error on reconnect", () => {
const { bridge, sent, onStatus } = harness()
onStatus("wt1", null, "gh_auth")
sent.length = 0
bridge.replay()
expect(
sent.some((m) => m.type === "agentManager.prError" && (m as never as { error: string }).error === "gh_auth"),
).toBe(true)
})
it("does not replay fetch_failed errors", () => {
const { bridge, sent, onStatus } = harness()
onStatus("wt1", null, "fetch_failed")
sent.length = 0
bridge.replay()
expect(sent).toHaveLength(0)
})
})
// --- snapshot ---
describe("PRStatusBridge.snapshot", () => {
it("returns only entries with a non-null pr", () => {
const { bridge, onStatus } = harness()
onStatus("wt1", pr)
onStatus("wt2", pr)
expect(bridge.snapshot().size).toBe(2)
})
it("excludes entries where pr was null", () => {
const { bridge, onStatus } = harness({ hasPersisted: true })
onStatus("wt1", null, "fetch_failed")
expect(bridge.snapshot().size).toBe(0)
})
})
// --- remove / reset ---
describe("PRStatusBridge.remove", () => {
it("removes a cached entry so it is no longer replayed", () => {
const { bridge, sent, onStatus } = harness()
onStatus("wt1", pr)
bridge.remove("wt1")
sent.length = 0
bridge.replay()
expect(sent).toHaveLength(0)
})
})
describe("PRStatusBridge.reset", () => {
it("clears cache and error state so replay sends nothing", () => {
const { bridge, sent, onStatus } = harness()
onStatus("wt1", pr)
bridge.notifyError("gh_auth")
bridge.reset()
sent.length = 0
bridge.replay()
expect(sent).toHaveLength(0)
})
it("allows the same error to be sent again after reset", () => {
const { bridge, sent } = harness()
bridge.notifyError("gh_auth")
bridge.reset()
sent.length = 0
bridge.notifyError("gh_auth")
expect(sent).toHaveLength(1)
})
})
@@ -0,0 +1,324 @@
import { describe, expect, it } from "bun:test"
import {
parsePRResult,
checkStatus,
formatCheckDuration,
parseComments,
parseReviewers,
} from "../../src/agent-manager/am-pr-utils"
import type { GhThread, GhReviewRequest, GhReview } from "../../src/agent-manager/am-pr-types"
// --- parsePRResult ---
describe("parsePRResult", () => {
it("returns null when number is missing", () => {
expect(parsePRResult(JSON.stringify({ title: "foo" }))).toBeNull()
})
it("parses an open PR", () => {
const raw = {
number: 42,
title: "my PR",
body: "desc",
url: "https://github.com/x/y/pull/42",
state: "OPEN",
isDraft: false,
reviewDecision: null,
additions: 10,
deletions: 3,
changedFiles: 2,
}
expect(parsePRResult(JSON.stringify(raw))).toEqual({
number: 42,
title: "my PR",
body: "desc",
url: "https://github.com/x/y/pull/42",
state: "open",
review: null,
additions: 10,
deletions: 3,
files: 2,
})
})
it("maps isDraft to draft state regardless of gh state field", () => {
const raw = {
number: 1,
title: "",
body: "",
url: "",
state: "OPEN",
isDraft: true,
reviewDecision: null,
additions: 0,
deletions: 0,
changedFiles: 0,
}
expect(parsePRResult(JSON.stringify(raw))?.state).toBe("draft")
})
it("maps MERGED state", () => {
const raw = {
number: 1,
title: "",
body: "",
url: "",
state: "MERGED",
isDraft: false,
reviewDecision: null,
additions: 0,
deletions: 0,
changedFiles: 0,
}
expect(parsePRResult(JSON.stringify(raw))?.state).toBe("merged")
})
it("maps CLOSED state", () => {
const raw = {
number: 1,
title: "",
body: "",
url: "",
state: "CLOSED",
isDraft: false,
reviewDecision: null,
additions: 0,
deletions: 0,
changedFiles: 0,
}
expect(parsePRResult(JSON.stringify(raw))?.state).toBe("closed")
})
it("maps APPROVED review decision", () => {
const raw = {
number: 1,
title: "",
body: "",
url: "",
state: "OPEN",
isDraft: false,
reviewDecision: "APPROVED",
additions: 0,
deletions: 0,
changedFiles: 0,
}
expect(parsePRResult(JSON.stringify(raw))?.review).toBe("approved")
})
it("maps CHANGES_REQUESTED review decision", () => {
const raw = {
number: 1,
title: "",
body: "",
url: "",
state: "OPEN",
isDraft: false,
reviewDecision: "CHANGES_REQUESTED",
additions: 0,
deletions: 0,
changedFiles: 0,
}
expect(parsePRResult(JSON.stringify(raw))?.review).toBe("changes_requested")
})
it("maps REVIEW_REQUIRED review decision to pending", () => {
const raw = {
number: 1,
title: "",
body: "",
url: "",
state: "OPEN",
isDraft: false,
reviewDecision: "REVIEW_REQUIRED",
additions: 0,
deletions: 0,
changedFiles: 0,
}
expect(parsePRResult(JSON.stringify(raw))?.review).toBe("pending")
})
it("returns null review for unknown decision", () => {
const raw = {
number: 1,
title: "",
body: "",
url: "",
state: "OPEN",
isDraft: false,
reviewDecision: "SOMETHING_ELSE",
additions: 0,
deletions: 0,
changedFiles: 0,
}
expect(parsePRResult(JSON.stringify(raw))?.review).toBeNull()
})
it("defaults missing fields to empty strings and zeros", () => {
const result = parsePRResult(JSON.stringify({ number: 5 }))
expect(result).toEqual(
expect.objectContaining({ title: "", body: "", url: "", additions: 0, deletions: 0, files: 0 }),
)
})
})
// --- checkStatus ---
describe("checkStatus", () => {
it("maps SUCCESS", () => expect(checkStatus("SUCCESS")).toBe("success"))
it("maps FAILURE", () => expect(checkStatus("FAILURE")).toBe("failure"))
it("maps ERROR to failure", () => expect(checkStatus("ERROR")).toBe("failure"))
it("maps PENDING", () => expect(checkStatus("PENDING")).toBe("pending"))
it("maps QUEUED to pending", () => expect(checkStatus("QUEUED")).toBe("pending"))
it("maps IN_PROGRESS to pending", () => expect(checkStatus("IN_PROGRESS")).toBe("pending"))
it("maps REQUESTED to pending", () => expect(checkStatus("REQUESTED")).toBe("pending"))
it("maps WAITING to pending", () => expect(checkStatus("WAITING")).toBe("pending"))
it("maps SKIPPED", () => expect(checkStatus("SKIPPED")).toBe("skipped"))
it("maps CANCELLED", () => expect(checkStatus("CANCELLED")).toBe("cancelled"))
it("maps TIMED_OUT to cancelled", () => expect(checkStatus("TIMED_OUT")).toBe("cancelled"))
it("maps STALE to cancelled", () => expect(checkStatus("STALE")).toBe("cancelled"))
it("maps STARTUP_FAILURE to cancelled", () => expect(checkStatus("STARTUP_FAILURE")).toBe("cancelled"))
it("maps unknown state to pending", () => expect(checkStatus("WHATEVER")).toBe("pending"))
it("is case-insensitive", () => expect(checkStatus("success")).toBe("success"))
})
// --- formatCheckDuration ---
describe("formatCheckDuration", () => {
it("returns undefined when startedAt is missing", () => {
expect(formatCheckDuration(undefined, "2024-01-01T00:01:00Z")).toBeUndefined()
})
it("returns undefined when completedAt is missing", () => {
expect(formatCheckDuration("2024-01-01T00:00:00Z", undefined)).toBeUndefined()
})
it("formats sub-minute durations in seconds", () => {
expect(formatCheckDuration("2024-01-01T00:00:00Z", "2024-01-01T00:00:45Z")).toBe("45s")
})
it("formats durations over a minute as m/s", () => {
expect(formatCheckDuration("2024-01-01T00:00:00Z", "2024-01-01T00:02:30Z")).toBe("2m 30s")
})
it("formats exactly 60 seconds as 1m 0s", () => {
expect(formatCheckDuration("2024-01-01T00:00:00Z", "2024-01-01T00:01:00Z")).toBe("1m 0s")
})
})
// --- parseComments ---
describe("parseComments", () => {
it("returns empty array for empty threads", () => {
expect(parseComments([])).toEqual([])
})
it("skips threads with no comments", () => {
const threads: GhThread[] = [{ isResolved: false, comments: { nodes: [] } }]
expect(parseComments(threads)).toHaveLength(0)
})
it("parses a resolved thread", () => {
const threads: GhThread[] = [
{
isResolved: true,
comments: {
nodes: [
{
id: "c1",
author: { login: "alice", avatarUrl: "https://avatar" },
body: "looks good",
path: "src/foo.ts",
line: 10,
url: "https://url",
createdAt: "2024-01-01T00:00:00Z",
},
],
},
},
]
expect(parseComments(threads)).toEqual([
{
id: "c1",
author: "alice",
avatar: "https://avatar",
body: "looks good",
file: "src/foo.ts",
line: 10,
url: "https://url",
resolved: true,
createdAt: new Date("2024-01-01T00:00:00Z").getTime(),
},
])
})
it("defaults missing author to 'unknown'", () => {
const threads: GhThread[] = [{ isResolved: false, comments: { nodes: [{ id: "c2", body: "note" }] } }]
expect(parseComments(threads)[0]?.author).toBe("unknown")
})
it("only uses the first comment of each thread", () => {
const threads: GhThread[] = [
{
isResolved: false,
comments: {
nodes: [
{ id: "first", body: "first comment" },
{ id: "second", body: "second comment" },
],
},
},
]
const result = parseComments(threads)
expect(result).toHaveLength(1)
expect(result[0]?.id).toBe("first")
})
})
// --- parseReviewers ---
describe("parseReviewers", () => {
it("returns empty array with no requests or reviews", () => {
expect(parseReviewers([], [])).toEqual([])
})
it("adds pending reviewer from request", () => {
const requests: GhReviewRequest[] = [{ requestedReviewer: { login: "alice", avatarUrl: "https://avatar" } }]
expect(parseReviewers(requests, [])).toEqual([{ login: "alice", avatar: "https://avatar", state: "pending" }])
})
it("skips review requests without a login", () => {
const requests: GhReviewRequest[] = [{ requestedReviewer: {} }]
expect(parseReviewers(requests, [])).toHaveLength(0)
})
it("adds reviewer from review when not in requests", () => {
const reviews: GhReview[] = [{ author: { login: "bob" }, state: "APPROVED" }]
expect(parseReviewers([], reviews)).toEqual([{ login: "bob", avatar: undefined, state: "approved" }])
})
it("upgrades pending request to approved when review arrives", () => {
const requests: GhReviewRequest[] = [{ requestedReviewer: { login: "alice" } }]
const reviews: GhReview[] = [{ author: { login: "alice" }, state: "APPROVED" }]
expect(parseReviewers(requests, reviews)).toEqual([{ login: "alice", avatar: undefined, state: "approved" }])
})
it("does not downgrade approved to commented", () => {
const requests: GhReviewRequest[] = [{ requestedReviewer: { login: "alice" } }]
const reviews: GhReview[] = [
{ author: { login: "alice" }, state: "APPROVED" },
{ author: { login: "alice" }, state: "COMMENTED" },
]
expect(parseReviewers(requests, reviews)[0]?.state).toBe("approved")
})
it("does upgrade pending to changes_requested", () => {
const requests: GhReviewRequest[] = [{ requestedReviewer: { login: "alice" } }]
const reviews: GhReview[] = [{ author: { login: "alice" }, state: "CHANGES_REQUESTED" }]
expect(parseReviewers(requests, reviews)[0]?.state).toBe("changes_requested")
})
it("skips reviews without a login", () => {
const reviews: GhReview[] = [{ author: {}, state: "APPROVED" }]
expect(parseReviewers([], reviews)).toHaveLength(0)
})
})
@@ -292,4 +292,20 @@ describe("validateCustomProvider – variant name validation", () => {
const saved = out.result!.config.models["model-1"] as Record<string, unknown>
expect(saved.modalities).toEqual({ input: ["text", "audio", "video", "pdf"], output: ["text", "audio"] })
})
it("handles multiple models with reasoning and images toggled", () => {
const form = base()
form.models = [
{ id: "m1", name: "Model 1", reasoning: true, supportsImages: true, modalities: {}, variants: [] },
{ id: "m2", name: "Model 2", reasoning: true, supportsImages: false, modalities: {}, variants: [] },
]
const out = validateCustomProvider(args(form))
expect(out.result).toBeDefined()
const m1 = out.result!.config.models["m1"] as Record<string, unknown>
const m2 = out.result!.config.models["m2"] as Record<string, unknown>
expect(m1.reasoning).toBe(true)
expect(m1.modalities).toEqual({ input: ["text", "image"] })
expect(m2.reasoning).toBe(true)
expect(m2.modalities).toBeUndefined()
})
})
@@ -127,6 +127,43 @@ describe("KiloProvider memory events", () => {
expect(calls).toEqual(["/repo", "/repo"])
})
it("refreshes status without forwarding transient memory errors", async () => {
const calls: string[] = []
const posts: unknown[] = []
const client = {
memory: {
status: async (input: { directory: string }) => {
calls.push(input.directory)
return { data: status(input.directory) }
},
},
} as unknown as KiloClient
const provider = new KiloProvider(
{} as never,
{
getClient: () => client,
} as never,
)
const item = provider as unknown as Internals
item.webview = { postMessage: async (message) => posts.push(message) }
item.currentSession = { id: "ses_active" }
item.trackedSessionIds.add("ses_active")
provider.setSessionDirectory("ses_active", "/repo")
item.handleEvent(
{
type: "memory.error",
properties: { sessionID: "ses_active", reason: "transient" },
},
"/repo",
)
await item.memory.idle()
expect(posts).not.toContainEqual(expect.objectContaining({ type: "memoryEvent" }))
expect(posts).toContainEqual(expect.objectContaining({ type: "memoryLoaded", sessionID: "ses_active" }))
expect(calls).toEqual(["/repo"])
})
it("uses the project directory when toggling memory", async () => {
const calls: unknown[] = []
const client = {
@@ -21,6 +21,7 @@ import type {
Event,
EventSessionStatus,
EventSessionTurnClose,
EventSessionError,
EventSandboxStatusChanged,
EventPermissionAsked,
EventPermissionReplied,
@@ -375,6 +376,30 @@ describe("mapSSEEventToWebviewMessage", () => {
expect(msg).toEqual({ type: "sessionTurnClosed", sessionID: "sess-1", reason: "interrupted" })
})
it("maps session errors with their event identity and message", () => {
const event: EventSessionError = {
id: "evt-error",
type: "session.error",
properties: {
sessionID: "sess-1",
error: {
name: "APIError",
data: {
message: "prompt_cache_breakpoint is not supported on this model",
isRetryable: false,
},
},
},
}
expect(mapSSEEventToWebviewMessage(event, "sess-1")).toEqual({
type: "sessionError",
eventID: "evt-error",
sessionID: "sess-1",
error: event.properties.error,
})
})
it("maps permission.asked to permissionRequest", () => {
const event: EventPermissionAsked = {
type: "permission.asked",
@@ -18,6 +18,11 @@ describe("fmtPrice", () => {
expect(fmtPrice(0)).toBe("Free")
})
it("returns dash for negative price", () => {
expect(fmtPrice(-1)).toBe("—")
expect(fmtPrice(-1000000)).toBe("—")
})
it("uses 4 decimal places for sub-cent prices", () => {
expect(fmtPrice(0.005)).toBe("$0.0050/1M")
})
@@ -1,5 +1,10 @@
import { describe, expect, it } from "bun:test"
import { errorIDs, visibleError } from "../../webview-ui/src/context/session-errors"
import {
errorIDs,
preserveSessionErrors,
visibleError,
withoutResolvedSessionErrors,
} from "../../webview-ui/src/context/session-errors"
import type { Message } from "../../webview-ui/src/types/messages"
const base = {
@@ -45,3 +50,32 @@ describe("visibleError", () => {
expect(visibleError(messages, () => false)).toBeUndefined()
})
})
describe("session error reconciliation", () => {
const error = {
name: "APIError",
data: { message: "prompt_cache_breakpoint is not supported on this model", isRetryable: false },
}
it("preserves transient errors across stale history replacements", () => {
const user = { ...base, id: "message_1", role: "user" as const }
const transient = { ...assistant("message_2", error), parentID: user.id, sessionErrorID: "event_1" }
expect(preserveSessionErrors([user, transient], [user])).toEqual([user, transient])
})
it("replaces a transient error with its persisted assistant message", () => {
const transient = { ...assistant("message_2", error), parentID: "message_1", sessionErrorID: "event_1" }
const persisted = { ...assistant("message_3", error), parentID: "message_1" }
expect(preserveSessionErrors([transient], [persisted])).toEqual([persisted])
expect(withoutResolvedSessionErrors([transient], [persisted])).toEqual([])
})
it("deduplicates repeated delivery of the same session error event", () => {
const first = { ...assistant("message_2", error), parentID: "message_1", sessionErrorID: "event_1" }
const repeat = { ...assistant("message_3", error), parentID: "message_1", sessionErrorID: "event_1" }
expect(withoutResolvedSessionErrors([first], [repeat])).toEqual([])
})
})
@@ -36,6 +36,7 @@ import type {
RunStatus,
PRStatus,
AgentManagerPRStatusMessage,
AgentManagerPRErrorMessage,
AgentManagerProjectsMessage,
AgentProjectSnapshot,
ManagedSessionState,
@@ -143,6 +144,7 @@ import {
import { focusCurrentTab, renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering"
import { useTabScroll } from "./tab-scroll"
import { DiffPanel } from "./DiffPanel"
import { PRPanel } from "./pr/PRPanel"
import { createRevertFile } from "./revert-file"
import { FullScreenDiffView } from "../diff-viewer/FullScreenDiffView"
import { createApplyToLocal } from "./apply-to-local"
@@ -152,6 +154,8 @@ import { clearReviewComposer, createReviewComposer } from "../diff-viewer/review
import type { SidebarSearchMenuRef } from "./SidebarSearchMenu"
import { createSidebarSearch, type SidebarSearchItem } from "./sidebar-search"
import { randomColor } from "./section-colors"
import { createMarkdownRender } from "./review-preferences"
import { createSidebarCollapse } from "./sidebar-collapse"
import { createNewTaskDrafts } from "./new-task-drafts"
import {
buildTopLevelItems,
@@ -170,11 +174,9 @@ import { DiffScopeControls } from "../diff-viewer/DiffScopeControls"
import { scopeCapabilities } from "./diff-scope-state"
import { createDiffReviewScope } from "./diff-review-scope"
import { initialMessage, seedInitialVariant } from "./initial-message"
import { createMarkdownRender } from "./review-preferences"
import { createSidebarCollapse } from "./sidebar-collapse"
import { SidebarToggleButton } from "./SidebarToggleButton"
import { setTabWidths } from "./tab-widths"
import { clampPanelWidth, createPanelResize, maxPanelWidth, minPanelWidth } from "./side-panel-layout"
import { clampPanelWidth, createPanelResize, maxPanelWidth, minPanelWidth, SidePanel } from "./side-panel-layout"
import { buildShortcutCategories } from "./shortcuts"
import { tracker } from "./telemetry"
import { createChatFocus, createPromptFocus, hasQuestionOption } from "./focus"
@@ -196,7 +198,7 @@ interface SetupState {
/** Sidebar selection: LOCAL for local repo, worktree ID for a worktree, or null for an unassigned session. */
type SidebarSelection = typeof LOCAL | string | null
type SidePanel = "diff" | "pr" | "terminal" | null
export type SidePanelState = SidePanel | null
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
import { parseBindingTokens } from "./keybind-tokens"
@@ -288,8 +290,16 @@ const AgentManagerContent: Component = () => {
let pendingSidebarWidth: number | undefined
const [history, setHistory] = createSignal(false)
const [sidePanel, setSidePanel] = createSignal<SidePanel>(null)
const diffOpen = () => sidePanel() === "diff"
const [sidePanel, setSidePanel] = createSignal<SidePanelState>(null)
const diffOpen = () => sidePanel() === SidePanel.Diff
const prOpen = () => sidePanel() === SidePanel.PR
const activePR = createMemo(() => {
const selected = selection()
if (!selected || selected === LOCAL) return undefined
const pr = prStatuses()[selected]
if (!pr) return undefined
return { pr, selected, wt: worktrees().find((w) => w.id === selected) }
})
const diffs = createWorktreeDiffs(vscode)
const diffDatas = diffs.diffDatas
const diffLoading = diffs.diffLoading
@@ -302,7 +312,7 @@ const AgentManagerContent: Component = () => {
const showSideTerminal = () => {
setHistory(false)
setReviewActive(false)
setSidePanel("terminal")
setSidePanel(SidePanel.Terminal)
}
const [reviewOpenByContext, setReviewOpenByContext] = createSignal<Record<string, boolean>>({})
@@ -314,7 +324,6 @@ const AgentManagerContent: Component = () => {
// Per-worktree git stats (diff additions/deletions, commits missing from origin)
const worktreeStats = () => registry.active().worktreeStats()
// Per-worktree PR status data
const prStatuses = () => registry.active().prStatuses()
const runStatuses = () => registry.active().runStatuses()
@@ -392,7 +401,7 @@ const AgentManagerContent: Component = () => {
const rememberPromptFocus = (focused: boolean) => {
if (focused) focusMemory.set(focusKey(), "prompt")
}
const terminalVisible = () => sidePanel() === "terminal" && !history() && !reviewActive()
const terminalVisible = () => sidePanel() === SidePanel.Terminal && !history() && !reviewActive()
const focusOnDraftChange = () => {
const key = focusKey()
const owner = focusMemory.get(key)
@@ -461,6 +470,15 @@ const AgentManagerContent: Component = () => {
}
createEffect(on(selection, () => cancelPendingDelete(), { defer: true }))
createEffect(on(selection, () => clearReviewComposer(reviewComposer), { defer: true }))
createEffect(
on(
selection,
() => {
if (!activePR()) setSidePanel((p) => (p === SidePanel.PR ? null : p))
},
{ defer: true },
),
)
onCleanup(() => clearTimeout(pendingDeleteTimer))
// Per-context tab memory lives in the active project's store: maps sidebar
@@ -518,11 +536,17 @@ const AgentManagerContent: Component = () => {
}
const openWindow = metrics.click("open_worktree_window", "tab_toolbar", openWorktreeDirectory)
const togglePRPanel = () => {
setHistory(false)
if (reviewActive()) closeReviewTab()
setSidePanel((prev) => (prev === SidePanel.PR ? null : SidePanel.PR))
}
const openSelectedPR = () => {
const sel = selection()
if (!sel || sel === LOCAL || !prStatuses()[sel]) return
metrics.track("open_pull_request", "keyboard_shortcut")
vscode.postMessage({ type: "agentManager.openPR", worktreeId: sel })
togglePRPanel()
}
const runWorktree = (id: string, destination: TerminalDestination) => {
@@ -1154,8 +1178,8 @@ const AgentManagerContent: Component = () => {
} else if (msg.action === "toggleDiff") {
if (reviewActive()) {
closeReviewTab()
setSidePanel("diff")
} else setSidePanel((prev) => (prev === "diff" ? null : "diff"))
setSidePanel(SidePanel.Diff)
} else setSidePanel((prev) => (prev === SidePanel.Diff ? null : SidePanel.Diff))
} else if (msg.action === "newTab") handleNewTabForCurrentSelection()
else if (msg.action === "closeTab") closeActiveTab()
else if (msg.action === "newWorktree") showNewWorktreeDialog()
@@ -1316,6 +1340,7 @@ 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),
onSideClosed: (_contextKey, terminalId) => forgetTerminalFocus(terminalId),
onScriptRunning: (contextKey, terminalId) => {
if (terms.sideKey() !== contextKey) return
@@ -1383,7 +1408,7 @@ const AgentManagerContent: Component = () => {
if (!isActivePayload(ev.projectId)) return
// Close diff/review panels — nothing to show during setup.
// Terminal panels keep live setup output, so they stay open.
if (sidePanel() === "diff") setSidePanel(null)
if (sidePanel() === SidePanel.Diff) setSidePanel(null)
setReviewActive(false)
setSetup({ active: true, message: ev.message, branch: ev.branch, worktreeId: ev.worktreeId })
}
@@ -1533,6 +1558,15 @@ const AgentManagerContent: Component = () => {
managedSession: focusManagedSession,
})
if (msg.type === "agentManager.prError") {
const ev = msg as AgentManagerPRErrorMessage
showToast({
variant: "error",
title: t(`agentManager.pr.error.${ev.error}.title`),
description: t(`agentManager.pr.error.${ev.error}.description`),
})
}
if (projectLive.apply(msg)) return
})
@@ -1982,7 +2016,7 @@ const AgentManagerContent: Component = () => {
const sideCtl = createSideTerminal({
handlers: termHandlers,
visible: () => sidePanel() === "terminal" && !history() && !reviewActive(),
visible: () => sidePanel() === SidePanel.Terminal && !history() && !reviewActive(),
focusedId: () => terms.sideFocusedId(),
count: () => terms.sidesForContext(terms.sideKey()).length,
isScript: terms.isScript,
@@ -2115,7 +2149,7 @@ const AgentManagerContent: Component = () => {
const closeActiveTab = () => {
// A focused side terminal owns Cmd+W while its panel is visible.
// Closing a chat tab out from under the user's cursor would be surprising.
if (sidePanel() === "terminal" && terms.sideFocusedId()) {
if (sidePanel() === SidePanel.Terminal && terms.sideFocusedId()) {
if (sideCtl.close()) return
}
if (termHandlers.closeFocused()) {
@@ -2171,10 +2205,10 @@ const AgentManagerContent: Component = () => {
})
if (reviewActive()) {
closeReviewTab()
setSidePanel("diff")
setSidePanel(SidePanel.Diff)
return
}
setSidePanel((prev) => (prev === "diff" ? null : "diff"))
setSidePanel((prev) => (prev === SidePanel.Diff ? null : SidePanel.Diff))
}
const renderTabById = (id: string) =>
@@ -2370,8 +2404,11 @@ const AgentManagerContent: Component = () => {
reviewActive={reviewActive}
onToggleDiff={toggleDiffPanel}
onToggleReview={metrics.click("fullscreen_review", "tab_toolbar", toggleReviewTab)}
prStatus={() => activePR()?.pr}
prOpen={prOpen}
onTogglePR={togglePRPanel}
terminalDestination={sideCtl.destination}
terminalDestinationActive={() => sidePanel() === "terminal"}
terminalDestinationActive={() => sidePanel() === SidePanel.Terminal}
terminalKeybind={() => kb().showTerminal ?? ""}
onTerminalDestinationOpen={() => {
cancelAmbientSetup()
@@ -2559,7 +2596,7 @@ const AgentManagerContent: Component = () => {
/>
</Show>
<div class="am-diff-panel-wrapper">
<Show when={sidePanel() === "diff"}>
<Show when={sidePanel() === SidePanel.Diff}>
<DiffPanel
diffs={reviewDiffs()}
loading={diffLoading()}
@@ -2594,10 +2631,29 @@ const AgentManagerContent: Component = () => {
activeTerminalId={terms.activeId()}
/>
</Show>
<Show when={sidePanel() === SidePanel.PR && activePR()}>
{(() => {
const data = activePR()!
return (
<PRPanel
pr={data.pr}
worktree={data.wt}
onClose={() => setSidePanel(null)}
onOpenExternal={() =>
vscode.postMessage({
type: "agentManager.openPR",
worktreeId: data.selected,
url: data.pr.url,
})
}
/>
)
})()}
</Show>
<SideTerminalPanel
state={terms}
contextKey={terms.sideKey}
visible={() => sidePanel() === "terminal"}
visible={() => sidePanel() === SidePanel.Terminal}
nextKeybind={kb().nextTerminal ?? ""}
closeKeybind={kb().closeTab ?? ""}
onFocusPrompt={prompt.focus}
@@ -13,7 +13,7 @@ import {
closestCenter,
type DragEvent,
} from "@thisbeyond/solid-dnd"
import type { LocalGitStats, RunStatus, WorktreeGitStats } from "../src/types/messages"
import type { LocalGitStats, RunStatus, WorktreeGitStats, PRStatus } from "../src/types/messages"
import type { LanguageContextValue } from "../src/context/language"
import { LOCAL } from "./navigate"
import { ConstrainDragYAxis } from "../src/components/chat/TabDnd"
@@ -55,6 +55,9 @@ export interface TabBarProps {
reviewActive: () => boolean
onToggleDiff: () => void
onToggleReview: () => void
prStatus: () => PRStatus | undefined
prOpen: () => boolean
onTogglePR: () => void
terminalDestination: () => TerminalDestination
terminalDestinationActive: () => boolean
terminalKeybind: () => string
@@ -215,6 +218,20 @@ export const TabBar: Component<TabBarProps> = (props) => (
)
})()}
</Show>
<Show when={props.prStatus()}>
{(pr) => (
<Tooltip value={`PR #${pr().number}`} placement="bottom">
<IconButton
icon="pull-request"
size="small"
variant="ghost"
label={`PR #${pr().number}`}
class={props.prOpen() ? "am-tab-diff-btn-active" : ""}
onClick={props.onTogglePR}
/>
</Tooltip>
)}
</Show>
<TooltipKeybind
title={props.t("agentManager.diff.toggle")}
keybind={props.bindings().toggleDiff ?? ""}
@@ -226,14 +243,19 @@ export const TabBar: Component<TabBarProps> = (props) => (
title={props.t("agentManager.diff.toggle")}
>
<Icon name="layers" size="small" />
<Show when={hasChanges()}>
<span class="am-diff-toggle-stats">
<Show when={stats()!.files > 0}>
<span class="am-stat-files">{stats()!.files}f</span>
<Show when={props.prStatus()}>
{(pr) => (
<Show when={pr().additions > 0 || pr().deletions > 0}>
<span class="am-diff-toggle-stats">
<Show when={pr().additions > 0}>
<span class="am-stat-additions">+{pr().additions}</span>
</Show>
<Show when={pr().deletions > 0}>
<span class="am-stat-deletions">−{pr().deletions}</span>
</Show>
</span>
</Show>
<span class="am-stat-additions">+{stats()!.additions}</span>
<span class="am-stat-deletions">−{stats()!.deletions}</span>
</span>
)}
</Show>
</button>
</TooltipKeybind>
@@ -10,7 +10,8 @@ import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import { HoverCard } from "@kilocode/kilo-ui/hover-card"
import { ContextMenu } from "@kilocode/kilo-ui/context-menu"
import { Button } from "@kilocode/kilo-ui/button"
import type { WorktreeState, WorktreeGitStats, PRStatus, SectionState, RunStatus } from "../src/types/messages"
import type { WorktreeState, WorktreeGitStats, SectionState, RunStatus } from "../src/types/messages"
import type { PRStatus } from "../src/types/messages"
import { colorCss } from "./section-colors"
import { useLanguage } from "../src/context/language"
import { formatRelativeDate } from "../src/utils/date"
@@ -57,7 +58,7 @@ interface WorktreeItemProps {
/** Keybinding string for the open-in-vscode action. */
openKeybind: string
/** PR status for this worktree's branch, or null if no PR. */
pr?: PRStatus | null
pr?: PRStatus
runStatus?: RunStatus
/** Callback when the PR badge is clicked. */
onOpenPR?: () => void
@@ -94,14 +95,6 @@ const hasStats = (s: WorktreeGitStats | undefined): s is WorktreeGitStats =>
* and review results are conveyed by a separate status icon (see prBadgeIndicator)
* so a failing check is not mistaken for a closed PR.
*/
export function prAccentColor(pr: PRStatus): string {
if (pr.state === "draft") return "var(--text-weaker)"
if (pr.state === "merged") return "#a78bfa"
if (pr.state === "closed") return "#f87171"
if (pr.checks.status === "pending") return "#fbbf24"
return "#34d399"
}
/** True while an open PR's checks are still running — drives the pulsing amber badge. */
export function prChecksRunning(pr: PRStatus): boolean {
return pr.state === "open" && pr.checks.status === "pending"
@@ -349,18 +342,30 @@ export const WorktreeItem: Component<WorktreeItemProps> = (props) => {
}
>
{(pr) => {
const accent = () => prAccentColor(pr())
const indicator = () => prBadgeIndicator(pr())
return (
<span
class="am-pr-badge"
style={{ "--pr-accent": accent() }}
data-pending={prChecksRunning(pr()) ? "" : undefined}
classList={{
"am-pr-accent-draft": pr().state === "draft",
"am-pr-accent-merged": pr().state === "merged",
"am-pr-accent-closed": pr().state === "closed",
"am-pr-accent-pending": pr().state === "open" && pr().checks.status === "pending",
"am-pr-accent-open": pr().state === "open" && pr().checks.status !== "pending",
"am-pr-badge-pending": prChecksRunning(pr()),
}}
onClick={handleOpenPR}
>
<Switch fallback={<Icon name="branch" size="small" />}>
<Icon name="pull-request" size="small" />
<span class="am-pr-badge-number">#{pr().number}</span>
<Switch>
<Match when={indicator() === "failure"}>
<Icon name="circle-x" size="small" class="am-pr-badge-status" data-status="failure" />
<Icon
name="circle-x-outline"
size="small"
class="am-pr-badge-status"
data-status="failure"
/>
</Match>
<Match when={indicator() === "changes"}>
<Icon name="warning" size="small" class="am-pr-badge-status" data-status="changes" />
@@ -374,7 +379,6 @@ export const WorktreeItem: Component<WorktreeItemProps> = (props) => {
/>
</Match>
</Switch>
<span class="am-pr-badge-number">#{pr().number}</span>
</span>
)
}}
@@ -840,7 +840,7 @@ html[data-theme="kilo-vscode"]
}
.am-run-badge[data-run-state="stopping"] {
--am-run-accent: #fbbf24;
--am-run-accent: var(--vscode-charts-yellow, #fbbf24);
}
.am-run-badge[data-run-state="success"] {
@@ -848,7 +848,7 @@ html[data-theme="kilo-vscode"]
}
.am-run-badge[data-run-state="error"] {
--am-run-accent: #f87171;
--am-run-accent: var(--vscode-testing-iconFailed, #f87171);
}
@keyframes am-run-pulse {
@@ -898,24 +898,44 @@ html[data-theme="kilo-vscode"]
}
.am-stat-additions {
color: #34d399;
color: var(--vscode-testing-iconPassed, #34d399);
}
.am-stat-deletions {
color: #f87171;
color: var(--vscode-testing-iconFailed, #f87171);
}
/* PR badge accent colors via classList */
.am-pr-accent-open,
.am-pr-panel-badge.am-pr-accent-open {
--am-pr-accent: var(--vscode-testing-iconPassed, #34d399);
}
.am-pr-accent-pending,
.am-pr-panel-badge.am-pr-accent-pending {
--am-pr-accent: var(--vscode-charts-yellow, #fbbf24);
}
.am-pr-accent-draft,
.am-pr-panel-badge.am-pr-accent-draft {
--am-pr-accent: var(--text-weaker);
}
.am-pr-accent-merged,
.am-pr-panel-badge.am-pr-accent-merged {
--am-pr-accent: var(--vscode-charts-purple, var(--vscode-foreground));
}
.am-pr-accent-closed,
.am-pr-panel-badge.am-pr-accent-closed {
--am-pr-accent: var(--vscode-testing-iconFailed, #f87171);
}
/* PR pill badge — second row inside am-wt-row2.
Uses --pr-accent custom property (set inline) to derive all colors. */
.am-pr-badge {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px 7px 2px 4px;
padding: 2px 4px;
border-radius: 6px;
border: none;
background: color-mix(in srgb, var(--pr-accent) 12%, transparent);
color: var(--pr-accent);
background: color-mix(in srgb, var(--am-pr-accent) 12%, transparent);
color: var(--am-pr-accent);
cursor: pointer;
font-size: var(--kilo-font-size-11);
font-weight: 500;
@@ -924,34 +944,34 @@ html[data-theme="kilo-vscode"]
margin-left: auto;
}
.am-pr-badge:hover {
background: color-mix(in srgb, var(--pr-accent) 25%, transparent);
background: color-mix(in srgb, var(--am-pr-accent) 25%, transparent);
}
.am-pr-badge [data-component="icon"] {
width: 12px;
height: 12px;
color: var(--pr-accent);
color: var(--am-pr-accent);
}
.am-pr-badge-number {
line-height: 1;
color: var(--text-muted);
}
.am-pr-badge:hover .am-pr-badge-number {
color: var(--pr-accent);
color: var(--am-pr-accent);
}
/* Status icon overlaid on the badge — colored by CI/review result, independent
of the badge's PR-state accent color. */
.am-pr-badge-status[data-status="failure"] {
color: #ef4444;
color: var(--vscode-testing-iconFailed, #f87171);
}
.am-pr-badge-status[data-status="changes"] {
color: #fbbf24;
color: var(--vscode-charts-yellow, #fbbf24);
}
.am-pr-badge-status[data-status="approved"] {
color: #34d399;
color: var(--vscode-testing-iconPassed, #34d399);
}
/* Checks still running — pulse the whole amber badge. */
.am-pr-badge[data-pending] {
.am-pr-badge.am-pr-badge-pending {
animation: am-pr-pulse 1.5s ease-in-out infinite;
}
@keyframes am-pr-pulse {
@@ -965,17 +985,17 @@ html[data-theme="kilo-vscode"]
}
@media (prefers-reduced-motion: reduce) {
.am-pr-badge[data-pending] {
.am-pr-badge.am-pr-badge-pending {
animation: none;
opacity: 0.9;
}
}
.am-worktree-item-active .am-pr-badge {
background: color-mix(in srgb, var(--pr-accent) 18%, transparent);
background: color-mix(in srgb, var(--am-pr-accent) 18%, transparent);
}
.am-worktree-item-active .am-pr-badge:hover {
background: color-mix(in srgb, var(--pr-accent) 30%, transparent);
background: color-mix(in srgb, var(--am-pr-accent) 30%, transparent);
}
/* Clickable link icon in hover card to open PR on GitHub */
@@ -996,35 +1016,35 @@ html[data-theme="kilo-vscode"]
}
.am-worktree-item-active .am-stat-files {
color: rgba(255, 255, 255, 0.7);
color: var(--text-weak);
}
.am-worktree-item-active .am-stat-additions {
color: #6ee7b7;
color: var(--vscode-testing-iconPassed, #6ee7b7);
}
.am-worktree-item-active .am-stat-deletions {
color: #fca5a5;
color: var(--vscode-testing-iconFailed, #fca5a5);
}
.am-worktree-commits {
color: #34d399;
color: var(--vscode-testing-iconPassed, #34d399);
}
.am-worktree-behind {
color: #f87171;
color: var(--vscode-testing-iconFailed, #f87171);
}
.am-worktree-item-active .am-worktree-commits {
color: #6ee7b7;
color: var(--vscode-testing-iconPassed, #6ee7b7);
}
.am-worktree-item-active .am-worktree-behind {
color: #fca5a5;
color: var(--vscode-testing-iconFailed, #fca5a5);
}
.am-local-item-active .am-worktree-behind {
color: #fca5a5;
color: var(--vscode-testing-iconFailed, #fca5a5);
}
.am-local-item:hover .am-worktree-stats,
@@ -1699,7 +1719,7 @@ body.am-wt-dragging-active * {
}
.am-sidebar-search-status {
color: #fbbf24;
color: var(--vscode-charts-yellow, #fbbf24);
text-transform: uppercase;
}
@@ -1794,9 +1814,10 @@ body.am-wt-dragging-active * {
color: var(--text-base);
}
.am-diff-toggle-btn.am-tab-diff-btn-active {
background: var(--surface-interactive-base) !important;
color: var(--text-base) !important;
.am-diff-toggle-btn.am-tab-diff-btn-active,
.am-diff-toggle-btn.am-tab-diff-btn-active:hover {
background: var(--surface-interactive-base);
color: var(--text-on-interactive-base);
}
.am-diff-toggle-btn.am-diff-toggle-has-changes {
@@ -1810,20 +1831,24 @@ body.am-wt-dragging-active * {
}
.am-diff-toggle-btn.am-tab-diff-btn-active .am-stat-files {
color: rgba(255, 255, 255, 0.7);
color: var(--text-weak);
}
.am-diff-toggle-btn.am-tab-diff-btn-active .am-stat-additions {
color: #6ee7b7;
color: var(--vscode-testing-iconPassed, #6ee7b7);
}
.am-diff-toggle-btn.am-tab-diff-btn-active .am-stat-deletions {
color: #fca5a5;
color: var(--vscode-testing-iconFailed, #fca5a5);
}
.am-tab-diff-btn-active {
background: var(--surface-interactive-base) !important;
color: var(--text-base) !important;
color: var(--text-on-interactive-base) !important;
}
.am-tab-diff-btn-active [data-slot="icon-svg"] {
color: var(--text-on-interactive-base) !important;
}
.am-detail-content {
@@ -2947,9 +2972,10 @@ body.am-wt-dragging-active * {
color: var(--text-base);
}
.am-nv-pill-active {
background: var(--border-focus, #007fd4) !important;
color: #fff !important;
.am-nv-pill-active,
.am-nv-pill-active:hover {
background: var(--border-focus, #007fd4);
color: var(--text-on-interactive-base);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
+8 -3
View File
@@ -14,7 +14,7 @@ export const dict = {
"agentManager.worktree.new": "Worktree جديد",
"agentManager.worktree.setupScript": "سكربت إعداد Worktree",
"agentManager.worktree.delete": "حذف Worktree",
"agentManager.worktree.confirmDelete": "Delete?",
"agentManager.worktree.confirmDelete": "حذف؟",
"agentManager.worktree.stale": "قديم",
"agentManager.worktree.staleTooltip": "مفقود على القرص أو لم يعد git worktree يتتبعه",
"agentManager.worktree.removeStale": "إزالة Worktree القديم",
@@ -104,8 +104,8 @@ export const dict = {
"agentManager.run.options": "خيارات التشغيل",
"agentManager.run.configure": "تكوين سكربت التشغيل",
"agentManager.shortcuts.openAgentManager": "فتح Agent Manager",
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
"agentManager.shortcuts.cycleAgentMode": "وضع الوكيل التالي",
"agentManager.shortcuts.cyclePreviousAgentMode": "وضع الوكيل السابق",
"agentManager.shortcuts.showShortcuts": "إظهار اختصارات لوحة المفاتيح",
"agentManager.dialog.removeStaleWorktree.title": "إزالة Worktree القديم",
"agentManager.dialog.removeStaleWorktree.messagePre": "إزالة Worktree القديم ",
@@ -211,4 +211,9 @@ export const dict = {
"agentManager.import.noBranchesFound": "لم يتم العثور على فروع.",
"agentManager.import.noBranchesHint": "الصق رابط PR أعلاه أو أنشئ Worktree جديدًا.",
"agentManager.import.failed": "فشل الاستيراد",
"agentManager.pr.error.gh_auth.title": "مصادقة GitHub مطلوبة",
"agentManager.pr.error.gh_auth.description": "شغّل 'gh auth login' في الطرفية لاستعادة حالة PR.",
"agentManager.pr.error.gh_missing.title": "لم يتم تثبيت GitHub CLI",
"agentManager.pr.error.gh_missing.description": "قم بتثبيت GitHub CLI لتفعيل حالة PR.",
}
+8 -3
View File
@@ -14,7 +14,7 @@ export const dict = {
"agentManager.worktree.new": "Novo Worktree",
"agentManager.worktree.setupScript": "Script de configuração do Worktree",
"agentManager.worktree.delete": "Excluir Worktree",
"agentManager.worktree.confirmDelete": "Delete?",
"agentManager.worktree.confirmDelete": "Excluir?",
"agentManager.worktree.stale": "Obsoleto",
"agentManager.worktree.staleTooltip": "Ausente no disco ou não é mais rastreado pelo git worktree",
"agentManager.worktree.removeStale": "Remover Worktree obsoleto",
@@ -106,8 +106,8 @@ export const dict = {
"agentManager.run.options": "Opções de execução",
"agentManager.run.configure": "Configurar script de execução",
"agentManager.shortcuts.openAgentManager": "Abrir Agent Manager",
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
"agentManager.shortcuts.cycleAgentMode": "Próximo modo de agente",
"agentManager.shortcuts.cyclePreviousAgentMode": "Modo de agente anterior",
"agentManager.shortcuts.showShortcuts": "Mostrar atalhos de teclado",
"agentManager.dialog.removeStaleWorktree.title": "Remover Worktree obsoleto",
"agentManager.dialog.removeStaleWorktree.messagePre": "Remover Worktree obsoleto ",
@@ -215,4 +215,9 @@ export const dict = {
"agentManager.import.noBranchesFound": "Nenhum branch encontrado.",
"agentManager.import.noBranchesHint": "Cole uma URL de PR acima ou crie um novo Worktree.",
"agentManager.import.failed": "Falha na importação",
"agentManager.pr.error.gh_auth.title": "Autenticação do GitHub necessária",
"agentManager.pr.error.gh_auth.description": "Execute 'gh auth login' no terminal para restaurar o status do PR.",
"agentManager.pr.error.gh_missing.title": "GitHub CLI não instalado",
"agentManager.pr.error.gh_missing.description": "Instale o GitHub CLI para habilitar o status do PR.",
}
+9 -4
View File
@@ -14,7 +14,7 @@ export const dict = {
"agentManager.worktree.new": "Novi Worktree",
"agentManager.worktree.setupScript": "Skripta za postavljanje Worktree-a",
"agentManager.worktree.delete": "Obriši Worktree",
"agentManager.worktree.confirmDelete": "Delete?",
"agentManager.worktree.confirmDelete": "Izbrisati?",
"agentManager.worktree.stale": "Zastario",
"agentManager.worktree.staleTooltip": "Nedostaje na disku ili ga git worktree više ne prati",
"agentManager.worktree.removeStale": "Ukloni zastarjeli Worktree",
@@ -106,8 +106,8 @@ export const dict = {
"agentManager.run.options": "Opcije pokretanja",
"agentManager.run.configure": "Konfiguriši skriptu za pokretanje",
"agentManager.shortcuts.openAgentManager": "Otvori Agent Manager",
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
"agentManager.shortcuts.cycleAgentMode": "Sljedeći način rada agenta",
"agentManager.shortcuts.cyclePreviousAgentMode": "Prethodni način rada agenta",
"agentManager.shortcuts.showShortcuts": "Prikaži prečice na tastaturi",
"agentManager.dialog.removeStaleWorktree.title": "Ukloni zastarjeli Worktree",
"agentManager.dialog.removeStaleWorktree.messagePre": "Ukloni zastarjeli Worktree ",
@@ -203,7 +203,7 @@ export const dict = {
"agentManager.review.imageUnavailable": "Pregled slike nije dostupan za ovaj snimak sesije.",
"agentManager.review.endOfLongDiff": "Došli ste do kraja!",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pullRequest": "Zahtjev za povlačenje",
"agentManager.import.pastePrUrl": "Zalijepite PR URL...",
"agentManager.import.open": "Otvori",
"agentManager.import.branches": "Branchevi",
@@ -214,4 +214,9 @@ export const dict = {
"agentManager.import.noBranchesFound": "Nisu pronađeni branchevi.",
"agentManager.import.noBranchesHint": "Zalijepite PR URL iznad ili kreirajte novi Worktree.",
"agentManager.import.failed": "Uvoz neuspješan",
"agentManager.pr.error.gh_auth.title": "Potrebna GitHub autentikacija",
"agentManager.pr.error.gh_auth.description": "Pokrenite 'gh auth login' u terminalu da vratite status PR-a.",
"agentManager.pr.error.gh_missing.title": "GitHub CLI nije instaliran",
"agentManager.pr.error.gh_missing.description": "Instalirajte GitHub CLI da omogućite status PR-a.",
}
+10 -5
View File
@@ -14,7 +14,7 @@ export const dict = {
"agentManager.worktree.new": "Nyt Worktree",
"agentManager.worktree.setupScript": "Worktree-opsætningsscript",
"agentManager.worktree.delete": "Slet Worktree",
"agentManager.worktree.confirmDelete": "Delete?",
"agentManager.worktree.confirmDelete": "Slet?",
"agentManager.worktree.stale": "Forældet",
"agentManager.worktree.staleTooltip": "Mangler på disken eller spores ikke længere af git worktree",
"agentManager.worktree.removeStale": "Fjern forældet Worktree",
@@ -107,8 +107,8 @@ export const dict = {
"agentManager.run.options": "Kørselsindstillinger",
"agentManager.run.configure": "Konfigurer kørselsscript",
"agentManager.shortcuts.openAgentManager": "Åbn Agent Manager",
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
"agentManager.shortcuts.cycleAgentMode": "Næste agenttilstand",
"agentManager.shortcuts.cyclePreviousAgentMode": "Forrige agenttilstand",
"agentManager.shortcuts.showShortcuts": "Vis tastaturgenveje",
"agentManager.dialog.removeStaleWorktree.title": "Fjern forældet Worktree",
"agentManager.dialog.removeStaleWorktree.messagePre": "Fjern forældet Worktree ",
@@ -205,10 +205,10 @@ export const dict = {
"Forhåndsvisning af billedet er ikke tilgængelig for dette snapshot af sessionen.",
"agentManager.review.endOfLongDiff": "Du nåede slutningen!",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pullRequest": "Pull request",
"agentManager.import.pastePrUrl": "Indsæt PR URL...",
"agentManager.import.open": "Åbn",
"agentManager.import.branches": "Branches",
"agentManager.import.branches": "Grene",
"agentManager.import.selectBranch": "Vælg branch...",
"agentManager.import.loading": "Indlæser...",
"agentManager.import.loadingBranches": "Indlæser branches...",
@@ -216,4 +216,9 @@ export const dict = {
"agentManager.import.noBranchesFound": "Ingen branches fundet.",
"agentManager.import.noBranchesHint": "Indsæt en PR URL ovenfor eller opret et nyt Worktree.",
"agentManager.import.failed": "Import mislykkedes",
"agentManager.pr.error.gh_auth.title": "GitHub-godkendelse påkrævet",
"agentManager.pr.error.gh_auth.description": "Kør 'gh auth login' i din terminal for at gendanne PR-status.",
"agentManager.pr.error.gh_missing.title": "GitHub CLI er ikke installeret",
"agentManager.pr.error.gh_missing.description": "Installer GitHub CLI for at aktivere PR-status.",
}
@@ -14,7 +14,7 @@ export const dict = {
"agentManager.worktree.new": "Neuer Worktree",
"agentManager.worktree.setupScript": "Worktree-Einrichtungsskript",
"agentManager.worktree.delete": "Worktree löschen",
"agentManager.worktree.confirmDelete": "Delete?",
"agentManager.worktree.confirmDelete": "Löschen?",
"agentManager.worktree.stale": "Veraltet",
"agentManager.worktree.staleTooltip": "Fehlt auf der Festplatte oder wird nicht mehr von git worktree verfolgt",
"agentManager.worktree.removeStale": "Veralteten Worktree entfernen",
@@ -108,8 +108,8 @@ export const dict = {
"agentManager.run.options": "Ausführungsoptionen",
"agentManager.run.configure": "Ausführungsskript konfigurieren",
"agentManager.shortcuts.openAgentManager": "Agent Manager öffnen",
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
"agentManager.shortcuts.cycleAgentMode": "Nächster Agentenmodus",
"agentManager.shortcuts.cyclePreviousAgentMode": "Vorheriger Agentenmodus",
"agentManager.shortcuts.showShortcuts": "Tastenkürzel anzeigen",
"agentManager.dialog.removeStaleWorktree.title": "Veralteten Worktree entfernen",
"agentManager.dialog.removeStaleWorktree.messagePre": "Veralteten Worktree entfernen ",
@@ -208,7 +208,7 @@ export const dict = {
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "PR-URL einfügen...",
"agentManager.import.open": "Öffnen",
"agentManager.import.branches": "Branches",
"agentManager.import.branches": "Zweige",
"agentManager.import.selectBranch": "Branch auswählen...",
"agentManager.import.loading": "Laden...",
"agentManager.import.loadingBranches": "Branches werden geladen...",
@@ -216,4 +216,10 @@ export const dict = {
"agentManager.import.noBranchesFound": "Keine Branches gefunden.",
"agentManager.import.noBranchesHint": "Fügen Sie oben eine PR-URL ein oder erstellen Sie einen neuen Worktree.",
"agentManager.import.failed": "Import fehlgeschlagen",
"agentManager.pr.error.gh_auth.title": "GitHub-Authentifizierung erforderlich",
"agentManager.pr.error.gh_auth.description":
"Führen Sie 'gh auth login' in Ihrem Terminal aus, um den PR-Status wiederherzustellen.",
"agentManager.pr.error.gh_missing.title": "GitHub CLI nicht installiert",
"agentManager.pr.error.gh_missing.description": "Installieren Sie die GitHub CLI, um den PR-Status zu aktivieren.",
}
@@ -217,4 +217,8 @@ export const dict = {
"agentManager.import.noBranchesFound": "No branches found.",
"agentManager.import.noBranchesHint": "Paste a PR URL above or create a new worktree.",
"agentManager.import.failed": "Import failed",
"agentManager.pr.error.gh_auth.title": "GitHub authentication required",
"agentManager.pr.error.gh_auth.description": "Run 'gh auth login' in your terminal to restore PR status.",
"agentManager.pr.error.gh_missing.title": "GitHub CLI not installed",
"agentManager.pr.error.gh_missing.description": "Install the GitHub CLI to enable PR status.",
}
+10 -4
View File
@@ -14,7 +14,7 @@ export const dict = {
"agentManager.worktree.new": "Nuevo Worktree",
"agentManager.worktree.setupScript": "Script de configuración de Worktree",
"agentManager.worktree.delete": "Eliminar Worktree",
"agentManager.worktree.confirmDelete": "Delete?",
"agentManager.worktree.confirmDelete": "¿Eliminar?",
"agentManager.worktree.stale": "Obsoleto",
"agentManager.worktree.staleTooltip": "No está en disco o ya no está seguido por git worktree",
"agentManager.worktree.removeStale": "Eliminar Worktree obsoleto",
@@ -107,8 +107,8 @@ export const dict = {
"agentManager.run.options": "Opciones de ejecución",
"agentManager.run.configure": "Configurar script de ejecución",
"agentManager.shortcuts.openAgentManager": "Abrir Agent Manager",
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
"agentManager.shortcuts.cycleAgentMode": "Siguiente modo de agente",
"agentManager.shortcuts.cyclePreviousAgentMode": "Modo de agente anterior",
"agentManager.shortcuts.showShortcuts": "Mostrar atajos de teclado",
"agentManager.dialog.removeStaleWorktree.title": "Eliminar Worktree obsoleto",
"agentManager.dialog.removeStaleWorktree.messagePre": "¿Eliminar Worktree obsoleto ",
@@ -208,7 +208,7 @@ export const dict = {
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "Pegar URL del PR...",
"agentManager.import.open": "Abrir",
"agentManager.import.branches": "Branches",
"agentManager.import.branches": "Ramas",
"agentManager.import.selectBranch": "Seleccionar branch...",
"agentManager.import.loading": "Cargando...",
"agentManager.import.loadingBranches": "Cargando branches...",
@@ -216,4 +216,10 @@ export const dict = {
"agentManager.import.noBranchesFound": "No se encontraron branches.",
"agentManager.import.noBranchesHint": "Pega una URL de PR arriba o crea un nuevo Worktree.",
"agentManager.import.failed": "Error en la importación",
"agentManager.pr.error.gh_auth.title": "Se requiere autenticación de GitHub",
"agentManager.pr.error.gh_auth.description":
"Ejecuta 'gh auth login' en tu terminal para restaurar el estado del PR.",
"agentManager.pr.error.gh_missing.title": "GitHub CLI no está instalado",
"agentManager.pr.error.gh_missing.description": "Instala GitHub CLI para habilitar el estado del PR.",
}
+8 -3
View File
@@ -3,8 +3,8 @@ export const dict = {
"agentManager.sidebar.collapse": "بستن نوار کناری",
"agentManager.sidebar.expand": "نمایش نوار کناری",
"agentManager.section.worktrees": "WORKTREES",
"agentManager.section.sessions": "SESSIONS",
"agentManager.projects": "PROJECTS",
"agentManager.section.sessions": "جلسات",
"agentManager.projects": "پروژه‌ها",
"agentManager.project.add": "افزودن پروژه",
"agentManager.project.remove": "حذف از Agent Manager",
"agentManager.project.trust": "اعتماد",
@@ -207,7 +207,7 @@ export const dict = {
"agentManager.review.imageUnavailable": "پیش‌نمایش تصویر برای این نمونه جلسه در دسترس نیست.",
"agentManager.review.endOfLongDiff": "به انتها رسیدید!",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pullRequest": "درخواست ادغام",
"agentManager.import.pastePrUrl": "URL درخواست PR را وارد کنید...",
"agentManager.import.open": "باز کردن",
"agentManager.import.branches": "شاخه‌ها",
@@ -218,4 +218,9 @@ export const dict = {
"agentManager.import.noBranchesFound": "هیچ شاخه‌ای یافت نشد.",
"agentManager.import.noBranchesHint": "یک URL درخواست ادغام را در بالا جای‌گذاری کنید یا یک worktree جدید بسازید.",
"agentManager.import.failed": "وارد کردن ناموفق بود",
"agentManager.pr.error.gh_auth.title": "احراز هویت GitHub لازم است",
"agentManager.pr.error.gh_auth.description": "برای بازیابی وضعیت PR، دستور 'gh auth login' را در ترمینال اجرا کنید.",
"agentManager.pr.error.gh_missing.title": "GitHub CLI نصب نشده است",
"agentManager.pr.error.gh_missing.description": "برای فعال کردن وضعیت PR، GitHub CLI را نصب کنید.",
}
+10 -4
View File
@@ -14,7 +14,7 @@ export const dict = {
"agentManager.worktree.new": "Nouveau Worktree",
"agentManager.worktree.setupScript": "Script de configuration du Worktree",
"agentManager.worktree.delete": "Supprimer le Worktree",
"agentManager.worktree.confirmDelete": "Delete?",
"agentManager.worktree.confirmDelete": "Supprimer ?",
"agentManager.worktree.stale": "Obsolète",
"agentManager.worktree.staleTooltip": "Absent du disque ou n'est plus suivi par git worktree",
"agentManager.worktree.removeStale": "Supprimer le Worktree obsolète",
@@ -60,7 +60,7 @@ export const dict = {
"agentManager.sidebarSearch.label": "Rechercher des Worktrees et des sessions",
"agentManager.sidebarSearch.scope":
"Recherche dans l'espace de travail local, les sessions locales, les Worktrees et leurs sessions",
"agentManager.sidebarSearch.contexts": "LOCAL & WORKTREES",
"agentManager.sidebarSearch.contexts": "LOCAL ET WORKTREES",
"agentManager.terminal.new": "Nouvel onglet de terminal",
"agentManager.terminal.ended": "terminal terminé — fermez l'onglet pour ignorer",
@@ -107,8 +107,8 @@ export const dict = {
"agentManager.run.options": "Options d'exécution",
"agentManager.run.configure": "Configurer le script d'exécution",
"agentManager.shortcuts.openAgentManager": "Ouvrir Agent Manager",
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
"agentManager.shortcuts.cycleAgentMode": "Mode d'agent suivant",
"agentManager.shortcuts.cyclePreviousAgentMode": "Mode d'agent précédent",
"agentManager.shortcuts.showShortcuts": "Afficher les raccourcis clavier",
"agentManager.dialog.removeStaleWorktree.title": "Supprimer le Worktree obsolète",
"agentManager.dialog.removeStaleWorktree.messagePre": "Supprimer le Worktree obsolète ",
@@ -216,4 +216,10 @@ export const dict = {
"agentManager.import.noBranchesFound": "Aucune branche trouvée.",
"agentManager.import.noBranchesHint": "Collez une URL de PR ci-dessus ou créez un nouveau Worktree.",
"agentManager.import.failed": "Échec de l'importation",
"agentManager.pr.error.gh_auth.title": "Authentification GitHub requise",
"agentManager.pr.error.gh_auth.description":
"Exécutez 'gh auth login' dans votre terminal pour restaurer le statut de la PR.",
"agentManager.pr.error.gh_missing.title": "GitHub CLI non installé",
"agentManager.pr.error.gh_missing.description": "Installez GitHub CLI pour activer le statut de la PR.",
}
+7 -1
View File
@@ -36,7 +36,7 @@ export const dict = {
"agentManager.section.moveUp": "Sposta su",
"agentManager.section.moveDown": "Sposta giù",
"agentManager.hoverCard.branch": "BRANCH",
"agentManager.hoverCard.branch": "Ramo",
"agentManager.hoverCard.worktree": "Worktree",
"agentManager.hoverCard.base": "Base",
"agentManager.hoverCard.sessions": "Sessioni",
@@ -222,4 +222,10 @@ export const dict = {
"agentManager.import.noBranchesFound": "Nessun branch trovato.",
"agentManager.import.noBranchesHint": "Incolla un URL PR sopra o crea un nuovo worktree.",
"agentManager.import.failed": "Importazione non riuscita",
"agentManager.pr.error.gh_auth.title": "Autenticazione GitHub richiesta",
"agentManager.pr.error.gh_auth.description":
"Esegui 'gh auth login' nel terminale per ripristinare lo stato della PR.",
"agentManager.pr.error.gh_missing.title": "GitHub CLI non installato",
"agentManager.pr.error.gh_missing.description": "Installa GitHub CLI per abilitare lo stato della PR.",
}
+11 -4
View File
@@ -14,7 +14,7 @@ export const dict = {
"agentManager.worktree.new": "新しいWorktree",
"agentManager.worktree.setupScript": "Worktreeセットアップスクリプト",
"agentManager.worktree.delete": "Worktreeを削除",
"agentManager.worktree.confirmDelete": "Delete?",
"agentManager.worktree.confirmDelete": "削除しますか?",
"agentManager.worktree.stale": "無効",
"agentManager.worktree.staleTooltip": "ディスク上に存在しないか、git worktree で追跡されていません",
"agentManager.worktree.removeStale": "無効な Worktree を削除",
@@ -107,8 +107,8 @@ export const dict = {
"agentManager.run.options": "実行オプション",
"agentManager.run.configure": "実行スクリプトを設定",
"agentManager.shortcuts.openAgentManager": "Agent Managerを開く",
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
"agentManager.shortcuts.cycleAgentMode": "次のエージェントモード",
"agentManager.shortcuts.cyclePreviousAgentMode": "前のエージェントモード",
"agentManager.shortcuts.showShortcuts": "キーボードショートカットを表示",
"agentManager.dialog.removeStaleWorktree.title": "無効な Worktree を削除",
"agentManager.dialog.removeStaleWorktree.messagePre": "無効な Worktree を削除 ",
@@ -204,7 +204,7 @@ export const dict = {
"agentManager.review.imageUnavailable": "このセッションスナップショットでは画像プレビューを利用できません。",
"agentManager.review.endOfLongDiff": "最後まで到達しました!",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pullRequest": "プルリクエスト",
"agentManager.import.pastePrUrl": "PR URLを貼り付け...",
"agentManager.import.open": "開く",
"agentManager.import.branches": "ブランチ",
@@ -215,4 +215,11 @@ export const dict = {
"agentManager.import.noBranchesFound": "ブランチが見つかりません。",
"agentManager.import.noBranchesHint": "上にPR URLを貼り付けるか、新しいWorktreeを作成してください。",
"agentManager.import.failed": "インポートに失敗しました",
"agentManager.pr.error.gh_auth.title": "GitHub認証が必要です",
"agentManager.pr.error.gh_auth.description":
"PRステータスを復元するには、ターミナルで 'gh auth login' を実行してください。",
"agentManager.pr.error.gh_missing.title": "GitHub CLIがインストールされていません",
"agentManager.pr.error.gh_missing.description":
"PRステータスを有効にするには、GitHub CLIをインストールしてください。",
}
+9 -4
View File
@@ -14,7 +14,7 @@ export const dict = {
"agentManager.worktree.new": "새 Worktree",
"agentManager.worktree.setupScript": "Worktree 설정 스크립트",
"agentManager.worktree.delete": "Worktree 삭제",
"agentManager.worktree.confirmDelete": "Delete?",
"agentManager.worktree.confirmDelete": "삭제하시겠습니까?",
"agentManager.worktree.stale": "오래됨",
"agentManager.worktree.staleTooltip": "디스크에 없거나 더 이상 git worktree에서 추적되지 않습니다",
"agentManager.worktree.removeStale": "오래된 Worktree 제거",
@@ -105,8 +105,8 @@ export const dict = {
"agentManager.run.options": "실행 옵션",
"agentManager.run.configure": "실행 스크립트 구성",
"agentManager.shortcuts.openAgentManager": "Agent Manager 열기",
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
"agentManager.shortcuts.cycleAgentMode": "다음 에이전트 모드",
"agentManager.shortcuts.cyclePreviousAgentMode": "이전 에이전트 모드",
"agentManager.shortcuts.showShortcuts": "키보드 단축키 표시",
"agentManager.dialog.removeStaleWorktree.title": "오래된 Worktree 제거",
"agentManager.dialog.removeStaleWorktree.messagePre": "오래된 Worktree 제거 ",
@@ -202,7 +202,7 @@ export const dict = {
"agentManager.review.imageUnavailable": "이 세션 스냅샷에서는 이미지 미리보기를 사용할 수 없습니다.",
"agentManager.review.endOfLongDiff": "끝까지 도달했습니다!",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pullRequest": "풀 리퀘스트",
"agentManager.import.pastePrUrl": "PR URL 붙여넣기...",
"agentManager.import.open": "열기",
"agentManager.import.branches": "브랜치",
@@ -213,4 +213,9 @@ export const dict = {
"agentManager.import.noBranchesFound": "브랜치를 찾을 수 없습니다.",
"agentManager.import.noBranchesHint": "위에 PR URL을 붙여넣거나 새 Worktree를 만드세요.",
"agentManager.import.failed": "가져오기 실패",
"agentManager.pr.error.gh_auth.title": "GitHub 인증 필요",
"agentManager.pr.error.gh_auth.description": "PR 상태를 복원하려면 터미널에서 'gh auth login'을 실행하세요.",
"agentManager.pr.error.gh_missing.title": "GitHub CLI가 설치되지 않음",
"agentManager.pr.error.gh_missing.description": "PR 상태를 활성화하려면 GitHub CLI를 설치하세요.",
}
+7 -2
View File
@@ -138,7 +138,7 @@ export const dict = {
"agentManager.dialog.baseBranch": "Basis branch",
"agentManager.dialog.searchBranches": "Zoeken naar branches...",
"agentManager.dialog.branchBadge.default": "standaard",
"agentManager.dialog.branchBadge.remote": "remote",
"agentManager.dialog.branchBadge.remote": "extern",
"agentManager.dialog.versions": "Versies",
"agentManager.dialog.versionHint": "{{count}} worktrees zullen parallel draaien",
"agentManager.dialog.compareModels": "Modellen vergelijken",
@@ -209,7 +209,7 @@ export const dict = {
"agentManager.review.imageUnavailable": "Afbeeldingsvoorbeeld is niet beschikbaar voor deze sessiesnapshot.",
"agentManager.review.endOfLongDiff": "Je hebt het einde bereikt!",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pullRequest": "Pull request",
"agentManager.import.pastePrUrl": "Plak PR URL...",
"agentManager.import.open": "Openen",
"agentManager.import.branches": "Branches",
@@ -220,4 +220,9 @@ export const dict = {
"agentManager.import.noBranchesFound": "Geen branches gevonden.",
"agentManager.import.noBranchesHint": "Plak hierboven een PR URL of maak een nieuwe worktree.",
"agentManager.import.failed": "Importeren mislukt",
"agentManager.pr.error.gh_auth.title": "GitHub-authenticatie vereist",
"agentManager.pr.error.gh_auth.description": "Voer 'gh auth login' uit in je terminal om de PR-status te herstellen.",
"agentManager.pr.error.gh_missing.title": "GitHub CLI niet geïnstalleerd",
"agentManager.pr.error.gh_missing.description": "Installeer de GitHub CLI om de PR-status in te schakelen.",
}
+10 -5
View File
@@ -14,7 +14,7 @@ export const dict = {
"agentManager.worktree.new": "Nytt Worktree",
"agentManager.worktree.setupScript": "Worktree-oppsettskript",
"agentManager.worktree.delete": "Slett Worktree",
"agentManager.worktree.confirmDelete": "Delete?",
"agentManager.worktree.confirmDelete": "Slette?",
"agentManager.worktree.stale": "Utdatert",
"agentManager.worktree.staleTooltip": "Mangler på disken eller spores ikke lenger av git worktree",
"agentManager.worktree.removeStale": "Fjern utdatert Worktree",
@@ -105,8 +105,8 @@ export const dict = {
"agentManager.run.options": "Kjøringsalternativer",
"agentManager.run.configure": "Konfigurer kjøreskript",
"agentManager.shortcuts.openAgentManager": "Åpne Agent Manager",
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
"agentManager.shortcuts.cycleAgentMode": "Neste agentmodus",
"agentManager.shortcuts.cyclePreviousAgentMode": "Forrige agentmodus",
"agentManager.shortcuts.showShortcuts": "Vis tastatursnarveier",
"agentManager.dialog.removeStaleWorktree.title": "Fjern utdatert Worktree",
"agentManager.dialog.removeStaleWorktree.messagePre": "Fjern utdatert Worktree ",
@@ -202,10 +202,10 @@ export const dict = {
"Forhåndsvisning av bildet er ikke tilgjengelig for dette øyeblikksbildet av økten.",
"agentManager.review.endOfLongDiff": "Du nådde slutten!",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pullRequest": "Pull request",
"agentManager.import.pastePrUrl": "Lim inn PR URL...",
"agentManager.import.open": "Åpne",
"agentManager.import.branches": "Branches",
"agentManager.import.branches": "Grener",
"agentManager.import.selectBranch": "Velg branch...",
"agentManager.import.loading": "Laster...",
"agentManager.import.loadingBranches": "Laster branches...",
@@ -213,4 +213,9 @@ export const dict = {
"agentManager.import.noBranchesFound": "Ingen branches funnet.",
"agentManager.import.noBranchesHint": "Lim inn en PR URL ovenfor eller opprett et nytt Worktree.",
"agentManager.import.failed": "Import mislyktes",
"agentManager.pr.error.gh_auth.title": "GitHub-autentisering kreves",
"agentManager.pr.error.gh_auth.description": "Kjør 'gh auth login' i terminalen for å gjenopprette PR-status.",
"agentManager.pr.error.gh_missing.title": "GitHub CLI er ikke installert",
"agentManager.pr.error.gh_missing.description": "Installer GitHub CLI for å aktivere PR-status.",
}
+8 -3
View File
@@ -14,7 +14,7 @@ export const dict = {
"agentManager.worktree.new": "Nowy Worktree",
"agentManager.worktree.setupScript": "Skrypt konfiguracji Worktree",
"agentManager.worktree.delete": "Usuń Worktree",
"agentManager.worktree.confirmDelete": "Delete?",
"agentManager.worktree.confirmDelete": "Usunąć?",
"agentManager.worktree.stale": "Nieaktualny",
"agentManager.worktree.staleTooltip": "Brak na dysku lub nie jest już śledzony przez git worktree",
"agentManager.worktree.removeStale": "Usuń nieaktualny Worktree",
@@ -107,8 +107,8 @@ export const dict = {
"agentManager.run.options": "Opcje uruchamiania",
"agentManager.run.configure": "Konfiguruj skrypt uruchamiania",
"agentManager.shortcuts.openAgentManager": "Otwórz Agent Manager",
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
"agentManager.shortcuts.cycleAgentMode": "Następny tryb agenta",
"agentManager.shortcuts.cyclePreviousAgentMode": "Poprzedni tryb agenta",
"agentManager.shortcuts.showShortcuts": "Pokaż skróty klawiszowe",
"agentManager.dialog.removeStaleWorktree.title": "Usuń nieaktualny Worktree",
"agentManager.dialog.removeStaleWorktree.messagePre": "Usunąć nieaktualny Worktree ",
@@ -214,4 +214,9 @@ export const dict = {
"agentManager.import.noBranchesFound": "Nie znaleziono branchy.",
"agentManager.import.noBranchesHint": "Wklej URL PR powyżej lub utwórz nowy Worktree.",
"agentManager.import.failed": "Import nie powiódł się",
"agentManager.pr.error.gh_auth.title": "Wymagana autoryzacja GitHub",
"agentManager.pr.error.gh_auth.description": "Uruchom 'gh auth login' w terminalu, aby przywrócić status PR.",
"agentManager.pr.error.gh_missing.title": "GitHub CLI nie jest zainstalowane",
"agentManager.pr.error.gh_missing.description": "Zainstaluj GitHub CLI, aby włączyć status PR.",
}
+8 -3
View File
@@ -14,7 +14,7 @@ export const dict = {
"agentManager.worktree.new": "Новый Worktree",
"agentManager.worktree.setupScript": "Скрипт настройки Worktree",
"agentManager.worktree.delete": "Удалить Worktree",
"agentManager.worktree.confirmDelete": "Delete?",
"agentManager.worktree.confirmDelete": "Удалить?",
"agentManager.worktree.stale": "Устаревший",
"agentManager.worktree.staleTooltip": "Отсутствует на диске или больше не отслеживается git worktree",
"agentManager.worktree.removeStale": "Удалить устаревший Worktree",
@@ -107,8 +107,8 @@ export const dict = {
"agentManager.run.options": "Параметры запуска",
"agentManager.run.configure": "Настроить скрипт запуска",
"agentManager.shortcuts.openAgentManager": "Открыть Agent Manager",
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
"agentManager.shortcuts.cycleAgentMode": "Следующий режим агента",
"agentManager.shortcuts.cyclePreviousAgentMode": "Предыдущий режим агента",
"agentManager.shortcuts.showShortcuts": "Показать сочетания клавиш",
"agentManager.dialog.removeStaleWorktree.title": "Удалить устаревший Worktree",
"agentManager.dialog.removeStaleWorktree.messagePre": "Удалить устаревший Worktree ",
@@ -215,4 +215,9 @@ export const dict = {
"agentManager.import.noBranchesFound": "Ветки не найдены.",
"agentManager.import.noBranchesHint": "Вставьте URL PR выше или создайте новый Worktree.",
"agentManager.import.failed": "Ошибка импорта",
"agentManager.pr.error.gh_auth.title": "Требуется аутентификация GitHub",
"agentManager.pr.error.gh_auth.description": "Выполните 'gh auth login' в терминале, чтобы восстановить статус PR.",
"agentManager.pr.error.gh_missing.title": "GitHub CLI не установлен",
"agentManager.pr.error.gh_missing.description": "Установите GitHub CLI, чтобы включить статус PR.",
}
+10 -5
View File
@@ -14,7 +14,7 @@ export const dict = {
"agentManager.worktree.new": "Worktree ใหม่",
"agentManager.worktree.setupScript": "สคริปต์ตั้งค่า Worktree",
"agentManager.worktree.delete": "ลบ Worktree",
"agentManager.worktree.confirmDelete": "Delete?",
"agentManager.worktree.confirmDelete": "ลบ?",
"agentManager.worktree.stale": "ล้าสมัย",
"agentManager.worktree.staleTooltip": "ไม่พบบนดิสก์หรือไม่ได้ถูกติดตามโดย git worktree อีกต่อไป",
"agentManager.worktree.removeStale": "ลบ Worktree ที่ล้าสมัย",
@@ -102,8 +102,8 @@ export const dict = {
"agentManager.run.options": "ตัวเลือกการเรียกใช้",
"agentManager.run.configure": "กำหนดค่าสคริปต์การเรียกใช้",
"agentManager.shortcuts.openAgentManager": "เปิด Agent Manager",
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
"agentManager.shortcuts.cycleAgentMode": "โหมดเอเจนต์ถัดไป",
"agentManager.shortcuts.cyclePreviousAgentMode": "โหมดเอเจนต์ก่อนหน้า",
"agentManager.shortcuts.showShortcuts": "แสดงปุ่มลัดแป้นพิมพ์",
"agentManager.dialog.removeStaleWorktree.title": "ลบ Worktree ที่ล้าสมัย",
"agentManager.dialog.removeStaleWorktree.messagePre": "ลบ Worktree ที่ล้าสมัย ",
@@ -198,10 +198,10 @@ export const dict = {
"agentManager.review.imageUnavailable": "ไม่สามารถแสดงตัวอย่างรูปภาพสำหรับสแนปช็อตของเซสชันนี้ได้",
"agentManager.review.endOfLongDiff": "คุณมาถึงท้ายสุดแล้ว!",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pullRequest": "คำขอรวมโค้ด",
"agentManager.import.pastePrUrl": "วาง URL ของ PR...",
"agentManager.import.open": "เปิด",
"agentManager.import.branches": "Branches",
"agentManager.import.branches": "สาขา",
"agentManager.import.selectBranch": "เลือก Branch...",
"agentManager.import.loading": "กำลังโหลด...",
"agentManager.import.loadingBranches": "กำลังโหลด Branches...",
@@ -209,4 +209,9 @@ export const dict = {
"agentManager.import.noBranchesFound": "ไม่พบ Branch",
"agentManager.import.noBranchesHint": "วาง URL ของ PR ด้านบนหรือสร้าง Worktree ใหม่",
"agentManager.import.failed": "นำเข้าล้มเหลว",
"agentManager.pr.error.gh_auth.title": "ต้องยืนยันตัวตน GitHub",
"agentManager.pr.error.gh_auth.description": "รันคำสั่ง 'gh auth login' ในเทอร์มินัลเพื่อกู้คืนสถานะ PR",
"agentManager.pr.error.gh_missing.title": "ไม่ได้ติดตั้ง GitHub CLI",
"agentManager.pr.error.gh_missing.description": "ติดตั้ง GitHub CLI เพื่อเปิดใช้งานสถานะ PR",
}
+7 -1
View File
@@ -211,7 +211,7 @@ export const dict = {
"agentManager.review.imageUnavailable": "Bu oturum anlık görüntüsü için görsel önizlemesi kullanılamıyor.",
"agentManager.review.endOfLongDiff": "Sonuna geldiniz!",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pullRequest": "Çekme İsteği",
"agentManager.import.pastePrUrl": "PR URL'sini yapıştırın...",
"agentManager.import.open": "Aç",
"agentManager.import.branches": "Dallar",
@@ -222,4 +222,10 @@ export const dict = {
"agentManager.import.noBranchesFound": "Hiçbir dal bulunamadı.",
"agentManager.import.noBranchesHint": "Yukarıya bir PR URL'si yapıştırın veya yeni bir worktree oluşturun.",
"agentManager.import.failed": "İçe aktarma başarısız oldu",
"agentManager.pr.error.gh_auth.title": "GitHub kimlik doğrulaması gerekli",
"agentManager.pr.error.gh_auth.description":
"PR durumunu geri yüklemek için terminalinizde 'gh auth login' komutunu çalıştırın.",
"agentManager.pr.error.gh_missing.title": "GitHub CLI yüklü değil",
"agentManager.pr.error.gh_missing.description": "PR durumunu etkinleştirmek için GitHub CLI'yi yükleyin.",
}
+6 -1
View File
@@ -212,7 +212,7 @@ export const dict = {
"agentManager.review.imageUnavailable": "Попередній перегляд зображення недоступний для цього знімка сеансу.",
"agentManager.review.endOfLongDiff": "Ви дійшли до кінця!",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pullRequest": "Пул-реквест",
"agentManager.import.pastePrUrl": "Вставте URL PR...",
"agentManager.import.open": "Відкрити",
"agentManager.import.branches": "Гілки",
@@ -223,4 +223,9 @@ export const dict = {
"agentManager.import.noBranchesFound": "Гілок не знайдено.",
"agentManager.import.noBranchesHint": "Вставте URL PR вище або створіть нове робоче дерево.",
"agentManager.import.failed": "Імпортування не вдалося",
"agentManager.pr.error.gh_auth.title": "Потрібна автентифікація GitHub",
"agentManager.pr.error.gh_auth.description": "Виконайте 'gh auth login' у терміналі, щоб відновити статус PR.",
"agentManager.pr.error.gh_missing.title": "GitHub CLI не встановлено",
"agentManager.pr.error.gh_missing.description": "Встановіть GitHub CLI, щоб увімкнути статус PR.",
}
+9 -4
View File
@@ -14,7 +14,7 @@ export const dict = {
"agentManager.worktree.new": "新建 Worktree",
"agentManager.worktree.setupScript": "Worktree 设置脚本",
"agentManager.worktree.delete": "删除 Worktree",
"agentManager.worktree.confirmDelete": "Delete?",
"agentManager.worktree.confirmDelete": "删除?",
"agentManager.worktree.stale": "已失效",
"agentManager.worktree.staleTooltip": "在磁盘上缺失,或不再由 git worktree 跟踪",
"agentManager.worktree.removeStale": "移除失效 Worktree",
@@ -102,8 +102,8 @@ export const dict = {
"agentManager.run.options": "运行选项",
"agentManager.run.configure": "配置运行脚本",
"agentManager.shortcuts.openAgentManager": "打开 Agent Manager",
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
"agentManager.shortcuts.cycleAgentMode": "下一个代理模式",
"agentManager.shortcuts.cyclePreviousAgentMode": "上一个代理模式",
"agentManager.shortcuts.showShortcuts": "显示键盘快捷键",
"agentManager.dialog.removeStaleWorktree.title": "移除失效 Worktree",
"agentManager.dialog.removeStaleWorktree.messagePre": "移除失效 Worktree ",
@@ -196,7 +196,7 @@ export const dict = {
"agentManager.review.imageUnavailable": "此会话快照无法提供图片预览。",
"agentManager.review.endOfLongDiff": "你已经到末尾了!",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pullRequest": "拉取请求",
"agentManager.import.pastePrUrl": "粘贴 PR URL...",
"agentManager.import.open": "打开",
"agentManager.import.branches": "分支",
@@ -207,4 +207,9 @@ export const dict = {
"agentManager.import.noBranchesFound": "未找到分支。",
"agentManager.import.noBranchesHint": "在上方粘贴 PR URL 或创建新的 Worktree。",
"agentManager.import.failed": "导入失败",
"agentManager.pr.error.gh_auth.title": "需要 GitHub 身份验证",
"agentManager.pr.error.gh_auth.description": "在终端中运行 'gh auth login' 以恢复 PR 状态。",
"agentManager.pr.error.gh_missing.title": "未安装 GitHub CLI",
"agentManager.pr.error.gh_missing.description": "安装 GitHub CLI 以启用 PR 状态。",
}
+9 -4
View File
@@ -14,7 +14,7 @@ export const dict = {
"agentManager.worktree.new": "新建 Worktree",
"agentManager.worktree.setupScript": "Worktree 設定指令碼",
"agentManager.worktree.delete": "刪除 Worktree",
"agentManager.worktree.confirmDelete": "Delete?",
"agentManager.worktree.confirmDelete": "刪除?",
"agentManager.worktree.stale": "已失效",
"agentManager.worktree.staleTooltip": "磁碟上不存在,或不再由 git worktree 追蹤",
"agentManager.worktree.removeStale": "移除失效 Worktree",
@@ -102,8 +102,8 @@ export const dict = {
"agentManager.run.options": "執行選項",
"agentManager.run.configure": "設定執行指令碼",
"agentManager.shortcuts.openAgentManager": "開啟 Agent Manager",
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
"agentManager.shortcuts.cycleAgentMode": "下一個代理模式",
"agentManager.shortcuts.cyclePreviousAgentMode": "上一個代理模式",
"agentManager.shortcuts.showShortcuts": "顯示鍵盤快捷鍵",
"agentManager.dialog.removeStaleWorktree.title": "移除失效 Worktree",
"agentManager.dialog.removeStaleWorktree.messagePre": "移除失效 Worktree ",
@@ -196,7 +196,7 @@ export const dict = {
"agentManager.review.imageUnavailable": "此工作階段快照無法預覽圖片。",
"agentManager.review.endOfLongDiff": "你已經到最後了!",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pullRequest": "提取請求",
"agentManager.import.pastePrUrl": "貼上 PR URL...",
"agentManager.import.open": "開啟",
"agentManager.import.branches": "分支",
@@ -207,4 +207,9 @@ export const dict = {
"agentManager.import.noBranchesFound": "找不到分支。",
"agentManager.import.noBranchesHint": "在上方貼上 PR URL 或建立新的 Worktree。",
"agentManager.import.failed": "匯入失敗",
"agentManager.pr.error.gh_auth.title": "需要 GitHub 驗證",
"agentManager.pr.error.gh_auth.description": "在終端機中執行 'gh auth login' 以還原 PR 狀態。",
"agentManager.pr.error.gh_missing.title": "未安裝 GitHub CLI",
"agentManager.pr.error.gh_missing.description": "安裝 GitHub CLI 以啟用 PR 狀態。",
}
@@ -0,0 +1,22 @@
/** @jsxImportSource solid-js */
import { createSignal } from "solid-js"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
export function CopyButton(props: { text: string; label?: string; class?: string }) {
const [copied, setCopied] = createSignal(false)
const copy = () => {
navigator.clipboard.writeText(props.text)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
return (
<IconButton
icon={copied() ? "check" : "copy"}
size="small"
variant="ghost"
label={props.label ?? "Copy"}
class={props.class}
onClick={copy}
/>
)
}
@@ -0,0 +1,33 @@
/** @jsxImportSource solid-js */
import { Show } from "solid-js"
import { Icon } from "@kilocode/kilo-ui/icon"
import type { PRStatus } from "../../src/types/messages"
import { prBadgeIndicator, prChecksRunning } from "../WorktreeItem"
const INDICATOR_ICON: Record<string, string> = {
failure: "circle-x-outline",
changes: "warning",
approved: "circle-check",
}
export function PRBadge(props: { pr: PRStatus }) {
const indicator = () => prBadgeIndicator(props.pr)
return (
<span
class="am-pr-panel-badge am-pr-row"
classList={{
"am-pr-accent-draft": props.pr.state === "draft",
"am-pr-accent-merged": props.pr.state === "merged",
"am-pr-accent-closed": props.pr.state === "closed",
"am-pr-accent-pending": props.pr.state === "open" && props.pr.checks.status === "pending",
"am-pr-accent-open": props.pr.state === "open" && props.pr.checks.status !== "pending",
"am-pr-badge-pending": prChecksRunning(props.pr),
}}
>
<Icon name="pull-request" size="small" />
<Show when={indicator() !== "none"}>
<Icon name={INDICATOR_ICON[indicator()]} size="small" class="am-pr-badge-status" data-status={indicator()} />
</Show>
</span>
)
}
@@ -0,0 +1,62 @@
/** @jsxImportSource solid-js */
import { For, Show, createSignal } from "solid-js"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import type { PRStatus } from "../../src/types/messages"
import type { PRCheck, CheckStatus } from "./pr-types"
import { SectionHeading } from "./SectionHeading"
import { useVSCode } from "../../src/context/vscode"
const CHECK: Record<CheckStatus, { icon: string; label: string }> = {
success: { icon: "circle-check", label: "Passed" },
failure: { icon: "circle-x-outline", label: "Failed" },
cancelled: { icon: "circle-x-outline", label: "Cancelled" },
skipped: { icon: "circle-x-outline", label: "Skipped" },
pending: { icon: "play", label: "Running" },
}
export function PRChecks(props: { checks: PRStatus["checks"] }) {
const vscode = useVSCode()
const [open, setOpen] = createSignal(true)
return (
<>
<div class="am-pr-panel-divider" />
<div class="am-pr-panel-section">
<SectionHeading
title="Checks"
open={open()}
onToggle={() => setOpen((v) => !v)}
count={`${props.checks.passed}/${props.checks.total} passed`}
countClass={`am-pr-checks-count-${props.checks.status}`}
/>
<Show when={open()}>
<div class="am-pr-panel-checks am-pr-col">
<For each={props.checks.checks}>
{(check: PRCheck) => (
<div class="am-pr-panel-check-item am-pr-row" data-status={check.status}>
<Icon name={CHECK[check.status].icon} size="small" class="am-pr-check-icon" />
<span class="am-pr-check-name">{check.name}</span>
<span class="am-pr-check-status">{CHECK[check.status].label}</span>
<Show when={check.duration}>
<span class="am-pr-check-duration">{check.duration}</span>
</Show>
<Show when={check.url}>
<Tooltip value="Open in browser" placement="bottom">
<button
class="am-pr-check-link"
aria-label="Open check in browser"
onClick={() => vscode.postMessage({ type: "openExternal", url: check.url! })}
>
<Icon name="link" size="small" />
</button>
</Tooltip>
</Show>
</div>
)}
</For>
</div>
</Show>
</div>
</>
)
}
@@ -0,0 +1,51 @@
/** @jsxImportSource solid-js */
import { For, Show, createSignal } from "solid-js"
import { Markdown } from "@kilocode/kilo-ui/markdown"
import type { PRStatus } from "../../src/types/messages"
import type { PRComment } from "./pr-types"
import { SectionHeading } from "./SectionHeading"
import { CopyButton } from "./CopyButton"
export function PRComments(props: { comments: NonNullable<PRStatus["comments"]> }) {
const [open, setOpen] = createSignal(true)
return (
<>
<div class="am-pr-panel-divider" />
<div class="am-pr-panel-section">
<SectionHeading
title="Comments"
open={open()}
onToggle={() => setOpen((v) => !v)}
count={props.comments.unresolved > 0 ? `${props.comments.unresolved} unresolved` : undefined}
countClass="am-pr-panel-unresolved"
/>
<Show when={open()}>
<div class="am-pr-panel-comment-list am-pr-col">
<For each={props.comments.comments}>
{(comment: PRComment) => (
<div class="am-pr-panel-comment" classList={{ "am-pr-panel-comment-resolved": comment.resolved }}>
<div class="am-pr-panel-comment-header am-pr-row">
<span class="am-pr-panel-comment-author">{comment.author}</span>
<Show when={comment.file}>
<span class="am-pr-panel-comment-file">
{comment.file}
<Show when={comment.line}>{`:${comment.line}`}</Show>
</span>
</Show>
<Show when={comment.resolved}>
<span class="am-pr-panel-comment-resolved-badge">Resolved</span>
</Show>
<CopyButton text={comment.body} class="am-pr-copy-btn" />
</div>
<div class="am-pr-panel-comment-body">
<Markdown text={comment.body} />
</div>
</div>
)}
</For>
</div>
</Show>
</div>
</>
)
}
@@ -0,0 +1,21 @@
/** @jsxImportSource solid-js */
import { Show, createSignal } from "solid-js"
import { Markdown } from "@kilocode/kilo-ui/markdown"
import { SectionHeading } from "./SectionHeading"
export function PRDescription(props: { body: string }) {
const [open, setOpen] = createSignal(true)
return (
<>
<div class="am-pr-panel-divider" />
<div class="am-pr-panel-section">
<SectionHeading title="Description" open={open()} onToggle={() => setOpen((v) => !v)} />
<Show when={open()}>
<div class="am-pr-panel-description">
<Markdown text={props.body} />
</div>
</Show>
</div>
</>
)
}
@@ -0,0 +1,51 @@
/** @jsxImportSource solid-js */
import { Show } from "solid-js"
import { Icon } from "@kilocode/kilo-ui/icon"
import type { WorktreeState } from "../../src/types/messages"
import type { PRStatus } from "../../src/types/messages"
const STATE_LABEL: Record<PRStatus["state"], string> = {
open: "Open",
draft: "Draft",
merged: "Merged",
closed: "Closed",
}
const REVIEW_LABEL: Partial<Record<NonNullable<PRStatus["review"]>, string>> = {
approved: "Approved",
changes_requested: "Changes Requested",
pending: "Review Pending",
}
export function PROverview(props: { pr: PRStatus; worktree?: WorktreeState }) {
return (
<div class="am-pr-panel-section">
<Show when={props.worktree}>
{(wt) => (
<div class="am-pr-panel-row am-pr-row">
<span class="am-pr-panel-label">Branch</span>
<span class="am-pr-panel-value am-pr-panel-branch am-pr-row">
<span class="am-pr-branch-name">{wt().branch}</span>
<Icon name="arrow-right" size="small" class="am-pr-branch-arrow" />
<span class="am-pr-branch-name">{wt().parentBranch}</span>
</span>
</div>
)}
</Show>
<div class="am-pr-panel-row am-pr-row">
<span class="am-pr-panel-label">Status</span>
<span class="am-pr-panel-value" data-pr-state={props.pr.state}>
{STATE_LABEL[props.pr.state]}
</span>
</div>
<Show when={props.pr.review}>
{(review) => (
<div class="am-pr-panel-row am-pr-row">
<span class="am-pr-panel-label">Review</span>
<span class="am-pr-panel-value">{REVIEW_LABEL[review()]}</span>
</div>
)}
</Show>
</div>
)
}
@@ -0,0 +1,63 @@
/** @jsxImportSource solid-js */
import { Component, Show } from "solid-js"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import type { WorktreeState } from "../../src/types/messages"
import type { PRStatus } from "../../src/types/messages"
import { PRBadge } from "./PRBadge"
import { PROverview } from "./PROverview"
import { PRReviewers } from "./PRReviewers"
import { PRDescription } from "./PRDescription"
import { PRChecks } from "./PRChecks"
import { PRComments } from "./PRComments"
import { PRSummary } from "./PRSummary"
import "./pr-panel.css"
interface PRPanelProps {
pr: PRStatus
worktree?: WorktreeState
onClose: () => void
onOpenExternal: () => void
}
export const PRPanel: Component<PRPanelProps> = (props) => {
return (
<div class="am-pr-panel am-pr-col">
<div class="am-pr-panel-header am-pr-row">
<div class="am-pr-panel-title-row am-pr-row">
<PRBadge pr={props.pr} />
<span class="am-pr-panel-title">{props.pr.title}</span>
<span class="am-pr-panel-number">#{props.pr.number}</span>
</div>
<div class="am-pr-panel-actions am-pr-row">
<Tooltip value="Open in browser" placement="bottom">
<IconButton
icon="link"
size="small"
variant="ghost"
label="Open in browser"
onClick={props.onOpenExternal}
/>
</Tooltip>
<Tooltip value="Close" placement="bottom">
<IconButton icon="close" size="small" variant="ghost" label="Close PR panel" onClick={props.onClose} />
</Tooltip>
</div>
</div>
<div class="am-pr-panel-body">
<PRSummary pr={props.pr} />
<PROverview pr={props.pr} worktree={props.worktree} />
<Show when={(props.pr.reviewers ?? []).length > 0}>
<PRReviewers reviewers={props.pr.reviewers ?? []} />
</Show>
<Show when={props.pr.body}>{(body) => <PRDescription body={body()} />}</Show>
<Show when={props.pr.checks.total > 0}>
<PRChecks checks={props.pr.checks} />
</Show>
<Show when={props.pr.comments?.total ? props.pr.comments : undefined}>
{(comments) => <PRComments comments={comments()} />}
</Show>
</div>
</div>
)
}
@@ -0,0 +1,44 @@
/** @jsxImportSource solid-js */
import { For, Show, createSignal } from "solid-js"
import { Icon } from "@kilocode/kilo-ui/icon"
import type { PRReviewer, ReviewerState } from "./pr-types"
import { SectionHeading } from "./SectionHeading"
const REVIEWER_ICON: Record<ReviewerState, string> = {
approved: "circle-check",
changes_requested: "refresh",
commented: "edit",
pending: "dash",
}
const REVIEWER_LABEL: Record<ReviewerState, string> = {
approved: "Approved",
changes_requested: "Changes requested",
commented: "Commented",
pending: "Awaiting",
}
export function PRReviewers(props: { reviewers: PRReviewer[] }) {
const [open, setOpen] = createSignal(true)
return (
<>
<div class="am-pr-panel-divider" />
<div class="am-pr-panel-section">
<SectionHeading title="Reviewers" open={open()} onToggle={() => setOpen((v) => !v)} />
<Show when={open()}>
<div class="am-pr-panel-reviewers am-pr-col">
<For each={props.reviewers}>
{(reviewer) => (
<div class="am-pr-panel-reviewer am-pr-row" data-state={reviewer.state}>
<Icon name={REVIEWER_ICON[reviewer.state]} size="small" class="am-pr-reviewer-icon" />
<span class="am-pr-reviewer-login">{reviewer.login}</span>
<span class="am-pr-reviewer-state">{REVIEWER_LABEL[reviewer.state]}</span>
</div>
)}
</For>
</div>
</Show>
</div>
</>
)
}
@@ -0,0 +1,72 @@
/** @jsxImportSource solid-js */
import { Show } from "solid-js"
import { Icon } from "@kilocode/kilo-ui/icon"
import type { PRStatus } from "../../src/types/messages"
interface PRSummaryProps {
pr: PRStatus
}
function summaryRows(pr: PRStatus): Array<{ icon: string; label: string; status: string }> {
const rows = []
if (pr.checks.total > 0) {
const { passed, total, status } = pr.checks
rows.push({
icon: status === "success" ? "circle-check" : status === "failure" ? "circle-x-outline" : "play",
label: status === "success" ? "All checks passing" : `${passed}/${total} checks passed`,
status,
})
}
if (pr.review) {
const status = pr.review === "approved" ? "success" : pr.review === "changes_requested" ? "failure" : "pending"
rows.push({
icon: status === "success" ? "circle-check" : status === "failure" ? "circle-x-outline" : "play",
label: status === "success" ? "Approved" : status === "failure" ? "Changes requested" : "Review pending",
status,
})
}
if (pr.comments && pr.comments.unresolved > 0) {
rows.push({
icon: "comment",
label: `${pr.comments.unresolved} unresolved comment${pr.comments.unresolved > 1 ? "s" : ""}`,
status: "warning",
})
}
return rows
}
export function PRSummary(props: PRSummaryProps) {
const rows = () => summaryRows(props.pr)
return (
<Show when={rows().length > 0}>
<div class="am-pr-summary">
<div class="am-pr-summary-header am-pr-row">
<span class="am-pr-summary-title">PR Summary</span>
<span class="am-pr-panel-section-count am-pr-panel-diff am-pr-row">
<Show when={props.pr.files > 0}>
<span class="am-stat-files">{props.pr.files}f</span>
</Show>
<Show when={props.pr.additions > 0}>
<span class="am-stat-additions">+{props.pr.additions}</span>
</Show>
<Show when={props.pr.deletions > 0}>
<span class="am-stat-deletions">−{props.pr.deletions}</span>
</Show>
</span>
</div>
<div class="am-pr-summary-rows am-pr-col">
{rows().map((row) => (
<div class="am-pr-summary-row am-pr-row" data-status={row.status}>
<Icon name={row.icon} size="small" class="am-pr-summary-icon" />
<span class="am-pr-summary-label">{row.label}</span>
</div>
))}
</div>
</div>
</Show>
)
}
@@ -0,0 +1,23 @@
/** @jsxImportSource solid-js */
import { Show } from "solid-js"
import { Icon } from "@kilocode/kilo-ui/icon"
export function SectionHeading(props: {
title: string
open: boolean
onToggle: () => void
count?: string
countClass?: string
}) {
return (
<button class="am-pr-panel-section-heading am-pr-panel-section-toggle am-pr-row" onClick={props.onToggle}>
<span class="am-pr-panel-section-heading-left am-pr-row">
<Icon name={props.open ? "chevron-down" : "chevron-right"} size="small" class="am-pr-section-chevron" />
{props.title}
</span>
<Show when={props.count}>
<span class={`am-pr-panel-section-count ${props.countClass ?? ""}`}>{props.count}</span>
</Show>
</button>
)
}
@@ -0,0 +1,457 @@
/* PR Panel */
.am-pr-panel {
height: 100%;
overflow: hidden;
}
.am-pr-row {
display: flex;
align-items: center;
}
.am-pr-col {
display: flex;
flex-direction: column;
}
.am-pr-panel-header {
align-items: flex-start;
justify-content: space-between;
gap: 8px;
padding: 10px 12px 8px;
border-bottom: 1px solid var(--vscode-panel-border);
flex-shrink: 0;
}
.am-pr-panel-title-row {
gap: 8px;
min-width: 0;
}
.am-pr-panel-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 4px;
border-radius: 4px;
font-size: var(--kilo-font-size-10);
font-variant-numeric: tabular-nums;
background: color-mix(in srgb, var(--am-pr-accent) 15%, transparent);
color: var(--am-pr-accent);
border: 1px solid color-mix(in srgb, var(--am-pr-accent) 40%, transparent);
white-space: nowrap;
flex-shrink: 0;
}
.am-pr-panel-badge.am-pr-badge-pending {
animation: am-pr-badge-pulse 1.5s ease-in-out infinite;
}
.am-pr-panel-title {
font-size: var(--kilo-font-size-14);
color: var(--vscode-foreground);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.am-pr-panel-number {
font-size: var(--kilo-font-size-12);
color: var(--vscode-descriptionForeground);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.am-pr-panel-actions {
gap: 2px;
flex-shrink: 0;
}
.am-pr-panel-body {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.am-pr-panel-section {
padding: 4px 12px;
}
.am-pr-panel-section-heading {
justify-content: space-between;
font-size: var(--font-size-small);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-weak);
padding-bottom: 6px;
}
.am-pr-panel-section-toggle {
width: 100%;
background: none;
border: none;
cursor: pointer;
padding: 0;
text-align: left;
border-radius: 3px;
}
.am-pr-panel-section-toggle:hover {
color: var(--vscode-foreground);
}
.am-pr-panel-section-heading-left {
gap: 4px;
}
.am-pr-section-chevron {
flex-shrink: 0;
opacity: 0.6;
}
/* Branch → base row */
.am-pr-panel-branch {
gap: 4px;
flex-wrap: wrap;
}
.am-pr-branch-name {
font-family: var(--font-mono, monospace);
font-size: var(--kilo-font-size-11);
}
.am-pr-branch-arrow {
opacity: 0.5;
flex-shrink: 0;
}
/* Check count color by status */
.am-pr-checks-count-success {
color: var(--vscode-testing-iconPassed, #34d399);
}
.am-pr-checks-count-failure {
color: var(--vscode-testing-iconFailed, #f87171);
}
.am-pr-checks-count-pending {
color: var(--vscode-charts-yellow, #fbbf24);
}
.am-pr-panel-section-count {
font-size: var(--kilo-font-size-11);
font-weight: 400;
text-transform: none;
letter-spacing: 0;
color: var(--text-weak);
}
.am-pr-panel-divider {
height: 1px;
background: var(--vscode-panel-border);
margin: 6px 0;
}
.am-pr-panel-row {
justify-content: space-between;
gap: 8px;
padding: 3px 0;
min-height: 22px;
}
.am-pr-panel-label {
font-size: var(--kilo-font-size-11);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-weaker);
flex-shrink: 0;
}
.am-pr-panel-value {
font-size: var(--kilo-font-size-12);
color: var(--vscode-foreground);
text-align: right;
}
.am-pr-panel-value[data-pr-state="open"] {
color: var(--vscode-testing-iconPassed, #34d399);
}
.am-pr-panel-value[data-pr-state="merged"] {
color: var(--vscode-charts-purple, #a78bfa);
}
.am-pr-panel-value[data-pr-state="closed"] {
color: var(--vscode-testing-iconFailed, #f87171);
}
.am-pr-panel-value[data-pr-state="draft"] {
color: var(--text-weaker);
}
.am-pr-panel-value[data-checks="failure"] {
color: var(--vscode-testing-iconFailed, #f87171);
}
.am-pr-panel-value[data-checks="pending"] {
color: var(--vscode-charts-yellow, #fbbf24);
}
.am-pr-panel-value[data-checks="success"] {
color: var(--vscode-testing-iconPassed, #34d399);
}
.am-pr-panel-diff {
gap: 6px;
}
.am-pr-panel-unresolved {
color: var(--vscode-charts-yellow, #fbbf24);
}
/* PR comment list */
.am-pr-panel-comment-list {
gap: 8px;
margin-top: 6px;
}
.am-pr-panel-comment {
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
padding: 8px;
opacity: 1;
}
.am-pr-panel-comment-resolved {
opacity: 0.5;
}
.am-pr-panel-comment-header {
gap: 6px;
margin-bottom: 4px;
flex-wrap: wrap;
}
.am-pr-panel-comment-author {
font-size: var(--kilo-font-size-12);
font-weight: 600;
color: var(--vscode-foreground);
}
.am-pr-panel-comment-file {
font-size: var(--kilo-font-size-11);
color: var(--text-weak);
font-family: var(--font-mono, monospace);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 180px;
}
.am-pr-panel-comment-resolved-badge {
font-size: var(--kilo-font-size-10);
color: var(--text-weaker);
border: 1px solid var(--vscode-panel-border);
border-radius: 3px;
padding: 1px 4px;
margin-left: auto;
}
.am-pr-copy-btn {
margin-left: auto;
}
.am-pr-panel-comment-body [data-component="markdown"] {
font-size: var(--kilo-font-size-12);
color: var(--vscode-foreground);
}
.am-pr-panel-description {
line-height: 1.5;
padding: 2px 0 4px;
}
.am-pr-panel-description [data-component="markdown"],
.am-pr-panel-comment-body [data-component="markdown"] {
font-size: var(--kilo-font-size-13);
color: var(--vscode-foreground);
}
.am-pr-panel-description [data-component="markdown"] h1,
.am-pr-panel-description [data-component="markdown"] h2,
.am-pr-panel-description [data-component="markdown"] h3,
.am-pr-panel-description [data-component="markdown"] h4,
.am-pr-panel-description [data-component="markdown"] h5,
.am-pr-panel-description [data-component="markdown"] h6 {
font-weight: 600;
color: var(--vscode-foreground);
margin-top: 0.75em;
margin-bottom: 0.25em;
}
.am-pr-panel-description [data-component="markdown"] h1 {
font-size: var(--kilo-font-size-16);
}
.am-pr-panel-description [data-component="markdown"] h2 {
font-size: var(--kilo-font-size-14);
}
.am-pr-panel-description [data-component="markdown"] h3 {
font-size: var(--kilo-font-size-13);
}
/* PR Reviewers */
.am-pr-panel-reviewers {
gap: 2px;
}
.am-pr-panel-reviewer {
gap: 6px;
padding: 3px 0;
font-size: var(--kilo-font-size-12);
}
.am-pr-reviewer-icon {
flex-shrink: 0;
}
.am-pr-panel-reviewer[data-state="approved"] .am-pr-reviewer-icon {
color: var(--vscode-testing-iconPassed, #34d399);
}
.am-pr-panel-reviewer[data-state="changes_requested"] .am-pr-reviewer-icon {
color: var(--vscode-charts-yellow, #fbbf24);
}
.am-pr-panel-reviewer[data-state="commented"] .am-pr-reviewer-icon {
color: var(--text-weak);
}
.am-pr-panel-reviewer[data-state="pending"] .am-pr-reviewer-icon {
color: var(--text-weaker);
}
.am-pr-reviewer-login {
flex: 1;
color: var(--vscode-foreground);
}
.am-pr-reviewer-state {
color: var(--text-weak);
flex-shrink: 0;
}
.am-pr-panel-checks {
gap: 2px;
margin-top: 4px;
}
.am-pr-panel-check-item {
gap: 6px;
padding: 3px 0;
font-size: var(--kilo-font-size-12);
}
.am-pr-check-icon {
flex-shrink: 0;
}
.am-pr-panel-check-item[data-status="success"] .am-pr-check-icon {
color: var(--vscode-testing-iconPassed, #34d399);
}
.am-pr-panel-check-item[data-status="failure"] .am-pr-check-icon {
color: var(--vscode-testing-iconFailed, #f87171);
}
.am-pr-panel-check-item[data-status="cancelled"] .am-pr-check-icon {
color: var(--text-weaker);
}
.am-pr-panel-check-item[data-status="skipped"] .am-pr-check-icon {
color: var(--text-weaker);
}
.am-pr-panel-check-item[data-status="pending"] .am-pr-check-icon {
color: var(--vscode-charts-yellow, #fbbf24);
}
.am-pr-check-name {
flex: 1;
color: var(--vscode-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.am-pr-check-status {
color: var(--text-weak);
flex-shrink: 0;
}
.am-pr-check-duration {
color: var(--text-weaker);
font-size: var(--kilo-font-size-10);
flex-shrink: 0;
}
.am-pr-check-link {
color: var(--text-weak);
display: flex;
align-items: center;
flex-shrink: 0;
cursor: pointer;
text-decoration: none;
}
.am-pr-check-link:hover {
color: var(--text-base);
}
/* PR toggle button in tab bar */
.am-pr-toggle-number {
font-size: var(--kilo-font-size-10);
font-variant-numeric: tabular-nums;
}
/* PR Summary banner */
.am-pr-summary {
margin: 8px 12px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
overflow: hidden;
}
.am-pr-summary-header {
justify-content: space-between;
padding: 6px 6px 6px 10px;
border-bottom: 1px solid var(--vscode-panel-border);
background: var(--vscode-editor-background);
}
.am-pr-summary-title {
font-size: var(--font-size-small);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-weak);
}
.am-pr-summary-rows {
padding: 6px 10px;
gap: 4px;
background: var(--vscode-editor-background);
}
.am-pr-summary-row {
gap: 7px;
padding: 2px 0;
font-size: var(--kilo-font-size-12);
}
.am-pr-summary-icon {
flex-shrink: 0;
}
.am-pr-summary-row[data-status="success"] .am-pr-summary-icon {
color: var(--vscode-testing-iconPassed, #34d399);
}
.am-pr-summary-row[data-status="failure"] .am-pr-summary-icon {
color: var(--vscode-testing-iconFailed, #f87171);
}
.am-pr-summary-row[data-status="pending"] .am-pr-summary-icon {
color: var(--vscode-charts-yellow, #fbbf24);
}
.am-pr-summary-row[data-status="warning"] .am-pr-summary-icon {
color: var(--vscode-charts-yellow, #fbbf24);
}
.am-pr-summary-label {
color: var(--vscode-foreground);
}
@@ -0,0 +1,34 @@
// PR sub-types — source of truth for all PR-related types used in the PR panel.
// PRStatus lives in src/types/messages/agent-manager.ts for broad consumption.
export type PRState = "open" | "draft" | "merged" | "closed"
export type ReviewDecision = "approved" | "changes_requested" | "pending"
export type CheckStatus = "success" | "failure" | "pending" | "skipped" | "cancelled"
export type AggregateCheckStatus = "success" | "failure" | "pending" | "none"
export interface PRCheck {
name: string
status: CheckStatus
url?: string
duration?: string
}
export interface PRComment {
id: string
author: string
avatar?: string
body: string
file?: string
line?: number
url?: string
resolved: boolean
createdAt?: number
}
export type ReviewerState = "approved" | "changes_requested" | "pending" | "commented"
export interface PRReviewer {
login: string
avatar?: string
state: ReviewerState
}
@@ -5,6 +5,12 @@ export const SIDE_RESIZE_INTERVAL_MS = 32
type Frame = (callback: (time: number) => void) => number
export enum SidePanel {
Diff = "diff",
PR = "pr",
Terminal = "terminal",
}
function viewportWidth(viewport: number): number {
return Number.isFinite(viewport) && viewport > 0 ? viewport : MIN_PANEL_WIDTH
}
@@ -13,11 +13,12 @@
import { createEffect, createSignal, type Accessor } from "solid-js"
import type { ScriptTerminalStatus, TerminalStateControls } from "./state"
import type { TerminalTabStateWithContext } from "./state"
import type { SidePanelState } from "../AgentManagerApp"
interface AmbientSetupDeps {
terms: TerminalStateControls
selection: Accessor<string | null>
sidePanel: Accessor<string | null>
sidePanel: Accessor<SidePanelState>
setSidePanel(panel: null): void
}
@@ -451,6 +451,18 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => {
setErrors("models", (v) => v.filter((_, i) => i !== index))
}
function toggleAllReasoning() {
const all = form.models.length > 0 && form.models.every((m) => m.reasoning)
const target = !all
form.models.forEach((_, i) => setForm("models", i, "reasoning", target))
}
function toggleAllImages() {
const all = form.models.length > 0 && form.models.every((m) => m.supportsImages)
const target = !all
form.models.forEach((_, i) => setForm("models", i, "supportsImages", target))
}
function addHeader() {
setForm("headers", (v) => [...v, { key: "", value: "" }])
setErrors("headers", (v) => [...v, {}])
@@ -515,6 +527,7 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => {
return (
<Dialog
size="large"
title={
<IconButton
tabIndex={-1}
@@ -530,13 +543,15 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => {
style={{
display: "flex",
"flex-direction": "column",
gap: "24px",
padding: "0 10px 12px 10px",
gap: "20px",
padding: "0 16px 16px 16px",
"overflow-y": "auto",
"max-height": "60vh",
flex: 1,
width: "100%",
"box-sizing": "border-box",
}}
>
<div style={{ padding: "0 10px", display: "flex", gap: "16px", "align-items": "center" }}>
<div style={{ display: "flex", gap: "16px", "align-items": "center" }}>
<ProviderIcon id="synthetic" width={20} height={20} />
<div
style={{ "font-size": "var(--kilo-font-size-16)", "font-weight": "500", color: "var(--vscode-foreground)" }}
@@ -545,36 +560,35 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => {
</div>
</div>
<form
onSubmit={save}
style={{ padding: "0 10px 24px 10px", display: "flex", "flex-direction": "column", gap: "24px" }}
>
<div style={{ "font-size": "var(--kilo-font-size-14)", color: "var(--text-base)" }}>
{language.t("provider.custom.description.prefix")}
<a
href="https://kilo.ai/docs/ai-providers#custom-provider"
onClick={(e) => {
e.preventDefault()
vscode.postMessage({
type: "openExternal",
url: "https://kilo.ai/docs/ai-providers#custom-provider",
})
}}
>
{language.t("provider.custom.description.link")}
</a>
{language.t("provider.custom.description.suffix")}
<form onSubmit={save} style={{ display: "flex", "flex-direction": "column", gap: "20px" }}>
<div style={{ display: "flex", "flex-direction": "column", gap: "10px" }}>
<div style={{ "font-size": "var(--kilo-font-size-14)", color: "var(--text-base)" }}>
{language.t("provider.custom.description.prefix")}
<a
href="https://kilo.ai/docs/ai-providers#custom-provider"
onClick={(e) => {
e.preventDefault()
vscode.postMessage({
type: "openExternal",
url: "https://kilo.ai/docs/ai-providers#custom-provider",
})
}}
>
{language.t("provider.custom.description.link")}
</a>
{language.t("provider.custom.description.suffix")}
</div>
<Show when={editing()}>
<div style={{ "margin-top": "8px" }}>
<a
href="#"
onClick={(e) => {
e.preventDefault()
vscode.postMessage(configMessage("global", language.t))
}}
<div>
<Button
type="button"
variant="secondary"
size="small"
icon="edit"
onClick={() => vscode.postMessage(configMessage("global", language.t))}
>
{language.t("provider.custom.edit.advanced")}
</a>
</Button>
</div>
</Show>
</div>
@@ -651,19 +665,49 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => {
{/* Models */}
<div style={{ display: "flex", "flex-direction": "column", gap: "12px" }}>
<div style={{ display: "flex", "align-items": "center", gap: "8px" }}>
<label
style={{
"font-size": "var(--kilo-font-size-12)",
"font-weight": "500",
color: "var(--text-weak-base)",
}}
>
{language.t("provider.custom.models.label")}
</label>
<Show when={fetching()}>
<Spinner style={{ width: "12px", height: "12px" }} />
</Show>
<div
style={{
display: "flex",
"justify-content": "space-between",
"align-items": "center",
"flex-wrap": "wrap",
gap: "8px",
}}
>
<div style={{ display: "flex", "align-items": "center", gap: "8px" }}>
<label
style={{
"font-size": "var(--kilo-font-size-12)",
"font-weight": "500",
color: "var(--text-weak-base)",
}}
>
{language.t("provider.custom.models.label")}
</label>
<Show when={fetching()}>
<Spinner style={{ width: "12px", height: "12px" }} />
</Show>
</div>
<div style={{ display: "flex", gap: "8px", "align-items": "center", "flex-wrap": "wrap" }}>
<Button
type="button"
size="small"
variant="ghost"
onClick={toggleAllReasoning}
disabled={form.models.length === 0}
>
{language.t("provider.custom.models.toggleReasoning")}
</Button>
<Button
type="button"
size="small"
variant="ghost"
onClick={toggleAllImages}
disabled={form.models.length === 0}
>
{language.t("provider.custom.models.toggleImages")}
</Button>
</div>
</div>
<For each={form.models}>
{(m, i) => (
@@ -98,42 +98,44 @@ export function ModelCard(props: ModelCardProps) {
/>
</div>
{/* Reasoning toggle */}
<label
style={{
display: "flex",
"align-items": "center",
gap: "8px",
cursor: "pointer",
"font-size": "var(--kilo-font-size-13)",
color: "var(--vscode-foreground)",
}}
>
<input
type="checkbox"
checked={props.m.reasoning}
onChange={(e) => props.onChangeReasoning(e.currentTarget.checked)}
/>
{props.t("provider.custom.models.reasoning.label")}
</label>
{/* Reasoning and Image toggles */}
<div style={{ display: "flex", gap: "16px", "align-items": "center", "flex-wrap": "wrap" }}>
<label
style={{
display: "flex",
"align-items": "center",
gap: "8px",
cursor: "pointer",
"font-size": "var(--kilo-font-size-13)",
color: "var(--vscode-foreground)",
}}
>
<input
type="checkbox"
checked={props.m.reasoning}
onChange={(e) => props.onChangeReasoning(e.currentTarget.checked)}
/>
{props.t("provider.custom.models.reasoning.label")}
</label>
<label
style={{
display: "flex",
"align-items": "center",
gap: "8px",
cursor: "pointer",
"font-size": "var(--kilo-font-size-13)",
color: "var(--vscode-foreground)",
}}
>
<input
type="checkbox"
checked={props.m.supportsImages}
onChange={(e) => props.onChangeSupportsImages(e.currentTarget.checked)}
/>
{props.t("provider.custom.models.modalities.image")}
</label>
<label
style={{
display: "flex",
"align-items": "center",
gap: "8px",
cursor: "pointer",
"font-size": "var(--kilo-font-size-13)",
color: "var(--vscode-foreground)",
}}
>
<input
type="checkbox"
checked={props.m.supportsImages}
onChange={(e) => props.onChangeSupportsImages(e.currentTarget.checked)}
/>
{props.t("provider.custom.models.modalities.image")}
</label>
</div>
<Show when={issue()}>
{(error) => (
@@ -50,12 +50,13 @@ export const ModelPreview: Component<Props> = (props) => {
<Show when={m()}>
{(model) => {
const cost = () => model().cost
const hasPricing = () => cost() && (cost()!.input > 0 || cost()!.output > 0)
const bench = () => model().terminalBench
const cachedText = () => {
if (!cost()) return ""
if (!cost() || !hasPricing()) return ""
return fmtCachedPrice(cost()!) ?? language.t("model.preview.value.notSupported")
}
const avg = () => (cost() ? avgPrice(cost()!) : undefined)
const avg = () => (cost() && hasPricing() ? avgPrice(cost()!) : undefined)
const freeLabel = () => language.t("model.tag.free")
const dataLabel = () => freeDataLabel(language.t("model.tag.free"), language.t("model.tag.dataCollected"))
const autoLabel = () => autoSummary(model())
@@ -139,8 +140,8 @@ export const ModelPreview: Component<Props> = (props) => {
<span class="model-preview-value">{fmtDate(model().releaseDate!)}</span>
</Show>
{/* Pricing — hidden for free models */}
<Show when={cost() && !model().isFree}>
{/* Pricing — hidden for free models or models without fixed pricing */}
<Show when={cost() && !model().isFree && hasPricing()}>
<span class="model-preview-label">{language.t("model.preview.label.input")}</span>
<span class="model-preview-value">{fmtPrice(cost()!.input)}</span>
<span class="model-preview-label">{language.t("model.preview.label.output")}</span>
@@ -3,6 +3,7 @@
* Expects `n` in $/M tokens (as stored in model.cost.input / model.cost.output).
*/
export function fmtPrice(n: number): string {
if (n < 0) return "—"
if (n === 0) return "Free"
if (n < 0.01) return `$${n.toFixed(4)}/1M`
return `$${n.toFixed(2)}/1M`
@@ -52,6 +52,8 @@ const Root: ParentComponent = (props) => (
<MermaidDownloadBridge />
<ServerProvider>
<LanguageBridge>
{/* MarkedProvider is required here for all markdown consumers in the tree,
including PRPanel's PRDescription and PRComments components. Do not remove. */}
<MarkedProvider>
<DiffComponentProvider component={Diff}>
<CodeComponentProvider component={Code}>
@@ -3,6 +3,27 @@ import type { Message } from "../types/messages"
type Entry = { id: string; error?: Message["error"] }
type Error = NonNullable<Message["error"]>
function sameError(a: Message, b: Message) {
if (!a.error || !b.error || a.error.name !== b.error.name) return false
if (a.parentID !== b.parentID) return false
return JSON.stringify(a.error.data) === JSON.stringify(b.error.data)
}
export function withoutResolvedSessionErrors(current: Message[], incoming: Message[]) {
const events = new Set(incoming.map((msg) => msg.sessionErrorID).filter((id): id is string => !!id))
return current.filter((msg) => {
if (!msg.sessionErrorID) return true
if (events.has(msg.sessionErrorID)) return false
return !incoming.some((next) => !next.sessionErrorID && sameError(msg, next))
})
}
export function preserveSessionErrors(current: Message[], incoming: Message[]) {
const ids = new Set(incoming.map((msg) => msg.id))
const errors = withoutResolvedSessionErrors(current, incoming).filter((msg) => msg.sessionErrorID && !ids.has(msg.id))
return [...incoming, ...errors]
}
export function errorIDs(messages: Entry[]) {
return messages.filter((msg) => !!msg.error).map((msg) => msg.id)
}
@@ -0,0 +1,43 @@
import type { Message, MessageLoadMode, Part } from "../types/messages"
import { sameParts } from "./session-parts"
import { withoutResolvedSessionErrors } from "./session-errors"
export function mergeMessages(current: Message[], incoming: Message[], mode: Exclude<MessageLoadMode, "focus">) {
const kept = withoutResolvedSessionErrors(current, incoming)
if (mode === "reconcile") {
// Tail reconcile: incoming is the authoritative newest-N snapshot.
// Local state may already hold some of those IDs and may also hold
// newer optimistic entries created after the fetch was taken. Merge
// by id (server wins on collision) then sort by createdAt so new
// server messages land in the right position and optimistic tail
// entries stay at the end.
const byId = new Map<string, Message>()
for (const msg of kept) byId.set(msg.id, msg)
for (const msg of incoming) byId.set(msg.id, msg)
return [...byId.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
}
const seen = new Set<string>()
const source = mode === "prepend" ? [...incoming, ...kept] : incoming
return source.filter((msg) => {
if (seen.has(msg.id)) return false
seen.add(msg.id)
return true
})
}
// Cheap tail check: same ids in the same order and no visible streamed-part
// correction to apply. It skips store churn when SSE already matches the
// snapshot, but lets reconcile heal part removals and finalized text.
export function sameReconcileShape(
current: Message[],
incoming: Message[],
getParts: (messageID: string) => Part[] | undefined,
): boolean {
if (current.length !== incoming.length) return false
for (const [i, n] of incoming.entries()) {
const c = current[i]!
if (c.id !== n.id) return false
if (!sameParts(getParts(c.id) ?? c.parts, n.parts)) return false
}
return true
}
@@ -71,9 +71,10 @@ import { Identifier } from "../utils/id"
import { resolveModelSelection } from "./model-selection"
import { getAgentModel } from "./session-model-store"
import { resolveMessagePrefs } from "./session-preferences"
import { errorIDs } from "./session-errors"
import { errorIDs, preserveSessionErrors, withoutResolvedSessionErrors } from "./session-errors"
import { PartStash } from "./part-stash"
import { mergeParts, sameParts } from "./session-parts"
import { mergeParts } from "./session-parts"
import { mergeMessages, sameReconcileShape } from "./session-merge"
import { state as todoState } from "./todo-revert"
import { sessionVariantKeys, transferVariants, variantKey } from "./session-variant-store"
import { createSessionVariants } from "./session-variants"
@@ -1168,7 +1169,7 @@ export const SessionProvider: ParentComponent = (props) => {
break
case "sessionError": {
if (message.error?.name === "MessageAbortedError") break
if (!message.error || message.error.name === "MessageAbortedError") break
const sid = message.sessionID ?? currentSessionID()
if (!sid) break
// Find the last user message in this session to use as parentID
@@ -1181,6 +1182,7 @@ export const SessionProvider: ParentComponent = (props) => {
createdAt: new Date().toISOString(),
parentID: parent?.id,
error: message.error,
sessionErrorID: message.eventID,
}
handleMessageCreated(errorMsg)
break
@@ -1373,28 +1375,6 @@ export const SessionProvider: ParentComponent = (props) => {
setPages(sessionID, { ...(pages[sessionID] ?? emptyPageState), ...patch })
}
function mergeMessages(current: Message[], incoming: Message[], mode: Exclude<MessageLoadMode, "focus">) {
if (mode === "reconcile") {
// Tail reconcile: incoming is the authoritative newest-N snapshot.
// Local state may already hold some of those IDs and may also hold
// newer optimistic entries created after the fetch was taken. Merge
// by id (server wins on collision) then sort by createdAt so new
// server messages land in the right position and optimistic tail
// entries stay at the end.
const byId = new Map<string, Message>()
for (const msg of current) byId.set(msg.id, msg)
for (const msg of incoming) byId.set(msg.id, msg)
return [...byId.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
}
const seen = new Set<string>()
const source = mode === "prepend" ? [...incoming, ...current] : incoming
return source.filter((msg) => {
if (seen.has(msg.id)) return false
seen.add(msg.id)
return true
})
}
function recoverPrefs(sessionID: string, messages: Message[], names = agentNames()) {
const prefs = resolveMessagePrefs(messages, names)
if (prefs.agent && !store.agentSelections[sessionID]) {
@@ -1411,25 +1391,13 @@ export const SessionProvider: ParentComponent = (props) => {
}
function withPending(sessionID: string, messages: Message[]) {
const pending = pendingOptimistic.get(sessionID)
if (!pending || pending.size === 0) return messages
const ids = new Set(messages.map((msg) => msg.id))
const current = store.messages[sessionID] ?? []
const merged = preserveSessionErrors(current, messages)
const pending = pendingOptimistic.get(sessionID)
if (!pending || pending.size === 0) return merged
const ids = new Set(merged.map((msg) => msg.id))
const orphans = current.filter((msg) => pending.has(msg.id) && !ids.has(msg.id))
return [...messages, ...orphans]
}
// Cheap tail check: same ids in the same order and no visible streamed-part
// correction to apply. It skips store churn when SSE already matches the
// snapshot, but lets reconcile heal part removals and finalized text.
function sameReconcileShape(current: Message[], incoming: Message[]): boolean {
if (current.length !== incoming.length) return false
for (const [i, n] of incoming.entries()) {
const c = current[i]!
if (c.id !== n.id) return false
if (!sameParts(store.parts[c.id] ?? c.parts, n.parts)) return false
}
return true
return [...merged, ...orphans]
}
function setTools(sessionID: string, tools: ToolPart[]) {
@@ -1480,7 +1448,10 @@ export const SessionProvider: ParentComponent = (props) => {
// Reconcile fast-path: if the tail matches local state shape-wise, every
// message+part-count already agrees with the server. Skip the reactive
// store churn entirely — virtualizer and rendering stay untouched.
if (mode === "reconcile" && sameReconcileShape(store.messages[sessionID] ?? [], messages)) {
if (
mode === "reconcile" &&
sameReconcileShape(store.messages[sessionID] ?? [], messages, (id) => store.parts[id])
) {
const parts = messageParts(messages)
for (const msg of messages) {
if (store.parts[msg.id]) delete parts[msg.id]
@@ -1594,16 +1565,18 @@ export const SessionProvider: ParentComponent = (props) => {
const exists = (store.messages[message.sessionID] ?? []).some((msg) => msg.id === message.id)
setStore("messages", message.sessionID, (msgs = []) => {
if (message.sessionErrorID && msgs.some((msg) => msg.sessionErrorID === message.sessionErrorID)) return msgs
const current = withoutResolvedSessionErrors(msgs, [message])
// Check if message already exists (optimistic or update case).
// Since we now use the same messageID for optimistic and server messages,
// this naturally handles the optimistic→real transition.
const idx = msgs.findIndex((m) => m.id === message.id)
const idx = current.findIndex((m) => m.id === message.id)
if (idx >= 0) {
const updated = [...msgs]
updated[idx] = { ...msgs[idx], ...message }
const updated = [...current]
updated[idx] = { ...current[idx], ...message }
return updated
}
return [...msgs, message]
return [...current, message]
})
patchPage(message.sessionID, { lastMutation: exists ? "update" : "append" })
+2
View File
@@ -443,6 +443,8 @@ export const dict = {
"provider.custom.models.name.placeholder": "الاسم المعروض",
"provider.custom.models.reasoning.label": "الاستدلال",
"provider.custom.models.modalities.image": "صورة",
"provider.custom.models.toggleReasoning": "تبديل التفكير للكل",
"provider.custom.models.toggleImages": "تبديل الصور للكل",
"provider.custom.models.remove": "إزالة النموذج",
"provider.custom.models.add": "إضافة نموذج",
"provider.custom.models.fetch.authError": "فشلت المصادقة. تحقق من مفتاح API أعلاه وحاول مرة أخرى.",
+2
View File
@@ -454,6 +454,8 @@ export const dict = {
"provider.custom.models.name.placeholder": "Nome de Exibição",
"provider.custom.models.reasoning.label": "Raciocínio",
"provider.custom.models.modalities.image": "Imagem",
"provider.custom.models.toggleReasoning": "Alternar raciocínio para todos",
"provider.custom.models.toggleImages": "Alternar imagem para todos",
"provider.custom.models.remove": "Remover modelo",
"provider.custom.models.add": "Adicionar modelo",
"provider.custom.models.fetch.authError": "Falha na autenticação. Verifique a chave de API acima e tente novamente.",
+2
View File
@@ -496,6 +496,8 @@ export const dict = {
"provider.custom.models.name.placeholder": "Naziv za prikaz",
"provider.custom.models.reasoning.label": "Zaključivanje",
"provider.custom.models.modalities.image": "Slika",
"provider.custom.models.toggleReasoning": "Uključi/isključi rezonovanje za sve",
"provider.custom.models.toggleImages": "Uključi/isključi slike za sve",
"provider.custom.models.remove": "Ukloni model",
"provider.custom.models.add": "Dodaj model",
"provider.custom.models.fetch.authError":
+2
View File
@@ -494,6 +494,8 @@ export const dict = {
"provider.custom.models.name.placeholder": "Visningsnavn",
"provider.custom.models.reasoning.label": "Ræsonnement",
"provider.custom.models.modalities.image": "Billede",
"provider.custom.models.toggleReasoning": "Slå ræsonnement til/fra for alle",
"provider.custom.models.toggleImages": "Slå billede til/fra for alle",
"provider.custom.models.remove": "Fjern model",
"provider.custom.models.add": "Tilføj model",
"provider.custom.models.fetch.authError": "Godkendelse mislykkedes. Kontrollér API-nøglen ovenfor, og prøv igen.",
@@ -504,6 +504,8 @@ export const dict = {
"provider.custom.models.name.placeholder": "Anzeigename",
"provider.custom.models.reasoning.label": "Schlussfolgerung",
"provider.custom.models.modalities.image": "Bild",
"provider.custom.models.toggleReasoning": "Schlussfolgerung für alle umschalten",
"provider.custom.models.toggleImages": "Bild für alle umschalten",
"provider.custom.models.remove": "Modell entfernen",
"provider.custom.models.add": "Modell hinzufügen",
"provider.custom.models.fetch.authError":
@@ -408,6 +408,8 @@ export const dict = {
"provider.custom.models.name.placeholder": "Display Name",
"provider.custom.models.reasoning.label": "Reasoning",
"provider.custom.models.modalities.image": "Image",
"provider.custom.models.toggleReasoning": "Toggle reasoning for all",
"provider.custom.models.toggleImages": "Toggle image for all",
"provider.custom.models.remove": "Remove model",
"provider.custom.models.add": "Add model",
"provider.custom.models.fetch.authError": "Authentication failed. Check the API key above and try again.",

Some files were not shown because too many files have changed in this diff Show More