Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 49 additions & 3 deletions src/cli/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ import { render } from "ink";
import { validateIntegrity } from "@/cli/bootstrap";
import { registerCommands } from "@/cli/commands";
import { loadConfig } from "@/cli/config";
import { listSessions, loadSession } from "@/cli/sessions";
import { SettingsLoader, SettingsWriter } from "@/cli/settings";
import { createCodingAgent, globalApprovalManager, globalAskUserQuestionManager } from "@/coding";
import { AnthropicModelProvider } from "@/community/anthropic";
import { OpenAIModelProvider } from "@/community/openai";
import type { ModelProvider } from "@/foundation";
import type { ModelProvider, NonSystemMessage } from "@/foundation";
import { Model } from "@/foundation";

import { App } from "./tui";
Expand All @@ -28,12 +29,50 @@ registerCommands(program);

const args = process.argv.slice(2);

if (args.length > 0) {
// Parse `--resume [sessionId]` manually so the TUI can boot without triggering
// commander's default "no subcommand → print help" exit. Any other non-empty
// argv is passed to commander for subcommand dispatch (e.g. `config`).
function parseResumeFlag(argv: string[]): { present: boolean; sessionId?: string } {
const idx = argv.indexOf("--resume");
if (idx === -1) return { present: false };
const next = argv[idx + 1];
const sessionId = next && !next.startsWith("-") ? next : undefined;
return { present: true, sessionId };
}

const resumeFlag = parseResumeFlag(args);
const remainingArgs = args.filter((arg, i) => {
if (arg === "--resume") return false;
if (resumeFlag.sessionId && i > 0 && args[i - 1] === "--resume") return false;
return true;
});

if (remainingArgs.length > 0) {
await program.parseAsync(process.argv);
} else {
console.info();
await validateIntegrity();

// Session lookup requires HELIXENT_HOME, which validateIntegrity() ensures.
let sessionId: string | undefined;
let sessionCreatedAt: string | undefined;
let resumeMessages: NonSystemMessage[] | undefined;

if (resumeFlag.present) {
const session = resumeFlag.sessionId ? loadSession(resumeFlag.sessionId) : listSessions()[0];
if (!session) {
const hint = resumeFlag.sessionId
? `Session "${resumeFlag.sessionId}" not found.`
: "No saved sessions found.";
console.error(hint);
process.exit(1);
}
sessionId = session.id;
sessionCreatedAt = session.createdAt;
resumeMessages = session.messages;
console.info(`Resuming session ${session.id} (${session.messages.length} messages)\n`);
}

const config = loadConfig();
const defaultModelName = config.defaultModel ?? config.models[0]?.name;
const entry = defaultModelName ? config.models.find((m) => m.name === defaultModelName) : undefined;
Expand Down Expand Up @@ -80,11 +119,18 @@ if (args.length > 0) {
loadAllowList: (cwd) => settingsLoader.loadAllowList(cwd),
persistAllowedTool: (cwd, toolName) => settingsWriter.appendAllowedTool(cwd, toolName),
},
resumeMessages,
});
const commands: SlashCommand[] = await loadAvailableCommands(skillsDirs);

render(
<AgentLoopProvider agent={agent} commands={commands}>
<AgentLoopProvider
agent={agent}
commands={commands}
sessionId={sessionId}
sessionCreatedAt={sessionCreatedAt}
initialMessages={resumeMessages}
>
<App commands={commands} supportProjectWideAllow />
</AgentLoopProvider>,
{ patchConsole: false },
Expand Down
2 changes: 2 additions & 0 deletions src/cli/sessions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { generateSessionId, listSessions, loadSession, saveSession } from "./session-store";
export type { Session } from "./session-store";
105 changes: 105 additions & 0 deletions src/cli/sessions/session-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";

import { afterEach, beforeEach, describe, expect, test } from "bun:test";

import type { NonSystemMessage } from "@/foundation";

import { generateSessionId, listSessions, loadSession, saveSession, type Session } from "./session-store";

let tmpHome: string;
let originalHome: string | undefined;

beforeEach(() => {
tmpHome = mkdtempSync(path.join(tmpdir(), "helixent-session-test-"));
originalHome = Bun.env.HELIXENT_HOME;
Bun.env.HELIXENT_HOME = tmpHome;
});

afterEach(() => {
if (originalHome === undefined) delete Bun.env.HELIXENT_HOME;
else Bun.env.HELIXENT_HOME = originalHome;
rmSync(tmpHome, { recursive: true, force: true });
});

const sampleMessages: NonSystemMessage[] = [
{ role: "user", content: [{ type: "text", text: "hello" }] },
{ role: "assistant", content: [{ type: "text", text: "hi there" }] },
];

function makeSession(overrides: Partial<Session> = {}): Session {
return {
id: "abcd1234",
createdAt: "2026-04-17T00:00:00.000Z",
updatedAt: "2026-04-17T00:00:01.000Z",
cwd: "/tmp/project",
messages: sampleMessages,
...overrides,
};
}

describe("session-store", () => {
test("save → load round-trip preserves all fields", () => {
const original = makeSession();
saveSession(original);
const loaded = loadSession(original.id);
expect(loaded).toEqual(original);
});

test("loadSession returns null for missing id", () => {
expect(loadSession("does-not-exist")).toBeNull();
});

test("loadSession returns null for corrupt JSON (does not throw)", () => {
const dir = path.join(tmpHome, "sessions");
mkdirSync(dir, { recursive: true });
writeFileSync(path.join(dir, "broken.json"), "{ not valid json", "utf8");
expect(loadSession("broken")).toBeNull();
});

test("listSessions returns sessions sorted by updatedAt desc", () => {
saveSession(makeSession({ id: "old00000", updatedAt: "2026-04-01T00:00:00.000Z" }));
saveSession(makeSession({ id: "mid00000", updatedAt: "2026-04-10T00:00:00.000Z" }));
saveSession(makeSession({ id: "new00000", updatedAt: "2026-04-15T00:00:00.000Z" }));
const ids = listSessions().map((s) => s.id);
expect(ids).toEqual(["new00000", "mid00000", "old00000"]);
});

test("listSessions skips corrupt files without throwing", () => {
saveSession(makeSession({ id: "good0001" }));
const dir = path.join(tmpHome, "sessions");
writeFileSync(path.join(dir, "corrupt.json"), "{ bad", "utf8");
const ids = listSessions().map((s) => s.id);
expect(ids).toEqual(["good0001"]);
});

test("listSessions returns [] when no sessions dir exists", () => {
expect(listSessions()).toEqual([]);
});

test("saveSession overwrites existing session with same id", () => {
saveSession(makeSession({ id: "same0000", updatedAt: "2026-04-01T00:00:00.000Z" }));
saveSession(
makeSession({
id: "same0000",
updatedAt: "2026-04-02T00:00:00.000Z",
messages: [{ role: "user", content: [{ type: "text", text: "updated" }] }],
}),
);
const loaded = loadSession("same0000");
expect(loaded?.updatedAt).toBe("2026-04-02T00:00:00.000Z");
expect(loaded?.messages).toHaveLength(1);
});

test("generateSessionId returns 8-character string", () => {
const id = generateSessionId();
expect(id).toHaveLength(8);
expect(id).toMatch(/^[0-9a-f]{8}$/);
});

test("generateSessionId returns unique ids across calls", () => {
const ids = new Set(Array.from({ length: 100 }, () => generateSessionId()));
expect(ids.size).toBe(100);
});
});
57 changes: 57 additions & 0 deletions src/cli/sessions/session-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";

import { getHelixentHomePath } from "@/cli/config";
import type { NonSystemMessage } from "@/foundation";

export interface Session {
id: string;
createdAt: string;
updatedAt: string;
cwd: string;
/** All conversation messages visible in the TUI (excludes the AGENTS.md preamble). */
messages: NonSystemMessage[];
}

function getSessionsDir(): string {
return path.join(getHelixentHomePath(), "sessions");
}

function getSessionFilePath(sessionId: string): string {
return path.join(getSessionsDir(), `${sessionId}.json`);
}

export function generateSessionId(): string {
return crypto.randomUUID().slice(0, 8);
}

export function saveSession(session: Session): void {
const dir = getSessionsDir();
mkdirSync(dir, { recursive: true });
writeFileSync(getSessionFilePath(session.id), JSON.stringify(session), "utf8");
}

export function loadSession(sessionId: string): Session | null {
const filePath = getSessionFilePath(sessionId);
if (!existsSync(filePath)) return null;
try {
return JSON.parse(readFileSync(filePath, "utf8")) as Session;
} catch {
return null;
}
}

export function listSessions(): Session[] {
const dir = getSessionsDir();
if (!existsSync(dir)) return [];
const sessions: Session[] = [];
for (const file of readdirSync(dir)) {
if (!file.endsWith(".json")) continue;
try {
sessions.push(JSON.parse(readFileSync(path.join(dir, file), "utf8")) as Session);
} catch {
// skip corrupt files
}
}
return sessions.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
5 changes: 3 additions & 2 deletions src/cli/tui/components/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ function formatTokenCount(count: number): string {
}

export function Footer() {
const { agent, tokenCount } = useAgentLoop();
const { agent, sessionId, tokenCount } = useAgentLoop();
return (
<Box paddingX={2} width="100%">
<Box flexGrow={1} justifyContent="flex-start">
<Text color={currentTheme.colors.dimText}>{agent.model.name}</Text>
</Box>
<Box justifyContent="flex-end">
<Box justifyContent="flex-end" columnGap={2}>
<Text color={currentTheme.colors.dimText}>session {sessionId}</Text>
<Text color={currentTheme.colors.dimText}>{formatTokenCount(tokenCount)} tokens</Text>
</Box>
</Box>
Expand Down
41 changes: 38 additions & 3 deletions src/cli/tui/hooks/use-agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createContext, createElement, useCallback, useContext, useEffect, useMe
import type { ReactNode } from "react";

import type { Agent } from "@/agent";
import { generateSessionId, saveSession } from "@/cli/sessions";
import type { AssistantMessage, NonSystemMessage, UserMessage } from "@/foundation";

import type { PromptSubmission, SlashCommand } from "../command-registry";
Expand All @@ -11,6 +12,7 @@ type AgentLoopState = {
agent: Agent;
streaming: boolean;
messages: NonSystemMessage[];
sessionId: string;
// eslint-disable-next-line no-unused-vars
onSubmit: (submission: PromptSubmission) => Promise<void>;
abort: () => void;
Expand All @@ -22,14 +24,26 @@ const AgentLoopContext = createContext<AgentLoopState | null>(null);
export function AgentLoopProvider({
agent,
commands = [],
sessionId: initialSessionId,
sessionCreatedAt: initialSessionCreatedAt,
initialMessages = [],
children,
}: {
agent: Agent;
commands?: SlashCommand[];
/** Session ID to persist the conversation under. A new one is generated if omitted. */
sessionId?: string;
/** Original createdAt of the session; preserved across saves when resuming. */
sessionCreatedAt?: string;
/** Pre-populated messages when resuming a saved session. */
initialMessages?: NonSystemMessage[];
children: ReactNode;
}) {
const [streaming, setStreaming] = useState(false);
const [messages, setMessages] = useState<NonSystemMessage[]>([]);
const [messages, setMessages] = useState<NonSystemMessage[]>(initialMessages);
const sessionIdRef = useRef(initialSessionId ?? generateSessionId());
const sessionCreatedAtRef = useRef(initialSessionCreatedAt ?? new Date().toISOString());
const messagesRef = useRef<NonSystemMessage[]>(initialMessages);

const streamingRef = useRef(streaming);
const pendingMessagesRef = useRef<NonSystemMessage[]>([]);
Expand All @@ -48,7 +62,11 @@ export function AgentLoopProvider({

const pending = pendingMessagesRef.current;
pendingMessagesRef.current = [];
setMessages((prev) => [...prev, ...pending]);
setMessages((prev) => {
const next = [...prev, ...pending];
messagesRef.current = next;
return next;
});
}, []);

const enqueueMessage = useCallback(
Expand Down Expand Up @@ -94,6 +112,7 @@ export function AgentLoopProvider({
if (invocation?.name === "clear") {
agent.clearMessages();
flushPendingMessages();
messagesRef.current = [];
setMessages([]);
clearTerminal();
return;
Expand All @@ -120,7 +139,11 @@ export function AgentLoopProvider({
try {
agent.setRequestedSkillName(requestedSkillName);
const userMessage: UserMessage = { role: "user", content: [{ type: "text", text }] };
setMessages((prev) => [...prev, userMessage]);
setMessages((prev) => {
const next = [...prev, userMessage];
messagesRef.current = next;
return next;
});

const stream = agent.stream(userMessage);
for await (const event of stream) {
Expand All @@ -138,6 +161,17 @@ export function AgentLoopProvider({
agent.setRequestedSkillName(null);
flushPendingMessages();
setStreaming(false);
try {
saveSession({
id: sessionIdRef.current,
createdAt: sessionCreatedAtRef.current,
updatedAt: new Date().toISOString(),
cwd: process.cwd(),
messages: messagesRef.current,
});
} catch {
// session save is best-effort; never crash the TUI
}
}
},
[agent, commands, enqueueMessage, flushPendingMessages],
Expand All @@ -148,6 +182,7 @@ export function AgentLoopProvider({
agent,
streaming,
messages,
sessionId: sessionIdRef.current,
onSubmit,
abort,
tokenCount,
Expand Down
Loading
Loading