loopat 0.1.38 → 0.1.39

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopat",
3
- "version": "0.1.38",
3
+ "version": "0.1.39",
4
4
  "description": "Self-hosted AI workspace built around context management — works solo, scales to teams",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/simpx/loopat",
@@ -0,0 +1,319 @@
1
+ /**
2
+ * A2A (Agent-to-Agent) adapter — in-process, standard A2A (Google's Agent2Agent).
3
+ *
4
+ * Each loopat account is its own A2A agent:
5
+ * - GET /a2a/<user>/agent-card.json → that user's Agent Card
6
+ * - POST /a2a/<user> → JSON-RPC (message/send | message/stream)
7
+ *
8
+ * Auth is per-user: the caller presents that user's loopat API token
9
+ * (`Authorization: Bearer la_…`). We validate the token resolves to <user> and
10
+ * forward it to loopat's own `/api/v1` over loopback — which already does
11
+ * per-token-user auth, loop creation, turn execution, and SSE — so this adapter
12
+ * is a thin protocol translator (A2A ↔ /api/v1 SSE) that can't break web or
13
+ * /api/v1. The A2A `contextId` (conversation) maps to a loopat loop.
14
+ */
15
+ import { Hono, type Context } from "hono"
16
+ import { streamSSE } from "hono/streaming"
17
+ import { randomBytes } from "node:crypto"
18
+ import { resolveApiToken } from "./api-tokens"
19
+ import { loadA2AConfig, type A2AUserConfig } from "./config"
20
+
21
+ const A2A_PROTOCOL_VERSION = "0.3.0"
22
+ const SELF_BASE = `http://127.0.0.1:${process.env.PORT ?? 10001}`
23
+
24
+ // contextId (A2A conversation) → loopat loopId. In-memory: a restart starts a
25
+ // fresh conversation (acceptable for v1).
26
+ const ctxToLoop = new Map<string, string>()
27
+
28
+ function resolvePublicUrl(c: Context): string {
29
+ const configured = process.env.LOOPAT_A2A_PUBLIC_URL
30
+ if (configured) return configured.replace(/\/+$/, "")
31
+ const proto = c.req.header("x-forwarded-proto") ?? "http"
32
+ const host = c.req.header("host") ?? `127.0.0.1:${process.env.PORT ?? 10001}`
33
+ return `${proto}://${host}`
34
+ }
35
+
36
+ function agentCard(user: string, publicUrl: string, cfg: A2AUserConfig) {
37
+ const name = cfg.card?.name?.trim() || `Loopat Agent (${user})`
38
+ const description = cfg.card?.description?.trim() ||
39
+ "Self-hosted AI workspace (Claude Agent SDK). Runs development tasks in an isolated sandbox: writing code, reading repos, editing files, running commands, and researching."
40
+ return {
41
+ protocolVersion: A2A_PROTOCOL_VERSION,
42
+ name,
43
+ description,
44
+ url: `${publicUrl}/a2a/${encodeURIComponent(user)}`,
45
+ version: "1.0.0",
46
+ preferredTransport: "JSONRPC",
47
+ capabilities: { streaming: true, pushNotifications: false, stateTransitionHistory: false },
48
+ defaultInputModes: ["text/plain", "application/json"],
49
+ defaultOutputModes: ["text/plain"],
50
+ securitySchemes: {
51
+ bearer: { type: "http", scheme: "bearer", description: "A loopat API token belonging to this user." },
52
+ },
53
+ security: [{ bearer: [] }],
54
+ skills: [
55
+ {
56
+ id: "loopat_dev_turn",
57
+ name: "General development task",
58
+ description:
59
+ "Run one development task in a loopat sandbox: write/edit code, read the repo, run commands, research, and return the result. Multi-turn within a conversation (reuse contextId to continue).",
60
+ tags: ["coding", "agent", "dev", "sandbox"],
61
+ examples: ["Add a health-check endpoint to the repo and make the tests pass."],
62
+ inputModes: ["text/plain", "application/json"],
63
+ outputModes: ["text/plain"],
64
+ },
65
+ ],
66
+ }
67
+ }
68
+
69
+ type ParsedMsg = { text: string; contextId?: string; taskId?: string }
70
+
71
+ function parseMessage(params: any): ParsedMsg {
72
+ const message = params?.message ?? {}
73
+ let text = ""
74
+ const parts = Array.isArray(message.parts) ? message.parts : []
75
+ for (const p of parts) {
76
+ if (p?.kind === "text" && typeof p.text === "string") { text += p.text }
77
+ else if (p?.kind === "data" && p.data) {
78
+ const d = p.data
79
+ text += typeof d.message === "string" ? d.message
80
+ : typeof d.text === "string" ? d.text
81
+ : typeof d === "string" ? d : ""
82
+ }
83
+ }
84
+ return {
85
+ text: text.trim(),
86
+ contextId: typeof message.contextId === "string" && message.contextId ? message.contextId : undefined,
87
+ taskId: typeof message.taskId === "string" && message.taskId ? message.taskId : undefined,
88
+ }
89
+ }
90
+
91
+ async function getOrCreateLoop(parsed: ParsedMsg, callerAuth: string, cfg: A2AUserConfig): Promise<{ loopId: string; contextId: string }> {
92
+ let contextId = parsed.contextId
93
+ if (contextId && ctxToLoop.has(contextId)) return { loopId: ctxToLoop.get(contextId)!, contextId }
94
+ if (!contextId) contextId = `ctx_${randomBytes(8).toString("hex")}`
95
+
96
+ const title = parsed.text.slice(0, 60) || "a2a"
97
+ const res = await fetch(`${SELF_BASE}/api/v1/loops`, {
98
+ method: "POST",
99
+ headers: { authorization: callerAuth, "content-type": "application/json" },
100
+ body: JSON.stringify({
101
+ title,
102
+ profiles: cfg.profiles && cfg.profiles.length ? cfg.profiles : undefined,
103
+ vault: cfg.vault || undefined,
104
+ metadata: { a2a_context_id: contextId },
105
+ }),
106
+ })
107
+ if (!res.ok) {
108
+ const body = await res.text().catch(() => "")
109
+ throw new Error(`loop create failed: ${res.status} ${body}`)
110
+ }
111
+ const loop = await res.json()
112
+ const loopId = loop.id as string
113
+ ctxToLoop.set(contextId, loopId)
114
+ return { loopId, contextId }
115
+ }
116
+
117
+ type LoopatEvent = { event: string; data: any }
118
+
119
+ async function* loopbackTurn(loopId: string, content: string, taskId: string, callerAuth: string): AsyncGenerator<LoopatEvent> {
120
+ const res = await fetch(`${SELF_BASE}/api/v1/loops/${loopId}/messages`, {
121
+ method: "POST",
122
+ headers: {
123
+ authorization: callerAuth,
124
+ "content-type": "application/json",
125
+ accept: "text/event-stream",
126
+ "idempotency-key": taskId,
127
+ },
128
+ body: JSON.stringify({ content, permission_mode: "bypassPermissions" }),
129
+ })
130
+ if (!res.ok || !res.body) {
131
+ const body = await res.text().catch(() => "")
132
+ yield { event: "error", data: { code: "send_failed", message: `${res.status} ${body}` } }
133
+ return
134
+ }
135
+ const reader = res.body.getReader()
136
+ const decoder = new TextDecoder()
137
+ let buf = ""
138
+ let curEvent = "message"
139
+ let curData = ""
140
+ const flush = (): LoopatEvent | null => {
141
+ if (!curData) { curEvent = "message"; return null }
142
+ let parsed: any = curData
143
+ try { parsed = JSON.parse(curData) } catch { /* keep string */ }
144
+ const ev = { event: curEvent, data: parsed }
145
+ curEvent = "message"; curData = ""
146
+ return ev
147
+ }
148
+ while (true) {
149
+ const { done, value } = await reader.read()
150
+ if (done) break
151
+ buf += decoder.decode(value, { stream: true })
152
+ const lines = buf.split("\n")
153
+ buf = lines.pop() ?? ""
154
+ for (const line of lines) {
155
+ if (line === "") { const ev = flush(); if (ev) yield ev; continue }
156
+ if (line.startsWith("event:")) curEvent = line.slice(6).trim()
157
+ else if (line.startsWith("data:")) curData += line.slice(5).trim()
158
+ }
159
+ }
160
+ const ev = flush(); if (ev) yield ev
161
+ }
162
+
163
+ function extractAssistantText(d: any): string | null {
164
+ if (!d || d.type !== "assistant") return null
165
+ const content = d.message?.content
166
+ if (!Array.isArray(content)) return null
167
+ const txt = content.filter((b: any) => b?.type === "text" && typeof b.text === "string").map((b: any) => b.text).join("")
168
+ return txt || null
169
+ }
170
+
171
+ function resultError(d: any): string | null {
172
+ if (!d || d.type !== "result") return null
173
+ if (d.is_error === true) return typeof d.result === "string" && d.result ? d.result : "agent error"
174
+ return null
175
+ }
176
+
177
+ // ── Standard A2A streaming event builders ─────────────────────────────────
178
+ function artifactUpdate(reqId: unknown, taskId: string, contextId: string, artifactId: string, text: string, append: boolean, lastChunk: boolean) {
179
+ return {
180
+ jsonrpc: "2.0", id: reqId,
181
+ result: {
182
+ kind: "artifact-update",
183
+ taskId, contextId, append, lastChunk,
184
+ artifact: { artifactId, name: "response", parts: [{ kind: "text", text }] },
185
+ },
186
+ }
187
+ }
188
+ function statusUpdate(reqId: unknown, taskId: string, contextId: string, state: "working" | "completed" | "failed", final: boolean, errorText?: string) {
189
+ const status: Record<string, unknown> = { state }
190
+ if (errorText) {
191
+ status.message = {
192
+ kind: "message", role: "agent", messageId: `msg_${randomBytes(6).toString("hex")}`,
193
+ parts: [{ kind: "text", text: errorText }], taskId, contextId,
194
+ }
195
+ }
196
+ return { jsonrpc: "2.0", id: reqId, result: { kind: "status-update", taskId, contextId, status, final } }
197
+ }
198
+
199
+ async function handleStream(c: Context, reqId: unknown, parsed: ParsedMsg, callerAuth: string, cfg: A2AUserConfig) {
200
+ return streamSSE(c, async (stream) => {
201
+ const artifactId = `artifact_${randomBytes(8).toString("hex")}`
202
+ const taskId = parsed.taskId || `task_${randomBytes(8).toString("hex")}`
203
+ try {
204
+ const { loopId, contextId } = await getOrCreateLoop(parsed, callerAuth, cfg)
205
+ await stream.writeSSE({ data: JSON.stringify(statusUpdate(reqId, taskId, contextId, "working", false)) })
206
+ let gotDelta = false
207
+ let fallbackText: string | null = null
208
+ let errMsg: string | undefined
209
+ const finish = async () => {
210
+ if (errMsg) {
211
+ await stream.writeSSE({ data: JSON.stringify(statusUpdate(reqId, taskId, contextId, "failed", true, errMsg)) })
212
+ return
213
+ }
214
+ if (!gotDelta && fallbackText) {
215
+ await stream.writeSSE({ data: JSON.stringify(artifactUpdate(reqId, taskId, contextId, artifactId, fallbackText, false, false)) })
216
+ }
217
+ await stream.writeSSE({ data: JSON.stringify(artifactUpdate(reqId, taskId, contextId, artifactId, "", true, true)) })
218
+ await stream.writeSSE({ data: JSON.stringify(statusUpdate(reqId, taskId, contextId, "completed", true)) })
219
+ }
220
+ for await (const ev of loopbackTurn(loopId, parsed.text, taskId, callerAuth)) {
221
+ if (ev.event === "assistant_delta" && typeof ev.data?.text === "string") {
222
+ await stream.writeSSE({ data: JSON.stringify(artifactUpdate(reqId, taskId, contextId, artifactId, ev.data.text, gotDelta, false)) })
223
+ gotDelta = true
224
+ } else if (ev.event === "sdk_message") {
225
+ const t = extractAssistantText(ev.data); if (t) fallbackText = t
226
+ const e = resultError(ev.data); if (e) errMsg = e
227
+ } else if (ev.event === "done") {
228
+ await finish(); return
229
+ } else if (ev.event === "error" || ev.event === "interrupted") {
230
+ errMsg = ev.data?.message ?? ev.event
231
+ await finish(); return
232
+ }
233
+ }
234
+ await finish()
235
+ } catch (e: any) {
236
+ const taskIdSafe = taskId
237
+ await stream.writeSSE({ data: JSON.stringify(statusUpdate(reqId, taskIdSafe, parsed.contextId ?? "", "failed", true, e?.message ?? String(e))) })
238
+ }
239
+ })
240
+ }
241
+
242
+ async function handleSend(c: Context, reqId: unknown, parsed: ParsedMsg, callerAuth: string, cfg: A2AUserConfig) {
243
+ const artifactId = `artifact_${randomBytes(8).toString("hex")}`
244
+ const taskId = parsed.taskId || `task_${randomBytes(8).toString("hex")}`
245
+ try {
246
+ const { loopId, contextId } = await getOrCreateLoop(parsed, callerAuth, cfg)
247
+ let full = ""
248
+ let fallbackText: string | null = null
249
+ let errMsg: string | undefined
250
+ for await (const ev of loopbackTurn(loopId, parsed.text, taskId, callerAuth)) {
251
+ if (ev.event === "assistant_delta" && typeof ev.data?.text === "string") full += ev.data.text
252
+ else if (ev.event === "sdk_message") {
253
+ const t = extractAssistantText(ev.data); if (t) fallbackText = t
254
+ const e = resultError(ev.data); if (e) errMsg = e
255
+ }
256
+ else if (ev.event === "error" || ev.event === "interrupted") errMsg = ev.data?.message ?? ev.event
257
+ else if (ev.event === "done") break
258
+ }
259
+ if (!full && fallbackText) full = fallbackText
260
+ if (errMsg && !full) full = errMsg
261
+ const success = !errMsg
262
+ // A2A Task object.
263
+ return c.json({
264
+ jsonrpc: "2.0",
265
+ id: reqId,
266
+ result: {
267
+ kind: "task",
268
+ id: taskId,
269
+ contextId,
270
+ status: { state: success ? "completed" : "failed" },
271
+ artifacts: [{ artifactId, name: "response", parts: [{ kind: "text", text: full }] }],
272
+ },
273
+ })
274
+ } catch (e: any) {
275
+ return c.json({ jsonrpc: "2.0", id: reqId, error: { code: -32603, message: e?.message ?? String(e) } })
276
+ }
277
+ }
278
+
279
+ export function buildA2A() {
280
+ const a = new Hono()
281
+
282
+ const cardHandler = async (c: Context) => {
283
+ const user = c.req.param("user")
284
+ if (!user) return c.json({ error: "not found" }, 404)
285
+ const cfg = await loadA2AConfig(user)
286
+ return c.json(agentCard(user, resolvePublicUrl(c), cfg))
287
+ }
288
+ a.get("/a2a/:user/agent-card.json", cardHandler)
289
+ a.get("/a2a/:user/.well-known/agent-card.json", cardHandler)
290
+ a.get("/a2a/:user/agent.json", cardHandler) // legacy filename alias
291
+
292
+ a.post("/a2a/:user", async (c) => {
293
+ const user = c.req.param("user")
294
+ const rpc = await c.req.json().catch(() => null)
295
+ const reqId = rpc?.id ?? null
296
+ if (!rpc || typeof rpc.method !== "string") {
297
+ return c.json({ jsonrpc: "2.0", id: reqId, error: { code: -32600, message: "invalid request" } }, 400)
298
+ }
299
+ // Per-user auth: the caller must present THIS user's loopat API token.
300
+ const auth = c.req.header("authorization") ?? null
301
+ const tokenUser = await resolveApiToken(auth)
302
+ if (!tokenUser) {
303
+ return c.json({ jsonrpc: "2.0", id: reqId, error: { code: -32000, message: "unauthorized: present a loopat API token" } }, 401)
304
+ }
305
+ if (tokenUser !== user) {
306
+ return c.json({ jsonrpc: "2.0", id: reqId, error: { code: -32000, message: "token does not belong to this agent" } }, 403)
307
+ }
308
+ const cfg = await loadA2AConfig(user)
309
+ const parsed = parseMessage(rpc.params)
310
+ if (!parsed.text) {
311
+ return c.json({ jsonrpc: "2.0", id: reqId, error: { code: -32602, message: "empty message" } }, 400)
312
+ }
313
+ if (rpc.method === "message/stream") return handleStream(c, reqId, parsed, auth!, cfg)
314
+ if (rpc.method === "message/send") return handleSend(c, reqId, parsed, auth!, cfg)
315
+ return c.json({ jsonrpc: "2.0", id: reqId, error: { code: -32601, message: `method not found: ${rpc.method}` } }, 404)
316
+ })
317
+
318
+ return a
319
+ }
@@ -483,6 +483,38 @@ export async function loadPersonalConfig(
483
483
  return cfg
484
484
  }
485
485
 
486
+ // ── Per-user A2A config ───────────────────────────────────────────────────
487
+ // The user's A2A agent: editable card fields + which profiles/vault loops
488
+ // created by A2A use. No secrets (the credential is the user's API token), so
489
+ // it's plain JSON next to config.json. `$LOOPAT_HOME/personal/<user>/.loopat/a2a.json`.
490
+ export type A2AUserConfig = {
491
+ card?: { name?: string; description?: string }
492
+ profiles?: string[]
493
+ vault?: string
494
+ }
495
+ function a2aConfigPath(user: string): string {
496
+ return join(personalLoopatDir(user), "a2a.json")
497
+ }
498
+ export async function loadA2AConfig(user: string): Promise<A2AUserConfig> {
499
+ const p = a2aConfigPath(user)
500
+ if (!existsSync(p)) return {}
501
+ try {
502
+ const j = JSON.parse(await readFile(p, "utf8")) as A2AUserConfig
503
+ return j && typeof j === "object" ? j : {}
504
+ } catch { return {} }
505
+ }
506
+ export async function saveA2AConfig(user: string, patch: A2AUserConfig): Promise<void> {
507
+ const cur = await loadA2AConfig(user)
508
+ const next: A2AUserConfig = {
509
+ card: patch.card !== undefined ? patch.card : cur.card,
510
+ profiles: patch.profiles !== undefined ? patch.profiles : cur.profiles,
511
+ vault: patch.vault !== undefined ? patch.vault : cur.vault,
512
+ }
513
+ const p = a2aConfigPath(user)
514
+ await mkdir(dirname(p), { recursive: true })
515
+ await writeFile(p, JSON.stringify(next, null, 2) + "\n")
516
+ }
517
+
486
518
  export function getActiveProvider(cfg: PersonalConfig): { name: string; provider: ProviderConfig } | null {
487
519
  const raw = cfg.default
488
520
  if (!raw) return null
@@ -55,7 +55,8 @@ import {
55
55
  personalReposDir,
56
56
  loopsDir,
57
57
  } from "./paths"
58
- import { loadConfig, loadPersonalConfig, savePersonalConfig, saveWorkspaceConfig, loadTokenUsage, getActiveProvider, readPersonalDiskRaw, savePersonalDisk, describeApiKeyRef, writeVaultEnv, deleteVaultEnv, loadKnowledgeConfig, saveKnowledgeConfig, type ProviderConfig, type ModelEntry } from "./config"
58
+ import { loadConfig, loadPersonalConfig, savePersonalConfig, saveWorkspaceConfig, loadTokenUsage, getActiveProvider, readPersonalDiskRaw, savePersonalDisk, describeApiKeyRef, writeVaultEnv, deleteVaultEnv, loadKnowledgeConfig, saveKnowledgeConfig, loadA2AConfig, saveA2AConfig, type ProviderConfig, type ModelEntry } from "./config"
59
+ import { createApiToken, listApiTokens, revokeApiToken } from "./api-tokens"
59
60
  import { listBoards, createBoard, renameBoard, listKanbanColumns, addCard, toggleCard, deleteCard, moveCard, updateCardMeta, updateCardBlock, reorderCards, createColumn, deleteColumn, readKanbanConfig, saveColumnOrder, setColumnColor, renameColumn, assignDriverForCard, createLoopFromCard, linkLoopToCard, kanbanUserCtx } from "./kanban"
60
61
  import { printBootstrapBanner, printReadyLine } from "./bootstrap"
61
62
  import { resolveProvider } from "./providers"
@@ -127,6 +128,11 @@ app.get("/api/version", (c) => {
127
128
  import { buildApiV1 } from "./api-v1"
128
129
  app.route("/api/v1", buildApiV1())
129
130
 
131
+ // A2A (Agent-to-Agent) adapter — per-user Agent Card + JSON-RPC under /a2a/<user>.
132
+ // Mounted before the SPA catch-all so those routes resolve.
133
+ import { buildA2A } from "./a2a"
134
+ app.route("/", buildA2A())
135
+
130
136
  // ── workspace serve config ──
131
137
 
132
138
  function getLocalIp(): string {
@@ -1551,6 +1557,58 @@ app.get("/api/vaults", requireAuth, async (c) => {
1551
1557
  return c.json({ vaults: listVaults(userId) })
1552
1558
  })
1553
1559
 
1560
+ // ── A2A settings (per-user agent card + default profiles/vault + key) ──
1561
+ const A2A_KEY_LABEL = "a2a"
1562
+ function a2aPublicBase(c: any): string {
1563
+ const configured = process.env.LOOPAT_A2A_PUBLIC_URL
1564
+ if (configured) return configured.replace(/\/+$/, "")
1565
+ const proto = c.req.header("x-forwarded-proto") ?? "http"
1566
+ const host = c.req.header("host") ?? `127.0.0.1:${process.env.PORT ?? 10001}`
1567
+ return `${proto}://${host}`
1568
+ }
1569
+ app.get("/api/a2a", requireAuth, async (c) => {
1570
+ const userId = c.get("userId") as string
1571
+ const cfg = await loadA2AConfig(userId)
1572
+ const { listVaults } = await import("./vaults")
1573
+ const { listProfiles } = await import("./profiles")
1574
+ const tokens = await listApiTokens(userId)
1575
+ const base = a2aPublicBase(c)
1576
+ return c.json({
1577
+ card: { name: cfg.card?.name ?? "", description: cfg.card?.description ?? "" },
1578
+ profiles: cfg.profiles ?? [],
1579
+ vault: cfg.vault ?? "",
1580
+ cardUrl: `${base}/a2a/${encodeURIComponent(userId)}/agent-card.json`,
1581
+ endpoint: `${base}/a2a/${encodeURIComponent(userId)}`,
1582
+ hasKey: tokens.some((t) => t.label === A2A_KEY_LABEL),
1583
+ availableProfiles: await listProfiles(userId),
1584
+ availableVaults: listVaults(userId),
1585
+ })
1586
+ })
1587
+ app.put("/api/a2a", requireAuth, async (c) => {
1588
+ const userId = c.get("userId") as string
1589
+ const body = await c.req.json().catch(() => ({}))
1590
+ const patch: { card?: { name?: string; description?: string }; profiles?: string[]; vault?: string } = {}
1591
+ if (body.card && typeof body.card === "object") {
1592
+ patch.card = {
1593
+ name: typeof body.card.name === "string" ? body.card.name.slice(0, 200) : undefined,
1594
+ description: typeof body.card.description === "string" ? body.card.description.slice(0, 2000) : undefined,
1595
+ }
1596
+ }
1597
+ if (Array.isArray(body.profiles)) patch.profiles = body.profiles.filter((p: unknown): p is string => typeof p === "string")
1598
+ if (typeof body.vault === "string") patch.vault = body.vault
1599
+ await saveA2AConfig(userId, patch)
1600
+ return c.json({ ok: true })
1601
+ })
1602
+ // Rotate the A2A key: revoke any prior a2a-labelled token, mint a fresh one
1603
+ // (returned once — store it in the orchestrator).
1604
+ app.post("/api/a2a/key", requireAuth, async (c) => {
1605
+ const userId = c.get("userId") as string
1606
+ const existing = await listApiTokens(userId)
1607
+ for (const t of existing) if (t.label === A2A_KEY_LABEL) await revokeApiToken(userId, t.tokenId)
1608
+ const created = await createApiToken(userId, A2A_KEY_LABEL)
1609
+ return c.json({ token: created.token })
1610
+ })
1611
+
1554
1612
  // Return the current user's `default_profiles` from
1555
1613
  // personal/<u>/.loopat/config.json. Used by NewLoopDialog to pre-check the
1556
1614
  // user's typical setup. Returns [] if config absent / field missing.
@@ -1 +1 @@
1
- import{n as e,o as t,t as n}from"./jsx-runtime-Bt-cYkS5.js";import{_ as r,v as i,y as a}from"./index-Dv2YmrAT.js";import{CodeEditor as o}from"./CodeEditor-DtHZtsPs.js";var s=a(`text-wrap`,[[`path`,{d:`m16 16-3 3 3 3`,key:`117b85`}],[`path`,{d:`M3 12h14.5a1 1 0 0 1 0 7H13`,key:`18xa6z`}],[`path`,{d:`M3 19h6`,key:`1ygdsz`}],[`path`,{d:`M3 5h18`,key:`1u36vt`}]]),c=t(e(),1),l=n();function u(){try{if(localStorage.getItem(`loopat:editor:wordWrap`)===`0`)return!1}catch{}return!0}function d({loopId:e,path:t,onSelectionChange:n}){let[a,d]=(0,c.useState)(``),[f,p]=(0,c.useState)(``),[m,h]=(0,c.useState)(!1),[g,_]=(0,c.useState)(!1),[v,y]=(0,c.useState)(u);(0,c.useEffect)(()=>{if(!t){d(``),p(``);return}h(!0),r(e,t).then(e=>{let t=e?.content??``;d(t),p(t)}).finally(()=>h(!1))},[e,t]);let b=t&&f!==a,x=async()=>{if(!(!t||g)){_(!0);try{await i(e,t,f)&&d(f)}finally{_(!1)}}};return t?(0,l.jsxs)(l.Fragment,{children:[(0,l.jsx)(`div`,{className:`flex-1 min-h-0 relative`,onKeyDown:e=>{(e.metaKey||e.ctrlKey)&&e.key===`s`&&(e.preventDefault(),x())},children:m?(0,l.jsx)(`div`,{className:`h-full w-full flex items-center justify-center text-[12px] text-gray-400`,children:`loading…`}):(0,l.jsx)(o,{path:t,value:f,onChange:p,wordWrap:v,onSelectionChange:n})}),(0,l.jsxs)(`div`,{className:`border-t border-gray-200 px-3 py-1.5 text-[11px] text-gray-500 flex items-center gap-3`,children:[(0,l.jsx)(`span`,{className:`truncate`,children:t}),b&&(0,l.jsx)(`button`,{onClick:x,className:`text-orange-600 hover:underline`,title:`ctrl/⌘+S`,children:g?`saving…`:`unsaved · save`}),(0,l.jsx)(`span`,{className:`flex-1`}),(0,l.jsx)(`button`,{onClick:()=>{let e=!v;y(e);try{localStorage.setItem(`loopat:editor:wordWrap`,e?`1`:`0`)}catch{}},className:`flex items-center gap-1 hover:text-gray-700 transition-colors ${v?`text-gray-500`:`text-gray-300`}`,title:v?`word wrap: on`:`word wrap: off`,children:(0,l.jsx)(s,{size:13})}),(0,l.jsx)(`span`,{children:`utf-8 · LF`})]})]}):(0,l.jsx)(`div`,{className:`flex-1 min-h-0 flex items-center justify-center text-[13px] text-gray-500 px-8 text-center`,children:`没打开文件 · 在 ▤ workdir 里点一个`})}export{d as Editor};
1
+ import{n as e,o as t,t as n}from"./jsx-runtime-Bt-cYkS5.js";import{_ as r,v as i,y as a}from"./index-CaZDV18H.js";import{CodeEditor as o}from"./CodeEditor-DtHZtsPs.js";var s=a(`text-wrap`,[[`path`,{d:`m16 16-3 3 3 3`,key:`117b85`}],[`path`,{d:`M3 12h14.5a1 1 0 0 1 0 7H13`,key:`18xa6z`}],[`path`,{d:`M3 19h6`,key:`1ygdsz`}],[`path`,{d:`M3 5h18`,key:`1u36vt`}]]),c=t(e(),1),l=n();function u(){try{if(localStorage.getItem(`loopat:editor:wordWrap`)===`0`)return!1}catch{}return!0}function d({loopId:e,path:t,onSelectionChange:n}){let[a,d]=(0,c.useState)(``),[f,p]=(0,c.useState)(``),[m,h]=(0,c.useState)(!1),[g,_]=(0,c.useState)(!1),[v,y]=(0,c.useState)(u);(0,c.useEffect)(()=>{if(!t){d(``),p(``);return}h(!0),r(e,t).then(e=>{let t=e?.content??``;d(t),p(t)}).finally(()=>h(!1))},[e,t]);let b=t&&f!==a,x=async()=>{if(!(!t||g)){_(!0);try{await i(e,t,f)&&d(f)}finally{_(!1)}}};return t?(0,l.jsxs)(l.Fragment,{children:[(0,l.jsx)(`div`,{className:`flex-1 min-h-0 relative`,onKeyDown:e=>{(e.metaKey||e.ctrlKey)&&e.key===`s`&&(e.preventDefault(),x())},children:m?(0,l.jsx)(`div`,{className:`h-full w-full flex items-center justify-center text-[12px] text-gray-400`,children:`loading…`}):(0,l.jsx)(o,{path:t,value:f,onChange:p,wordWrap:v,onSelectionChange:n})}),(0,l.jsxs)(`div`,{className:`border-t border-gray-200 px-3 py-1.5 text-[11px] text-gray-500 flex items-center gap-3`,children:[(0,l.jsx)(`span`,{className:`truncate`,children:t}),b&&(0,l.jsx)(`button`,{onClick:x,className:`text-orange-600 hover:underline`,title:`ctrl/⌘+S`,children:g?`saving…`:`unsaved · save`}),(0,l.jsx)(`span`,{className:`flex-1`}),(0,l.jsx)(`button`,{onClick:()=>{let e=!v;y(e);try{localStorage.setItem(`loopat:editor:wordWrap`,e?`1`:`0`)}catch{}},className:`flex items-center gap-1 hover:text-gray-700 transition-colors ${v?`text-gray-500`:`text-gray-300`}`,title:v?`word wrap: on`:`word wrap: off`,children:(0,l.jsx)(s,{size:13})}),(0,l.jsx)(`span`,{children:`utf-8 · LF`})]})]}):(0,l.jsx)(`div`,{className:`flex-1 min-h-0 flex items-center justify-center text-[13px] text-gray-500 px-8 text-center`,children:`没打开文件 · 在 ▤ workdir 里点一个`})}export{d as Editor};