فهرست منبع

feat(tui): model display, slash Tab-complete, /config command

- Show configured model + provider in startup header
- Yellow warning if no model configured, directs to /config
- Tab completion for all slash commands (/write, /books, etc.)
- /config command launches interactive LLM setup wizard inline
- detectModelInfo reads provider/model/baseUrl from env files
Ma 7 ماه پیش
والد
کامیت
f753d00140
3فایلهای تغییر یافته به همراه94 افزوده شده و 16 حذف شده
  1. 32 6
      packages/cli/src/tui/app.ts
  2. 28 10
      packages/cli/src/tui/effects.ts
  3. 34 0
      packages/cli/src/tui/setup.ts

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

@@ -15,7 +15,7 @@ import {
 } from "./session-store.js";
 import { createInteractionTools } from "./tools.js";
 import { formatTuiResult } from "./output.js";
-import { ensureProject, interactiveLlmSetup } from "./setup.js";
+import { ensureProject, interactiveLlmSetup, detectModelInfo } from "./setup.js";
 import {
   c, bold, dim, cyan, green, yellow, gray, red, brightCyan, brightWhite,
   showCursor, reset, box,
@@ -187,8 +187,9 @@ export async function launchTui(
   const activeBookId = await resolveSessionActiveBook(projectRoot, session);
   const version = await readVersion();
 
-  // 4. Animated welcome
-  await animateStartup(version, basename(projectRoot), activeBookId);
+  // 4. Detect model + animated welcome
+  const modelInfo = await detectModelInfo(projectRoot);
+  await animateStartup(version, basename(projectRoot), activeBookId, modelInfo ?? undefined);
 
   // 5. Bail if not interactive
   if (!process.stdin.isTTY || !process.stdout.isTTY) {
@@ -219,11 +220,25 @@ export async function launchTui(
     return (origStderrWrite as Function)(chunk, ...args);
   };
 
-  // 8. REPL loop
+  // 8. Slash command completer
+  const SLASH_COMMANDS = [
+    "/write", "/rewrite", "/books", "/open", "/status",
+    "/mode", "/focus", "/config", "/clear", "/help", "/quit",
+  ];
+  const completer = (line: string): [string[], string] => {
+    if (line.startsWith("/")) {
+      const hits = SLASH_COMMANDS.filter((cmd) => cmd.startsWith(line));
+      return [hits.length > 0 ? hits : SLASH_COMMANDS, line];
+    }
+    return [[], line];
+  };
+
+  // 9. REPL loop
   const rl = readline.createInterface({
     input: process.stdin,
     output: process.stdout,
     prompt: buildInputChrome().promptPrefix,
+    completer,
   });
 
   const cleanup = () => {
@@ -237,6 +252,7 @@ export async function launchTui(
     const chrome = buildInputChrome();
     rl.setPrompt(chrome.promptPrefix);
     drawInputArea();
+    process.stdout.write(`\x1b[${chrome.promptLiftRows}A\r`);
     rl.prompt();
   };
 
@@ -253,14 +269,13 @@ export async function launchTui(
   for await (const line of rl) {
     const input = line.trim();
     const chrome = buildInputChrome();
+    process.stdout.write(`\x1b[${chrome.settleRowsAfterSubmit}B\r`);
 
     if (!input) {
-      console.log(chrome.bottomBorder);
       promptInput();
       continue;
     }
 
-    console.log(chrome.bottomBorder);
     console.log();
 
     // Built-in TUI commands
@@ -293,6 +308,17 @@ export async function launchTui(
       continue;
     }
 
+    if (/^\/config$/i.test(input)) {
+      await interactiveLlmSetup(projectRoot);
+      const newModel = await detectModelInfo(projectRoot);
+      if (newModel) {
+        console.log(`  ${c("◇", cyan)} ${c("Model", gray)}    ${c(newModel.model, brightWhite)} ${c(`(${newModel.provider})`, dim)}`);
+      }
+      console.log();
+      promptInput();
+      continue;
+    }
+
     if (/^\/clear$/i.test(input)) {
       console.clear();
       promptInput();

+ 28 - 10
packages/cli/src/tui/effects.ts

@@ -167,9 +167,12 @@ export interface InputChrome {
   readonly width: number;
   readonly topBorder: string;
   readonly helperLine: string;
+  readonly promptLine: string;
   readonly bottomBorder: string;
   readonly promptPrefix: string;
   readonly outerGapLines: number;
+  readonly promptLiftRows: number;
+  readonly settleRowsAfterSubmit: number;
 }
 
 export function buildInputChrome(columns = process.stdout.columns ?? 80): InputChrome {
@@ -184,9 +187,12 @@ export function buildInputChrome(columns = process.stdout.columns ?? 80): InputC
     width,
     topBorder: `  ${c(`╭${"─".repeat(width - 2)}╮`, gray)}`,
     helperLine: `  ${c("│", gray)} ${c(helper, dim)} ${c("│", gray)}`,
+    promptLine: `  ${c("│", gray)} ${" ".repeat(innerWidth)} ${c("│", gray)}`,
     bottomBorder: `  ${c(`╰${"─".repeat(width - 2)}╯`, gray)}`,
     promptPrefix: `  ${c("│", gray)} ${c("❯", gray)} `,
     outerGapLines: 1,
+    promptLiftRows: 3,
+    settleRowsAfterSubmit: 2,
   };
 }
 
@@ -197,6 +203,9 @@ export function drawInputArea(): void {
   }
   console.log(chrome.topBorder);
   console.log(chrome.helperLine);
+  console.log(chrome.promptLine);
+  console.log(chrome.bottomBorder);
+  console.log();
 }
 
 export function printInputSeparator(): void {
@@ -206,7 +215,12 @@ export function printInputSeparator(): void {
 
 /* ── Startup animation ── */
 
-export async function animateStartup(version: string, projectName: string, bookTitle?: string): Promise<void> {
+export interface StartupModelInfo {
+  readonly provider: string;
+  readonly model: string;
+}
+
+export async function animateStartup(version: string, projectName: string, bookTitle?: string, modelInfo?: StartupModelInfo): Promise<void> {
   const isTTY = process.stdout.isTTY;
 
   if (isTTY) {
@@ -237,24 +251,28 @@ export async function animateStartup(version: string, projectName: string, bookT
 
   // Project info
   console.log();
+  const bookDisplay = bookTitle
+    ? c(bookTitle, brightWhite)
+    : c("no book yet", dim);
+  const modelDisplay = modelInfo
+    ? `${c(modelInfo.model, brightWhite)} ${c(`(${modelInfo.provider})`, dim)}`
+    : c("not configured — type /config to set up", yellow);
+
   if (isTTY) {
     await typewrite(`  ${c("◇", cyan)} ${c("Project", gray)}  ${c(projectName, brightWhite)}`, 8);
-    await sleep(80);
-    const bookDisplay = bookTitle
-      ? c(bookTitle, brightWhite)
-      : c("no book yet", dim);
+    await sleep(60);
     await typewrite(`  ${c("◇", cyan)} ${c("Book", gray)}     ${bookDisplay}`, 8);
-    await sleep(80);
+    await sleep(60);
+    await typewrite(`  ${c("◇", cyan)} ${c("Model", gray)}    ${modelDisplay}`, 8);
+    await sleep(60);
   } else {
     console.log(`  ${c("◇", cyan)} ${c("Project", gray)}  ${c(projectName, brightWhite)}`);
-    const bookDisplay = bookTitle
-      ? c(bookTitle, brightWhite)
-      : c("no book yet", dim);
     console.log(`  ${c("◇", cyan)} ${c("Book", gray)}     ${bookDisplay}`);
+    console.log(`  ${c("◇", cyan)} ${c("Model", gray)}    ${modelDisplay}`);
   }
 
   console.log();
-  console.log(c("  /help for commands. Type anything to begin.", dim));
+  console.log(c("  /help for commands · Tab to autocomplete", dim));
   console.log();
 }
 

+ 34 - 0
packages/cli/src/tui/setup.ts

@@ -183,6 +183,40 @@ async function checkEnvForKey(envPath: string): Promise<boolean> {
   }
 }
 
+export interface ModelInfo {
+  readonly provider: string;
+  readonly model: string;
+  readonly baseUrl: string;
+}
+
+export async function detectModelInfo(projectRoot: string): Promise<ModelInfo | undefined> {
+  const paths = [join(projectRoot, ".env"), GLOBAL_ENV_PATH];
+  for (const p of paths) {
+    const info = await parseEnvModel(p);
+    if (info) return info;
+  }
+  return undefined;
+}
+
+async function parseEnvModel(envPath: string): Promise<ModelInfo | undefined> {
+  try {
+    const content = await readFile(envPath, "utf-8");
+    const get = (key: string) => {
+      const m = content.match(new RegExp(`^${key}=(.+)$`, "m"));
+      return m?.[1]?.trim() ?? "";
+    };
+    const key = get("INKOS_LLM_API_KEY");
+    if (!key || key.includes("your-api-key")) return undefined;
+    return {
+      provider: get("INKOS_LLM_PROVIDER") || "openai",
+      model: get("INKOS_LLM_MODEL") || "unknown",
+      baseUrl: get("INKOS_LLM_BASE_URL") || "",
+    };
+  } catch {
+    return undefined;
+  }
+}
+
 async function fileExists(path: string): Promise<boolean> {
   try {
     await access(path);