mini-coder 0.4.1 → 0.5.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/README.md +89 -48
- package/assets/icon-1-minimal.svg +31 -0
- package/assets/icon-2-dark-terminal.svg +48 -0
- package/assets/icon-3-gradient-modern.svg +45 -0
- package/assets/icon-4-filled-bold.svg +54 -0
- package/assets/icon-5-community-badge.svg +63 -0
- package/assets/preview-0-5-0.png +0 -0
- package/assets/preview.gif +0 -0
- package/bin/mc.ts +14 -0
- package/bun.lock +438 -0
- package/package.json +12 -29
- package/src/agent.ts +640 -0
- package/src/cli.ts +124 -0
- package/src/git.ts +171 -0
- package/src/headless.ts +140 -0
- package/src/index.ts +666 -0
- package/src/input.ts +155 -0
- package/src/paths.ts +37 -0
- package/src/plugins.ts +183 -0
- package/src/prompt.ts +301 -0
- package/src/session.ts +1043 -0
- package/src/settings.ts +191 -0
- package/src/skills.ts +262 -0
- package/src/submit.ts +323 -0
- package/src/theme.ts +147 -0
- package/src/tools.ts +636 -0
- package/src/ui/agent.test.ts +49 -0
- package/src/ui/agent.ts +210 -0
- package/src/ui/commands.test.ts +610 -0
- package/src/ui/commands.ts +638 -0
- package/src/ui/conversation.test.ts +892 -0
- package/src/ui/conversation.ts +926 -0
- package/src/ui/help.test.ts +44 -0
- package/src/ui/help.ts +125 -0
- package/src/ui/input.test.ts +74 -0
- package/src/ui/input.ts +138 -0
- package/src/ui/overlay.test.ts +42 -0
- package/src/ui/overlay.ts +59 -0
- package/src/ui/status.test.ts +451 -0
- package/src/ui/status.ts +357 -0
- package/src/ui.ts +694 -0
- package/.claude/settings.local.json +0 -54
- package/.prettierignore +0 -7
- package/dist/mc-edit.js +0 -275
- package/dist/mc.js +0 -7355
- package/docs/KNOWN_ISSUES.md +0 -13
- package/docs/design-decisions.md +0 -31
- package/docs/mini-coder.1.md +0 -227
- package/docs/superpowers/plans/2026-03-30-anthropic-oauth-removal.md +0 -61
- package/docs/superpowers/specs/2026-03-30-anthropic-oauth-removal-design.md +0 -47
- package/lefthook.yml +0 -4
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { DEFAULT_SHOW_REASONING } from "../settings.ts";
|
|
3
|
+
import { buildHelpText, type HelpRenderState } from "./help.ts";
|
|
4
|
+
|
|
5
|
+
describe("ui/help", () => {
|
|
6
|
+
test("buildHelpText includes current reasoning and verbose state", () => {
|
|
7
|
+
const helpState: HelpRenderState = {
|
|
8
|
+
providers: new Map(),
|
|
9
|
+
model: null,
|
|
10
|
+
agentsMd: [],
|
|
11
|
+
skills: [],
|
|
12
|
+
plugins: [],
|
|
13
|
+
showReasoning: DEFAULT_SHOW_REASONING,
|
|
14
|
+
verbose: false,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const text = buildHelpText(helpState);
|
|
18
|
+
|
|
19
|
+
expect(text).toContain(
|
|
20
|
+
`/reasoning Toggle thinking display (currently ${DEFAULT_SHOW_REASONING ? "on" : "off"})`,
|
|
21
|
+
);
|
|
22
|
+
expect(text).toContain(
|
|
23
|
+
"/verbose Toggle verbose tool rendering (currently off)",
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("buildHelpText includes the current Escape input-focus note", () => {
|
|
28
|
+
const helpState: HelpRenderState = {
|
|
29
|
+
providers: new Map(),
|
|
30
|
+
model: null,
|
|
31
|
+
agentsMd: [],
|
|
32
|
+
skills: [],
|
|
33
|
+
plugins: [],
|
|
34
|
+
showReasoning: DEFAULT_SHOW_REASONING,
|
|
35
|
+
verbose: false,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const text = buildHelpText(helpState);
|
|
39
|
+
|
|
40
|
+
expect(text).toContain("Escape blurs the input first");
|
|
41
|
+
expect(text).toContain("Tab re-focuses the input");
|
|
42
|
+
expect(text).toContain("Escape again interrupts the current turn");
|
|
43
|
+
});
|
|
44
|
+
});
|
package/src/ui/help.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/help` text rendering and command descriptions for the terminal UI.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { AppState } from "../index.ts";
|
|
8
|
+
import { COMMANDS } from "../input.ts";
|
|
9
|
+
import { abbreviatePath } from "./status.ts";
|
|
10
|
+
|
|
11
|
+
/** Help text inputs derived from application state. */
|
|
12
|
+
export interface HelpRenderState {
|
|
13
|
+
/** Available provider credentials keyed by provider id. */
|
|
14
|
+
providers: AppState["providers"];
|
|
15
|
+
/** Current model selection. */
|
|
16
|
+
model: AppState["model"];
|
|
17
|
+
/** Loaded AGENTS.md files. */
|
|
18
|
+
agentsMd: AppState["agentsMd"];
|
|
19
|
+
/** Discovered skills. */
|
|
20
|
+
skills: AppState["skills"];
|
|
21
|
+
/** Active plugins. */
|
|
22
|
+
plugins: AppState["plugins"];
|
|
23
|
+
/** Whether reasoning blocks are shown in the log. */
|
|
24
|
+
showReasoning: AppState["showReasoning"];
|
|
25
|
+
/** Whether verbose tool rendering is enabled in the log. */
|
|
26
|
+
verbose: AppState["verbose"];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Command descriptions for `/help` and command autocomplete. */
|
|
30
|
+
export const COMMAND_DESCRIPTIONS: Record<string, string> = {
|
|
31
|
+
model: "Select a model",
|
|
32
|
+
effort: "Set reasoning effort",
|
|
33
|
+
session: "Resume a session",
|
|
34
|
+
new: "New session",
|
|
35
|
+
fork: "Fork session",
|
|
36
|
+
undo: "Undo last turn",
|
|
37
|
+
reasoning: "Toggle thinking display",
|
|
38
|
+
verbose: "Toggle verbose tool rendering",
|
|
39
|
+
login: "OAuth login",
|
|
40
|
+
logout: "OAuth logout",
|
|
41
|
+
help: "Show help",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Get the `/help` description for a command, including current state when relevant.
|
|
46
|
+
*
|
|
47
|
+
* @param command - Command name.
|
|
48
|
+
* @param state - Help-relevant application state.
|
|
49
|
+
* @returns Human-readable command description.
|
|
50
|
+
*/
|
|
51
|
+
function getHelpCommandDescription(
|
|
52
|
+
command: (typeof COMMANDS)[number],
|
|
53
|
+
state: Pick<HelpRenderState, "showReasoning" | "verbose">,
|
|
54
|
+
): string {
|
|
55
|
+
const description = COMMAND_DESCRIPTIONS[command] ?? "";
|
|
56
|
+
if (command === "reasoning") {
|
|
57
|
+
return `${description} (currently ${state.showReasoning ? "on" : "off"})`;
|
|
58
|
+
}
|
|
59
|
+
if (command === "verbose") {
|
|
60
|
+
return `${description} (currently ${state.verbose ? "on" : "off"})`;
|
|
61
|
+
}
|
|
62
|
+
return description;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build the `/help` text shown in the conversation log.
|
|
67
|
+
*
|
|
68
|
+
* @param state - Help-relevant application state.
|
|
69
|
+
* @returns Multi-line help text for display.
|
|
70
|
+
*/
|
|
71
|
+
export function buildHelpText(state: HelpRenderState): string {
|
|
72
|
+
const lines: string[] = [];
|
|
73
|
+
|
|
74
|
+
lines.push("Commands:");
|
|
75
|
+
for (const command of COMMANDS) {
|
|
76
|
+
lines.push(` /${command} ${getHelpCommandDescription(command, state)}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const providerNames = Array.from(state.providers.keys());
|
|
80
|
+
lines.push("");
|
|
81
|
+
lines.push("Note:");
|
|
82
|
+
lines.push(" While a turn is running, Escape blurs the input first.");
|
|
83
|
+
lines.push(" Tab re-focuses the input.");
|
|
84
|
+
lines.push(" Escape again interrupts the current turn.");
|
|
85
|
+
|
|
86
|
+
lines.push("");
|
|
87
|
+
lines.push(
|
|
88
|
+
providerNames.length > 0
|
|
89
|
+
? `Providers: ${providerNames.join(", ")}`
|
|
90
|
+
: "Providers: none (use /login)",
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
lines.push(
|
|
94
|
+
state.model
|
|
95
|
+
? `Model: ${state.model.provider}/${state.model.id}`
|
|
96
|
+
: "Model: none (use /model)",
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
if (state.agentsMd.length > 0) {
|
|
100
|
+
lines.push("");
|
|
101
|
+
lines.push("AGENTS.md files:");
|
|
102
|
+
for (const agentFile of state.agentsMd) {
|
|
103
|
+
lines.push(` ${abbreviatePath(agentFile.path)}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (state.skills.length > 0) {
|
|
108
|
+
lines.push("");
|
|
109
|
+
lines.push("Skills:");
|
|
110
|
+
for (const skill of state.skills) {
|
|
111
|
+
const description = skill.description ? ` ${skill.description}` : "";
|
|
112
|
+
lines.push(` ${skill.name}${description}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (state.plugins.length > 0) {
|
|
117
|
+
lines.push("");
|
|
118
|
+
lines.push("Plugins:");
|
|
119
|
+
for (const plugin of state.plugins) {
|
|
120
|
+
lines.push(` ${plugin.entry.name}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return lines.join("\n");
|
|
125
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { DEFAULT_THEME } from "../theme.ts";
|
|
6
|
+
import {
|
|
7
|
+
autocompleteInputPath,
|
|
8
|
+
type InputController,
|
|
9
|
+
renderInputArea,
|
|
10
|
+
} from "./input.ts";
|
|
11
|
+
|
|
12
|
+
const tempDirs: string[] = [];
|
|
13
|
+
|
|
14
|
+
function createTempDir(): string {
|
|
15
|
+
const dir = mkdtempSync(join(tmpdir(), "mini-coder-ui-input-test-"));
|
|
16
|
+
tempDirs.push(dir);
|
|
17
|
+
return dir;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
for (const dir of tempDirs.splice(0)) {
|
|
22
|
+
rmSync(dir, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function expectTextInput(node: ReturnType<typeof renderInputArea>) {
|
|
27
|
+
if (node.type !== "textinput") {
|
|
28
|
+
throw new Error("Expected a TextInput node");
|
|
29
|
+
}
|
|
30
|
+
return node;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("ui/input", () => {
|
|
34
|
+
test("renderInputArea shows the current draft with the configured placeholder and size limits", () => {
|
|
35
|
+
const controller: InputController = {
|
|
36
|
+
onChange: () => {},
|
|
37
|
+
onFocus: () => {},
|
|
38
|
+
onBlur: () => {},
|
|
39
|
+
onKeyPress: () => undefined,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const input = expectTextInput(
|
|
43
|
+
renderInputArea(DEFAULT_THEME, controller, "draft", true),
|
|
44
|
+
);
|
|
45
|
+
const placeholder = input.props.placeholder;
|
|
46
|
+
|
|
47
|
+
expect(input.props.value).toBe("draft");
|
|
48
|
+
expect(input.props.focused).toBe(true);
|
|
49
|
+
expect(input.props.minHeight).toBe(2);
|
|
50
|
+
expect(input.props.maxHeight).toBe(10);
|
|
51
|
+
expect(input.props.padding).toEqual({ x: 1 });
|
|
52
|
+
expect(placeholder?.type).toBe("text");
|
|
53
|
+
if (!placeholder || placeholder.type !== "text") {
|
|
54
|
+
throw new Error("Expected a text placeholder");
|
|
55
|
+
}
|
|
56
|
+
expect(placeholder.content).toBe("message…");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("autocompleteInputPath completes the last file path token", () => {
|
|
60
|
+
const cwd = createTempDir();
|
|
61
|
+
mkdirSync(join(cwd, "src"), { recursive: true });
|
|
62
|
+
writeFileSync(join(cwd, "src", "ui.ts"), "", "utf-8");
|
|
63
|
+
|
|
64
|
+
expect(autocompleteInputPath("inspect src/u", cwd)).toBe(
|
|
65
|
+
"inspect src/ui.ts",
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("autocompleteInputPath returns null when no completion is available", () => {
|
|
70
|
+
const cwd = createTempDir();
|
|
71
|
+
|
|
72
|
+
expect(autocompleteInputPath("inspect src/u", cwd)).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
});
|
package/src/ui/input.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input-area rendering and path-autocomplete helpers for the terminal UI.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readdirSync } from "node:fs";
|
|
8
|
+
import { isAbsolute, join } from "node:path";
|
|
9
|
+
import { Text, TextInput } from "@cel-tui/core";
|
|
10
|
+
import type { Node } from "@cel-tui/types";
|
|
11
|
+
import type { Theme } from "../theme.ts";
|
|
12
|
+
|
|
13
|
+
/** Stable callbacks for the controlled TextInput. */
|
|
14
|
+
export interface InputController {
|
|
15
|
+
/** Update the controlled input value and re-render. */
|
|
16
|
+
onChange: (value: string) => void;
|
|
17
|
+
/** Mark the input as focused and re-render. */
|
|
18
|
+
onFocus: () => void;
|
|
19
|
+
/** Mark the input as blurred and re-render. */
|
|
20
|
+
onBlur: () => void;
|
|
21
|
+
/** Intercept submit/autocomplete keys before default editing runs. */
|
|
22
|
+
onKeyPress: (key: string) => boolean | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Find the longest common prefix across a list of strings. */
|
|
26
|
+
function getLongestCommonPrefix(values: readonly string[]): string {
|
|
27
|
+
if (values.length === 0) {
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let prefix = values[0]!;
|
|
32
|
+
for (let i = 1; i < values.length && prefix.length > 0; i++) {
|
|
33
|
+
const value = values[i]!;
|
|
34
|
+
let j = 0;
|
|
35
|
+
while (j < prefix.length && j < value.length && prefix[j] === value[j]) {
|
|
36
|
+
j++;
|
|
37
|
+
}
|
|
38
|
+
prefix = prefix.slice(0, j);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return prefix;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Attempt to autocomplete the final path token in the current draft.
|
|
46
|
+
*
|
|
47
|
+
* @param value - Current input draft.
|
|
48
|
+
* @param cwd - Working directory used to resolve relative paths.
|
|
49
|
+
* @returns The completed input value, or `null` when no completion is available.
|
|
50
|
+
*/
|
|
51
|
+
export function autocompleteInputPath(
|
|
52
|
+
value: string,
|
|
53
|
+
cwd: string,
|
|
54
|
+
): string | null {
|
|
55
|
+
const tokenMatch = /(^|\s)(\S+)$/.exec(value);
|
|
56
|
+
if (!tokenMatch?.[2]) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const token = tokenMatch[2];
|
|
61
|
+
const tokenStart = tokenMatch.index + tokenMatch[1]!.length;
|
|
62
|
+
const slashIndex = token.lastIndexOf("/");
|
|
63
|
+
const dirToken = slashIndex >= 0 ? token.slice(0, slashIndex + 1) : "";
|
|
64
|
+
const partial = token.slice(slashIndex + 1);
|
|
65
|
+
const searchDir = isAbsolute(token)
|
|
66
|
+
? dirToken || "/"
|
|
67
|
+
: join(cwd, dirToken || ".");
|
|
68
|
+
|
|
69
|
+
const entries = (() => {
|
|
70
|
+
try {
|
|
71
|
+
return readdirSync(searchDir, {
|
|
72
|
+
encoding: "utf8",
|
|
73
|
+
withFileTypes: true,
|
|
74
|
+
});
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
})();
|
|
79
|
+
if (!entries) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const showHidden = partial.startsWith(".");
|
|
84
|
+
const matches = entries
|
|
85
|
+
.filter((entry) => (showHidden ? true : !entry.name.startsWith(".")))
|
|
86
|
+
.filter((entry) => entry.name.startsWith(partial))
|
|
87
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
88
|
+
|
|
89
|
+
if (matches.length === 0) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let completedName: string | null = null;
|
|
94
|
+
if (matches.length === 1) {
|
|
95
|
+
const match = matches[0]!;
|
|
96
|
+
completedName = `${match.name}${match.isDirectory() ? "/" : ""}`;
|
|
97
|
+
} else {
|
|
98
|
+
const prefix = getLongestCommonPrefix(matches.map((entry) => entry.name));
|
|
99
|
+
if (prefix.length > partial.length) {
|
|
100
|
+
completedName = prefix;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!completedName) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return `${value.slice(0, tokenStart)}${dirToken}${completedName}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Render the input area.
|
|
113
|
+
*
|
|
114
|
+
* @param theme - Active UI theme.
|
|
115
|
+
* @param controller - Stable TextInput callbacks.
|
|
116
|
+
* @param value - Current input draft.
|
|
117
|
+
* @param focused - Whether the input should be focused.
|
|
118
|
+
* @returns The input area node.
|
|
119
|
+
*/
|
|
120
|
+
export function renderInputArea(
|
|
121
|
+
theme: Theme,
|
|
122
|
+
controller: InputController,
|
|
123
|
+
value: string,
|
|
124
|
+
focused: boolean,
|
|
125
|
+
): Node {
|
|
126
|
+
return TextInput({
|
|
127
|
+
minHeight: 2,
|
|
128
|
+
maxHeight: 10,
|
|
129
|
+
padding: { x: 1 },
|
|
130
|
+
value,
|
|
131
|
+
onChange: controller.onChange,
|
|
132
|
+
placeholder: Text("message…", { fgColor: theme.mutedText }),
|
|
133
|
+
focused,
|
|
134
|
+
onFocus: controller.onFocus,
|
|
135
|
+
onBlur: controller.onBlur,
|
|
136
|
+
onKeyPress: controller.onKeyPress,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Select } from "@cel-tui/components";
|
|
3
|
+
import type { Node } from "@cel-tui/types";
|
|
4
|
+
import { DEFAULT_THEME } from "../theme.ts";
|
|
5
|
+
import { type ActiveOverlay, renderOverlay } from "./overlay.ts";
|
|
6
|
+
|
|
7
|
+
function collectText(node: Node | null): string[] {
|
|
8
|
+
if (!node) {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
if (node.type === "text") {
|
|
12
|
+
return [node.content];
|
|
13
|
+
}
|
|
14
|
+
if (node.type === "textinput") {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
return node.children.flatMap((child) => collectText(child));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("ui/overlay", () => {
|
|
21
|
+
test("renderOverlay shows the title above the selectable body", () => {
|
|
22
|
+
const overlay: ActiveOverlay = {
|
|
23
|
+
title: "Commands",
|
|
24
|
+
select: Select({
|
|
25
|
+
items: [{ label: "overlay body", value: "body", filterText: "body" }],
|
|
26
|
+
maxVisible: 1,
|
|
27
|
+
placeholder: "type to filter...",
|
|
28
|
+
focused: true,
|
|
29
|
+
highlightColor: DEFAULT_THEME.accentText,
|
|
30
|
+
onSelect: () => {},
|
|
31
|
+
onBlur: () => {},
|
|
32
|
+
}),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const text = collectText(renderOverlay(DEFAULT_THEME, overlay));
|
|
36
|
+
const titleIndex = text.indexOf("Commands");
|
|
37
|
+
const bodyIndex = text.indexOf("overlay body");
|
|
38
|
+
|
|
39
|
+
expect(titleIndex).toBeGreaterThanOrEqual(0);
|
|
40
|
+
expect(bodyIndex).toBeGreaterThan(titleIndex);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overlay rendering primitives for the terminal UI.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { SelectInstance } from "@cel-tui/components";
|
|
8
|
+
import { Text, VStack } from "@cel-tui/core";
|
|
9
|
+
import type { Node } from "@cel-tui/types";
|
|
10
|
+
import type { Theme } from "../theme.ts";
|
|
11
|
+
|
|
12
|
+
/** Max visible items in the overlay Select. */
|
|
13
|
+
export const OVERLAY_MAX_VISIBLE = 15;
|
|
14
|
+
|
|
15
|
+
/** Horizontal padding around the overlay modal. */
|
|
16
|
+
export const OVERLAY_PADDING_X = 4;
|
|
17
|
+
|
|
18
|
+
/** Active overlay for interactive commands such as `/model` and `/session`. */
|
|
19
|
+
export interface ActiveOverlay {
|
|
20
|
+
/** The Select component instance. */
|
|
21
|
+
select: SelectInstance;
|
|
22
|
+
/** Title displayed above the Select. */
|
|
23
|
+
title: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Render the centered overlay modal for an active Select.
|
|
28
|
+
*
|
|
29
|
+
* @param theme - Active UI theme.
|
|
30
|
+
* @param overlay - Overlay title and Select instance to render.
|
|
31
|
+
* @returns The overlay node.
|
|
32
|
+
*/
|
|
33
|
+
export function renderOverlay(theme: Theme, overlay: ActiveOverlay): Node {
|
|
34
|
+
const modalHeight = OVERLAY_MAX_VISIBLE + 3;
|
|
35
|
+
|
|
36
|
+
return VStack(
|
|
37
|
+
{
|
|
38
|
+
height: "100%",
|
|
39
|
+
justifyContent: "center",
|
|
40
|
+
padding: { x: OVERLAY_PADDING_X },
|
|
41
|
+
},
|
|
42
|
+
[
|
|
43
|
+
VStack(
|
|
44
|
+
{
|
|
45
|
+
height: modalHeight,
|
|
46
|
+
bgColor: theme.overlayBg,
|
|
47
|
+
padding: { x: 1 },
|
|
48
|
+
},
|
|
49
|
+
[
|
|
50
|
+
Text(overlay.title, {
|
|
51
|
+
bold: true,
|
|
52
|
+
fgColor: theme.accentText,
|
|
53
|
+
}),
|
|
54
|
+
overlay.select(),
|
|
55
|
+
],
|
|
56
|
+
),
|
|
57
|
+
],
|
|
58
|
+
);
|
|
59
|
+
}
|