idea-manager 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/.next/build-manifest.json +2 -2
  2. package/.next/prerender-manifest.json +3 -3
  3. package/.next/required-server-files.js +5 -0
  4. package/.next/required-server-files.json +5 -0
  5. package/.next/routes-manifest.json +10 -0
  6. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  7. package/.next/server/app/_global-error.html +2 -2
  8. package/.next/server/app/_global-error.rsc +1 -1
  9. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  14. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/server/app/_not-found.html +2 -2
  17. package/.next/server/app/_not-found.rsc +2 -2
  18. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  19. package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  20. package/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  21. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  22. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  23. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  24. package/.next/server/app/api/archive/route.js +34 -4
  25. package/.next/server/app/api/archive/route_client-reference-manifest.js +1 -1
  26. package/.next/server/app/api/filesystem/route_client-reference-manifest.js +1 -1
  27. package/.next/server/app/api/filesystem/tree/route_client-reference-manifest.js +1 -1
  28. package/.next/server/app/api/global-memo/route.js +34 -4
  29. package/.next/server/app/api/global-memo/route_client-reference-manifest.js +1 -1
  30. package/.next/server/app/api/health/route_client-reference-manifest.js +1 -1
  31. package/.next/server/app/api/projects/[id]/apply-distribute/route.js +6 -82
  32. package/.next/server/app/api/projects/[id]/apply-distribute/route_client-reference-manifest.js +1 -1
  33. package/.next/server/app/api/projects/[id]/auto-distribute/route.js +6 -6
  34. package/.next/server/app/api/projects/[id]/auto-distribute/route_client-reference-manifest.js +1 -1
  35. package/.next/server/app/api/projects/[id]/brainstorm/route.js +1 -77
  36. package/.next/server/app/api/projects/[id]/brainstorm/route_client-reference-manifest.js +1 -1
  37. package/.next/server/app/api/projects/[id]/git-sync/route.js +1 -77
  38. package/.next/server/app/api/projects/[id]/git-sync/route_client-reference-manifest.js +1 -1
  39. package/.next/server/app/api/projects/[id]/route.js +1 -77
  40. package/.next/server/app/api/projects/[id]/route_client-reference-manifest.js +1 -1
  41. package/.next/server/app/api/projects/[id]/sub-projects/[subId]/route.js +38 -8
  42. package/.next/server/app/api/projects/[id]/sub-projects/[subId]/route_client-reference-manifest.js +1 -1
  43. package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route.js +15 -10
  44. package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route_client-reference-manifest.js +1 -1
  45. package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route.js +34 -4
  46. package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route_client-reference-manifest.js +1 -1
  47. package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/refine/route.js +26 -0
  48. package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/refine/route_client-reference-manifest.js +1 -0
  49. package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route.js +34 -4
  50. package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route_client-reference-manifest.js +1 -1
  51. package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/route.js +34 -4
  52. package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/route_client-reference-manifest.js +1 -1
  53. package/.next/server/app/api/projects/[id]/sub-projects/route.js +38 -8
  54. package/.next/server/app/api/projects/[id]/sub-projects/route_client-reference-manifest.js +1 -1
  55. package/.next/server/app/api/projects/route.js +1 -77
  56. package/.next/server/app/api/projects/route_client-reference-manifest.js +1 -1
  57. package/.next/server/app/api/sync/route.js +34 -4
  58. package/.next/server/app/api/sync/route_client-reference-manifest.js +1 -1
  59. package/.next/server/app/index.html +2 -2
  60. package/.next/server/app/index.rsc +3 -3
  61. package/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  62. package/.next/server/app/index.segments/_full.segment.rsc +3 -3
  63. package/.next/server/app/index.segments/_head.segment.rsc +1 -1
  64. package/.next/server/app/index.segments/_index.segment.rsc +2 -2
  65. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  66. package/.next/server/app/page.js +15 -6
  67. package/.next/server/app/page_client-reference-manifest.js +1 -1
  68. package/.next/server/app/projects/[id]/page_client-reference-manifest.js +1 -1
  69. package/.next/server/app-paths-manifest.json +9 -8
  70. package/.next/server/chunks/117.js +107 -0
  71. package/.next/server/pages/404.html +2 -2
  72. package/.next/server/pages/500.html +2 -2
  73. package/.next/server/server-reference-manifest.json +1 -1
  74. package/.next/static/chunks/363642f4-9eb39e0bc542c65b.js +1 -0
  75. package/.next/static/chunks/374-23189d7e246ad164.js +1 -0
  76. package/.next/static/chunks/app/_global-error/page-6ec0e723e471f87a.js +1 -0
  77. package/.next/static/chunks/app/api/archive/route-6ec0e723e471f87a.js +1 -0
  78. package/.next/static/chunks/app/api/filesystem/route-6ec0e723e471f87a.js +1 -0
  79. package/.next/static/chunks/app/api/filesystem/tree/route-6ec0e723e471f87a.js +1 -0
  80. package/.next/static/chunks/app/api/global-memo/route-6ec0e723e471f87a.js +1 -0
  81. package/.next/static/chunks/app/api/health/route-6ec0e723e471f87a.js +1 -0
  82. package/.next/static/chunks/app/api/projects/[id]/apply-distribute/route-6ec0e723e471f87a.js +1 -0
  83. package/.next/static/chunks/app/api/projects/[id]/auto-distribute/route-6ec0e723e471f87a.js +1 -0
  84. package/.next/static/chunks/app/api/projects/[id]/brainstorm/route-6ec0e723e471f87a.js +1 -0
  85. package/.next/static/chunks/app/api/projects/[id]/git-sync/route-6ec0e723e471f87a.js +1 -0
  86. package/.next/static/chunks/app/api/projects/[id]/route-6ec0e723e471f87a.js +1 -0
  87. package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/route-6ec0e723e471f87a.js +1 -0
  88. package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route-6ec0e723e471f87a.js +1 -0
  89. package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route-6ec0e723e471f87a.js +1 -0
  90. package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/refine/route-6ec0e723e471f87a.js +1 -0
  91. package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route-6ec0e723e471f87a.js +1 -0
  92. package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/route-6ec0e723e471f87a.js +1 -0
  93. package/.next/static/chunks/app/api/projects/[id]/sub-projects/route-6ec0e723e471f87a.js +1 -0
  94. package/.next/static/chunks/app/api/projects/route-6ec0e723e471f87a.js +1 -0
  95. package/.next/static/chunks/app/api/sync/route-6ec0e723e471f87a.js +1 -0
  96. package/.next/static/chunks/app/page-6a511af64da7531f.js +28 -0
  97. package/.next/static/chunks/next/dist/client/components/builtin/app-error-6ec0e723e471f87a.js +1 -0
  98. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-6ec0e723e471f87a.js +1 -0
  99. package/.next/static/chunks/next/dist/client/components/builtin/not-found-6ec0e723e471f87a.js +1 -0
  100. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-6ec0e723e471f87a.js +1 -0
  101. package/.next/static/css/cc32379d0efa7d1d.css +3 -0
  102. package/next.config.mjs +3 -0
  103. package/package.json +11 -6
  104. package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route.ts +9 -5
  105. package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/refine/route.ts +76 -0
  106. package/src/components/dashboard/DashboardPanel.tsx +1 -1
  107. package/src/components/dashboard/SubProjectCard.tsx +1 -0
  108. package/src/components/task/CommandPalette.tsx +137 -0
  109. package/src/components/task/NoteEditor.tsx +411 -0
  110. package/src/components/task/ProjectTree.tsx +1 -1
  111. package/src/components/task/StatusFlow.tsx +43 -20
  112. package/src/components/task/TaskChat.tsx +7 -7
  113. package/src/components/task/TaskDetail.tsx +270 -89
  114. package/src/components/task/TaskList.tsx +1 -1
  115. package/src/components/workspace/WorkspacePanel.tsx +8 -3
  116. package/src/lib/ai/agents.ts +3 -3
  117. package/src/lib/ai/client.ts +3 -1
  118. package/src/lib/db/index.ts +4 -1
  119. package/src/lib/db/queries/sub-projects.ts +3 -3
  120. package/src/lib/db/queries/tasks.ts +1 -1
  121. package/src/lib/db/schema.ts +60 -1
  122. package/src/types/index.ts +3 -1
  123. package/.next/server/chunks/806.js +0 -77
  124. package/.next/static/chunks/151-332d463cd8bd4db6.js +0 -1
  125. package/.next/static/chunks/app/_global-error/page-fd75b71b49e9729e.js +0 -1
  126. package/.next/static/chunks/app/api/archive/route-fd75b71b49e9729e.js +0 -1
  127. package/.next/static/chunks/app/api/filesystem/route-fd75b71b49e9729e.js +0 -1
  128. package/.next/static/chunks/app/api/filesystem/tree/route-fd75b71b49e9729e.js +0 -1
  129. package/.next/static/chunks/app/api/global-memo/route-fd75b71b49e9729e.js +0 -1
  130. package/.next/static/chunks/app/api/health/route-fd75b71b49e9729e.js +0 -1
  131. package/.next/static/chunks/app/api/projects/[id]/apply-distribute/route-fd75b71b49e9729e.js +0 -1
  132. package/.next/static/chunks/app/api/projects/[id]/auto-distribute/route-fd75b71b49e9729e.js +0 -1
  133. package/.next/static/chunks/app/api/projects/[id]/brainstorm/route-fd75b71b49e9729e.js +0 -1
  134. package/.next/static/chunks/app/api/projects/[id]/git-sync/route-fd75b71b49e9729e.js +0 -1
  135. package/.next/static/chunks/app/api/projects/[id]/route-fd75b71b49e9729e.js +0 -1
  136. package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/route-fd75b71b49e9729e.js +0 -1
  137. package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route-fd75b71b49e9729e.js +0 -1
  138. package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route-fd75b71b49e9729e.js +0 -1
  139. package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route-fd75b71b49e9729e.js +0 -1
  140. package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/route-fd75b71b49e9729e.js +0 -1
  141. package/.next/static/chunks/app/api/projects/[id]/sub-projects/route-fd75b71b49e9729e.js +0 -1
  142. package/.next/static/chunks/app/api/projects/route-fd75b71b49e9729e.js +0 -1
  143. package/.next/static/chunks/app/api/sync/route-fd75b71b49e9729e.js +0 -1
  144. package/.next/static/chunks/app/page-d0d563bda0034c18.js +0 -19
  145. package/.next/static/chunks/next/dist/client/components/builtin/app-error-fd75b71b49e9729e.js +0 -1
  146. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-fd75b71b49e9729e.js +0 -1
  147. package/.next/static/chunks/next/dist/client/components/builtin/not-found-fd75b71b49e9729e.js +0 -1
  148. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-fd75b71b49e9729e.js +0 -1
  149. package/.next/static/css/22a3bf63fb41db4f.css +0 -3
  150. /package/.next/static/{3dIOxF31xgLe9pGE0yrsa → 63zinfEtSLCdG9nUZ3W-E}/_buildManifest.js +0 -0
  151. /package/.next/static/{3dIOxF31xgLe9pGE0yrsa → 63zinfEtSLCdG9nUZ3W-E}/_ssgManifest.js +0 -0
@@ -1,7 +1,6 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import { getTaskConversations, addTaskConversation } from '@/lib/db/queries/task-conversations';
3
3
  import { getTask } from '@/lib/db/queries/tasks';
4
- import { getTaskPrompt } from '@/lib/db/queries/task-prompts';
5
4
  import { getBrainstorm } from '@/lib/db/queries/brainstorms';
6
5
  import { getProject } from '@/lib/db/queries/projects';
7
6
  import { runAgent } from '@/lib/ai/client';
@@ -39,18 +38,23 @@ export async function POST(
39
38
 
40
39
  // Build context for AI
41
40
  const history = getTaskConversations(taskId);
42
- const prompt = getTaskPrompt(taskId);
43
41
  const brainstorm = getBrainstorm(projectId);
44
42
  const project = getProject(projectId);
45
43
 
46
44
  const aiPolicy = project?.ai_context ? `\n\nProject AI Policy:\n${project.ai_context}` : '';
47
45
 
48
- const systemPrompt = `You are a helpful assistant helping refine a development task. Respond in Korean. Be concise.
46
+ const systemPrompt = `당신은 사용자가 자기 태스크 "노트"를 다듬는 것을 돕는 보조자입니다.
47
+ 사용자는 터미널 Claude Code에서 실제 작업을 수행하며, IM에서는 태스크의 맥락·배경·결정사항·질문 등을 자유롭게 메모합니다.
48
+ 당신의 역할:
49
+ - 사용자가 질문하면 간결하게 답한다 (긴 설교 금지)
50
+ - 사용자가 "이 부분 정리해줘" 같은 요청을 하면 노트에 바로 삽입 가능한 형태(마크다운)로 답한다
51
+ - 공식 프롬프트를 만들려 하지 말 것. 사용자의 생각을 **정리·명확화**하는 역할만
52
+ 응답은 한국어로.
49
53
  ${aiPolicy}
50
54
  Task: ${task.title}
51
- Description: ${task.description}
55
+ Note(현재):
56
+ ${task.description || '(비어있음)'}
52
57
  Status: ${task.status}
53
- ${prompt?.content ? `Current prompt:\n${prompt.content}` : ''}
54
58
  ${brainstorm?.content ? `\nBrainstorming context:\n${brainstorm.content.slice(0, 3000)}` : ''}`;
55
59
 
56
60
  const conversationText = history
@@ -0,0 +1,76 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { getTask } from '@/lib/db/queries/tasks';
3
+ import { getProject } from '@/lib/db/queries/projects';
4
+ import { runAgent } from '@/lib/ai/client';
5
+ import { ensureDb } from '@/lib/db';
6
+
7
+ type RefineCommand = 'continue' | 'tidy' | 'split' | 'to-questions' | 'summarize' | 'custom';
8
+
9
+ function buildInstruction(cmd: RefineCommand, customText?: string): string {
10
+ switch (cmd) {
11
+ case 'continue':
12
+ return '아래 노트의 흐름을 자연스럽게 이어서 덧붙일 한 단락(또는 bullet 몇 개)을 마크다운으로 작성하세요. 설명은 빼고 이어질 내용만 출력.';
13
+ case 'tidy':
14
+ return '아래 선택 영역의 뜻을 바꾸지 않고 깔끔하게 다듬어 주세요. 설명 없이 다듬어진 본문만 마크다운으로 출력.';
15
+ case 'split':
16
+ return '아래 선택 영역(또는 노트 전체)을 구체적인 할 일 단위의 체크박스 리스트로 변환하세요. 각 항목은 "- [ ] "로 시작. 설명 없이 리스트만 출력.';
17
+ case 'to-questions':
18
+ return '아래 내용에서 애매하거나 결정이 필요한 부분을 찾아 명확하게 해줄 질문 목록으로 바꿔주세요. "- Q. "로 시작하는 bullet로 출력. 설명 생략.';
19
+ case 'summarize':
20
+ return '아래 내용을 3줄 이내로 요약하세요. bullet 3개. 설명 없이 요약만.';
21
+ case 'custom':
22
+ return customText?.trim() || '아래 내용을 개선해주세요.';
23
+ }
24
+ }
25
+
26
+ export async function POST(
27
+ request: NextRequest,
28
+ { params }: { params: Promise<{ id: string; subId: string; taskId: string }> },
29
+ ) {
30
+ await ensureDb();
31
+ const { id: projectId, taskId } = await params;
32
+ const body = await request.json() as { command?: RefineCommand; customText?: string; selection?: string; note?: string };
33
+
34
+ if (!body.command) {
35
+ return NextResponse.json({ error: 'command is required' }, { status: 400 });
36
+ }
37
+
38
+ const task = getTask(taskId);
39
+ if (!task) return NextResponse.json({ error: 'Task not found' }, { status: 404 });
40
+
41
+ const project = getProject(projectId);
42
+ const aiPolicy = project?.ai_context ? `\n\nProject AI Policy:\n${project.ai_context}` : '';
43
+ const instruction = buildInstruction(body.command, body.customText);
44
+ const selection = body.selection?.trim();
45
+ const note = body.note ?? task.description ?? '';
46
+
47
+ const prompt = `당신은 사용자의 노트 작성을 돕는 보조자입니다. 한국어로 답하세요.
48
+ ${aiPolicy}
49
+
50
+ [태스크]
51
+ 제목: ${task.title}
52
+
53
+ [명령]
54
+ ${instruction}
55
+
56
+ ${selection
57
+ ? `[선택 영역]\n${selection}\n\n[노트 전체 컨텍스트]\n${note}`
58
+ : `[노트 전체]\n${note}`}
59
+
60
+ 중요: 답변은 노트에 그대로 삽입될 마크다운 텍스트만 출력하세요. 설명·전제·서론·결론 금지.`;
61
+
62
+ try {
63
+ const agentType = project?.agent_type || 'claude';
64
+ // Refine is a pure text-tidying task — no repo exploration or tool use.
65
+ // Skip cwd so the project's CLAUDE.md isn't loaded into context, and run
66
+ // against a faster model. Keeps latency well under the 90s budget.
67
+ const result = await runAgent(agentType, prompt, undefined, undefined, {
68
+ timeoutMs: 90000,
69
+ model: 'sonnet',
70
+ });
71
+ return NextResponse.json({ result: result.trim() });
72
+ } catch (err) {
73
+ const msg = err instanceof Error ? err.message : 'AI 호출 실패';
74
+ return NextResponse.json({ error: msg }, { status: 500 });
75
+ }
76
+ }
@@ -276,7 +276,7 @@ export default function DashboardPanel() {
276
276
 
277
277
  const STATUS_FILTERS: Record<string, (t: ITask) => boolean> = {
278
278
  total: () => true,
279
- active: (t) => t.status === 'submitted' || t.status === 'testing',
279
+ active: (t) => t.status === 'doing' || t.status === 'submitted' || t.status === 'testing',
280
280
  pending: (t) => t.status === 'idea' || t.status === 'writing',
281
281
  done: (t) => t.status === 'done',
282
282
  problem: (t) => t.status === 'problem',
@@ -4,6 +4,7 @@ import type { ISubProjectWithStats, TaskStatus } from '@/types';
4
4
 
5
5
  const STATUS_ICONS: Record<TaskStatus, string> = {
6
6
  idea: '\u{1F4A1}',
7
+ doing: '\u{1F525}',
7
8
  writing: '\u{270F}\u{FE0F}',
8
9
  submitted: '\u{1F680}',
9
10
  testing: '\u{1F9EA}',
@@ -0,0 +1,137 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+
5
+ export type RefineCommand =
6
+ | 'continue'
7
+ | 'tidy'
8
+ | 'split'
9
+ | 'to-questions'
10
+ | 'summarize'
11
+ | 'custom';
12
+
13
+ const COMMANDS: { key: RefineCommand; label: string; hint: string }[] = [
14
+ { key: 'continue', label: '이어서 써줘', hint: '커서 앞 맥락을 이어서 자연스럽게 덧붙임' },
15
+ { key: 'tidy', label: '이 부분 정리해줘', hint: '선택 영역을 깔끔히 다듬음 (의미 유지)' },
16
+ { key: 'split', label: '할 일로 쪼개줘', hint: '선택 영역을 체크박스 리스트로' },
17
+ { key: 'to-questions', label: '질문으로 바꿔줘', hint: '모호한 부분을 명확하게 하는 질문 목록으로' },
18
+ { key: 'summarize', label: '요약해줘', hint: '선택 영역을 3줄 이내로' },
19
+ { key: 'custom', label: '직접 입력…', hint: '임의 명령을 프롬프트로 전달' },
20
+ ];
21
+
22
+ export default function CommandPalette({
23
+ open,
24
+ hasSelection,
25
+ onClose,
26
+ onRun,
27
+ }: {
28
+ open: boolean;
29
+ hasSelection: boolean;
30
+ onClose: () => void;
31
+ onRun: (cmd: RefineCommand, customText?: string) => void;
32
+ }) {
33
+ const [idx, setIdx] = useState(0);
34
+ const [customMode, setCustomMode] = useState(false);
35
+ const [custom, setCustom] = useState('');
36
+ const inputRef = useRef<HTMLInputElement>(null);
37
+
38
+ useEffect(() => {
39
+ if (open) {
40
+ setIdx(0);
41
+ setCustomMode(false);
42
+ setCustom('');
43
+ }
44
+ }, [open]);
45
+
46
+ useEffect(() => {
47
+ if (customMode) inputRef.current?.focus();
48
+ }, [customMode]);
49
+
50
+ if (!open) return null;
51
+
52
+ const runAt = (i: number) => {
53
+ const c = COMMANDS[i];
54
+ if (c.key === 'custom') {
55
+ setCustomMode(true);
56
+ return;
57
+ }
58
+ onRun(c.key);
59
+ };
60
+
61
+ const submitCustom = () => {
62
+ const t = custom.trim();
63
+ if (!t) return;
64
+ onRun('custom', t);
65
+ };
66
+
67
+ return (
68
+ <div
69
+ onClick={onClose}
70
+ className="fixed inset-0 z-50 flex items-start justify-center pt-[18vh]"
71
+ style={{ background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(2px)' }}
72
+ >
73
+ <div
74
+ onClick={(e) => e.stopPropagation()}
75
+ className="bg-card border border-border rounded-xl shadow-2xl w-full max-w-md animate-dialog-in"
76
+ >
77
+ <div className="px-4 py-3 border-b border-border flex items-center justify-between">
78
+ <div className="text-xs text-muted-foreground">
79
+ {hasSelection ? '선택 영역에 명령 실행' : '커서 위치 기준 명령 실행'}
80
+ </div>
81
+ <button onClick={onClose} className="text-xs text-muted-foreground hover:text-foreground">Esc</button>
82
+ </div>
83
+
84
+ {!customMode ? (
85
+ <ul
86
+ onKeyDown={(e) => {
87
+ if (e.key === 'ArrowDown') { e.preventDefault(); setIdx((i) => Math.min(i + 1, COMMANDS.length - 1)); }
88
+ else if (e.key === 'ArrowUp') { e.preventDefault(); setIdx((i) => Math.max(i - 1, 0)); }
89
+ else if (e.key === 'Enter') { e.preventDefault(); runAt(idx); }
90
+ }}
91
+ tabIndex={0}
92
+ ref={(el) => { el?.focus(); }}
93
+ className="py-1 max-h-[50vh] overflow-y-auto focus:outline-none"
94
+ >
95
+ {COMMANDS.map((c, i) => (
96
+ <li
97
+ key={c.key}
98
+ onMouseEnter={() => setIdx(i)}
99
+ onClick={() => runAt(i)}
100
+ className={`px-4 py-2 cursor-pointer flex flex-col gap-0.5 ${
101
+ i === idx ? 'bg-muted' : ''
102
+ }`}
103
+ >
104
+ <span className="text-sm text-foreground">{c.label}</span>
105
+ <span className="text-xs text-muted-foreground">{c.hint}</span>
106
+ </li>
107
+ ))}
108
+ </ul>
109
+ ) : (
110
+ <div className="p-4 flex flex-col gap-3">
111
+ <input
112
+ ref={inputRef}
113
+ value={custom}
114
+ onChange={(e) => setCustom(e.target.value)}
115
+ onKeyDown={(e) => {
116
+ if (e.key === 'Enter') submitCustom();
117
+ if (e.key === 'Escape') onClose();
118
+ }}
119
+ placeholder="예: 이 부분 markdown 표로 만들어줘"
120
+ className="w-full bg-input border border-border rounded-md px-3 py-2 text-sm focus:border-primary focus:outline-none"
121
+ />
122
+ <div className="flex justify-end gap-2">
123
+ <button onClick={() => setCustomMode(false)} className="text-xs text-muted-foreground px-2 py-1">뒤로</button>
124
+ <button
125
+ onClick={submitCustom}
126
+ disabled={!custom.trim()}
127
+ className="text-xs px-3 py-1 bg-primary text-primary-foreground rounded disabled:opacity-40"
128
+ >
129
+ 실행
130
+ </button>
131
+ </div>
132
+ </div>
133
+ )}
134
+ </div>
135
+ </div>
136
+ );
137
+ }
@@ -0,0 +1,411 @@
1
+ 'use client';
2
+
3
+ import { forwardRef, useMemo, useRef } from 'react';
4
+ import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror';
5
+ import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
6
+ import { EditorView, Decoration, keymap, ViewPlugin, WidgetType } from '@codemirror/view';
7
+ import { EditorState, StateEffect, StateField, Prec } from '@codemirror/state';
8
+ import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
9
+ import { tags as t } from '@lezer/highlight';
10
+
11
+ // ─────────────────────────────────────────────────────────────
12
+ // Ghost-text state: a decoration widget after the cursor.
13
+ // ─────────────────────────────────────────────────────────────
14
+ const setGhost = StateEffect.define<{ from: number; text: string } | null>();
15
+
16
+ class GhostWidget extends WidgetType {
17
+ constructor(public readonly text: string) { super(); }
18
+ eq(other: GhostWidget) { return other.text === this.text; }
19
+ toDOM() {
20
+ const span = document.createElement('span');
21
+ span.className = 'cm-ghost-text';
22
+ span.textContent = this.text;
23
+ span.style.opacity = '0.35';
24
+ span.style.pointerEvents = 'none';
25
+ return span;
26
+ }
27
+ ignoreEvent() { return true; }
28
+ }
29
+
30
+ const ghostField = StateField.define<{ from: number; text: string } | null>({
31
+ create: () => null,
32
+ update(value, tr) {
33
+ for (const e of tr.effects) {
34
+ if (e.is(setGhost)) return e.value;
35
+ }
36
+ // Any doc change or selection move clears ghost unless effect explicitly set it
37
+ if (tr.docChanged || tr.selection) return null;
38
+ return value;
39
+ },
40
+ });
41
+
42
+ const ghostDecorations = EditorView.decorations.compute([ghostField], (state) => {
43
+ const g = state.field(ghostField);
44
+ if (!g) return Decoration.none;
45
+ return Decoration.set([
46
+ Decoration.widget({ widget: new GhostWidget(g.text), side: 1 }).range(g.from),
47
+ ]);
48
+ });
49
+
50
+ // ─────────────────────────────────────────────────────────────
51
+ // Local autocomplete: suggest multi-word phrases sourced from the
52
+ // current doc + an optional wider corpus (sibling tasks, brainstorm).
53
+ // Phrases are segmented by punctuation; longer repeated n-grams win
54
+ // over single-word matches when they appear often.
55
+ // ─────────────────────────────────────────────────────────────
56
+ const CURRENT_DOC_WEIGHT = 3;
57
+ const MAX_PHRASE_LEN = 3;
58
+ const MAX_GHOST_CHARS = 40;
59
+ const TOKEN_MIN_LEN = 2;
60
+ const SEGMENT_SPLIT = /[.!?,;:·\n\r()[\]{}"]+/;
61
+
62
+ interface Segment { tokens: string[]; weight: number }
63
+
64
+ function tokenizeSegments(text: string): string[][] {
65
+ const out: string[][] = [];
66
+ for (const raw of text.split(SEGMENT_SPLIT)) {
67
+ const tokens = raw.trim().split(/\s+/).filter(t => t.length >= TOKEN_MIN_LEN);
68
+ if (tokens.length) out.push(tokens);
69
+ }
70
+ return out;
71
+ }
72
+
73
+ // Cache extra-corpus segmentation by array identity to avoid re-splitting
74
+ // a potentially large text blob on every keystroke.
75
+ let cachedExtraRef: string[] | null = null;
76
+ let cachedExtraSegs: Segment[] = [];
77
+ function getExtraSegments(extraCorpus: string[]): Segment[] {
78
+ if (extraCorpus === cachedExtraRef) return cachedExtraSegs;
79
+ const segs: Segment[] = [];
80
+ for (const text of extraCorpus) {
81
+ for (const toks of tokenizeSegments(text)) segs.push({ tokens: toks, weight: 1 });
82
+ }
83
+ cachedExtraRef = extraCorpus;
84
+ cachedExtraSegs = segs;
85
+ return segs;
86
+ }
87
+
88
+ function buildSegments(doc: string, extraCorpus: string[]): Segment[] {
89
+ const out: Segment[] = [];
90
+ for (const toks of tokenizeSegments(doc)) out.push({ tokens: toks, weight: CURRENT_DOC_WEIGHT });
91
+ return out.concat(getExtraSegments(extraCorpus));
92
+ }
93
+
94
+ function caretPrefix(state: EditorState): { word: string; from: number } | null {
95
+ const pos = state.selection.main.head;
96
+ const line = state.doc.lineAt(pos);
97
+ const col = pos - line.from;
98
+ const before = line.text.slice(0, col);
99
+ const after = line.text.slice(col);
100
+ // Don't suggest when the caret sits inside a word — would duplicate the
101
+ // trailing part (e.g. "안녕하|세요" + ghost "세요" → "안녕하세요세요").
102
+ if (/^[A-Za-z가-힣\w_-]/.test(after)) return null;
103
+ const m = before.match(/([A-Za-z가-힣][\w가-힣_-]*)$/);
104
+ if (!m) return null;
105
+ return { word: m[1], from: pos - m[1].length };
106
+ }
107
+
108
+ // Gather vocabulary already present in the current doc. Segments from the
109
+ // extra corpus that share vocabulary with this set are considered "topically
110
+ // related" and receive a score boost — so if the user is writing about Pods,
111
+ // "Pod"-adjacent completions win over unrelated ones.
112
+ function buildContextVocabulary(doc: string): Set<string> {
113
+ const set = new Set<string>();
114
+ const matches = doc.toLowerCase().match(/[a-z가-힣][\w가-힣_-]{1,}/g);
115
+ if (!matches) return set;
116
+ for (const w of matches) {
117
+ if (w.length >= TOKEN_MIN_LEN) set.add(w);
118
+ }
119
+ return set;
120
+ }
121
+
122
+ function computeLocalGhost(
123
+ state: EditorState,
124
+ extraCorpus: string[],
125
+ ): { from: number; text: string } | null {
126
+ const ctx = caretPrefix(state);
127
+ if (!ctx || ctx.word.length < 2) return null;
128
+ const doc = state.doc.toString();
129
+ const segments = buildSegments(doc, extraCorpus);
130
+ const prefix = ctx.word.toLowerCase();
131
+ const contextVocab = buildContextVocabulary(doc);
132
+
133
+ // Tally candidate completions by the full tail text that would be inserted.
134
+ // Longer phrases get a small length bonus so "Claude Code" beats "Claude"
135
+ // when both appear with equal frequency. Segments that share vocabulary
136
+ // with the current doc get a relevance boost (up to 2×) so topically
137
+ // related completions surface first.
138
+ const scores = new Map<string, number>();
139
+ for (const seg of segments) {
140
+ let overlap = 0;
141
+ for (const tk of seg.tokens) {
142
+ if (contextVocab.has(tk.toLowerCase())) overlap++;
143
+ }
144
+ const relevance = 1 + Math.min(overlap * 0.25, 1.0);
145
+
146
+ for (let i = 0; i < seg.tokens.length; i++) {
147
+ const first = seg.tokens[i];
148
+ if (first.length <= ctx.word.length) continue;
149
+ if (!first.toLowerCase().startsWith(prefix)) continue;
150
+ if (first === ctx.word) continue;
151
+
152
+ const maxExtra = Math.min(MAX_PHRASE_LEN - 1, seg.tokens.length - i - 1);
153
+ for (let n = 0; n <= maxExtra; n++) {
154
+ const tail = n === 0
155
+ ? first.slice(ctx.word.length)
156
+ : `${first.slice(ctx.word.length)} ${seg.tokens.slice(i + 1, i + 1 + n).join(' ')}`;
157
+ if (tail.length > MAX_GHOST_CHARS) break;
158
+ const lengthBonus = 1 + n * 0.5;
159
+ scores.set(tail, (scores.get(tail) ?? 0) + seg.weight * lengthBonus * relevance);
160
+ }
161
+ }
162
+ }
163
+
164
+ if (!scores.size) return null;
165
+ // On exact tie, prefer later-inserted (longer) phrases using `>=`.
166
+ let best: { text: string; score: number } | null = null;
167
+ for (const [text, score] of scores) {
168
+ if (!best || score >= best.score) best = { text, score };
169
+ }
170
+ if (!best) return null;
171
+ return { from: state.selection.main.head, text: best.text };
172
+ }
173
+
174
+ function createLocalCompletionPlugin(corpusRef: { current: string[] }) {
175
+ return ViewPlugin.fromClass(class {
176
+ timer: ReturnType<typeof setTimeout> | null = null;
177
+ constructor(public view: EditorView) {}
178
+ update(u: { docChanged: boolean; selectionSet: boolean; state: EditorState; view: EditorView }) {
179
+ if (!u.docChanged && !u.selectionSet) return;
180
+ if (this.timer) clearTimeout(this.timer);
181
+ this.timer = setTimeout(() => {
182
+ const ghost = computeLocalGhost(u.view.state, corpusRef.current);
183
+ u.view.dispatch({ effects: setGhost.of(ghost) });
184
+ }, 120);
185
+ }
186
+ destroy() {
187
+ if (this.timer) clearTimeout(this.timer);
188
+ }
189
+ });
190
+ }
191
+
192
+ // Accept-ghost command — bound to Tab, only when ghost exists
193
+ function acceptGhost(view: EditorView): boolean {
194
+ const ghost = view.state.field(ghostField, false);
195
+ if (!ghost) return false;
196
+ view.dispatch({
197
+ changes: { from: ghost.from, insert: ghost.text },
198
+ selection: { anchor: ghost.from + ghost.text.length },
199
+ effects: setGhost.of(null),
200
+ });
201
+ return true;
202
+ }
203
+
204
+ function dismissGhost(view: EditorView): boolean {
205
+ const ghost = view.state.field(ghostField, false);
206
+ if (!ghost) return false;
207
+ view.dispatch({ effects: setGhost.of(null) });
208
+ return true;
209
+ }
210
+
211
+ // ─────────────────────────────────────────────────────────────
212
+ // Markdown list / checkbox continuation on Enter.
213
+ // ─────────────────────────────────────────────────────────────
214
+ function continueList(view: EditorView): boolean {
215
+ const { state } = view;
216
+ const pos = state.selection.main.head;
217
+ const line = state.doc.lineAt(pos);
218
+ // Only when caret is at end of line
219
+ if (pos !== line.to) return false;
220
+ const text = line.text;
221
+
222
+ const bullet = text.match(/^(\s*)([-*+])\s(\[[ xX]\]\s)?(.*)$/);
223
+ const ordered = text.match(/^(\s*)(\d+)\.\s(.*)$/);
224
+
225
+ if (bullet) {
226
+ const [, indent, mark, check, content] = bullet;
227
+ if (!content.trim()) {
228
+ // Empty bullet → exit list
229
+ view.dispatch({
230
+ changes: { from: line.from, to: line.to, insert: indent },
231
+ selection: { anchor: line.from + indent.length },
232
+ });
233
+ return true;
234
+ }
235
+ const prefix = check ? `${indent}${mark} [ ] ` : `${indent}${mark} `;
236
+ view.dispatch({
237
+ changes: { from: pos, insert: `\n${prefix}` },
238
+ selection: { anchor: pos + 1 + prefix.length },
239
+ });
240
+ return true;
241
+ }
242
+ if (ordered) {
243
+ const [, indent, numStr, content] = ordered;
244
+ const num = parseInt(numStr, 10);
245
+ if (!content.trim()) {
246
+ view.dispatch({
247
+ changes: { from: line.from, to: line.to, insert: indent },
248
+ selection: { anchor: line.from + indent.length },
249
+ });
250
+ return true;
251
+ }
252
+ const prefix = `${indent}${num + 1}. `;
253
+ view.dispatch({
254
+ changes: { from: pos, insert: `\n${prefix}` },
255
+ selection: { anchor: pos + 1 + prefix.length },
256
+ });
257
+ return true;
258
+ }
259
+ return false;
260
+ }
261
+
262
+ // ─────────────────────────────────────────────────────────────
263
+ // Markdown syntax highlighting — explicit Lezer-tag mapping so list
264
+ // marks, headings and inline code stand out clearly against plain
265
+ // text. `t.processingInstruction` covers ATX heading `#`s, list
266
+ // bullets (`-` `*` `+`), ordered list `1.`, quote `>`, emphasis `*`
267
+ // and link brackets — all of which we want to visually punctuate.
268
+ // ─────────────────────────────────────────────────────────────
269
+ const mdHighlight = HighlightStyle.define([
270
+ { tag: t.heading1, color: 'hsl(var(--foreground))', fontWeight: '800', fontSize: '1.35em' },
271
+ { tag: t.heading2, color: 'hsl(var(--foreground))', fontWeight: '700', fontSize: '1.2em' },
272
+ { tag: t.heading3, color: 'hsl(var(--foreground))', fontWeight: '700', fontSize: '1.08em' },
273
+ { tag: [t.heading4, t.heading5, t.heading6], color: 'hsl(var(--foreground))', fontWeight: '700' },
274
+ { tag: t.processingInstruction, color: 'hsl(var(--accent))', fontWeight: '700' },
275
+ { tag: t.list, color: 'hsl(var(--foreground))' },
276
+ { tag: t.emphasis, fontStyle: 'italic', color: 'hsl(var(--foreground))' },
277
+ { tag: t.strong, fontWeight: '700', color: 'hsl(var(--foreground))' },
278
+ { tag: [t.link, t.url], color: 'hsl(var(--primary))', textDecoration: 'underline' },
279
+ { tag: t.monospace, color: 'hsl(var(--warning))' },
280
+ { tag: t.quote, color: 'hsl(var(--muted-foreground))', fontStyle: 'italic' },
281
+ { tag: t.contentSeparator, color: 'hsl(var(--border))' },
282
+ { tag: t.strikethrough, textDecoration: 'line-through', color: 'hsl(var(--muted-foreground))' },
283
+ ]);
284
+
285
+ // ─────────────────────────────────────────────────────────────
286
+ // Component
287
+ // ─────────────────────────────────────────────────────────────
288
+ export interface NoteEditorProps {
289
+ value: string;
290
+ onChange: (v: string) => void;
291
+ onBlur?: () => void;
292
+ onOpenCommand?: () => void;
293
+ placeholder?: string;
294
+ /** Extra text blobs (sibling tasks, brainstorm, …) to widen the autocomplete corpus. */
295
+ extraCorpus?: string[];
296
+ }
297
+
298
+ const NoteEditor = forwardRef<ReactCodeMirrorRef, NoteEditorProps>(function NoteEditor(
299
+ { value, onChange, onBlur, onOpenCommand, placeholder, extraCorpus },
300
+ ref,
301
+ ) {
302
+ // Mutable ref keeps the plugin in sync with the latest corpus without
303
+ // rebuilding the extension list (which would re-init CodeMirror).
304
+ const corpusRef = useRef<string[]>(extraCorpus ?? []);
305
+ corpusRef.current = extraCorpus ?? [];
306
+
307
+ const extensions = useMemo(() => [
308
+ markdown({ base: markdownLanguage }),
309
+ syntaxHighlighting(mdHighlight),
310
+ ghostField,
311
+ ghostDecorations,
312
+ createLocalCompletionPlugin(corpusRef),
313
+ Prec.highest(keymap.of([
314
+ { key: 'Tab', run: acceptGhost },
315
+ { key: 'Escape', run: dismissGhost },
316
+ { key: 'Enter', run: continueList },
317
+ { key: 'Mod-k', run: () => { onOpenCommand?.(); return true; } },
318
+ ])),
319
+ EditorView.lineWrapping,
320
+ EditorView.theme({
321
+ '&': {
322
+ fontSize: '13px',
323
+ backgroundColor: 'transparent',
324
+ color: 'hsl(var(--foreground))',
325
+ height: '100%',
326
+ },
327
+ '.cm-editor': { backgroundColor: 'transparent' },
328
+ '&.cm-focused': { outline: 'none' },
329
+ '.cm-scroller': {
330
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
331
+ lineHeight: '1.7',
332
+ backgroundColor: 'transparent',
333
+ },
334
+ '.cm-content': {
335
+ padding: '12px 16px',
336
+ caretColor: 'hsl(var(--primary))',
337
+ color: 'hsl(var(--foreground))',
338
+ },
339
+ '.cm-gutters': { display: 'none' },
340
+ '.cm-activeLine': { backgroundColor: 'transparent' },
341
+ '.cm-activeLineGutter': { backgroundColor: 'transparent' },
342
+ '.cm-line': { backgroundColor: 'transparent' },
343
+ '.cm-selectionLayer .cm-selectionBackground, .cm-content ::selection, ::selection': {
344
+ backgroundColor: 'hsl(var(--primary) / 0.25)',
345
+ },
346
+ '&.cm-focused .cm-selectionBackground': {
347
+ backgroundColor: 'hsl(var(--primary) / 0.3)',
348
+ },
349
+ '.cm-cursor, .cm-dropCursor': {
350
+ borderLeftColor: 'hsl(var(--primary))',
351
+ borderLeftWidth: '2px',
352
+ },
353
+ '.cm-ghost-text': {
354
+ color: 'hsl(var(--muted-foreground))',
355
+ opacity: '0.55',
356
+ fontStyle: 'italic',
357
+ },
358
+ '.cm-placeholder': {
359
+ color: 'hsl(var(--muted-foreground) / 0.55)',
360
+ whiteSpace: 'normal',
361
+ display: 'inline-block',
362
+ maxWidth: '90%',
363
+ lineHeight: '1.6',
364
+ },
365
+ // Markdown syntax coloring (one-dark style tuned to IM palette)
366
+ '.tok-heading, .tok-heading1, .tok-heading2, .tok-heading3, .tok-heading4, .tok-heading5, .tok-heading6': {
367
+ color: 'hsl(var(--foreground))',
368
+ fontWeight: '700',
369
+ },
370
+ '.tok-emphasis': { fontStyle: 'italic', color: 'hsl(var(--foreground))' },
371
+ '.tok-strong': { fontWeight: '700', color: 'hsl(var(--foreground))' },
372
+ '.tok-link': { color: 'hsl(var(--primary))', textDecoration: 'underline' },
373
+ '.tok-url': { color: 'hsl(var(--primary))' },
374
+ '.tok-monospace, .tok-literal': {
375
+ color: 'hsl(var(--warning))',
376
+ backgroundColor: 'hsl(var(--muted) / 0.5)',
377
+ padding: '0 3px',
378
+ borderRadius: '3px',
379
+ },
380
+ '.tok-list': { color: 'hsl(var(--accent))' },
381
+ '.tok-quote': { color: 'hsl(var(--muted-foreground))', fontStyle: 'italic' },
382
+ '.tok-comment, .tok-meta': { color: 'hsl(var(--muted-foreground))' },
383
+ }, { dark: true }),
384
+ ], [onOpenCommand]);
385
+
386
+ return (
387
+ <div className="h-full w-full">
388
+ <CodeMirror
389
+ ref={ref}
390
+ value={value}
391
+ onChange={onChange}
392
+ onBlur={onBlur}
393
+ extensions={extensions}
394
+ theme="none"
395
+ basicSetup={{
396
+ lineNumbers: false,
397
+ foldGutter: false,
398
+ highlightActiveLine: false,
399
+ highlightActiveLineGutter: false,
400
+ autocompletion: false,
401
+ searchKeymap: false,
402
+ }}
403
+ placeholder={placeholder}
404
+ height="100%"
405
+ style={{ height: '100%' }}
406
+ />
407
+ </div>
408
+ );
409
+ });
410
+
411
+ export default NoteEditor;