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

feat(tui): persist chat history and mode feedback

Ma 7 месяцев назад
Родитель
Сommit
7f41f26c5c

+ 2 - 0
packages/cli/src/__tests__/tui-control-flow.test.ts

@@ -36,6 +36,8 @@ describe("tui control flow", () => {
     expect(tools.writeNextChapter).toHaveBeenCalledWith("harbor");
     expect(tools.writeNextChapter).toHaveBeenCalledWith("harbor");
     expect(result.session.activeBookId).toBe("harbor");
     expect(result.session.activeBookId).toBe("harbor");
     expect(result.session.currentExecution?.status).toBe("completed");
     expect(result.session.currentExecution?.status).toBe("completed");
+    expect(result.session.messages.at(0)?.role).toBe("user");
+    expect(result.session.messages.at(-1)?.role).toBe("assistant");
   });
   });
 
 
   it("routes focus updates through the interaction runtime", async () => {
   it("routes focus updates through the interaction runtime", async () => {

+ 3 - 0
packages/cli/src/__tests__/tui-layout.test.ts

@@ -14,6 +14,7 @@ describe("tui layout", () => {
     expect(frame).toContain("Book: none");
     expect(frame).toContain("Book: none");
     expect(frame).toContain("Mode: semi");
     expect(frame).toContain("Mode: semi");
     expect(frame).toContain("Stage: idle");
     expect(frame).toContain("Stage: idle");
+    expect(frame).toContain("Messages:");
     expect(frame).toContain(">");
     expect(frame).toContain(">");
   });
   });
 
 
@@ -23,10 +24,12 @@ describe("tui layout", () => {
       activeBookTitle: "Night Harbor Echo",
       activeBookTitle: "Night Harbor Echo",
       automationMode: "auto",
       automationMode: "auto",
       status: "writing",
       status: "writing",
+      messages: ["user: continue", "assistant: Completed write_next for harbor."],
     });
     });
 
 
     expect(frame).toContain("Book: Night Harbor Echo");
     expect(frame).toContain("Book: Night Harbor Echo");
     expect(frame).toContain("Mode: auto");
     expect(frame).toContain("Mode: auto");
     expect(frame).toContain("Stage: writing");
     expect(frame).toContain("Stage: writing");
+    expect(frame).toContain("user: continue");
   });
   });
 });
 });

+ 28 - 9
packages/cli/src/tui/app.ts

@@ -1,6 +1,7 @@
 import { basename } from "node:path";
 import { basename } from "node:path";
 import readline from "node:readline/promises";
 import readline from "node:readline/promises";
 import {
 import {
+  appendInteractionMessage,
   routeNaturalLanguageIntent,
   routeNaturalLanguageIntent,
   runInteractionRequest,
   runInteractionRequest,
   type AutomationMode,
   type AutomationMode,
@@ -21,6 +22,7 @@ export interface TuiFrameState {
   readonly activeBookTitle?: string;
   readonly activeBookTitle?: string;
   readonly automationMode: AutomationMode;
   readonly automationMode: AutomationMode;
   readonly status: ExecutionStatus;
   readonly status: ExecutionStatus;
+  readonly messages?: ReadonlyArray<string>;
 }
 }
 
 
 export function renderTuiFrame(state: TuiFrameState): string {
 export function renderTuiFrame(state: TuiFrameState): string {
@@ -30,6 +32,11 @@ export function renderTuiFrame(state: TuiFrameState): string {
     `Mode: ${state.automationMode}`,
     `Mode: ${state.automationMode}`,
     `Stage: ${state.status}`,
     `Stage: ${state.status}`,
     "",
     "",
+    "Messages:",
+    ...(state.messages?.length
+      ? state.messages.slice(-3).map((message) => `- ${message}`)
+      : ["- (empty)"]),
+    "",
     "> ",
     "> ",
   ];
   ];
 
 
@@ -43,9 +50,14 @@ export async function processTuiInput(
 ) {
 ) {
   const session = await loadProjectSession(projectRoot);
   const session = await loadProjectSession(projectRoot);
   const activeBookId = await resolveSessionActiveBook(projectRoot, session);
   const activeBookId = await resolveSessionActiveBook(projectRoot, session);
-  const boundSession = activeBookId && session.activeBookId !== activeBookId
+  const sessionWithBook = activeBookId && session.activeBookId !== activeBookId
     ? { ...session, activeBookId }
     ? { ...session, activeBookId }
     : session;
     : session;
+  const boundSession = appendInteractionMessage(sessionWithBook, {
+    role: "user",
+    content: input,
+    timestamp: Date.now(),
+  });
   const request = routeNaturalLanguageIntent(input, {
   const request = routeNaturalLanguageIntent(input, {
     activeBookId: boundSession.activeBookId,
     activeBookId: boundSession.activeBookId,
   });
   });
@@ -54,8 +66,19 @@ export async function processTuiInput(
     request,
     request,
     tools,
     tools,
   });
   });
-  await persistProjectSession(projectRoot, result.session);
-  return { ...result, request };
+  const summary = formatTuiResult({
+    intent: request.intent,
+    status: result.session.currentExecution?.status ?? "completed",
+    bookId: result.session.activeBookId,
+    mode: request.mode,
+  });
+  const nextSession = appendInteractionMessage(result.session, {
+    role: "assistant",
+    content: summary,
+    timestamp: Date.now(),
+  });
+  await persistProjectSession(projectRoot, nextSession);
+  return { ...result, session: nextSession, request };
 }
 }
 
 
 export async function launchTui(
 export async function launchTui(
@@ -69,6 +92,7 @@ export async function launchTui(
     activeBookTitle: activeBookId,
     activeBookTitle: activeBookId,
     automationMode: session.automationMode,
     automationMode: session.automationMode,
     status: session.currentExecution?.status ?? "idle",
     status: session.currentExecution?.status ?? "idle",
+    messages: session.messages.map((message) => `${message.role}: ${message.content}`),
   });
   });
 
 
   process.stdout.write(frame);
   process.stdout.write(frame);
@@ -88,12 +112,7 @@ export async function launchTui(
       return;
       return;
     }
     }
     const result = await processTuiInput(projectRoot, input, tools ?? await createInteractionTools(projectRoot));
     const result = await processTuiInput(projectRoot, input, tools ?? await createInteractionTools(projectRoot));
-    process.stdout.write(`\n${formatTuiResult({
-      intent: result.request.intent,
-      status: result.session.currentExecution?.status ?? "completed",
-      bookId: result.session.activeBookId,
-      mode: result.request.mode,
-    })}\n`);
+    process.stdout.write(`\n${result.session.messages.at(-1)?.content ?? ""}\n`);
   } finally {
   } finally {
     rl.close();
     rl.close();
   }
   }

+ 22 - 0
packages/core/src/__tests__/interaction-models.test.ts

@@ -7,6 +7,7 @@ import {
   bindActiveBook,
   bindActiveBook,
   clearPendingDecision,
   clearPendingDecision,
   isTerminalExecutionStatus,
   isTerminalExecutionStatus,
+  appendInteractionMessage,
 } from "../index.js";
 } from "../index.js";
 
 
 describe("interaction models", () => {
 describe("interaction models", () => {
@@ -76,4 +77,25 @@ describe("interaction models", () => {
       pendingDecision: undefined,
       pendingDecision: undefined,
     });
     });
   });
   });
+
+  it("appends interaction messages in timestamp order", () => {
+    const session = InteractionSessionSchema.parse({
+      sessionId: "session-3",
+      projectRoot: "/tmp/project",
+      automationMode: "semi",
+      messages: [],
+    });
+
+    const next = appendInteractionMessage(session, {
+      role: "user",
+      content: "continue",
+      timestamp: 1,
+    });
+
+    expect(next.messages).toEqual([{
+      role: "user",
+      content: "continue",
+      timestamp: 1,
+    }]);
+  });
 });
 });

+ 1 - 0
packages/core/src/index.ts

@@ -96,6 +96,7 @@ export {
   bindActiveBook,
   bindActiveBook,
   clearPendingDecision,
   clearPendingDecision,
   updateAutomationMode,
   updateAutomationMode,
+  appendInteractionMessage,
 } from "./interaction/session.js";
 } from "./interaction/session.js";
 export { routeInteractionRequest } from "./interaction/request-router.js";
 export { routeInteractionRequest } from "./interaction/request-router.js";
 export {
 export {

+ 10 - 0
packages/core/src/interaction/session.ts

@@ -64,3 +64,13 @@ export function updateAutomationMode(
     automationMode,
     automationMode,
   };
   };
 }
 }
+
+export function appendInteractionMessage(
+  session: InteractionSession,
+  message: InteractionMessage,
+): InteractionSession {
+  return {
+    ...session,
+    messages: [...session.messages, message].sort((left, right) => left.timestamp - right.timestamp),
+  };
+}