clauderipple 0.9.4 → 0.10.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 (118) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +31 -0
  3. package/dist/app/package.json +1 -1
  4. package/dist/cli/src/hooks/agent-title.js +7 -4
  5. package/dist/cli/src/index.js +43 -0
  6. package/dist/cli/src/remote.js +263 -0
  7. package/dist/cli/src/schtasks.js +48 -40
  8. package/dist/cli/src/settings.js +2 -1
  9. package/dist/mod/hooks/i18n.ts +252 -6
  10. package/dist/mod/hooks/judge.ts +35 -17
  11. package/dist/mod/hooks/modes.ts +56 -1
  12. package/dist/mod/hooks/register.tsx +238 -32
  13. package/dist/mod/hooks/session.ts +98 -1
  14. package/dist/mod/hooks/settings.js +7 -3
  15. package/dist/mod/hooks/settings.ts +9 -4
  16. package/dist/mod/hooks/stats.ts +25 -1
  17. package/dist/mod/hooks/ui/agents.tsx +858 -79
  18. package/dist/mod/hooks/ui/alerts.tsx +20 -17
  19. package/dist/mod/hooks/ui/art.ts +33 -7
  20. package/dist/mod/hooks/ui/band.tsx +262 -81
  21. package/dist/mod/hooks/ui/flat.ts +265 -0
  22. package/dist/mod/hooks/ui/kit.tsx +70 -55
  23. package/dist/mod/hooks/ui/look.ts +129 -0
  24. package/dist/mod/hooks/ui/settings.tsx +71 -46
  25. package/dist/mod/hooks/ui/setup.tsx +15 -13
  26. package/dist/mod/hooks/ui/theme.ts +3 -0
  27. package/dist/mod/hooks/ui/tools.ts +124 -0
  28. package/dist/mod/hooks/ui/view.ts +116 -3
  29. package/dist/mod/hooks/workers.ts +33 -6
  30. package/dist/mod/tests/register.test.tsx +155 -11
  31. package/dist/mod/tests/ui.test.tsx +375 -27
  32. package/dist/mod/types/index.d.ts +68 -2
  33. package/dist/remote/package.json +28 -0
  34. package/dist/remote/src/away.js +85 -0
  35. package/dist/remote/src/bridge-client.js +295 -0
  36. package/dist/remote/src/composer-menu.js +11 -0
  37. package/dist/remote/src/config.js +102 -0
  38. package/dist/remote/src/desktop.js +1018 -0
  39. package/dist/remote/src/e2ee.js +126 -0
  40. package/dist/remote/src/e2ee.ts +126 -0
  41. package/dist/remote/src/eventlog.js +298 -0
  42. package/dist/remote/src/expo-push.js +127 -0
  43. package/dist/remote/src/folders.js +79 -0
  44. package/dist/remote/src/fsutil.js +39 -0
  45. package/dist/remote/src/handoff.js +38 -0
  46. package/dist/remote/src/host-service.js +168 -0
  47. package/dist/remote/src/images.js +215 -0
  48. package/dist/remote/src/local-control.js +138 -0
  49. package/dist/remote/src/main.js +264 -0
  50. package/dist/remote/src/permissions.js +124 -0
  51. package/dist/remote/src/power.js +76 -0
  52. package/dist/remote/src/processes.js +24 -0
  53. package/dist/remote/src/protocol.js +32 -0
  54. package/dist/remote/src/protocol.ts +284 -0
  55. package/dist/remote/src/push-content.js +32 -0
  56. package/dist/remote/src/push.js +134 -0
  57. package/dist/remote/src/receipts.js +111 -0
  58. package/dist/remote/src/relay.js +438 -0
  59. package/dist/remote/src/router.js +208 -0
  60. package/dist/remote/src/sdk-launcher.js +18 -0
  61. package/dist/remote/src/server.js +706 -0
  62. package/dist/remote/src/service.js +157 -0
  63. package/dist/remote/src/sessions.js +1800 -0
  64. package/dist/remote/src/sidebar-storage.js +211 -0
  65. package/dist/remote/src/sidebar.js +173 -0
  66. package/dist/remote/src/tool-details.js +205 -0
  67. package/dist/remote/src/tool-details.ts +153 -0
  68. package/dist/remote/src/worktree.js +99 -0
  69. package/dist/remote/web/app.ts +746 -0
  70. package/dist/remote/web/conn.ts +193 -0
  71. package/dist/remote/web/demo.ts +134 -0
  72. package/dist/remote/web/dom.ts +124 -0
  73. package/dist/remote/web/esc.ts +4 -0
  74. package/dist/remote/web/i18n.ts +374 -0
  75. package/dist/remote/web/icon.svg +1 -0
  76. package/dist/remote/web/index.html +24 -0
  77. package/dist/remote/web/manifest.webmanifest +10 -0
  78. package/dist/remote/web/mascot.ts +60 -0
  79. package/dist/remote/web/md.ts +78 -0
  80. package/dist/remote/web/notify.ts +55 -0
  81. package/dist/remote/web/pushstate.ts +27 -0
  82. package/dist/remote/web/secure.ts +109 -0
  83. package/dist/remote/web/sidebar.ts +106 -0
  84. package/dist/remote/web/style.css +311 -0
  85. package/dist/remote/web/sw.ts +48 -0
  86. package/dist/remote/web/timeline.ts +224 -0
  87. package/dist/remote/web/views.ts +323 -0
  88. package/dist/router/src/admin.js +227 -4
  89. package/dist/router/src/config.js +30 -0
  90. package/dist/router/src/index.js +39 -0
  91. package/dist/router/src/ingress/translate.js +6 -2
  92. package/dist/router/src/phone.js +82 -0
  93. package/dist/router/src/presets.js +3 -0
  94. package/dist/router/src/providers/anthropic-usage.js +33 -1
  95. package/dist/router/src/providers/chatgpt/index.js +41 -11
  96. package/dist/router/src/providers/chatgpt/translate.js +147 -7
  97. package/dist/router/src/providers/google/index.js +26 -0
  98. package/dist/router/src/providers/google/prompt-refusal.js +29 -0
  99. package/dist/router/src/providers/google/usage.js +257 -0
  100. package/dist/router/src/providers/grok/index.js +18 -0
  101. package/dist/router/src/providers/grok/usage.js +77 -0
  102. package/dist/router/src/providers/openai/index.js +29 -5
  103. package/dist/router/src/providers/openai/translate.js +260 -27
  104. package/dist/router/src/providers/opencode-usage.js +116 -0
  105. package/dist/router/src/proxy.js +339 -258
  106. package/dist/router/src/reasoning.js +157 -0
  107. package/dist/router/src/remote-host.js +320 -0
  108. package/dist/router/src/remote-tunnel.js +320 -0
  109. package/dist/router/src/requestlog.js +48 -39
  110. package/dist/router/src/thinking.js +20 -9
  111. package/dist/router/src/usage-history.js +227 -0
  112. package/dist/router/src/version.js +1 -1
  113. package/dist/ui/app.js +436 -59
  114. package/dist/ui/i18n.js +184 -0
  115. package/dist/ui/index.html +24 -5
  116. package/dist/ui/style.css +39 -0
  117. package/docs/ARCHITECTURE.md +164 -12
  118. package/package.json +7 -1
@@ -0,0 +1,224 @@
1
+ // Events → what the timeline shows. Pure, so the same reduction runs in tests and in the app.
2
+ import type { PermissionContent, Effort, Figures, ImagePart, SessionEvent, SessionState, Text, ToolCall } from "../src/protocol.ts";
3
+
4
+ export interface ToolView extends ToolCall { result?: { ok: boolean; text: Text; images?: ImagePart[] } }
5
+
6
+ export type Item =
7
+ | { type: "user"; key: string; ver: string; text: string; images?: ImagePart[]; at: number; queued?: "pending" | "sending" }
8
+ | { type: "assistant"; key: string; ver: string; text: Text; images?: ImagePart[]; streaming: boolean; tools: ToolView[]; model: string; at: number }
9
+ | { type: "thinking"; key: string; ver: string; text: Text; streaming: boolean; msg: string; at: number }
10
+ | { type: "subagent"; key: string; ver: string; toolUseId: string; sub: string; description: string; model: string | null; status: "running" | "done" | "failed"; steps: number }
11
+ | (PermissionContent & { type: "permission"; key: string; ver: string; id: string; tool: string; title: string; summary: string; input: Text; canAlways: boolean; decision: null | "allow" | "allow_always" | "deny" | "expired"; reason?: string; at: number })
12
+ | { type: "result"; key: string; ver: string; ok: boolean; error?: string; ms: number; model: string; effort: Effort | null }
13
+ | { type: "notice"; key: string; ver: string; code: string; text?: string }
14
+ | { type: "start"; key: string; ver: string };
15
+
16
+ export interface SessionView {
17
+ items: Item[];
18
+ state: SessionState | null;
19
+ figures: Figures | null;
20
+ model: string | null;
21
+ effort: Effort | null;
22
+ /** open permission requests, oldest first */
23
+ open: Extract<Item, { type: "permission" }>[];
24
+ /** when the current turn began, for the "working for" line */
25
+ turnStartedAt: number | null;
26
+ lastSeq: number;
27
+ }
28
+
29
+ export interface Outgoing { clientMsgId: string; text: string; at: number; state: "pending" | "sending" }
30
+
31
+ /**
32
+ * Reduce a session's loaded events (ascending seq; gaps allowed, duplicates ignored) and the
33
+ * messages this phone has not seen acknowledged yet.
34
+ */
35
+ export function reduce(events: readonly SessionEvent[], outbox: readonly Outgoing[] = [], atStart = false): SessionView {
36
+ const items: Item[] = [];
37
+ const byKey = new Map<string, number>();
38
+ const toolOwner = new Map<string, { item: number; tool: number }>();
39
+ const seenUser = new Set<string>();
40
+ let state: SessionState | null = null;
41
+ let figures: Figures | null = null;
42
+ let model: string | null = null;
43
+ let effort: Effort | null = null;
44
+ let turnStartedAt: number | null = null;
45
+ let lastSeq = 0;
46
+ const put = (item: Item): void => {
47
+ const at = byKey.get(item.key);
48
+ if (at === undefined) { byKey.set(item.key, items.length); items.push(item); }
49
+ else items[at] = item;
50
+ };
51
+ if (atStart) put({ type: "start", key: "start", ver: "1" });
52
+ for (const e of events) {
53
+ if (e.seq <= lastSeq) continue;
54
+ lastSeq = e.seq;
55
+ switch (e.kind) {
56
+ case "user":
57
+ seenUser.add(e.clientMsgId);
58
+ // The app counts "working for" from the person's message; Mac sessions have no turn event to say so.
59
+ turnStartedAt = e.at;
60
+ put({ type: "user", key: `u${e.clientMsgId}`, ver: `u${e.seq}`, text: e.text, ...(e.images ? { images: e.images } : {}), at: e.at });
61
+ break;
62
+ case "turn":
63
+ model = e.model; effort = e.effort; turnStartedAt = e.at;
64
+ break;
65
+ case "thinking": {
66
+ const key = `h${e.msg}:${e.block}`;
67
+ const prev = byKey.has(key) ? items[byKey.get(key)!] : undefined;
68
+ const text = e.delta ? (prev?.type === "thinking" && typeof prev.text === "string" ? prev.text : "") + (typeof e.text === "string" ? e.text : e.text.preview) : e.text;
69
+ put({ type: "thinking", key, ver: `h${e.seq}`, msg: e.msg, text, streaming: e.delta === true, at: e.at });
70
+ break;
71
+ }
72
+ case "delta": {
73
+ const key = `a${e.msg}`;
74
+ const prev = byKey.has(key) ? items[byKey.get(key)!] : undefined;
75
+ const text = (prev?.type === "assistant" && typeof prev.text === "string" ? prev.text : "") + e.text;
76
+ put({ type: "assistant", key, ver: `d${e.seq}`, text, streaming: true, tools: prev?.type === "assistant" ? prev.tools : [], model: model ?? "", at: e.at });
77
+ break;
78
+ }
79
+ case "assistant": {
80
+ const key = `a${e.msg}`;
81
+ const previous = byKey.has(key) ? items[byKey.get(key)!] : undefined;
82
+ const results = new Map(previous?.type === "assistant" ? previous.tools.map(tc => [tc.id, tc.result]) : []);
83
+ const tools: ToolView[] = e.tools.map(tc => ({ ...tc, ...(results.get(tc.id) ? { result: results.get(tc.id)! } : {}) }));
84
+ put({ type: "assistant", key, ver: `f${e.seq}`, text: e.text, ...(e.images ? { images: e.images } : {}), streaming: false, tools, model: e.model, at: e.at });
85
+ const idx = byKey.get(key)!;
86
+ tools.forEach((tc, i) => toolOwner.set(tc.id, { item: idx, tool: i }));
87
+ break;
88
+ }
89
+ case "tool_result": {
90
+ const owner = toolOwner.get(e.toolUseId);
91
+ const item = owner ? items[owner.item] : undefined;
92
+ if (owner && item?.type === "assistant") {
93
+ const tools = item.tools.slice();
94
+ tools[owner.tool] = { ...tools[owner.tool]!, result: { ok: e.ok, text: e.text, ...(e.images ? { images: e.images } : {}) } };
95
+ items[owner.item] = { ...item, tools, ver: `${item.ver}r${e.seq}` };
96
+ }
97
+ break;
98
+ }
99
+ case "subagent":
100
+ put({ type: "subagent", key: `s${e.toolUseId}`, ver: `${e.seq}`, toolUseId: e.toolUseId, sub: e.sub, description: e.description, model: e.model, status: e.status, steps: e.steps });
101
+ break;
102
+ case "permission":
103
+ put({ type: "permission", key: `p${e.id}`, ver: `${e.seq}`, id: e.id, tool: e.tool, title: e.title, summary: e.summary, input: e.input, canAlways: e.canAlways, ...(e.requestKind ? { requestKind: e.requestKind } : {}), ...(e.questions ? { questions: e.questions } : {}), decision: null, at: e.at });
104
+ break;
105
+ case "permission_decided": {
106
+ const at = byKey.get(`p${e.id}`);
107
+ const item = at === undefined ? undefined : items[at];
108
+ if (item?.type === "permission") items[at!] = { ...item, decision: e.decision, ...(e.reason ? { reason: e.reason } : {}), ver: `${item.ver}d${e.seq}` };
109
+ break;
110
+ }
111
+ case "result":
112
+ // A queued next turn can already be running in the final snapshot.
113
+ // Completion belongs to this point in the log, not only to the final state.
114
+ for (const item of items) if ((item.type === "thinking" || item.type === "assistant") && item.streaming) { item.streaming = false; item.ver += "s"; }
115
+ put({ type: "result", key: `r${e.seq}`, ver: "1", ok: e.ok, ...(e.error ? { error: e.error } : {}), ms: e.ms, model: model ?? "", effort });
116
+ turnStartedAt = null;
117
+ break;
118
+ case "state":
119
+ state = e.state;
120
+ // Idle ends a Mac session's turn (it has no result event); "stopped" is a host restart's blip, not an end.
121
+ if (e.state === "idle" || e.state === "error") turnStartedAt = null;
122
+ else if (e.state === "running" && turnStartedAt === null) turnStartedAt = e.at;
123
+ break;
124
+ case "figures":
125
+ figures = e.figures;
126
+ break;
127
+ case "notice":
128
+ put({ type: "notice", key: `n${e.seq}`, ver: "1", code: e.code, ...(e.text ? { text: e.text } : {}) });
129
+ break;
130
+ }
131
+ }
132
+ for (const o of outbox) {
133
+ if (seenUser.has(o.clientMsgId)) continue;
134
+ put({ type: "user", key: `u${o.clientMsgId}`, ver: o.state, text: o.text, at: o.at, queued: o.state });
135
+ }
136
+ for (const it of items) if ((it.type === "assistant" || it.type === "thinking") && it.streaming && state !== "running" && state !== "starting" && state !== "waiting") { it.streaming = false; it.ver += "s"; }
137
+ const open = items.filter((i): i is Extract<Item, { type: "permission" }> => i.type === "permission" && i.decision === null);
138
+ return { items, state, figures, model, effort, open, turnStartedAt, lastSeq };
139
+ }
140
+
141
+ export type ActivityRow = { type: "tool"; tool: ToolView } | (Extract<Item, { type: "subagent" }> & { call?: ToolView });
142
+ export interface ToolGroup {
143
+ type: "toolGroup"; key: string; ver: string; rows: ActivityRow[];
144
+ tools: number; workers: number; running: boolean; latest: string;
145
+ }
146
+ export type TimelineItem = Item | ToolGroup;
147
+
148
+ /** A tool call that draws a "visualize" widget rather than doing work: `show_widget` or `…__show_widget`. */
149
+ export const isWidgetTool = (name: string): boolean => name === "show_widget" || name.endsWith("__show_widget");
150
+
151
+ /** Presentation only: text, permissions and turn boundaries separate consecutive activity. */
152
+ export function timelineItems(view: Pick<SessionView, "items" | "state">): TimelineItem[] {
153
+ const result: TimelineItem[] = [];
154
+ const workers = new Map(view.items.flatMap(i => i.type === "subagent" ? [[i.toolUseId, i] as const] : []));
155
+ const owned = new Set(view.items.flatMap(i => i.type === "assistant" ? i.tools.map(t => t.id) : []));
156
+ let group: ToolGroup | null = null;
157
+ const add = (row: ActivityRow, key: string, ver: string): void => {
158
+ if (!group) {
159
+ group = { type: "toolGroup", key: `g${key}`, ver: "", rows: [], tools: 0, workers: 0, running: false, latest: "" };
160
+ result.push(group);
161
+ }
162
+ group.rows.push(row);
163
+ group.ver += ver;
164
+ if (row.type === "tool") {
165
+ if (row.tool.name === "Agent" || row.tool.name === "Task") group.workers++;
166
+ else group.tools++;
167
+ group.latest = row.tool.summary || row.tool.name;
168
+ }
169
+ else { group.workers++; group.latest = row.description; }
170
+ };
171
+ for (const item of view.items) {
172
+ if (item.type === "subagent") {
173
+ if (!owned.has(item.toolUseId)) add(item, item.key, item.ver);
174
+ continue;
175
+ }
176
+ if (item.type === "assistant") {
177
+ if (typeof item.text !== "string" || item.text.trim() || item.images?.length || item.streaming) {
178
+ group = null;
179
+ result.push({ ...item, tools: [] });
180
+ }
181
+ for (const tool of item.tools) {
182
+ // A widget is something to look at, not activity: it stands on its own instead of folding away.
183
+ if (isWidgetTool(tool.name)) {
184
+ group = null;
185
+ result.push({ ...item, key: `${item.key}:${tool.id}`, text: "", images: [], streaming: false, tools: [tool] });
186
+ continue;
187
+ }
188
+ const worker = workers.get(tool.id);
189
+ if (worker) add({ ...worker, call: tool }, worker.key, `${worker.ver}:${item.ver}`);
190
+ else add({ type: "tool", tool }, tool.id, item.ver);
191
+ }
192
+ continue;
193
+ }
194
+ // As in the Claude app's default view, thinking is not drawn and does not split a run (most of it
195
+ // has its text withheld anyway). Only thinking still streaming at the very end shows, as a live line.
196
+ if (item.type === "thinking") continue;
197
+ group = null;
198
+ result.push(item);
199
+ }
200
+ const tail = view.items.at(-1);
201
+ if (tail?.type === "thinking" && tail.streaming) result.push(tail);
202
+ // Only the final activity run of the active turn spins, even between tool results.
203
+ const last = result.at(-1);
204
+ if (last?.type === "toolGroup" && (view.state === "running" || view.state === "starting" || view.state === "waiting")) {
205
+ last.running = true;
206
+ last.ver += "running";
207
+ }
208
+ return result;
209
+ }
210
+
211
+ /** Merge a page into a sorted event list without duplicates (pages may overlap after a reconnect). */
212
+ export function mergeEvents(into: SessionEvent[], page: readonly SessionEvent[]): SessionEvent[] {
213
+ if (page.length === 0) return into;
214
+ const first = page[0]!.seq;
215
+ const last = page[page.length - 1]!.seq;
216
+ const head = into.length ? into[0]!.seq : Infinity;
217
+ const tail = into.length ? into[into.length - 1]!.seq : -Infinity;
218
+ if (first > tail) return into.concat(page);
219
+ if (last < head) return page.concat(into);
220
+ const map = new Map<number, SessionEvent>();
221
+ for (const e of into) map.set(e.seq, e);
222
+ for (const e of page) map.set(e.seq, e);
223
+ return Array.from(map.values()).sort((a, b) => a.seq - b.seq);
224
+ }
@@ -0,0 +1,323 @@
1
+ // Screens as strings of markup. The app and the screenshot fixtures both call these, so the
2
+ // mockups in docs/media/remote are the shipped UI, not a drawing of it.
3
+ import type { BrowseResult, Effort, FolderEntry, HostInfo, ProviderGroup, SessionSummary, Text, ImagePart } from "../src/protocol.ts";
4
+ import type { LinkStatus } from "./conn.ts";
5
+ import type { PushAvail } from "./pushstate.ts";
6
+ import { clock, duration, esc, folderName, icon, relTime, shortPath, toolIcon } from "./dom.ts";
7
+ import { t, lang } from "./i18n.ts";
8
+ import { activitySummary } from "../src/tool-details.ts";
9
+ import type { Key } from "./i18n.ts";
10
+ import { markdown } from "./md.ts";
11
+ import { ripple, stateRing } from "./mascot.ts";
12
+ import { sidebarSections, sidebarState } from "./sidebar.ts";
13
+ import type { TimelineItem, SessionView, ToolView } from "./timeline.ts";
14
+
15
+ export interface Draft { cwd: string | null; model: string | null; effort: Effort | null }
16
+
17
+ export type Sheet =
18
+ | { kind: "folder"; recent: FolderEntry[]; browse: BrowseResult | null; filter: string; loading: boolean }
19
+ | { kind: "model"; groups: ProviderGroup[]; recent: string[]; model: string | null; effort: Effort | null; forSession: string | null };
20
+
21
+ export interface UI {
22
+ now: number;
23
+ link: LinkStatus;
24
+ host: HostInfo | null;
25
+ home: string | undefined;
26
+ sessions: SessionSummary[];
27
+ groups?: string[] | undefined;
28
+ draft: Draft;
29
+ models: ProviderGroup[];
30
+ sheet: Sheet | null;
31
+ /** expanded tool outputs, by tool-use id, with the text once fetched */
32
+ expanded: Map<string, string | null>;
33
+ workersOpen: boolean;
34
+ permOpen: Set<string>;
35
+ toast: string | null;
36
+ /** whether notifications can work here, and are on; null until known */
37
+ push?: PushAvail | null;
38
+ /** the person said "later" to the notification card */
39
+ pushLater?: boolean;
40
+ }
41
+
42
+ // ---- small helpers -----------------------------------------------------------------------------
43
+
44
+ export function modelName(models: ProviderGroup[], id: string | null): string {
45
+ if (!id) return t("chip.model");
46
+ for (const g of models) for (const m of g.models) if (m.model === id) return m.name;
47
+ return id;
48
+ }
49
+
50
+ function effortLabel(e: Effort | null): string {
51
+ return e ? t(`effort.${e}` as Key) : "";
52
+ }
53
+
54
+ function textOf(v: Text): string {
55
+ return typeof v === "string" ? v : v.preview + "…";
56
+ }
57
+
58
+ /** A link is "lost" (not merely reconnecting) once it has been down for 30 s: say so plainly. */
59
+ export function linkKind(link: LinkStatus, now: number): "open" | "connecting" | "waiting" | "offline" | "denied" | "lost" {
60
+ if (link.state === "open" || link.state === "denied" || link.state === "offline") return link.state;
61
+ if (link.downSince !== null && now - link.downSince > 30_000) return "lost";
62
+ return link.state;
63
+ }
64
+
65
+ /** What copy calls the connected computer: its name once known, the neutral fallback before that. */
66
+ export function hostName(ui: UI): string {
67
+ return ui.host?.name ?? t("host.fallback");
68
+ }
69
+
70
+ export function linkPill(ui: UI): string {
71
+ const kind = linkKind(ui.link, ui.now);
72
+ const host = hostName(ui);
73
+ let label: string;
74
+ if (kind === "open") label = t("conn.open");
75
+ else if (kind === "offline") label = t("conn.offline");
76
+ else if (kind === "denied") label = t("conn.denied.title");
77
+ else if (ui.link.state === "waiting" && ui.link.nextAt) label = t("conn.retryIn", { s: Math.max(1, Math.ceil((ui.link.nextAt - ui.now) / 1000)) });
78
+ else label = ui.link.attempt > 0 ? t("conn.retrying") : t("conn.connecting");
79
+ return `<button class="link ${kind}" data-act="retry" aria-live="polite"><span class="dot"></span><span class="host">${esc(host)}</span><span>${esc(label)}</span></button>`;
80
+ }
81
+
82
+ /** The connection banner. Nothing when all is well; a slim line while retrying briefly; a card once it is a real outage. */
83
+ export function banner(ui: UI): string {
84
+ const kind = linkKind(ui.link, ui.now);
85
+ const seen = ui.link.lastRx ? t("conn.lastSeen", { time: clock(ui.link.lastRx) }) : "";
86
+ if (kind === "open") {
87
+ if (ui.host && !ui.host.routerOk) return `<div class="banner warn">${ripple("uneasy", 34)}<div class="txt">${esc(t("conn.router", { host: hostName(ui) }))}</div></div>`;
88
+ if (ui.host?.desktopProblem) return `<div class="banner warn">${ripple("uneasy", 34)}<div class="txt">${esc(t("conn.desktop", { problem: ui.host.desktopProblem }))}</div></div>`;
89
+ return "";
90
+ }
91
+ if (kind === "denied") {
92
+ return `<div class="banner bad">${ripple("sorry", 40)}<div class="txt"><b>${esc(t("conn.denied.title"))}</b>${esc(t("conn.denied.body", { host: hostName(ui) }))}</div></div>`;
93
+ }
94
+ if (kind === "offline") {
95
+ return `<div class="banner bad">${ripple("sleepy", 40)}<div class="txt"><b>${esc(t("conn.offline.title"))}</b>${esc(t("conn.offline.body"))}${seen ? `<small>${esc(seen)}</small>` : ""}</div></div>`;
96
+ }
97
+ if (kind === "lost") {
98
+ const ago = duration(ui.now - (ui.link.downSince ?? ui.now));
99
+ return `<div class="banner bad">${ripple("sleepy", 40)}<div class="txt"><b>${esc(t("conn.lost.title", { host: hostName(ui) }))}</b>${esc(t("conn.lost.body", { ago, host: hostName(ui) }))}${seen ? `<small>${esc(seen)}</small>` : ""}<div class="act"><button class="btn primary" data-act="retry">${esc(t("conn.retryNow"))}</button></div></div></div>`;
100
+ }
101
+ if (ui.link.attempt === 0) return "";
102
+ return `<div class="banner slim">${icon.clock(16)}<span>${esc(ui.link.lastRx ? t("conn.stale", { time: clock(ui.link.lastRx) }) : t("conn.connecting"))}</span></div>`;
103
+ }
104
+
105
+ // ---- home --------------------------------------------------------------------------------------
106
+
107
+ function sessionRow(s: SessionSummary, ui: UI): string {
108
+ const tag = s.pending > 0
109
+ ? `<span class="tag">${esc(t("state.waiting"))}${s.pending > 1 ? ` ${s.pending}` : ""}</span>`
110
+ : s.state === "running" || s.state === "starting" ? `<span class="tag run">${esc(t("state.running"))}</span>` : "";
111
+ return `<button class="row" data-act="open" data-id="${esc(s.id)}">${s.state === "error" && !s.pending ? ripple("sorry", 24) : stateRing(sidebarState(s), 24)}<div class="body"><div class="t1">${esc(s.title)}</div><div class="t2"><span>${esc(folderName(s.cwd, ui.home))}</span><span class="sep">·</span><span class="m">${esc(modelName(ui.models, s.model))}</span><span class="sep">·</span><span>${esc(relTime(s.updatedAt, ui.now))}</span></div></div>${tag}</button>`;
112
+ }
113
+
114
+ export function homeMain(ui: UI): string {
115
+ const sections = sidebarSections(ui.sessions, ui.groups);
116
+ // While the link is down the states below are the last ones seen, and look it.
117
+ const stale = ui.link.state === "open" ? "" : " stale";
118
+ const sec = (title: string, list: SessionSummary[]): string =>
119
+ list.length ? `<div class="sec">${esc(title)}</div><div class="list${stale}">${list.map((s) => sessionRow(s, ui)).join("")}</div>` : "";
120
+ const empty = ui.sessions.length === 0
121
+ ? `<div class="empty">${ripple("calm", 84)}<b>${esc(t("home.empty.title"))}</b><p>${esc(t("home.empty.body", { host: hostName(ui) }))}</p><div class="hint">${icon.down(22)}</div></div>`
122
+ : "";
123
+ return `<div class="lt">${esc(t("home.title"))}</div>${banner(ui)}${sections.map(g => sec(g.name, g.sessions)).join("")}${empty}${pushCard(ui)}<div style="height:12px"></div>`;
124
+ }
125
+
126
+ /** Below the list, near the thumb: turn notifications on, or what it takes on this phone. Gone once on or dismissed. */
127
+ export function pushCard(ui: UI): string {
128
+ const p = ui.push;
129
+ if (!p || p === "on" || p === "unsupported" || ui.pushLater) return "";
130
+ const later = `<button class="btn ghost" data-act="push-later">${esc(t("push.later"))}</button>`;
131
+ const body = p === "off" ? t("push.body") : p === "install" ? t("push.install") : p === "denied" ? t("push.denied") : t("push.insecure", { host: hostName(ui) });
132
+ const action = p === "off" ? `<button class="btn primary" data-act="push-on">${icon.bell(18)}${esc(t("push.on"))}</button>` : "";
133
+ return `<div class="banner push">${icon.bell(22)}<div class="txt"><b>${esc(t("push.title"))}</b>${esc(body)}<div class="act">${action}${later}</div></div></div>`;
134
+ }
135
+
136
+ export function homeTop(ui: UI): string {
137
+ return `${linkPill(ui)}<span class="spacer"></span><button class="icon-btn" data-act="settings" aria-label="${esc(t("a11y.settings"))}">${icon.gear(21)}</button>`;
138
+ }
139
+
140
+ export function chips(ui: UI): string {
141
+ const folder = ui.draft.cwd ? folderName(ui.draft.cwd, ui.home) : t("chip.folder");
142
+ const eff = ui.draft.effort ? `<span class="eff">${esc(effortLabel(ui.draft.effort))}</span>` : "";
143
+ return `<div class="chips"><button class="chip folder" data-act="pick-folder">${icon.folder(18)}<span class="lbl">${esc(folder)}</span></button><button class="chip model" data-act="pick-model">${icon.model(18)}<span class="lbl">${esc(modelName(ui.models, ui.draft.model))}</span>${eff}<span class="down">${icon.down(16)}</span></button></div>`;
144
+ }
145
+
146
+ export function composer(placeholder: string, mode: "send" | "stop", value = ""): string {
147
+ const btn = mode === "stop"
148
+ ? `<button class="send stop" data-act="stop" aria-label="${esc(t("composer.stop"))}">${icon.stop(18)}</button>`
149
+ : `<button class="send" data-act="send" aria-label="${esc(t("composer.send"))}" ${value.trim() ? "" : "disabled"}>${icon.send(20)}</button>`;
150
+ return `<div class="composer"><textarea id="input" rows="1" placeholder="${esc(placeholder)}" enterkeyhint="send" autocomplete="off">${esc(value)}</textarea>${btn}</div>`;
151
+ }
152
+
153
+ // ---- session -----------------------------------------------------------------------------------
154
+
155
+ export function sessionTop(ui: UI, s: SessionSummary | undefined, view: SessionView): string {
156
+ const model = view.model ?? s?.model ?? null;
157
+ const eff = view.effort ?? s?.effort ?? null;
158
+ const state = s?.desktopBridge ? s.state : view.open.length ? "waiting" : view.state ?? s?.state ?? "stopped";
159
+ if (s?.readOnly) return `<button class="icon-btn" data-act="home" aria-label="${esc(t("a11y.back"))}">${icon.back(22)}</button><div class="title"><b>${esc(s.title || folderName(s.cwd, ui.home))}</b><span>${esc(modelName(ui.models, model))}</span></div>${stateRing("stopped", 24)}`;
160
+ return `<button class="icon-btn" data-act="home" aria-label="${esc(t("a11y.back"))}">${icon.back(22)}</button><div class="title"><b>${esc(s ? s.title || folderName(s.cwd, ui.home) : "")}</b><button data-act="pick-model-session"><span>${esc(modelName(ui.models, model))}${eff ? ` · ${esc(effortLabel(eff))}` : ""}</span>${icon.down(14)}</button></div><span class="icon-btn${ui.link.state === "open" ? "" : " stale"}" aria-label="${esc(t(`state.${state}` as Key))}">${stateRing(state, 24)}</span>`;
161
+ }
162
+
163
+ function pct(v: number | null): string {
164
+ return v === null ? `<div class="v na">—</div>` : `<div class="v">${Math.round(v)}%</div>`;
165
+ }
166
+
167
+ export function band(ui: UI, view: SessionView): string {
168
+ const f = view.figures;
169
+ const ctx = f?.contextPct ?? null;
170
+ const five = f?.fiveHourPct ?? null;
171
+ const tone = (v: number | null): string => (v === null ? "" : v >= 85 ? "bad" : v >= 65 ? "warn" : "");
172
+ const bar = (v: number | null): string => `<div class="bar ${tone(v)}"><i style="width:${v === null ? 0 : Math.min(100, Math.max(2, v))}%"></i></div>`;
173
+ const cells = `<div class="cell"><div class="k">${esc(t("band.effort"))}</div>${f?.effort ? `<div class="v">${esc(effortLabel(f.effort))}</div>` : `<div class="v na">—</div>`}</div>` +
174
+ `<div class="cell"><div class="k">${esc(t("band.cache"))}</div>${pct(f?.cachePct ?? null)}</div>` +
175
+ `<div class="cell"><div class="k">${esc(t("band.context"))}</div>${pct(ctx)}${bar(ctx)}</div>` +
176
+ `<div class="cell"><div class="k">${esc(t("band.fiveHour"))}</div>${pct(five)}${bar(five)}</div>`;
177
+ let workers = "";
178
+ if (ui.workersOpen) {
179
+ const list = f?.workers ?? [];
180
+ const rows = list.length
181
+ ? list.map((w) => `<div class="w"><span>${esc(w.model)}</span><span>${esc(t("band.workerLine", { n: w.requests }))}</span></div>`).join("")
182
+ : `<div class="none">${esc(t("band.workersNone"))}</div>`;
183
+ const foot = [f?.fiveHourResetAt ? t("band.resetsAt", { time: clock(f.fiveHourResetAt) }) : "", f ? t("band.updated", { time: clock(f.at) }) : ""].filter(Boolean).join(" · ");
184
+ workers = `<div class="workers"><div class="k" style="font-size:12px;color:var(--muted);font-weight:600;margin-bottom:4px">${esc(t("band.workers"))}</div>${rows}${foot ? `<div class="foot">${esc(foot)}</div>` : ""}</div>`;
185
+ }
186
+ return `<button class="band${ui.link.state === "open" ? "" : " stale"}" data-act="workers" aria-expanded="${ui.workersOpen}">${cells}</button>${workers}`;
187
+ }
188
+
189
+ function images(parts?: ImagePart[]): string {
190
+ return (parts ?? []).map(part => part.blob
191
+ ? `<button class="conversation-image" data-act="image" data-blob="${esc(part.blob)}" data-media="${esc(part.mediaType)}" aria-label="${esc(t('image.view'))}"><span>${esc(t('tool.loading'))}</span><img hidden alt="${esc(t('image.view'))}" /></button>`
192
+ : `<div class="image-unavailable">${esc(t('image.unavailable'))}</div>`).join('');
193
+ }
194
+
195
+ function toolRow(tool: ToolView, ui: UI): string {
196
+ const open = ui.expanded.has(tool.id);
197
+ const st = !tool.result ? `<span class="st run">${icon.clock(15)}</span>` : tool.result.ok ? `<span class="st">${icon.check(15)}</span>` : `<span class="st bad">${icon.x(15)}</span>`;
198
+ const row = `<button class="tool" data-act="tool" data-id="${esc(tool.id)}" aria-expanded="${open}">${toolIcon(tool.name)}<span class="nm">${esc(tool.name)}</span><span class="sm">${esc(tool.summary)}</span>${st}</button>`;
199
+ if (!open) return row;
200
+ const fetched = ui.expanded.get(tool.id);
201
+ const body = fetched ?? [tool.input ? textOf(tool.input) : "", tool.result ? textOf(tool.result.text) : ""].filter(Boolean).join("\n\n────\n\n");
202
+ return row + (body ? `<details class="tool-details"><summary>${esc(t("perm.showAll"))}</summary><div class="tool-out">${esc(body)}</div></details><div class="tool-preview tool-out">${esc(body.slice(0, 1200).split("\n").slice(0, 12).join("\n"))}${body.length > 1200 || body.split("\n").length > 12 ? "\n…" : ""}</div>` : "") + images(tool.result?.images);
203
+ }
204
+
205
+ export function item(it: TimelineItem, ui: UI): string {
206
+ switch (it.type) {
207
+ case "toolGroup": {
208
+ const open = ui.expanded.has(it.key);
209
+ const summary = activitySummary(it.rows, it.running, lang());
210
+ const label = summary.text + (summary.added || summary.removed ? ` [+${summary.added} −${summary.removed}]` : "") + (summary.failed ? (lang() === "ko" ? " · 실패" : " · Failed") : "");
211
+ return `<div class="tools"><button class="tool" data-act="tool-group" data-id="${esc(it.key)}" aria-expanded="${open}">${it.running ? stateRing("running", 16) : toolIcon("Bash")}<span class="sm">${esc(label)}</span><span style="transform:rotate(${open ? 90 : 0}deg)">${icon.chevron(16)}</span></button>${open ? it.rows.map(row => row.type === "tool" ? toolRow(row.tool, ui) : item(row, ui)).join("") : ""}</div>`;
212
+ }
213
+ case "start":
214
+ return `<div class="start">${esc(t("history.start"))}</div>`;
215
+ case "user": {
216
+ const q = it.queued ? `<div class="q">${icon.clock(13)}${esc(it.queued === "pending" ? t("queue.pending") : t("queue.sending"))}</div>` : "";
217
+ return `<div class="msg-user${it.queued ? " queued" : ""}">${esc(it.text)}${images(it.images)}${q}</div>`;
218
+ }
219
+ case "thinking":
220
+ return `<details class="tool-details"><summary>${icon.clock(15)} ${lang() === "ko" ? "사고 과정" : "Thinking"}</summary><div class="tool-out">${esc(textOf(it.text))}</div></details>`;
221
+ case "assistant": {
222
+ const text = textOf(it.text);
223
+ const caret = it.streaming ? `<span class="caret"></span>` : "";
224
+ const html = text ? markdown(text) : "";
225
+ const withCaret = caret ? (html.endsWith("</p>") ? html.slice(0, -4) + caret + "</p>" : html + caret) : html;
226
+ const tools = it.tools.length ? `<div class="tools">${it.tools.map((tl) => toolRow(tl, ui)).join("")}</div>` : "";
227
+ return `<div class="msg-asst">${withCaret}${images(it.images)}${tools}</div>`;
228
+ }
229
+ case "subagent": {
230
+ const st = it.status === "running" ? t("sub.running") : it.status === "done" ? t("sub.done") : t("sub.failed");
231
+ return `<button class="sub" data-act="sub" data-id="${esc(it.sub)}">${stateRing(it.status === "running" ? "running" : it.status === "done" ? "idle" : "error", 28)}<div class="body"><div class="k">${esc(t("sub.worker"))}${it.model ? `<span class="model">${esc(it.model)}</span>` : ""}<span>· ${esc(st)} · ${esc(t("sub.steps", { n: it.steps }))}</span></div><div class="d">${esc(it.description)}</div></div>${icon.chevron(18)}</button>`;
232
+ }
233
+ case "permission": {
234
+ if (it.decision === null) return `<div class="perm-done open">${icon.shield(16)}<span class="nm">${esc(it.tool)}</span><span class="sm">${esc(it.summary)}</span><span>${esc(t("state.waiting"))}</span></div>`;
235
+ const label = it.decision === "allow" ? t("perm.allowed") : it.decision === "allow_always" ? t("perm.allowedAlways") : it.decision === "deny" ? t("perm.denied") : t("perm.expired");
236
+ const cls = it.decision === "allow_always" ? "allow" : it.decision;
237
+ const why = it.decision === "expired" && it.reason ? `<span class="why">${esc(t(`perm.reason.${it.reason}` as Key, { host: hostName(ui) }))}</span>` : "";
238
+ return `<div class="perm-done ${cls}">${it.decision === "allow" || it.decision === "allow_always" ? icon.check(16) : icon.x(16)}<span class="nm">${esc(it.tool)}</span><span class="sm">${esc(it.summary)}</span><span>${esc(label)}</span></div>${why ? `<div class="foot">${why}</div>` : ""}`;
239
+ }
240
+ case "result":
241
+ if (!it.ok) return `<div class="foot bad">${icon.x(14)}${esc(t("turn.failed", { error: it.error ?? "" }))}</div>`;
242
+ return `<div class="foot">${esc(t("turn.footer", { model: modelName(ui.models, it.model), effort: effortLabel(it.effort) || "—", dur: duration(it.ms) }))}</div>`;
243
+ case "notice":
244
+ return `<div class="notice">${esc(t(`notice.${it.code}` as Key, { text: it.text ?? "", host: hostName(ui) }))}</div>`;
245
+ }
246
+ }
247
+
248
+ /** The open permission request, docked above the composer where the thumb already is. */
249
+ export function permCard(view: SessionView, ui: UI): string {
250
+ const p = view.open[0];
251
+ if (!p) return "";
252
+ const open = ui.permOpen.has(p.id);
253
+ const what = textOf(p.input) || p.summary;
254
+ const long = what.split("\n").length > 5 || what.length > 280;
255
+ return `<div class="perm" role="alertdialog" aria-label="${esc(t("perm.title", { tool: p.tool }))}"><div class="hd">${icon.shield(16)}<span>${esc(p.tool)}</span>${view.open.length > 1 ? `<span class="of">${esc(t("perm.of", { i: 1, n: view.open.length }))}</span>` : ""}</div><div class="q">${esc(p.title || t("perm.title", { tool: p.tool }))}</div><div class="what${open ? " open" : ""}">${esc(what)}</div>${long && !open ? `<button class="more" data-act="perm-more" data-id="${esc(p.id)}">${esc(t("perm.showAll"))}</button>` : ""}<div class="act"><button class="deny" data-act="deny" data-id="${esc(p.id)}">${esc(t("perm.deny"))}</button><button class="allow" data-act="allow" data-id="${esc(p.id)}">${icon.check(20)}${esc(t("perm.allow"))}</button></div>${p.canAlways ? `<button class="always" data-act="always" data-id="${esc(p.id)}">${esc(t("perm.always"))}</button>` : ""}</div>`;
256
+ }
257
+
258
+ export function workingLine(view: SessionView, ui: UI, s?: SessionSummary): string {
259
+ if (!(view.state === "running" || view.state === "starting") || view.open.length) return "";
260
+ // Disconnected, a ticking "working for" would claim knowledge we do not have: say when it was last seen working.
261
+ if (ui.link.state !== "open") return `<div class="working stale">${icon.clock(16)}<span>${esc(t("running.lastSeen", { time: clock(ui.link.lastRx ?? ui.now) }))}</span></div>`;
262
+ const since = s?.turnStartedAt ?? view.turnStartedAt ?? ui.now;
263
+ return `<div class="working">${stateRing("running", 16)}<span>${esc(t("running.for", { t: duration(ui.now - since) }))}</span></div>`;
264
+ }
265
+
266
+ /** The background-task chip; with a task list from the host it opens to show what is running. */
267
+ export function tasks(ui: UI, s: SessionSummary | undefined, open: boolean): string {
268
+ if (!s?.tasks) return "";
269
+ const list = s.taskList ?? [];
270
+ const chip = esc(t("tasks.running", { n: s.tasks }));
271
+ if (!list.length) return `<div class="tasks-chip" role="status">${chip}</div>`;
272
+ const rows = open ? `<div class="tasks-list">${list.map(task => `<div class="task">${task.kind === "shell" ? icon.terminal(16) : icon.model(16)}<span>${esc(task.label)}</span>${task.startedAt === null ? "" : `<time>${esc(duration(ui.now - task.startedAt))}</time>`}</div>`).join("")}</div>` : "";
273
+ return `${rows}<button class="tasks-chip" data-act="toggle-tasks" aria-expanded="${open}">${chip}${open ? icon.down(14) : icon.chevron(14)}</button>`;
274
+ }
275
+
276
+ // ---- sheets ------------------------------------------------------------------------------------
277
+
278
+ export function sheet(ui: UI): string {
279
+ const s = ui.sheet;
280
+ if (!s) return "";
281
+ const head = (title: string): string => `<div class="grab"></div><div class="sh"><b>${esc(title)}</b><button class="icon-btn" data-act="close-sheet" aria-label="${esc(t("a11y.close"))}">${icon.x(20)}</button></div>`;
282
+ if (s.kind === "folder") {
283
+ const f = s.filter.trim().toLowerCase();
284
+ const recent = s.recent.filter((r) => !f || r.name.toLowerCase().includes(f) || r.path.toLowerCase().includes(f)).slice(0, 6);
285
+ const recentHtml = recent.length
286
+ ? `<div class="sec">${esc(t("folder.recent"))}</div><div class="list">${recent.map((r) => `<button class="opt${ui.draft.cwd === r.path ? " on" : ""}" data-act="choose-folder" data-path="${esc(r.path)}">${icon.folder(20)}<div class="body"><div class="t1">${esc(r.name)}</div><div class="t2">${esc(shortPath(r.path, ui.home))}${r.lastUsed ? ` · ${esc(relTime(r.lastUsed, ui.now))}` : ""}</div></div>${ui.draft.cwd === r.path ? `<span class="tick">${icon.check(20)}</span>` : ""}</button>`).join("")}</div>`
287
+ : "";
288
+ let browse = "";
289
+ if (s.browse) {
290
+ const b = s.browse;
291
+ const rel = shortPath(b.path, b.home);
292
+ const parts = rel.split("/").filter(Boolean);
293
+ const crumbs = parts.map((p, i) => {
294
+ const full = rel.startsWith("~") ? b.home + "/" + parts.slice(1, i + 1).join("/") : "/" + parts.slice(0, i + 1).join("/");
295
+ return `${i ? `<span class="sl">/</span>` : ""}<button data-act="browse" data-path="${esc(i === 0 && rel.startsWith("~") ? b.home : full.replace(/\/$/, ""))}">${esc(p)}</button>`;
296
+ }).join("");
297
+ const entries = b.entries.filter((e) => !f || e.name.toLowerCase().includes(f));
298
+ browse = `<div class="sec">${esc(t("folder.browse"))}</div><div class="crumbs">${crumbs}</div><div class="list">${b.parent ? `<button class="opt" data-act="browse" data-path="${esc(b.parent)}">${icon.up(20)}<div class="body"><div class="t1">${esc(t("folder.up"))}</div></div></button>` : ""}${entries.map((e) => `<button class="opt" data-act="browse" data-path="${esc(e.path)}">${icon.folder(20)}<div class="body"><div class="t1">${esc(e.name)}</div></div>${icon.chevron(18)}</button>`).join("") || `<div class="opt"><div class="body"><div class="t2">${esc(t("folder.empty"))}</div></div></div>`}</div>${b.truncated ? `<div class="sec">${esc(t("folder.truncated", { n: b.entries.length }))}</div>` : ""}`;
299
+ }
300
+ const target = s.browse?.path ?? ui.draft.cwd;
301
+ const foot = target ? `<div class="sfoot"><div class="path">${esc(shortPath(target, ui.home))}</div><button class="btn primary" data-act="choose-folder" data-path="${esc(target)}">${esc(t("folder.here"))}</button></div>` : "";
302
+ return `<div class="scrim" data-act="close-sheet"></div><div class="sheet" role="dialog">${head(t("folder.title"))}<div class="filter">${icon.search(18)}<input id="folder-filter" placeholder="${esc(t("folder.filter"))}" value="${esc(s.filter)}" autocomplete="off"></div><div class="sbody">${recentHtml}${browse}</div>${foot}</div>`;
303
+ }
304
+ const chosen = s.groups.flatMap((g) => g.models).find((m) => m.model === s.model);
305
+ const recentIds = s.recent.filter((id) => s.groups.some((g) => g.models.some((m) => m.model === id && m.usable))).slice(0, 3);
306
+ const opt = (m: { model: string; name: string; usable: boolean }, sub: string): string =>
307
+ `<button class="opt${s.model === m.model ? " on" : ""}" data-act="choose-model" data-model="${esc(m.model)}" ${m.usable ? "" : "disabled"}>${icon.model(20)}<div class="body"><div class="t1">${esc(m.name)}</div><div class="t2 mono">${esc(sub)}</div></div>${s.model === m.model ? `<span class="tick">${icon.check(20)}</span>` : m.usable ? "" : `<span class="badge">${esc(t("model.unusable"))}</span>`}</button>`;
308
+ const recentHtml = recentIds.length
309
+ ? `<div class="sec">${esc(t("model.recent"))}</div><div class="list">${recentIds.map((id) => { const m = s.groups.flatMap((g) => g.models.map((x) => ({ ...x, label: g.label }))).find((x) => x.model === id)!; return opt(m, `${m.label} · ${m.model}`); }).join("")}</div>`
310
+ : "";
311
+ const groups = s.groups.map((g) => `<div class="sec">${esc(g.label)}${g.usable ? "" : ` · ${esc(t("model.unusable"))}`}</div><div class="list">${g.models.map((m) => opt(m, m.model)).join("")}</div>`).join("");
312
+ const efforts = chosen?.efforts ?? [];
313
+ const seg = efforts.length
314
+ ? `<div class="seg-label"><span>${esc(t("model.effort"))}</span></div><div class="seg">${efforts.map((e) => `<button class="${s.effort === e ? "on" : ""}" data-act="choose-effort" data-effort="${e}">${esc(effortLabel(e))}</button>`).join("")}</div>`
315
+ : `<div class="seg-label"><span>${esc(t("model.noEffort"))}</span></div>`;
316
+ return `<div class="scrim" data-act="close-sheet"></div><div class="sheet" role="dialog">${head(t("model.title"))}<div class="sbody">${recentHtml}${groups}</div><div class="sfoot">${seg}<div style="height:10px"></div><button class="btn primary" data-act="model-done" ${s.model ? "" : "disabled"}>${esc(t("model.done"))}</button></div></div>`;
317
+ }
318
+
319
+ // ---- pairing -----------------------------------------------------------------------------------
320
+
321
+ export function pairScreen(): string {
322
+ return `<div class="pair">${ripple("calm", 96)}<h1>${esc(t("pair.title"))}</h1><ol><li><div>${esc(t("pair.step1"))}<code>${esc(t("pair.where"))}</code></div></li><li><div>${esc(t("pair.step2"))}</div></li><li><div>${esc(t("pair.step3"))}</div></li></ol><div class="paste"><label for="pair-link">${esc(t("pair.paste"))}</label><div class="row"><input id="pair-link" type="url" inputmode="url" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="https://…#pair=…"><button class="btn primary" data-act="pair-link">${esc(t("pair.connect"))}</button></div></div><div class="note">${esc(t("pair.note"))}</div></div>`;
323
+ }