diff --git a/.ai-work/01-fix-lint-and-ci.md b/.ai-work/01-fix-lint-and-ci.md index 553f542..0a22573 100644 --- a/.ai-work/01-fix-lint-and-ci.md +++ b/.ai-work/01-fix-lint-and-ci.md @@ -1,6 +1,6 @@ # 01. Fix lint and add CI scripts -Status: pending +Status: completed Make the hygiene baseline green and easy to run locally/CI: diff --git a/package.json b/package.json index 4bb0ef1..eaa8ddf 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "build": "tsc && vite build", "typecheck": "tsc --noEmit", "lint": "eslint \"src/**/*.ts\" vite.config.ts", + "verify": "npm run typecheck && npm run lint", "start": "tsx src/server/index.ts", "start:sessiond": "tsx src/server/sessiond.ts" }, diff --git a/src/client/src/api.ts b/src/client/src/api.ts index eb13dda..77e5e44 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -356,7 +356,9 @@ function parseFileTreeEntry(value: unknown): FileTreeEntry { function parseFileContentResponse(value: unknown): FileContentResponse { const record = requireRecord(value); - return { path: requireString(record, "path"), ...optionalField("language", optionalString(record, "language")), encoding: requireString(record, "encoding") as "utf8", size: requireNumber(record, "size"), modifiedAt: requireString(record, "modifiedAt"), content: requireString(record, "content"), truncated: requireBoolean(record, "truncated"), binary: requireBoolean(record, "binary") }; + const encoding = requireString(record, "encoding"); + if (encoding !== "utf8") throw new Error("Invalid file encoding"); + return { path: requireString(record, "path"), ...optionalField("language", optionalString(record, "language")), encoding, size: requireNumber(record, "size"), modifiedAt: requireString(record, "modifiedAt"), content: requireString(record, "content"), truncated: requireBoolean(record, "truncated"), binary: requireBoolean(record, "binary") }; } function parseGitStatusResponse(value: unknown): GitStatusResponse { @@ -370,9 +372,20 @@ function parseGitStatusFile(value: unknown): GitStatusFile { } function parseGitFileState(value: unknown): GitFileState { - if (typeof value !== "string") throw new Error("Expected git file state"); - if (!["unmodified", "modified", "added", "deleted", "renamed", "copied", "untracked", "ignored", "conflicted"].includes(value)) throw new Error("Invalid git file state"); - return value as GitFileState; + switch (value) { + case "unmodified": + case "modified": + case "added": + case "deleted": + case "renamed": + case "copied": + case "untracked": + case "ignored": + case "conflicted": + return value; + default: + throw new Error("Invalid git file state"); + } } function parseGitDiffResponse(value: unknown): GitDiffResponse { diff --git a/src/client/src/chatHistoryCache.ts b/src/client/src/chatHistoryCache.ts index 6868fe3..78bd196 100644 --- a/src/client/src/chatHistoryCache.ts +++ b/src/client/src/chatHistoryCache.ts @@ -36,7 +36,7 @@ export function writeChatHistoryCache(sessionId: string, page: RawMessagePage): } export function mergeChatHistory(existing: RawMessagePage | undefined, incoming: RawMessagePage): RawMessagePage { - if (!existing || existing.total !== incoming.total) return incoming; + if (existing?.total !== incoming.total) return incoming; const start = Math.min(existing.start, incoming.start); const end = Math.max(existing.start + existing.messages.length, incoming.start + incoming.messages.length); diff --git a/src/client/src/chatMessages.ts b/src/client/src/chatMessages.ts index 990a500..88e86f9 100644 --- a/src/client/src/chatMessages.ts +++ b/src/client/src/chatMessages.ts @@ -26,7 +26,7 @@ export function normalizeMessage(message: unknown): ChatLine[] { const parts = normalizeContent(getProperty(message, "content"), message); const skillLines = role === "user" ? normalizeSkillInvocation(parts) : undefined; if (skillLines !== undefined) return skillLines; - const source = normalizeSource(message, parts); + const source = normalizeSource(message); if (role === "tool") return [{ role, parts, ...(source === undefined ? {} : { source }) }]; const visible = parts.filter((part) => part.type !== "empty"); @@ -44,7 +44,7 @@ function normalizeSkillInvocation(parts: ChatPart[]): ChatLine[] | undefined { } function parseSkillBlock(text: string): { name: string; location: string; content: string; userMessage?: string } | undefined { - const match = text.match(/^\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/); + const match = /^\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/.exec(text); if (match === null) return undefined; const userMessage = match[4]?.trim(); return { @@ -55,7 +55,7 @@ function parseSkillBlock(text: string): { name: string; location: string; conten }; } -function normalizeSource(message: unknown, _parts: ChatPart[]): ChatLine["source"] | undefined { +function normalizeSource(message: unknown): ChatLine["source"] | undefined { const source = getString(message, "source"); if (source === "compaction" || source === "branch_summary") return source; return undefined; diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index edd56b8..6e41ecf 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -86,11 +86,11 @@ export class ChatView extends LitElement { const olderCount = this.messageStart; const fullHistory = olderCount <= 0 ? "full history loaded" - : `${olderCount} older not loaded · ${loadedPercent}% loaded`; + : `${String(olderCount)} older not loaded · ${String(loadedPercent)}% loaded`; return html`
${fullHistory}
-
loaded scroll: ${this.loadedScrollPercent}% from top
+
loaded scroll: ${String(this.loadedScrollPercent)}% from top
`; } diff --git a/src/client/src/components/CodeViewer.ts b/src/client/src/components/CodeViewer.ts index 8d9e133..97280a4 100644 --- a/src/client/src/components/CodeViewer.ts +++ b/src/client/src/components/CodeViewer.ts @@ -97,6 +97,7 @@ const viewerTheme = EditorView.theme({ }); function languageExtensions(language: string | undefined): Extension[] { + if (language === undefined) return []; switch (language) { case "typescript": return [javascript({ typescript: true })]; case "javascript": return [javascript()]; diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 83bb635..c1cbd46 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -149,9 +149,7 @@ export class PiWebApp extends LitElement { const workspace = this.state.selectedWorkspace; if (!project || !workspace) return; if (this.state.expandedDirs[path] !== undefined) { - const next = { ...this.state.expandedDirs }; - delete next[path]; - this.setState({ expandedDirs: next }); + this.setState({ expandedDirs: omitKey(this.state.expandedDirs, path) }); return; } try { @@ -231,7 +229,7 @@ export class PiWebApp extends LitElement { } private renderWorkspacePanel() { - return html` this.selectWorkspaceTool(tool)} .onRefreshFiles=${() => this.refreshFiles()} .onExpandDir=${(path: string) => this.expandDir(path)} .onSelectFile=${(path: string) => this.selectFile(path)} .onRefreshGit=${() => this.refreshGit()} .onSelectDiff=${(path: string) => this.selectDiff(path)}>`; + return html` { this.selectWorkspaceTool(tool); }} .onRefreshFiles=${() => this.refreshFiles()} .onExpandDir=${(path: string) => this.expandDir(path)} .onSelectFile=${(path: string) => this.selectFile(path)} .onRefreshGit=${() => this.refreshGit()} .onSelectDiff=${(path: string) => this.selectDiff(path)}>`; } override render() { @@ -249,9 +247,9 @@ export class PiWebApp extends LitElement {
- - - + + +
${state.error ? html`
${state.error}
` : null} ${state.selectedSession ? html` @@ -274,6 +272,10 @@ function isActive(status: AppState["status"]): boolean { return status?.isStreaming === true || status?.isBashRunning === true || status?.isCompacting === true; } +function omitKey(record: Record, keyToOmit: string): Record { + return Object.fromEntries(Object.entries(record).filter(([key]) => key !== keyToOmit)); +} + function nextFrame(): Promise { return new Promise((resolve) => requestAnimationFrame(() => { resolve(); })); } diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index 86dbd13..69502b5 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -208,7 +208,7 @@ export class PromptEditor extends LitElement { function fileInsertText(path: string, pathMode: boolean, quoted: boolean): string { const prefix = pathMode ? "" : "@"; if (!quoted && !path.includes(" ")) return `${prefix}${path}`; - return `${prefix}\"${path}\"`; + return `${prefix}"${path}"`; } function emptySlashCommands(): SlashCommand[] { diff --git a/src/client/src/components/WorkspacePanel.ts b/src/client/src/components/WorkspacePanel.ts index 3d9f6e3..5336e92 100644 --- a/src/client/src/components/WorkspacePanel.ts +++ b/src/client/src/components/WorkspacePanel.ts @@ -29,8 +29,8 @@ export class WorkspacePanel extends LitElement { return html`
- - + +
${this.workspace.label}
@@ -58,19 +58,25 @@ export class WorkspacePanel extends LitElement { private renderTreeEntry(entry: FileTreeEntry, depth: number): TemplateResult { const children = this.expandedDirs[entry.path]; + const hasChildren = children !== undefined; return html` - - ${children ? children.map((child) => this.renderTreeEntry(child, depth + 1)) : null} + ${hasChildren ? children.map((child) => this.renderTreeEntry(child, depth + 1)) : null} `; } + private selectTreeEntry(entry: FileTreeEntry): void { + if (entry.type === "directory") this.onExpandDir(entry.path); + else this.onSelectFile(entry.path); + } + private renderFileViewer() { const file = this.selectedFileContent; - if (!this.selectedFilePath) return html`

Select a file.

`; - if (!file) return html`

Loading ${this.selectedFilePath}…

`; + if (this.selectedFilePath === undefined || this.selectedFilePath === "") return html`

Select a file.

`; + if (file === undefined) return html`

Loading ${this.selectedFilePath}…

`; if (file.binary) return html`

Binary file: ${file.path}

`; return html`
${file.path}${file.language ?? "text"}${file.truncated ? " · truncated" : ""}
@@ -88,10 +94,10 @@ export class WorkspacePanel extends LitElement {
- ${status === undefined ? html`

No status loaded.

` : status.isGitRepo === false ? html`

Not a git repository.

` : html` -

${status.branch ?? "detached"}${status.ahead || status.behind ? ` · ↑${status.ahead ?? 0} ↓${status.behind ?? 0}` : ""}

+ ${status === undefined ? html`

No status loaded.

` : !status.isGitRepo ? html`

Not a git repository.

` : html` +

${this.gitSummary(status)}

${status.files.length === 0 ? html`

No changes.

` : status.files.map((file) => html` - @@ -106,15 +112,22 @@ export class WorkspacePanel extends LitElement { } private renderDiffViewer() { - if (!this.selectedDiffPath) return html`

Select a changed file.

`; + if (this.selectedDiffPath === undefined || this.selectedDiffPath === "") return html`

Select a changed file.

`; const diff = this.selectedDiff; - if (!diff) return html`

Loading diff…

`; + if (diff === undefined) return html`

Loading diff…

`; return html`
${diff.path ?? "diff"}${diff.staged ? "staged" : "unstaged"}${diff.truncated ? " · truncated" : ""}
- + `; } + private gitSummary(status: GitStatusResponse): string { + const branch = status.branch ?? "detached"; + const ahead = status.ahead ?? 0; + const behind = status.behind ?? 0; + return ahead === 0 && behind === 0 ? branch : `${branch} · ↑${String(ahead)} ↓${String(behind)}`; + } + static override styles = workspacePanelStyles; } diff --git a/src/server/git/gitService.ts b/src/server/git/gitService.ts index 61bcbb1..734c119 100644 --- a/src/server/git/gitService.ts +++ b/src/server/git/gitService.ts @@ -88,6 +88,7 @@ function parseStatus(raw: string): GitStatusResponse { } function stateFor(code: string | undefined): GitFileState { + if (code === undefined) return "unmodified"; switch (code) { case ".": return "unmodified"; case "M": return "modified"; diff --git a/src/server/workspaces/fileContentService.ts b/src/server/workspaces/fileContentService.ts index 4208c47..983c696 100644 --- a/src/server/workspaces/fileContentService.ts +++ b/src/server/workspaces/fileContentService.ts @@ -41,7 +41,7 @@ function isProbablyBinary(buffer: Buffer): boolean { function languageForPath(path: string): { language?: string } { const ext = path.split(".").pop()?.toLowerCase(); - const language = ext === undefined ? undefined : ({ + const languages: Record = { ts: "typescript", tsx: "typescript", js: "javascript", @@ -56,6 +56,7 @@ function languageForPath(path: string): { language?: string } { sh: "shell", yml: "yaml", yaml: "yaml", - } as Record)[ext]; + }; + const language = ext === undefined ? undefined : languages[ext]; return language === undefined ? {} : { language }; }