Ver Fonte

fix(tui): add a blinking composer caret

Ma há 7 meses atrás
pai
commit
c7bbb2bb15

+ 14 - 4
packages/cli/src/__tests__/tui-composer-display.test.ts

@@ -3,15 +3,25 @@ import { renderComposerDisplay } from "../tui/composer-display.js";
 
 
 describe("tui composer display", () => {
 describe("tui composer display", () => {
   it("renders placeholder when empty", () => {
   it("renders placeholder when empty", () => {
-    expect(renderComposerDisplay("", "Ask InkOS")).toEqual({
+    expect(renderComposerDisplay("", "Ask InkOS", false)).toEqual({
       text: "Ask InkOS",
       text: "Ask InkOS",
+      cursor: "",
       isPlaceholder: true,
       isPlaceholder: true,
     });
     });
   });
   });
 
 
-  it("renders plain input text with a block cursor when typing", () => {
-    expect(renderComposerDisplay("continue", "Ask InkOS")).toEqual({
-      text: "continue▌",
+  it("renders plain input text with a blinking bar cursor when active", () => {
+    expect(renderComposerDisplay("continue", "Ask InkOS", true)).toEqual({
+      text: "continue",
+      cursor: "│",
+      isPlaceholder: false,
+    });
+  });
+
+  it("hides the cursor between blink frames", () => {
+    expect(renderComposerDisplay("continue", "Ask InkOS", false)).toEqual({
+      text: "continue",
+      cursor: "",
       isPlaceholder: false,
       isPlaceholder: false,
     });
     });
   });
   });

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

@@ -115,6 +115,26 @@ describe("ink dashboard", () => {
     expect(frame.indexOf("/clear")).toBeGreaterThan(frame.indexOf("› /c"));
     expect(frame.indexOf("/clear")).toBeGreaterThan(frame.indexOf("› /c"));
   });
   });
 
 
+  it("renders a low-frequency bar cursor in the active composer", 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="continue"
+        isSubmitting={false}
+        showComposerCursor
+      />,
+    );
+
+    const frame = lastFrame() ?? "";
+    expect(frame).toContain("continue│");
+  });
+
   it("defaults dashboard chrome to Chinese when locale is zh-CN", async () => {
   it("defaults dashboard chrome to Chinese when locale is zh-CN", async () => {
     const mod = await import("../tui/dashboard.js");
     const mod = await import("../tui/dashboard.js");
 
 

+ 5 - 2
packages/cli/src/tui/composer-display.ts

@@ -1,16 +1,19 @@
 export function renderComposerDisplay(
 export function renderComposerDisplay(
   inputValue: string,
   inputValue: string,
   placeholder: string,
   placeholder: string,
-): { readonly text: string; readonly isPlaceholder: boolean } {
+  showCursor: boolean,
+): { readonly text: string; readonly cursor: string; readonly isPlaceholder: boolean } {
   if (!inputValue) {
   if (!inputValue) {
     return {
     return {
       text: placeholder,
       text: placeholder,
+      cursor: showCursor ? "│" : "",
       isPlaceholder: true,
       isPlaceholder: true,
     };
     };
   }
   }
 
 
   return {
   return {
-    text: `${inputValue}▌`,
+    text: inputValue,
+    cursor: showCursor ? "│" : "",
     isPlaceholder: false,
     isPlaceholder: false,
   };
   };
 }
 }

+ 22 - 1
packages/cli/src/tui/dashboard.tsx

@@ -39,6 +39,7 @@ export interface InkTuiDashboardProps {
   readonly lastError?: string;
   readonly lastError?: string;
   readonly slashSuggestions?: ReadonlyArray<string>;
   readonly slashSuggestions?: ReadonlyArray<string>;
   readonly selectedSlashIndex?: number;
   readonly selectedSlashIndex?: number;
+  readonly showComposerCursor?: boolean;
   readonly onInputChange?: (value: string) => void;
   readonly onInputChange?: (value: string) => void;
   readonly onSubmit?: (value: string) => void;
   readonly onSubmit?: (value: string) => void;
 }
 }
@@ -73,7 +74,7 @@ export function InkTuiDashboard(props: InkTuiDashboardProps): React.JSX.Element
     sinceTimestamp: props.sinceTimestamp,
     sinceTimestamp: props.sinceTimestamp,
   });
   });
   const activeAccent = props.isSubmitting ? WARM_ACCENT : statusColor(model.executionStatus);
   const activeAccent = props.isSubmitting ? WARM_ACCENT : statusColor(model.executionStatus);
-  const composer = renderComposerDisplay(props.inputValue, model.composerPlaceholder);
+  const composer = renderComposerDisplay(props.inputValue, model.composerPlaceholder, props.showComposerCursor ?? false);
 
 
   return (
   return (
     <Box flexDirection="column" width="100%" paddingX={2}>
     <Box flexDirection="column" width="100%" paddingX={2}>
@@ -112,6 +113,11 @@ export function InkTuiDashboard(props: InkTuiDashboardProps): React.JSX.Element
             <Text color={composer.isPlaceholder ? WARM_MUTED : WARM_REPLY}>
             <Text color={composer.isPlaceholder ? WARM_MUTED : WARM_REPLY}>
               {composer.text}
               {composer.text}
             </Text>
             </Text>
+            {composer.cursor ? (
+              <Text color={props.isSubmitting ? WARM_ACCENT : WARM_ACCENT}>
+                {composer.cursor}
+              </Text>
+            ) : null}
           </Box>
           </Box>
           <Text color={props.isSubmitting ? WARM_ACCENT : WARM_MUTED}>
           <Text color={props.isSubmitting ? WARM_ACCENT : WARM_MUTED}>
             {model.composerStatus} • {model.composerHelper}
             {model.composerStatus} • {model.composerHelper}
@@ -151,6 +157,7 @@ export function InkTuiApp(props: InkTuiAppProps): React.JSX.Element {
   const [activityIntent, setActivityIntent] = useState<InteractionIntentType | "unknown">("unknown");
   const [activityIntent, setActivityIntent] = useState<InteractionIntentType | "unknown">("unknown");
   const [activityFrameIndex, setActivityFrameIndex] = useState(0);
   const [activityFrameIndex, setActivityFrameIndex] = useState(0);
   const [chatDepth, setChatDepth] = useState<ChatDepth>("normal");
   const [chatDepth, setChatDepth] = useState<ChatDepth>("normal");
+  const [showComposerCursor, setShowComposerCursor] = useState(true);
   const assistantDraftTimestampRef = useRef<number | null>(null);
   const assistantDraftTimestampRef = useRef<number | null>(null);
   const submitLockRef = useRef(false);
   const submitLockRef = useRef(false);
   const slashSuggestions = getSlashSuggestions(inputValue, SLASH_COMMANDS);
   const slashSuggestions = getSlashSuggestions(inputValue, SLASH_COMMANDS);
@@ -170,6 +177,19 @@ export function InkTuiApp(props: InkTuiAppProps): React.JSX.Element {
     return () => clearInterval(timer);
     return () => clearInterval(timer);
   }, [activity.frames.length, activity.intervalMs, isSubmitting]);
   }, [activity.frames.length, activity.intervalMs, isSubmitting]);
 
 
+  useEffect(() => {
+    if (isSubmitting) {
+      setShowComposerCursor(false);
+      return;
+    }
+
+    setShowComposerCursor(true);
+    const timer = setInterval(() => {
+      setShowComposerCursor((current) => !current);
+    }, 700);
+    return () => clearInterval(timer);
+  }, [isSubmitting]);
+
   if (props.chatStreamBridge) {
   if (props.chatStreamBridge) {
     props.chatStreamBridge.getChatRequestOptions = () => ({
     props.chatStreamBridge.getChatRequestOptions = () => ({
       temperature: chatDepthProfile.temperature,
       temperature: chatDepthProfile.temperature,
@@ -371,6 +391,7 @@ export function InkTuiApp(props: InkTuiAppProps): React.JSX.Element {
       lastError={lastError}
       lastError={lastError}
       slashSuggestions={slashSuggestions}
       slashSuggestions={slashSuggestions}
       selectedSlashIndex={selectedSlashIndex}
       selectedSlashIndex={selectedSlashIndex}
+      showComposerCursor={showComposerCursor}
       onInputChange={(value) => {
       onInputChange={(value) => {
         setInputValue(value);
         setInputValue(value);
         setSelectedSlashIndex(0);
         setSelectedSlashIndex(0);