kanna-code 0.41.7 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,8 +3,10 @@ import {
3
3
  SERVER_PROVIDERS,
4
4
  applyClaudeSdkModels,
5
5
  codexServiceTierFromModelOptions,
6
+ cursorModelIdForOptions,
6
7
  normalizeClaudeModelOptions,
7
8
  normalizeCodexModelOptions,
9
+ normalizeCursorModelOptions,
8
10
  normalizeServerModel,
9
11
  resetServerProvidersForTests,
10
12
  } from "./provider-catalog"
@@ -44,12 +46,12 @@ describe("provider catalog normalization", () => {
44
46
  })
45
47
 
46
48
  test("normalizes Codex model options and fast mode defaults", () => {
47
- expect(normalizeCodexModelOptions(undefined)).toEqual({
48
- reasoningEffort: "high",
49
+ expect(normalizeCodexModelOptions("gpt-5.6-sol", undefined)).toEqual({
50
+ reasoningEffort: "medium",
49
51
  fastMode: false,
50
52
  })
51
53
 
52
- const normalized = normalizeCodexModelOptions({
54
+ const normalized = normalizeCodexModelOptions("gpt-5.6-terra", {
53
55
  codex: {
54
56
  reasoningEffort: "xhigh",
55
57
  fastMode: true,
@@ -61,13 +63,38 @@ describe("provider catalog normalization", () => {
61
63
  fastMode: true,
62
64
  })
63
65
  expect(codexServiceTierFromModelOptions(normalized)).toBe("fast")
66
+
67
+ expect(normalizeCodexModelOptions("gpt-5.6-sol", {
68
+ codex: { reasoningEffort: "ultra" },
69
+ }).reasoningEffort).toBe("ultra")
70
+ expect(normalizeCodexModelOptions("gpt-5.6-luna", {
71
+ codex: { reasoningEffort: "ultra" },
72
+ }).reasoningEffort).toBe("max")
73
+ expect(normalizeCodexModelOptions("gpt-5.6-luna", undefined, "minimal").reasoningEffort).toBe("low")
74
+ })
75
+
76
+ test("normalizes Cursor model options and applies the fast model suffix", () => {
77
+ expect(normalizeCursorModelOptions(undefined)).toEqual({ fastMode: false })
78
+ expect(normalizeCursorModelOptions({ cursor: { fastMode: true } })).toEqual({ fastMode: true })
79
+
80
+ expect(cursorModelIdForOptions("composer-2.5", { fastMode: false })).toBe("composer-2.5")
81
+ expect(cursorModelIdForOptions("composer-2.5", { fastMode: true })).toBe("composer-2.5-fast")
82
+ // Idempotent if the base id already carries the suffix.
83
+ expect(cursorModelIdForOptions("composer-2.5-fast", { fastMode: true })).toBe("composer-2.5-fast")
84
+ })
85
+
86
+ test("resolves the Cursor default model through the server catalog", () => {
87
+ // Exercises the catalog lookup + default fallback (throws if "cursor" is unregistered).
88
+ expect(normalizeServerModel("cursor")).toBe("composer-2.5")
89
+ expect(normalizeServerModel("cursor", "composer-2.5-fast")).toBe("composer-2.5")
64
90
  })
65
91
 
66
92
  test("normalizes server model ids through the shared alias catalog", () => {
67
- expect(normalizeServerModel("codex")).toBe("gpt-5.5")
93
+ expect(normalizeServerModel("codex")).toBe("gpt-5.6-sol")
68
94
  expect(normalizeServerModel("claude", "fable")).toBe("fable")
69
95
  expect(normalizeServerModel("claude", "opus")).toBe("claude-opus-4-8")
70
96
  expect(normalizeServerModel("codex", "gpt-5-codex")).toBe("gpt-5.3-codex")
97
+ expect(normalizeServerModel("codex", "gpt-5.6")).toBe("gpt-5.6-sol")
71
98
  })
72
99
 
73
100
  test("resolves Claude API model ids for 1m context window", () => {
@@ -2,6 +2,7 @@ import type {
2
2
  AgentProvider,
3
3
  ClaudeModelOptions,
4
4
  CodexModelOptions,
5
+ CursorModelOptions,
5
6
  ClaudeContextWindow,
6
7
  ModelOptions,
7
8
  ProviderCatalogEntry,
@@ -11,20 +12,15 @@ import type {
11
12
  import {
12
13
  DEFAULT_CLAUDE_MODEL_OPTIONS,
13
14
  DEFAULT_CODEX_MODEL_OPTIONS,
15
+ DEFAULT_CURSOR_MODEL_OPTIONS,
14
16
  PROVIDERS,
15
17
  normalizeClaudeContextWindow,
18
+ normalizeCodexReasoningEffort,
16
19
  normalizeProviderModelId,
17
20
  isClaudeReasoningEffort,
18
21
  isCodexReasoningEffort,
19
22
  } from "../shared/types"
20
23
 
21
- const HARD_CODED_CODEX_MODELS: ProviderModelOption[] = [
22
- { id: "gpt-5.5", label: "GPT-5.5", supportsEffort: false },
23
- { id: "gpt-5.4", label: "GPT-5.4", supportsEffort: false },
24
- { id: "gpt-5.3-codex", label: "GPT-5.3 Codex", supportsEffort: false },
25
- { id: "gpt-5.3-codex-spark", label: "GPT-5.3 Codex Spark", supportsEffort: false },
26
- ]
27
-
28
24
  export interface ClaudeSdkModelInfo {
29
25
  value: string
30
26
  displayName?: string
@@ -35,15 +31,7 @@ export interface ClaudeSdkModelInfo {
35
31
  }
36
32
 
37
33
  function createServerProviders(): ProviderCatalogEntry[] {
38
- return PROVIDERS.map((provider) =>
39
- provider.id === "codex"
40
- ? {
41
- ...provider,
42
- defaultModel: "gpt-5.5",
43
- models: HARD_CODED_CODEX_MODELS,
44
- }
45
- : provider
46
- )
34
+ return structuredClone(PROVIDERS)
47
35
  }
48
36
 
49
37
  export const SERVER_PROVIDERS: ProviderCatalogEntry[] = createServerProviders()
@@ -137,14 +125,17 @@ export function normalizeClaudeModelOptions(
137
125
  }
138
126
  }
139
127
 
140
- export function normalizeCodexModelOptions(modelOptions?: ModelOptions, legacyEffort?: string): CodexModelOptions {
128
+ export function normalizeCodexModelOptions(
129
+ model: string,
130
+ modelOptions?: ModelOptions,
131
+ legacyEffort?: string,
132
+ ): CodexModelOptions {
141
133
  const reasoningEffort = modelOptions?.codex?.reasoningEffort
142
134
  return {
143
- reasoningEffort: isCodexReasoningEffort(reasoningEffort)
144
- ? reasoningEffort
145
- : isCodexReasoningEffort(legacyEffort)
146
- ? legacyEffort
147
- : DEFAULT_CODEX_MODEL_OPTIONS.reasoningEffort,
135
+ reasoningEffort: normalizeCodexReasoningEffort(
136
+ model,
137
+ isCodexReasoningEffort(reasoningEffort) ? reasoningEffort : legacyEffort,
138
+ ),
148
139
  fastMode: typeof modelOptions?.codex?.fastMode === "boolean"
149
140
  ? modelOptions.codex.fastMode
150
141
  : DEFAULT_CODEX_MODEL_OPTIONS.fastMode,
@@ -154,3 +145,18 @@ export function normalizeCodexModelOptions(modelOptions?: ModelOptions, legacyEf
154
145
  export function codexServiceTierFromModelOptions(modelOptions: CodexModelOptions): ServiceTier | undefined {
155
146
  return modelOptions.fastMode ? "fast" : undefined
156
147
  }
148
+
149
+ export function normalizeCursorModelOptions(modelOptions?: ModelOptions): CursorModelOptions {
150
+ return {
151
+ fastMode: typeof modelOptions?.cursor?.fastMode === "boolean"
152
+ ? modelOptions.cursor.fastMode
153
+ : DEFAULT_CURSOR_MODEL_OPTIONS.fastMode,
154
+ }
155
+ }
156
+
157
+ // Cursor encodes "fast" in the model id itself (composer-2.5 vs composer-2.5-fast),
158
+ // so we apply the suffix at spawn time rather than tracking a separate service tier.
159
+ export function cursorModelIdForOptions(baseModel: string, modelOptions: CursorModelOptions): string {
160
+ if (!modelOptions.fastMode) return baseModel
161
+ return baseModel.endsWith("-fast") ? baseModel : `${baseModel}-fast`
162
+ }
@@ -146,6 +146,9 @@ describe("read models", () => {
146
146
  expect(chat?.history.recentLimit).toBe(200)
147
147
  expect(chat?.availableProviders.length).toBeGreaterThan(1)
148
148
  expect(chat?.availableProviders.find((provider) => provider.id === "codex")?.models.map((model) => model.id)).toEqual([
149
+ "gpt-5.6-sol",
150
+ "gpt-5.6-terra",
151
+ "gpt-5.6-luna",
149
152
  "gpt-5.5",
150
153
  "gpt-5.4",
151
154
  "gpt-5.3-codex",
@@ -397,6 +400,18 @@ describe("read models", () => {
397
400
  sessionToken: "thread-1",
398
401
  lastTurnOutcome: null,
399
402
  })
403
+ state.chatsById.set("chat-cursor", {
404
+ id: "chat-cursor",
405
+ projectId: "project-1",
406
+ title: "Cursor",
407
+ createdAt: 4,
408
+ updatedAt: 4,
409
+ unread: false,
410
+ provider: "cursor",
411
+ planMode: false,
412
+ sessionToken: "cursor-session",
413
+ lastTurnOutcome: null,
414
+ })
400
415
 
401
416
  const sidebar = deriveSidebarData(
402
417
  state,
@@ -407,5 +422,7 @@ describe("read models", () => {
407
422
  expect(sidebar.projectGroups[0]?.chats.find((chat) => chat.chatId === "chat-active")?.canFork).toBeUndefined()
408
423
  expect(sidebar.projectGroups[0]?.chats.find((chat) => chat.chatId === "chat-pending")?.canFork).toBe(true)
409
424
  expect(sidebar.projectGroups[0]?.chats.find((chat) => chat.chatId === "chat-draining")?.canFork).toBeUndefined()
425
+ // Cursor has no fork primitive, so forking is disabled even with a live session.
426
+ expect(sidebar.projectGroups[0]?.chats.find((chat) => chat.chatId === "chat-cursor")?.canFork).toBeUndefined()
410
427
  })
411
428
  })
@@ -31,6 +31,8 @@ function canForkChat(
31
31
  drainingChatIds: Set<string>,
32
32
  ) {
33
33
  if (!chat.provider) return false
34
+ // Cursor has no fork/branch primitive, so forking would silently start a fresh session.
35
+ if (chat.provider === "cursor") return false
34
36
  if (!chat.sessionToken && !chat.pendingForkSessionToken) return false
35
37
  if (activeStatuses.has(chat.id)) return false
36
38
  if (drainingChatIds.has(chat.id)) return false
@@ -0,0 +1,14 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import type { TranscriptEntry } from "../shared/types"
3
+
4
+ /** Stamp a transcript entry with a generated id and creation time. */
5
+ export function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(
6
+ entry: T,
7
+ createdAt = Date.now()
8
+ ): TranscriptEntry {
9
+ return {
10
+ _id: randomUUID(),
11
+ createdAt,
12
+ ...entry,
13
+ } as TranscriptEntry
14
+ }
@@ -102,6 +102,13 @@ const DEFAULT_APP_SETTINGS_SNAPSHOT: AppSettingsSnapshot = {
102
102
  },
103
103
  planMode: false,
104
104
  },
105
+ cursor: {
106
+ model: "composer-2.5",
107
+ modelOptions: {
108
+ fastMode: false,
109
+ },
110
+ planMode: false,
111
+ },
105
112
  },
106
113
  warning: null,
107
114
  filePathDisplay: "~/.kanna/data/settings.json",
@@ -15,7 +15,7 @@ import { EventStore } from "./event-store"
15
15
  import { openExternal } from "./external-open"
16
16
  import { KeybindingsManager } from "./keybindings"
17
17
  import { killLocalHttpServer, listLocalHttpServers } from "./local-http-servers"
18
- import { ensureProjectDirectory, resolveLocalPath } from "./paths"
18
+ import { cloneRepository, ensureProjectDirectory, resolveClonePath, resolveLocalPath } from "./paths"
19
19
  import { readProjectQuickActions, writeProjectQuickActions } from "./project-quick-actions"
20
20
  import { writeStandaloneTranscriptExport } from "./standalone-export"
21
21
  import { TerminalManager } from "./terminal-manager"
@@ -473,9 +473,16 @@ export function createWsRouter({
473
473
  planMode: false,
474
474
  },
475
475
  codex: {
476
- model: "gpt-5.5",
476
+ model: "gpt-5.6-sol",
477
+ modelOptions: {
478
+ reasoningEffort: "medium",
479
+ fastMode: false,
480
+ },
481
+ planMode: false,
482
+ },
483
+ cursor: {
484
+ model: "composer-2.5",
477
485
  modelOptions: {
478
- reasoningEffort: "high",
479
486
  fastMode: false,
480
487
  },
481
488
  planMode: false,
@@ -512,6 +519,14 @@ export function createWsRouter({
512
519
  ...patch.providerDefaults?.codex?.modelOptions,
513
520
  },
514
521
  },
522
+ cursor: {
523
+ ...snapshot.providerDefaults.cursor,
524
+ ...patch.providerDefaults?.cursor,
525
+ modelOptions: {
526
+ ...snapshot.providerDefaults.cursor.modelOptions,
527
+ ...patch.providerDefaults?.cursor?.modelOptions,
528
+ },
529
+ },
515
530
  },
516
531
  })
517
532
  const resolvedAppSettings = {
@@ -1212,6 +1227,14 @@ export function createWsRouter({
1212
1227
  await broadcastFilteredSnapshots({ includeSidebar: true })
1213
1228
  return
1214
1229
  }
1230
+ case "project.clone": {
1231
+ const cloneDest = await resolveClonePath(command.localPath, command.fallbackPath)
1232
+ await cloneRepository(command.cloneUrl, cloneDest)
1233
+ const project = await store.openProject(cloneDest, command.title)
1234
+ await refreshDiscovery()
1235
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { projectId: project.id, localPath: cloneDest } })
1236
+ break
1237
+ }
1215
1238
  case "project.remove": {
1216
1239
  await store.removeProject(command.projectId)
1217
1240
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Exhaustiveness guard for discriminated unions. Reaching this at runtime means
3
+ * a case was missed; referencing `value: never` makes it a compile-time error too.
4
+ */
5
+ export function assertNever(value: never): never {
6
+ throw new Error(`Unexpected value: ${JSON.stringify(value)}`)
7
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Utilities for detecting and parsing GitHub/GitLab clone URLs.
3
+ */
4
+
5
+ const GIT_URL_PATTERNS = [
6
+ // HTTPS: https://github.com/owner/repo or https://github.com/owner/repo.git
7
+ /^https?:\/\/(github\.com|gitlab\.com)\/([^/]+)\/([^/.]+?)(?:\.git)?\/?$/,
8
+ // SSH: git@github.com:owner/repo.git
9
+ /^git@(github\.com|gitlab\.com):([^/]+)\/([^/.]+?)(?:\.git)?\/?$/,
10
+ ]
11
+
12
+ export interface ParsedGitUrl {
13
+ host: string
14
+ owner: string
15
+ repo: string
16
+ url: string
17
+ }
18
+
19
+ /**
20
+ * Check if a string looks like a GitHub or GitLab repository URL.
21
+ */
22
+ export function isGitRepoUrl(input: string): boolean {
23
+ const trimmed = input.trim()
24
+ return GIT_URL_PATTERNS.some((pattern) => pattern.test(trimmed))
25
+ }
26
+
27
+ /**
28
+ * Parse a GitHub/GitLab URL into its components.
29
+ * Returns null if the input isn't a valid git repo URL.
30
+ */
31
+ export function parseGitRepoUrl(input: string): ParsedGitUrl | null {
32
+ const trimmed = input.trim()
33
+ for (const pattern of GIT_URL_PATTERNS) {
34
+ const match = trimmed.match(pattern)
35
+ if (match) {
36
+ return {
37
+ host: match[1]!,
38
+ owner: match[2]!,
39
+ repo: match[3]!,
40
+ url: trimmed,
41
+ }
42
+ }
43
+ }
44
+ return null
45
+ }
46
+
47
+ /**
48
+ * Normalize a git repo URL to HTTPS format for cloning.
49
+ */
50
+ export function toCloneUrl(input: string): string {
51
+ const parsed = parseGitRepoUrl(input)
52
+ if (!parsed) return input.trim()
53
+ return `https://${parsed.host}/${parsed.owner}/${parsed.repo}.git`
54
+ }
@@ -0,0 +1,14 @@
1
+ /** Small, dependency-free coercion helpers for parsing untrusted JSON values. */
2
+
3
+ export function asRecord(value: unknown): Record<string, unknown> | null {
4
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null
5
+ return value as Record<string, unknown>
6
+ }
7
+
8
+ export function asString(value: unknown): string | undefined {
9
+ return typeof value === "string" ? value : undefined
10
+ }
11
+
12
+ export function asNumber(value: unknown): number | undefined {
13
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined
14
+ }
@@ -73,6 +73,7 @@ export type ClientCommand =
73
73
  | { type: "project.open"; localPath: string }
74
74
  | { type: "project.create"; localPath: string; title: string }
75
75
  | { type: "project.rename"; projectId: string; title: string }
76
+ | { type: "project.clone"; cloneUrl: string; localPath: string; fallbackPath?: string; title: string }
76
77
  | { type: "project.remove"; projectId: string }
77
78
  | { type: "sidebar.reorderProjectGroups"; projectIds: string[] }
78
79
  | { type: "project.readDiffPatch"; projectId: string; path: string }
@@ -1,8 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test"
2
2
  import {
3
3
  deriveClaudeModelLabel,
4
+ getCodexReasoningOptions,
4
5
  normalizeClaudeModelId,
5
6
  normalizeCodexModelId,
7
+ normalizeCodexReasoningEffort,
8
+ isCodexReasoningEffort,
6
9
  supportsClaudeMaxReasoningEffort,
7
10
  } from "./types"
8
11
 
@@ -21,8 +24,54 @@ describe("shared model normalization", () => {
21
24
  })
22
25
 
23
26
  test("normalizes legacy Codex aliases and defaults to the latest catalog model", () => {
24
- expect(normalizeCodexModelId()).toBe("gpt-5.5")
27
+ expect(normalizeCodexModelId()).toBe("gpt-5.6-sol")
28
+ expect(normalizeCodexModelId("gpt-5.6")).toBe("gpt-5.6-sol")
29
+ expect(normalizeCodexModelId("gpt-5.6-terra")).toBe("gpt-5.6-terra")
30
+ expect(normalizeCodexModelId("gpt-5.6-luna")).toBe("gpt-5.6-luna")
25
31
  expect(normalizeCodexModelId("gpt-5-codex")).toBe("gpt-5.3-codex")
32
+ expect(normalizeCodexModelId("not-a-real-model")).toBe("gpt-5.6-sol")
33
+ })
34
+
35
+ test("exposes model-specific GPT-5.6 reasoning efforts", () => {
36
+ expect(getCodexReasoningOptions("gpt-5.6-sol").map((option) => option.id)).toEqual([
37
+ "low", "medium", "high", "xhigh", "max", "ultra",
38
+ ])
39
+ expect(getCodexReasoningOptions("gpt-5.6-terra").map((option) => option.id)).toEqual([
40
+ "low", "medium", "high", "xhigh", "max", "ultra",
41
+ ])
42
+ expect(getCodexReasoningOptions("gpt-5.6-luna").map((option) => option.id)).toEqual([
43
+ "low", "medium", "high", "xhigh", "max",
44
+ ])
45
+ })
46
+
47
+ test("preserves all 17 supported GPT-5.6 model and reasoning combinations", () => {
48
+ const combinations = [
49
+ ["gpt-5.6-sol", ["low", "medium", "high", "xhigh", "max", "ultra"]],
50
+ ["gpt-5.6-terra", ["low", "medium", "high", "xhigh", "max", "ultra"]],
51
+ ["gpt-5.6-luna", ["low", "medium", "high", "xhigh", "max"]],
52
+ ] as const
53
+
54
+ expect(combinations.reduce((count, [, efforts]) => count + efforts.length, 0)).toBe(17)
55
+ for (const [model, efforts] of combinations) {
56
+ for (const effort of efforts) {
57
+ expect(normalizeCodexReasoningEffort(model, effort)).toBe(effort)
58
+ }
59
+ }
60
+ })
61
+
62
+ test("normalizes unsupported GPT-5.6 reasoning efforts", () => {
63
+ expect(normalizeCodexReasoningEffort("gpt-5.6-sol", "minimal")).toBe("low")
64
+ expect(normalizeCodexReasoningEffort("gpt-5.6-luna", "ultra")).toBe("max")
65
+ expect(normalizeCodexReasoningEffort("gpt-5.6-terra", "ultra")).toBe("ultra")
66
+ expect(normalizeCodexReasoningEffort("gpt-5.6-sol", "unknown")).toBe("medium")
67
+ })
68
+
69
+ test("recognizes public and legacy Codex reasoning values", () => {
70
+ expect(isCodexReasoningEffort("max")).toBe(true)
71
+ expect(isCodexReasoningEffort("ultra")).toBe(true)
72
+ expect(isCodexReasoningEffort("minimal")).toBe(true)
73
+ expect(getCodexReasoningOptions("gpt-5.6-sol").find((option) => option.id === "xhigh")?.label).toBe("Extra High")
74
+ expect(getCodexReasoningOptions("gpt-5.6-sol").find((option) => option.id === "ultra")?.description).toContain("subagents")
26
75
  })
27
76
 
28
77
  test("uses declarative metadata for Claude max-effort support", () => {