@cdoing/opentuicli 0.1.6 → 0.1.18

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.
@@ -1,11 +1,16 @@
1
1
  /**
2
- * DialogModel — model picker dialog (Ctrl+P)
2
+ * DialogModel — model picker dialog (Ctrl+O)
3
+ *
4
+ * Uses OpenTUI <select> for the model list with proper
5
+ * highlight styling and keyboard navigation.
3
6
  */
4
7
 
5
8
  import { TextAttributes } from "@opentui/core";
6
- import { useState } from "react";
7
- import { useKeyboard } from "@opentui/react";
9
+ import type { SelectOption } from "@opentui/core";
10
+ import { useState, useMemo } from "react";
11
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
8
12
  import { useTheme } from "../context/theme";
13
+ import { getProviders } from "@cdoing/ai";
9
14
 
10
15
  export interface ModelOption {
11
16
  id: string;
@@ -13,22 +18,11 @@ export interface ModelOption {
13
18
  hint?: string;
14
19
  }
15
20
 
16
- const MODELS: Record<string, ModelOption[]> = {
17
- anthropic: [
18
- { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", hint: "fast & smart" },
19
- { id: "claude-opus-4-6", name: "Claude Opus 4.6", hint: "most capable" },
20
- { id: "claude-haiku-4-5", name: "Claude Haiku 4.5", hint: "fastest" },
21
- ],
22
- openai: [
23
- { id: "gpt-4o", name: "GPT-4o", hint: "recommended" },
24
- { id: "gpt-4o-mini", name: "GPT-4o mini", hint: "fastest" },
25
- { id: "o3", name: "o3", hint: "reasoning" },
26
- ],
27
- google: [
28
- { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash", hint: "fast" },
29
- { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", hint: "most capable" },
30
- ],
31
- };
21
+ // Build model map from centralized catalog
22
+ const MODELS: Record<string, ModelOption[]> = {};
23
+ for (const p of getProviders() as Array<{ id: string; models: Array<{ id: string; label: string; hint?: string }> }>) {
24
+ MODELS[p.id] = p.models.map((m) => ({ id: m.id, name: m.label, hint: m.hint }));
25
+ }
32
26
 
33
27
  export function DialogModel(props: {
34
28
  provider: string;
@@ -36,23 +30,50 @@ export function DialogModel(props: {
36
30
  onSelect: (model: string) => void;
37
31
  onClose: () => void;
38
32
  }) {
39
- const { theme } = useTheme();
33
+ const { theme, customBg } = useTheme();
40
34
  const t = theme;
35
+ const dims = useTerminalDimensions();
41
36
  const models = MODELS[props.provider] || [];
42
- const [selected, setSelected] = useState(
43
- Math.max(0, models.findIndex((m) => m.id === props.currentModel))
44
- );
37
+ const [isCustom, setIsCustom] = useState(false);
38
+ const [customInput, setCustomInput] = useState("");
39
+
40
+ // Build SelectOption list: models + "Custom model..." at the end
41
+ const selectOptions: SelectOption[] = useMemo(() => {
42
+ const opts: SelectOption[] = models.map((m) => ({
43
+ name: m.name,
44
+ description: [
45
+ m.hint || "",
46
+ m.id === props.currentModel ? "● current" : "",
47
+ ].filter(Boolean).join(" "),
48
+ value: m.id,
49
+ }));
50
+ opts.push({
51
+ name: "Custom model...",
52
+ description: "type any model name",
53
+ value: "__custom__",
54
+ });
55
+ return opts;
56
+ }, [models, props.currentModel]);
57
+
58
+ const initialIndex = Math.max(0, models.findIndex((m) => m.id === props.currentModel));
45
59
 
46
60
  useKeyboard((key: any) => {
47
61
  if (key.name === "escape" || (key.ctrl && key.name === "c")) {
62
+ if (isCustom) { setIsCustom(false); return; }
48
63
  props.onClose();
49
- } else if (key.name === "up" || key.name === "k") {
50
- setSelected((s) => Math.max(0, s - 1));
51
- } else if (key.name === "down" || key.name === "j") {
52
- setSelected((s) => Math.min(models.length - 1, s + 1));
53
- } else if (key.name === "return") {
54
- const m = models[selected];
55
- if (m) props.onSelect(m.id);
64
+ return;
65
+ }
66
+ if (!isCustom) return; // Let <select> handle navigation
67
+ // Custom model text input mode
68
+ if (key.name === "return") {
69
+ const m = customInput.trim();
70
+ if (m) props.onSelect(m);
71
+ } else if (key.name === "backspace") {
72
+ setCustomInput((s) => s.slice(0, -1));
73
+ } else if (key.ctrl && key.name === "u") {
74
+ setCustomInput("");
75
+ } else if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
76
+ setCustomInput((s) => s + key.sequence);
56
77
  }
57
78
  });
58
79
 
@@ -60,34 +81,62 @@ export function DialogModel(props: {
60
81
  <box
61
82
  borderStyle="double"
62
83
  borderColor={t.primary}
84
+ backgroundColor={customBg || t.bg}
63
85
  paddingX={1}
64
86
  paddingY={1}
65
87
  flexDirection="column"
66
88
  position="absolute"
67
- top="30%"
68
- left="20%"
69
- width="60%"
89
+ top={Math.max(2, Math.floor((dims.height || 24) * 0.25))}
90
+ left={Math.max(1, Math.floor(((dims.width || 80) - Math.min(60, (dims.width || 80) - 4)) / 2))}
91
+ width={Math.min(60, (dims.width || 80) - 4)}
70
92
  >
71
93
  <text fg={t.primary} attributes={TextAttributes.BOLD}>
72
94
  {" Select Model"}
73
95
  </text>
74
96
  <text fg={t.textDim}>{` Provider: ${props.provider}`}</text>
75
- <text fg={t.textDim}>{""}</text>
76
- {models.map((model, i) => (
77
- <box key={model.id}>
78
- <text
79
- fg={i === selected ? t.primary : t.text}
80
- attributes={i === selected ? TextAttributes.BOLD : undefined}
81
- >
82
- {` ${i === selected ? "❯" : " "} ${model.name}`}
83
- </text>
84
- <text fg={t.textDim}>{model.hint ? ` ${model.hint}` : ""}</text>
85
- <text fg={model.id === props.currentModel ? t.success : t.textDim}>
86
- {model.id === props.currentModel ? " ●" : ""}
87
- </text>
88
- </box>
89
- ))}
90
- <text fg={t.textDim}>{"\n ↑↓ Navigate Enter Select Esc Close"}</text>
97
+ <text>{""}</text>
98
+ {isCustom ? (
99
+ <>
100
+ <text fg={t.text}>{" Enter custom model ID:"}</text>
101
+ <box flexDirection="row">
102
+ <text fg={t.primary}>{" > "}</text>
103
+ <text fg={t.text}>{customInput}</text>
104
+ <text fg={t.primary} attributes={TextAttributes.BOLD}>{"_"}</text>
105
+ </box>
106
+ <text>{""}</text>
107
+ <text fg={t.textDim}>{" Enter Confirm Ctrl+U Clear Esc Back"}</text>
108
+ </>
109
+ ) : (
110
+ <>
111
+ <select
112
+ options={selectOptions}
113
+ focused={!isCustom}
114
+ selectedIndex={initialIndex}
115
+ height={Math.min(selectOptions.length, 10)}
116
+ showDescription={true}
117
+ backgroundColor={customBg || undefined}
118
+ focusedBackgroundColor={customBg || undefined}
119
+ textColor={t.text}
120
+ focusedTextColor={t.text}
121
+ selectedBackgroundColor={t.primary}
122
+ selectedTextColor={t.bg}
123
+ descriptionColor={t.textDim}
124
+ selectedDescriptionColor={t.bg}
125
+ showScrollIndicator={selectOptions.length > 10}
126
+ onSelect={(_index: number, option: SelectOption | null) => {
127
+ if (!option) return;
128
+ if (option.value === "__custom__") {
129
+ setIsCustom(true);
130
+ setCustomInput("");
131
+ } else {
132
+ props.onSelect(option.value);
133
+ }
134
+ }}
135
+ />
136
+ <text>{""}</text>
137
+ <text fg={t.textDim}>{" ↑↓ Navigate Enter Select Esc Close"}</text>
138
+ </>
139
+ )}
91
140
  </box>
92
141
  );
93
142
  }
@@ -1,20 +1,21 @@
1
1
  /**
2
2
  * DialogStatus — system status dialog showing provider, tools, config info.
3
- * Scrollable overlay with sections for Provider, System, and Tools.
3
+ * Uses native <scrollbox> for smooth scrolling through sections.
4
4
  */
5
5
 
6
6
  import { TextAttributes } from "@opentui/core";
7
- import { useState } from "react";
8
7
  import { useKeyboard, useTerminalDimensions } from "@opentui/react";
9
8
  import { useTheme } from "../context/theme";
10
9
  import { useSDK } from "../context/sdk";
11
10
 
12
11
  export function DialogStatus(props: { onClose: () => void }) {
13
- const { theme } = useTheme();
12
+ const { theme, customBg } = useTheme();
14
13
  const t = theme;
15
14
  const sdk = useSDK();
16
15
  const dims = useTerminalDimensions();
17
- const [scrollOffset, setScrollOffset] = useState(0);
16
+
17
+ const dialogWidth = Math.min(60, (dims.width || 80) - 4);
18
+ const dialogHeight = Math.max(10, (dims.height || 24) - 6);
18
19
 
19
20
  // Gather status info
20
21
  const allTools = sdk.registry.getAll ? sdk.registry.getAll() : [];
@@ -24,99 +25,60 @@ export function DialogStatus(props: { onClose: () => void }) {
24
25
  )
25
26
  : [];
26
27
 
27
- const sections: Array<{ title: string; rows: Array<[string, string]> }> = [
28
- {
29
- title: "Provider",
30
- rows: [
31
- ["Provider", sdk.provider],
32
- ["Model", sdk.model],
33
- ["Directory", sdk.workingDir],
34
- ],
35
- },
36
- {
37
- title: "System",
38
- rows: [
39
- ["Node", process.version],
40
- ["Platform", `${process.platform} ${process.arch}`],
41
- [
42
- "Terminal",
43
- process.env.TERM_PROGRAM || process.env.TERM || "unknown",
44
- ],
45
- ["Shell", process.env.SHELL || "unknown"],
46
- ],
47
- },
48
- {
49
- title: `Tools (${toolNames.length})`,
50
- rows: toolNames.slice(0, 20).map((name: string) => ["\u2022", name]),
51
- },
52
- ];
53
-
54
- // Build flat lines for scrolling
55
- const lines: Array<{
56
- type: "header" | "row";
57
- text: string;
58
- value?: string;
59
- }> = [];
60
- for (const section of sections) {
61
- lines.push({ type: "header", text: section.title });
62
- for (const [label, value] of section.rows) {
63
- lines.push({ type: "row", text: label, value });
64
- }
65
- lines.push({ type: "row", text: "", value: "" }); // spacer
66
- }
67
-
68
- const maxVisible = Math.max(5, (dims.height || 24) - 10);
69
-
70
28
  useKeyboard((key: any) => {
71
29
  if (key.name === "escape" || key.name === "q") props.onClose();
72
- if (key.name === "up" || key.name === "k")
73
- setScrollOffset((s) => Math.max(0, s - 1));
74
- if (key.name === "down" || key.name === "j")
75
- setScrollOffset((s) => Math.min(lines.length - maxVisible, s + 1));
76
30
  });
77
31
 
78
- const visible = lines.slice(scrollOffset, scrollOffset + maxVisible);
79
-
80
32
  return (
81
33
  <box
82
34
  borderStyle="double"
83
35
  borderColor={t.primary}
36
+ backgroundColor={customBg || t.bg}
84
37
  paddingX={1}
85
38
  paddingY={1}
86
39
  flexDirection="column"
87
40
  position="absolute"
88
- top="10%"
89
- left="15%"
90
- width="70%"
41
+ top={Math.max(1, Math.floor((dims.height || 24) * 0.1))}
42
+ left={Math.max(1, Math.floor(((dims.width || 80) - dialogWidth) / 2))}
43
+ width={dialogWidth}
44
+ height={dialogHeight}
91
45
  >
92
- <text fg={t.primary} attributes={TextAttributes.BOLD}>
93
- {" System Status"}
94
- </text>
95
- <text>{""}</text>
96
- {visible.map((line, i) => {
97
- if (line.type === "header") {
98
- return (
99
- <text
100
- key={`h-${i}`}
101
- fg={t.secondary}
102
- attributes={TextAttributes.BOLD}
103
- >
104
- {` ${line.text}`}
105
- </text>
106
- );
107
- }
108
- if (!line.text && !line.value) return <text key={`s-${i}`}>{""}</text>;
109
- return (
110
- <box key={`r-${i}`} flexDirection="row">
111
- <text fg={t.textMuted}>{` ${line.text}`}</text>
112
- {line.value && <text fg={t.text}>{` ${line.value}`}</text>}
113
- </box>
114
- );
115
- })}
116
- <text>{""}</text>
117
- <text fg={t.textDim}>
118
- {" \u2191\u2193 Scroll Esc Close"}
119
- </text>
46
+ {/* Title bar */}
47
+ <box flexDirection="row" flexShrink={0}>
48
+ <text fg={t.primary} attributes={TextAttributes.BOLD} flexGrow={1}>
49
+ {" System Status"}
50
+ </text>
51
+ <text fg={t.textDim}>{"esc"}</text>
52
+ </box>
53
+ <text flexShrink={0}>{""}</text>
54
+
55
+ <scrollbox flexGrow={1}>
56
+ <box flexShrink={0}>
57
+ {/* Provider */}
58
+ <text fg={t.secondary} attributes={TextAttributes.BOLD}>{" Provider"}</text>
59
+ <box flexDirection="row"><text fg={t.textMuted}>{" Provider "}</text><text fg={t.text}>{sdk.provider}</text></box>
60
+ <box flexDirection="row"><text fg={t.textMuted}>{" Model "}</text><text fg={t.text}>{sdk.model}</text></box>
61
+ <box flexDirection="row"><text fg={t.textMuted}>{" Directory "}</text><text fg={t.text}>{sdk.workingDir}</text></box>
62
+ <text>{""}</text>
63
+
64
+ {/* System */}
65
+ <text fg={t.secondary} attributes={TextAttributes.BOLD}>{" System"}</text>
66
+ <box flexDirection="row"><text fg={t.textMuted}>{" Node "}</text><text fg={t.text}>{process.version}</text></box>
67
+ <box flexDirection="row"><text fg={t.textMuted}>{" Platform "}</text><text fg={t.text}>{`${process.platform} ${process.arch}`}</text></box>
68
+ <box flexDirection="row"><text fg={t.textMuted}>{" Terminal "}</text><text fg={t.text}>{process.env.TERM_PROGRAM || process.env.TERM || "unknown"}</text></box>
69
+ <box flexDirection="row"><text fg={t.textMuted}>{" Shell "}</text><text fg={t.text}>{process.env.SHELL || "unknown"}</text></box>
70
+ <text>{""}</text>
71
+
72
+ {/* Tools */}
73
+ <text fg={t.secondary} attributes={TextAttributes.BOLD}>{` Tools (${toolNames.length})`}</text>
74
+ {toolNames.map((name: string) => (
75
+ <box key={name} flexDirection="row">
76
+ <text fg={t.textMuted}>{" • "}</text>
77
+ <text fg={t.text}>{name}</text>
78
+ </box>
79
+ ))}
80
+ </box>
81
+ </scrollbox>
120
82
  </box>
121
83
  );
122
84
  }