diff --git a/src/session.ts b/src/session.ts
index 4c42f81..a174f9b 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -121,6 +121,12 @@ export type MessageMeta = {
resultMd?: string;
asThinking?: boolean;
isSummary?: boolean;
+ isModelChange?: boolean;
+ modelConfig?: {
+ model: string;
+ thinkingEnabled: boolean;
+ reasoningEffort?: string;
+ };
skill?: SkillInfo;
};
@@ -598,7 +604,7 @@ The candidate skills are as follows:\n\n`;
if (!fs.existsSync(root)) {
return [];
}
- let entries: fs.Dirent[] = [];
+ let entries: fs.Dirent[];
try {
entries = fs.readdirSync(root, { withFileTypes: true });
} catch {
@@ -1326,6 +1332,25 @@ ${skillMd}
return messages;
}
+ addSessionSystemMessage(sessionId: string, content: string, meta?: MessageMeta): void {
+ const now = new Date().toISOString();
+ const message: SessionMessage = {
+ id: crypto.randomUUID(),
+ sessionId,
+ role: "system",
+ content,
+ contentParams: null,
+ messageParams: null,
+ compacted: false,
+ visible: true,
+ createTime: now,
+ updateTime: now,
+ meta,
+ };
+ this.appendSessionMessage(sessionId, message);
+ this.onAssistantMessage(message, false);
+ }
+
private normalizeSessionMessage(message: SessionMessage): SessionMessage {
if (message.role !== "tool") {
return message;
diff --git a/src/tests/dropdownMenu.test.ts b/src/tests/dropdownMenu.test.ts
new file mode 100644
index 0000000..3e4e3ef
--- /dev/null
+++ b/src/tests/dropdownMenu.test.ts
@@ -0,0 +1,148 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { calculateVisibleStart } from "../ui/DropdownMenu";
+
+test("calculateVisibleStart centers active item when possible", () => {
+ // 10 items, max 5 visible, active index 4 (middle)
+ // Should show items 2-6 (start at 2)
+ const start = calculateVisibleStart(4, 10, 5);
+ assert.equal(start, 2);
+});
+
+test("calculateVisibleStart handles active item at the beginning", () => {
+ // 10 items, max 5 visible, active index 0
+ // Should show items 0-4 (start at 0)
+ const start = calculateVisibleStart(0, 10, 5);
+ assert.equal(start, 0);
+});
+
+test("calculateVisibleStart handles active item at the end", () => {
+ // 10 items, max 5 visible, active index 9 (last)
+ // Should show items 5-9 (start at 5)
+ const start = calculateVisibleStart(9, 10, 5);
+ assert.equal(start, 5);
+});
+
+test("calculateVisibleStart handles fewer items than maxVisible", () => {
+ // 3 items, max 5 visible, active index 1
+ // Should show all items (start at 0)
+ const start = calculateVisibleStart(1, 3, 5);
+ assert.equal(start, 0);
+});
+
+test("calculateVisibleStart handles single item", () => {
+ // 1 item, max 5 visible, active index 0
+ // Should start at 0
+ const start = calculateVisibleStart(0, 1, 5);
+ assert.equal(start, 0);
+});
+
+test("calculateVisibleStart handles empty list", () => {
+ // 0 items, max 5 visible, active index 0
+ // Should start at 0
+ const start = calculateVisibleStart(0, 0, 5);
+ assert.equal(start, 0);
+});
+
+test("calculateVisibleStart handles activeIndex near start with odd maxVisible", () => {
+ // 10 items, max 7 visible (odd), active index 2
+ // floor((7-1)/2) = 3, so 2-3 = -1, clamped to 0
+ const start = calculateVisibleStart(2, 10, 7);
+ assert.equal(start, 0);
+});
+
+test("calculateVisibleStart handles activeIndex near start with even maxVisible", () => {
+ // 10 items, max 6 visible (even), active index 2
+ // floor((6-1)/2) = 2, so 2-2 = 0
+ const start = calculateVisibleStart(2, 10, 6);
+ assert.equal(start, 0);
+});
+
+test("calculateVisibleStart keeps active item centered in middle range", () => {
+ // 20 items, max 5 visible, active index 10
+ // floor((5-1)/2) = 2, so 10-2 = 8
+ const start = calculateVisibleStart(10, 20, 5);
+ assert.equal(start, 8);
+});
+
+test("calculateVisibleStart handles activeIndex at exact boundary", () => {
+ // 10 items, max 5 visible, active index 2 (boundary where centering starts)
+ // floor((5-1)/2) = 2, so 2-2 = 0
+ const start = calculateVisibleStart(2, 10, 5);
+ assert.equal(start, 0);
+});
+
+test("calculateVisibleStart handles activeIndex just after boundary", () => {
+ // 10 items, max 5 visible, active index 3
+ // floor((5-1)/2) = 2, so 3-2 = 1
+ const start = calculateVisibleStart(3, 10, 5);
+ assert.equal(start, 1);
+});
+
+test("calculateVisibleStart handles large maxVisible", () => {
+ // 10 items, max 100 visible, active index 5
+ // Should show all items (start at 0)
+ const start = calculateVisibleStart(5, 10, 100);
+ assert.equal(start, 0);
+});
+
+test("calculateVisibleStart handles activeIndex equal to totalItems", () => {
+ // 10 items, max 5 visible, active index 10 (out of bounds)
+ // floor((5-1)/2) = 2, so 10-2 = 8, clamped to 5 (10-5)
+ const start = calculateVisibleStart(10, 10, 5);
+ assert.equal(start, 5);
+});
+
+test("calculateVisibleStart with maxVisible of 1", () => {
+ // 5 items, max 1 visible, active index 2
+ // floor((1-1)/2) = 0, so 2-0 = 2, clamped to 4 (5-1)
+ const start = calculateVisibleStart(2, 5, 1);
+ assert.equal(start, 2);
+});
+
+test("calculateVisibleStart with maxVisible of 1 at end", () => {
+ // 5 items, max 1 visible, active index 4 (last)
+ // floor((1-1)/2) = 0, so 4-0 = 4, clamped to 4 (5-1)
+ const start = calculateVisibleStart(4, 5, 1);
+ assert.equal(start, 4);
+});
+
+test("calculateVisibleStart scrolling behavior - moving down", () => {
+ // Simulate scrolling through a list
+ // 10 items, max 5 visible
+
+ // Start at index 0
+ assert.equal(calculateVisibleStart(0, 10, 5), 0);
+
+ // Move to index 2 (still centered)
+ assert.equal(calculateVisibleStart(2, 10, 5), 0);
+
+ // Move to index 5 (window should scroll)
+ assert.equal(calculateVisibleStart(5, 10, 5), 3);
+
+ // Move to index 8 (near end)
+ assert.equal(calculateVisibleStart(8, 10, 5), 5);
+
+ // Move to index 9 (at end)
+ assert.equal(calculateVisibleStart(9, 10, 5), 5);
+});
+
+test("calculateVisibleStart scrolling behavior - moving up", () => {
+ // Simulate scrolling up through a list
+ // 10 items, max 5 visible
+
+ // Start at index 9 (end)
+ assert.equal(calculateVisibleStart(9, 10, 5), 5);
+
+ // Move to index 6
+ assert.equal(calculateVisibleStart(6, 10, 5), 4);
+
+ // Move to index 4 (window should scroll up)
+ assert.equal(calculateVisibleStart(4, 10, 5), 2);
+
+ // Move to index 1 (near start)
+ assert.equal(calculateVisibleStart(1, 10, 5), 0);
+
+ // Move to index 0 (at start)
+ assert.equal(calculateVisibleStart(0, 10, 5), 0);
+});
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index 9617926..8728219 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -8,6 +8,7 @@ import OpenAI from "openai";
import {
SessionManager,
type LlmStreamProgress,
+ type MessageMeta,
type SessionEntry,
type SessionMessage,
type SessionStatus,
@@ -268,12 +269,47 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R
const { changed } = writeModelConfigSelection(selection, current, projectRoot);
const next = resolveCurrentSettings(projectRoot);
setResolvedSettings(next);
+
if (!changed) {
return "Model settings unchanged";
}
+
+ const activeSessionId = sessionManager.getActiveSessionId();
+ const meta: MessageMeta = {
+ isModelChange: true,
+ modelConfig: {
+ model: selection.model,
+ thinkingEnabled: selection.thinkingEnabled,
+ reasoningEffort: selection.reasoningEffort,
+ },
+ };
+ const content = `/model\n⎿ Set model to ${selection.model}`;
+
+ if (activeSessionId) {
+ sessionManager.addSessionSystemMessage(activeSessionId, content, meta);
+ } else {
+ const now = new Date().toISOString();
+ setMessages((prev) => [
+ ...prev,
+ {
+ id: crypto.randomUUID(),
+ sessionId: "local",
+ role: "system" as const,
+ content,
+ contentParams: null,
+ messageParams: null,
+ compacted: false,
+ visible: true,
+ createTime: now,
+ updateTime: now,
+ meta,
+ },
+ ]);
+ }
+
return `Model settings updated: ${formatModelConfig(current)} → ${formatModelConfig(next)}`;
},
- [projectRoot]
+ [projectRoot, sessionManager]
);
const handleSubmit = useCallback(
diff --git a/src/ui/DropdownMenu.tsx b/src/ui/DropdownMenu.tsx
new file mode 100644
index 0000000..3a963a5
--- /dev/null
+++ b/src/ui/DropdownMenu.tsx
@@ -0,0 +1,191 @@
+import React, { useMemo } from "react";
+import { Box, Text } from "ink";
+
+/**
+ * Generic dropdown menu item structure
+ */
+export type DropdownMenuItem = {
+ /** Unique key for React list rendering */
+ key: string;
+ /** Main label text (can include status indicators) */
+ label: string;
+ /** Secondary description text (dimmed) */
+ description?: string;
+ /** Whether this item is currently selected */
+ selected?: boolean;
+ /** Whether to show a special status indicator (e.g., loaded checkmark) */
+ statusIndicator?: {
+ symbol: string;
+ color: string;
+ };
+};
+
+/**
+ * Props for the DropdownMenu component
+ */
+type DropdownMenuProps = {
+ /** List of items to display */
+ items: DropdownMenuItem[];
+ /** Index of the currently active/highlighted item */
+ activeIndex: number;
+ /** Maximum number of visible items before scrolling */
+ maxVisible?: number;
+ /** Container width in columns */
+ width: number;
+ /** Optional title displayed at the top */
+ title?: string;
+ /** Color for the title (default: "magenta") */
+ titleColor?: string;
+ /** Color for the active item indicator (default: "cyanBright") */
+ activeColor?: string;
+ /** Help text displayed at the bottom */
+ helpText?: string;
+ /** Text to display when items list is empty */
+ emptyText?: string;
+ /** Custom item renderer (overrides default rendering) */
+ renderItem?: (item: DropdownMenuItem, isActive: boolean) => React.ReactNode;
+};
+
+/**
+ * Calculate the visible window start position for scrolling
+ * Ensures the activeIndex is always visible within the window
+ */
+export function calculateVisibleStart(activeIndex: number, totalItems: number, maxVisible: number): number {
+ return Math.min(Math.max(0, activeIndex - Math.floor((maxVisible - 1) / 2)), Math.max(0, totalItems - maxVisible));
+}
+
+/**
+ * Generic dropdown menu component with scrolling support
+ * Used by Skills Dropdown, Model Dropdown, and other selection menus
+ */
+const DropdownMenu = React.memo(function DropdownMenu({
+ items,
+ activeIndex,
+ maxVisible = 8,
+ width,
+ title,
+ titleColor = "magenta",
+ activeColor = "cyanBright",
+ helpText,
+ emptyText = "No items found",
+ renderItem,
+}: DropdownMenuProps): React.ReactElement | null {
+ // Calculate visible window
+ const visibleStart = calculateVisibleStart(activeIndex, items?.length, maxVisible);
+ const visibleItems = items?.slice(visibleStart, visibleStart + maxVisible);
+
+ // 计算标签列最佳宽度:包含所有可能的前缀和后缀
+ const labelColumnWidth = useMemo(() => {
+ if (visibleItems.length === 0) {
+ return 0;
+ }
+ // 计算每个 item 实际需要的最大宽度
+ const maxContentWidth = Math.max(
+ ...visibleItems.map((item) => {
+ let width = 2; // prefix "› " or " "
+ if (item.selected !== undefined) {
+ width += 2; // "● " or "○ "
+ }
+ width += item.label.length;
+ if (item.statusIndicator) {
+ width += 2; // " ✓" or similar
+ }
+ return width;
+ })
+ );
+ const maxAllowed = Math.max(10, (width - 2) >> 1); // 容器50%宽度(减去gap),至少保留10列
+ return Math.min(maxContentWidth, maxAllowed);
+ }, [visibleItems, width]);
+
+ // Early return if no items
+ if (items?.length === 0) {
+ return (
+
+ {title ? (
+
+ {title}
+
+ ) : null}
+ {emptyText}
+ {helpText ? {helpText} : null}
+
+ );
+ }
+
+ return (
+
+ {/* Title */}
+ {title ? (
+
+
+ {title}
+
+
+ ) : null}
+
+ {/* Scroll indicator - top */}
+ {visibleStart > 0 ? (
+
+ … {visibleStart} above
+
+ ) : null}
+
+ {/* Visible items */}
+ {visibleItems.map((item, idx) => {
+ const actualIndex = visibleStart + idx;
+ const isActive = actualIndex === activeIndex;
+
+ // Use custom renderer if provided
+ if (renderItem) {
+ return {renderItem(item, isActive)};
+ }
+
+ // Default rendering with selection indicator and optional features
+ return (
+
+
+
+ {isActive ? "› " : " "}
+ {item.selected !== undefined ? (item.selected ? "●" : "○") : null} {item.label}
+ {item.statusIndicator ? (
+ {item.statusIndicator.symbol}
+ ) : null}
+
+
+ {item.description ? {`${item.description}`} : null}
+
+ );
+ })}
+
+ {/* Scroll indicator - bottom */}
+ {visibleStart + visibleItems.length < items.length ? (
+
+ … {items.length - visibleStart - visibleItems.length} more
+
+ ) : null}
+
+ {/* Help text */}
+ {helpText ? (
+
+ {helpText}
+
+ ) : null}
+
+ );
+});
+
+export default DropdownMenu;
diff --git a/src/ui/MessageView.tsx b/src/ui/MessageView.tsx
index ae9dd19..5f0c3df 100644
--- a/src/ui/MessageView.tsx
+++ b/src/ui/MessageView.tsx
@@ -16,17 +16,15 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement |
if (message.role === "user") {
const text = message.content || "(no content)";
return (
-
-
-
- {`>`}
-
-
- {text}
- {Array.isArray(message.contentParams) && message.contentParams.length > 0 ? (
- {` 📎 ${message.contentParams.length} image attachment(s)`}
- ) : null}
-
+
+
+ {`>`}
+
+
+ {text}
+ {Array.isArray(message.contentParams) && message.contentParams.length > 0 ? (
+ {` 📎 ${message.contentParams.length} image attachment(s)`}
+ ) : null}
);
@@ -48,7 +46,9 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement |
return (
- {content ? {renderMarkdown(content)} : null}
+
+ {content ? {renderMarkdown(content)} : null}
+
);
}
@@ -81,6 +81,27 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement |
}
if (message.role === "system") {
+ // 渲染模型变更消息
+ if (message.meta?.isModelChange) {
+ return (
+
+
+ {`>`}
+
+
+ /model
+
+ ⎿ Set model to{" "}
+
+ {message.meta.modelConfig?.model}
+
+ {` (${message.meta.modelConfig?.reasoningEffort || "normal"} effort)`}
+
+
+
+ );
+ }
+
if (message.meta?.skill) {
return (
diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx
index b878961..6a11431 100644
--- a/src/ui/PromptInput.tsx
+++ b/src/ui/PromptInput.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from "react";
+import React, { useEffect, useMemo, useState } from "react";
import { Box, Text, useApp, useStdout } from "ink";
import chalk from "chalk";
import {
@@ -35,6 +35,7 @@ import type { InputKey } from "./prompt";
import { useHiddenTerminalCursor, useTerminalExtendedKeys, useTerminalFocusReporting } from "./prompt";
import SlashCommandMenu from "./SlashCommandMenu";
import type { ModelConfigSelection, ReasoningEffort } from "../settings";
+import DropdownMenu from "./DropdownMenu";
export type PromptSubmission = {
text: string;
@@ -89,7 +90,7 @@ const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy:
}, [busy]);
const prefix = busy ? `${SPINNER_FRAMES[spinnerIndex]} ` : "> ";
- return {prefix};
+ return {prefix};
});
export const PromptInput = React.memo(function PromptInput({
@@ -671,8 +672,6 @@ export const PromptInput = React.memo(function PromptInput({
});
}
- const visibleSkillStart = Math.min(Math.max(0, skillsDropdownIndex - 7), Math.max(0, skills.length - 8));
- const visibleSkills = skills.slice(visibleSkillStart, visibleSkillStart + 8);
const modelDropdownItems =
modelDropdownStep === "model"
? MODEL_COMMAND_MODELS.map((model) => ({
@@ -686,6 +685,11 @@ export const PromptInput = React.memo(function PromptInput({
description: option.thinkingEnabled ? `reasoningEffort: ${option.reasoningEffort}` : "thinking disabled",
}));
+ const showFooterText = useMemo(
+ () => showMenu || showSkillsDropdown || modelDropdownStep !== null,
+ [showMenu, showSkillsDropdown, modelDropdownStep]
+ );
+
return (
{imageUrls.length > 0 ? (
@@ -715,58 +719,45 @@ export const PromptInput = React.memo(function PromptInput({
{renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder)}
{showSkillsDropdown ? (
-
-
- Select Skills
-
- {skills.length === 0 ? (
- No skills found
- ) : (
- visibleSkills.map((skill, idx) => {
- const skillIndex = visibleSkillStart + idx;
- const selected = isSkillSelected(selectedSkills, skill);
- const active = skillIndex === skillsDropdownIndex;
- return (
-
- {active ? "› " : " "}
- {selected ? "●" : "○"} {skill.name}
- {skill.isLoaded ? ✓ : null}
- {` ${skill.path}`}
-
- );
- })
- )}
- {visibleSkillStart > 0 ? … {visibleSkillStart} above : null}
- {visibleSkillStart + visibleSkills.length < skills.length ? (
- … {skills.length - visibleSkillStart - visibleSkills.length} more
- ) : null}
- space toggle · enter toggle · esc to close
-
+ ({
+ key: skill.path || skill.name,
+ label: skill.name,
+ description: skill.path,
+ selected: isSkillSelected(selectedSkills, skill),
+ statusIndicator: skill.isLoaded ? { symbol: "✓", color: "green" } : undefined,
+ }))}
+ activeIndex={skillsDropdownIndex}
+ activeColor="#229ac3"
+ maxVisible={6}
+ />
) : null}
{modelDropdownStep ? (
-
-
- {modelDropdownStep === "model" ? "Select Model" : "Select Thinking Mode"}
-
- {modelDropdownItems.map((item, idx) => {
- const active = idx === modelDropdownIndex;
- return (
-
- {active ? "› " : " "}
- {item.selected ? "●" : "○"} {item.label}
- {item.description ? {` ${item.description}`} : null}
-
- );
- })}
-
- {modelDropdownStep === "model"
+
-
+ : "space/enter apply · esc to cancel"
+ }
+ items={modelDropdownItems.map((item) => ({
+ key: item.label,
+ label: item.label,
+ description: item.description,
+ selected: item.selected,
+ }))}
+ activeIndex={modelDropdownIndex}
+ activeColor="#229ac3"
+ maxVisible={6}
+ />
) : null}
- {!showMenu && (
+ {!showFooterText && (
{footerText}