clauderipple 0.9.5 → 0.11.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 (163) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.es.md +45 -0
  3. package/README.ja.md +17 -0
  4. package/README.ko.md +31 -0
  5. package/README.md +70 -0
  6. package/README.pt-BR.md +45 -0
  7. package/README.zh-CN.md +17 -0
  8. package/dist/app/assets/icon.icns +0 -0
  9. package/dist/app/assets/icon.ico +0 -0
  10. package/dist/app/assets/icon.png +0 -0
  11. package/dist/app/assets/trayDirectTemplate.png +0 -0
  12. package/dist/app/assets/trayDirectTemplate@2x.png +0 -0
  13. package/dist/app/assets/trayDownTemplate.png +0 -0
  14. package/dist/app/assets/trayDownTemplate@2x.png +0 -0
  15. package/dist/app/assets/trayTemplate.png +0 -0
  16. package/dist/app/assets/trayTemplate@2x.png +0 -0
  17. package/dist/app/assets/trayWarnTemplate.png +0 -0
  18. package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
  19. package/dist/app/assets/trayWin.png +0 -0
  20. package/dist/app/assets/trayWin@2x.png +0 -0
  21. package/dist/app/assets/trayWinDirect.png +0 -0
  22. package/dist/app/assets/trayWinDirect@2x.png +0 -0
  23. package/dist/app/assets/trayWinDown.png +0 -0
  24. package/dist/app/assets/trayWinDown@2x.png +0 -0
  25. package/dist/app/assets/trayWinWarn.png +0 -0
  26. package/dist/app/assets/trayWinWarn@2x.png +0 -0
  27. package/dist/app/dist/claude-desktop.js +67 -0
  28. package/dist/app/dist/main.js +423 -110
  29. package/dist/app/dist/preload.js +21 -0
  30. package/dist/app/dist/protocol.js +121 -0
  31. package/dist/app/dist/status-model.js +87 -0
  32. package/dist/app/dist/window.js +133 -0
  33. package/dist/app/package.json +1 -1
  34. package/dist/cli/src/awake-setup.js +314 -0
  35. package/dist/cli/src/bundle.js +9 -5
  36. package/dist/cli/src/direct.js +159 -0
  37. package/dist/cli/src/doctor.js +205 -0
  38. package/dist/cli/src/hooks/agent-title.js +7 -4
  39. package/dist/cli/src/index.js +227 -31
  40. package/dist/cli/src/launchd.js +29 -20
  41. package/dist/cli/src/launcher.js +215 -0
  42. package/dist/cli/src/power.js +182 -0
  43. package/dist/cli/src/registration.js +157 -0
  44. package/dist/cli/src/remote.js +263 -0
  45. package/dist/cli/src/schtasks.js +50 -40
  46. package/dist/cli/src/settings.js +26 -1
  47. package/dist/cli/src/tray.js +5 -2
  48. package/dist/mod/hooks/i18n.ts +473 -227
  49. package/dist/mod/hooks/judge.ts +35 -17
  50. package/dist/mod/hooks/modes.ts +56 -1
  51. package/dist/mod/hooks/register.tsx +237 -32
  52. package/dist/mod/hooks/session.ts +102 -1
  53. package/dist/mod/hooks/settings.js +7 -3
  54. package/dist/mod/hooks/settings.ts +9 -4
  55. package/dist/mod/hooks/stats.ts +25 -1
  56. package/dist/mod/hooks/ui/agents.tsx +903 -79
  57. package/dist/mod/hooks/ui/alerts.tsx +20 -17
  58. package/dist/mod/hooks/ui/art.ts +33 -7
  59. package/dist/mod/hooks/ui/band.tsx +262 -81
  60. package/dist/mod/hooks/ui/flat.ts +265 -0
  61. package/dist/mod/hooks/ui/kit.tsx +70 -55
  62. package/dist/mod/hooks/ui/look.ts +129 -0
  63. package/dist/mod/hooks/ui/settings.tsx +71 -46
  64. package/dist/mod/hooks/ui/setup.tsx +15 -13
  65. package/dist/mod/hooks/ui/theme.ts +3 -0
  66. package/dist/mod/hooks/ui/tools.ts +124 -0
  67. package/dist/mod/hooks/ui/view.ts +120 -4
  68. package/dist/mod/hooks/workers.ts +33 -6
  69. package/dist/mod/tests/register.test.tsx +155 -11
  70. package/dist/mod/tests/ui.test.tsx +408 -27
  71. package/dist/mod/types/index.d.ts +66 -2
  72. package/dist/remote/package.json +28 -0
  73. package/dist/remote/src/away.js +85 -0
  74. package/dist/remote/src/bridge-client.js +336 -0
  75. package/dist/remote/src/composer-menu.js +11 -0
  76. package/dist/remote/src/config.js +102 -0
  77. package/dist/remote/src/desktop.js +1018 -0
  78. package/dist/remote/src/e2ee.js +126 -0
  79. package/dist/remote/src/e2ee.ts +126 -0
  80. package/dist/remote/src/eventlog.js +298 -0
  81. package/dist/remote/src/expo-push.js +127 -0
  82. package/dist/remote/src/folders.js +79 -0
  83. package/dist/remote/src/fsutil.js +39 -0
  84. package/dist/remote/src/handoff.js +38 -0
  85. package/dist/remote/src/host-service.js +168 -0
  86. package/dist/remote/src/images.js +215 -0
  87. package/dist/remote/src/local-control.js +138 -0
  88. package/dist/remote/src/main.js +264 -0
  89. package/dist/remote/src/permissions.js +124 -0
  90. package/dist/remote/src/power.js +76 -0
  91. package/dist/remote/src/processes.js +24 -0
  92. package/dist/remote/src/protocol.js +32 -0
  93. package/dist/remote/src/protocol.ts +284 -0
  94. package/dist/remote/src/push-content.js +32 -0
  95. package/dist/remote/src/push.js +134 -0
  96. package/dist/remote/src/receipts.js +111 -0
  97. package/dist/remote/src/relay.js +438 -0
  98. package/dist/remote/src/router.js +208 -0
  99. package/dist/remote/src/sdk-launcher.js +18 -0
  100. package/dist/remote/src/server.js +706 -0
  101. package/dist/remote/src/service.js +157 -0
  102. package/dist/remote/src/sessions.js +1800 -0
  103. package/dist/remote/src/sidebar-storage.js +211 -0
  104. package/dist/remote/src/sidebar.js +173 -0
  105. package/dist/remote/src/tool-details.js +205 -0
  106. package/dist/remote/src/tool-details.ts +153 -0
  107. package/dist/remote/src/worktree.js +99 -0
  108. package/dist/remote/web/app.ts +746 -0
  109. package/dist/remote/web/conn.ts +193 -0
  110. package/dist/remote/web/demo.ts +134 -0
  111. package/dist/remote/web/dom.ts +124 -0
  112. package/dist/remote/web/esc.ts +4 -0
  113. package/dist/remote/web/i18n.ts +374 -0
  114. package/dist/remote/web/icon.svg +1 -0
  115. package/dist/remote/web/index.html +24 -0
  116. package/dist/remote/web/manifest.webmanifest +10 -0
  117. package/dist/remote/web/mascot.ts +60 -0
  118. package/dist/remote/web/md.ts +78 -0
  119. package/dist/remote/web/notify.ts +55 -0
  120. package/dist/remote/web/pushstate.ts +27 -0
  121. package/dist/remote/web/secure.ts +109 -0
  122. package/dist/remote/web/sidebar.ts +106 -0
  123. package/dist/remote/web/style.css +311 -0
  124. package/dist/remote/web/sw.ts +48 -0
  125. package/dist/remote/web/timeline.ts +224 -0
  126. package/dist/remote/web/views.ts +323 -0
  127. package/dist/router/src/admin.js +331 -5
  128. package/dist/router/src/awake-win.js +590 -0
  129. package/dist/router/src/awake.js +432 -0
  130. package/dist/router/src/config.js +94 -0
  131. package/dist/router/src/health.js +86 -3
  132. package/dist/router/src/hold.js +107 -0
  133. package/dist/router/src/index.js +108 -6
  134. package/dist/router/src/ingress/server.js +26 -4
  135. package/dist/router/src/ingress/translate.js +6 -2
  136. package/dist/router/src/phone.js +82 -0
  137. package/dist/router/src/power.js +107 -0
  138. package/dist/router/src/providers/anthropic-usage.js +33 -1
  139. package/dist/router/src/providers/google/antigravity.js +73 -10
  140. package/dist/router/src/providers/google/index.js +191 -14
  141. package/dist/router/src/providers/google/prompt-refusal.js +29 -0
  142. package/dist/router/src/providers/google/thoughtSignatures.js +13 -0
  143. package/dist/router/src/providers/google/transport.js +1 -1
  144. package/dist/router/src/providers/google/usage.js +302 -0
  145. package/dist/router/src/providers/grok/index.js +18 -0
  146. package/dist/router/src/providers/grok/usage.js +77 -0
  147. package/dist/router/src/providers/openai/translate.js +13 -2
  148. package/dist/router/src/providers/opencode-usage.js +116 -0
  149. package/dist/router/src/providers/retry.js +4 -1
  150. package/dist/router/src/proxy.js +371 -261
  151. package/dist/router/src/remote-host.js +320 -0
  152. package/dist/router/src/remote-tunnel.js +320 -0
  153. package/dist/router/src/requestlog.js +48 -39
  154. package/dist/router/src/tether-win.js +211 -0
  155. package/dist/router/src/tether.js +712 -0
  156. package/dist/router/src/usage-history.js +227 -0
  157. package/dist/router/src/version.js +1 -1
  158. package/dist/ui/app.js +1083 -59
  159. package/dist/ui/i18n.js +670 -69
  160. package/dist/ui/index.html +33 -6
  161. package/dist/ui/style.css +177 -0
  162. package/docs/ARCHITECTURE.md +402 -14
  163. package/package.json +8 -2
@@ -0,0 +1,193 @@
1
+ // The link to the host. It never gives up: after a drop it waits min(30 s, attempt × 1 s) and
2
+ // tries again, forever, and at once when the phone comes back online or the app comes to the
3
+ // front. A half-open socket (the phone switched networks, the Mac went away without a FIN) is
4
+ // caught by the app-level ping, not by TCP. Only a refused token stops it, because retrying a
5
+ // token the host rejects would just say "connecting" forever — which would be a lie.
6
+ import { BACKOFF_CAP_MS, PING_MS, READY_MS, STALE_MS } from "../src/protocol.ts";
7
+ import type { ClientMsg, HostMsg } from "../src/protocol.ts";
8
+
9
+ /** A socket that opens but never says `ready` is as good as down (through the relay: the handshake counts too). */
10
+ export { READY_MS };
11
+
12
+ export type LinkState = "connecting" | "open" | "waiting" | "offline" | "denied";
13
+
14
+ export interface LinkStatus {
15
+ state: LinkState;
16
+ /** consecutive failed attempts since the last good connection */
17
+ attempt: number;
18
+ /** when the next attempt starts (state "waiting") */
19
+ nextAt: number | null;
20
+ /** last time a frame arrived from the host; null if never */
21
+ lastRx: number | null;
22
+ /** when the current outage began; null while open */
23
+ downSince: number | null;
24
+ }
25
+
26
+ export interface SocketLike {
27
+ readyState: number;
28
+ send(data: string): void;
29
+ close(code?: number, reason?: string): void;
30
+ onopen: ((ev: unknown) => void) | null;
31
+ onmessage: ((ev: { data: unknown }) => void) | null;
32
+ onclose: ((ev: unknown) => void) | null;
33
+ onerror: ((ev: unknown) => void) | null;
34
+ }
35
+
36
+ export interface LinkDeps {
37
+ url: string;
38
+ token: string;
39
+ clientId: string;
40
+ makeSocket: (url: string) => SocketLike;
41
+ now: () => number;
42
+ setTimer: (fn: () => void, ms: number) => unknown;
43
+ clearTimer: (id: unknown) => void;
44
+ isOnline: () => boolean;
45
+ }
46
+
47
+ export function backoff(attempt: number): number {
48
+ return Math.min(BACKOFF_CAP_MS, Math.max(1, attempt) * 1000);
49
+ }
50
+
51
+ export class Link {
52
+ status: LinkStatus = { state: "connecting", attempt: 0, nextAt: null, lastRx: null, downSince: null };
53
+ onMessage: (msg: HostMsg) => void = () => {};
54
+ /** Called on every status change and once a second while waiting, so countdowns stay true. */
55
+ onStatus: (s: LinkStatus) => void = () => {};
56
+ /** Called after the host accepted the token: re-attach sessions, flush the outbox. */
57
+ onReady: () => void = () => {};
58
+
59
+ private deps: LinkDeps;
60
+ private sock: SocketLike | null = null;
61
+ private retryTimer: unknown = null;
62
+ private tickTimer: unknown = null;
63
+ private pingTimer: unknown = null;
64
+ private readyTimer: unknown = null;
65
+ private pingN = 0;
66
+ private stopped = false;
67
+
68
+ constructor(deps: LinkDeps) { this.deps = deps; }
69
+
70
+ start(): void { this.stopped = false; this.connect(); }
71
+
72
+ stop(): void {
73
+ this.stopped = true;
74
+ this.clearAll();
75
+ this.sock?.close(1000, "stop");
76
+ this.sock = null;
77
+ }
78
+
79
+ /** Network came back, app came to the front, or the user pressed "retry now". */
80
+ nudge(): void {
81
+ if (this.stopped || this.status.state === "denied") return;
82
+ if (this.status.state === "open") { this.ping(); return; }
83
+ this.clearAll();
84
+ this.connect();
85
+ }
86
+
87
+ send(msg: ClientMsg): boolean {
88
+ if (this.status.state !== "open" || !this.sock) return false;
89
+ try { this.sock.send(JSON.stringify(msg)); return true; } catch { return false; }
90
+ }
91
+
92
+ private set(patch: Partial<LinkStatus>): void {
93
+ this.status = { ...this.status, ...patch };
94
+ this.onStatus(this.status);
95
+ }
96
+
97
+ private clearAll(): void {
98
+ for (const id of [this.retryTimer, this.tickTimer, this.pingTimer, this.readyTimer]) if (id !== null) this.deps.clearTimer(id);
99
+ this.retryTimer = this.tickTimer = this.pingTimer = this.readyTimer = null;
100
+ }
101
+
102
+ private connect(): void {
103
+ if (this.stopped) return;
104
+ if (!this.deps.isOnline()) {
105
+ this.set({ state: "offline", nextAt: null, downSince: this.status.downSince ?? this.deps.now() });
106
+ this.schedule();
107
+ return;
108
+ }
109
+ this.set({ state: "connecting", nextAt: null });
110
+ let sock: SocketLike;
111
+ try { sock = this.deps.makeSocket(this.deps.url); } catch { this.fail(); return; }
112
+ this.sock = sock;
113
+ let authed = false;
114
+ sock.onopen = () => {
115
+ if (this.sock !== sock) return;
116
+ this.status.lastRx = this.deps.now();
117
+ sock.send(JSON.stringify({ t: "hello", token: this.deps.token, clientId: this.deps.clientId } satisfies ClientMsg));
118
+ this.armPing();
119
+ };
120
+ // From creation, not from open: a connect that hangs (a dead address after a network switch)
121
+ // may fire neither open nor close for minutes.
122
+ this.readyTimer = this.deps.setTimer(() => {
123
+ this.readyTimer = null;
124
+ if (authed || this.sock !== sock) return;
125
+ this.sock = null;
126
+ try { sock.close(4002, "no ready"); } catch { /* gone */ }
127
+ this.fail();
128
+ }, READY_MS);
129
+ sock.onmessage = (ev) => {
130
+ if (this.sock !== sock) return;
131
+ this.status.lastRx = this.deps.now();
132
+ let msg: HostMsg;
133
+ try { msg = JSON.parse(String(ev.data)) as HostMsg; } catch { return; }
134
+ if (msg.t === "denied") {
135
+ this.clearAll();
136
+ this.sock = null;
137
+ sock.close(4001, "denied");
138
+ this.set({ state: "denied", nextAt: null });
139
+ return;
140
+ }
141
+ if (msg.t === "ready" && !authed) {
142
+ authed = true;
143
+ this.set({ state: "open", attempt: 0, nextAt: null, downSince: null });
144
+ this.onReady();
145
+ }
146
+ if (msg.t !== "pong") this.onMessage(msg);
147
+ };
148
+ sock.onclose = () => { if (this.sock === sock) this.fail(); };
149
+ sock.onerror = () => { /* onclose follows */ };
150
+ }
151
+
152
+ private fail(): void {
153
+ this.sock = null;
154
+ this.clearAll();
155
+ if (this.stopped || this.status.state === "denied") return;
156
+ this.set({ attempt: this.status.attempt + 1, downSince: this.status.downSince ?? this.deps.now() });
157
+ this.schedule();
158
+ }
159
+
160
+ private schedule(): void {
161
+ const wait = backoff(this.status.attempt);
162
+ const nextAt = this.deps.now() + wait;
163
+ if (this.status.state !== "offline") this.set({ state: "waiting", nextAt });
164
+ this.retryTimer = this.deps.setTimer(() => { this.retryTimer = null; this.connect(); }, wait);
165
+ const tick = (): void => {
166
+ this.onStatus(this.status);
167
+ this.tickTimer = this.deps.setTimer(tick, 1000);
168
+ };
169
+ this.tickTimer = this.deps.setTimer(tick, 1000);
170
+ }
171
+
172
+ private armPing(): void {
173
+ const loop = (): void => {
174
+ this.ping();
175
+ this.pingTimer = this.deps.setTimer(loop, PING_MS);
176
+ };
177
+ this.pingTimer = this.deps.setTimer(loop, PING_MS);
178
+ }
179
+
180
+ private ping(): void {
181
+ const sock = this.sock;
182
+ if (!sock) return;
183
+ const last = this.status.lastRx ?? 0;
184
+ if (this.deps.now() - last > STALE_MS) {
185
+ // Stale: no frame, not even a pong, for 30 s. Drop it ourselves rather than wait for TCP.
186
+ this.sock = null;
187
+ try { sock.close(4000, "stale"); } catch { /* already gone */ }
188
+ this.fail();
189
+ return;
190
+ }
191
+ try { sock.send(JSON.stringify({ t: "ping", n: ++this.pingN } satisfies ClientMsg)); } catch { /* close follows */ }
192
+ }
193
+ }
@@ -0,0 +1,134 @@
1
+ // Fixture states for the design screenshots (`?demo=<name>&theme=light|dark`). They drive the
2
+ // real App renderer with canned data and no socket, so docs/media/remote shows the shipped UI.
3
+ import type { BrowseResult, EventBody, Figures, ProviderGroup, SessionEvent, SessionSummary } from "../src/protocol.ts";
4
+ import { App } from "./app.ts";
5
+
6
+ const HOME = "/Users/me";
7
+ const now = Date.now();
8
+ const min = 60_000;
9
+
10
+ const MODELS: ProviderGroup[] = [
11
+ { provider: "chatgpt", label: "ChatGPT", usable: true, models: [
12
+ { model: "gpt-6.1-sol", name: "GPT-6.1 Sol", provider: "chatgpt", efforts: ["low", "medium", "high", "xhigh", "max"], usable: true },
13
+ { model: "gpt-6-astra", name: "GPT-6 Astra", provider: "chatgpt", efforts: ["low", "medium", "high", "xhigh", "max"], usable: true },
14
+ { model: "gpt-6-luna", name: "GPT-6 Luna", provider: "chatgpt", efforts: ["low", "medium", "high", "xhigh", "max"], usable: true },
15
+ ] },
16
+ { provider: "anthropic", label: "Claude", usable: true, models: [
17
+ { model: "claude-opus-5-5", name: "Opus 5.5", provider: "anthropic", efforts: ["low", "medium", "high", "max"], usable: true },
18
+ { model: "claude-sonnet-5-5", name: "Sonnet 5.5", provider: "anthropic", efforts: ["low", "medium", "high", "max"], usable: true },
19
+ ] },
20
+ { provider: "opencode-go", label: "OpenCode Go", usable: true, models: [
21
+ { model: "deepseek-v4.1-flash", name: "deepseek-v4.1-flash", provider: "opencode-go", efforts: ["low", "high", "max"], usable: true },
22
+ { model: "glm-5.3-flash", name: "glm-5.3-flash", provider: "opencode-go", efforts: ["low", "high", "max"], usable: true },
23
+ ] },
24
+ { provider: "google-gemini", label: "Google Gemini", usable: false, models: [
25
+ { model: "gemini-3.8-flash", name: "gemini-3.8-flash", provider: "google-gemini", efforts: ["low", "medium", "high"], usable: false },
26
+ ] },
27
+ ];
28
+
29
+ const SESSIONS: SessionSummary[] = [
30
+ { id: "s1", title: "로그인 토큰 갱신이 두 번 도는 버그 고쳐줘", cwd: `${HOME}/code/dayripple`, model: "gpt-6.1-sol", effort: "high", state: "waiting", pending: 1, createdAt: now - 14 * min, updatedAt: now - 1 * min, lastSeq: 40, epoch: "e" },
31
+ { id: "s2", title: "README 영어판을 한국어판에 맞춰 갱신", cwd: `${HOME}/Documents/ClaudeRipple`, model: "deepseek-v4.1-flash", effort: "high", state: "running", pending: 0, createdAt: now - 6 * min, updatedAt: now - 0.2 * min, lastSeq: 22, epoch: "e" },
32
+ { id: "s3", title: "결제 웹훅 재시도 로직 설계 검토", cwd: `${HOME}/code/bodyripple`, model: "claude-opus-5-5", effort: "max", state: "idle", pending: 0, createdAt: now - 200 * min, updatedAt: now - 95 * min, lastSeq: 88, epoch: "e" },
33
+ { id: "s4", title: "테스트 깨진 것 원인만 찾아줘 (수정 금지)", cwd: `${HOME}/code/talkripple`, model: "glm-5.3-flash", effort: "low", state: "stopped", pending: 0, createdAt: now - 3000 * min, updatedAt: now - 1500 * min, lastSeq: 31, epoch: "e" },
34
+ ];
35
+
36
+ const FIG: Figures = { effort: "high", cachePct: 94, contextPct: 38, fiveHourPct: 12, fiveHourResetAt: now + 130 * min, workers: [{ model: "deepseek-v4.1-flash", provider: "opencode-go", requests: 12 }, { model: "gpt-6-luna", provider: "chatgpt", requests: 3 }], at: now - 20_000 };
37
+
38
+ function events(bodies: EventBody[], start = now - 9 * min): SessionEvent[] {
39
+ return bodies.map((b, i) => ({ ...b, seq: i + 1, at: start + i * 9000 } as SessionEvent));
40
+ }
41
+
42
+ const ANSWER = "원인을 찾았습니다. `refreshToken()`이 **두 곳**에서 불립니다.\n\n1. `useSession` 훅이 마운트될 때\n2. `apiClient`의 401 인터셉터가 같은 순간에\n\n둘이 동시에 돌면 첫 번째가 받은 토큰을 두 번째가 무효로 만듭니다. 인터셉터 쪽에 진행 중인 갱신을 공유하는 잠금을 두겠습니다.";
43
+
44
+ const CHAT = (streaming: boolean, perm: boolean): EventBody[] => [
45
+ { kind: "user", text: "로그인 토큰 갱신이 두 번 도는 버그 고쳐줘. 앱을 켜자마자 가끔 로그아웃돼.", clientMsgId: "c1" },
46
+ { kind: "turn", model: "gpt-6.1-sol", effort: "high" },
47
+ { kind: "state", state: "running" },
48
+ { kind: "figures", figures: FIG },
49
+ { kind: "assistant", msg: "m1", model: "gpt-6.1-sol", text: "먼저 토큰을 갱신하는 곳을 모두 찾아보겠습니다.", tools: [
50
+ { id: "t1", name: "Grep", summary: "refreshToken src/" },
51
+ { id: "t2", name: "Read", summary: "src/api/client.ts" },
52
+ ] },
53
+ { kind: "tool_result", toolUseId: "t1", ok: true, text: "src/hooks/useSession.ts:42\nsrc/api/client.ts:88" },
54
+ { kind: "tool_result", toolUseId: "t2", ok: true, text: "…" },
55
+ { kind: "subagent", toolUseId: "t3", sub: "sub-t3", description: "갱신 경쟁 상태를 재현하는 테스트 작성", model: "deepseek-v4.1-flash", status: "done", steps: 7 },
56
+ ...(streaming
57
+ ? [{ kind: "delta", msg: "m2", text: ANSWER.slice(0, 150) } as EventBody]
58
+ : [
59
+ { kind: "assistant", msg: "m2", model: "gpt-6.1-sol", text: ANSWER, tools: [{ id: "t4", name: "Edit", summary: "src/api/client.ts +14 −3" }] } as EventBody,
60
+ { kind: "tool_result", toolUseId: "t4", ok: true, text: "ok" } as EventBody,
61
+ ]),
62
+ ...(!streaming && !perm ? [
63
+ { kind: "result", ok: true, ms: 74_000, turns: 6 } as EventBody,
64
+ { kind: "state", state: "idle" } as EventBody,
65
+ ] : []),
66
+ ...(perm ? [
67
+ { kind: "state", state: "waiting" } as EventBody,
68
+ { kind: "permission", id: "p1", tool: "Bash", title: "테스트를 실행해도 될까요?", summary: "npm test -- src/api", input: "npm test -- src/api/client.test.ts --reporter=dot", canAlways: true } as EventBody,
69
+ ] : []),
70
+ ];
71
+
72
+ const BROWSE: BrowseResult = { path: `${HOME}/code`, parent: HOME, home: HOME, truncated: false, entries: ["bodyripple", "carlog", "dayripple", "mambo", "talkripple"].map((n) => ({ path: `${HOME}/code/${n}`, name: n })) };
73
+
74
+ export function runDemo(name: string, params: URLSearchParams): void {
75
+ const theme = params.get("theme");
76
+ if (theme) document.documentElement.dataset.theme = theme;
77
+ const open = { state: "open" as const, attempt: 0, nextAt: null, lastRx: now - 2000, downSince: null };
78
+ const app = new App(open);
79
+ app.ui.now = now;
80
+ app.ui.host = { name: "MacBook Pro", version: "0.8.10", routerOk: true };
81
+ app.ui.home = HOME;
82
+ app.ui.models = MODELS;
83
+ app.ui.sessions = SESSIONS;
84
+ app.ui.draft = { cwd: `${HOME}/Documents/ClaudeRipple`, model: "gpt-6.1-sol", effort: "high" };
85
+ app.recentModels = ["gpt-6.1-sol", "deepseek-v4.1-flash", "claude-opus-5-5"];
86
+ app.outbox = [];
87
+ const session = (evs: EventBody[], state: SessionSummary["state"] = "running"): void => {
88
+ app.ui.sessions = [{ ...SESSIONS[0]!, state, pending: 0 }];
89
+ const c = app.cache("s1");
90
+ c.events = events(evs);
91
+ c.epoch = "e";
92
+ app.screen = { name: "session", id: "s1" };
93
+ };
94
+ switch (name) {
95
+ case "pair": app.screen = { name: "pair" }; break;
96
+ case "home": app.screen = { name: "home" }; break;
97
+ case "home-empty": app.ui.sessions = []; app.screen = { name: "home" }; break;
98
+ case "folder":
99
+ app.screen = { name: "home" };
100
+ app.ui.sheet = { kind: "folder", loading: false, filter: "", browse: BROWSE, recent: [
101
+ { path: `${HOME}/Documents/ClaudeRipple`, name: "ClaudeRipple", lastUsed: now - 6 * min },
102
+ { path: `${HOME}/code/dayripple`, name: "dayripple", lastUsed: now - 14 * min },
103
+ { path: `${HOME}/code/bodyripple`, name: "bodyripple", lastUsed: now - 95 * min },
104
+ ] };
105
+ break;
106
+ case "model":
107
+ app.screen = { name: "home" };
108
+ app.ui.sheet = { kind: "model", groups: MODELS, recent: app.recentModels, model: "gpt-6.1-sol", effort: "high", forSession: null };
109
+ break;
110
+ case "stream": session(CHAT(true, false)); break;
111
+ case "permission": session(CHAT(false, true), "waiting"); break;
112
+ case "workers": session(CHAT(false, false), "idle"); app.ui.workersOpen = true; break;
113
+ case "reconnecting":
114
+ session(CHAT(true, false));
115
+ app.ui.link = { state: "waiting", attempt: 3, nextAt: now + 3000, lastRx: now - 9000, downSince: now - 9000 };
116
+ break;
117
+ case "lost":
118
+ session(CHAT(true, false));
119
+ app.ui.link = { state: "waiting", attempt: 9, nextAt: now + 9000, lastRx: now - 134_000, downSince: now - 134_000 };
120
+ app.outbox = [{ clientMsgId: "q1", session: "s1", text: "잠금 말고 단일 비행(single-flight)으로 해줘", at: now - 40_000, state: "pending" }];
121
+ break;
122
+ case "offline":
123
+ app.screen = { name: "home" };
124
+ app.ui.link = { state: "offline", attempt: 2, nextAt: null, lastRx: now - 50_000, downSince: now - 50_000 };
125
+ break;
126
+ }
127
+ app.render();
128
+ if (name === "stream" || name === "reconnecting" || name === "lost") {
129
+ // Clock pinned so "working for" reads the same on every render.
130
+ app.cache("s1").events.forEach((e) => { if (e.kind === "turn") e.at = now - 42_000; });
131
+ app.renderSession();
132
+ }
133
+ document.body.dataset.ready = "1";
134
+ }
@@ -0,0 +1,124 @@
1
+ import { t } from "./i18n.ts";
2
+
3
+ export { esc } from "./esc.ts";
4
+
5
+ const svg = (body: string, size = 20): string =>
6
+ `<svg class="ic" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
7
+
8
+ export const icon = {
9
+ back: (s?: number) => svg(`<path d="M15 5l-7 7 7 7"/>`, s),
10
+ chevron: (s?: number) => svg(`<path d="M9 6l6 6-6 6"/>`, s),
11
+ down: (s?: number) => svg(`<path d="M6 9l6 6 6-6"/>`, s),
12
+ folder: (s?: number) => svg(`<path d="M3.5 7.5a2 2 0 0 1 2-2h4l2 2.2h7a2 2 0 0 1 2 2v7.8a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2z"/>`, s),
13
+ model: (s?: number) => svg(`<path d="M12 3.5l7.5 4.3v8.4L12 20.5l-7.5-4.3V7.8z"/><path d="M12 12l7.3-4.1M12 12v8.3M12 12L4.7 7.9"/>`, s),
14
+ send: (s?: number) => svg(`<path d="M12 19V5M6 11l6-6 6 6"/>`, s),
15
+ stop: (s?: number) => `<svg class="ic" width="${s ?? 20}" height="${s ?? 20}" viewBox="0 0 24 24" aria-hidden="true"><rect x="6.5" y="6.5" width="11" height="11" rx="2.2" fill="currentColor"/></svg>`,
16
+ gear: (s?: number) => svg(`<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-1.8-.3 1.6 1.6 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.6 1.6 0 0 0-1-1.5 1.6 1.6 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.6 1.6 0 0 0 .3-1.8 1.6 1.6 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.6 1.6 0 0 0 1.5-1 1.6 1.6 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.6 1.6 0 0 0 1.8.3H9a1.6 1.6 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 1 1.5 1.6 1.6 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0-.3 1.8V9a1.6 1.6 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.6 1.6 0 0 0-1.5 1z"/>`, s),
17
+ terminal: (s?: number) => svg(`<path d="M5 7l5 5-5 5M12 18h7"/>`, s),
18
+ file: (s?: number) => svg(`<path d="M14 3.5H7a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8.5z"/><path d="M14 3.5v5h5"/>`, s),
19
+ pencil: (s?: number) => svg(`<path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16z"/><path d="M13.5 6.5l4 4"/>`, s),
20
+ search: (s?: number) => svg(`<circle cx="11" cy="11" r="6.5"/><path d="M20 20l-4.2-4.2"/>`, s),
21
+ globe: (s?: number) => svg(`<circle cx="12" cy="12" r="8.5"/><path d="M3.5 12h17M12 3.5c2.4 2.6 3.6 5.4 3.6 8.5s-1.2 5.9-3.6 8.5c-2.4-2.6-3.6-5.4-3.6-8.5s1.2-5.9 3.6-8.5z"/>`, s),
22
+ check: (s?: number) => svg(`<path d="M5 12.5l4.5 4.5L19 7.5"/>`, s),
23
+ x: (s?: number) => svg(`<path d="M6 6l12 12M18 6L6 18"/>`, s),
24
+ clock: (s?: number) => svg(`<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>`, s),
25
+ shield: (s?: number) => svg(`<path d="M12 3.5l7 2.8v5.2c0 4.4-3 7.8-7 9-4-1.2-7-4.6-7-9V6.3z"/>`, s),
26
+ task: (s?: number) => svg(`<path d="M8 6h11M8 12h11M8 18h11"/><circle cx="4.5" cy="6" r="1"/><circle cx="4.5" cy="12" r="1"/><circle cx="4.5" cy="18" r="1"/>`, s),
27
+ home: (s?: number) => svg(`<path d="M4 11l8-6.5 8 6.5"/><path d="M6 9.5V19h12V9.5"/>`, s),
28
+ bell: (s?: number) => svg(`<path d="M6 16.5V11a6 6 0 0 1 12 0v5.5l1.5 2h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/>`, s),
29
+ up: (s?: number) => svg(`<path d="M12 19V7M7 11l5-5 5 5"/><path d="M5 4.5h14"/>`, s),
30
+ };
31
+
32
+ export function toolIcon(name: string): string {
33
+ if (name === "Bash" || name === "BashOutput" || name === "KillShell") return icon.terminal(16);
34
+ if (name === "Edit" || name === "Write" || name === "MultiEdit" || name === "NotebookEdit") return icon.pencil(16);
35
+ if (name === "Read" || name === "Glob" || name === "Grep" || name === "LS") return name === "Read" ? icon.file(16) : icon.search(16);
36
+ if (name === "WebFetch" || name === "WebSearch") return icon.globe(16);
37
+ if (name === "Task" || name === "Agent") return icon.model(16);
38
+ if (name === "TodoWrite") return icon.task(16);
39
+ return icon.chevron(16);
40
+ }
41
+
42
+ export function relTime(at: number, now: number): string {
43
+ const s = Math.max(0, Math.round((now - at) / 1000));
44
+ if (s < 45) return t("time.now");
45
+ if (s < 3600) return t("time.min", { n: Math.max(1, Math.round(s / 60)) });
46
+ if (s < 86400) return t("time.hour", { n: Math.round(s / 3600) });
47
+ return t("time.day", { n: Math.round(s / 86400) });
48
+ }
49
+
50
+ export function duration(ms: number): string {
51
+ const s = Math.max(0, Math.round(ms / 1000));
52
+ if (s < 60) return t("dur.s", { n: s });
53
+ // An hour and up reads as hours and minutes ("1시간 47분"), not "107분".
54
+ if (s >= 3600) {
55
+ const m = Math.round(s / 60);
56
+ return m % 60 === 0 ? t("dur.h", { n: m / 60 }) : t("dur.hm", { h: Math.floor(m / 60), m: m % 60 });
57
+ }
58
+ if (s % 60 === 0 || s >= 600) return t("dur.m", { n: Math.round(s / 60) });
59
+ return t("dur.ms", { m: Math.floor(s / 60), s: s % 60 });
60
+ }
61
+
62
+ export function clock(at: number): string {
63
+ const d = new Date(at);
64
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
65
+ }
66
+
67
+ /** The last path segment, the way a folder is named in speech. `~` for the home folder. */
68
+ export function folderName(path: string, home?: string): string {
69
+ if (home && path === home) return "~";
70
+ const parts = path.split("/").filter(Boolean);
71
+ return parts[parts.length - 1] ?? "/";
72
+ }
73
+
74
+ export function shortPath(path: string, home?: string): string {
75
+ return home && (path === home || path.startsWith(home + "/")) ? "~" + path.slice(home.length) : path;
76
+ }
77
+
78
+ const lastHtml = new WeakMap<Element, string>();
79
+
80
+ /**
81
+ * Replace a region only when its markup changed. Regions re-render every second for countdowns;
82
+ * replacing an unchanged button under a thumb between touch-down and touch-up swallows the tap.
83
+ */
84
+ export function setHTML(el: Element | null, html: string): void {
85
+ if (!el || lastHtml.get(el) === html) return;
86
+ lastHtml.set(el, html);
87
+ el.innerHTML = html;
88
+ }
89
+
90
+ /**
91
+ * Keyed list patch: nodes whose key and version are unchanged stay in place (scroll position,
92
+ * text selection and expanded state survive a streaming update); changed ones are replaced.
93
+ */
94
+ export function patchList<T extends { key: string; ver: string }>(parent: HTMLElement, items: readonly T[], render: (item: T) => string): void {
95
+ const existing = new Map<string, HTMLElement>();
96
+ for (const child of Array.from(parent.children) as HTMLElement[]) {
97
+ const k = child.dataset.key;
98
+ if (k) existing.set(k, child);
99
+ else child.remove();
100
+ }
101
+ let cursor: Element | null = parent.firstElementChild;
102
+ const tpl = document.createElement("template");
103
+ for (const item of items) {
104
+ let node = existing.get(item.key);
105
+ if (node && node.dataset.ver !== item.ver) {
106
+ tpl.innerHTML = render(item).trim();
107
+ const fresh = tpl.content.firstElementChild as HTMLElement;
108
+ fresh.dataset.key = item.key;
109
+ fresh.dataset.ver = item.ver;
110
+ node.replaceWith(fresh);
111
+ if (cursor === node) cursor = fresh;
112
+ node = fresh;
113
+ } else if (!node) {
114
+ tpl.innerHTML = render(item).trim();
115
+ node = tpl.content.firstElementChild as HTMLElement;
116
+ node.dataset.key = item.key;
117
+ node.dataset.ver = item.ver;
118
+ }
119
+ existing.delete(item.key);
120
+ if (node !== cursor) parent.insertBefore(node, cursor);
121
+ else cursor = cursor.nextElementSibling;
122
+ }
123
+ for (const stale of existing.values()) stale.remove();
124
+ }
@@ -0,0 +1,4 @@
1
+ // HTML escaping with no DOM, so Node tests and the native app can import what uses it.
2
+ export function esc(s: string): string {
3
+ return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
4
+ }