@javierkaiser/opencode-indexing 0.1.1 → 0.2.1

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.
package/tui.ts CHANGED
@@ -24,10 +24,13 @@ import type { ThemeLike } from "./tui/page-style.ts"
24
24
  import { PAGE_ACTIONS, PAGE_ROWS, type SettingsPageRpc } from "./tui/settings-page-data.ts"
25
25
  import { footerAction, footerState, type IndexingState } from "./tui/footer.ts"
26
26
  import { activeWorkspace, type WorkspaceSources } from "./tui/footer.ts"
27
+ import { bindWorkspace } from "./tui/workspace-rpc.ts"
27
28
  import { describeRow, forgetWorkspaceFlow, type WorkspacesRpc } from "./tui/workspaces.ts"
28
29
  import { pagePalette } from "./tui/page-style.ts"
29
30
  import type { FooterHandle, FooterState } from "./tui/footer-view.tsx"
30
31
  import { describeError } from "./src/errors.ts"
32
+ import { getDefaultModelId, isLiveTested, listModelIds } from "./src/registry.ts"
33
+ import type { EmbedderProvider } from "./src/types.ts"
31
34
 
32
35
  /**
33
36
  * Navigable rows on the page.
@@ -40,11 +43,21 @@ const PAGE_ROW_COUNT = PAGE_ROWS.length
40
43
 
41
44
  const ID = "opencode.indexing"
42
45
 
46
+ /** Sentinel for the "type it yourself" entry in the model chooser. */
47
+ const CUSTOM_MODEL = "\u0000custom-model"
48
+
43
49
  type RpcClient = {
44
50
  "settings.get": (input: Record<string, never>) => Promise<{ settings: SettingsView }>
45
51
  "settings.set": (input: { patch: Record<string, unknown> }) => Promise<{ settings: SettingsView }>
46
- "settings.test": (input: { target: "qdrant" | "lancedb" | "provider" | "kilo" }) => Promise<{ ok: boolean; message: string }>
47
- "kilo.discover": () => Promise<{ sources: Array<{ kind: string; name: string; pointsCount: number | null; compatible: boolean; profile: string | null }> }>
52
+ "settings.test": (input: {
53
+ target: "qdrant" | "lancedb" | "provider" | "kilo"
54
+ directory?: string
55
+ }) => Promise<{ ok: boolean; message: string }>
56
+ "kilo.discover": (input?: { directory?: string }) => Promise<{
57
+ sources: Array<{ kind: string; name: string; pointsCount: number | null; compatible: boolean; profile: string | null }>
58
+ /** Sources that exist but could not be read. */
59
+ problems?: string[]
60
+ }>
48
61
  "status.get": (input: { checkFreshness?: boolean; directory?: string }) => Promise<{ summary: string; recommendation: string; ownKind: string; ownStore: string; ownPoints?: number | null; ownComplete?: boolean | null; kiloCollection?: string | null; directory?: string; ownStoreReachable?: boolean }>
49
62
  "index.build": (input: { rebuild?: boolean; skipImport?: boolean; directory?: string }) => Promise<{ summary: string; error?: string }>
50
63
  "index.refresh": (input: { maxFiles?: number; directory?: string }) => Promise<{ summary: string; error?: string }>
@@ -64,18 +77,54 @@ interface SettingsView {
64
77
  /** Master switch for writing the own index. */
65
78
  enabled: boolean
66
79
  searchMaxResults: number
67
- hasMistralKey: boolean
68
- hasOpenAiKey: boolean
80
+ /** Providers that have a usable credential or base URL configured. */
81
+ configuredProviders: string[]
69
82
  settingsFile: string
70
83
  }
71
84
 
72
85
  const plugin = {
73
86
  id: ID,
74
87
  async setup(context: Plugin.Context): Promise<void> {
88
+ /**
89
+ * The workspace the caller is working in.
90
+ *
91
+ * The server plugin serves its own instance directory, so every RPC that
92
+ * reads or writes an index must be told which workspace it is about. Without
93
+ * this the footer would report — and the palette command would index — the
94
+ * service's directory instead of the user's: the ACTIVE tab is what follows
95
+ * the user, and the instance directory is the fallback for a host with tabs
96
+ * switched off.
97
+ *
98
+ * Every accessor is optional: a host may expose neither `ui.tabs` nor
99
+ * `data.session`, and resolving the workspace must degrade rather than
100
+ * throw. Read from a reactive context (the footer slot render) the lookups
101
+ * re-run when the host's tab or session state changes.
102
+ *
103
+ * Declared before `rpc` because `rpc` binds the directory into every
104
+ * workspace-scoped call: a call made before this binding exists would be
105
+ * answered against the service's own directory.
106
+ */
107
+ const workspaceSources: WorkspaceSources = {
108
+ tabsEnabled: () => context.ui.tabs?.enabled?.() === true,
109
+ tabs: () => context.ui.tabs?.list?.() ?? [],
110
+ sessions: () => context.data?.session?.list?.() ?? [],
111
+ instanceDirectory: () => context.location?.directory,
112
+ }
113
+
114
+ const workspace = (): string | undefined => activeWorkspace(workspaceSources)
115
+
75
116
  // Resolve the RPC subclient lazily: the TUI may start before the server
76
117
  // plugin is ready, and calls are retried on first use.
118
+ //
119
+ // Every call goes through `bindWorkspace`, so a workspace-scoped method
120
+ // always names the caller's directory. Passing it per call site is what
121
+ // failed before: the footer did it and the palette did not, and the palette
122
+ // silently read and wrote the service's index instead of the user's.
77
123
  const rpc = (): RpcClient =>
78
- (context.client as unknown as { rpc: (def: unknown, options?: unknown) => RpcClient }).rpc(IndexingRpc)
124
+ bindWorkspace(
125
+ (context.client as unknown as { rpc: (def: unknown, options?: unknown) => RpcClient }).rpc(IndexingRpc),
126
+ workspace,
127
+ )
79
128
 
80
129
  const toast = (message: string, variant: "success" | "error" | "info" = "info"): void => {
81
130
  context.ui.toast.show({ title: "Indexing", message, variant, duration: 5000 })
@@ -334,13 +383,49 @@ const plugin = {
334
383
  })
335
384
  if (typeof provider !== "string") return
336
385
 
337
- const model = await context.ui.dialog.prompt({
338
- title: `Model for ${provider} (leave empty for default)`,
339
- placeholder: provider === "mistral" ? "codestral-embed-2505" : "",
386
+ // Offer the catalog instead of a blank text field. The model list existed
387
+ // all along and was never shown, so the only way to discover a valid model
388
+ // id was to already know it.
389
+ const catalog = listModelIds(provider as EmbedderProvider)
390
+ const defaultModel = getDefaultModelId(provider as EmbedderProvider)
391
+ const tested = isLiveTested(provider as EmbedderProvider)
392
+ const choice = await context.ui.dialog.select({
393
+ title: `Model for ${provider}`,
394
+ options: [
395
+ {
396
+ title: defaultModel ? `${defaultModel} (default)` : "(provider default)",
397
+ value: "",
398
+ description: tested
399
+ ? "Leave the model unset so the provider default applies"
400
+ : "Not yet exercised against the real API; see the README",
401
+ },
402
+ ...catalog
403
+ .filter((modelId) => modelId !== defaultModel)
404
+ .map((modelId) => ({ title: modelId, value: modelId, description: `${provider} model` })),
405
+ {
406
+ title: "Custom…",
407
+ value: CUSTOM_MODEL,
408
+ description: catalog.length > 0 ? "Type a model id that is not in the list" : "This provider has no catalog; type the model id",
409
+ },
410
+ ],
340
411
  })
341
- const patch: Record<string, unknown> = { provider }
342
- if (model && model.trim()) patch.model = model.trim()
343
- await patchSettings(patch)
412
+ if (typeof choice !== "string") return
413
+
414
+ let model = choice
415
+ if (choice === CUSTOM_MODEL) {
416
+ const typed = await context.ui.dialog.prompt({
417
+ title: `Model id for ${provider}`,
418
+ placeholder: defaultModel || "e.g. bge-m3",
419
+ })
420
+ if (typed === undefined) return
421
+ model = typed.trim()
422
+ }
423
+
424
+ // `model` is always written, empty when the user accepted the default.
425
+ // Omitting it left the previous provider's model in the file, where it
426
+ // outranks the new provider's default: switching to voyage produced
427
+ // `voyage` + `codestral-embed-2505`.
428
+ await patchSettings({ provider, model })
344
429
 
345
430
  // API key for the provider (masked prompt).
346
431
  const keyLabel =
@@ -357,7 +442,7 @@ const plugin = {
357
442
  : null
358
443
 
359
444
  if (keyLabel) {
360
- const current = provider === "mistral" && settings.hasMistralKey
445
+ const current = settings.configuredProviders.includes(provider)
361
446
  const key = await context.ui.dialog.prompt({
362
447
  title: `${keyLabel} (leave empty to keep current)`,
363
448
  placeholder: current ? "•••••• (set)" : "not set",
@@ -405,14 +490,24 @@ const plugin = {
405
490
 
406
491
  async function kiloMenu(): Promise<void> {
407
492
  let sources: Array<{ kind: string; name: string; pointsCount: number | null; compatible: boolean; profile: string | null }> = []
493
+ let problems: string[] = []
408
494
  try {
409
- sources = (await rpc()["kilo.discover"]()).sources
495
+ const found = await rpc()["kilo.discover"]({})
496
+ sources = found.sources
497
+ problems = found.problems ?? []
410
498
  } catch (error) {
411
499
  fail(error)
412
500
  return
413
501
  }
414
502
  if (sources.length === 0) {
415
- toast("No Kilo index found for this workspace", "info")
503
+ // "Found but unreadable" and "never indexed" look identical from here,
504
+ // and only one of them is fixed by indexing. Say which one it is.
505
+ toast(
506
+ problems.length > 0
507
+ ? `No importable Kilo index: ${problems.join(" · ")}`
508
+ : "No Kilo index found for this workspace",
509
+ problems.length > 0 ? "error" : "info",
510
+ )
416
511
  return
417
512
  }
418
513
  const choice = await context.ui.dialog.select({
@@ -461,7 +556,7 @@ const plugin = {
461
556
  toast("Running…", "info")
462
557
  try {
463
558
  if (action === "refresh") {
464
- reportRun(await rpc()["index.refresh"]({ directory: workspace() }))
559
+ reportRun(await rpc()["index.refresh"]({}))
465
560
  } else if (action === "build") {
466
561
  reportRun(await rpc()["index.build"]({}))
467
562
  } else if (action === "build-scratch") {
@@ -541,30 +636,6 @@ const plugin = {
541
636
  return route?.type === "plugin" && route.name === SETTINGS_PAGE_NAME
542
637
  }
543
638
 
544
- /**
545
- * The workspace the caller is working in.
546
- *
547
- * The server plugin serves its own instance directory, so every RPC that
548
- * reads or writes an index must be told which workspace it is about. Without
549
- * this the footer would report — and the palette command would index — the
550
- * service's directory instead of the user's: the ACTIVE tab is what follows
551
- * the user, and the instance directory is the fallback for a host with tabs
552
- * switched off.
553
- *
554
- * Every accessor is optional: a host may expose neither `ui.tabs` nor
555
- * `data.session`, and resolving the workspace must degrade rather than
556
- * throw. Read from a reactive context (the footer slot render) the lookups
557
- * re-run when the host's tab or session state changes.
558
- */
559
- const workspaceSources: WorkspaceSources = {
560
- tabsEnabled: () => context.ui.tabs?.enabled?.() === true,
561
- tabs: () => context.ui.tabs?.list?.() ?? [],
562
- sessions: () => context.data?.session?.list?.() ?? [],
563
- instanceDirectory: () => context.location?.directory,
564
- }
565
-
566
- const workspace = (): string | undefined => activeWorkspace(workspaceSources)
567
-
568
639
  /** Opens the dialog editor, holding the page keys off for its duration. */
569
640
  async function openEditor(): Promise<void> {
570
641
  dialogOpen = true
@@ -779,8 +850,8 @@ const plugin = {
779
850
  toast("Indexing started…", "info")
780
851
  const summary =
781
852
  from.state === "stale"
782
- ? await rpc()["index.refresh"]({ directory: workspace() })
783
- : await rpc()["index.build"]({ skipImport: false, directory: workspace() })
853
+ ? await rpc()["index.refresh"]({})
854
+ : await rpc()["index.build"]({ skipImport: false })
784
855
  // A failed run comes back as data, not as a rejection: the reason is far
785
856
  // more useful than "something went wrong", and it is already readable.
786
857
  reportRun(summary)