pleumrouter 0.3.0 → 0.3.1

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.
package/README.md CHANGED
@@ -29,6 +29,7 @@ cat error.log | npx pleumrouter run gpt-4.1 "원인은?" # 파이프
29
29
 
30
30
  | 명령 | 설명 |
31
31
  |------|------|
32
+ | `pleum` | (인자 없음, TTY) 인터랙티브 런처 메뉴 — 채팅·에이전트 실행·잔액·리더보드를 키보드·마우스로. ↑/↓·휠 이동 · enter·클릭 실행 · →·우클릭 모델 설정 · esc 종료. 미설치 에이전트는 확인 후 자동 설치, 실행 종료 후 메뉴 복귀. 파이프에선 도움말 출력 |
32
33
  | `pleum login [--device]` | 브라우저로 로그인 → CLI가 API 키를 자동 발급·저장(`~/.pleum/config.json`). `--device`는 SSH·CI |
33
34
  | `pleum logout` | 저장된 키 삭제 |
34
35
  | `pleum launch [tool] [--model id] [-- args]` | 에이전트 실행. `--` 뒤 인자는 도구로 그대로 전달. 도구 생략 시 지원 목록·설치 여부 표시 |
package/bin/pleum.mjs CHANGED
@@ -11,9 +11,11 @@ import { health } from "../src/health.mjs";
11
11
  import { completion } from "../src/completion.mjs";
12
12
  import { upgrade } from "../src/upgrade.mjs";
13
13
  import { clearConfig } from "../src/config.mjs";
14
+ import { mainMenu } from "../src/menu.mjs";
14
15
 
15
16
  const HELP = `pleum — PleumRouter 터미널 CLI
16
17
 
18
+ pleum (인자 없음) 인터랙티브 런처 메뉴 — 키보드·마우스 (TTY 전용)
17
19
  pleum login [--device] 브라우저로 로그인 + 키 자동 저장
18
20
  --device: 브라우저 없는 환경(SSH·CI)
19
21
  pleum logout 저장된 키 삭제
@@ -114,6 +116,9 @@ try {
114
116
  version();
115
117
  break;
116
118
  case undefined:
119
+ // TTY면 ollama풍 런처 메뉴, 파이프/스크립트면 기존 도움말.
120
+ if (!(await mainMenu())) console.log(HELP);
121
+ break;
117
122
  case "-h":
118
123
  case "--help":
119
124
  case "help":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pleumrouter",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Official PleumRouter terminal CLI — launch any coding agent against PleumRouter.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/ansi.mjs CHANGED
@@ -49,8 +49,15 @@ export function createStyler(level = detectColorLevel()) {
49
49
  export const style = createStyler();
50
50
 
51
51
  export function stripAnsi(s) {
52
- // eslint 불필요 — CSI 시퀀스만 제거하면 충분(우리가 쓰는 코드 전부 CSI).
53
- return String(s).replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
52
+ // CSI(색·커서·마우스) + OSC(하이퍼링크) — displayWidth가 폭을 잘못 세지 않게 둘 다 제거.
53
+ return String(s)
54
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
55
+ .replace(/\x1b\[[0-9;<]*[A-Za-z]/g, "");
56
+ }
57
+
58
+ // OSC 8 클릭 가능한 하이퍼링크. 무색(파이프·NO_COLOR·dumb)이면 텍스트만.
59
+ export function hyperlink(url, text = url, level = style.level) {
60
+ return level ? `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\` : text;
54
61
  }
55
62
 
56
63
  // 터미널 표시 폭 — CJK(한글 등) 전각 문자는 2칸. 정렬·줄수 계산용.
@@ -27,10 +27,17 @@ export function spinner(text, { stream = process.stderr, intervalMs = 80 } = {})
27
27
  };
28
28
  }
29
29
 
30
+ // 마우스 리포팅 켬/끔 — bubbletea와 동일 조합(1002 버튼추적 + 1003 모든모션 + 1006 SGR 좌표).
31
+ // 미지원 모드는 터미널이 무시하므로 셋 다 켜고, 끌 때도 셋 다 리셋한다.
32
+ export const MOUSE_ON = "\x1b[?1002h\x1b[?1003h\x1b[?1006h";
33
+ export const MOUSE_OFF = "\x1b[?1002l\x1b[?1003l\x1b[?1006l";
34
+
30
35
  // raw-mode 입력 청크 → 키 이벤트 배열(순수 함수 — 테스트 대상).
31
36
  // 화살표(\x1b[A/B)와 일반 문자가 한 청크에 섞여 와도 순서대로 분해한다.
32
37
  // 청크 끝의 외톨이 ESC는 escape(취소)로 처리 — 실터미널은 화살표 3바이트를
33
38
  // 한 이벤트로 보내므로 실사용에서 오인될 일은 거의 없다.
39
+ // SGR 마우스(\x1b[<Cb;Cx;CyM|m): Cb 비트 0-1=버튼(0좌 2우), 32=모션, 64=휠(64업 65다운).
40
+ // CPR(\x1b[row;colR): DSR \x1b[6n 응답 — 인라인 렌더의 마우스 좌표 매핑용.
34
41
  export function parseKeys(chunk) {
35
42
  const out = [];
36
43
  for (let i = 0; i < chunk.length; ) {
@@ -38,12 +45,32 @@ export function parseKeys(chunk) {
38
45
  if (ch === "\x1b") {
39
46
  if (chunk[i + 1] === "[") {
40
47
  const rest = chunk.slice(i + 2);
48
+ const mouse = /^<(\d+);(\d+);(\d+)([Mm])/.exec(rest);
49
+ if (mouse) {
50
+ const cb = Number(mouse[1]);
51
+ out.push({
52
+ type: "mouse",
53
+ btn: cb & 0b11,
54
+ x: Number(mouse[2]),
55
+ y: Number(mouse[3]),
56
+ motion: Boolean(cb & 32),
57
+ release: mouse[4] === "m",
58
+ wheel: cb & 64 ? ((cb & 0b11) === 0 ? "up" : (cb & 0b11) === 1 ? "down" : null) : null,
59
+ });
60
+ i += 2 + mouse[0].length;
61
+ continue;
62
+ }
41
63
  const m = /^([0-9;]*)([A-Za-z~])/.exec(rest);
42
64
  if (m) {
43
65
  const code = m[2];
44
66
  if (code === "A") out.push({ type: "up" });
45
67
  else if (code === "B") out.push({ type: "down" });
46
- else out.push({ type: "unknown" }); // 좌우 화살표 등은 무시
68
+ else if (code === "C") out.push({ type: "right" });
69
+ else if (code === "D") out.push({ type: "left" });
70
+ else if (code === "R") {
71
+ const [row, col] = m[1].split(";").map(Number);
72
+ out.push({ type: "cpr", row: row || 0, col: col || 0 });
73
+ } else out.push({ type: "unknown" }); // 기타 CSI는 무시
47
74
  i += 2 + m[1].length + 1;
48
75
  continue;
49
76
  }
@@ -62,7 +89,52 @@ export function parseKeys(chunk) {
62
89
  return out;
63
90
  }
64
91
 
92
+ // 청크 경계에서 잘린 CSI/SGR 시퀀스를 다음 청크와 이어 붙이는 스트림 래퍼(순수 폐쇄 — 테스트 대상).
93
+ // 1003 모션 홍수에서 \x1b[<… 가 중간에 잘리면 escape로 오인돼 UI가 닫히는 것을 방지.
94
+ // 외톨이 \x1b(=ESC 키)는 즉시 escape 처리(기존 트레이드오프 유지).
95
+ export function keyFeeder() {
96
+ let carry = "";
97
+ return (chunk) => {
98
+ let data = carry + chunk;
99
+ const cut = /\x1b\[[<0-9;]*$/.exec(data);
100
+ carry = cut ? cut[0] : "";
101
+ if (cut) data = data.slice(0, cut.index);
102
+ return parseKeys(data);
103
+ };
104
+ }
105
+
106
+ // 절대 y(1-기반)를 인라인 프레임의 라인 인덱스로 변환(순수 함수 — 테스트 대상).
107
+ // endRow = 프레임 마지막 줄의 절대 행(CPR로 얻음). 줄바꿈은 rowsFor(라인별 시각 행수)로 반영.
108
+ export function lineIndexAt(y, endRow, lines, rowsFor) {
109
+ if (!endRow) return -1;
110
+ const total = lines.reduce((n, l) => n + rowsFor(l), 0);
111
+ const top = endRow - total + 1;
112
+ if (y < top || y > endRow) return -1;
113
+ let row = top;
114
+ for (let i = 0; i < lines.length; i++) {
115
+ const h = rowsFor(lines[i]);
116
+ if (y < row + h) return i;
117
+ row += h;
118
+ }
119
+ return -1;
120
+ }
121
+
122
+ // 마우스 리포팅 토글 + 비정상 종료 안전망(프로세스 exit 시 터미널 모드 복원).
123
+ let mouseActive = false;
124
+ let exitHooked = false;
125
+ export function setMouse(output, on) {
126
+ if (!exitHooked) {
127
+ exitHooked = true;
128
+ process.on("exit", () => {
129
+ if (mouseActive) process.stdout.write(MOUSE_OFF + "\x1b[?25h");
130
+ });
131
+ }
132
+ mouseActive = on;
133
+ output.write(on ? MOUSE_ON : MOUSE_OFF);
134
+ }
135
+
65
136
  // 화살표 이동 · 타이핑 필터 · Enter 선택 · Esc/Ctrl+C 취소(null).
137
+ // 마우스: 호버=하이라이트, 휠=이동, 좌클릭=선택.
66
138
  // 비TTY면 즉시 null — 호출자가 기존 번호 피커 등으로 폴백한다.
67
139
  // process.exit는 절대 호출하지 않는다(취소 처리는 호출자 몫).
68
140
  export async function select(items, {
@@ -79,6 +151,9 @@ export async function select(items, {
79
151
  let query = "";
80
152
  let cursor = 0;
81
153
  let lastRows = 0;
154
+ let lastLines = [];
155
+ let shownCount = 0;
156
+ let cprRow = 0; // 프레임 마지막 줄의 절대 행 — DSR \x1b[6n 응답으로 갱신
82
157
 
83
158
  const rowsFor = (s) => Math.max(1, Math.ceil(displayWidth(s) / Math.max(terminalWidth(), 1)));
84
159
  const erase = () => {
@@ -104,41 +179,73 @@ export async function select(items, {
104
179
  const frame = lines.join("\n");
105
180
  output.write(frame);
106
181
  lastRows = lines.reduce((n, l) => n + rowsFor(l), 0);
182
+ lastLines = lines;
183
+ shownCount = shown.length;
184
+ output.write("\x1b[6n"); // CPR 질의 — 마우스 좌표 매핑용
107
185
  return pool;
108
186
  };
109
187
 
188
+ // 클릭/호버 y좌표 → 표시 중인 항목 인덱스(항목 라인은 3번째 줄부터). 밖이면 -1.
189
+ const itemAt = (y) => {
190
+ const li = lineIndexAt(y, cprRow, lastLines, rowsFor);
191
+ return li >= 2 && li < 2 + shownCount ? li - 2 : -1;
192
+ };
193
+
110
194
  const finish = (value) => {
111
195
  input.off("data", onData);
112
196
  input.setRawMode(false);
113
197
  input.pause();
198
+ setMouse(output, false);
114
199
  output.write("\x1b[?25h");
115
200
  erase();
116
201
  if (value != null) output.write(`${style.green("✓")} ${formatItem(value)}\n`);
117
202
  resolve(value);
118
203
  };
119
204
 
205
+ const feed = keyFeeder();
120
206
  const onData = (chunk) => {
121
- for (const key of parseKeys(chunk)) {
207
+ let dirty = false; // CPR만 온 턴에 재렌더하면 렌더→CPR 질의→응답 무한 루프
208
+ for (const key of feed(chunk)) {
122
209
  if (key.type === "ctrlc" || key.type === "escape") return finish(null);
210
+ if (key.type === "cpr") {
211
+ cprRow = key.row;
212
+ continue;
213
+ }
123
214
  if (key.type === "enter") {
124
215
  const pool = matches(items, query);
125
216
  return finish(pool[cursor] ?? null);
126
217
  }
127
- if (key.type === "up") cursor = Math.max(0, cursor - 1);
218
+ if (key.type === "mouse") {
219
+ if (key.wheel === "up") cursor = Math.max(0, cursor - 1);
220
+ else if (key.wheel === "down") cursor = Math.min(limit - 1, cursor + 1);
221
+ else if (key.motion) {
222
+ const idx = itemAt(key.y);
223
+ if (idx < 0 || idx === cursor) continue;
224
+ cursor = idx;
225
+ } else if (key.btn === 0 && !key.release) {
226
+ const idx = itemAt(key.y);
227
+ if (idx < 0) continue;
228
+ cursor = idx;
229
+ const pool = matches(items, query);
230
+ return finish(pool[cursor] ?? null);
231
+ } else continue;
232
+ } else if (key.type === "up") cursor = Math.max(0, cursor - 1);
128
233
  else if (key.type === "down") cursor = Math.min(limit - 1, cursor + 1);
129
234
  else if (key.type === "backspace") query = query.slice(0, -1);
130
235
  else if (key.type === "char") {
131
236
  query += key.value;
132
237
  cursor = 0;
133
- }
238
+ } else continue;
239
+ dirty = true;
134
240
  }
135
- render();
241
+ if (dirty) render();
136
242
  };
137
243
 
138
244
  input.setEncoding("utf8"); // 멀티바이트(한글) 조각 자동 재조립
139
245
  input.setRawMode(true);
140
246
  input.resume();
141
247
  output.write("\x1b[?25l");
248
+ setMouse(output, true);
142
249
  input.on("data", onData);
143
250
  render();
144
251
  });
package/src/launch.mjs CHANGED
@@ -5,8 +5,8 @@ import { join, delimiter } from "node:path";
5
5
  import { requireKey } from "./config.mjs";
6
6
  import { RECIPES, MANUAL, codexToml } from "./recipes.mjs";
7
7
 
8
- // PATH에서 실행 파일을 찾는다(설치 여부 표시용).
9
- function onPath(bin) {
8
+ // PATH에서 실행 파일을 찾는다(설치 여부 표시용). menu.mjs도 사용.
9
+ export function onPath(bin) {
10
10
  const exts = process.platform === "win32" ? [".cmd", ".exe", ".bat", ""] : [""];
11
11
  return (process.env.PATH || "")
12
12
  .split(delimiter)
@@ -28,7 +28,8 @@ export function launchHelp() {
28
28
  console.log("\n예: pleum launch claude --model claude-sonnet-4");
29
29
  }
30
30
 
31
- export async function launch(tool, model, passthrough) {
31
+ // exit:false면 process.exit 대신 자식 종료 코드를 반환 — 런처 메뉴의 실행 후 복귀용.
32
+ export async function launch(tool, model, passthrough, { exit = true } = {}) {
32
33
  const key = requireKey();
33
34
  const r = RECIPES[tool];
34
35
 
@@ -59,20 +60,27 @@ export async function launch(tool, model, passthrough) {
59
60
  if (r.note) console.log(`ℹ ${r.note}`);
60
61
  console.log(`▶ ${r.label} 실행: ${r.bin} ${args.join(" ")}\n`);
61
62
 
62
- const child = spawn(r.bin, args, {
63
- env,
64
- stdio: "inherit",
65
- shell: process.platform === "win32",
63
+ return await new Promise((resolve) => {
64
+ const child = spawn(r.bin, args, {
65
+ env,
66
+ stdio: "inherit",
67
+ shell: process.platform === "win32",
68
+ });
69
+ child.on("error", (e) => {
70
+ if (e.code === "ENOENT") {
71
+ console.error(`✗ '${r.bin}'를 찾을 수 없습니다. 설치:\n ${r.install}`);
72
+ if (exit) process.exit(127);
73
+ return resolve(127);
74
+ }
75
+ console.error(`✗ ${e.message}`);
76
+ if (exit) process.exit(1);
77
+ resolve(1);
78
+ });
79
+ child.on("exit", (code) => {
80
+ if (exit) process.exit(code ?? 0);
81
+ resolve(code ?? 0);
82
+ });
66
83
  });
67
- child.on("error", (e) => {
68
- if (e.code === "ENOENT") {
69
- console.error(`✗ '${r.bin}'를 찾을 수 없습니다. 설치:\n ${r.install}`);
70
- process.exit(127);
71
- }
72
- console.error(`✗ ${e.message}`);
73
- process.exit(1);
74
- });
75
- child.on("exit", (code) => process.exit(code ?? 0));
76
84
  }
77
85
 
78
86
  function printManual(tool, key) {
package/src/menu.mjs ADDED
@@ -0,0 +1,360 @@
1
+ // Ollama 0.31 런처풍 시작 메뉴 — bare `pleum`(TTY)에서 뜬다. 비TTY면 false 반환(호출자가 HELP 폴백).
2
+ // 동작 패리티(ollama cmd/tui/tui.go·cmd/launch 소스 분석 기반):
3
+ // · 인라인 렌더(대체 화면 아님), ▸ 커서 + dim 설명 2줄 항목, 하단 키 안내
4
+ // · "더 보기…"는 커서가 닿는 순간 그 자리에서 펼쳐지고, pinned 구간으로 올라가면 접힘
5
+ // · →(configure) = 모델 피커를 강제로 띄우고 선택 즉시 실행(+ launch_models에 저장)
6
+ // · Enter = 저장된 모델(launch_models)로 바로 실행, 미설치면 Y/n 확인 후 자동 설치
7
+ // · 실행 종료 후 메뉴로 복귀(ollama runInteractiveTUI 루프), last_selection 커서 복원
8
+ // · 키: ↑/k ↓/j · enter/space 실행 · →/l 모델 설정 · esc/q/ctrl+c 종료
9
+ // 마우스(ollama엔 없음 — SGR 1002/1003/1006 직접 구현):
10
+ // · 호버=하이라이트 · 좌클릭=바로 실행 · 우클릭=모델 설정 · 휠=이동
11
+ // · 인라인 렌더라 프레임 상단 행이 가변 — 렌더마다 DSR \x1b[6n으로 끝 행을 얻어 좌표 매핑
12
+
13
+ import { readFileSync } from "node:fs";
14
+ import { spawn } from "node:child_process";
15
+ import { createInterface } from "node:readline";
16
+ import { style, displayWidth, terminalWidth, hyperlink } from "./ansi.mjs";
17
+ import { keyFeeder, select, setMouse, lineIndexAt } from "./interactive.mjs";
18
+ import { readConfig, writeConfig } from "./config.mjs";
19
+ import { RECIPES } from "./recipes.mjs";
20
+ import { onPath, launch } from "./launch.mjs";
21
+ import { fetchModels, matchModels, listModels } from "./models.mjs";
22
+ import { run } from "./run.mjs";
23
+ import { login } from "./login.mjs";
24
+ import { top } from "./top.mjs";
25
+ import { usage } from "./usage.mjs";
26
+ import { balance } from "./balance.mjs";
27
+ import { health } from "./health.mjs";
28
+
29
+ const TOOL_DESC = {
30
+ claude: "Anthropic 코딩 에이전트 — 서브에이전트 지원",
31
+ codex: "OpenAI 코딩 에이전트",
32
+ aider: "터미널 페어 프로그래밍",
33
+ goose: "Block의 로컬 우선 에이전트",
34
+ openhands: "자율 개발 에이전트",
35
+ };
36
+
37
+ const autoInstallable = (r) => !/^https?:/.test(r.install); // URL이면 수동 설치 안내만
38
+
39
+ // 메뉴 항목 빌드(순수 함수 — 테스트 대상). tool이 있는 항목만 →(모델 설정) 가능.
40
+ export function buildItems({ loggedIn, installed, savedModels = {}, expanded = false }) {
41
+ const items = [];
42
+ if (!loggedIn) items.push({ id: "login", title: "로그인", desc: "브라우저로 로그인하고 API 키를 저장합니다" });
43
+ items.push({
44
+ id: "chat",
45
+ tool: "chat",
46
+ title: "모델과 채팅",
47
+ desc: `대화형 채팅을 시작합니다${savedModels.chat ? ` · 모델: ${savedModels.chat}` : ""}`,
48
+ });
49
+ const tools = expanded ? Object.keys(RECIPES) : ["claude", "codex"];
50
+ for (const t of tools) {
51
+ const r = RECIPES[t];
52
+ items.push({
53
+ id: `launch:${t}`,
54
+ tool: t,
55
+ title: `${r.label} 실행${installed[t] ? "" : autoInstallable(r) ? " (설치)" : " (미설치)"}`,
56
+ desc: installed[t]
57
+ ? `${TOOL_DESC[t]}${savedModels[t] ? ` · 모델: ${savedModels[t]}` : ""}`
58
+ : autoInstallable(r)
59
+ ? `Enter로 바로 설치 — ${r.install}`
60
+ : `수동 설치: ${hyperlink(r.install)}`,
61
+ });
62
+ }
63
+ if (expanded) {
64
+ items.push(
65
+ { id: "models", title: "모델 목록", desc: "전체 모델 — 가격·컨텍스트 포함" },
66
+ { id: "top", title: "리더보드", desc: "모델 랭킹·제공자 점유율" },
67
+ { id: "usage", title: "지출 리포트", desc: "최근 30일 내 지출" },
68
+ { id: "balance", title: "잔액", desc: "크레딧 잔액 조회" },
69
+ { id: "health", title: "상태 점검", desc: "게이트웨이 연결·로그인 상태" },
70
+ );
71
+ } else {
72
+ items.push({ id: "more", title: "더 보기…", desc: "다른 에이전트 · 모델 목록 · 잔액 · 리더보드" });
73
+ }
74
+ return items;
75
+ }
76
+
77
+ // 항목별 기본 모델 저장(launch_models). 로그인 전에도 동작 — /v1/models는 공개.
78
+ function saveModel(tool, id) {
79
+ const cfg = readConfig() ?? {};
80
+ cfg.launch_models = { ...cfg.launch_models, [tool]: id };
81
+ writeConfig(cfg);
82
+ }
83
+
84
+ // 마지막 메뉴 선택 저장 — 다음 실행 시 커서 복원(ollama last_selection과 동일).
85
+ function saveLastSelection(id) {
86
+ const cfg = readConfig() ?? {};
87
+ cfg.last_selection = id;
88
+ writeConfig(cfg);
89
+ }
90
+
91
+ async function pickModelFor(item) {
92
+ let models;
93
+ try {
94
+ models = await fetchModels();
95
+ } catch (e) {
96
+ console.log(`${style.red("✗")} 모델 목록 조회 실패: ${e.message}`);
97
+ return undefined;
98
+ }
99
+ const m = await select(models, {
100
+ message: `${item.title.replace(/\s+\((설치|미설치)\)/, "")} — 모델 선택`,
101
+ formatItem: (x) => `${x.id}${x.display_name ? ` ${style.dim(x.display_name)}` : ""}`,
102
+ matches: matchModels,
103
+ limit: 12,
104
+ });
105
+ return m?.id;
106
+ }
107
+
108
+ // 미설치 도구: ollama처럼 확인 후 그 자리에서 설치(stdio 상속). 성공 시 true.
109
+ async function ensureInstalled(r) {
110
+ if (!autoInstallable(r)) {
111
+ console.log(`${r.label}는 수동 설치가 필요합니다:\n ${style.bold(hyperlink(r.install))}`);
112
+ return false;
113
+ }
114
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
115
+ const ans = await new Promise((res) => rl.question(`${r.label}가 설치되어 있지 않습니다. 지금 설치할까요? [Y/n] `, res));
116
+ rl.close();
117
+ if (/^n/i.test(ans.trim())) {
118
+ console.log(`설치 취소 — 수동 설치: ${r.install}`);
119
+ return false;
120
+ }
121
+ console.log(style.dim(`$ ${r.install}`));
122
+ const code = await new Promise((res) => {
123
+ const c = spawn(r.install, { shell: true, stdio: "inherit" });
124
+ c.on("exit", (n) => res(n ?? 1));
125
+ c.on("error", () => res(1));
126
+ });
127
+ if (code !== 0) {
128
+ console.log(`${style.red("✗")} 설치 실패 (exit ${code})`);
129
+ return false;
130
+ }
131
+ console.log(`${style.green("✓")} ${r.label} 설치 완료`);
132
+ return true;
133
+ }
134
+
135
+ // 클릭 y좌표 → 항목 인덱스(순수 함수 — 테스트 대상).
136
+ // 프레임 구조: [헤더, 공백, 항목별 2줄…, 공백, 푸터]. 항목 밖(헤더/공백/푸터)이면 -1.
137
+ export function itemIndexAt(y, cprRow, lines, itemCount, rowsFor) {
138
+ const li = lineIndexAt(y, cprRow, lines, rowsFor);
139
+ if (li < 2) return -1;
140
+ const idx = (li - 2) >> 1;
141
+ return idx < itemCount ? idx : -1;
142
+ }
143
+
144
+ // 화살표+마우스 메뉴 루프 — {item, model?} 또는 null(Esc) 반환. 비TTY면 undefined.
145
+ async function menuSelect() {
146
+ const input = process.stdin;
147
+ const output = process.stdout;
148
+ if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== "function") return undefined;
149
+
150
+ const installed = Object.fromEntries(Object.entries(RECIPES).map(([t, r]) => [t, onPath(r.bin)]));
151
+ const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
152
+ let expanded = false;
153
+ const rebuild = () => {
154
+ const cfg = readConfig();
155
+ return buildItems({ loggedIn: Boolean(cfg?.key), installed, savedModels: cfg?.launch_models ?? {}, expanded });
156
+ };
157
+
158
+ let items = rebuild();
159
+ const pinnedCount = items.length - 1; // "더 보기…" 자리 — 여기 닿으면 펼침, 위로 벗어나면 접힘
160
+ let cursor = 0;
161
+
162
+ // last_selection 커서 복원 — 더 보기 그룹에 있었으면 펼친 채 시작(ollama shouldExpandOthers).
163
+ const last = readConfig()?.last_selection;
164
+ if (last) {
165
+ let idx = items.findIndex((it) => it.id === last);
166
+ if (idx < 0) {
167
+ expanded = true;
168
+ items = rebuild();
169
+ idx = items.findIndex((it) => it.id === last);
170
+ if (idx < 0) {
171
+ expanded = false;
172
+ items = rebuild();
173
+ }
174
+ }
175
+ if (idx >= 0) cursor = idx;
176
+ }
177
+
178
+ let lastRows = 0;
179
+ let lastLines = [];
180
+ let cprRow = 0;
181
+
182
+ const rowsFor = (s) => Math.max(1, Math.ceil(displayWidth(s) / Math.max(terminalWidth(), 1)));
183
+ const erase = () => {
184
+ if (!lastRows) return;
185
+ output.write("\r" + (lastRows > 1 ? `\x1b[${lastRows - 1}A` : "") + "\x1b[0J");
186
+ lastRows = 0;
187
+ };
188
+ const render = () => {
189
+ const lines = [
190
+ `${style.bold(style.plum("PleumRouter"))} ${style.dim(`pleum ${version}`)}`,
191
+ "",
192
+ ...items.flatMap((it, i) =>
193
+ i === cursor
194
+ ? [`${style.plum("▸")} ${style.bold(it.title)}`, ` ${style.dim(it.desc)}`]
195
+ : [` ${it.title}`, ` ${style.dim(it.desc)}`],
196
+ ),
197
+ "",
198
+ style.dim("↑/↓·휠 이동 • enter·클릭 실행 • →·우클릭 모델 설정 • esc 종료"),
199
+ ];
200
+ erase();
201
+ output.write(lines.join("\n"));
202
+ lastRows = lines.reduce((n, l) => n + rowsFor(l), 0);
203
+ lastLines = lines;
204
+ output.write("\x1b[6n"); // CPR 질의 — 마우스 좌표 매핑용
205
+ };
206
+
207
+ const moveTo = (next) => {
208
+ if (!expanded && next === pinnedCount) {
209
+ expanded = true; // "더 보기…"에 커서가 닿으면 그 자리에서 펼침
210
+ items = rebuild();
211
+ cursor = next;
212
+ return;
213
+ }
214
+ if (expanded && next < pinnedCount) {
215
+ expanded = false; // pinned 구간으로 돌아가면 접힘
216
+ items = rebuild();
217
+ cursor = next;
218
+ return;
219
+ }
220
+ cursor = next;
221
+ };
222
+ const move = (dir) => moveTo(Math.max(0, Math.min(items.length - 1, cursor + dir)));
223
+ const itemAt = (y) => itemIndexAt(y, cprRow, lastLines, items.length, rowsFor);
224
+
225
+ return new Promise((resolve) => {
226
+ const suspend = () => {
227
+ input.off("data", onData);
228
+ input.setRawMode(false);
229
+ setMouse(output, false);
230
+ output.write("\x1b[?25h");
231
+ erase();
232
+ };
233
+ const resume = () => {
234
+ input.setRawMode(true);
235
+ input.resume();
236
+ output.write("\x1b[?25l");
237
+ setMouse(output, true);
238
+ input.on("data", onData);
239
+ render();
240
+ };
241
+ const finish = (value) => {
242
+ suspend();
243
+ input.pause();
244
+ resolve(value);
245
+ };
246
+ // 모델 셀렉터는 자체 raw-mode·마우스 관리 — 메뉴 입력을 내려놓고 넘긴다.
247
+ // 선택되면 ollama처럼 즉시 실행으로 이어가고, 취소면 메뉴 복귀.
248
+ const configure = async (it) => {
249
+ suspend();
250
+ const picked = await pickModelFor(it);
251
+ if (picked) {
252
+ input.pause();
253
+ resolve({ item: it, model: picked });
254
+ return;
255
+ }
256
+ resume();
257
+ };
258
+
259
+ const feed = keyFeeder();
260
+ const onData = async (chunk) => {
261
+ let dirty = false; // CPR만 온 턴에 재렌더하면 렌더→CPR 질의→응답 무한 루프
262
+ for (const key of feed(chunk)) {
263
+ if (key.type === "ctrlc" || key.type === "escape" || (key.type === "char" && key.value === "q")) {
264
+ return finish(null);
265
+ }
266
+ if (key.type === "cpr") {
267
+ cprRow = key.row;
268
+ continue;
269
+ }
270
+ if (key.type === "enter" || (key.type === "char" && key.value === " ")) {
271
+ return finish({ item: items[cursor] });
272
+ }
273
+ if (key.type === "mouse") {
274
+ if (key.wheel === "up") move(-1);
275
+ else if (key.wheel === "down") move(1);
276
+ else if (key.motion) {
277
+ const idx = itemAt(key.y);
278
+ if (idx < 0 || idx === cursor) continue;
279
+ moveTo(idx);
280
+ } else if (!key.release && key.btn === 0) {
281
+ const idx = itemAt(key.y);
282
+ if (idx < 0) continue;
283
+ const wasMore = !expanded && items[idx]?.id === "more";
284
+ moveTo(idx);
285
+ if (!wasMore) return finish({ item: items[cursor] });
286
+ } else if (!key.release && key.btn === 2) {
287
+ const idx = itemAt(key.y);
288
+ if (idx < 0 || !items[idx].tool) continue;
289
+ moveTo(idx);
290
+ return void (await configure(items[cursor]));
291
+ } else continue;
292
+ } else if (key.type === "up" || (key.type === "char" && key.value === "k")) move(-1);
293
+ else if (key.type === "down" || (key.type === "char" && key.value === "j")) move(1);
294
+ else if (key.type === "right" || (key.type === "char" && key.value === "l")) {
295
+ const it = items[cursor];
296
+ if (!it.tool) continue;
297
+ return void (await configure(it));
298
+ } else continue;
299
+ dirty = true;
300
+ }
301
+ if (dirty) render();
302
+ };
303
+
304
+ input.setEncoding("utf8");
305
+ input.setRawMode(true);
306
+ input.resume();
307
+ output.write("\x1b[?25l");
308
+ setMouse(output, true);
309
+ input.on("data", onData);
310
+ render();
311
+ });
312
+ }
313
+
314
+ // bare `pleum` 진입점. 비TTY면 false(호출자가 HELP 출력), 그 외 true.
315
+ // ollama처럼 실행이 끝나면 메뉴로 복귀하는 루프(REPL 채팅·Esc 종료 제외).
316
+ export async function mainMenu() {
317
+ for (;;) {
318
+ const picked = await menuSelect();
319
+ if (picked === undefined) return false; // 비TTY
320
+ if (picked === null) return true; // Esc — ollama처럼 조용히 종료
321
+
322
+ const { item, model } = picked;
323
+ if (item.id !== "login") saveLastSelection(item.id);
324
+ if (model && item.tool) saveModel(item.tool, model);
325
+ const saved = readConfig()?.launch_models ?? {};
326
+
327
+ switch (item.id) {
328
+ case "login":
329
+ await login();
330
+ continue; // 로그인 후 메뉴로 복귀
331
+ case "chat": {
332
+ const m = model ?? saved.chat;
333
+ await run(m ? [m] : []); // REPL 종료는 run 내부 process.exit — 메뉴 복귀 없음
334
+ return true;
335
+ }
336
+ case "models":
337
+ await listModels([]);
338
+ continue;
339
+ case "top":
340
+ await top([]);
341
+ continue;
342
+ case "usage":
343
+ await usage([]);
344
+ continue;
345
+ case "balance":
346
+ await balance();
347
+ continue;
348
+ case "health":
349
+ await health();
350
+ continue;
351
+ default: {
352
+ const r = RECIPES[item.tool];
353
+ if (!onPath(r.bin) && !(await ensureInstalled(r))) continue;
354
+ await launch(item.tool, model ?? saved[item.tool], [], { exit: false });
355
+ console.log(); // 에이전트 출력과 다음 메뉴 사이 여백
356
+ continue; // ollama처럼 에이전트 종료 후 메뉴 복귀
357
+ }
358
+ }
359
+ }
360
+ }