generated from nhcarrigan/template
e6e9f7ae59
## Summary A large productivity-focused feature branch delivering a suite of improvements across automation, project management, theming, performance, and documentation. ### Features - **Guided Project Workflow** (#189) — Four-phase workflow panel (Discuss → Plan → Execute → Verify) to keep projects structured from idea to completion - **Automated Task Loop** (#179) — Per-task conversation orchestration with wave-based parallel execution, blocked-task detection, and concurrency control - **Wave-Based Parallel Execution** (#191) — Tasks run in dependency-aware waves with configurable concurrency; independent tasks execute in parallel - **Auto-Commit After Task Completion** (#192) — Task Loop optionally commits after each completed task so progress is never lost - **PRD Creator** (#180) — AI-assisted PRD and task list panel that outputs `hikari-tasks.json` for the Task Loop to consume - **Project Context Panel** (#188) — Persistent `PROJECT.md`, `REQUIREMENTS.md`, `ROADMAP.md`, and `STATE.md` files injected into Claude's context automatically - **Codebase Mapper** (#190) — Generates a `CODEBASE.md` architectural summary so Claude always understands the project structure - **Community Preset Themes** (#181) — Six built-in community themes: Dracula, Catppuccin Mocha, Nord, Solarized Dark, Gruvbox Dark, and Rosé Pine - **In-App Changelog Panel** (#193) — Fetches release notes from GitHub at runtime and displays them inside the app - **Full Embedded Documentation** (#196) — Replaced the single-page help modal with a 12-page paginated docs browser featuring a sidebar TOC, prev/next navigation, keyboard navigation (arrow keys, `?` shortcut), and comprehensive coverage of every feature ### Performance & Fixes - **Lazy Loading & Virtualisation** (#194) — Virtual windowing for conversation history, markdown memoisation, and debounced search for smooth rendering of large sessions - **Ctrl+C Copy Fix** (#195) — `Ctrl+C` now copies selected text as expected; interrupt-Claude behaviour only fires when no text is selected ### UX - Back-to-workflow button in PRD Creator and Task Loop panels for easy navigation - Navigation icon cluster replaced with a single clean dropdown menu ## Closes Closes #179 Closes #180 Closes #181 Closes #188 Closes #189 Closes #190 Closes #191 Closes #192 Closes #193 Closes #194 Closes #195 Closes #196 --- ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #197 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
369 lines
11 KiB
TypeScript
369 lines
11 KiB
TypeScript
/**
|
|
* Terminal Component Tests
|
|
*
|
|
* Tests the pure helper functions extracted from the Terminal component:
|
|
* - getLineClass: maps line types to CSS class names
|
|
* - getLinePrefix: maps line types to display prefixes
|
|
* - formatTime: formats a Date as "HH:MM AM/PM"
|
|
* - isToolContentLong: checks if tool content exceeds collapse threshold
|
|
* - truncateToolContent: truncates long tool content with ellipsis
|
|
*
|
|
* Manual testing checklist:
|
|
* - [ ] rate-limit lines appear in amber
|
|
* - [ ] error lines appear in red
|
|
* - [ ] tool lines appear in purple
|
|
* - [ ] system lines appear in grey italic
|
|
* - [ ] user lines appear in cyan
|
|
* - [ ] assistant lines appear in primary text colour
|
|
* - [ ] long tool content is collapsed by default with a toggle button
|
|
*/
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
|
|
// Mirror functions from Terminal.svelte for isolated testing
|
|
|
|
function getLineClass(type: string): string {
|
|
switch (type) {
|
|
case "user":
|
|
return "terminal-user";
|
|
case "assistant":
|
|
return "terminal-assistant";
|
|
case "system":
|
|
return "terminal-system italic";
|
|
case "tool":
|
|
return "terminal-tool";
|
|
case "error":
|
|
return "terminal-error";
|
|
case "thinking":
|
|
return "terminal-thinking";
|
|
case "rate-limit":
|
|
return "terminal-rate-limit";
|
|
case "compact-prompt":
|
|
return "terminal-compact-prompt";
|
|
case "worktree":
|
|
return "terminal-worktree";
|
|
case "config-change":
|
|
return "terminal-config-change";
|
|
default:
|
|
return "terminal-default";
|
|
}
|
|
}
|
|
|
|
function getLinePrefix(type: string): string {
|
|
switch (type) {
|
|
case "user":
|
|
return ">";
|
|
case "assistant":
|
|
return "";
|
|
case "system":
|
|
return "[system]";
|
|
case "tool":
|
|
return "[tool]";
|
|
case "error":
|
|
return "[error]";
|
|
case "rate-limit":
|
|
return "[rate-limit]";
|
|
case "worktree":
|
|
return "[worktree]";
|
|
case "config-change":
|
|
return "[config]";
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function formatTime(date: Date): string {
|
|
return date.toLocaleTimeString("en-US", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
}
|
|
|
|
const TOOL_COLLAPSE_THRESHOLD = 60;
|
|
|
|
function isToolContentLong(content: string): boolean {
|
|
return content.length > TOOL_COLLAPSE_THRESHOLD;
|
|
}
|
|
|
|
function truncateToolContent(content: string): string {
|
|
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
|
|
}
|
|
|
|
// Virtual windowing helpers — mirror the logic in Terminal.svelte
|
|
|
|
const WINDOW_SIZE = 150;
|
|
const LOAD_CHUNK = 50;
|
|
const AVG_LINE_HEIGHT = 60;
|
|
|
|
/** Returns the windowStart index when auto-scrolling to the bottom. */
|
|
function autoScrollWindowStart(linesLength: number, windowSize: number): number {
|
|
return Math.max(0, linesLength - windowSize);
|
|
}
|
|
|
|
/** Returns the new windowStart after loading LOAD_CHUNK older messages. */
|
|
function olderWindowStart(currentStart: number, chunkSize: number): number {
|
|
return Math.max(0, currentStart - chunkSize);
|
|
}
|
|
|
|
/** Returns the height in pixels of the invisible top spacer. */
|
|
function topSpacerHeight(windowStart: number, avgLineHeight: number): number {
|
|
return windowStart * avgLineHeight;
|
|
}
|
|
|
|
// ---
|
|
|
|
describe("getLineClass", () => {
|
|
it("returns terminal-user for user lines", () => {
|
|
expect(getLineClass("user")).toBe("terminal-user");
|
|
});
|
|
|
|
it("returns terminal-assistant for assistant lines", () => {
|
|
expect(getLineClass("assistant")).toBe("terminal-assistant");
|
|
});
|
|
|
|
it("returns terminal-system italic for system lines", () => {
|
|
expect(getLineClass("system")).toBe("terminal-system italic");
|
|
});
|
|
|
|
it("returns terminal-tool for tool lines", () => {
|
|
expect(getLineClass("tool")).toBe("terminal-tool");
|
|
});
|
|
|
|
it("returns terminal-error for error lines", () => {
|
|
expect(getLineClass("error")).toBe("terminal-error");
|
|
});
|
|
|
|
it("returns terminal-thinking for thinking lines", () => {
|
|
expect(getLineClass("thinking")).toBe("terminal-thinking");
|
|
});
|
|
|
|
it("returns terminal-rate-limit for rate-limit lines", () => {
|
|
expect(getLineClass("rate-limit")).toBe("terminal-rate-limit");
|
|
});
|
|
|
|
it("returns terminal-compact-prompt for compact-prompt lines", () => {
|
|
expect(getLineClass("compact-prompt")).toBe("terminal-compact-prompt");
|
|
});
|
|
|
|
it("returns terminal-worktree for worktree lines", () => {
|
|
expect(getLineClass("worktree")).toBe("terminal-worktree");
|
|
});
|
|
|
|
it("returns terminal-config-change for config-change lines", () => {
|
|
expect(getLineClass("config-change")).toBe("terminal-config-change");
|
|
});
|
|
|
|
it("returns terminal-default for unknown line types", () => {
|
|
expect(getLineClass("unknown")).toBe("terminal-default");
|
|
expect(getLineClass("")).toBe("terminal-default");
|
|
expect(getLineClass("random-future-type")).toBe("terminal-default");
|
|
});
|
|
});
|
|
|
|
describe("getLinePrefix", () => {
|
|
it("returns > for user lines", () => {
|
|
expect(getLinePrefix("user")).toBe(">");
|
|
});
|
|
|
|
it("returns empty string for assistant lines", () => {
|
|
expect(getLinePrefix("assistant")).toBe("");
|
|
});
|
|
|
|
it("returns [system] for system lines", () => {
|
|
expect(getLinePrefix("system")).toBe("[system]");
|
|
});
|
|
|
|
it("returns [tool] for tool lines", () => {
|
|
expect(getLinePrefix("tool")).toBe("[tool]");
|
|
});
|
|
|
|
it("returns [error] for error lines", () => {
|
|
expect(getLinePrefix("error")).toBe("[error]");
|
|
});
|
|
|
|
it("returns [rate-limit] for rate-limit lines", () => {
|
|
expect(getLinePrefix("rate-limit")).toBe("[rate-limit]");
|
|
});
|
|
|
|
it("returns empty string for compact-prompt lines (button renders instead)", () => {
|
|
expect(getLinePrefix("compact-prompt")).toBe("");
|
|
});
|
|
|
|
it("returns [worktree] for worktree lines", () => {
|
|
expect(getLinePrefix("worktree")).toBe("[worktree]");
|
|
});
|
|
|
|
it("returns [config] for config-change lines", () => {
|
|
expect(getLinePrefix("config-change")).toBe("[config]");
|
|
});
|
|
|
|
it("returns empty string for thinking lines (no prefix)", () => {
|
|
expect(getLinePrefix("thinking")).toBe("");
|
|
});
|
|
|
|
it("returns empty string for unknown line types", () => {
|
|
expect(getLinePrefix("unknown")).toBe("");
|
|
expect(getLinePrefix("")).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("formatTime", () => {
|
|
it("formats time in 12-hour format with AM/PM", () => {
|
|
const date = new Date(2026, 1, 7, 14, 35);
|
|
const formatted = formatTime(date);
|
|
expect(formatted).toMatch(/\d{2}:\d{2}\s?(AM|PM)/i);
|
|
});
|
|
|
|
it("formats afternoon times correctly", () => {
|
|
const date = new Date(2026, 1, 7, 14, 35);
|
|
const formatted = formatTime(date);
|
|
expect(formatted).toContain("02:35");
|
|
expect(formatted.toUpperCase()).toContain("PM");
|
|
});
|
|
|
|
it("formats morning times correctly", () => {
|
|
const date = new Date(2026, 1, 7, 9, 5);
|
|
const formatted = formatTime(date);
|
|
expect(formatted).toContain("09:05");
|
|
expect(formatted.toUpperCase()).toContain("AM");
|
|
});
|
|
|
|
it("formats midnight correctly", () => {
|
|
const date = new Date(2026, 1, 7, 0, 0);
|
|
const formatted = formatTime(date);
|
|
expect(formatted).toContain("12:00");
|
|
expect(formatted.toUpperCase()).toContain("AM");
|
|
});
|
|
|
|
it("formats noon correctly", () => {
|
|
const date = new Date(2026, 1, 7, 12, 0);
|
|
const formatted = formatTime(date);
|
|
expect(formatted).toContain("12:00");
|
|
expect(formatted.toUpperCase()).toContain("PM");
|
|
});
|
|
});
|
|
|
|
describe("isToolContentLong", () => {
|
|
it("returns false for content at or below the threshold", () => {
|
|
const exactThreshold = "x".repeat(TOOL_COLLAPSE_THRESHOLD);
|
|
expect(isToolContentLong(exactThreshold)).toBe(false);
|
|
});
|
|
|
|
it("returns true for content exceeding the threshold", () => {
|
|
const overThreshold = "x".repeat(TOOL_COLLAPSE_THRESHOLD + 1);
|
|
expect(isToolContentLong(overThreshold)).toBe(true);
|
|
});
|
|
|
|
it("returns false for short content", () => {
|
|
expect(isToolContentLong("short")).toBe(false);
|
|
});
|
|
|
|
it("returns false for empty content", () => {
|
|
expect(isToolContentLong("")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("truncateToolContent", () => {
|
|
it("truncates content to the threshold length with an ellipsis", () => {
|
|
const long = "x".repeat(100);
|
|
const result = truncateToolContent(long);
|
|
expect(result).toBe("x".repeat(TOOL_COLLAPSE_THRESHOLD) + "…");
|
|
});
|
|
|
|
it("keeps content shorter than threshold unchanged (plus ellipsis)", () => {
|
|
const short = "hello";
|
|
const result = truncateToolContent(short);
|
|
expect(result).toBe("hello…");
|
|
});
|
|
|
|
it("uses the unicode ellipsis character (not three dots)", () => {
|
|
const long = "x".repeat(100);
|
|
const result = truncateToolContent(long);
|
|
expect(result.endsWith("…")).toBe(true);
|
|
expect(result.endsWith("...")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("autoScrollWindowStart", () => {
|
|
it("returns 0 when lines fit within the window", () => {
|
|
expect(autoScrollWindowStart(50, WINDOW_SIZE)).toBe(0);
|
|
});
|
|
|
|
it("returns 0 when lines exactly fill the window", () => {
|
|
expect(autoScrollWindowStart(WINDOW_SIZE, WINDOW_SIZE)).toBe(0);
|
|
});
|
|
|
|
it("advances when lines exceed the window size", () => {
|
|
expect(autoScrollWindowStart(200, WINDOW_SIZE)).toBe(50);
|
|
});
|
|
|
|
it("never returns a negative value", () => {
|
|
expect(autoScrollWindowStart(0, WINDOW_SIZE)).toBe(0);
|
|
});
|
|
|
|
it("keeps last WINDOW_SIZE lines visible for large collections", () => {
|
|
expect(autoScrollWindowStart(500, WINDOW_SIZE)).toBe(350);
|
|
});
|
|
});
|
|
|
|
describe("olderWindowStart", () => {
|
|
it("subtracts the chunk size from the current start", () => {
|
|
expect(olderWindowStart(100, LOAD_CHUNK)).toBe(50);
|
|
});
|
|
|
|
it("never returns a negative value when chunk is larger than start", () => {
|
|
expect(olderWindowStart(20, LOAD_CHUNK)).toBe(0);
|
|
});
|
|
|
|
it("returns 0 when current start is 0", () => {
|
|
expect(olderWindowStart(0, LOAD_CHUNK)).toBe(0);
|
|
});
|
|
|
|
it("returns 0 when current start exactly equals the chunk size", () => {
|
|
expect(olderWindowStart(LOAD_CHUNK, LOAD_CHUNK)).toBe(0);
|
|
});
|
|
|
|
it("correctly loads a partial chunk near the beginning", () => {
|
|
expect(olderWindowStart(30, LOAD_CHUNK)).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("topSpacerHeight", () => {
|
|
it("returns 0 when windowStart is 0", () => {
|
|
expect(topSpacerHeight(0, AVG_LINE_HEIGHT)).toBe(0);
|
|
});
|
|
|
|
it("multiplies windowStart by avgLineHeight", () => {
|
|
expect(topSpacerHeight(10, AVG_LINE_HEIGHT)).toBe(600);
|
|
});
|
|
|
|
it("scales linearly with windowStart", () => {
|
|
expect(topSpacerHeight(50, AVG_LINE_HEIGHT)).toBe(3000);
|
|
expect(topSpacerHeight(100, AVG_LINE_HEIGHT)).toBe(6000);
|
|
expect(topSpacerHeight(150, AVG_LINE_HEIGHT)).toBe(9000);
|
|
});
|
|
|
|
it("uses the provided avgLineHeight rather than a hard-coded value", () => {
|
|
expect(topSpacerHeight(5, 100)).toBe(500);
|
|
expect(topSpacerHeight(5, 80)).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe("virtual windowing constants", () => {
|
|
it("WINDOW_SIZE is 150", () => {
|
|
expect(WINDOW_SIZE).toBe(150);
|
|
});
|
|
|
|
it("LOAD_CHUNK is 50", () => {
|
|
expect(LOAD_CHUNK).toBe(50);
|
|
});
|
|
|
|
it("LOAD_CHUNK is smaller than WINDOW_SIZE", () => {
|
|
expect(LOAD_CHUNK).toBeLessThan(WINDOW_SIZE);
|
|
});
|
|
|
|
it("AVG_LINE_HEIGHT is a positive number", () => {
|
|
expect(AVG_LINE_HEIGHT).toBeGreaterThan(0);
|
|
});
|
|
});
|