Răsfoiți Sursa

fix(tui): stabilize slash commands and idle caret

Ma 7 luni în urmă
părinte
comite
3d75ac2eab

+ 55 - 0
packages/cli/src/__tests__/tui-composer-caret.test.ts

@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import { resolveComposerCaretState } from "../tui/composer-caret.js";
+
+describe("tui composer caret", () => {
+  it("keeps the empty composer caret visible without animation", () => {
+    expect(resolveComposerCaretState({
+      inputValue: "",
+      isSubmitting: false,
+      blinkTick: 0,
+    })).toEqual({
+      visible: true,
+      shouldAnimate: false,
+    });
+  });
+
+  it("blinks briefly after typing and then settles", () => {
+    expect(resolveComposerCaretState({
+      inputValue: "continue",
+      isSubmitting: false,
+      blinkTick: 0,
+    })).toEqual({
+      visible: true,
+      shouldAnimate: true,
+    });
+
+    expect(resolveComposerCaretState({
+      inputValue: "continue",
+      isSubmitting: false,
+      blinkTick: 1,
+    })).toEqual({
+      visible: false,
+      shouldAnimate: true,
+    });
+
+    expect(resolveComposerCaretState({
+      inputValue: "continue",
+      isSubmitting: false,
+      blinkTick: 2,
+    })).toEqual({
+      visible: true,
+      shouldAnimate: false,
+    });
+  });
+
+  it("hides the caret while submitting", () => {
+    expect(resolveComposerCaretState({
+      inputValue: "continue",
+      isSubmitting: true,
+      blinkTick: 0,
+    })).toEqual({
+      visible: false,
+      shouldAnimate: false,
+    });
+  });
+});

+ 15 - 3
packages/cli/src/__tests__/tui-composer-display.test.ts

@@ -4,15 +4,26 @@ import { renderComposerDisplay } from "../tui/composer-display.js";
 describe("tui composer display", () => {
   it("renders placeholder when empty", () => {
     expect(renderComposerDisplay("", "Ask InkOS", false)).toEqual({
-      text: "Ask InkOS",
+      textBeforeCursor: "Ask InkOS",
+      textAfterCursor: "",
       cursor: "",
       isPlaceholder: true,
     });
   });
 
+  it("keeps the initial caret before the placeholder text", () => {
+    expect(renderComposerDisplay("", "Ask InkOS", true)).toEqual({
+      textBeforeCursor: "",
+      textAfterCursor: "Ask InkOS",
+      cursor: "│",
+      isPlaceholder: true,
+    });
+  });
+
   it("renders plain input text with a blinking bar cursor when active", () => {
     expect(renderComposerDisplay("continue", "Ask InkOS", true)).toEqual({
-      text: "continue",
+      textBeforeCursor: "continue",
+      textAfterCursor: "",
       cursor: "│",
       isPlaceholder: false,
     });
@@ -20,7 +31,8 @@ describe("tui composer display", () => {
 
   it("hides the cursor between blink frames", () => {
     expect(renderComposerDisplay("continue", "Ask InkOS", false)).toEqual({
-      text: "continue",
+      textBeforeCursor: "continue",
+      textAfterCursor: "",
       cursor: "",
       isPlaceholder: false,
     });

+ 20 - 0
packages/cli/src/__tests__/tui-dashboard.test.tsx

@@ -70,6 +70,26 @@ describe("ink dashboard", () => {
     expect(frame).not.toContain("Composer");
   });
 
+  it("places the initial caret before the placeholder text", async () => {
+    const mod = await import("../tui/dashboard.js");
+
+    const { lastFrame } = render(
+      <mod.InkTuiDashboard
+        locale="en"
+        projectName="inkos-demo"
+        activeBookTitle="Night Harbor Echo"
+        modelLabel="gpt-5.4 (openai)"
+        session={createSession()}
+        inputValue=""
+        isSubmitting={false}
+        showComposerCursor
+      />,
+    );
+
+    const frame = lastFrame() ?? "";
+    expect(frame).toContain("› │Ask InkOS");
+  });
+
   it("renders the compact status strip directly above the composer", async () => {
     const mod = await import("../tui/dashboard.js");
 

+ 5 - 3
packages/cli/src/__tests__/tui-slash-autocomplete.test.ts

@@ -9,8 +9,9 @@ import {
 describe("tui slash autocomplete", () => {
   it("filters slash commands by prefix", () => {
     expect(getSlashSuggestions("/st", SLASH_COMMANDS)).toEqual(["/status"]);
-    expect(getSlashSuggestions("/c", SLASH_COMMANDS)).toEqual(["/clear", "/config"]);
-    expect(getSlashSuggestions("/d", SLASH_COMMANDS)).toEqual(["/depth"]);
+    expect(getSlashSuggestions("/w", SLASH_COMMANDS)).toEqual(["/write"]);
+    expect(getSlashSuggestions("/o", SLASH_COMMANDS)).toEqual(["/open <book>"]);
+    expect(getSlashSuggestions("/d", SLASH_COMMANDS)).toEqual(["/depth <light|normal|deep>"]);
   });
 
   it("does not suggest anything for non-slash input", () => {
@@ -26,6 +27,7 @@ describe("tui slash autocomplete", () => {
 
   it("applies the selected suggestion to the composer input", () => {
     expect(applySlashSuggestion("/st", ["/status"], 0)).toBe("/status");
-    expect(applySlashSuggestion("/c", ["/clear", "/config"], 1)).toBe("/config");
+    expect(applySlashSuggestion("/o", ["/open <book>"], 0)).toBe("/open ");
+    expect(applySlashSuggestion("/d", ["/depth <light|normal|deep>"], 0)).toBe("/depth ");
   });
 });

+ 36 - 0
packages/cli/src/tui/composer-caret.ts

@@ -0,0 +1,36 @@
+export interface ComposerCaretState {
+  readonly visible: boolean;
+  readonly shouldAnimate: boolean;
+}
+
+export function resolveComposerCaretState(params: {
+  readonly inputValue: string;
+  readonly isSubmitting: boolean;
+  readonly blinkTick: number;
+}): ComposerCaretState {
+  if (params.isSubmitting) {
+    return {
+      visible: false,
+      shouldAnimate: false,
+    };
+  }
+
+  if (!params.inputValue) {
+    return {
+      visible: true,
+      shouldAnimate: false,
+    };
+  }
+
+  if (params.blinkTick >= 2) {
+    return {
+      visible: true,
+      shouldAnimate: false,
+    };
+  }
+
+  return {
+    visible: params.blinkTick % 2 === 0,
+    shouldAnimate: true,
+  };
+}

+ 10 - 3
packages/cli/src/tui/composer-display.ts

@@ -2,17 +2,24 @@ export function renderComposerDisplay(
   inputValue: string,
   placeholder: string,
   showCursor: boolean,
-): { readonly text: string; readonly cursor: string; readonly isPlaceholder: boolean } {
+): {
+  readonly textBeforeCursor: string;
+  readonly textAfterCursor: string;
+  readonly cursor: string;
+  readonly isPlaceholder: boolean;
+} {
   if (!inputValue) {
     return {
-      text: placeholder,
+      textBeforeCursor: showCursor ? "" : placeholder,
+      textAfterCursor: showCursor ? placeholder : "",
       cursor: showCursor ? "│" : "",
       isPlaceholder: true,
     };
   }
 
   return {
-    text: inputValue,
+    textBeforeCursor: inputValue,
+    textAfterCursor: "",
     cursor: showCursor ? "│" : "",
     isPlaceholder: false,
   };

+ 28 - 13
packages/cli/src/tui/dashboard.tsx

@@ -9,6 +9,7 @@ import {
 } from "@actalk/inkos-core";
 import { Box, Text, useApp, useInput } from "ink";
 import { describeActivityState } from "./activity-state.js";
+import { resolveComposerCaretState } from "./composer-caret.js";
 import { resolveChatDepthProfile, type ChatDepth } from "./chat-depth.js";
 import { appendStreamingAssistantChunk, createOptimisticUserMessageSession } from "./chat-draft.js";
 import { renderComposerDisplay } from "./composer-display.js";
@@ -110,14 +111,21 @@ export function InkTuiDashboard(props: InkTuiDashboardProps): React.JSX.Element
             <Text color={props.isSubmitting ? WARM_ACCENT : WARM_ACCENT} bold>
               ›{" "}
             </Text>
-            <Text color={composer.isPlaceholder ? WARM_MUTED : WARM_REPLY}>
-              {composer.text}
-            </Text>
+            {composer.textBeforeCursor ? (
+              <Text color={composer.isPlaceholder ? WARM_MUTED : WARM_REPLY}>
+                {composer.textBeforeCursor}
+              </Text>
+            ) : null}
             {composer.cursor ? (
               <Text color={props.isSubmitting ? WARM_ACCENT : WARM_ACCENT}>
                 {composer.cursor}
               </Text>
             ) : null}
+            {composer.textAfterCursor ? (
+              <Text color={composer.isPlaceholder ? WARM_MUTED : WARM_REPLY}>
+                {composer.textAfterCursor}
+              </Text>
+            ) : null}
           </Box>
           <Text color={props.isSubmitting ? WARM_ACCENT : WARM_MUTED}>
             {model.composerStatus} • {model.composerHelper}
@@ -157,13 +165,18 @@ export function InkTuiApp(props: InkTuiAppProps): React.JSX.Element {
   const [activityIntent, setActivityIntent] = useState<InteractionIntentType | "unknown">("unknown");
   const [activityFrameIndex, setActivityFrameIndex] = useState(0);
   const [chatDepth, setChatDepth] = useState<ChatDepth>("normal");
-  const [showComposerCursor, setShowComposerCursor] = useState(true);
+  const [composerCaretTick, setComposerCaretTick] = useState(0);
   const assistantDraftTimestampRef = useRef<number | null>(null);
   const submitLockRef = useRef(false);
   const slashSuggestions = getSlashSuggestions(inputValue, SLASH_COMMANDS);
   const inputHistory = buildInputHistory(session.messages);
   const activity = describeActivityState(activityIntent, copy);
   const chatDepthProfile = resolveChatDepthProfile(chatDepth);
+  const composerCaret = resolveComposerCaretState({
+    inputValue,
+    isSubmitting,
+    blinkTick: composerCaretTick,
+  });
 
   useEffect(() => {
     if (!isSubmitting) {
@@ -178,17 +191,19 @@ export function InkTuiApp(props: InkTuiAppProps): React.JSX.Element {
   }, [activity.frames.length, activity.intervalMs, isSubmitting]);
 
   useEffect(() => {
-    if (isSubmitting) {
-      setShowComposerCursor(false);
+    setComposerCaretTick(0);
+  }, [inputValue, isSubmitting]);
+
+  useEffect(() => {
+    if (!composerCaret.shouldAnimate) {
       return;
     }
 
-    setShowComposerCursor(true);
-    const timer = setInterval(() => {
-      setShowComposerCursor((current) => !current);
-    }, 700);
-    return () => clearInterval(timer);
-  }, [isSubmitting]);
+    const timer = setTimeout(() => {
+      setComposerCaretTick((current) => current + 1);
+    }, 900);
+    return () => clearTimeout(timer);
+  }, [composerCaret.shouldAnimate, composerCaretTick]);
 
   if (props.chatStreamBridge) {
     props.chatStreamBridge.getChatRequestOptions = () => ({
@@ -391,7 +406,7 @@ export function InkTuiApp(props: InkTuiAppProps): React.JSX.Element {
       lastError={lastError}
       slashSuggestions={slashSuggestions}
       selectedSlashIndex={selectedSlashIndex}
-      showComposerCursor={showComposerCursor}
+      showComposerCursor={composerCaret.visible}
       onInputChange={(value) => {
         setInputValue(value);
         setSelectedSlashIndex(0);

+ 4 - 4
packages/cli/src/tui/i18n.ts

@@ -83,13 +83,13 @@ const ZH_CN: TuiCopy = {
   composer: {
     placeholder: "告诉 InkOS 要写什么、修改什么,或解释什么…",
     emptyConversation: "先告诉 InkOS 你要做什么。",
-    helper: "回车发送 • /help • /status • /clear • /config • /depth • /quit",
+    helper: "回车发送 • /write • /books • /open • /mode • /depth • /help",
     submitting: "处理中…",
     failed: "上次请求失败",
     ready: "就绪",
   },
   notes: {
-    help: "可用命令:/help、/status、/clear、/config、/depth、/quit。也支持直接输入自然语言。",
+    help: "可用命令:/write、/books、/open、/mode、/rewrite、/focus、/truth、/rename、/replace、/export、/status、/clear、/depth、/quit。也支持直接输入自然语言。",
     status: (stage, mode) => `当前状态:${stage}(${mode})。`,
     config: "当前 Ink 仪表盘里还不支持交互式 /config。请使用 inkos config set-global。",
     depthSet: (depthLabel) => `思考深度已切换为 ${depthLabel}。`,
@@ -162,13 +162,13 @@ const EN: TuiCopy = {
   composer: {
     placeholder: "Ask InkOS to write, revise, or explain…",
     emptyConversation: "Start by asking InkOS what to do.",
-    helper: "Enter to send • /help • /status • /clear • /config • /depth • /quit",
+    helper: "Enter to send • /write • /books • /open • /mode • /depth • /help",
     submitting: "Submitting…",
     failed: "Last request failed",
     ready: "Ready",
   },
   notes: {
-    help: "Commands: /help, /status, /clear, /config, /depth, /quit. Natural language still works.",
+    help: "Commands: /write, /books, /open, /mode, /rewrite, /focus, /truth, /rename, /replace, /export, /status, /clear, /depth, /quit. Natural language still works.",
     status: (stage, mode) => `Status: ${stage} (${mode}).`,
     config: "Interactive /config is not available inside the Ink dashboard yet. Use inkos config set-global.",
     depthSet: (depthLabel) => `Thinking depth set to ${depthLabel}.`,

+ 23 - 4
packages/cli/src/tui/slash-autocomplete.ts

@@ -1,9 +1,18 @@
 export const SLASH_COMMANDS = [
+  "/write",
+  "/books",
+  "/open <book>",
+  "/mode <auto|semi|manual>",
+  "/rewrite <n>",
+  "/focus <text>",
+  "/truth <file> <content>",
+  "/rename <from> => <to>",
+  "/replace <n> <from> => <to>",
+  "/export [txt|md|epub]",
   "/help",
   "/status",
   "/clear",
-  "/config",
-  "/depth",
+  "/depth <light|normal|deep>",
   "/quit",
   "/exit",
 ] as const;
@@ -16,7 +25,7 @@ export function getSlashSuggestions(input: string, commands: readonly string[]):
     return [];
   }
 
-  return commands.filter((command) => command.startsWith(value));
+  return commands.filter((command) => slashCommandStem(command).startsWith(value));
 }
 
 export function getNextSlashSelection(
@@ -40,5 +49,15 @@ export function applySlashSuggestion(
   suggestions: readonly string[],
   selectedIndex: number,
 ): string {
-  return suggestions[selectedIndex] ?? "";
+  const suggestion = suggestions[selectedIndex] ?? "";
+  return slashSuggestionInsertion(suggestion);
+}
+
+function slashCommandStem(command: string): string {
+  return command.match(/^\/\S+/)?.[0] ?? command;
+}
+
+function slashSuggestionInsertion(suggestion: string): string {
+  const stem = slashCommandStem(suggestion);
+  return suggestion === stem ? stem : `${stem} `;
 }