diff --git a/src/cli.test.ts b/src/cli.test.ts new file mode 100644 index 0000000..b88fa6e --- /dev/null +++ b/src/cli.test.ts @@ -0,0 +1,136 @@ +import { expect, test } from "bun:test"; + +// Export the parsing function from cli.ts for testing +function tryParseHookEvent( + line: string, +): { type: string; [key: string]: unknown } | null { + // Look for lines that are valid JSON objects + const trimmed = line.trim(); + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { + return null; + } + + try { + const parsed = JSON.parse(trimmed); + // Check if it has a type field + if (typeof parsed === "object" && parsed !== null && "type" in parsed) { + return parsed as { type: string; [key: string]: unknown }; + } + return null; + } catch { + return null; + } +} + +test("tryParseHookEvent - parses valid state event", () => { + const line = '{"type":"state","state":"thinking","timestamp":1706472000000}'; + const result = tryParseHookEvent(line); + + expect(result).not.toBeNull(); + expect(result?.type).toBe("state"); + expect(result?.state).toBe("thinking"); + expect(result?.timestamp).toBe(1706472000000); +}); + +test("tryParseHookEvent - parses valid stats event", () => { + const line = + '{"type":"stats","prompts":5,"completions":3,"tools":12,"compressions":1,"thinking_seconds":45,"work_seconds":180,"mode":"normal","model":"claude-opus-4-5-20251101","prompts_changed_at":1706472000.0,"completions_changed_at":1706471980.0,"tool_timestamps":[1706471900.0,1706471920.0],"session_start":1706471800.0,"idle_since":1706472100.0}'; + const result = tryParseHookEvent(line); + + expect(result).not.toBeNull(); + expect(result?.type).toBe("stats"); + expect(result?.prompts).toBe(5); + expect(result?.completions).toBe(3); + expect(result?.tools).toBe(12); + expect(result?.mode).toBe("normal"); + expect(result?.model).toBe("claude-opus-4-5-20251101"); +}); + +test("tryParseHookEvent - handles whitespace around JSON", () => { + const line = + ' {"type":"state","state":"ready","timestamp":1706472000000} \n'; + const result = tryParseHookEvent(line); + + expect(result).not.toBeNull(); + expect(result?.type).toBe("state"); + expect(result?.state).toBe("ready"); +}); + +test("tryParseHookEvent - returns null for non-JSON lines", () => { + const line = "This is just regular output text"; + const result = tryParseHookEvent(line); + + expect(result).toBeNull(); +}); + +test("tryParseHookEvent - returns null for partial JSON", () => { + const line = '{"type":"state","state":"thinking"'; + const result = tryParseHookEvent(line); + + expect(result).toBeNull(); +}); + +test("tryParseHookEvent - returns null for JSON without type field", () => { + const line = '{"something":"else","value":123}'; + const result = tryParseHookEvent(line); + + expect(result).toBeNull(); +}); + +test("tryParseHookEvent - returns null for JSON arrays", () => { + const line = '[{"type":"state"}]'; + const result = tryParseHookEvent(line); + + expect(result).toBeNull(); +}); + +test("tryParseHookEvent - handles invalid JSON gracefully", () => { + const line = '{type:"state",state:"thinking"}'; + const result = tryParseHookEvent(line); + + expect(result).toBeNull(); +}); + +test("tryParseHookEvent - parses state with different states", () => { + const states = [ + "ready", + "thinking", + "permission", + "question", + "complete", + "interrupted", + ]; + + for (const state of states) { + const line = `{"type":"state","state":"${state}","timestamp":1706472000000}`; + const result = tryParseHookEvent(line); + + expect(result).not.toBeNull(); + expect(result?.type).toBe("state"); + expect(result?.state).toBe(state); + } +}); + +test("tryParseHookEvent - parses stats with different modes", () => { + const modes = ["normal", "auto_accept", "plan"]; + + for (const mode of modes) { + const line = `{"type":"stats","prompts":1,"completions":1,"tools":1,"compressions":0,"thinking_seconds":10,"work_seconds":20,"mode":"${mode}","model":"claude-opus-4-5","prompts_changed_at":1706472000.0,"completions_changed_at":1706471980.0,"tool_timestamps":[],"session_start":1706471800.0,"idle_since":null}`; + const result = tryParseHookEvent(line); + + expect(result).not.toBeNull(); + expect(result?.type).toBe("stats"); + expect(result?.mode).toBe(mode); + } +}); + +test("tryParseHookEvent - preserves null values in stats", () => { + const line = + '{"type":"stats","prompts":1,"completions":1,"tools":1,"compressions":0,"thinking_seconds":10,"work_seconds":20,"mode":"normal","model":null,"prompts_changed_at":1706472000.0,"completions_changed_at":1706471980.0,"tool_timestamps":[],"session_start":1706471800.0,"idle_since":null}'; + const result = tryParseHookEvent(line); + + expect(result).not.toBeNull(); + expect(result?.type).toBe("stats"); + expect(result?.model).toBeNull(); + expect(result?.idle_since).toBeNull(); +}); diff --git a/src/cli.ts b/src/cli.ts index 3b403e0..ac85c05 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -53,6 +53,27 @@ function getTerminalSize(): { cols: number; rows: number } { return { cols: 80, rows: 24 }; } +function tryParseHookEvent( + line: string, +): { type: string; [key: string]: unknown } | null { + // Look for lines that are valid JSON objects + const trimmed = line.trim(); + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { + return null; + } + + try { + const parsed = JSON.parse(trimmed); + // Check if it has a type field + if (typeof parsed === "object" && parsed !== null && "type" in parsed) { + return parsed as { type: string; [key: string]: unknown }; + } + return null; + } catch { + return null; + } +} + async function main() { const args = parseArgs(); const { cols, rows } = getTerminalSize(); @@ -63,6 +84,7 @@ async function main() { let isAuthenticated = false; let reconnectTimer: Timer | null = null; const disposables: Array<{ dispose: () => void }> = []; + let lineBuffer = ""; const cleanup = () => { if (isExiting) return; @@ -116,8 +138,55 @@ async function main() { process.stdout.write(data); if (ws && ws.readyState === WebSocket.OPEN && isAuthenticated) { - const msg: ClientMessage = { type: "output", data }; - ws.send(JSON.stringify(msg)); + // Send output to server + const outputMsg: ClientMessage = { type: "output", data }; + ws.send(JSON.stringify(outputMsg)); + + // Buffer data and process complete lines for hook events + lineBuffer += data; + const lines = lineBuffer.split("\n"); + // Keep the last incomplete line in the buffer + lineBuffer = lines.pop() || ""; + + // Process each complete line for hook events + for (const line of lines) { + const event = tryParseHookEvent(line); + if (event && (event.type === "state" || event.type === "stats")) { + // Send state or stats event to server + if (event.type === "state") { + const stateMsg: ClientMessage = { + type: "state", + state: event.state as + | "ready" + | "thinking" + | "permission" + | "question" + | "complete" + | "interrupted", + timestamp: event.timestamp as number, + }; + ws.send(JSON.stringify(stateMsg)); + } else if (event.type === "stats") { + const statsMsg: ClientMessage = { + type: "stats", + prompts: event.prompts as number, + completions: event.completions as number, + tools: event.tools as number, + compressions: event.compressions as number, + thinking_seconds: event.thinking_seconds as number, + work_seconds: event.work_seconds as number, + mode: event.mode as "normal" | "auto_accept" | "plan", + model: (event.model as string) || null, + prompts_changed_at: event.prompts_changed_at as number, + completions_changed_at: event.completions_changed_at as number, + tool_timestamps: event.tool_timestamps as number[], + session_start: event.session_start as number, + idle_since: (event.idle_since as number) || null, + }; + ws.send(JSON.stringify(statsMsg)); + } + } + } } }), ); diff --git a/src/types.ts b/src/types.ts index eb3511d..226cce1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,6 +17,20 @@ export interface Session { ended_at: number | null; cwd: string | null; command: string | null; + // Phase 2.3: Session state and stats + state: string; + prompts: number; + completions: number; + tools: number; + compressions: number; + thinking_seconds: number; + work_seconds: number; + mode: string; + model: string | null; + idle_since: number | null; + // Git state (for later phase) + git_branch: string | null; + git_files_json: string | null; } export interface Prompt { @@ -89,7 +103,34 @@ export type ClientMessage = | { type: "output"; data: string } | { type: "resize"; cols: number; rows: number } | { type: "exit"; code: number } - | { type: "prompt"; session_id: number; prompt_text: string }; + | { type: "prompt"; session_id: number; prompt_text: string } + | { + type: "state"; + state: + | "ready" + | "thinking" + | "permission" + | "question" + | "complete" + | "interrupted"; + timestamp: number; + } + | { + type: "stats"; + prompts: number; + completions: number; + tools: number; + compressions: number; + thinking_seconds: number; + work_seconds: number; + mode: "normal" | "auto_accept" | "plan"; + model: string | null; + prompts_changed_at: number; + completions_changed_at: number; + tool_timestamps: number[]; + session_start: number; + idle_since: number | null; + }; export type ServerMessage = | { type: "authenticated"; session_id: number } @@ -119,4 +160,51 @@ export type SSEEvent = type: "prompt_response"; prompt_id: number; response: string; + } + | { + type: "state"; + session_id: number; + state: + | "ready" + | "thinking" + | "permission" + | "question" + | "complete" + | "interrupted"; + timestamp: number; + } + | { + type: "stats"; + session_id: number; + prompts: number; + completions: number; + tools: number; + compressions: number; + thinking_seconds: number; + work_seconds: number; + mode: "normal" | "auto_accept" | "plan"; + model: string | null; + idle_since: number | null; }; + +// Session state tracking (in-memory) + +export interface SessionState { + state: + | "ready" + | "thinking" + | "permission" + | "question" + | "complete" + | "interrupted"; + prompts: number; + completions: number; + tools: number; + compressions: number; + thinking_seconds: number; + work_seconds: number; + mode: "normal" | "auto_accept" | "plan"; + model: string | null; + idle_since: number | null; + dirty: boolean; +}