feat: normal screen architecture, burn rate by model time, dependency upgrades
Major changes:
- Replace alternate screen buffer + app-side mouse tracking/virtual scroll
with normal screen buffer + Ink <Static> for permanent scrollback.
Terminal's native scroll/selection/copy just works — no mouseInput.ts needed.
- Fix burn rate (🔥) to use model response time (modelTimeMs) instead of
session elapsed time, so it reflects actual generation throughput.
- Upgrade dependencies: openai 6→7, commander 13→15, execa 9→10, vitest 3→4,
node types 22→26, tsup target node20→node22.
- Update upgrade memo with current architecture, all completed upgrades,
and working tree status.
This commit is contained in:
+145
-595
@@ -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 + `<Static>`
|
||||
**대폭 변경**: 이전 alternate screen buffer + 인앱 가상 스크롤 + 마우스 트래킹 아키텍처를 완전히 폐기.
|
||||
|
||||
- **Normal screen buffer**: `index.tsx`가 alternate screen에 진입하지 않음. 앱이 터미널의 일반 스크롤백에 직접 출력.
|
||||
- **Ink `<Static>`**: 완료된 턴/이벤트를 `<Static>` 컴포넌트로 한 번만 렌더링 → 터미널 스크롤백의 영구 부분이 됨. 재렌더링 없음.
|
||||
- **마우스 트래킹 완전 제거**: `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 컴포넌트. `<Static>` + 라이브 영역. 상태: 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 <path>`로 인코딩 확인; 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 + `<Static>` 아키텍처로 전환 |
|
||||
| `src/ui/ink/App.tsx` | 마우스/가상스크롤 코드 제거, `<Static>` 기반 렌더링으로 재구조화 |
|
||||
| `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 `<Static>` 컴포넌트 — 완료된 히스토리 아이템을 한 번만 렌더링, 재렌더링 없음
|
||||
- 마우스 트래킹 없음 — 터미널 자체 네이티브 스크롤/선택/복사에 의존
|
||||
- `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 <number>` (예: 200). 정지 시 작업 내용은 보존되므로 "continue"로 이어서 진행 가능.
|
||||
- "자꾸 에러"의 주요 원인: max_tokens 잘림 → malformed 툴콜 (✅ 해결됨 — 동적 max_tokens + CRLF 제어문자 이스케이프)
|
||||
- CRLF 파일 edit_file 매칭 버그: ✅ 해결됨 (LF 정규화 공간에서 매칭, 쓰기 전 원래 EOL 복원)
|
||||
- "Paused after N steps": `locode config set maxIterations <number>` (기본 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 '<json>'` (키=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 <number>`로 조절 가능
|
||||
- 출력이 띄엄띄엄한 원인: 모델이 툴콜 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<string, unknown>`
|
||||
- 런타임 동작은 동일 (속성 접근 시 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 재구조화)
|
||||
Generated
+829
-255
File diff suppressed because it is too large
Load Diff
+12
-12
@@ -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
|
||||
|
||||
+66
-4
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+61
-1
@@ -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."
|
||||
|
||||
@@ -150,12 +150,35 @@ function toLocation(loc: Location): LocResult {
|
||||
async function startServer(spec: LanguageSpec, cwd: string): Promise<LspHandle> {
|
||||
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<void>((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<LspHandle>
|
||||
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", () => {
|
||||
|
||||
+11
-6
@@ -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
|
||||
|
||||
+19
-3
@@ -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;
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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<string, SessionSummary> {
|
||||
let index = new Map<string, SessionSummary>();
|
||||
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<string, SessionSummary>): 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<string, Promise<void>>();
|
||||
* otherwise load as "no session found" (see loadSession, which treats an unparseable file as absent). */
|
||||
export async function saveSession(record: SessionRecord): Promise<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
+27
-292
@@ -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<PendingPermission | null>(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<string | null>(null);
|
||||
const [isThinking, setIsThinking] = useState(false);
|
||||
const [permMode, setPermMode] = useState<PermissionMode>("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<AbortController | null>(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<DOMElement | null>(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<DOMElement | null>(null);
|
||||
// Static history items can't be retroactively expanded once printed (Ink's <Static> 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<DOMElement | null>(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 (
|
||||
<Box flexDirection="row" width="100%" height={terminalRows} overflow="hidden">
|
||||
<Box flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
|
||||
{/* 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 <Static> (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. */}
|
||||
<Box ref={historyViewportRef} flexDirection="column" flexGrow={1} flexShrink={1} overflowY="hidden">
|
||||
{/* 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. */}
|
||||
<Box flexDirection="column" flexShrink={0} ref={historyContentRef} marginTop={-effectiveScrollTop}>
|
||||
{staticItems.map((item) => (
|
||||
<HistoryItemView key={item.id} item={item} />
|
||||
))}
|
||||
{streamingText !== null && (
|
||||
<HistoryItemView item={{ id: "streaming", kind: "streaming_text", text: streamingText }} />
|
||||
)}
|
||||
{isThinking && streamingText === null && !permission && !exportPrompt && (
|
||||
<ThinkingIndicator label={runningToolIsBash ? "thinking... (ctrl+b to background)" : undefined} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" ref={bottomSectionRef} flexShrink={0}>
|
||||
<>
|
||||
{/* 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). */}
|
||||
<Static items={staticItems}>{(item) => <HistoryItemView key={item.id} item={item} />}</Static>
|
||||
<Box flexDirection="row" width="100%" alignItems="flex-end">
|
||||
<Box flexDirection="column" flexGrow={1} flexShrink={1}>
|
||||
{streamingText !== null && (
|
||||
<HistoryItemView item={{ id: "streaming", kind: "streaming_text", text: streamingText }} />
|
||||
)}
|
||||
{isThinking && streamingText === null && !permission && !exportPrompt && (
|
||||
<ThinkingIndicator label={runningToolIsBash ? "thinking... (ctrl+b to background)" : undefined} />
|
||||
)}
|
||||
{permission ? (
|
||||
<PermissionPrompt
|
||||
toolName={permission.toolName}
|
||||
@@ -1323,9 +1059,6 @@ export function App({
|
||||
<ModelSelect models={modelList} currentModel={suggestedModel} onSelect={handleModelSelect} />
|
||||
) : (
|
||||
<>
|
||||
{!pinnedToBottom && sessionRef.current && phaseRef.current === "input" && (
|
||||
<Text dimColor>── scrolled up · PageDown to jump to latest ──</Text>
|
||||
)}
|
||||
{sessionRef.current && phaseRef.current === "input" && (
|
||||
<StatusBar
|
||||
model={sessionRef.current.model}
|
||||
@@ -1339,6 +1072,7 @@ export function App({
|
||||
createdAt={sessionRef.current.createdAt}
|
||||
inputTokens={sessionRef.current.stats.inputTokens}
|
||||
outputTokens={sessionRef.current.stats.outputTokens}
|
||||
modelTimeMs={sessionRef.current.stats.modelTimeMs}
|
||||
gitInfo={gitInfo}
|
||||
/>
|
||||
)}
|
||||
@@ -1357,16 +1091,17 @@ export function App({
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
<FilePanel
|
||||
visible={filePanelVisible}
|
||||
activeTab={filePanelTab}
|
||||
cwd={cwd}
|
||||
touchedFiles={touchedFilesList}
|
||||
width={FILE_PANEL_WIDTH}
|
||||
height={terminalRows}
|
||||
focused={filePanelFocused}
|
||||
onExitFocus={() => setFilePanelFocused(false)}
|
||||
/>
|
||||
</Box>
|
||||
<FilePanel
|
||||
visible={filePanelVisible}
|
||||
activeTab={filePanelTab}
|
||||
cwd={cwd}
|
||||
touchedFiles={touchedFilesList}
|
||||
width={FILE_PANEL_WIDTH}
|
||||
focused={filePanelFocused}
|
||||
onExitFocus={() => setFilePanelFocused(false)}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <Static>
|
||||
* 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<string[] | null>(null);
|
||||
const [collapsedPaths, setCollapsedPaths] = useState<Set<string>>(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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<Box flexDirection="column" width="100%" paddingX={1}>
|
||||
|
||||
+6
-29
@@ -52,25 +52,12 @@ export async function runInkApp(opts: RunInkAppOptions): Promise<void> {
|
||||
// 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" + "[2J" + "[H";
|
||||
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 <Static>), 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(
|
||||
<App
|
||||
baseURL={opts.baseURL}
|
||||
@@ -83,10 +70,6 @@ export async function runInkApp(opts: RunInkAppOptions): Promise<void> {
|
||||
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<void> {
|
||||
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.
|
||||
|
||||
@@ -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: [<button;col;rowM (press/drag) or [<button;col;rowm (release)
|
||||
// button encodes button number + modifier keys:
|
||||
// bit 0: shift
|
||||
// bit 1: meta (alt)
|
||||
// bit 2: ctrl
|
||||
// bits 4-5: 00=left, 01=middle, 10=right
|
||||
// Special: 64=wheel up, 65=wheel down
|
||||
// Drag reports button 3 (all buttons released) during motion.
|
||||
|
||||
const MOUSE_SEQUENCE_RE = /^\[<(\d+);(\d+);(\d+)([Mm])$/;
|
||||
|
||||
export interface MouseEvent {
|
||||
/** Raw button code (includes modifier bits in bits 0-2, button in bits 4-5). */
|
||||
button: number;
|
||||
/** 1-based column. */
|
||||
col: number;
|
||||
/** 1-based row. */
|
||||
row: number;
|
||||
/** True on press/drag (trailing 'M'), false on release (trailing 'm'). */
|
||||
pressed: boolean;
|
||||
/** True if shift was held. */
|
||||
shift: boolean;
|
||||
/** True if meta (alt) was held. */
|
||||
meta: boolean;
|
||||
/** True if ctrl was held. */
|
||||
ctrl: boolean;
|
||||
}
|
||||
|
||||
/** Which logical button is pressed (ignoring modifiers).
|
||||
*
|
||||
* SGR-1006 button encoding (with modifiers stripped by ~0x07):
|
||||
* 0 = left button press (no motion bit)
|
||||
* 1 = middle button press
|
||||
* 2 = right button press
|
||||
* 3 = button release (in SGR mode, trailing 'm')
|
||||
* 32 = motion/drag bit — added to the base button code during drag:
|
||||
* 32 = left-drag, 33 = middle-drag, 34 = right-drag
|
||||
* 64 = wheel up, 65 = wheel down
|
||||
*
|
||||
* When a button is released, the protocol sends button=3 with trailing 'm',
|
||||
* regardless of which button was held. */
|
||||
export function logicalButton(e: MouseEvent): "left" | "middle" | "right" | "wheel-up" | "wheel-down" | "release" | "drag" | "other" {
|
||||
const raw = e.button & ~0x07; // strip modifier bits (shift=4, meta=8, ctrl=16)
|
||||
if (raw === 0 && e.pressed) return "left";
|
||||
if (raw === 1 && e.pressed) return "middle";
|
||||
if (raw === 2 && e.pressed) return "right";
|
||||
if (raw === 32) return "drag"; // left-drag (most common)
|
||||
if (raw === 33) return "drag"; // middle-drag
|
||||
if (raw === 34) return "drag"; // right-drag
|
||||
if (raw === 64) return "wheel-up";
|
||||
if (raw === 65) return "wheel-down";
|
||||
// SGR-1006 release: button=3 with trailing 'm' (pressed=false)
|
||||
if (!e.pressed) return "release";
|
||||
return "other";
|
||||
}
|
||||
|
||||
export function matchMouseSequence(input: string): MouseEvent | null {
|
||||
const match = MOUSE_SEQUENCE_RE.exec(input);
|
||||
if (!match) return null;
|
||||
const button = Number(match[1]);
|
||||
const col = Number(match[2]);
|
||||
const row = Number(match[3]);
|
||||
const pressed = match[4] === "M";
|
||||
return {
|
||||
button,
|
||||
col,
|
||||
row,
|
||||
pressed,
|
||||
shift: !!(button & 4),
|
||||
meta: !!(button & 8),
|
||||
ctrl: !!(button & 16),
|
||||
};
|
||||
}
|
||||
|
||||
/** Write text to the system clipboard using OSC 52 (works over SSH, tmux, etc.).
|
||||
* Falls back to platform-specific commands if OSC 52 doesn't work. */
|
||||
export function copyToClipboard(text: string, stdout: NodeJS.WriteStream): void {
|
||||
// OSC 52: \x1b]52;c;<base64>\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
|
||||
}
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user