@rizal72/oc-models-pro 0.1.1 → 0.1.2

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.
Files changed (2) hide show
  1. package/package.json +5 -1
  2. package/tui.tsx +60 -5
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "@rizal72/oc-models-pro",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "OpenCode v2 model picker with prices, context, intelligence, Go credit and the cost of a request",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/rizal72/opencode-plugins.git"
9
+ },
6
10
  "license": "MIT",
7
11
  "files": [
8
12
  "*.ts",
package/tui.tsx CHANGED
@@ -250,15 +250,27 @@ function modelRow(row: Row, mine: boolean, favourite: boolean, f: Fit): string {
250
250
 
251
251
  const TRACE = join(tmpdir(), "oc-models-pro.log")
252
252
 
253
+ /** The tail is the part worth reading; the file is trimmed at startup, never during a session. */
254
+ const TRACE_LINES = 1000
255
+
253
256
  function trace(message: string): void {
254
257
  try {
255
258
  appendFileSync(TRACE, new Date().toISOString() + " " + message + "\n")
256
259
  } catch {}
257
260
  }
258
261
 
262
+ /** Keeps the log to its last `TRACE_LINES` lines, so it neither grows without bound nor loses the recent past. */
263
+ function trimTrace(): void {
264
+ try {
265
+ const lines = readFileSync(TRACE, "utf8").split("\n")
266
+ if (lines.length > TRACE_LINES) writeFileSync(TRACE, lines.slice(-TRACE_LINES).join("\n"))
267
+ } catch {}
268
+ }
269
+
259
270
  export default Plugin.define({
260
271
  id: "oc-models-pro",
261
272
  setup(context: any) {
273
+ trimTrace()
262
274
  const ui = context.ui
263
275
  const themeColour = (...paths: string[][]): any => {
264
276
  for (const path of paths) {
@@ -417,6 +429,13 @@ export default Plugin.define({
417
429
  }
418
430
 
419
431
  const Panel = (props: { panel: any }) => {
432
+ // Who had the focus before the panel took it: the prompt itself. The renderer can hand it back,
433
+ // and nothing else in the API can — no host command focuses the prompt from a plugin.
434
+ const previousFocus = context.renderer?.currentFocusedRenderable
435
+ trace(
436
+ "panel: previous focus=" +
437
+ (previousFocus ? (previousFocus.constructor?.name ?? "?") : "none"),
438
+ )
420
439
  const [selected, setSelected] = createSignal<string | undefined>(undefined)
421
440
  /** The panel's width, in columns, and the layout it buys — reactive: the host resizes it. */
422
441
  const width = createMemo(() => Math.floor(Number(props.panel.width) || 0), undefined, { equals: Object.is })
@@ -448,8 +467,31 @@ export default Plugin.define({
448
467
  let field: any
449
468
  /** Diagnostic: more than one mount per open means the panel is being rebuilt under us. */
450
469
  let inputMounts = 0
451
- /** Whether the field holds the focus — the host resets it on some reflows. */
452
- let focused = false
470
+ /**
471
+ * Who the host's renderer has focus on: the truth the blur/refocus guessing could only infer.
472
+ * Clicking a row or the field moves it without necessarily firing a blur on the input.
473
+ */
474
+ const holdsField = (): boolean => Boolean(field) && context.renderer?.currentFocusedRenderable === field
475
+ /**
476
+ * Hands the focus back to whoever held it before the panel opened — the prompt. The host does not
477
+ * do it for a panel closed by a click, and there is no command that focuses the prompt: this is
478
+ * the renderer's own method, and it is what makes a click on a row behave like `esc`.
479
+ */
480
+ const focusPrompt = (): void => {
481
+ try {
482
+ if (!previousFocus || previousFocus.isDestroyed || !context.renderer?.focusRenderable) {
483
+ trace("focus: no target")
484
+ return
485
+ }
486
+ context.renderer.focusRenderable(previousFocus)
487
+ trace(
488
+ "focus: restored, current=" +
489
+ (context.renderer.currentFocusedRenderable === previousFocus ? "yes" : "no"),
490
+ )
491
+ } catch (error) {
492
+ fail("focus", error)
493
+ }
494
+ }
453
495
  const clear = (): void => {
454
496
  setQuery("")
455
497
  if (field && !field.isDestroyed) field.value = ""
@@ -600,7 +642,13 @@ export default Plugin.define({
600
642
  selectable().find((entry) => prefKey(entry.providerID, entry.modelID) === wanted)
601
643
  if (!row) return
602
644
  trace("choose: " + prefKey(row.providerID, row.modelID))
645
+ // Esc closes with the search field focused and the host hands the prompt its focus back; a
646
+ // click leaves it on the row, so the field takes it again before the panel goes away.
647
+ if (field && !field.isDestroyed) field.focus?.()
603
648
  props.panel.close()
649
+ // The host returns the prompt's focus on its own only most of the time, and never while its
650
+ // unmount is still running: the saved renderable takes it back once the panel is gone.
651
+ if (props.panel.presentation === "panel") setTimeout(focusPrompt, 40)
604
652
  void (async () => {
605
653
  if (!(await selectModel(row))) {
606
654
  // The native never fails here — it writes the selection itself — so this is the one
@@ -692,6 +740,8 @@ export default Plugin.define({
692
740
  description: "",
693
741
  })),
694
742
  })) as string | undefined
743
+ // A dialog takes the focus and does not hand it back either: the same command, once it closed.
744
+ setTimeout(focusPrompt, 40)
695
745
  // Cancelling leaves the model without a variant, and the question will come back, as in
696
746
  // the native picker.
697
747
  if (chosen === undefined) return
@@ -762,7 +812,8 @@ export default Plugin.define({
762
812
  fitted()
763
813
  hints()
764
814
  const element = field
765
- if (focused || !element || element.isDestroyed || !props.panel.focused) return
815
+ if (!element || element.isDestroyed || !props.panel.focused || holdsField()) return
816
+ trace("reflow: field takes the focus back")
766
817
  element.focus()
767
818
  })
768
819
 
@@ -824,6 +875,12 @@ export default Plugin.define({
824
875
  borderColor={BORDER}
825
876
  paddingLeft={1}
826
877
  paddingRight={1}
878
+ onMouseDown={() => {
879
+ // A click inside the field moves the host's focus elsewhere; take it back a tick later.
880
+ setTimeout(() => {
881
+ if (field && !field.isDestroyed) field.focus()
882
+ }, 1)
883
+ }}
827
884
  >
828
885
  <input
829
886
  ref={(element: any) => {
@@ -838,8 +895,6 @@ export default Plugin.define({
838
895
  if (!element.isDestroyed) element.focus()
839
896
  }, 1)
840
897
  }}
841
- onFocus={() => (focused = true)}
842
- onBlur={() => (focused = false)}
843
898
  placeholder="Search (ctrl+u clear)"
844
899
  flexGrow={1}
845
900
  placeholderColor={themeColour(["text", "faint"])}