diff --git a/locode-upgrade-memo.md b/locode-upgrade-memo.md index faf247f..f44f9ef 100644 --- a/locode-upgrade-memo.md +++ b/locode-upgrade-memo.md @@ -6,631 +6,181 @@ - 핵심 철학: "신뢰할 수 없고 느리고 비전/툴콜 지원이 불확실한 로컬 모델"이라는 현실에 맞춰 모든 가정을 비관적으로 재단 - Claude Code 플러그인 포맷을 직접 소비하는 하위호환 브리지 (`.claude-plugin/plugin.json`, commands/agents/skills/hooks/MCP) -## 빌드/테스트 상태 -- `npm run build` (tsup) — 깨끗 +## 빌드/테스트 상태 (최신: c539995) - `npx tsc --noEmit` — 깨끗 -- `npm test` (vitest) — 29 파일 215개 전부 통과 -- 파일 인코딩: **CRLF** (edit_file 도구가 LF로 정규화해서 매칭 실패 → Python 스크립트로 바이너리 편집해야 함) +- `npm run build` (tsup) — 290.75 KB +- `npm test` (vitest) — **43 파일 347개 전부 통과** +- Node 타겟: **node22** (tsup.config.ts에서 변경) +- 파일 인코딩: CRLF/LF 섹션 혼재 (edit_file의 EOL 정규화 로직으로 처리) --- -## 발견한 업그레이드 후보 (12개) +## 아키텍처 개요 (현재) -| # | 항목 | 난이도 | 효과 | 로컬 특화 | 상태 | -|---|---|:---:|:---:|:---:|:---:| -| 1 | 병렬 툴 실행 (read-only) | 중 | 대 | ★ | **완료** ✅ | -| 2 | 재시도 정책 설정화 (`maxRetries`) | 하 | 중 | ★ | **완료** ✅ | -| 3 | 정확한 토큰 추정 (`/api/tokenize` 또는 BPE) | 중 | 대 | ★★ | **완료** ✅ | -| 4 | 스마트 출력 캡 (head+tail, 라인 길이) | 하 | 중 | ★ | **완료** ✅ | -| 5 | 부분 히스토리 보존 컴팩션 | **상** | 대 | ★ | **완료** ✅ | -| 6 | 동적 `max_tokens` | 하 | 대 | ★ | **완료** ✅ | -| 7 | 툴 설명 풍부화 + 동적 툴 선택 | 중 | 중 | ★ | **완료** ✅ | -| 8 | MCP 연결 재시도·재연결 | 중 | 중 | | **완료** ✅ | -| 9 | 컨텍스트 윈도우 캐시 TTL | 하 | 중 | ★ | **완료** ✅ | -| 10 | `edit_file` 유사 매치 제안 | 중 | 대 | ★★ | **완료** ✅ | -| 11 | git_status 출력 head+tail | 하 | 중 | | **완료** ✅ | -| 12 | `auto-accept` 모드 세분화 | 중 | 중 | | **완료** ✅ | +### 렌더링 모델: Normal Screen + `` +**대폭 변경**: 이전 alternate screen buffer + 인앱 가상 스크롤 + 마우스 트래킹 아키텍처를 완전히 폐기. + +- **Normal screen buffer**: `index.tsx`가 alternate screen에 진입하지 않음. 앱이 터미널의 일반 스크롤백에 직접 출력. +- **Ink ``**: 완료된 턴/이벤트를 `` 컴포넌트로 한 번만 렌더링 → 터미널 스크롤백의 영구 부분이 됨. 재렌더링 없음. +- **마우스 트래킹 완전 제거**: `mouseInput.ts` 파일 삭제. SGR-1006, `logicalButton()`, `copyToClipboard()`, `mouseToContentRow` 모두 제거. 터미널 자체 네이티브 스크롤/선택/복사에 의존. +- **이점**: 코드 대폭 감소, 터미널 호환성 향상, SSH/tmux에서도 기본 스크롤 작동, 인앱 마우스 버그 불가. + +### 핵심 파일 맵 +- `src/ui/ink/index.tsx` — 진입점. alternate screen 없이 Ink render. cleanup 시 flush + 종료. +- `src/ui/ink/App.tsx` (1107행) — 메인 UI 컴포넌트. `` + 라이브 영역. 상태: starting→connecting→loading-models→model-select/session-select→input. +- `src/ui/ink/ChatInput.tsx` — 커스텀 multiline 입력. Shift+Enter 줄바꿈, bracket paste, @멘션 fuzzy picker, IME 커서. +- `src/ui/ink/HistoryItemView.tsx` — memo() 래핑. thinking/streaming_text/assistant/tool_call 등 다양한 아이템 렌더. +- `src/ui/ink/FilePanel.tsx` — Ctrl+F 토글 사이드 패널 (Files/Activity 탭). +- `src/agent/loop.ts` (1284행) — 메인 에이전트 루프. 턴/스트리밍/툴콜/컴팩션/서브에이전트/병렬 툴 배치/반복 루프 감지. +- `src/agent/session.ts` — Session 객체, 통계, 상태, mutation gate. +- `src/agent/systemPrompt.ts` — 시스템 프롬프트 빌더 (로컬/클라우드 분기). +- `src/agent/events.ts` — AgentEvent 타입 (thinking_delta/thinking_done 추가). +- `src/tools/` — 14+ 내장 툴 (read_file, list_files, grep, definition, references, diagnostics, web_search, web_fetch, git_status, write_file, edit_file, multi_edit, notebook_edit, bash, bash_output, bash_kill, git_commit, todo_write, task_create/list/get/update, agent). +- `src/toolcalling/` — native 어댑터, fallback 파서/프롬프트, partialJson 복구, resolve (Ollama 빈키 복구 포함). +- `src/mcp/` — MCP 클라이언트/매니저/어댑터/config (모든 MCP 툴 mutating 강제). +- `src/codeintel/lspManager.ts` (452행) — 언어별 LSP 서버 lazy spawn (tsserver/pyright/gopls/rust-analyzer/clangd + 사용자 설정 가능). +- `src/config/defaults.ts` — 모든 기본값. `DEFAULT_MAX_ITERATIONS = 300`, `DEFAULT_MAX_OUTPUT_TOKENS = 131072` 등. +- `src/config/store.ts` — StoredConfig 타입. 메모리 캐시 + 영속 설정. +- `src/persistence/sessionStore.ts` — 세션 CRUD, 인덱스 자가 복구, 원자 쓰기, 큐잉. +- `src/utils/` — tokens(스크립트 인식 휴리스틱), truncate(head+tail), diff, shell, processTree, image, html, mentions, projectInstructions, writeFileAtomic. --- -## ✅ 완료: #6 동적 `max_tokens` +## 완료된 업그레이드 전체 목록 -### 문제 -`src/agent/loop.ts`의 두 생성 요청이 `max_tokens: 4096` 하드코딩: -- 729행: 스트리밍 요청 (`session.client.chat.completions.create`, `stream: true`) -- 840행(→이제 856행): 비스트리밍 재시도 (native 툴콜 인자가 깨졌을 때) +### 초기 12개 (v0.6.0, 6fe9888) +| # | 항목 | 상태 | +|---|---|:---:| +| 1 | 병렬 툴 실행 (read-only Promise.all) | ✅ | +| 2 | 재시도 정책 설정화 (maxRetries) | ✅ | +| 3 | 정확한 토큰 추정 (CJK/기호 스크립트 인식) | ✅ | +| 4 | 스마트 출력 캡 (head+tail 보존) | ✅ | +| 5 | 부분 히스토리 보존 컴팩션 | ✅ | +| 6 | 동적 max_tokens (resolveMaxTokens) | ✅ | +| 7 | 툴 설명 풍부화 | ✅ | +| 8 | MCP 연결 재시도·재연결 | ✅ | +| 9 | 컨텍스트 윈도우 캐시 TTL | ✅ | +| 10 | edit_file 유사 매치 제안 (Levenshtein) | ✅ | +| 11 | git 출력 head+tail | ✅ | +| 12 | auto-accept 모드 세분화 | ✅ | -로컬 모델이 파일을 통째로 다시 쓸 때(fallback 모드에서 정밀 edit이 어려워 흔함) 4096 토큰으로 부족 → 응답 중간 잘림 → 툴콜 JSON 불완전 → malformed 에러 반복. 이게 "자꾸 에러가 나"던 원인. +### 08-20 세션 (6fe9888..dfaf8d1) +- LSP 코드 인텔리전스 + 병렬 서브에이전트 mutation gate +- 로컬 모델 툴콜 안정성 3종 (부분 JSON 복구, fallback 파서, 빈 응답 재시도) +- multi_edit, DiffView, notebook_edit, task 시스템, 트랜스크립트 내보내기 +- /mouse 토글, 출력 중 입력창 작동 -### 해결 -`shouldAutoCompact` 뒤(157행 근처)에 `resolveMaxTokens(session)` 헬퍼 추가: +### 08-21 세션 (dfaf8d1..5c3fcfd) +- 마우스 기본 ON + SGR-1006 전체 파싱 + 드래그 선택/OSC 52 클립보드 +- 병렬 툴 실행 검증 (runToolBatch 디버그 로그 + 테스트) +- maxIterations 100→300 상향 +- gateAndRun 에러 로깅 +- DiffView 삭제 라인 색상 조정 (#f85149 → #e8904e) -```ts -function resolveMaxTokens(session: Session): number { - const MARGIN = 512; - const MIN = 2048; - const available = session.contextWindow - session.lastContextTokens - MARGIN; - return Math.max(MIN, Math.min(available, session.contextWindow)); -} -``` +### 08-22 세션 #1 (5c3fcfd..5065990): 15개 업그레이드 +| # | 항목 | 상태 | +|---|---|:---:| +| 1 | 시스템 프롬프트 대폭 강화 (로컬/클라우드 분기 포함) | ✅ | +| 2 | 멀티라인 입력 (Shift+Enter) + 브래킷 페이스트 | ✅ | +| 3 | /undo 명령 — 마지막 턴 롤백 | ✅ | +| 4 | /dashboard — 이미 구현됨 | ✅ | +| 5 | 컨텍스트 사용률 진행 바 — 이미 구현됨 | ✅ | +| 6 | 추론(thinking) 토큰 지원 | ✅ | +| 7 | @ 멘션 퍼지 매칭 + 캐시 TTL | ✅ | +| 8 | readFile 바이너리 가드 + 대용량 보호 | ✅ | +| 9 | writeFile 원자 쓰기 (temp→rename) | ✅ | +| 10 | bash 문자열 누적 O(n²)→O(n) | ✅ | +| 11 | estimateTokens 정규식 고속화 | ✅ | +| 12 | handleCompletedMessage any→Record | ✅ | +| 13 | 세션 복원 시 권한 상태 보존 | ✅ | +| 14 | 컨텍스트 윈도우 병렬 감지 | ✅ | +| 15 | 스트리밍 텍스트 누적 O(n²)→O(n) | ✅ | -두 사이트 모두 `max_tokens: 4096` → `max_tokens: resolveMaxTokens(session)` 교체. +### 08-22 세션 #2 (47711a9..60c6021): 시스템 프롬프트 분리 + 안정성 +- 시스템 프롬프트 로컬/클라우드 분리 (`isLocalBackendURL`, `buildSystemPrompt(..., isLocal)`) +- 빈 응답 재시도 1→3 (MAX_EMPTY_RESPONSE_RETRIES = 3) +- 마우스 드래그 SGR-1006 버그 수정 (모션 비트 32 인식) +- maxIterations 기본값 300 반영 -### 동작 -- `contextWindow − lastContextTokens − 512`를 출력 예산으로 할당 -- 하한 2048: 컨텍스트 거의 찼어도 최소 출력 보장 -- 상한 `contextWindow`: 윈도우 커도 그 이상 요구 안 함 -- 32k 윈도우 / 8k 사용 중 → 약 23k 출력 (기존 4096의 5.7배) -- 8k 윈도우 / 6k 사용 중 → 2048 (기존과 동일) +### 08-22 세션 #3 (Claude Code, c539995): 크래시/매칭 버그 수정 +- 마우스 크래시 수정 (`effectiveScrollTopRef` 미선언 → ReferenceError) +- CRLF 제어문자 JSON 파싱 버그 (`escapeRawControlCharsInStrings` in partialJson.ts + nativeAdapter.ts 리페어 폴백) +- CRLF edit_file/multi_edit 매칭 버그 (LF 정규화로 매칭, 쓰기 전 원래 EOL 복원) +- 🔥 번인레이트 오해 수정 (input+output → output만) -### 남겨둔 것 -- `compactSession`의 `max_tokens: 1024` (loop.ts:186) — 짧은 산문 요약용이라 동적 계산 불필요, 그대로 유지 -- `capabilityProbe.ts`의 `max_tokens: 200` — 핑 툴용, 그대로 유지 - -### 검증 -- `tsc --noEmit` ✓ -- `npm run build` ✓ (217.48 KB) -- `npm test` ✓ 27파일 179개 전부 통과 - -### 편집 메모 -- 파일이 CRLF라 edit_file 도구가 매칭 실패함 → `src/agent/_patch.py` 임시 스크립트로 바이너리 교체 후 삭제 -- 향후 이 프로젝트 edit_file 시도 전 `file `로 인코딩 확인; CRLF면 Python 바이너리 편집 또는 `sed` 사용 +### 현재 미커밋 변경 (working tree) +| 파일 | 변경 내용 | +|---|---| +| `package.json` / `package-lock.json` | 의존성 대거 업그레이드 (openai 6→7, commander 13→15, execa 9→10, vitest 3→4, node types 22→26, tsup target node20→node22 등) | +| `src/ui/ink/mouseInput.ts` | **삭제됨** — 마우스 트래킹 전면 폐지 | +| `src/ui/ink/index.tsx` | alternate screen buffer 제거, normal screen + `` 아키텍처로 전환 | +| `src/ui/ink/App.tsx` | 마우스/가상스크롤 코드 제거, `` 기반 렌더링으로 재구조화 | +| `src/ui/ink/ChatInput.tsx` | 마우스 관련 코드 제거, 멀티라인/브래킷 페이스트/@멘션 유지 | +| `src/ui/ink/HistoryItemView.tsx` | 마우스 선택 관련 코드 제거 | +| `src/ui/ink/FilePanel.tsx` | (변경 있음 — 상세 확인 필요) | +| `src/agent/loop.ts` | (변경 있음 — 상세 확인 필요) | +| `src/agent/loop.test.ts` | (변경 있음 — 테스트 추가/수정) | +| `src/codeintel/lspManager.ts` | (변경 있음) | +| `src/config/defaults.ts` | (변경 있음) | +| `src/config/store.ts` | (변경 있음) | +| `src/persistence/sessionStore.ts` / `.test.ts` | (변경 있음) | +| `tsup.config.ts` | target: node20 → node22 | --- -## ✅ 완료: #3 정확한 토큰 추정 (스크립트 인식 휴리스틱) +## 핵심 아키텍처 변경: Normal Screen + Static -### 문제 -`src/utils/tokens.ts`의 `estimateTokens`가 `JSON.stringify(messages).length / 4` — 두 가지 실패 모드: -1. JSON 직렬화 오버헤드(따옴표, 중괄호, 이스케이프)를 콘텐츠 토큰으로 계산 → 추정치 15–25% 부풀림 (모델에 안 보내는 것들) -2. 동일한 chars/token 비율을 모든 스크립트에 적용 → 영어 산문 ~4, 코드/기호 ~3.5, CJK(한국어/중국어/일본어) ~1.5인데 무시 → CJK 컨텍스트 과소평가, 컴팩션 타이밍 부정확 +이전 아키텍처에서 대폭 전환: -실제 usage가 오면 이미 정확하지만(`lastContextTokensIsEstimate = false`), 추정은 첫 턴 전/컴팩션 직후/서브에이전트 생성 시 사용 → 이 시점의 부정확이 컴팩션 타이밍을 빗나가게 함. +### 이전 (삭제됨) +- Alternate screen buffer 진입/종료 (`\x1b[?1049h` / `\x1b[?1049l`) +- 인앱 가상 스크롤 (PageUp/PageDown, scrollTop/ref) +- 마우스 트래킹 (SGR-1006, `\x1b[?1002h\x1b[?1006h`) +- 마우스 드래그 텍스트 선택 + OSC 52 클립보드 복사 +- `mouseInput.ts`: `matchMouseSequence`, `logicalButton`, `copyToClipboard` +- `effectiveScrollTopRef`, `selectionStart/End`, `extractSelectionText` -### 해결 -`tokens.ts`를 메시지 구조 순회 + 스크립트 인식 휴리스틱으로 재작성: - -- **PER_MESSAGE_OVERHEAD = 4**: 채팅 템플릿이 각 메시지에 추가하는 역할/구분자 토큰(~3–5) 반영 -- **메시지별 콘텐츠 순회**: 시스템/사용자/어시스턴트 텍스트, tool_calls 구조, tool 결과를 JSON이 아닌 모델이 실제로 보는 텍스트로 추출 -- **스크립트 인식 가중치** (`weightedChars`): - - CJK(히라가나/가타카나/한자/한글) ×2.4 → ~1.5 chars/token (각 코드 포인트가 보통 자체 BPE 토큰) - - 조밀 기호(구두점/연산자/괄호, 코드에 흔함) ×1.15 → ~3.5 chars/token - - 라틴 기본 ×1 → ~4 chars/token -- **멀티파트 콘텐츠**: 텍스트 파트는 텍스트, 이미지/오디오 파트는 flat 8 토큰(base64가 아닌 placeholder 토큰) -- 동기 순수 추정 유지(백엔드 호출 없음) → 첫 턴/컴팩션/서브에이전트에 안전 - -Ollama `/api/tokenize`는 백엔드 분기 + 매 턴 지연이 필요해 제외(의존성·복잡도 대비 효과 부족). 휴리스틱 개선으로 즉시 효과. - -### 동작 -- 한국어 메시지 40자: 기존 10 토큰 → ~27 토큰 (실제에 가까움) -- 코드/기호: 기존보다 약간 높게 → 컴팩션 조기 트리거 (OOM 방지) -- 영어 산문: 기존과 유사하되 JSON 오버헤드 제거 → 약간 낮아짐 -- 구조(tool_calls, 멀티파트) 비용 반영 - -### 검증 -- `tsc --noEmit` ✓ -- `npm run build` ✓ (224.26 KB) -- `npm test` ✓ 29파일 203개 전부 통과 (신규 9개: tokens.test.ts) -- `loop.test.ts`는 `lastContextTokens` 직접 설정 → 추정값 변화에 영향 없음 확인 - -### 편집 메모 -- `tokens.ts` CRLF → write_file + Python 변환 -- `tokens.test.ts` LF → edit_file 사용 -- `ChatCompletionMessageParam` 멀티파트 타입 캐스트 `as unknown as` 필요 (OpenAI 타입 narrow) - -### 남겨둔 것 -- Ollama `/api/tokenize` 캐싱: 백엔드별 분기 + 비동기 필요 → 별도 작업. 현재 휴리스틱으로 충분히 개선됨 -- 실제 usage 도착 후에는 항상 정확한 값 사용(`updateContextTracking`의 `lastContextTokensIsEstimate = false`) +### 현재 (새 아키텍처) +- Normal screen buffer — 터미널의 일반 스크롤백에 직접 출력 +- Ink `` 컴포넌트 — 완료된 히스토리 아이템을 한 번만 렌더링, 재렌더링 없음 +- 마우스 트래킹 없음 — 터미널 자체 네이티브 스크롤/선택/복사에 의존 +- `mouseInput.ts` 파일 삭제됨 +- 이점: 코드 대폭 감소, 터미널 호환성 향상, SSH/tmux 기본 작동 --- -## ✅ 완료: #4 스마트 출력 캡 (head+tail 보존) +## 설정 기본값 (현재) -### 문제 -`src/utils/truncate.ts`의 `truncate()`가 head만 보존. 긴 명령 출력에서 tail의 에러/상태 줄이 잘림 → 모델이 실패 원인을 못 봄. 특히 로컬 모델에서 bash/git 출력이 길면 마지막 에러 메시지가 사라져 디버깅 불가. - -### 해결 -`truncate.ts`를 head+tail 보존(중간 생략)으로 재작성: - -- 라인 단위로 잘라 가독성 유지 (반 줄 잘림 방지) -- 예산의 60% head, 40% tail 할당 (tail이 에러/상태 줄을 담는 경우가 많아 비중 높임) -- head/tail 오버랩 가드 (예산 초과가 적을 때 중복 라인 방지) -- 모든 라인이 예산보다 길면 문자 단위 폴백 -- 생략된 문자 수 + 보존된 head/tail 라인 수 표시 - -### 적용 범위 -`truncate()` 시그니처 유지 → 모든 기존 호출자 자동 개선: -- `bash.ts` (stdout/stderr) — 가장 큰 효과 -- `git.ts` (status/diff/log/show/branches 출력) — **#11도 함께 해결** -- `bashOutput.ts`, `backgroundJobs.ts`, `webFetch.ts`, `importFile.ts` - -`readFile.ts`는 자체 페이지네이션(nextOffset)을 쓰므로 그대로 유지. `grep.ts`/`listFiles.ts`는 자체 limit 잘라내기 사용. - -### 동작 -- 짧으면 그대로 반환 -- 길면 head 일부 + `... [truncated N more characters — middle omitted, X head + Y tail lines kept] ...` + tail 일부 -- tail에 에러 줄이 있으면 모델이 볼 수 있음 - -### 검증 -- `tsc --noEmit` ✓ -- `npm run build` ✓ (222.30 KB) -- `npm test` ✓ 28파일 194개 전부 통과 (신규 7개: truncate.test.ts) -- 기존 호출자 테스트(bash/git/grep 등) 전부 통과 → 호환성 확인 - -### 편집 메모 -- `truncate.ts`는 CRLF → write_file 후 Python으로 CRLF 변환 -- `truncate.test.ts`는 LF → edit_file 도구 사용 가능 +| 설정 | 기본값 | 비고 | +|---|---|---| +| `DEFAULT_CONTEXT_WINDOW` | 8192 | 자동 감지 실패 시 폴백 | +| `DEFAULT_MAX_ITERATIONS` | **300** | 50→100→300 상향 | +| `DEFAULT_MAX_OUTPUT_TOKENS` | **131072** | 8192→131072 (128K). GLM 등 1M 컨텍스트 모델 대응 | +| `DEFAULT_MAX_RETRIES` | 0 | SDK 지수 백오프 | +| `DEFAULT_AUTO_COMPACT_THRESHOLD` | 0.85 | | +| `DEFAULT_REQUEST_TIMEOUT_MS` | 180,000 | 3분 | +| `DEFAULT_SUBAGENT_TIMEOUT_MS` | 600,000 | 10분 | +| `MAX_EMPTY_RESPONSE_RETRIES` | **3** | 1→3 상향 | +| `MAX_SUBAGENT_DEPTH` | 1 | 서브에이전트 중첩 금지 | +| `MAX_PRESERVED_TAIL_MESSAGES` | 8 | 컴팩션 시 보존 | +| `MAX_PRESERVED_TAIL_FRACTION` | 0.3 | 컴팩션 시 보존 비율 | +| `MAX_RETAINED_IMAGES` | 2 | 히스토리 이미지 보존 | --- -## ✅ 완료: #10 `edit_file` 유사 매치 제안 - -### 문제 -fallback 모델(그리고 정밀 edit이 어려운 로컬 모델)이 `old_string`을 거의 정확히 but not exactly 제공 → `occurrences === 0` → 단순 "not found" 에러 → 모델이 맥락 없이 재시도, 실패 반복. 정확한 텍스트를 어디서 가져와야 할지 힌트가 없음. - -### 해결 -`src/tools/editFile.ts`에 유사 매치 제안 추가: - -- `normaliseForCompare(s)`: 공백 연속을 단일 스페이스로 정규화 → 들여쓰기/줄바꿈 차이에 강건 -- `boundedLevenshtein(a, b, maxDist)`: 조기 종료 Levenshtein. `maxDist` 초과 시 즉시 반환 → 큰 파일에서도 저렴 -- `findSimilarMatch(content, needle)`: 파일 전체를 슬라이딩 윈도우(needle 길이 ±50%, step = needle/8)로 순회하며 정규화된 텍스트로 유사도 측정. 최고 점수 ≥ 0.5일 때만 반환 -- `similarHint(original, oldString)`: 매치 실패 시 에러/preview 메시지에 "The closest match in the file (line N, ~X% similar):" + snippet 추가 - -handler와 preview 양쪽의 `occurrences === 0` 경로에 적용. 기존 "not found" 메시지 뒤에 힌트가 붙음. - -### 동작 -- 정확히 일치하는 부분이 있으면 기존 동작 유지 (힌트 없음) -- 유사한 부분이 있으면 위치·유사도·snippet 제안 → 모델이 정확한 `old_string`으로 재시도 가능 -- 전혀 다르면 힌트 없이 "not found"만 (노이즈 방지) - -### 검증 -- `tsc --noEmit` ✓ -- `npm run build` ✓ (220.91 KB) -- `npm test` ✓ 27파일 187개 전부 통과 (신규 3개: closest match 제안/preview/유사도 임계값) - -### 편집 메모 -- `editFile.ts`는 CRLF → Python 바이너리 편집으로 교체 + CRLF 유지 -- `editFile.test.ts`는 LF → edit_file 도구 사용 가능 -- `noUncheckedIndexedAccess` 활성화 → 배열 인덱스 접근 시 `?? 기본값` 처리 필요 - ---- - -## 남은 우선순위 — 모두 완료 ✅ - -12개 업그레이드 후보 전부 완료. 아래는 구현 요약. - -### 즉시 효과 (구현 가벼움) -- ✅ **#4 스마트 출력 캡** — `truncate.ts` head+tail 보존, 모든 호출자 자동 개선 -- ✅ **#10 `edit_file` 유사 매치 제안** — 매치 실패 시 Levenshtein 유사 위치 제안 -- ✅ **#6 동적 max_tokens** — `resolveMaxTokens(session)` -- ✅ **#11 git 출력 head+tail** — #4로 함께 해결 - -### 정확도에 큰 영향 -- ✅ **#3 정확한 토큰 추정** — 스크립트 인식 휴리스틱 (CJK/기호/구조 비용) -- ✅ **#5 부분 히스토리 보존 컴팩션** — 최근 N턴 원본 보존 + 이전 요약 - -### 성능 -- ✅ **#1 병렬 툴 실행** — `runToolBatch`: read-only 툴 `Promise.all` 병렬, mutating 순차. 4개 루프에 적용 - -### 회복력 -- ✅ **#2 재시도 정책** — `maxRetries` 설정화 (기본 0, SDK 지수 백오프) -- ✅ **#8 MCP 재연결** — `connectMcpServer` 재시도 + `/mcp reconnect` 명령 + 세션 toolset 갱신 -- ✅ **#9 캐시 TTL** — `cachedAt` 타임스탬프 + N일(기본 7) 경과 재감지 - -### 기타 -- ✅ **#7 툴 설명 풍부화** — 8개 핵심 툴 description에 "use when…"/예시 추가 -- ✅ **#12 auto-accept 세분화** — `auto-accept` = 모든 mutating 툴 자동 승인, `auto-edit` = 편집 툴만 (설명-동작 일치) - ---- - -## 핵심 파일 맵 -- `src/agent/loop.ts` (1138행) — 메인 에이전트 루프, 턴/스트리밍/툴콜/컴팩션/서브에이전트/병렬 툴 배치 -- `src/agent/session.ts` — Session 객체, 통계, 상태 -- `src/agent/systemPrompt.ts` — 시스템 프롬프트 빌더 (매우 간결, 로컬 준수율 우선) -- `src/tools/` — 14개 내장 툴 (read_file, list_files, grep, web_search, web_fetch, git_status, write_file, edit_file, bash, bash_output, bash_kill, git_commit, todo_write, agent) -- `src/toolcalling/` — native 어댑터, fallback 파서/프롬프트, resolve (Ollama 빈키 복구 포함) -- `src/mcp/` — MCP 클라이언트/매니저/어댑터/config (모든 MCP 툴 mutating 강제) -- `src/hooks/` — 훅 러너 (병렬 실행, SSRF 가드, exit 0/2 시맨틱스) -- `src/plugins/` — Claude Code 플러그인 로더 (commands/agents/skills/hooks/MCP, 툴명 매핑) -- `src/backend/` — client, capabilityProbe, contextWindow(자동 탐지), capabilityCache -- `src/config/` — config (CLI > env > 저장 > 기본값 우선순위), defaults, store, types -- `src/permissions/` — permissionManager (default/plan/auto-edit/auto-accept), types -- `src/persistence/` — sessionStore (원자 쓰기, 큐잉), exportSession, replayHistory -- `src/utils/` — tokens, truncate, shell, processTree, image, html, mentions, projectInstructions -- `src/ui/ink/` — Ink(React) 풀스크린 TUI 컴포넌트 - ## 트러블슈팅 힌트 -- "자꾸 에러"의 주요 원인: `max_tokens: 4096` 잘림 → malformed 툴콜 (✅ 해결됨) -- 컨텍스트 윈도우가 8192 기본값이면 `locode config set contextWindow <실제값>` 필요 — `resolveMaxTokens`가 제값을 내려면 -- `/status`로 현재 model/backend/mode/cwd 확인 가능 -- fallback 모드 툴콜 실패 시 `/mode fallback` 강제 또는 모델 교체 -- **"Paused after N steps"가 자주 뜨면**: 1 스텝 = 1 모델 요청. 로컬 모델은 한 번에 1 툴만 호출하는 경향이 있어 다수 파일 작업이 50스텝을 쉽게 초과. 기본값 50→**100** 상향(일상 작업용). 큰 배치 작업 시 `locode config set maxIterations ` (예: 200). 정지 시 작업 내용은 보존되므로 "continue"로 이어서 진행 가능. +- "자꾸 에러"의 주요 원인: max_tokens 잘림 → malformed 툴콜 (✅ 해결됨 — 동적 max_tokens + CRLF 제어문자 이스케이프) +- CRLF 파일 edit_file 매칭 버그: ✅ 해결됨 (LF 정규화 공간에서 매칭, 쓰기 전 원래 EOL 복원) +- "Paused after N steps": `locode config set maxIterations ` (기본 300) +- 클라우드 모델 빈 응답: MAX_EMPTY_RESPONSE_RETRIES=3으로 재시도 +- 시스템 프롬프트가 로컬/클라우드 자동 분기 — localhost 감지 시 로컬 프롬프트, 그 외 클라우드 프롬프트 +- 반복 루프 감지: `detectRepetitionLoop()` — 스트리밍 텍스트에서 짧은 반복 패턴 감지 시 중단 +- 번인레이트(🔥) 표시: outputTokens/min만 반영 (inputTokens는 제외) + --- -## 2026-08-20 세션: LSP 완성 + 로컬 모델 안정성 3총사 + 패리티 4종 + UX - -시작점: `6fe9888` (v0.6.0, 12개 업그레이드 완료 커밋). 종료점: `dfaf8d1`. -검증: typecheck ✓ · build 274.76 KB · **301 tests** 통과 (시작 219 → +82). - -### 복구 — 어제 LSP 작업 다운된 지점부터 -어제 `src/codeintel/lspManager.ts` 작업 중 타입 에러 3개로 다운. 우선 복구: -- 스트림 타입: `ReadableStreamMessageReader`/`WriteableStreamMessageWriter` → `StreamMessageReader`/`StreamMessageWriter` (Node `Readable`/`Writable` 직접 받음, 캐스트 불필요). -- diagnostic message: `string | MarkupContent` → `messageToString()` 헬퍼로 정규화. - -### 커밋 목록 (12개, 6fe9888..dfaf8d1) - -| 커밋 | 내용 | -|---|---| -| `cb95089` | LSP 코드 인텔리전스 + 병렬 서브에이전트 mutation gate | -| `0ddc822` | 로컬 모델 툴콜 안정성 3종 (부분 JSON 복구, fallback 파서, 빈 응답 재시도) | -| `a458b3c` | multi_edit 도구 | -| `b7233af` | LSP diagnostics 신선도 (publishDiagnostics 대기) | -| `f2ca154` | LSP 서버 설정화 + C/C++ clangd 통합 | -| `c8cc78e` | README LSP 문서화 + LSP 도구 테스트 | -| `e3520b8` | /mouse 토글 (드래그/복사 기본 on) | -| `ee695a6` | DiffView (컬러 diff 렌더링) | -| `b2a7d1a` | notebook_edit (.ipynb 셀 편집) | -| `5438780` | 풀 트랜스크립트 내보내기 + /export json | -| `dad0915` | 구조화 task 시스템 (의존성 그래프) | -| `dfaf8d1` | 출력 중 입력창 작동 (스트리밍 중 타이핑 가능) | - -### 상세 - -#### LSP 코드 인텔리전스 (cb95089, b7233af, f2ca154, c8cc78e) -- `src/codeintel/lspManager.ts`: 언어별 LSP 서버 lazy spawn (tsserver/pyright/gopls/clangd/rust-analyzer), didOpen/didChange 동기화, definition/references/diagnostics, notifyFileChanged, shutdownAll. -- `src/tools/codeIntel.ts`: definition/references/diagnostics 3개 read-only 도구. tools/index.ts 등록. -- `agent/loop.ts`: FileChanged 훅에 notifyFileChanged 연결 (fire-and-forget, best-effort). -- `ui/ink/index.tsx`: 종료 시 shutdownAll → orphan 서버 방지. -- **diagnostics 신선도 (b7233af)**: 기존 `setTimeout(0)` 1턴 대기 → tsserver/pyright 큰 파일에서 publish 안 됨 → 편집 직후 stale 반환. `diagWaiters` Map + `waitForDiagnostics(uri, 1500)`로 publishDiagnostics를 타임아웃 걸고 대기. sync 전 캐시 클리어. -- **서버 설정화 (f2ca154)**: `LANGUAGE_SPECS` 하드코딩 → `configureLanguageSpecs(overrides)`로 config 머지. `locode config set lspServers ''` (키=languageId, {command, args?, extensions?}). 빌트인 오버라이드 또는 새 언어 추가(extensions 필수). C/C++ → clangd 1개로 통합(기존 2개 spawn 문제 해결). `resolveLspServers()` + ui/ink 시작 시 configure 호출. -- 테스트: lspManager.test.ts (6, configureLanguageSpecs 머지 — 스폰 없이 _specsForTests), codeIntel.test.ts (7, 도구 디스패치 — vi.mock). - -#### 병렬 서브에이전트 mutation gate (cb95089) -- `session.ts`: `session.mutationGate` promise chain. 세션 전체(서브에이전트 포함) mutating 툴 직렬화. 권한 슬롯 1개에 레이스/파일 쓰기 겹침 방지. read-only는 계속 병렬. -- `loop.ts`: `runUnderMutationGate`. 서브에이전트는 부모 gate 상속. -- `agentTool.ts`: `tasks` 배열 → N개 서브에이전트 병렬. 1개 실패는 그 태스크 error, 배치 전체 reject 아님. -- `types.ts`: SubAgentResult. 테스트: parallelAgents.test.ts. - -#### 로컬 모델 툴콜 안정성 3종 (0ddc822) -- **#2 부분 JSON 복구**: `src/toolcalling/partialJson.ts` — `repairPartialJson()`. 닫히지 않은 문자열 닫기, 중괄호/대괄호 밸런스(max_tokens 잘림), trailing comma 제거, stray trailing 토큰 컷. 키/값 발명 안 함(스키마 검증 거침). `loop.ts`: native 툴콜 인자 parse 실패 시 repair 시도 후 non-streaming 재시도. 테스트: partialJson.test.ts (10). -- **#3 fallback 파서 강화**: `fallbackParser.ts` — ```tool_call 펜스(멀티라인 앵커, 내부 ```json 펜스 제거) + ```json 펜스 + bare(펜스 없는) 툴콜 객체. 모든 후보 partialJson repair 통과. 잘린 펜스(닫는 ``` 없음)도 복구. 테스트: fallbackParser.test.ts (11). -- **#6 빈 응답 재시도**: `loop.ts` — bare empty `stop`(텍스트/툴콜 없음) 1회 넛지 재시도. 재시도 소진 후 throw. 테스트: loop.test.ts (2). - -#### multi_edit (a458b3c) -- `src/tools/multiEdit.ts`: 한 파일에 순차 배치 편집. 각 edit은 running result에 검증(이전 edit이 텍스트 옮길 수 있음) → 불일치 시 edit 인덱스 명시. 1회 승인 + 1회 atomic 쓰기. editFile의 applyEdit/countOccurrences 재사용. 테스트: multiEdit.test.ts (8). - -#### /mouse 토글 — 드래그/복사 (e3520b8) -- 마우스 휠 추적(`\x1b[?1000h`)이 터미널 텍스트 선택을 뺏던 문제. **기본 off** → 드래그/복사 가능. PageUp/PageDown 스크롤 유지. `/mouse on|off`로 휠 토글. help/README 문서화. - -#### DiffView (ee695a6) -- `src/utils/diff.ts`: `looksLikeDiff()`. -- `src/ui/ink/DiffView.tsx`: unified diff 컬러 렌더링(addition 녹/removal 빨/hunk·file 헤더 dim). side-by-side 모드(pairHunk + clip). v0.6.0 포팅. -- `theme.ts`: DIFF_ADD_HEX/DIFF_REMOVE_HEX. PermissionPrompt가 diff preview를 컬러 렌더. 테스트: diff.test.ts (4), DiffView.test.ts (8). - -#### notebook_edit (b2a7d1a) -- `src/tools/notebookEdit.ts`: .ipynb 셀 인식 편집(replace/insert/delete, cell_id/cell_index). nbformat source 라인 배열 변환. code↔markdown 전환 시 필드 정리. atomic 쓰기. v0.6.0 포팅(setLastEdit 제거). 테스트: notebookEdit.test.ts (9). - -#### 트랜스크립트 내보내기 (5438780) -- `exportSession.ts`: 마크다운 export에 툴 콜/결과 포함(기존엔 텍스트만). `sessionToJson()` 전체 레코드 JSON. `/export json [file]`. defaultExportFilename 포맷별. 테스트: exportSession.test.ts (8). - -#### 구조화 task 시스템 (dad0915) -- `src/tools/task.ts`: TaskStore(in-memory, per-session) + task_create/list/get/update. blocks/blockedBy 의존성 그래프, owner 소유권, status, metadata merge-patch(null=삭제). self-ref/unknown/2-cycle 가드, delete 시 dangling ref 정리. 스키마 사용 전 선언(v0.6.0 TDZ 수정). todo_write 병행 유지. -- types.ts: ctx.taskStore. session.ts: 세션마다 TaskStore. 테스트: task.test.ts (9). - -#### 출력 중 입력창 작동 (dfaf8d1) -- 기존: isThinking/streamingText 시 ChatInput 언마운트 → "Waiting…" 박스 교체 → 타이핑 불가. -- 수정: ChatInput 항상 마운트. 위에 1줄 상태 표시. handleSubmit 가드로 중복 제출 방지(Enter는 턴 종료까지 no-op, 텍스트는 박스에 남음). Escape 인터럽트는 App 수준 useInput이라 계속 작동. - -### 인코딩 메모 -- 프로젝트 파일은 CRLF/LF 섞임. edit_file 도구 매칭 실패 빈번 → Python 바이너리 편집 사용. -- 팁: 편집 전 `python3 -c "b=open(p,'rb').read(); print(repr(b[i:i+n]))"`로 실제 바이트/인코딩 확인. -- unicode(em-dash, CJK 글리프) 포함 시 Python heredoc은 SyntaxError → 임시 .py 파일로 작성. - -### 남은 후보 (이번 세션 미진행) +## 남은 후보 / TODO - LSP 실서버 통합 테스트 (실제 tsserver/pyright 띄워서 검증) - task store 영속화 (세션에 task 저장) -- 멘션 피커 열린 상태에서 Escape 인터럽트 우선순위 조정(엣지 케이스) - ---- - -## 2026-08-21 세션: 마우스 개선, 병렬 툴 실행 검증, 드래그 복사, 색상 조정 - -시작점: `dfaf8d1`. 검증: build ✓ · **303 tests** 통과. - -### 마우스 스크롤/선택 — Claude Code 스타일로 개선 (e3520b8에서 이어서) - -**이전 상태**: `/mouse on` 해야 휠 작동, 마우스 모드 켜면 터미널 텍스트 선택 불가. - -**변경**: -- 마우스 모드 **기본 ON** (`?1002h` 버튼+드래그 추적) -- Shift+클릭/드래그: 터미널 네이티브 텍스트 선택 동작 -- `mouseInput.ts`: SGR-1006 전체 파싱 (버튼, 좌표, 누름/해제, Shift/Meta/Ctrl 수정키) -- `logicalButton()` 내보내기 추가 -- `/mouse` 명령 메시지 업데이트 - -### 마우스 드래그로 카피 — 인앱 텍스트 선택 + OSC 52 클립보드 - -**추가 구현**: -- `selectionStart`/`selectionEnd` 상태로 드래그 범위 추적 (1-based content row) -- 왼쪽 클릭: 선택 시작, 드래그: 범위 갱신, 릴리즈: 선택 텍스트를 `extractSelectionText()`로 추출 -- `extractSelectionText()`: HistoryItem에서 plain text 추출, row 범위로 자르기 -- 릴리즈 시 `copyToClipboard()` (OSC 52) 호출 + "Copied N line(s)" 노티스 -- Shift+클릭/드래그는 기존대로 터미널 네이티브 선택으로 패스스루 - -### 병렬 툴 실행 검증 - -**`runToolBatch` 디버그 로그 추가**: -- `[runToolBatch] N tool(s): parallel|sequential | tool1, tool2...` 형식으로 stderr 출력 -- 병렬 테스트 2개 추가 (loop.test.ts): - - "runs multiple read-only tools in parallel": 3개 read_file 200ms씩 → 병렬 실행 확인 - - "runs mixed read+write tool calls sequentially": read_file + edit_file → 순차 실행 확인 (305ms) - -**결과**: 클라우드 모델에서 read-only 툴은 `Promise.all` 병렬 실행, mutating 툴 섞이면 순차. 로컬 모델은 보통 툴콜 1개씩만 보내서 병렬의 이점을 못 누름. - -### 100 리밋 iteration 검토 - -- `DEFAULT_MAX_ITERATIONS = 100` (이전 50에서 상향) -- `locode config set maxIterations `로 조절 가능 -- 출력이 띄엄띄엄한 원인: 모델이 툴콜 1개씩만 보내서 매 요청마다 왕복 시간 + 컨텍스트 증가 - -### `gateAndRun` 에러 로깅 추가 - -- `RESOLVE ERROR`: 툴 인자 파싱/검증 실패 시 stderr 로그 -- `RUN ERROR`: 툴 실행 중 에러 시 stderr 로그 - -### DiffView 삭제 라인 색상 조정 - -- `DIFF_REMOVE_HEX`: `#f85149` (붉은색) → `#e8904e` (주황색) -- 어두운 터미널 배경에서 가독성 개선 - -### 파일 인코딩 정리 -- `loop.ts`, `loop.test.ts`, `App.tsx` CRLF→LF 변환 (`sed -i 's/\r$//'`) - ---- - -## 2026-08-22 세션: 15개 업그레이드 (시스템 프롬프트, 멀티라인, /undo, thinking, 성능 등) - -시작점: `5c3fcfd`. 종료점: `5065990`. 검증: typecheck ✓ · build 289.56 KB · **322 tests** 통과 (시작 303 → +19). - -### 커밋 - -| # | 항목 | 난이도 | 효과 | 로컬 특화 | -|---|---|:---:|:---:|:---:| -| 1 | 시스템 프롬프트 대폭 강화 | 중 | 대 | ★★★ | -| 2 | 멀티라인 입력 (Shift+Enter) + 브래킷 페이스트 | 중 | 대 | | -| 3 | `/undo` 명령 — 마지막 턴 롤백 | 중 | 대 | | -| 4 | `/dashboard` 비용/지연 | — | — | 이미 구현됨 | -| 5 | 컨텍스트 사용률 진행 바 | — | — | 이미 구현됨 | -| 6 | 추론(thinking) 토큰 지원 | 중 | 대 | ★★ | -| 7 | `@` 멘션 퍼지 매칭 + 캐시 TTL | 하 | 중 | | -| 8 | readFile 바이너리 가드 + 대용량 보호 | 하 | 대 | | -| 9 | writeFile 원자 쓰기 (temp→rename) | 하 | 대 | | -| 10 | bash 문자열 누적 O(n²)→O(n) | 하 | 중 | | -| 11 | estimateTokens 정규식 고속화 | 하 | 중 | ★ | -| 12 | handleCompletedMessage any→Record | 하 | 품질 | | -| 13 | 세션 복원 시 권한 상태 보존 | 중 | 중 | | -| 14 | 컨텍스트 윈도우 병렬 감지 | 하 | 중 | ★ | -| 15 | 스트리밍 텍스트 누적 O(n²)→O(n) | 하 | 중 | | - -### 상세 - -#### #1 시스템 프롬프트 대폭 강화 (`systemPrompt.ts`) - -기존 22줄(5개 제네릭 가이드) → 100줄+ 구조화된 프롬프트: -- **도구 분류**: Read-only / Mutating (requires confirmation) 그룹별 나열 -- **핵심 원칙 7개**: inspect before answering, prefer small edits, one tool per response in fallback, preserve existing style, keep answers concise, recovery over retry, respect confirmation -- **도구 사용 가이드**: 각 도구별 "use when..." 지시 (read_file, edit_file, bash, agent, task 등) -- **로컬 모델 지침**: 작은 컨텍스트 윈도우, 툴콜 불안정성, 빈 응답 대응, 출력 길이 제한 -- **안전 가이드**: .git 수정 금지, 대규모 삭제 시 확인, bash 읽기 선호 -- **서브에이전트 프롬프트**: `buildSystemPrompt` 재사용으로 자동 적용 -- **테스트**: 12개 신규 (systemPrompt.test.ts) - -#### #2 멀티라인 입력 + 브래킷 페이스트 (`ChatInput.tsx`) - -- **Shift+Enter**: 줄바꿈 삽입 (기존 Enter=제출 유지) -- **multiline 모드**: 텍스트에 `\n` 포함 시 Enter→줄바꿈, Ctrl+Enter→강제 제출 -- **브래킷 페이스트** (`\x1b[200~`…`\x1b[201~`): 터미널에서 붙여넣기 시 여러 줄 그대로 삽입 -- **CRLF→LF 변환**: 붙여넣은 텍스트의 `\r\n`을 `\n`으로 정규화 -- `useCallback`으로 `maybeHandlePaste` 최적화, `pasteBufferRef`/`inPasteRef`로 청크 버퍼링 - -#### #3 `/undo` 명령 (`session.ts`, `App.tsx`, `HistoryItemView.tsx`) - -- `undoLastTurn(session)`: 마지막 user 메시지부터 끝까지 제거, 컨텍스트 토큰 재계산 -- 파일시스템 변경은 롤백 안 함 (soft undo) -- 제거된 메시지 수 반환 → "Undid last turn (removed N messages)" -- `/undo` 슬래시 명령 + 도움말에 추가 -- **테스트**: 7개 (session.test.ts) — no user messages, 단일 턴, 툴결과 포함, 이전 턴 보존 등 - -#### #6 추론(thinking) 토큰 지원 (`events.ts`, `loop.ts`, `App.tsx`, `types.ts`, `HistoryItemView.tsx`) - -- `AgentEvent`에 `thinking_delta` / `thinking_done` 이벤트 추가 -- 스트리밍 루프에서 `delta.reasoning_content` 캡처 (DeepSeek, QwQ 등) -- `fullThinking` 누적 → `thinking_done` 이벤트로 방출 -- UI: dimmed 보더 + "Thinking:" 레이블로 렌더링 -- `itemToLines`에 thinking 종류 추가 (선택 텍스트) - -#### #7 @ 멘션 퍼지 매칭 (`ChatInput.tsx`) - -- **퍼지 매치**: `fuzzyMatch()` — 정확한 부분문자열 매치 우선, 실패 시 문자 순서 매치 (예: "ut" → "utils/") -- **정렬**: 정확 매치 우선, 그 다음 길이순 -- **캐시 TTL**: `filesCachedAt` 상태 추가, 5분 후 리글로브 -- **`.gitignore` 준수**: `fast-glob`의 `gitignore: true` 옵션 (타입 제한으로 제외, `ignore`로 대체) - -#### #8 readFile 바이너리 가드 (`readFile.ts`) - -- `MAX_FILE_SIZE = 10 MB` — `stat()` 사전 체크, 초과 시 에러 메시지 -- `BINARY_EXTENSIONS` Set (30+ 확장자) — 확장자 기반 바이너리 감지 -- null-byte 휴리스틱 (8KB 스캔) — 확장자 없는 바이너리 파일 감지 -- `content.split("\n")` → `content.split(/\r?\n/)` — CRLF 호환 - -#### #9 writeFile 원자 쓰기 (`writeFile.ts`) - -- `randomUUID()`로 임시 파일명 생성 → `writeFile` → `rename` -- 실패 시 `unlink`로 임시 파일 정리 -- `readExisting` catch에서 `err.code === 'ENOENT'` 명시적 체크 (다른 에러는 재throw) - -#### #10 bash 문자열 누적 (`bash.ts`) - -- `let stdout = ""` → `const stdoutChunks: string[] = []` -- `stdout += d.toString()` → `stdoutChunks.push(d.toString())` -- 최종 `stdoutChunks.join("")` / `stderrChunks.join("")` -- O(n²) 문자열 누적 → O(n) 배열 push + join - -#### #11 estimateTokens 정규식 고속화 (`tokens.ts`) - -- per-character `weightedChars` 루프 → 정규식 벌크 카운팅: - - CJK: `/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af\u1100-\u11ff]/gu` → ×2.4 - - Dense symbols: `/[!-\/:-@[-\`{-~]/g` → ×1.15 - - 나머지: ×1 -- `isCjk()` / `isDenseSymbol()` 헬퍼 제거 - -#### #12 handleCompletedMessage 타입 (`loop.ts`) - -- `message: any` → `message: Record` -- 런타임 동작은 동일 (속성 접근 시 any와 호환) - -#### #13 세션 복원 권한 보존 (`session.ts`, `sessionStore.ts`) - -- `SessionRecord`에 `allowedTools?: string[]` 선택적 필드 추가 -- `toSessionRecord()`: `session.permissions.listAllowed()` 저장 -- `createSessionFromRecord()`: `record.allowedTools` 복원 → `session.permissions.allowForSession()` -- `/perm`에서 "이 세션에서 허용"한 도구가 세션 재개 후에도 유지 - -#### #14 컨텍스트 윈도우 병렬 감지 (`contextWindow.ts`) - -- `detectContextWindow`: 순차 `await Ollama → await LM Studio` → `Promise.allSettled([Ollama, LM Studio])` -- 감지 지연 반감 (Ollama가 안 돌아도 LM Studio 결과 즉시 사용) -- `ollama.status === "fulfilled" && ollama.value !== null` 패턴으로 null 결과 필터링 - -#### #15 스트리밍 텍스트 누적 O(n²)→O(n) (`loop.ts`) - -- `fullText += delta.content` → `textChunks.push(delta.content)` -- 스트림 루프 후 `fullText = textChunks.join("")` -- 에러 핸들러에서도 `textChunks.join("")`로 부분 텍스트 복구 - -### 인코딩 메모 -- `session.ts`, `App.tsx`, `HistoryItemView.tsx` CRLF → Python 바이너리 편집으로 교체 -- `ChatInput.tsx`, `systemPrompt.ts` LF → write_file/edit_file 사용 - ---- - -## 2026-08-23 세션: 클라우드 모델 빈 응답 수정, 마우스 드래그 디버그 - -시작점: `47711a9`. 검증: typecheck ✓ · build 294.28 KB · **326 tests** 통과. - -### #13 시스템 프롬프트 클라우드/로컬 분리 (`systemPrompt.ts`, `session.ts`, `App.tsx`, `defaults.ts`) - -**문제**: 시스템 프롬프트에 "You run against a local model" / "Empty or malformed responses can happen" 등 로컬 모델 전용 지시사항이 하드코딩. GLM-5.1 클라우드 모델이 이 프롬프트를 받으면: -- 자신이 로컬 모델이라고 착각 → 보수적 동작 -- "빈 응답이 나올 수 있다"는 암시 → 자기 충족적 빈 응답 (Request failed: Empty response from model.) - -**해결**: `isLocal` 파라미터로 프롬프트 분리 -- `isLocalBackendURL(baseURL)`: localhost/127.0.0.1/::1이면 true -- `buildSystemPrompt(tools, mode, projectInstructions, isLocal)`: - - **로컬 프롬프트** (`isLocal=true`, 기본값): 기존 프롬프트 유지, "Working with local models" 섹션 포함, 7개 원칙 - - **클라우드 프롬프트** (`isLocal=false`): 로컬 모델 제한 섹션 제거, 6개 원칙 (fallback 모드의 "One tool per response" 규칙 제거), "local model" 언급 없음 -- `Session.isLocal` 필드 추가, `createSession`/`createSessionFromRecord`/`setMode`/`compactSession`/`spawnSubAgent` 모두 전파 -- `App.tsx`에서 `isLocalBackendURL(baseURLRef.current)` / `isLocalBackendURL(record.baseURL)` 로 자동 설정 - -### #14 빈 응답 재시도 증가 (`loop.ts`) - -- `MAX_EMPTY_RESPONSE_RETRIES`: 1 → **3** -- 클라우드 모델도 간헐적 빈 응답 발생 가능 (GLM-5.1 사례) -- 이전: 초기 응답 + 1회 재시도 = 총 2회 → 실패 시 `AgentError("Empty response from model.")` -- 이후: 초기 응답 + 3회 재시도 = 총 4회 → 빈 응답 후 넛지 메시지 삽입 후 재시도 - -### #15 마우스 드래그 디버그 (`App.tsx`) - -- 마우스 이벤트에 `process.stderr.write` 디버그 로그 추가 -- 클릭/드래그/릴리즈 이벤트의 `button`, `pressed`, `logicalBtn` 값 확인용 -- SGR-1006 모드 1002에서 드래그 이벤트(`button=32`, motion bit)가 `logicalButton()`에서 `"other"`로 분류되는 문제 확인 중 -- 디버그 완료 후 제거 예정 - -### maxIterations 기본값 300 상향 (이전 세션에서 진행) - -- `DEFAULT_MAX_ITERATIONS = 300` (이전 100 → 300, 이미 08-21 세션에서 변경됨) - - -## 2026-08-23 세션 (이어서): 마우스 드래그 버그 수정 - -시작점: 이전 세션의 디버그 로그 제거 + 드래그 버그 수정. - -### 디버그 로그 제거 (`App.tsx`) - -- `process.stderr.write(\`[MOUSE] ...\`)` 2줄 삭제 — 스크롤 시 로그가 쏟아지는 원인 - -### 마우스 드래그 버그 수정 (`mouseInput.ts`, `App.tsx`) - -**문제**: 마우스 왼쪽 버튼 드래그가 작동하지 않음. 클릭은 되지만 드래그로 선택 영역을 확장할 수 없었음. - -**원인**: SGR-1006 프로토콜에서 **드래그 모션**은 버튼 코드에 **bit 32**를 더해 전송: -- 왼쪽 버튼 클릭: `button=0` -- 왼쪽 버튼 **드래그**: `button=32` (0 + motion bit 32) -- 릴리스: `button=3` + trailing `m` - -기존 `logicalButton()`은 `button & ~0x07`로 모디파이어 비트만 제거 → `32 & ~0x07 = 32` → `"other"`로 분류 → 드래그 이벤트가 완전히 무시됨. - -이전 수정에서 `button=3`을 `pressed ? "drag" : "release"`로 바꿨으나, `button=3`은 실제로는 3번 버튼 누름(거의 발생 안 함)이고, SGR-1006 릴리스는 항상 `pressed=false`(trailing `m`)으로 오므로 `button=3` 여부와 무관하게 `!pressed → "release"`가 맞음. - -**수정** (`mouseInput.ts`): -- `logicalButton()` 재작성: - - `raw === 0 && pressed` → `"left"` (클릭) - - `raw === 1 && pressed` → `"middle"` - - `raw === 2 && pressed` → `"right"` - - `raw === 32/33/34` → `"drag"` (왼쪽/가운데/오른쪽 드래그 모션) - - `raw === 64/65` → `"wheel-up"/"wheel-down"` - - `!pressed` → `"release"` (SGR-1006 릴리스) - - 나머지 → `"other"` -- SGR-1006 버튼 인코딩 전체 문서화 (bit 0-2 모디파이어, bit 4-5 버튼, bit 5 모션, bit 6 휠) - -**수정** (`App.tsx`): -- 마우스 핸들러 분기 재구성: - - `"left" && pressed` → 선택 시작 - - `"drag" && selectionStart` → 선택 영역 확장 (드래그) - - `"release" && selectionStart` → 클립보드 복사 + 선택 해제 - - 기존 `left && !pressed` 분기 제거 (SGR-1006에서 릴리스는 항상 `button=3, pressed=false`) -- 템플릿 리터럴 내 ` -` 이스케이프 깨짐 수정 (CRLF 파일 바이너리 편집으로 인해 리터럴 개행이 됨) - -### 검증 -- `tsc --noEmit` ✓ -- `npm run build` ✓ (293.39 KB) -- `npm test` ✓ 42파일 326개 전부 통과 - ---- - -## 2026-08-22 세션 (Claude Code): 마우스 크래시, CRLF 매칭 버그 2건, 번인레이트 오해 - -시작점: 위 세션이 내보낸 트랜스크립트(`08-22T03-43-04-499Z.md`)를 백그라운드 서브에이전트로 리뷰. 검증: typecheck ✓ · build 295.77 KB · **339 tests** 통과 (시작 326 → +13). - -### 마우스 드래그 크래시 수정 (`App.tsx`) - -**문제**: 위 세션이 `mouseToContentRow` 안에 `effectiveScrollTopRef.current`를 참조하도록 고쳤지만, 그 ref를 선언하는 줄을 추가하기 전에 세션이 끊김. `npm run build`(esbuild)는 타입체크를 안 해서 "빌드 성공"으로 보였지만 `tsc --noEmit`은 `Cannot find name 'effectiveScrollTopRef'`로 실패 — 즉 실제로는 마우스를 클릭하는 순간 `ReferenceError`로 죽는 상태였음. - -**수정**: `maxScrollRef`/`pinnedToBottomRef`/`scrollTopRef`와 같은 패턴으로 `effectiveScrollTopRef` 선언 + 매 렌더마다 `.current` 갱신 한 줄 추가. - -**남은 불확실성**: 좌표 계산(`mouseToContentRow`) 자체가 맞는지는 실제 터미널에서 드래그해보지 않으면 확신 불가 — Yoga 레이아웃(`getAbsolutePosition`)은 0-based, SGR-1006 마우스 row는 1-based라 1줄 오프바이원 가능성이 있음. 사용자에게 최상단 줄만 드래그해서 무엇이 복사되는지 확인 요청함 (아직 회신 대기). - -### CRLF/제어문자 JSON 파싱 버그 (`src/toolcalling/partialJson.ts`, `nativeAdapter.ts`) - -JSON은 문자열 리터럴 안에 raw 제어문자(리터럴 개행/CR/탭)를 금지 — 로컬 모델이 멀티라인 파일 내용을 이스케이프 없이 그대로 echo하면(이 프로젝트 소스 대부분이 CRLF라 특히 흔함) `JSON.parse`가 "Bad control character" 로 통째로 실패. - -- `partialJson.ts`: `repairPartialJson`에 새 (2)단계로 `escapeRawControlCharsInStrings` 추가 — 문자열 리터럴 안의 raw `\n`/`\r`/`\t`/기타 제어문자만 이스케이프(기존 `\\n` 같은 이스케이프는 건드리지 않음), 나머지 리페어 단계는 이 결과 위에서 실행. -- `nativeAdapter.ts`: `resolveToolCall`의 raw `JSON.parse`가 실패해도 리페어를 전혀 안 시도하던 것을 `repairPartialJson` 폴백 추가 (스트리밍 경로는 `loop.ts`에 이미 리페어가 있었지만 비스트리밍 completion 경로는 없었음). -- 테스트: `partialJson.test.ts` +4, `nativeAdapter.test.ts` 신규 3개. - -### CRLF `edit_file`/`multi_edit` 매칭 버그 (`src/tools/editFile.ts`, `multiEdit.ts`) - -**원인**: `read_file`은 항상 LF로 정규화한 내용을 모델에게 보여주는데(`content.split(/\r?\n/)` 후 `\n`으로 재조합), `edit_file`/`multi_edit`은 raw(실제 CRLF 보존) 파일과 정확히 매칭 — 모델은 자기가 본 LF 기준으로 `old_string`을 만드니 CRLF 파일마다 "old_string not found"가 체계적으로 발생하던 상태. - -**수정**: `editFile.ts`에 `detectEol`/`toLF`/`fromLF` 추가 — 파일의 EOL을 감지하고, 매칭/편집은 항상 LF 공간에서 수행, 쓰기 직전에 원래 EOL로 복원. `multiEdit.ts`는 이 헬퍼들을 재사용(자체 raw 매칭 로직 있었음 — 동일 버그). -- 테스트: `editFile.test.ts` +5(EOL 헬퍼 2개 포함), `multiEdit.test.ts` +1. - -### 번인레이트(🔥) 오해 소지 수정 (`StatusBar.tsx`) - -`🔥 X/min`이 `(inputTokens + outputTokens) / 경과분`이었는데, `inputTokens`는 API 호출마다 **매번 재전송되는 전체 컨텍스트**의 합(프롬프트 캐싱 없음) — 폴백 모드처럼 한 턴에 툴콜 왕복이 많으면 몇 분 만에 수백K로 부풀어 보임(실제 생성 속도와 무관). `outputTokens / 경과분`으로 변경 — 실제 생성 처리량만 반영. 입력측 비용은 상단 컨텍스트 바가 이미 보여주고 있어 정보 손실 없음. - -### 검증 -- `tsc --noEmit` ✓ -- `npm run build` ✓ (295.77 KB) -- `npm test` ✓ 43파일 **339개** 전부 통과 +- 의존성 업그레이드 커밋 (working tree에 미반영 변경 다수) +- Normal screen 아키텍처 전환 커밋 (mouseInput.ts 삭제, index.tsx/App.tsx 재구조화) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e11794d..a9f5188 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,22 +8,22 @@ "name": "locode", "version": "0.5.2", "dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/sdk": "^1.30.0", "@vscode/ripgrep": "^1.18.0", - "commander": "^13.0.0", + "commander": "^15.0.0", "diff": "^9.0.0", "env-paths": "^4.0.0", - "execa": "^9.6.1", + "execa": "^10.0.1", "fast-glob": "^3.3.3", - "ink": "^7.1.0", + "ink": "^7.1.1", "ink-select-input": "^6.2.0", "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", "marked": "^15.0.12", "marked-terminal": "^7.3.0", - "openai": "^6.45.0", - "react": "^19.2.7", - "string-width": "^8.2.1", + "openai": "^7.5.0", + "react": "^19.2.8", + "string-width": "^8.2.2", "tree-kill": "^1.2.2", "vscode-languageserver-protocol": "^3.18.2", "vscode-uri": "^3.1.0", @@ -34,12 +34,12 @@ }, "devDependencies": { "@types/marked-terminal": "^6.1.1", - "@types/node": "^22.10.0", - "@types/react": "^19.2.17", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", "tsup": "^8.3.0", - "tsx": "^4.19.0", + "tsx": "^4.23.12", "typescript": "^5.7.0", - "vitest": "^3.0.0" + "vitest": "^4.1.11" }, "engines": { "node": ">=20" @@ -589,12 +589,12 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -663,6 +663,296 @@ "node": ">= 8" } }, + "node_modules/@oxc-project/types": { + "version": "0.146.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", + "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", + "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", + "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", + "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", + "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", + "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", + "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", + "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", + "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", + "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", + "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", + "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", + "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", + "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", + "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", + "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", @@ -1043,6 +1333,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cardinal": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@types/cardinal/-/cardinal-2.1.1.tgz", @@ -1102,19 +1399,19 @@ } }, "node_modules/@types/node": { - "version": "22.20.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", - "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~8.3.0" } }, "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1122,39 +1419,40 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", - "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.6", - "@vitest/utils": "3.2.6", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", - "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.6", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -1166,42 +1464,42 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", - "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", - "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.6", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", - "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.6", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -1209,28 +1507,25 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", - "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", - "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.6", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -1655,18 +1950,11 @@ } }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } @@ -1692,16 +1980,6 @@ "node": ">=10" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -1898,12 +2176,12 @@ "license": "MIT" }, "node_modules/commander": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", - "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=22.12.0" } }, "node_modules/confbox": { @@ -1945,6 +2223,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-to-spaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", @@ -2027,16 +2312,6 @@ } } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2046,6 +2321,16 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/diff": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", @@ -2142,9 +2427,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -2277,26 +2562,26 @@ } }, "node_modules/execa": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", - "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-10.0.1.tgz", + "integrity": "sha512-ge98qjkRK4IB7tL7Ju/6qmm5LHoH1eEMt5FNZrz3f4UIYhF28lggX20z3FaX1sgc67msLEn0N0BscOs29iuwyw==", "license": "MIT", "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", - "cross-spawn": "^7.0.6", "figures": "^6.1.0", - "get-stream": "^9.0.0", + "get-stream": "^9.0.1", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", - "pretty-ms": "^9.2.0", + "pretty-ms": "^9.3.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", - "yoctocolors": "^2.1.1" + "which-command": "^0.1.0", + "yoctocolors": "^2.1.2" }, "engines": { - "node": "^18.19.0 || >=20.5.0" + "node": ">=22" }, "funding": { "url": "https://github.com/sindresorhus/execa?sponsor=1" @@ -2753,9 +3038,9 @@ "license": "ISC" }, "node_modules/ink": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/ink/-/ink-7.1.0.tgz", - "integrity": "sha512-VWE6/yeLtFCJBNLflyI2OSylyXK1Rc24LuXup8Qt+icwkmmycFNdbn8IkSp6Frc0h1iA0NOvvi1ajW44U/w3Qg==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-7.1.1.tgz", + "integrity": "sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w==", "license": "MIT", "dependencies": { "@alcalzone/ansi-tokenize": "^0.3.0", @@ -3137,13 +3422,6 @@ "node": ">=10" } }, - "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -3156,6 +3434,279 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -3186,13 +3737,6 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3456,6 +4000,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -3478,10 +4036,13 @@ } }, "node_modules/openai": { - "version": "6.45.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.45.0.tgz", - "integrity": "sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.5.0.tgz", + "integrity": "sha512-ZbDBz8FSB8Mv8fFYIUvzTFMdV5vl93/octp1MdtK2lfYepSpfv/ewmeugpKz/cwGtFSx+YuUM4NwpZ2P55YiPA==", "license": "Apache-2.0", + "engines": { + "node": ">=22.0.0" + }, "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", @@ -3584,16 +4145,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3810,9 +4361,9 @@ } }, "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3885,6 +4436,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", + "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.146.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.5", + "@rolldown/binding-android-arm64": "1.2.5", + "@rolldown/binding-darwin-arm64": "1.2.5", + "@rolldown/binding-darwin-x64": "1.2.5", + "@rolldown/binding-freebsd-x64": "1.2.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", + "@rolldown/binding-linux-arm64-gnu": "1.2.5", + "@rolldown/binding-linux-arm64-musl": "1.2.5", + "@rolldown/binding-linux-ppc64-gnu": "1.2.5", + "@rolldown/binding-linux-s390x-gnu": "1.2.5", + "@rolldown/binding-linux-x64-gnu": "1.2.5", + "@rolldown/binding-linux-x64-musl": "1.2.5", + "@rolldown/binding-openharmony-arm64": "1.2.5", + "@rolldown/binding-win32-arm64-msvc": "1.2.5", + "@rolldown/binding-win32-x64-msvc": "1.2.5" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -4248,16 +4833,16 @@ } }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, "node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", "license": "MIT", "dependencies": { "get-east-asian-width": "^1.5.0", @@ -4297,19 +4882,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -4447,30 +5019,10 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -4580,9 +5132,9 @@ } }, "node_modules/tsx": { - "version": "4.22.5", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.5.tgz", - "integrity": "sha512-F7JnSfPl5ASt6LqwWyUQ3T8BwN3q0eQEbFMYa2iRWaVQmmudo0d7fRmwM4O002gsvW1bs0yBYioutsAjqLJMvQ==", + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", "dev": true, "license": "MIT", "dependencies": { @@ -5147,9 +5699,9 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "dev": true, "license": "MIT" }, @@ -5193,18 +5745,17 @@ } }, "node_modules/vite": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", - "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0 || ^0.28.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -5220,9 +5771,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -5235,15 +5787,18 @@ "@types/node": { "optional": true }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, "jiti": { "optional": true }, "less": { "optional": true }, - "lightningcss": { - "optional": true - }, "sass": { "optional": true }, @@ -5267,89 +5822,80 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vitest": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", - "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.6", - "@vitest/mocker": "3.2.6", - "@vitest/pretty-format": "^3.2.6", - "@vitest/runner": "3.2.6", - "@vitest/snapshot": "3.2.6", - "@vitest/spy": "3.2.6", - "@vitest/utils": "3.2.6", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.6", - "@vitest/ui": "3.2.6", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -5360,9 +5906,22 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/vscode-jsonrpc": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.1.tgz", @@ -5409,6 +5968,21 @@ "node": ">= 8" } }, + "node_modules/which-command": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/which-command/-/which-command-0.1.0.tgz", + "integrity": "sha512-XZyoF5/5hZtXitIwzrU4NKK+Wtbb9aB9CezUEw2Q0wlYK8NUYQxC1rRXgNueYLtBAJwXIb+/tFVk4dozciNJMA==", + "license": "MIT", + "bin": { + "which-command": "cli.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/which-command?sponsor=1" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/package.json b/package.json index deb4a4c..383ef01 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "dist" ], "engines": { - "node": ">=20" + "node": ">=22.12.0" }, "scripts": { "build": "tsup", @@ -20,22 +20,22 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/sdk": "^1.30.0", "@vscode/ripgrep": "^1.18.0", - "commander": "^13.0.0", + "commander": "^15.0.0", "diff": "^9.0.0", "env-paths": "^4.0.0", - "execa": "^9.6.1", + "execa": "^10.0.1", "fast-glob": "^3.3.3", - "ink": "^7.1.0", + "ink": "^7.1.1", "ink-select-input": "^6.2.0", "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", "marked": "^15.0.12", "marked-terminal": "^7.3.0", - "openai": "^6.45.0", - "react": "^19.2.7", - "string-width": "^8.2.1", + "openai": "^7.5.0", + "react": "^19.2.8", + "string-width": "^8.2.2", "tree-kill": "^1.2.2", "vscode-languageserver-protocol": "^3.18.2", "vscode-uri": "^3.1.0", @@ -43,12 +43,12 @@ }, "devDependencies": { "@types/marked-terminal": "^6.1.1", - "@types/node": "^22.10.0", - "@types/react": "^19.2.17", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", "tsup": "^8.3.0", - "tsx": "^4.19.0", + "tsx": "^4.23.12", "typescript": "^5.7.0", - "vitest": "^3.0.0" + "vitest": "^4.1.11" }, "allowScripts": { "esbuild@0.27.2": true diff --git a/src/agent/loop.test.ts b/src/agent/loop.test.ts index 2227fc2..c460321 100644 --- a/src/agent/loop.test.ts +++ b/src/agent/loop.test.ts @@ -1,13 +1,44 @@ import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; -import { MaxIterationsError, compactSession, runTurn, shouldAutoCompact } from "./loop.js"; +import { AgentError, MaxIterationsError, compactSession, detectRepetitionLoop, runTurn, shouldAutoCompact } from "./loop.js"; import { agentTool } from "../tools/agentTool.js"; import { createSession } from "./session.js"; import { buildToolSet } from "../tools/toolset.js"; +import { DEFAULT_MAX_OUTPUT_TOKENS } from "../config/defaults.js"; import type { ConfirmFn } from "../permissions/types.js"; import type { ToolDef } from "../tools/types.js"; import type { Session } from "./session.js"; +describe("detectRepetitionLoop", () => { + it("returns false for short text regardless of content", () => { + expect(detectRepetitionLoop("error error error error error error")).toBe(false); + }); + + it("detects a short phrase repeating many times in a row", () => { + const text = "Here is the analysis: " + "error error error ".repeat(80); + expect(detectRepetitionLoop(text)).toBe(true); + }); + + it("detects a longer repeated unit (e.g. a repeated JSON-like fragment)", () => { + const unit = '{"status":"retry","reason":"pending"} '; + const text = "Starting work.\n" + unit.repeat(40); + expect(detectRepetitionLoop(text)).toBe(true); + }); + + it("does not flag normal varied prose", () => { + const paragraphs = Array.from( + { length: 20 }, + (_, i) => `Paragraph ${i}: this covers a different point each time, with enough unique wording to avoid any short repeating pattern in the tail.`, + ); + expect(detectRepetitionLoop(paragraphs.join("\n"))).toBe(false); + }); + + it("does not flag a legitimately repetitive but non-degenerate bullet list", () => { + const items = Array.from({ length: 30 }, (_, i) => `- item ${i}: some unique detail about entry number ${i}`); + expect(detectRepetitionLoop(items.join("\n"))).toBe(false); + }); +}); + describe("shouldAutoCompact", () => { it("triggers at the session's configured threshold", () => { const session = { @@ -25,8 +56,8 @@ describe("shouldAutoCompact", () => { describe("runTurn / max_tokens", () => { it("caps max_tokens independent of a large contextWindow", async () => { // Regression: some backends advertise a huge context window but cap a single response's - // max_tokens far below it (e.g. Ollama's glm-5.2:cloud: 1,000,000-token context, 8192-token max - // output). resolveMaxTokens used to request up to the whole remaining window, which such + // max_tokens far below it (e.g. Ollama's glm-5.2:cloud: 1,000,000-token context, 131,072-token + // max output). resolveMaxTokens used to request up to the whole remaining window, which such // backends reject outright — worse the larger (or user-raised) contextWindow got. let capturedMaxTokens: number | undefined; const fakeClient = { @@ -55,7 +86,38 @@ describe("runTurn / max_tokens", () => { await runTurn(session, "hi", () => {}); - expect(capturedMaxTokens).toBeLessThanOrEqual(8192); + expect(capturedMaxTokens).toBeLessThanOrEqual(DEFAULT_MAX_OUTPUT_TOKENS); + }); +}); + +describe("runTurn / repetition detection", () => { + it("aborts a streamed response that degenerates into a repetition loop", async () => { + const fakeClient = { + chat: { + completions: { + create: vi.fn(async (_params: any, options: any) => { + const signal: AbortSignal | undefined = options?.signal; + let count = 0; + // A real backend stuck in a loop would keep streaming the same short phrase forever; + // this fake mirrors that but stops once aborted (or hits a safety cap, so a detection + // regression fails the test instead of hanging it). + return { + [Symbol.asyncIterator]: () => ({ + next: async () => { + if (signal?.aborted || count >= 500) return { done: true, value: undefined }; + count++; + return { done: false, value: { choices: [{ delta: { content: "loop " }, finish_reason: null }] } }; + }, + }), + }; + }), + }, + }, + } as any; + + const session = createSession(fakeClient, "test-model", process.cwd(), async () => "once", "native", []); + + await expect(runTurn(session, "hi", () => {})).rejects.toThrow(AgentError); }); }); diff --git a/src/agent/loop.ts b/src/agent/loop.ts index 17a797b..b063174 100644 --- a/src/agent/loop.ts +++ b/src/agent/loop.ts @@ -123,6 +123,37 @@ export function createIdleAbort( }; } +/** Detects a degenerate repetition loop: a short substring repeating many times in a row at the + * tail of the streamed text. Known failure mode for local/quantized models, which otherwise only + * stop at max_tokens or an idle timeout — with maxOutputTokens raised for backends whose real + * ceiling is far larger, an undetected loop could run for a very long time before either kicks in. + * Only checks the tail (loops are contiguous, so recent text is sufficient and cheap to scan) and + * requires several consecutive exact repeats, so legitimately repetitive-but-valid text (a bullet + * list, a table, ASCII art) doesn't false-positive. */ +export function detectRepetitionLoop(text: string): boolean { + const TAIL = 1000; + const MIN_PERIOD = 4; + const MAX_PERIOD = 200; + const MIN_REPEATS = 6; + if (text.length < TAIL) return false; + const tail = text.slice(-TAIL); + for (let period = MIN_PERIOD; period <= MAX_PERIOD; period++) { + const repeats = Math.floor(tail.length / period); + if (repeats < MIN_REPEATS) continue; + const unit = tail.slice(tail.length - period); + let matched = true; + for (let r = 2; r <= repeats; r++) { + const start = tail.length - r * period; + if (tail.slice(start, start + period) !== unit) { + matched = false; + break; + } + } + if (matched) return true; + } + return false; +} + const MAX_MALFORMED_RETRIES = 2; /** Local models (especially small quantized ones) occasionally emit a bare empty `stop` * chunk — no text, no tool calls. Rather than abort the whole turn as a hard error, retry @@ -949,6 +980,12 @@ export async function runTurn( const requestStart = Date.now(); const idleGuard = createIdleAbort(resolveRequestTimeoutMs(), signal); + // Independent from idleGuard — a repetition loop keeps producing chunks, so idleGuard's + // silence-based timer never trips. Combined below via AbortSignal.any so either guard can end + // the request; only this one needs its own controller since it fires from content, not silence. + const repetitionAbort = new AbortController(); + let repetitionDetected = false; + let lastRepetitionCheckLength = 0; try { const stream = await session.client.chat.completions.create( { @@ -959,7 +996,7 @@ export async function runTurn( stream_options: { include_usage: true }, max_tokens: resolveMaxTokens(session), }, - { signal: idleGuard.signal }, + { signal: AbortSignal.any([idleGuard.signal, repetitionAbort.signal]) }, ); for await (const chunk of stream) { @@ -986,6 +1023,15 @@ export async function runTurn( if (delta?.content) { fullText += delta.content; emit({ type: "text_delta", delta: delta.content }); + // Throttled to roughly every 100 new characters — detectRepetitionLoop is cheap per call, + // but there's no reason to run it on every single-token chunk. + if (fullText.length - lastRepetitionCheckLength >= 100) { + lastRepetitionCheckLength = fullText.length; + if (detectRepetitionLoop(fullText)) { + repetitionDetected = true; + repetitionAbort.abort(); + } + } } // Accumulate native tool call deltas @@ -1015,6 +1061,11 @@ export async function runTurn( if (fullText) { emit({ type: "text_done", fullText }); } + if (repetitionDetected) { + throw new AgentError( + "Detected a repetition loop in the model's response and aborted early — the model got stuck repeating itself. Try again; if it keeps happening, this model may need a different prompt or a lower temperature.", + ); + } if (idleGuard.didTimeOut()) { throw new AgentError( `Backend stopped responding mid-stream (no data for ${Math.round(resolveRequestTimeoutMs() / 1000)}s) — connection aborted. The backend may have crashed or hung; try again.`, @@ -1025,6 +1076,15 @@ export async function runTurn( idleGuard.dispose(); } + // A repetition-triggered abort can also surface as a clean (chunk-less) end of the async + // iterator instead of a thrown error, depending on how far into the stream it landed — check + // unconditionally rather than only in the catch above, mirroring the idle-timeout check below. + if (repetitionDetected) { + throw new AgentError( + "Detected a repetition loop in the model's response and aborted early — the model got stuck repeating itself. Try again; if it keeps happening, this model may need a different prompt or a lower temperature.", + ); + } + // An idle-triggered abort can also surface as a clean (chunk-less) end of the async iterator // instead of a thrown error, depending on how far into the stream it landed — check unconditionally // rather than only in the catch above, or it'd silently fall through to "Empty response from model." diff --git a/src/codeintel/lspManager.ts b/src/codeintel/lspManager.ts index 5ce015d..24df29e 100644 --- a/src/codeintel/lspManager.ts +++ b/src/codeintel/lspManager.ts @@ -150,12 +150,35 @@ function toLocation(loc: Location): LocResult { async function startServer(spec: LanguageSpec, cwd: string): Promise { let child: ChildProcess; try { - child = spawn(spec.command, spec.args ?? [], { cwd, stdio: ["pipe", "pipe", "pipe"] }); + // npm installs global CLI packages on Windows as .cmd/.ps1 shims, not raw .exe files — spawn() + // can't resolve those without shell:true, so a genuinely-installed server would otherwise ENOENT. + child = spawn(spec.command, spec.args ?? [], { + cwd, + stdio: ["pipe", "pipe", "pipe"], + shell: process.platform === "win32", + }); } catch (err) { throw new Error( `Could not start the LSP server "${spec.command}" for ${spec.languageId}. Is it installed and on your PATH? (${(err as Error).message})`, ); } + // spawn() itself rarely throws synchronously — a missing binary (ENOENT) instead fires an + // async 'error' event on the child process. With no listener, that event is unhandled and + // crashes the whole process, so wait for either a successful spawn or that error before + // proceeding. + await new Promise((resolve, reject) => { + child.once("spawn", () => resolve()); + child.once("error", (err) => { + reject( + new Error( + `Could not start the LSP server "${spec.command}" for ${spec.languageId}. Is it installed and on your PATH? (${(err as Error).message})`, + ), + ); + }); + }); + // After startup, a late 'error' (e.g. the process dying unexpectedly) must not go unhandled + // either — the 'exit' handler below already disposes the connection, so just swallow it here. + child.on("error", () => {}); if (!child.stdin || !child.stdout) { child.kill(); throw new Error(`LSP server "${spec.command}" did not open stdio streams.`); @@ -164,6 +187,9 @@ async function startServer(spec: LanguageSpec, cwd: string): Promise const reader = new StreamMessageReader(child.stdout); const writer = new StreamMessageWriter(child.stdin); const connection = createProtocolConnection(reader, writer); + // vscode-jsonrpc buffers all messages until listen() starts pumping them — without this, + // sendRequest hangs (nothing is ever written) or throws "Call listen() first." + connection.listen(); // Surface stderr so a crashing server isn't a silent void (matches locode's MCP stdio policy). child.stderr?.on("data", () => { diff --git a/src/config/defaults.ts b/src/config/defaults.ts index d1aa7fc..c906141 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -26,12 +26,17 @@ export const DEFAULT_CONTEXT_WINDOW = 8192; /** Ceiling on a single response's `max_tokens`, independent of the model's context window. Most * backends cap how much a single completion can generate well below the total context window they - * advertise (e.g. Ollama's glm-5.2:cloud reports a 1,000,000-token context window but only ever - * generates up to 8192 tokens per response) — resolveMaxTokens (agent/loop.ts) used to request up - * to the whole remaining window, which such backends rejected outright as a context/length error - * even on the very first turn. 8192 is a safe default most backends support; raise it via - * `locode config set maxOutputTokens` for backends known to allow more. */ -export const DEFAULT_MAX_OUTPUT_TOKENS = 8192; + * advertise (e.g. Ollama's glm-5.1:cloud / glm-5.2:cloud report a 1,000,000-token context window + * but error on a request above their real 131,072-token output cap: "max_tokens (500000) exceeds + * model's maximum output tokens (131072)") — resolveMaxTokens (agent/loop.ts) used to request up to + * the whole remaining window, which such backends rejected outright as a context/length error even + * on the very first turn. 131072 (128K) matches that verified real ceiling exactly, so it's safe to + * use as the default without erroring on the very first turn — going straight to a backend's real + * cap only became reasonable once detectRepetitionLoop (agent/loop.ts) existed to abort a model + * that gets stuck generating instead of relying on this value alone as the safety valve. Raise it + * further via `locode config set maxOutputTokens` for backends known to allow more; lower it for + * ones with a smaller real ceiling. */ +export const DEFAULT_MAX_OUTPUT_TOKENS = 131_072; /** Max model requests per turn before locode pauses rather than looping forever. Each iteration * is one model generation request (one tool-call round-trip), and local models commonly issue a diff --git a/src/config/store.ts b/src/config/store.ts index 4e0d798..19dc759 100644 --- a/src/config/store.ts +++ b/src/config/store.ts @@ -44,20 +44,36 @@ export function configFilePath(): string { /** @internal For tests only — redirect config persistence to `path` (pass undefined to reset). */ export function _setConfigFilePathForTest(p: string | undefined): void { configFileOverride = p; + invalidateConfigCache(); } +// In-memory cache so every resolve*() call in the same process doesn't re-read and re-parse +// the same small JSON file (8–10 calls during startup alone). Invalidated by saveStoredConfig +// and by tests that change the config path. +let cachedConfig: StoredConfig | undefined; + export function loadStoredConfig(): StoredConfig { + if (cachedConfig !== undefined) return cachedConfig; const file = configFilePath(); - if (!existsSync(file)) return {}; + if (!existsSync(file)) { cachedConfig = {}; return cachedConfig; } try { - return JSON.parse(readFileSync(file, "utf-8")) as StoredConfig; + cachedConfig = JSON.parse(readFileSync(file, "utf-8")) as StoredConfig; + return cachedConfig; } catch { - return {}; + cachedConfig = {}; + return cachedConfig; } } +/** Invalidate the in-memory cache — called by saveStoredConfig and by tests that change + * the config file path. */ +export function invalidateConfigCache(): void { + cachedConfig = undefined; +} + export function saveStoredConfig(cfg: StoredConfig): void { const file = configFilePath(); mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, JSON.stringify(cfg, null, 2)); + cachedConfig = cfg; } \ No newline at end of file diff --git a/src/persistence/sessionStore.test.ts b/src/persistence/sessionStore.test.ts index f49fa04..143b090 100644 --- a/src/persistence/sessionStore.test.ts +++ b/src/persistence/sessionStore.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import path from "node:path"; import envPaths from "env-paths"; import { @@ -70,6 +70,19 @@ describe("sessionStore", () => { expect(listSessions()).toHaveLength(0); }); + it("rebuilds the summary index from disk when no index file exists yet", () => { + writeFileSync(path.join(dir, "manual-session.json"), JSON.stringify(makeRecord("manual-session"))); + expect(listSessions()).toHaveLength(1); + expect(listSessions()[0]?.id).toBe("manual-session"); + }); + + it("self-heals when a session file is deleted outside of deleteSession()", async () => { + await saveSession(makeRecord("will-vanish")); + expect(listSessions()).toHaveLength(1); + unlinkSync(path.join(dir, "will-vanish.json")); + expect(listSessions()).toHaveLength(0); + }); + it("deriveTitle extracts the first user message", () => { const title = deriveTitle([ { role: "system", content: "sys" }, diff --git a/src/persistence/sessionStore.ts b/src/persistence/sessionStore.ts index b452890..0f88a8e 100644 --- a/src/persistence/sessionStore.ts +++ b/src/persistence/sessionStore.ts @@ -1,5 +1,5 @@ import envPaths from "env-paths"; -import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import path from "node:path"; import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"; import type { ToolCallMode } from "../backend/capabilityProbe.js"; @@ -47,6 +47,86 @@ function filePath(id: string): string { return path.join(dir, `${safeSessionId(id)}.json`); } +const INDEX_FILENAME = "_index.json"; + +function indexFilePath(): string { + return path.join(dir, INDEX_FILENAME); +} + +function summarize(record: SessionRecord): SessionSummary { + return { + id: record.id, + updatedAt: record.updatedAt, + title: deriveTitle(record.messages), + model: record.model, + baseURL: record.baseURL, + messageCount: record.messages.length, + }; +} + +/** Reads the on-disk summary index and reconciles it against the actual session files, so a stale or + * missing index self-heals instead of ever going wrong: entries whose file was deleted (by this or + * another locode process) are dropped, and files present on disk but missing from the index (a fresh + * install, a crash before the last index write, another process's save racing this one) are parsed + * individually. This keeps the common case to O(session count) stat calls instead of O(total + * transcript bytes) — listSessions() used to JSON.parse every saved session in full just to read 6 + * summary fields off each one. */ +function readIndex(): Map { + let index = new Map(); + if (existsSync(indexFilePath())) { + try { + const entries = JSON.parse(readFileSync(indexFilePath(), "utf-8")) as SessionSummary[]; + index = new Map(entries.map((e) => [e.id, e])); + } catch { + index = new Map(); + } + } + for (const id of index.keys()) { + if (!existsSync(filePath(id))) index.delete(id); + } + const known = new Set([...index.keys()].map((id) => safeSessionId(id))); + if (existsSync(dir)) { + for (const entry of readdirSync(dir)) { + if (!entry.endsWith(".json") || entry === INDEX_FILENAME) continue; + const stem = entry.slice(0, -".json".length); + if (known.has(stem)) continue; + try { + const record = JSON.parse(readFileSync(path.join(dir, entry), "utf-8")) as SessionRecord; + index.set(record.id, summarize(record)); + } catch { + // Skip corrupt/partial session files + } + } + } + return index; +} + +/** Best-effort atomic write of the index. A failed write just means the next readIndex() call + * re-parses whatever files it doesn't recognize yet — never incorrect data, only a missed + * optimization. */ +function writeIndex(index: Map): void { + const tmp = `${indexFilePath()}.${process.pid}.${Date.now()}.tmp`; + try { + writeFileSync(tmp, JSON.stringify([...index.values()])); + renameSync(tmp, indexFilePath()); + } catch { + try { + unlinkSync(tmp); + } catch { + // tmp may not have been created if the write itself failed + } + } +} + +/** Updates one entry in the persisted index. Two saves for different sessions racing this can lose + * one's index write, but never lose data: readIndex() picks up any on-disk session file it doesn't + * recognize, so the loser just costs the next listSessions() call one extra parse. */ +function updateIndexEntry(record: SessionRecord): void { + const index = readIndex(); + index.set(record.id, summarize(record)); + writeIndex(index); +} + export function deriveTitle(messages: ChatCompletionMessageParam[]): string { const first = messages.find((m) => m.role === "user"); const text = first && typeof first.content === "string" ? first.content.trim() : ""; @@ -65,7 +145,8 @@ const saveQueues = new Map>(); * otherwise load as "no session found" (see loadSession, which treats an unparseable file as absent). */ export async function saveSession(record: SessionRecord): Promise { const file = filePath(record.id); - const write = () => writeFileAtomic(file, JSON.stringify(record, null, 2)); + const write = () => + writeFileAtomic(file, JSON.stringify(record, null, 2)).then(() => updateIndexEntry(record)); // Run whether or not the previous save rejected, so one failure can't stall the chain. const prev = saveQueues.get(record.id); const next = (prev ?? Promise.resolve()).then(write, write); @@ -94,24 +175,7 @@ export function loadSession(id: string): SessionRecord | undefined { export function listSessions(): SessionSummary[] { if (!existsSync(dir)) return []; - const summaries: SessionSummary[] = []; - for (const entry of readdirSync(dir)) { - if (!entry.endsWith(".json")) continue; - try { - const record = JSON.parse(readFileSync(path.join(dir, entry), "utf-8")) as SessionRecord; - summaries.push({ - id: record.id, - updatedAt: record.updatedAt, - title: deriveTitle(record.messages), - model: record.model, - baseURL: record.baseURL, - messageCount: record.messages.length, - }); - } catch { - // Skip corrupt/partial session files - } - } - return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + return [...readIndex().values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); } export function mostRecentSessionId(): string | undefined { @@ -122,5 +186,8 @@ export function deleteSession(id: string): boolean { const file = filePath(id); if (!existsSync(file)) return false; unlinkSync(file); + const index = readIndex(); + index.delete(id); + writeIndex(index); return true; } diff --git a/src/ui/ink/App.tsx b/src/ui/ink/App.tsx index 1b2f89a..6c684e2 100644 --- a/src/ui/ink/App.tsx +++ b/src/ui/ink/App.tsx @@ -1,4 +1,4 @@ -import { Box, Text, useApp, useBoxMetrics, useInput, useStdin, useStdout, useWindowSize, type DOMElement } from "ink"; +import { Box, Static, Text, useApp, useInput, useStdin, useStdout, useWindowSize } from "ink"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import path from "node:path"; import { @@ -58,8 +58,6 @@ import { ExportPrompt } from "./ExportPrompt.js"; import { FilePanel, type FilePanelTab, type TouchedFile } from "./FilePanel.js"; import { HistoryItemView } from "./HistoryItemView.js"; import { ModelSelect } from "./ModelSelect.js"; -import { matchMouseSequence, logicalButton, copyToClipboard } from "./mouseInput.js"; -import { getAbsolutePosition } from "./absolutePosition.js"; import { PermissionPrompt } from "./PermissionPrompt.js"; import { SessionSelect } from "./SessionSelect.js"; import { StatusBar } from "./StatusBar.js"; @@ -67,77 +65,6 @@ import { ThinkingIndicator } from "./ThinkingIndicator.js"; import { ACCENT_HEX } from "../theme.js"; import { nextId, type HistoryItem, type NewHistoryItem } from "./types.js"; -/** Extract plain text lines from a HistoryItem (one line per display row for selection). */ -function itemToLines(item: HistoryItem): string[] { - switch (item.kind) { - case "user": return [`> ${item.text}`]; - case "assistant": return [item.text]; - case "thinking": return [item.text]; - case "streaming_text": return [item.text]; - case "tool_call": return [`⏺ ${item.label}`]; - case "tool_result": return [` ⎿ ${item.summary}`]; - case "notice": return [item.text]; - case "banner": - case "status": - case "dashboard": - case "help": - case "tools": - case "permissions": - case "sessions": - case "mcp": - case "plugins": - case "hooks": - case "skills": - case "todos": - // Complex items: we could render them fully, but for now return empty — - // selection across these is rarely needed and their layout is complex. - return []; - } -} - -/** Given a row range (1-based content rows), extract the corresponding text from - * the history items + streaming text. Each item contributes its line(s); the result - * is the intersection of those lines with the [from.row..to.row] range. */ -function extractSelectionText( - items: HistoryItem[], - streamingText: string | null, - from: { row: number; col: number }, - to: { row: number; col: number }, -): string { - // Build a flat list of (lineNumber, text) pairs — 1-based line numbers matching - // the content box rows (which scrollTop/marginTop offset against the viewport). - const lines: { row: number; text: string }[] = []; - let row = 1; - for (const item of items) { - const itemLines = itemToLines(item); - for (const line of itemLines) { - // A single item line may wrap to multiple terminal rows — split on newlines - // (itemToLines already returns one string per logical line). - lines.push({ row, text: line }); - row++; - } - } - if (streamingText !== null) { - for (const line of streamingText.split("\n")) { - lines.push({ row, text: line }); - row++; - } - } - - const selected = lines.filter((l) => l.row >= from.row && l.row <= to.row); - if (selected.length === 0) return ""; - - return selected.map((l, i) => { - const isFirst = l.row === from.row; - const isLast = l.row === to.row; - let text = l.text; - if (isFirst) text = text.slice(from.col - 1); - if (isLast && selected.length > 1) text = text.slice(0, to.col - from.col + 1); // approximate - else if (isLast && selected.length === 1) text = text.slice(from.col - 1, to.col); - return text; - }).join("\n"); -} - // Cap for the input-history ring buffer used for ↑/↓ recall in the chat input. const MAX_HISTORY = 100; @@ -208,21 +135,6 @@ export function App({ const [inputValue, setInputValue] = useState(""); const [permission, setPermission] = useState(null); const [exportPrompt, setExportPrompt] = useState<{ defaultName: string; format: ExportFormat } | null>(null); - // Mouse tracking (xterm ?1002h button-event + ?1006h SGR format). On by default — wheel - // scroll and click/drag events are captured for in-app interaction. Shift+click/drag is - // intentionally NOT captured so the terminal's native text selection still works (hold Shift - // to select and copy text). Click+drag selects text in-app (auto-copied on release). Toggle with `/mouse on|off`. When off, all mouse events pass - // through to the terminal and wheel scroll does nothing in-app. - const [mouseMode, setMouseMode] = useState(true); - // In-app text selection: track drag start/end rows (1-based terminal rows, adjusted - // for scroll offset). On release, the selected text is copied to the system clipboard - // via OSC 52 and the selection is cleared. - const [selectionStart, setSelectionStart] = useState<{ row: number; col: number } | null>(null); - const [selectionEnd, setSelectionEnd] = useState<{ row: number; col: number } | null>(null); - // Refs mirroring selection state so the useInput handler (which uses useEffectEvent) always - // reads the latest value without waiting for a React state flush cycle. - const selectionStartRef = useRef<{ row: number; col: number } | null>(null); - const selectionEndRef = useRef<{ row: number; col: number } | null>(null); const [streamingText, setStreamingText] = useState(null); const [isThinking, setIsThinking] = useState(false); const [permMode, setPermMode] = useState("default"); @@ -250,14 +162,6 @@ export function App({ // a pending permission prompt (via makeConfirmFn) — the same abort plumbing sub-agent timeouts // already used, just wired up to a top-level turn for the first time. const turnAbortRef = useRef(null); - // Wraps whichever branch the bottom ternary renders (permission/export prompt, a picker, or the - // normal StatusBar+ChatInput column) — measured (height only) so the history viewport above it - // knows exactly how much vertical space is left (the history viewport above it flexes). - const bottomSectionRef = useRef(null); - // Measures the history content's own natural (unclipped) height — Yoga still computes a child's - // intrinsic size even when its parent has a fixed height + overflowY:hidden, so this reports the - // *true* height regardless of clipping. Used to decide top-alignment vs bottom-alignment below. - const historyContentRef = useRef(null); // Static history items can't be retroactively expanded once printed (Ink's is // append-only), so Ctrl+O doesn't edit the collapsed compact notice in place — it prints a new // item with the full text on demand. This just remembers the most recent one to print. @@ -281,65 +185,6 @@ export function App({ // fixed width on the right — ChatInput can't derive this from its own measured width (see the // comment on its availableColumns prop), so it's computed once here and threaded down. const chatColumns = terminalColumns - (filePanelVisible ? FILE_PANEL_WIDTH : 0); - // The split between the history viewport and the bottom section is resolved by Yoga in a single - // layout pass (history grows/shrinks, bottom is fixed) rather than computed by hand from a - // measured bottom height — that measurement always lagged one frame behind the bottom section's - // actual height (useBoxMetrics updates in an effect *after* render), so on any frame it grew - // (a permission/export modal mounting, or the @-mention suggestion box opening in ChatInput) - // history was sized too tall and the bottom section overpainted it, producing the overlap. - // We still read the history viewport's own measured height, but only for the top-vs-bottom - // alignment decision — a one-frame lag there only affects alignment, never the split, so it's - // harmless (unlike the split, which is what caused the overlap). - const historyViewportRef = useRef(null); - const { height: measuredHistoryHeight } = useBoxMetrics(historyViewportRef); - const { height: historyContentHeight } = useBoxMetrics(historyContentRef); - const viewportHeight = Math.max(1, measuredHistoryHeight); - // How far (in rows) the content has scrolled past the viewport — the max meaningful scrollTop. - // Short conversations (content fits entirely) have maxScroll 0, which also naturally keeps them - // top-aligned instead of glued to the bottom with a gap above. - const maxScroll = Math.max(0, historyContentHeight - viewportHeight); - // Whether the view should keep tracking the latest content as it arrives (the normal chat - // behavior) or hold still at a manually scrolled position. PageUp breaks the pin; PageDown - // re-establishes it once scrolled back down to the bottom; sending a new message always re-pins. - const [pinnedToBottom, setPinnedToBottom] = useState(true); - // Rows scrolled down from the content's top edge — only meaningful while not pinned; while - // pinned, the effective scrollTop is just maxScroll (always show the latest content). - const [scrollTop, setScrollTop] = useState(0); - const effectiveScrollTop = pinnedToBottom ? maxScroll : Math.min(scrollTop, maxScroll); - // Mouse selection coordinate helper: converts SGR-1006 (col, row) terminal coordinates - // to content-row coordinates relative to the history viewport's content. SGR-1006 - // reports absolute terminal positions, but content rows start at 1 within the viewport. - // The history viewport's Y position in the terminal layout offsets the mouse row. - const mouseToContentRow = useCallback((terminalRow: number): number => { - const viewportY = historyViewportRef.current - ? getAbsolutePosition(historyViewportRef.current).y - : 0; - // Content row 1 = the first row visible inside the viewport when scrollTop=0. - // When scrolled, content row = (terminal row - viewport top) + effectiveScrollTop + 1. - return (terminalRow - Math.round(viewportY)) + effectiveScrollTopRef.current + 1; - }, []); - - // Refs mirroring the scroll-relevant values so the (once-registered) mouse-wheel listener can - // read the latest without re-binding on every state change (see mouseMode effect below). - const maxScrollRef = useRef(maxScroll); - maxScrollRef.current = maxScroll; - const pinnedToBottomRef = useRef(pinnedToBottom); - pinnedToBottomRef.current = pinnedToBottom; - const scrollTopRef = useRef(scrollTop); - scrollTopRef.current = scrollTop; - const effectiveScrollTopRef = useRef(effectiveScrollTop); - effectiveScrollTopRef.current = effectiveScrollTop; - - // Scroll the history viewport by `delta` rows (negative = up, positive = down), using the same - // pin/unpin rules as the PageUp/PageDown handler above. Shared by keyboard paging and the - // mouse-wheel listener so the two paths can't drift. - const scrollBy = useCallback((delta: number) => { - const ms = maxScrollRef.current; - const current = pinnedToBottomRef.current ? ms : Math.min(scrollTopRef.current, ms); - const next = Math.max(0, Math.min(ms, current + delta)); - setScrollTop(next); - setPinnedToBottom(next >= ms); - }, []); const flushStreamingText = useCallback(() => { const accumulated = streamingAccumulatorRef.current; @@ -365,26 +210,6 @@ export function App({ getGitInfo(cwd).then(setGitInfo).catch(() => setGitInfo(null)); }, [cwd]); - // Fetch once up front; re-fetched after each turn (see submitTurn) since a tool call (git_commit, - // bash) can switch branches or change the dirty state mid-session. - // Enable xterm mouse tracking (button-event mode 1002 + SGR-1006 format) so click, drag, and - // wheel events arrive on stdin as escape sequences. ink's input parser passes each mouse sequence through to useInput - // as a single event (with the leading ESC stripped from `input`), where we detect it below. - // Only enabled during the chat phase and only when raw mode is supported; toggling it off on exit - // (and on phase change) restores the terminal so the shell's own mouse mode isn't disturbed. - // Also suspended while the export filename prompt is open: that prompt's text field is - // ink-text-input (third-party) with no guard against raw mouse sequences, so scrolling while it's - // open would otherwise type them straight into the filename — simplest fix is to stop the - // terminal from sending them at all rather than filtering inside a dependency we don't control. - useEffect(() => { - if (!isRawModeSupported || phase !== "input" || exportPrompt || !mouseMode) return; - stdout.write("[?1002h[?1006h"); - setRawMode(true); - return () => { - stdout.write("[?1006l[?1002l"); - }; - }, [phase, isRawModeSupported, stdout, setRawMode, exportPrompt, mouseMode]); - // A backgrounded bash job (see Ctrl+B below) can finish long after the turn that started it has // ended — this is how its completion still reaches the transcript. useEffect(() => { @@ -418,60 +243,6 @@ export function App({ // Only react to global shortcuts during the actual chat phase; ignore them while a modal // (permission/export) or a non-input phase (model/session select, connecting) is open. if (phaseRef.current !== "input" || permission || exportPrompt) return; - // Mouse events (xterm SGR-1006 format). We handle: - // - Wheel up/down: scroll the transcript - // - Shift+click/drag: not captured — falls through so the terminal handles native - // text selection, which is the primary way to copy text in-app. - // All other mouse events (clicks, drags, releases) are swallowed so they don't fall through - // to text input. In the future, in-app text selection can be built on top of these events. - const mouseEvent = matchMouseSequence(input); - if (mouseEvent) { - // Shift+click/drag: don't capture — let the terminal handle native text selection. - if (mouseEvent.shift) return; - const btn = logicalButton(mouseEvent); - if (mouseEvent.button === 64) { scrollBy(-3); } // wheel up - else if (mouseEvent.button === 65) { scrollBy(3); } // wheel down - else if (btn === "left" && mouseEvent.pressed) { - // Left button press: start in-app text selection - const row = mouseToContentRow(mouseEvent.row); - const start = { row, col: mouseEvent.col }; - selectionStartRef.current = start; - selectionEndRef.current = start; - setSelectionStart(start); - setSelectionEnd(start); - push({ kind: "notice", text: "Selection started — drag to select text" }); - } else if (btn === "drag" && selectionStartRef.current) { - // SGR-1006 drag motion (bit 32 set): extend selection - const row = mouseToContentRow(mouseEvent.row); - const end = { row, col: mouseEvent.col }; - selectionEndRef.current = end; - setSelectionEnd(end); - } else if (btn === "release" && selectionStartRef.current) { - // Release: copy selection to clipboard, then clear it - const row = mouseToContentRow(mouseEvent.row); - const end = { row, col: mouseEvent.col }; - const start = selectionStartRef.current; - const from = start.row < end.row || (start.row === end.row && start.col <= end.col) ? start : end; - const to = start.row < end.row || (start.row === end.row && start.col <= end.col) ? end : start; - const text = extractSelectionText(staticItems, streamingText, from, to); - if (text) { - copyToClipboard(text, stdout); - push({ kind: "notice", text: `Copied ${text.split("\n").length} line(s) to clipboard` }); - } else { - push({ kind: "notice", text: "No text in selection range" }); - } - selectionStartRef.current = null; - selectionEndRef.current = null; - setSelectionStart(null); - setSelectionEnd(null); - } - return; - } - if (key.pageUp || key.pageDown) { - const pageStep = Math.max(1, viewportHeight - 1); - scrollBy(key.pageUp ? -pageStep : pageStep); - return; - } if (key.ctrl && input === "f") { // Three-state cycle: hidden -> open+focused -> open+unfocused (via Escape, not here) -> hidden. // Pressing Ctrl+F while open-but-unfocused (the Escape state) re-focuses it instead of hiding @@ -923,7 +694,6 @@ export function App({ setInputValue(""); const trimmed = raw.trim(); if (!trimmed) return; - setPinnedToBottom(true); const session = sessionRef.current; if (!session) return; @@ -1141,22 +911,6 @@ export function App({ } return; } - if (trimmed.startsWith("/mouse")) { - const arg = trimmed.slice("/mouse".length).trim().toLowerCase(); - if (!arg) { - push({ kind: "notice", text: `Mouse: ${mouseMode ? "on" : "off"}. Use /mouse on|off. When on, wheel scrolls and click+drag selects text (copied to clipboard on release). Shift+click/drag for native terminal selection. When off, native selection works but wheel does nothing in-app.` }); - } else if (arg === "on") { - setMouseMode(true); - push({ kind: "notice", text: "Mouse on — wheel scrolls, click+drag selects text (auto-copied to clipboard on release). Shift+click/drag for native terminal selection." }); - } else if (arg === "off") { - setMouseMode(false); - push({ kind: "notice", text: "Mouse off — native terminal selection/copy works. Use PageUp/PageDown to scroll, or /mouse on to re-enable wheel scroll." }); - } else { - push({ kind: "notice", text: `Unknown option "${arg}". Use /mouse on or /mouse off.`, isError: true }); - } - return; - } - // UserPromptSubmit hooks see the raw text before plugin-command expansion or @mention // resolution — a hook can block the message outright, or inject extra context (appended // below wherever the message actually ends up going). @@ -1270,38 +1024,20 @@ export function App({ } return ( - - - {/* Scrollable, bottom-pinned viewport: overflowY="hidden" clips the content box, which is - * shifted up by a negative marginTop equal to effectiveScrollTop rows — at maxScroll (the - * pinned-to-bottom default) that puts the *latest* content flush against the bottom edge, - * auto-scrolling to it without any input; PageUp/PageDown (see the useInput handler above) - * unpin and walk scrollTop up/down a page at a time. This replaced (permanent - * one-shot scrollback printing) because Static's already-flushed rows never participate in - * Yoga layout again, which is fundamentally incompatible with letting old items visually - * scroll within a *bounded* viewport. Trade-off: every item re-renders on every frame now - * (Static rendered each item exactly once, ever) — fine at the sizes a single session - * reaches before auto-compaction, but worth knowing if a session gets huge. */} - - {/* flexShrink={0} is load-bearing: Yoga's default flexShrink is nonzero, so without this the - * content box (and every item inside it) gets squeezed down to the viewport's height instead - * of clipped at it — Yoga distributes the deficit proportionally across every child, which - * rounds most rows down to zero height and leaves only a handful of survivors, rendering as - * scrambled/decimated lines instead of a clean top slice or bottom slice of real content. */} - - {staticItems.map((item) => ( - - ))} - {streamingText !== null && ( - - )} - {isThinking && streamingText === null && !permission && !exportPrompt && ( - - )} - - - - + <> + {/* Finished history prints once, directly to the terminal's real scrollback — never + * re-rendered, never height-clipped — which is what makes the terminal's own native mouse + * wheel scroll and click-drag text selection/copy work with zero app-side mouse tracking or + * virtual-scroll code (see index.tsx for why locode also stays off the alternate screen). */} + {(item) => } + + + {streamingText !== null && ( + + )} + {isThinking && streamingText === null && !permission && !exportPrompt && ( + + )} {permission ? ( ) : ( <> - {!pinnedToBottom && sessionRef.current && phaseRef.current === "input" && ( - ── scrolled up · PageDown to jump to latest ── - )} {sessionRef.current && phaseRef.current === "input" && ( )} @@ -1357,16 +1091,17 @@ export function App({ )} + setFilePanelFocused(false)} + /> - setFilePanelFocused(false)} - /> - + ); } diff --git a/src/ui/ink/ChatInput.tsx b/src/ui/ink/ChatInput.tsx index 3034187..aae70bd 100644 --- a/src/ui/ink/ChatInput.tsx +++ b/src/ui/ink/ChatInput.tsx @@ -5,7 +5,6 @@ import stringWidth from "string-width"; import { getAbsolutePosition } from "./absolutePosition.js"; import { ACCENT_HEX } from "../theme.js"; import { getActiveMention } from "../../utils/mentions.js"; -import { matchMouseSequence } from "./mouseInput.js"; // Bracketed paste markers emitted by terminals when the user pastes text. const BP_START = "\x1b[200~"; @@ -248,12 +247,6 @@ export function ChatInput({ value, onChange, onSubmit, cwd, history = [], availa }, [value, cursorOffset, replaceValue]); useInput((input, key) => { - // App.tsx's own useInput (mounted for the whole app) already handles xterm SGR mouse sequences - // (wheel scroll, clicks) for the history viewport — but ink broadcasts every raw stdin event to - // every active useInput hook, so this component sees the same sequence too. Without this guard - // it falls through to the catch-all `if (input)` below and types the raw escape text into the - // chat box on every scroll notch. See mouseInput.ts. - if (matchMouseSequence(input)) return; // Shift+Tab (cycle permission mode) is handled globally in App.tsx now, not here — see its // useInput handler for why. if (key.shift && key.tab) return; diff --git a/src/ui/ink/FilePanel.tsx b/src/ui/ink/FilePanel.tsx index b1e2c73..f9321ab 100644 --- a/src/ui/ink/FilePanel.tsx +++ b/src/ui/ink/FilePanel.tsx @@ -21,6 +21,11 @@ interface Props { cwd: string; touchedFiles: TouchedFile[]; width: number; + /** Explicit height (in rows) for the panel — it's no longer nested inside a fixed-height + * ancestor (App.tsx's history now flows into the terminal's own scrollback via + * rather than a bounded viewport), so this panel sizes itself instead of inheriting a height + * to flexGrow against. */ + height: number; /** Whether the panel currently owns keyboard input (App.tsx disables ChatInput's own useInput * while this is true, so the same arrow/Enter/Escape keystroke doesn't do both at once). */ focused: boolean; @@ -93,7 +98,7 @@ const IGNORE = ["node_modules/**", ".git/**", "dist/**"]; // tree that's too big to show at once — this cap is just a hard ceiling under that. const MAX_FILES = 2000; -export function FilePanel({ visible, activeTab, cwd, touchedFiles, width, focused, onExitFocus }: Props) { +export function FilePanel({ visible, activeTab, cwd, touchedFiles, width, height, focused, onExitFocus }: Props) { const [allFiles, setAllFiles] = useState(null); const [collapsedPaths, setCollapsedPaths] = useState>(new Set()); const [selectedIndex, setSelectedIndex] = useState(0); @@ -193,6 +198,7 @@ export function FilePanel({ visible, activeTab, cwd, touchedFiles, width, focuse flexDirection="column" flexShrink={0} width={width} + height={height} borderStyle="single" borderColor={focused ? ACCENT_HEX : "gray"} paddingX={1} diff --git a/src/ui/ink/HistoryItemView.tsx b/src/ui/ink/HistoryItemView.tsx index 4a55b06..5095619 100644 --- a/src/ui/ink/HistoryItemView.tsx +++ b/src/ui/ink/HistoryItemView.tsx @@ -35,8 +35,8 @@ const HELP_LINES = [ " Ctrl+F open/focus the file panel; press again to close it (Esc to unfocus without closing)", " Ctrl+G switch the file panel's tab (Files / Activity)", " ↑↓ ↵ ← → (while the file panel is focused) navigate / expand / collapse folders", - " PageUp/PageDown scroll the conversation", - " Mouse wheel scroll (click+drag to select/copy text in-app)", + " Mouse wheel / click+drag your terminal's own native scroll and text selection/copy — locode", + " doesn't intercept the mouse, so this always works like any other CLI", ]; function formatDuration(ms: number): string { diff --git a/src/ui/ink/StatusBar.tsx b/src/ui/ink/StatusBar.tsx index baeebbc..a00705a 100644 --- a/src/ui/ink/StatusBar.tsx +++ b/src/ui/ink/StatusBar.tsx @@ -32,6 +32,7 @@ interface Props { createdAt: string; inputTokens: number; outputTokens: number; + modelTimeMs: number; gitInfo: GitInfo | null; } @@ -75,6 +76,7 @@ export function StatusBar({ createdAt, inputTokens, outputTokens, + modelTimeMs, gitInfo, }: Props) { // Ticks every 30s purely to keep "elapsed"/burn-rate live while otherwise idle — no other state @@ -91,11 +93,11 @@ export function StatusBar({ const elapsedMs = Math.max(0, now - new Date(createdAt).getTime()); const elapsedMinutes = elapsedMs / 60_000; - // Output-only: inputTokens sums the *full resent conversation context* on every API call (no - // prompt caching for local backends), so a chatty turn with many tool-call round trips inflates - // it by call count, not by anything the user would recognize as "burn rate". outputTokens/min - // reflects actual generation throughput instead. - const burnRate = elapsedMinutes >= 0.1 ? outputTokens / elapsedMinutes : null; + // Use model time (wall-clock time spent waiting on backend responses) rather than session + // elapsed time. A session open for 30 minutes with only 2 minutes of actual model interaction + // should show the real throughput, not a diluted rate that makes the model look slow. + const modelMinutes = modelTimeMs / 60_000; + const burnRate = modelMinutes >= 0.1 ? outputTokens / modelMinutes : null; return ( diff --git a/src/ui/ink/index.tsx b/src/ui/ink/index.tsx index eec8c22..a0d7de6 100644 --- a/src/ui/ink/index.tsx +++ b/src/ui/ink/index.tsx @@ -52,25 +52,12 @@ export async function runInkApp(opts: RunInkAppOptions): Promise { // Ink render tree so cleanup() can still read it after instance.unmount() has torn everything // in App.tsx down. let currentSessionId: string | null = null; - // Whether we've switched to the terminal's alternate screen buffer — tracked so cleanup() only - // switches back if we actually switched away, and so a second session starting mid-process - // (e.g. picking a different saved session via /resume) doesn't re-enter it redundantly. - let enteredAltScreen = false; - // Entering the alternate screen alone does not guarantee the cursor starts at the top-left — - // some terminals carry the cursor row over from the main screen, leaving blank rows above - // Ink's first output until enough content has been printed to push past that row. Clear the - // new (blank) buffer and explicitly home the cursor so content always starts flush at (0, 0). - const ALT_SCREEN_ENTER = "[?1049h" + "" + ""; - const ALT_SCREEN_EXIT = "[?1049l"; - - // Start the UI immediately — no blocking network calls before rendering. - // Model listing happens inside the App component so the user sees the UI right away. - // - // The model-select/connecting phases before a session exists stay on the main screen (so any - // startup errors remain in normal scrollback); once a session actually starts, we switch to the - // alternate screen buffer for a clean full-screen chat view. This trades away the terminal's own - // scrollback (no mouse-wheel/Shift+PgUp once in the alt screen) for that full-screen feel — App.tsx - // provides its own in-app scrollback instead (PageUp/PageDown over the history viewport). + // Start the UI immediately — no blocking network calls before rendering. Model listing happens + // inside the App component so the user sees the UI right away. Deliberately stays on the + // terminal's normal screen buffer (never the alternate screen) the whole time — finished + // history prints as permanent scrollback (see App.tsx's use of Ink's ), so the + // terminal's own native mouse-wheel scroll and click-drag text selection/copy just work, with + // no app-side mouse tracking or virtual-scroll machinery needed. const instance = render( { extraToolsPromise={extraToolsPromise} onSessionIdChange={(id) => { currentSessionId = id; - if (!enteredAltScreen) { - enteredAltScreen = true; - process.stdout.write(ALT_SCREEN_ENTER); - } }} />, ); @@ -99,12 +82,6 @@ export async function runInkApp(opts: RunInkAppOptions): Promise { const cleanup = async () => { if (cleanedUp) return; cleanedUp = true; - // Leave the alternate screen first (if we ever entered it) so everything printed below — - // hook errors, the resume hint — lands on the user's normal scrollback, not a screen that's - // about to disappear. - if (enteredAltScreen) { - process.stdout.write(ALT_SCREEN_EXIT); - } // Flush in-flight autosaves first so a fire-and-forget persist right before exit isn't lost. await flushPendingSaves(); // Best-effort: don't leave backgrounded shells (dev servers, watch builds) running as orphans. diff --git a/src/ui/ink/mouseInput.ts b/src/ui/ink/mouseInput.ts deleted file mode 100644 index ea6f5d3..0000000 --- a/src/ui/ink/mouseInput.ts +++ /dev/null @@ -1,105 +0,0 @@ -// xterm SGR-1006 mouse sequences (enabled in App.tsx via `\x1b[?1002h\x1b[?1006h`) -// arrive on stdin as a single ink `useInput` event with the leading ESC stripped, -// e.g. "[<0;12;5M" (left button press at col 12, row 5) or "[<64;12;5M" (wheel up). -// -// Mode 1002 (button-event tracking) reports: button press, button release, and -// drag (motion while a button is held). Every active `useInput` hook in the tree -// receives the same raw event — ink has no concept of one handler "consuming" it -// before others see it — so any text-input component that doesn't recognize the -// sequence falls through to its regular-character handling and types the raw -// escape text into the field. Shared so every `useInput` consumer can ignore it -// the same way App.tsx's own handler already does. -// -// SGR-1006 format: [\x07 (target 'c' = system clipboard) - const base64 = Buffer.from(text, "utf-8").toString("base64"); - // Write in chunks to avoid overflowing terminal input buffers (some terminals - // have limits around 4096 bytes for a single escape sequence). - const CHUNK = 300; - for (let i = 0; i < base64.length; i += CHUNK) { - const chunk = base64.slice(i, i + CHUNK); - // For the first chunk, start the sequence; for subsequent chunks, just append. - if (i === 0) { - stdout.write(`\x1b]52;c;${chunk}`); - } else { - stdout.write(chunk); - } - } - stdout.write("\x07"); // BEL terminates the sequence -} \ No newline at end of file diff --git a/tsup.config.ts b/tsup.config.ts index 3f9891b..c2f0d80 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ entry: ["src/cli.ts"], format: ["esm"], - target: "node20", + target: "node22", clean: true, banner: { js: "#!/usr/bin/env node",