mirror of
https://github.com/harivansh-afk/clanker-agent.git
synced 2026-04-15 15:03:34 +00:00
- Copy all pi-mono source into apps/companion-os/ - Update Dockerfile to COPY pre-built binary instead of downloading from GitHub Releases - Update deploy-staging.yml to build pi from source (bun compile) before Docker build - Add apps/companion-os/** to path triggers - No more cross-repo dispatch needed Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
207 lines
6.1 KiB
TypeScript
207 lines
6.1 KiB
TypeScript
import {
|
|
DEFAULT_EDITOR_KEYBINDINGS,
|
|
EditorKeybindingsManager,
|
|
setEditorKeybindings,
|
|
} from "@mariozechner/pi-tui";
|
|
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
import { KeybindingsManager } from "../src/core/keybindings.js";
|
|
import type { SessionInfo } from "../src/core/session-manager.js";
|
|
import { SessionSelectorComponent } from "../src/modes/interactive/components/session-selector.js";
|
|
import { initTheme } from "../src/modes/interactive/theme/theme.js";
|
|
|
|
type Deferred<T> = {
|
|
promise: Promise<T>;
|
|
resolve: (value: T) => void;
|
|
reject: (err: unknown) => void;
|
|
};
|
|
|
|
function createDeferred<T>(): Deferred<T> {
|
|
let resolve: (value: T) => void = () => {};
|
|
let reject: (err: unknown) => void = () => {};
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
async function flushPromises(): Promise<void> {
|
|
await new Promise<void>((resolve) => {
|
|
setImmediate(resolve);
|
|
});
|
|
}
|
|
|
|
function makeSession(
|
|
overrides: Partial<SessionInfo> & { id: string },
|
|
): SessionInfo {
|
|
return {
|
|
path: overrides.path ?? `/tmp/${overrides.id}.jsonl`,
|
|
id: overrides.id,
|
|
cwd: overrides.cwd ?? "",
|
|
name: overrides.name,
|
|
created: overrides.created ?? new Date(0),
|
|
modified: overrides.modified ?? new Date(0),
|
|
messageCount: overrides.messageCount ?? 1,
|
|
firstMessage: overrides.firstMessage ?? "hello",
|
|
allMessagesText: overrides.allMessagesText ?? "hello",
|
|
};
|
|
}
|
|
|
|
const CTRL_D = "\x04";
|
|
const CTRL_BACKSPACE = "\x1b[127;5u";
|
|
|
|
describe("session selector path/delete interactions", () => {
|
|
const keybindings = KeybindingsManager.inMemory();
|
|
|
|
beforeEach(() => {
|
|
// Ensure test isolation: editor keybindings are a global singleton
|
|
setEditorKeybindings(
|
|
new EditorKeybindingsManager(DEFAULT_EDITOR_KEYBINDINGS),
|
|
);
|
|
});
|
|
|
|
beforeAll(() => {
|
|
// session selector uses the global theme instance
|
|
initTheme("dark");
|
|
});
|
|
it("does not treat Ctrl+Backspace as delete when search query is non-empty", async () => {
|
|
const sessions = [makeSession({ id: "a" }), makeSession({ id: "b" })];
|
|
|
|
const selector = new SessionSelectorComponent(
|
|
async () => sessions,
|
|
async () => [],
|
|
() => {},
|
|
() => {},
|
|
() => {},
|
|
() => {},
|
|
{ keybindings },
|
|
);
|
|
await flushPromises();
|
|
|
|
const list = selector.getSessionList();
|
|
const confirmationChanges: Array<string | null> = [];
|
|
list.onDeleteConfirmationChange = (path) => confirmationChanges.push(path);
|
|
|
|
list.handleInput("a");
|
|
list.handleInput(CTRL_BACKSPACE);
|
|
|
|
expect(confirmationChanges).toEqual([]);
|
|
});
|
|
|
|
it("enters confirmation mode on Ctrl+D even with a non-empty search query", async () => {
|
|
const sessions = [makeSession({ id: "a" }), makeSession({ id: "b" })];
|
|
|
|
const selector = new SessionSelectorComponent(
|
|
async () => sessions,
|
|
async () => [],
|
|
() => {},
|
|
() => {},
|
|
() => {},
|
|
() => {},
|
|
{ keybindings },
|
|
);
|
|
await flushPromises();
|
|
|
|
const list = selector.getSessionList();
|
|
const confirmationChanges: Array<string | null> = [];
|
|
list.onDeleteConfirmationChange = (path) => confirmationChanges.push(path);
|
|
|
|
list.handleInput("a");
|
|
list.handleInput(CTRL_D);
|
|
|
|
expect(confirmationChanges).toEqual([sessions[0]!.path]);
|
|
});
|
|
|
|
it("enters confirmation mode on Ctrl+Backspace when search query is empty", async () => {
|
|
const sessions = [makeSession({ id: "a" }), makeSession({ id: "b" })];
|
|
|
|
const selector = new SessionSelectorComponent(
|
|
async () => sessions,
|
|
async () => [],
|
|
() => {},
|
|
() => {},
|
|
() => {},
|
|
() => {},
|
|
{ keybindings },
|
|
);
|
|
await flushPromises();
|
|
|
|
const list = selector.getSessionList();
|
|
const confirmationChanges: Array<string | null> = [];
|
|
list.onDeleteConfirmationChange = (path) => confirmationChanges.push(path);
|
|
|
|
let deletedPath: string | null = null;
|
|
list.onDeleteSession = async (sessionPath) => {
|
|
deletedPath = sessionPath;
|
|
};
|
|
|
|
list.handleInput(CTRL_BACKSPACE);
|
|
expect(confirmationChanges).toEqual([sessions[0]!.path]);
|
|
|
|
list.handleInput("\r");
|
|
expect(confirmationChanges).toEqual([sessions[0]!.path, null]);
|
|
expect(deletedPath).toBe(sessions[0]!.path);
|
|
});
|
|
|
|
it("does not switch scope back to All when All load resolves after toggling back to Current", async () => {
|
|
const currentSessions = [makeSession({ id: "current" })];
|
|
const allDeferred = createDeferred<SessionInfo[]>();
|
|
let allLoadCalls = 0;
|
|
|
|
const selector = new SessionSelectorComponent(
|
|
async () => currentSessions,
|
|
async () => {
|
|
allLoadCalls++;
|
|
return allDeferred.promise;
|
|
},
|
|
() => {},
|
|
() => {},
|
|
() => {},
|
|
() => {},
|
|
{ keybindings },
|
|
);
|
|
await flushPromises();
|
|
|
|
const list = selector.getSessionList();
|
|
list.handleInput("\t"); // current -> all (starts async load)
|
|
list.handleInput("\t"); // all -> current
|
|
|
|
allDeferred.resolve([makeSession({ id: "all" })]);
|
|
await flushPromises();
|
|
|
|
expect(allLoadCalls).toBe(1);
|
|
const output = selector.render(120).join("\n");
|
|
expect(output).toContain("Resume Session (Current Folder)");
|
|
expect(output).not.toContain("Resume Session (All)");
|
|
});
|
|
|
|
it("does not start redundant All loads when toggling scopes while All is already loading", async () => {
|
|
const currentSessions = [makeSession({ id: "current" })];
|
|
const allDeferred = createDeferred<SessionInfo[]>();
|
|
let allLoadCalls = 0;
|
|
|
|
const selector = new SessionSelectorComponent(
|
|
async () => currentSessions,
|
|
async () => {
|
|
allLoadCalls++;
|
|
return allDeferred.promise;
|
|
},
|
|
() => {},
|
|
() => {},
|
|
() => {},
|
|
() => {},
|
|
{ keybindings },
|
|
);
|
|
await flushPromises();
|
|
|
|
const list = selector.getSessionList();
|
|
list.handleInput("\t"); // current -> all (starts async load)
|
|
list.handleInput("\t"); // all -> current
|
|
list.handleInput("\t"); // current -> all again while load pending
|
|
|
|
expect(allLoadCalls).toBe(1);
|
|
|
|
allDeferred.resolve([makeSession({ id: "all" })]);
|
|
await flushPromises();
|
|
});
|
|
});
|