Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ same table the app dispatches from, so it cannot drift from what actually happen

## Design principles

- **Provider-agnostic** — Claude, OpenAI, Grok, and GLM today, behind a swappable adapter
- **Provider-agnostic** — Claude, OpenAI, Grok, GLM and Gemini today, plus any OpenAI-compatible
server you point it at, all behind a swappable adapter
- **Observable by default** — every AI action surfaces in the UI
- **Human-in-the-loop** — every change can be accepted, rejected, or reverted
- **Progressive fidelity** — validate the experience first, then deepen it at the editor core
Expand All @@ -121,8 +122,19 @@ npm run dist:linux # Linux build
## Roadmap

- **Phase 1** — validate the core experience *(done)*
- **Phase 2** — deepen renderer-level visual effects at the editor core *(in progress)*
- **Phase 3** — more providers (Gemini, local/OpenAI-compatible endpoints) and codebase indexing
- **Phase 2** — the four editing-visualization pillars *(done)*
- **Phase 3** — more providers *(done)* and codebase indexing *(next, as a 0.4 preview)*

Phase 2 was originally written as "fork Code-OSS", on the assumption that the visual effects
were impossible through the extension API. That fork never happened and should not: the app
has its own renderer already (Electron + React + Monaco), so the reason for forking never
applied. [docs/PHASE2-SURVEY.md](docs/PHASE2-SURVEY.md) audits the four pillars against the
code and closes the fork item as not-applicable. The three things the design named and the
code lacked — a ghost cursor, a rationale tooltip on the changed line, and accept/reject
CodeLens — now exist.

Providers: Claude, GPT, Grok, GLM, Gemini, and any OpenAI-compatible server you point it at —
Ollama, LM Studio, llama.cpp, or a corporate proxy. A local server needs an address, not a key.

The ecosystem work that used to sit in Phase 3 has shipped: VS Code extensions, Claude Code
skills and subagents, MCP servers over stdio and HTTP, and `.mcpb` bundles are all in the app
Expand Down
26 changes: 18 additions & 8 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,12 +142,22 @@ AI의 편집은 즉시 파일에 쓰이지 않고 **트랜잭션**으로 관리
- 멀티파일 오버뷰 Webview (2.4)
- **검증 질문**: 확장 API로 어디까지 "멋지게" 되는가? 무엇이 답답한가?

### Phase 2 — 포크(Fork)로 이전
> 목표: 확장으로 불가능했던 시각 효과를 위해 Code-OSS 포크. 진짜 차별화 완성.
- Code-OSS / VSCodium 기반 브랜딩·빌드 파이프라인
- 에디터 렌더러 레벨의 부드러운 타이핑 애니메이션 (2.1 완성)
- 확장 코드를 코어 기능으로 통합
- 배포(자동 업데이트, 서명 등)
### Phase 2 — 자체 렌더러 위의 시각화 *(완료, 2026-08-05)*
> 원래 계획은 "Code-OSS 포크" 였다. **하지 않았고, 하지 않는 것이 맞다.**
> 확장 API 로 렌더러에 손댈 수 없다는 것이 포크의 이유였는데, 이 앱은
> Electron + React + Monaco 로 처음부터 자기 렌더러를 갖고 있어 그 이유가 없다.
> 판정과 근거는 [PHASE2-SURVEY.md](PHASE2-SURVEY.md).

- ~~Code-OSS / VSCodium 기반 브랜딩·빌드 파이프라인~~ — 해당 없음(포크 안 함)
- 타이핑 애니메이션 + 글로우·잔광 + **유령 커서** (2.1)
- **변경 사유 툴팁**과 **수락/거절 CodeLens** — 바뀌는 그 줄에서 (2.2)
- 작업 상태·계획 패널 (2.3)
- 멀티파일 변경 개요 (2.4)
- 배포: 설치본 5종(서명은 아직, 자동 업데이트는 의도적으로 안 씀)

남은 것 하나 — 2.1 의 "스트리밍 삽입" 은 지금 **완성된 텍스트의 재생**이라 모델이
실제로 쓰는 속도와 무관하다. 진짜 토큰 스트림을 흘리려면 제안·승인 흐름 자체를
바꿔야 해서, 시각 효과가 아니라 별건으로 남긴다.

### Phase 3 — 확장·생태계
- 멀티 프로바이더 완성
Expand All @@ -162,7 +172,7 @@ AI의 편집은 즉시 파일에 쓰이지 않고 **트랜잭션**으로 관리
|---|---|---|
| 언어 | TypeScript | VSCode 생태계 표준 |
| Phase 1 베이스 | VSCode Extension API | 빠른 검증 |
| Phase 2 베이스 | Code-OSS (VSCodium 방식) | 포크 |
| Phase 2 베이스 | ~~Code-OSS (VSCodium 방식)~~ → Electron + React + Monaco | 포크하지 않음 — 렌더러가 이미 우리 것 |
| UI (패널·오버뷰) | Webview + (React 등) | 커스텀 시각화 자유도 |
| AI 통신 | 스트리밍 HTTP/SSE | 실시간성 필수 |
| 상태 관리 | (미정) | 오케스트레이터 이벤트 기반 |
Expand All @@ -172,7 +182,7 @@ AI의 편집은 즉시 파일에 쓰이지 않고 **트랜잭션**으로 관리
## 6. 리스크 & 열린 질문

**리스크**
- **R1. 확장 API의 시각 표현 한계** — 2.1의 매끄러운 애니메이션이 확장에서 기대만큼 안 나올 수 있음. → Phase 1에서 조기 검증, 안 되면 Phase 2 앞당김.
- **R1. 확장 API의 시각 표현 한계** — 2.1의 매끄러운 애니메이션이 확장에서 기대만큼 안 나올 수 있음. → Phase 1에서 조기 검증, 안 되면 Phase 2 앞당김. **(해소: 확장이 아니라 자체 렌더러로 갔다. 애니메이션·유령 커서·CodeLens 모두 우리 코드다.)**
- **R2. 포크 유지보수 부담** — VSCode 업스트림 추종 비용. VSCodium 사례 참고로 최소화.
- **R3. 실시간 스트리밍 편집의 충돌** — 사용자가 편집 중인데 AI도 편집하면? → 트랜잭션 모델 + 락/머지 정책 필요.

Expand Down
17 changes: 17 additions & 0 deletions ide/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import { XtermView } from "./editor/XtermView";
import { ImagePane, MarkdownPane, isImage, mdToHtml } from "./editor/MediaPane";
import monaco, { languageOf, applyTsPaths, revalidateTs } from "./editor/monacoSetup";
import * as projectModels from "./editor/projectModels";
import * as proposalDeco from "./editor/proposalDeco";
import { typeEdit, reducedMotion } from "./editor/editAnimator";
import * as lspClient from "./editor/lspClient";
import * as lspConv from "./editor/lspConverters";
Expand Down Expand Up @@ -4963,6 +4964,19 @@ ${(r.output || "").slice(0, 2000)}`;
return parseFindings(out);
}

/** 대기 중인 제안을 편집기 위에 반영한다 — 사유 툴팁과 수락/거절 렌즈. */
private syncProposalMarks() {
proposalDeco.ensureRegistered({
accept: id => void this.acceptProposal(id),
reject: id => this.rejectProposal(id),
});
proposalDeco.refresh(
this.state.proposals
.filter(p => p.status === "pending" && !this.parseDiffKey(p.rel))
.map(p => ({ id: p.id, rel: p.rel, find: p.find, rationale: p.rationale, agent: p.agent, range: p.range ?? null })),
);
}

componentDidMount() {
window.addEventListener("resize", this._clampChatOnResize);
applyTheme(getThemeId());
Expand Down Expand Up @@ -5899,6 +5913,9 @@ ${(r.output || "").slice(0, 2000)}`;
this.setState({ openingPhase: "intro" });
}
if (_ps) this._restoreClosedFocus(_ps);
// 대기 중인 제안을 코드 옆에 그린다(사유 툴팁 + 수락/거절 CodeLens).
// 목록이 실제로 바뀐 판에만 — 매 렌더마다 CodeLens 를 다시 요청하면 깜빡인다.
if (_ps && _ps.proposals !== this.state.proposals) this.syncProposalMarks();
// 저장 안 한 파일 목록을 메인에 맞춰 둔다 — 종료를 붙잡을지 여기서 정해진다.
this.reportDirty();
// 모드가 바뀌면 Monaco 를 다시 재어준다. automaticLayout 은 display:none 안에서
Expand Down
17 changes: 13 additions & 4 deletions ide/src/editor/editAnimator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,19 @@ export async function typeEdit(
const head = posAt(model, startOffset + written);
if (editor && !editor.getModel()?.isDisposed()) {
// 쓰고 있는 줄을 강조 — git 거터와 별개 채널
decoIds = editor.deltaDecorations(decoIds, [{
range: new monaco.Range(head.lineNumber, 1, head.lineNumber, 1),
options: { isWholeLine: true, className: "sz-ai-typing", overviewRuler: undefined },
}]);
decoIds = editor.deltaDecorations(decoIds, [
{
range: new monaco.Range(head.lineNumber, 1, head.lineNumber, 1),
options: { isWholeLine: true, className: "sz-ai-typing", overviewRuler: undefined },
},
// 유령 커서 — AI 가 **지금 어느 글자에** 쓰고 있는지. 줄 하이라이트만으로는
// 긴 줄에서 눈이 어디를 따라가야 할지 알 수 없다. 길이 0 범위에 붙이는
// 가짜 요소라 텍스트를 밀지 않는다.
{
range: new monaco.Range(head.lineNumber, head.column, head.lineNumber, head.column),
options: { beforeContentClassName: "sz-ai-caret" },
},
]);
if (opts.reveal !== false) editor.revealLineInCenterIfOutsideViewport(head.lineNumber, 0 /* Smooth */);
}
await delay(tickMs);
Expand Down
121 changes: 121 additions & 0 deletions ide/src/editor/proposalDeco.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// 아직 수락하지 않은 제안을 **코드 옆에** 보여 준다.
//
// 지금까지 제안은 오른쪽 카드에만 있었다. 무엇이 왜 바뀌는지 보려면 코드에서 눈을
// 떼야 했고, 바꿀지 말지도 거기서만 눌렀다. 설계(DESIGN.md 2.2)가 적어 둔 자리는
// 바뀌는 그 줄이다 — 사유는 툴팁으로, 수락·거절은 CodeLens 로.
//
// 자리를 찾는 일은 review/proposalMarks.ts 가 한다. 여기는 화면에 얹기만 한다.

import monaco from "./monacoSetup";
import * as projectModels from "./projectModels";
import { locate, markTooltip } from "../review/proposalMarks";
import { t } from "../i18n";

export interface PendingProposal {
id: string;
rel: string;
find: string;
rationale: string;
agent: string;
range?: { startLineNumber: number; startColumn: number; endLineNumber: number; endColumn: number } | null;
}

export interface ProposalHandlers {
accept: (id: string) => void;
reject: (id: string) => void;
}

let pending: PendingProposal[] = [];
let handlers: ProposalHandlers | null = null;
let registered = false;
// Monaco 의 CodeLensProvider.onDidChange 는 프로바이더 자신을 실어 보낸다.
const changed = new monaco.Emitter<monaco.languages.CodeLensProvider>();
/** 모델 uri → 지금 그려 둔 툴팁 데코레이션 */
const marks = new Map<string, string[]>();

/** 이 모델에 걸린 제안들과 그 자리. 못 찾은 것은 뺀다 — 틀린 자리에 그리느니 안 그린다. */
function marksFor(model: monaco.editor.ITextModel): { p: PendingProposal; at: ReturnType<typeof locate> }[] {
const rel = projectModels.relFor(model.uri.toString());
if (!rel) return [];
const text = model.getValue();
const out: { p: PendingProposal; at: ReturnType<typeof locate> }[] = [];
for (const p of pending) {
if (p.rel !== rel) continue;
const at = locate({ text, find: p.find, range: p.range ?? null });
if (at) out.push({ p, at });
}
return out;
}

/** 한 번만 등록한다. 앱이 살아 있는 동안 목록만 갈아 끼운다. */
export function ensureRegistered(h: ProposalHandlers): void {
handlers = h;
if (registered) return;
registered = true;

// CodeLens 의 command 는 id 로 부른다 — 렌즈마다 클로저를 넘길 수 없다.
monaco.editor.registerCommand("schutz.acceptProposal", (_a, id: string) => { handlers?.accept(String(id)); });
monaco.editor.registerCommand("schutz.rejectProposal", (_a, id: string) => { handlers?.reject(String(id)); });

const provider: monaco.languages.CodeLensProvider = {
onDidChange: changed.event,
provideCodeLenses: (model) => {
const lenses: monaco.languages.CodeLens[] = marksFor(model).flatMap(({ p, at }) => ([
{
range: { startLineNumber: at!.startLineNumber, startColumn: 1, endLineNumber: at!.startLineNumber, endColumn: 1 },
id: "acc-" + p.id,
command: { id: "schutz.acceptProposal", title: "✓ " + t("misc.accept"), arguments: [p.id] },
},
{
range: { startLineNumber: at!.startLineNumber, startColumn: 1, endLineNumber: at!.startLineNumber, endColumn: 1 },
id: "rej-" + p.id,
command: { id: "schutz.rejectProposal", title: "✕ " + t("misc.reject"), arguments: [p.id] },
},
]));
return { lenses, dispose: () => { /* 목록만 넘긴다 */ } };
},
};
fireChange = () => changed.fire(provider);
monaco.languages.registerCodeLensProvider({ scheme: "file" }, provider);
}

/** 등록 전에 refresh 가 오면 쏠 곳이 없다 — 등록될 때 채워진다. */
let fireChange: () => void = () => { /* 아직 등록 전 */ };

/** 제안 목록이 바뀔 때마다 부른다 — 렌즈를 새로 요청하게 하고 툴팁을 다시 그린다. */
export function refresh(list: PendingProposal[]): void {
pending = list;
fireChange();
for (const model of monaco.editor.getModels()) {
if (model.isDisposed()) continue;
const key = model.uri.toString();
const decos = marksFor(model).flatMap(({ p, at }) => {
const hover = markTooltip(p.rationale, p.agent);
if (!hover) return [];
return [{
range: new monaco.Range(at!.startLineNumber, at!.startColumn, at!.endLineNumber, at!.endColumn),
options: {
className: "sz-prop-pending",
hoverMessage: { value: hover },
// 개요 눈금에도 표시 — 긴 파일에서 제안이 어디 있는지 스크롤 없이 보인다.
overviewRuler: { color: "rgba(196,168,130,.7)", position: monaco.editor.OverviewRulerLane.Right },
},
}];
});
const prev = marks.get(key) ?? [];
if (!prev.length && !decos.length) continue;
marks.set(key, model.deltaDecorations(prev, decos));
}
}

/** 워크스페이스를 닫을 때 등 — 그려 둔 것을 걷는다. */
export function clearAll(): void {
pending = [];
for (const model of monaco.editor.getModels()) {
const key = model.uri.toString();
const prev = marks.get(key);
if (prev?.length && !model.isDisposed()) { try { model.deltaDecorations(prev, []); } catch { /* */ } }
}
marks.clear();
fireChange();
}
24 changes: 24 additions & 0 deletions ide/src/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,30 @@ button:disabled { opacity: .55; cursor: default !important; }
.sz-ai-typing 은 지금 쓰고 있는 줄, .sz-ai-changed 는 다 쓴 뒤 잠깐 남는 잔광. */
.sz-ai-typing { background: color-mix(in srgb, var(--accent) 15%, transparent); }
.sz-ai-changed { background: color-mix(in srgb, var(--accent) 9%, transparent); animation: szAiFade 1.4s var(--ease) both; }
/* 유령 커서 — AI 가 쓰고 있는 바로 그 자리. 길이 0 데코레이션의 ::before 라
글자를 밀지 않도록 폭을 0 으로 두고 테두리로만 그린다. */
.sz-ai-caret::before {
content: "";
display: inline-block;
width: 0;
height: 1.05em;
vertical-align: text-bottom;
border-left: 2px solid var(--accent);
animation: szCaretBlink 1s steps(2, start) infinite;
}
@keyframes szCaretBlink { 0%, 50% { opacity: 1; } 50.01%, 100% { opacity: .25; } }
/* 모션을 줄이겠다고 한 사람에게 깜빡이는 것을 들이밀지 않는다. */
@media (prefers-reduced-motion: reduce) {
.sz-ai-caret::before { animation: none; }
}

/* 아직 수락하지 않은 제안이 걸린 자리. 마우스를 올리면 왜 바꾸는지가 뜬다.
git 거터(초록/노랑/빨강)와 색이 겹치지 않게 황토색 밑줄로만 표시한다 —
이미 적용된 변경과 아직 아닌 제안은 눈에서 갈려야 한다. */
.sz-prop-pending {
border-bottom: 1px dashed rgba(196,168,130,.85);
background: color-mix(in srgb, #C4A882 8%, transparent);
}
@keyframes szAiFade { from { background-color: color-mix(in srgb, var(--accent) 20%, transparent); } to { background-color: transparent; } }

/* 확장 상세 README */
Expand Down
103 changes: 103 additions & 0 deletions ide/src/review/proposalMarks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, it, expect } from "vitest";
import { posAt, offsetOf, locate, markTooltip } from "./proposalMarks";

const TEXT = "const a = 1;\nconst b = 2;\nconst c = 3;\n";

describe("posAt", () => {
it("첫 글자는 1줄 1칸", () => {
expect(posAt(TEXT, 0)).toEqual({ line: 1, column: 1 });
});
it("줄바꿈 다음은 다음 줄 첫 칸", () => {
expect(posAt(TEXT, 13)).toEqual({ line: 2, column: 1 });
});
it("줄 가운데도 센다", () => {
expect(posAt(TEXT, 19)).toEqual({ line: 2, column: 7 });
});
it("범위를 넘으면 끝으로 붙인다", () => {
expect(posAt(TEXT, 9999).line).toBe(4);
expect(posAt(TEXT, -5)).toEqual({ line: 1, column: 1 });
});
});

describe("offsetOf", () => {
it("posAt 의 역이다", () => {
for (const off of [0, 5, 13, 19, 30]) {
const p = posAt(TEXT, off);
expect(offsetOf(TEXT, p.line, p.column)).toBe(off);
}
});
it("없는 줄은 -1", () => {
expect(offsetOf(TEXT, 99, 1)).toBe(-1);
expect(offsetOf(TEXT, 0, 1)).toBe(-1);
expect(offsetOf(TEXT, 1, 0)).toBe(-1);
});
});

describe("locate", () => {
it("유일한 조각을 찾는다", () => {
expect(locate({ text: TEXT, find: "const b = 2;" })).toEqual({
startLineNumber: 2, startColumn: 1, endLineNumber: 2, endColumn: 13,
});
});

it("여러 줄에 걸친 조각도 찾는다", () => {
const m = locate({ text: TEXT, find: "const b = 2;\nconst c = 3;" });
expect(m).toEqual({ startLineNumber: 2, startColumn: 1, endLineNumber: 3, endColumn: 13 });
});

it("새 파일 생성은 첫 줄에 건다", () => {
expect(locate({ text: "", find: "" })).toEqual({ startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 });
});

it("없으면 null — 없는 자리에 그리지 않는다", () => {
expect(locate({ text: TEXT, find: "nope" })).toBeNull();
});

// 찍어서 그리면 엉뚱한 줄에 "여기가 바뀝니다" 가 붙는다.
it("두 군데면 null", () => {
expect(locate({ text: "x;\nx;\n", find: "x;" })).toBeNull();
});

it("범위를 주면 그쪽을 먼저 믿는다 — 같은 조각이 하나뿐이어도 범위가 이긴다", () => {
const m = locate({
text: TEXT, find: "const c = 3;",
range: { startLineNumber: 3, startColumn: 1, endLineNumber: 3, endColumn: 13 },
});
expect(m).toEqual({ startLineNumber: 3, startColumn: 1, endLineNumber: 3, endColumn: 13 });
});

it("범위가 낡았으면 무시하고 텍스트로 찾는다", () => {
// 파일이 그 사이 바뀌어 3번 줄이 다른 코드가 된 상황
const m = locate({
text: TEXT, find: "const b = 2;",
range: { startLineNumber: 3, startColumn: 1, endLineNumber: 3, endColumn: 13 },
});
expect(m).toEqual({ startLineNumber: 2, startColumn: 1, endLineNumber: 2, endColumn: 13 });
});

it("범위가 낡았고 조각이 여러 군데면 null", () => {
const m = locate({
text: "x;\nx;\n", find: "x;",
range: { startLineNumber: 9, startColumn: 1, endLineNumber: 9, endColumn: 3 },
});
expect(m).toBeNull();
});

it("범위가 파일 밖을 가리켜도 터지지 않는다", () => {
expect(locate({ text: TEXT, find: "없는것", range: { startLineNumber: 999, startColumn: 999, endLineNumber: 999, endColumn: 999 } })).toBeNull();
});
});

describe("markTooltip", () => {
it("사유와 에이전트를 함께 낸다", () => {
expect(markTooltip("null 체크가 없어 추가함", "claude")).toBe("**claude** — null 체크가 없어 추가함");
});
it("에이전트가 없으면 사유만", () => {
expect(markTooltip("이유", "")).toBe("이유");
});
it("사유가 비면 툴팁을 달지 않는다", () => {
expect(markTooltip("", "claude")).toBeNull();
expect(markTooltip(" ", "claude")).toBeNull();
expect(markTooltip(undefined as any, "claude")).toBeNull();
});
});
Loading
Loading