From cb43e5ce3b1e4cbaf0aab1cdd7b54cf30f55d61f Mon Sep 17 00:00:00 2001 From: igor-susic1 Date: Mon, 29 Jun 2026 20:22:59 +0000 Subject: [PATCH] feat: add kimchi history subcommand --- src/commands/help.ts | 1 + src/commands/history.test.ts | 291 +++++++++++++++++++++++++++++++++++ src/commands/history.ts | 207 +++++++++++++++++++++++++ src/commands/registry.ts | 9 ++ 4 files changed, 508 insertions(+) create mode 100644 src/commands/history.test.ts create mode 100644 src/commands/history.ts diff --git a/src/commands/help.ts b/src/commands/help.ts index 5b1669a2f..f56f7b614 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -100,5 +100,6 @@ export async function printMergedHelp(): Promise { console.log(` kimchi ${dim("# launch the interactive harness")}`) console.log(` kimchi -p "explain src/cli.ts" ${dim("# one-shot prompt, no session")}`) console.log(` kimchi --continue ${dim("# resume the most recent session")}`) + console.log(` kimchi history ${dim("# pick a previous session to resume")}`) console.log(` kimchi claude -p "review this PR" ${dim("# run Claude Code via Kimchi")}`) } diff --git a/src/commands/history.test.ts b/src/commands/history.test.ts new file mode 100644 index 000000000..792c8a50c --- /dev/null +++ b/src/commands/history.test.ts @@ -0,0 +1,291 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const selectMock = vi.fn() +const isCancelMock = vi.fn((value: unknown) => value === Symbol.for("clack:cancel")) + +vi.mock("@clack/prompts", () => ({ + select: (...args: unknown[]) => selectMock(...args), + isCancel: (value: unknown) => isCancelMock(value), +})) + +const spawnMock = vi.fn() + +vi.mock("node:child_process", () => ({ + spawn: (...args: unknown[]) => spawnMock(...args), +})) + +const listMock = vi.fn() + +vi.mock("@earendil-works/pi-coding-agent", async () => { + const actual = await vi.importActual("@earendil-works/pi-coding-agent") + return { + ...(actual as object), + SessionManager: { + list: (...args: unknown[]) => listMock(...args), + }, + } +}) + +import { EventEmitter } from "node:events" +import { getHistoryHelp, runHistory } from "./history.js" + +function makeSession( + overrides: Partial<{ + path: string + modified: Date + id: string + cwd: string + created: Date + messageCount: number + firstMessage: string + allMessagesText: string + }> = {}, +): { + path: string + modified: Date + id: string + cwd: string + created: Date + messageCount: number + firstMessage: string + allMessagesText: string +} { + return { + path: "/tmp/session.md", + modified: new Date("2025-01-02T00:00:00Z"), + id: "session-1", + cwd: "/tmp", + created: new Date("2025-01-01T00:00:00Z"), + messageCount: 3, + firstMessage: "hello world", + allMessagesText: "hello world", + ...overrides, + } +} + +function makeFakeChild(): EventEmitter & { kill: ReturnType } { + const child = new EventEmitter() as EventEmitter & { kill: ReturnType } + child.kill = vi.fn() + return child +} + +describe("runHistory", () => { + let logSpy: ReturnType + let errSpy: ReturnType + let cwdSpy: ReturnType + let stdinIsTTY: boolean | undefined + + beforeEach(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}) + errSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/tmp") + stdinIsTTY = process.stdin.isTTY + Object.defineProperty(process.stdin, "isTTY", { + value: true, + configurable: true, + }) + selectMock.mockReset() + isCancelMock.mockReset() + isCancelMock.mockImplementation((value: unknown) => value === Symbol.for("clack:cancel")) + spawnMock.mockReset() + listMock.mockReset() + }) + + afterEach(() => { + logSpy.mockRestore() + errSpy.mockRestore() + cwdSpy.mockRestore() + Object.defineProperty(process.stdin, "isTTY", { + value: stdinIsTTY, + configurable: true, + }) + }) + + it("--help prints help and returns 0", async () => { + const code = await runHistory(["--help"]) + expect(code).toBe(0) + const out = logSpy.mock.calls.map((c) => String(c[0] ?? "")).join("\n") + expect(out).toContain("Usage: kimchi history") + expect(out).toContain("--limit") + }) + + it("-h prints help and returns 0", async () => { + const code = await runHistory(["-h"]) + expect(code).toBe(0) + expect(logSpy).toHaveBeenCalled() + }) + + it("returns 1 with a friendly message when there are no sessions", async () => { + listMock.mockResolvedValue([]) + const code = await runHistory([]) + expect(code).toBe(1) + expect(listMock).toHaveBeenCalledWith("/tmp") + const out = logSpy.mock.calls.map((c) => String(c[0] ?? "")).join("\n") + expect(out).toContain("No previous sessions found in /tmp") + }) + + it("spawns the current executable with --session when a session is selected", async () => { + const session = makeSession({ path: "/tmp/session-a.md" }) + listMock.mockResolvedValue([session]) + selectMock.mockResolvedValue("/tmp/session-a.md") + + const child = makeFakeChild() + spawnMock.mockReturnValue(child) + + const promise = runHistory([]) + // Give the event listeners a tick to attach. + await new Promise((resolve) => setTimeout(resolve, 10)) + child.emit("exit", 42, null) + const code = await promise + + expect(code).toBe(42) + expect(spawnMock).toHaveBeenCalledWith( + process.execPath, + [process.argv[1], "--session", "/tmp/session-a.md"], + expect.objectContaining({ stdio: "inherit" }), + ) + }) + + it("returns 130 when the user cancels the prompt", async () => { + listMock.mockResolvedValue([makeSession()]) + selectMock.mockResolvedValue(Symbol.for("clack:cancel")) + isCancelMock.mockReturnValue(true) + + const code = await runHistory([]) + + expect(code).toBe(130) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it("limits the list with --limit", async () => { + const sessions = [ + makeSession({ path: "/tmp/session-1.md", modified: new Date("2025-01-03T00:00:00Z"), firstMessage: "one" }), + makeSession({ path: "/tmp/session-2.md", modified: new Date("2025-01-02T00:00:00Z"), firstMessage: "two" }), + ] + listMock.mockResolvedValue(sessions) + selectMock.mockResolvedValue("/tmp/session-1.md") + + const child = makeFakeChild() + spawnMock.mockReturnValue(child) + + const promise = runHistory(["--limit", "1"]) + await new Promise((resolve) => setTimeout(resolve, 10)) + child.emit("exit", 0, null) + await promise + + expect(selectMock).toHaveBeenCalledTimes(1) + const options = selectMock.mock.calls[0]?.[0].options + expect(options).toHaveLength(1) + expect(options[0].value).toBe("/tmp/session-1.md") + }) + + it("caps --limit at 100", async () => { + const sessions = Array.from({ length: 101 }, (_, i) => + makeSession({ + path: `/tmp/session-${i}.md`, + modified: new Date(2025, 0, i + 1), + firstMessage: `msg-${i}`, + }), + ) + listMock.mockResolvedValue(sessions) + selectMock.mockResolvedValue(sessions[0].path) + + const child = makeFakeChild() + spawnMock.mockReturnValue(child) + + const promise = runHistory(["--limit", "200"]) + await new Promise((resolve) => setTimeout(resolve, 10)) + child.emit("exit", 0, null) + await promise + + const options = selectMock.mock.calls[0]?.[0].options + expect(options).toHaveLength(100) + }) + + it("returns 2 for an invalid --limit value", async () => { + const code = await runHistory(["--limit", "abc"]) + expect(code).toBe(2) + expect(errSpy.mock.calls.map((c) => String(c[0] ?? "")).join("\n")).toContain("invalid limit") + }) + + it("returns 2 for a missing --limit value", async () => { + const code = await runHistory(["--limit"]) + expect(code).toBe(2) + expect(errSpy.mock.calls.map((c) => String(c[0] ?? "")).join("\n")).toContain("missing value") + }) + + it("returns 2 for unknown flags", async () => { + const code = await runHistory(["--bogus"]) + expect(code).toBe(2) + expect(errSpy.mock.calls.map((c) => String(c[0] ?? "")).join("\n")).toContain("unknown flag") + }) + + it("returns 1 when SessionManager.list throws", async () => { + listMock.mockRejectedValue(new Error("disk unreadable")) + const code = await runHistory([]) + expect(code).toBe(1) + expect(errSpy.mock.calls.map((c) => String(c[0] ?? "")).join("\n")).toContain("failed to list sessions") + }) + + it("returns 1 when the spawned child fails to start", async () => { + listMock.mockResolvedValue([makeSession()]) + selectMock.mockResolvedValue("/tmp/session-a.md") + spawnMock.mockImplementation(() => { + throw new Error("spawn failed") + }) + + const code = await runHistory([]) + expect(code).toBe(1) + expect(errSpy.mock.calls.map((c) => String(c[0] ?? "")).join("\n")).toContain("failed to resume session") + }) + + it("prints a numbered list and exits 0 when stdin is not a TTY", async () => { + Object.defineProperty(process.stdin, "isTTY", { + value: false, + configurable: true, + }) + listMock.mockResolvedValue([makeSession({ path: "/tmp/session-a.md", firstMessage: "past work" })]) + + const code = await runHistory([]) + + expect(code).toBe(0) + expect(selectMock).not.toHaveBeenCalled() + expect(spawnMock).not.toHaveBeenCalled() + const out = logSpy.mock.calls.map((c) => String(c[0] ?? "")).join("\n") + expect(out).toContain("Recent sessions:") + expect(out).toContain("past work") + expect(out).toContain("kimchi --session") + }) + + it("sorts sessions by modified date descending", async () => { + const sessions = [ + makeSession({ path: "/tmp/session-old.md", modified: new Date("2025-01-01T00:00:00Z"), firstMessage: "old" }), + makeSession({ path: "/tmp/session-new.md", modified: new Date("2025-01-03T00:00:00Z"), firstMessage: "new" }), + makeSession({ path: "/tmp/session-mid.md", modified: new Date("2025-01-02T00:00:00Z"), firstMessage: "mid" }), + ] + listMock.mockResolvedValue(sessions) + selectMock.mockResolvedValue("/tmp/session-new.md") + + const child = makeFakeChild() + spawnMock.mockReturnValue(child) + + const promise = runHistory([]) + await new Promise((resolve) => setTimeout(resolve, 10)) + child.emit("exit", 0, null) + await promise + + const options = selectMock.mock.calls[0]?.[0].options + expect(options[0].value).toBe("/tmp/session-new.md") + expect(options[1].value).toBe("/tmp/session-mid.md") + expect(options[2].value).toBe("/tmp/session-old.md") + }) +}) + +describe("getHistoryHelp", () => { + it("returns a help string", () => { + const help = getHistoryHelp() + expect(help).toContain("kimchi history") + expect(help).toContain("--limit") + expect(help).toContain("--help") + }) +}) diff --git a/src/commands/history.ts b/src/commands/history.ts new file mode 100644 index 000000000..963607337 --- /dev/null +++ b/src/commands/history.ts @@ -0,0 +1,207 @@ +import { type ChildProcess, spawn } from "node:child_process" +import * as clack from "@clack/prompts" +import { SessionManager } from "@earendil-works/pi-coding-agent" + +const DEFAULT_LIMIT = 20 +const MAX_LIMIT = 100 + +interface SessionSummary { + path: string + modified: Date + id: string + cwd: string + created: Date + messageCount: number + firstMessage: string + allMessagesText: string +} + +export function getHistoryHelp(): string { + return [ + "Usage: kimchi history [options]", + "", + "Browse and resume previous chat/development sessions for this project.", + "", + "Options:", + " --limit Maximum number of sessions to list (default: 20, max: 100)", + " --help, -h Show this help", + "", + "Examples:", + " kimchi history # pick a previous session to resume", + " kimchi history --limit 5 # list only the 5 most recent sessions", + ].join("\n") +} + +function relativeDate(date: Date): string { + const now = Date.now() + const diff = now - date.getTime() + const seconds = Math.max(0, Math.floor(diff / 1000)) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + const days = Math.floor(hours / 24) + + if (seconds < 10) return "just now" + if (seconds < 60) return `${seconds}s ago` + if (minutes < 60) return `${minutes}m ago` + if (hours < 24) return `${hours}h ago` + if (days < 30) return `${days}d ago` + const months = Math.floor(days / 30) + if (months < 12) return `${months}mo ago` + return `${Math.floor(months / 12)}y ago` +} + +function formatLabel(session: SessionSummary): string { + const preview = session.firstMessage.trim() || "(no preview)" + const shortPreview = preview.length > 60 ? `${preview.slice(0, 57)}...` : preview + return `${relativeDate(session.modified)} — ${shortPreview}` +} + +function parseArgs(args: string[]): { limit: number; help: boolean; error?: string } { + let limit = DEFAULT_LIMIT + let help = false + + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg === "--help" || arg === "-h") { + help = true + continue + } + if (arg === "--limit") { + const next = args[i + 1] + if (next === undefined || next.startsWith("-")) { + return { limit: 0, help: false, error: "missing value for --limit" } + } + const parsed = Number(next) + if (!Number.isInteger(parsed) || parsed <= 0) { + return { limit: 0, help: false, error: `invalid limit: ${next}` } + } + limit = Math.min(parsed, MAX_LIMIT) + i++ + continue + } + return { limit: 0, help: false, error: `unknown flag: ${arg}` } + } + + return { limit, help } +} + +function forwardSignal(child: ChildProcess, signal: NodeJS.Signals): void { + try { + child.kill(signal) + } catch { + // Child already exited. + } +} + +function signalToNumber(sig: NodeJS.Signals): number { + switch (sig) { + case "SIGHUP": + return 1 + case "SIGINT": + return 2 + case "SIGTERM": + return 15 + default: + return 0 + } +} + +function resumeSession(sessionPath: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [process.argv[1], "--session", sessionPath], { + stdio: "inherit", + env: process.env, + }) + + const handler = (signal: NodeJS.Signals) => () => forwardSignal(child, signal) + const sigInt = handler("SIGINT") + const sigTerm = handler("SIGTERM") + const sigHup = handler("SIGHUP") + process.on("SIGINT", sigInt) + process.on("SIGTERM", sigTerm) + process.on("SIGHUP", sigHup) + + const cleanup = () => { + process.off("SIGINT", sigInt) + process.off("SIGTERM", sigTerm) + process.off("SIGHUP", sigHup) + } + + child.on("error", (err) => { + cleanup() + reject(err) + }) + child.on("exit", (code, signal) => { + cleanup() + if (code !== null) { + resolve(code) + } else if (signal) { + try { + process.kill(process.pid, signal) + resolve(128 + signalToNumber(signal)) + } catch { + resolve(128 + signalToNumber(signal)) + } + } else { + resolve(0) + } + }) + }) +} + +export async function runHistory(args: string[]): Promise { + const parsed = parseArgs(args) + if (parsed.help) { + console.log(getHistoryHelp()) + return 0 + } + if (parsed.error) { + console.error(`kimchi history: ${parsed.error}`) + console.error(getHistoryHelp()) + return 2 + } + + let sessions: SessionSummary[] + try { + sessions = (await SessionManager.list(process.cwd())) as SessionSummary[] + } catch (err) { + console.error("kimchi history: failed to list sessions", err instanceof Error ? err.message : String(err)) + return 1 + } + + const sorted = [...sessions].sort((a, b) => b.modified.getTime() - a.modified.getTime()) + const limited = sorted.slice(0, parsed.limit) + + if (limited.length === 0) { + console.log(`No previous sessions found in ${process.cwd()}.`) + return 1 + } + + if (!process.stdin.isTTY) { + console.log("Recent sessions:") + for (let i = 0; i < limited.length; i++) { + console.log(` ${i + 1}. ${formatLabel(limited[i])}`) + } + console.log("\nRun `kimchi --session ` to resume a session.") + return 0 + } + + const selected = await clack.select({ + message: "Pick a session to resume:", + options: limited.map((session) => ({ + value: session.path, + label: formatLabel(session), + })), + }) + + if (clack.isCancel(selected)) { + return 130 + } + + try { + return await resumeSession(selected as string) + } catch (err) { + console.error("kimchi history: failed to resume session", err instanceof Error ? err.message : String(err)) + return 1 + } +} diff --git a/src/commands/registry.ts b/src/commands/registry.ts index af29040c8..bb4cf0194 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -3,12 +3,15 @@ export interface CommandDefinition { summary: string /** Run this command. Receives args after the subcommand name. Should not return on success unless the command is purely informational. */ run: (args: string[]) => Promise + /** Optional help text generator for the subcommand. */ + help?: () => string } import { runClaude } from "./claude.js" import { runConfig } from "./config.js" import { runCursor } from "./cursor.js" import { runGsd2 } from "./gsd2.js" +import { getHistoryHelp, runHistory } from "./history.js" import { runLogin } from "./login.js" import { runOpenClaw } from "./openclaw.js" import { runOpenCode } from "./opencode.js" @@ -29,6 +32,12 @@ export const COMMANDS: CommandDefinition[] = [ { name: "gsd2", summary: "Install / configure GSD2 with Kimchi", run: runGsd2 }, { name: "update", summary: "Check for and install Kimchi/package updates", run: runUpdate }, { name: "config", summary: "Inspect or change kimchi config (e.g. telemetry)", run: runConfig }, + { + name: "history", + summary: "Browse and resume previous sessions for this project", + run: runHistory, + help: getHistoryHelp, + }, { name: "resources", summary: "Enable or disable Kimchi hooks, tools, extensions, and plugins", run: runResources }, { name: "version", summary: "Print the kimchi version", run: runVersion }, ]