Просмотр исходного кода

fix(tui): polish dashboard interactions, theme and animations

- Add semantic status colors and role colors to theme palette
- Improve conversation rows with distinct icons per role
- Upgrade ExecutionBadge with per-status icons
- Add visual separators between dashboard sections
- Improve composer with separated status/helper text
- Enhance startup animation with project info reveal
- Move hardcoded i18n strings into TuiCopy stageLabels
- Fix non-null assertions in effects.ts
Ma 7 месяцев назад
Родитель
Сommit
1edd52a3e0

+ 4 - 3
packages/cli/src/tui/dashboard-model.ts

@@ -142,9 +142,10 @@ function roleLabel(role: InteractionMessage["role"], copy: TuiCopy): string {
 function summarizeEvent(event: InteractionEvent, copy: TuiCopy): string {
   const base = compactInline(event.detail?.trim() || event.kind);
   if (event.bookId && event.chapterNumber !== undefined) {
-    return copy.locale === "zh-CN"
-      ? `${base}(${event.bookId} 第 ${event.chapterNumber} 章)`
-      : `${base} (${event.bookId} ch.${event.chapterNumber})`;
+    const chapterLabel = copy.locale === "zh-CN"
+      ? `第 ${event.chapterNumber} 章`
+      : `ch.${event.chapterNumber}`;
+    return `${base} (${event.bookId} ${chapterLabel})`;
   }
   if (event.bookId) {
     return `${base} (${event.bookId})`;

+ 87 - 30
packages/cli/src/tui/dashboard.tsx

@@ -25,7 +25,11 @@ import {
   getSlashSuggestions,
   SLASH_COMMANDS,
 } from "./slash-autocomplete.js";
-import { WARM_ACCENT, WARM_BORDER, WARM_MUTED, WARM_REPLY } from "./theme.js";
+import {
+  WARM_ACCENT, WARM_BORDER, WARM_MUTED, WARM_REPLY,
+  STATUS_SUCCESS, STATUS_ERROR, STATUS_ACTIVE, STATUS_IDLE,
+  ROLE_USER, ROLE_SYSTEM,
+} from "./theme.js";
 
 export interface InkTuiDashboardProps {
   readonly locale: TuiLocale;
@@ -77,38 +81,48 @@ export function InkTuiDashboard(props: InkTuiDashboardProps): React.JSX.Element
   const activeAccent = props.isSubmitting ? WARM_ACCENT : statusColor(model.executionStatus);
   const composer = renderComposerDisplay(props.inputValue, model.composerPlaceholder, props.showComposerCursor ?? false);
 
+  const separatorWidth = Math.max(20, (process.stdout.columns ?? 60) - 8);
+  const thinRule = "─".repeat(separatorWidth);
+
   return (
     <Box flexDirection="column" width="100%" paddingX={2}>
+      {/* Header bar */}
       <Text color={WARM_MUTED}>{model.headerLine}</Text>
+      <Text color={WARM_BORDER}>{thinRule}</Text>
 
+      {/* Conversation area */}
       <Box flexDirection="column" marginTop={1} flexGrow={1}>
         {model.messageRows.length > 0 ? (
           model.messageRows.map((row) => <ConversationRow key={row.key} row={row} />)
         ) : (
-          <MutedText>{copy.composer.emptyConversation}</MutedText>
+          <Box marginY={1}>
+            <Text color={WARM_MUTED} italic>  {copy.composer.emptyConversation}</Text>
+          </Box>
         )}
       </Box>
 
+      {/* Status strip */}
       <Box flexDirection="column" marginTop={1}>
-        <Text color={activeAccent}>
+        <Text color={WARM_BORDER}>{thinRule}</Text>
+        <Box marginTop={1}>
           <ExecutionBadge status={model.executionStatus} color={activeAccent} />
-          {" "}
-          {model.statusPrimaryLine}
-        </Text>
-        <Text color={model.errorText ? "red" : props.isSubmitting ? WARM_ACCENT : WARM_MUTED}>
-          {model.statusSecondaryLine}
+          <Text color={activeAccent}> {model.statusPrimaryLine}</Text>
+        </Box>
+        <Text color={model.errorText ? STATUS_ERROR : props.isSubmitting ? WARM_ACCENT : WARM_MUTED}>
+          {"  " + model.statusSecondaryLine}
         </Text>
 
+        {/* Composer input */}
         <Box
           marginTop={1}
           flexDirection="column"
           width="100%"
           borderStyle="round"
-          borderColor={props.isSubmitting ? WARM_ACCENT : WARM_BORDER}
+          borderColor={props.isSubmitting ? STATUS_ACTIVE : WARM_BORDER}
           paddingX={1}
         >
           <Box>
-            <Text color={props.isSubmitting ? WARM_ACCENT : WARM_ACCENT} bold>
+            <Text color={props.isSubmitting ? STATUS_ACTIVE : WARM_ACCENT} bold>
               ›{" "}
             </Text>
             {composer.textBeforeCursor ? (
@@ -117,7 +131,7 @@ export function InkTuiDashboard(props: InkTuiDashboardProps): React.JSX.Element
               </Text>
             ) : null}
             {composer.cursor ? (
-              <Text color={props.isSubmitting ? WARM_ACCENT : WARM_ACCENT}>
+              <Text color={props.isSubmitting ? STATUS_ACTIVE : WARM_ACCENT}>
                 {composer.cursor}
               </Text>
             ) : null}
@@ -127,18 +141,26 @@ export function InkTuiDashboard(props: InkTuiDashboardProps): React.JSX.Element
               </Text>
             ) : null}
           </Box>
-          <Text color={props.isSubmitting ? WARM_ACCENT : WARM_MUTED}>
-            {model.composerStatus} • {model.composerHelper}
-          </Text>
+          <Box>
+            <Text color={props.isSubmitting ? STATUS_ACTIVE : WARM_MUTED}>
+              {model.composerStatus}
+            </Text>
+            <Text color={WARM_BORDER}> │ </Text>
+            <Text color={WARM_MUTED}>{model.composerHelper}</Text>
+          </Box>
+
+          {/* Slash command suggestions */}
           {props.slashSuggestions && props.slashSuggestions.length > 0 ? (
-            <Box flexDirection="column" marginTop={1}>
-              {props.slashSuggestions.slice(0, 5).map((suggestion, index) => {
+            <Box flexDirection="column" marginTop={1} borderTop borderColor={WARM_BORDER}>
+              {props.slashSuggestions.slice(0, 6).map((suggestion, index) => {
                 const isSelected = index === (props.selectedSlashIndex ?? 0);
                 return (
-                  <Text key={suggestion} color={isSelected ? WARM_ACCENT : WARM_MUTED}>
-                    {isSelected ? "› " : "  "}
-                    {suggestion}
-                  </Text>
+                  <Box key={suggestion}>
+                    <Text color={isSelected ? WARM_ACCENT : WARM_BORDER}>{isSelected ? "› " : "  "}</Text>
+                    <Text color={isSelected ? WARM_REPLY : WARM_MUTED} bold={isSelected}>
+                      {suggestion}
+                    </Text>
+                  </Box>
                 );
               })}
             </Box>
@@ -407,31 +429,66 @@ export function InkTuiApp(props: InkTuiAppProps): React.JSX.Element {
 }
 
 function ConversationRow(props: { readonly row: DashboardMessageRow }): React.JSX.Element {
-  if (props.row.role === "user") {
+  const { role, content, label } = props.row;
+
+  if (role === "user") {
+    return (
+      <Box marginBottom={1}>
+        <Text color={ROLE_USER}>│ </Text>
+        <Text color={WARM_REPLY}>{content}</Text>
+      </Box>
+    );
+  }
+
+  if (role === "system") {
     return (
       <Box marginBottom={1}>
-        <Text color="gray">│ {props.row.content}</Text>
+        <Text color={ROLE_SYSTEM}>· </Text>
+        <Text color={ROLE_SYSTEM}>{content}</Text>
       </Box>
     );
   }
 
+  // assistant
   return (
     <Box marginBottom={1}>
-      <Text color={messageColor(props.row.role)}>
-        {props.row.role === "assistant" ? props.row.content : `${props.row.label}  ${props.row.content}`}
-      </Text>
+      <Text color={WARM_ACCENT}>◆ </Text>
+      <Text color={WARM_REPLY}>{content}</Text>
     </Box>
   );
 }
 
 function ExecutionBadge(props: { readonly status: string; readonly color?: string }): React.JSX.Element {
+  const icon = statusIcon(props.status);
   return (
     <Text color={props.color ?? statusColor(props.status)} bold>
-      ●
+      {icon}
     </Text>
   );
 }
 
+function statusIcon(status: string): string {
+  switch (status) {
+    case "completed":
+      return "✓";
+    case "failed":
+      return "✗";
+    case "blocked":
+    case "waiting_human":
+      return "◈";
+    case "writing":
+      return "✎";
+    case "planning":
+    case "composing":
+      return "◇";
+    case "repairing":
+    case "persisting":
+      return "◉";
+    default:
+      return "●";
+  }
+}
+
 function MutedText(props: { readonly children: React.ReactNode }): React.JSX.Element {
   return <Text color={WARM_MUTED}>{props.children}</Text>;
 }
@@ -452,9 +509,9 @@ function messageColor(role: DashboardMessageRow["role"]): string {
 function statusColor(status: string): string {
   switch (status) {
     case "completed":
-      return WARM_REPLY;
+      return STATUS_SUCCESS;
     case "failed":
-      return "red";
+      return STATUS_ERROR;
     case "blocked":
     case "waiting_human":
       return WARM_ACCENT;
@@ -463,8 +520,8 @@ function statusColor(status: string): string {
     case "planning":
     case "composing":
     case "persisting":
-      return WARM_ACCENT;
+      return STATUS_ACTIVE;
     default:
-      return WARM_MUTED;
+      return STATUS_IDLE;
   }
 }

+ 26 - 11
packages/cli/src/tui/effects.ts

@@ -202,15 +202,30 @@ export async function animateStartup(version: string, projectName: string, bookT
     // Animate ASCII logo line by line
     for (let i = 0; i < ASCII_LOGO.length; i++) {
       const line = ASCII_LOGO[i]!;
-      // Gradient: top lines brighter, bottom lines dimmer
       const shade = i < 2 ? brightCyan : i < 4 ? cyan : dim + cyan;
       process.stdout.write(`  ${c(line, shade)}\n`);
       await sleep(40);
     }
 
-    // Version + tagline below logo
+    // Version line
     console.log(c(`  v${version}`, dim));
-    await sleep(150);
+    await sleep(100);
+
+    // Project info strip
+    const infoParts = [
+      c("◇", cyan) + " " + c(projectName, white, bold),
+    ];
+    if (bookTitle) {
+      infoParts.push(c("◇", cyan) + " " + c(bookTitle, brightCyan));
+    }
+    if (modelInfo?.model && modelInfo.model !== "unknown") {
+      infoParts.push(c("◇", cyan) + " " + c(`${modelInfo.model} (${modelInfo.provider})`, dim));
+    }
+    console.log();
+    for (const part of infoParts) {
+      console.log(`  ${part}`);
+      await sleep(60);
+    }
 
     process.stdout.write(showCursor);
   } else {
@@ -310,7 +325,7 @@ export function intentToBadge(intent: string, locale: TuiLocale = resolveTuiLoca
   };
   const label = labels[intent as keyof typeof labels] ?? ` ${intent} `;
   const bg = backgrounds[intent] ?? bgGray;
-  return badge(label!, bg!);
+  return badge(label, bg);
 }
 
 /* ── Intent to spinner theme ── */
@@ -345,9 +360,9 @@ export function printStyledHelp(): void {
   for (const section of sections) {
     console.log(`  ${c(section.title, bold, cyan)}`);
     for (const [cmd, desc] of section.commands) {
-      const cmdStr = c(cmd!, green);
-      const descStr = c(desc!, dim);
-      const padding = " ".repeat(Math.max(1, 24 - stripAnsi(cmd!).length));
+      const cmdStr = c(cmd, green);
+      const descStr = c(desc, dim);
+      const padding = " ".repeat(Math.max(1, 24 - stripAnsi(cmd).length));
       console.log(`    ${cmdStr}${padding}${descStr}`);
     }
     console.log();
@@ -401,10 +416,10 @@ export function formatStyledStatusLines(
     waiting_human: brightYellow,
   };
   const statusColor = statusColors[params.status] ?? gray;
-  const modeLabel = locale === "en" ? "Mode" : "模式";
-  const bookLabel = locale === "en" ? "Book" : "作品";
-  const statusLabel = locale === "en" ? "Status" : "状态";
-  const recentLabel = locale === "en" ? "Recent" : "最近";
+  const modeLabel = copy.labels.mode;
+  const bookLabel = copy.labels.book;
+  const statusLabel = copy.labels.stage;
+  const recentLabel = copy.labels.recent;
   const lines = [
     `  ${c("◇", cyan)} ${c(modeLabel, gray)}     ${c(formatModeLabel(params.mode, copy), modeColor, bold)}`,
     `  ${c("◇", cyan)} ${c(bookLabel, gray)}     ${params.bookId ? c(params.bookId, brightWhite) : c(copy.labels.none, dim)}`,

+ 32 - 6
packages/cli/src/tui/i18n.ts

@@ -47,6 +47,14 @@ export interface TuiCopy {
     readonly system: string;
   };
   readonly activity: Record<"thinking" | "checking" | "writing" | "reviewing" | "updating", string>;
+  readonly stageLabels: {
+    readonly completed: string;
+    readonly failed: string;
+    readonly blocked: string;
+    readonly waitingHuman: string;
+    readonly pausedByUser: string;
+    readonly readyToContinue: string;
+  };
   readonly depthLabels: Record<ChatDepth, string>;
   readonly results: {
     readonly modeSwitched: (mode: string) => string;
@@ -112,6 +120,14 @@ const ZH_CN: TuiCopy = {
     reviewing: "审阅中",
     updating: "更新中",
   },
+  stageLabels: {
+    completed: "已完成",
+    failed: "失败",
+    blocked: "已阻塞",
+    waitingHuman: "等待你的决定",
+    pausedByUser: "已由用户暂停",
+    readyToContinue: "可继续执行",
+  },
   depthLabels: {
     light: "轻量",
     normal: "标准",
@@ -192,6 +208,14 @@ const EN: TuiCopy = {
     reviewing: "reviewing",
     updating: "updating",
   },
+  stageLabels: {
+    completed: "completed",
+    failed: "failed",
+    blocked: "blocked",
+    waitingHuman: "waiting for your decision",
+    pausedByUser: "paused by user",
+    readyToContinue: "ready to continue",
+  },
   depthLabels: {
     light: "light",
     normal: "normal",
@@ -251,16 +275,18 @@ export function normalizeStageLabel(label: string, copy: TuiCopy): string {
     [/^writing\b/i, copy.activity.writing],
     [/^reviewing\b/i, copy.activity.reviewing],
     [/^updating\b/i, copy.activity.updating],
-    [/^completed\b/i, copy.locale === "en" ? "completed" : "已完成"],
-    [/^failed\b/i, copy.locale === "en" ? "failed" : "失败"],
-    [/^blocked\b/i, copy.locale === "en" ? "blocked" : "已阻塞"],
-    [/^waiting_human\b/i, copy.locale === "en" ? "waiting for your decision" : "等待你的决定"],
-    [/^paused by user\b/i, copy.locale === "en" ? "paused by user" : "已由用户暂停"],
-    [/^ready to continue\b/i, copy.locale === "en" ? "ready to continue" : "可继续执行"],
+    [/^completed\b/i, copy.stageLabels.completed],
+    [/^failed\b/i, copy.stageLabels.failed],
+    [/^blocked\b/i, copy.stageLabels.blocked],
+    [/^waiting_human\b/i, copy.stageLabels.waitingHuman],
+    [/^paused by user\b/i, copy.stageLabels.pausedByUser],
+    [/^ready to continue\b/i, copy.stageLabels.readyToContinue],
   ];
 
   for (const [pattern, value] of replacements) {
     if (pattern.test(label)) {
+      // For English, keep the original label (already in English);
+      // for other locales, use the translated value
       return copy.locale === "en" ? label : value;
     }
   }

+ 10 - 0
packages/cli/src/tui/theme.ts

@@ -2,3 +2,13 @@ export const WARM_ACCENT = "#c88a56";
 export const WARM_MUTED = "#8f8374";
 export const WARM_REPLY = "#f0e6d8";
 export const WARM_BORDER = "#6b6156";
+
+// Semantic status colors
+export const STATUS_SUCCESS = "#7ec87e";
+export const STATUS_ERROR = "#e06060";
+export const STATUS_ACTIVE = "#d4a76a";
+export const STATUS_IDLE = "#7a7268";
+
+// Role colors
+export const ROLE_USER = "#a8c4d4";
+export const ROLE_SYSTEM = "#b8a8d0";