@makerstufferupper/flypi 0.1.4 → 0.1.6

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.
@@ -51,10 +51,17 @@ export const NewSessionCommand = Type.Object({
51
51
  requestId: Type.Optional(RequestId),
52
52
  });
53
53
 
54
- /** Rebind this conversation to an existing session file (the sidebar's "open"). */
54
+ /**
55
+ * Open an existing session file (the sidebar's "open") as its **own** conversation.
56
+ *
57
+ * Path-scoped, not conversation-scoped: every session file gets one conversation of its own, so opening a
58
+ * session never rebinds or disposes whatever the client was looking at. The `result` names the conversation
59
+ * that answers the file — the one already on it when there is one, otherwise a new one — and the client
60
+ * resumes it. No `conversationId` is asked for, because a page recovering from a restart holds ids the
61
+ * server has forgotten and still has to be able to open a session.
62
+ */
55
63
  export const SwitchSessionCommand = Type.Object({
56
64
  type: Type.Literal("switchSession"),
57
- conversationId: Id,
58
65
  sessionPath: Id,
59
66
  requestId: Type.Optional(RequestId),
60
67
  });
@@ -118,6 +125,18 @@ export const ResumeCommand = Type.Object({
118
125
  requestId: Type.Optional(RequestId),
119
126
  });
120
127
 
128
+ /**
129
+ * Stop streaming one conversation to this socket. A page holds every session it has opened, but only the
130
+ * ones with a turn in flight (and the one on screen) need to keep their pi session attached: an idle
131
+ * background conversation can be detached and then evicted by its own idle TTL, and a later `resume`
132
+ * re-attaches it. Detaching is per socket, so another tab's subscription is untouched.
133
+ */
134
+ export const UnsubscribeCommand = Type.Object({
135
+ type: Type.Literal("unsubscribe"),
136
+ conversationId: Id,
137
+ requestId: Type.Optional(RequestId),
138
+ });
139
+
121
140
  /** The sidebar's "open": the session files flypi owns. No conversation needed. */
122
141
  export const ListSessionsCommand = Type.Object({
123
142
  type: Type.Literal("listSessions"),
@@ -618,6 +637,7 @@ export const ClientCommandSchema = Type.Union([
618
637
  SetModelCommand,
619
638
  SetThinkingLevelCommand,
620
639
  ResumeCommand,
640
+ UnsubscribeCommand,
621
641
  ExtensionReplyCommand,
622
642
  ListSessionsCommand,
623
643
  RenameSessionCommand,
@@ -659,6 +679,12 @@ export const HelloFrame = Type.Object({
659
679
  type: Type.Literal("hello"),
660
680
  protocolVersion: Type.Integer(),
661
681
  serverVersion: Type.String(),
682
+ /**
683
+ * The server's clock at handshake, in epoch ms. Event frames are stamped with that same clock, so a
684
+ * page on another machine can measure a streaming response against it instead of against a skewed
685
+ * local clock. Optional: a server that does not send it is assumed to share this one's clock.
686
+ */
687
+ now: Type.Optional(Type.Integer()),
662
688
  /** Optional so an older server still parses; every flypi server from here on sends it. */
663
689
  pi: Type.Optional(PiInfoFrame),
664
690
  });
@@ -667,6 +693,13 @@ export const EventFrame = Type.Object({
667
693
  type: Type.Literal("event"),
668
694
  conversationId: Id,
669
695
  seq: Type.Integer({ minimum: 1 }),
696
+ /**
697
+ * When the **backend** first saw this event, in epoch milliseconds. It rides on replays too, so the
698
+ * same event carries the same clock whenever it arrives — which is what lets a client that attaches
699
+ * late (a reconnect, or opening a session it was not holding) measure the response's real window
700
+ * instead of the burst the replay arrived in. Optional: a client without it falls back to arrival.
701
+ */
702
+ at: Type.Optional(Type.Integer({ minimum: 0 })),
670
703
  event: SessionEventFrameBody,
671
704
  });
672
705
 
@@ -677,6 +710,12 @@ export const SessionSummary = Type.Object({
677
710
  modified: Type.String(),
678
711
  messageCount: Type.Integer(),
679
712
  firstMessage: Type.String(),
713
+ /**
714
+ * Is a turn running in this session right now, in this process? The sidebar needs it as its own fact:
715
+ * sessions keep running with nobody looking, so the only place a background turn is visible before it
716
+ * lands is this flag — and it says nothing about a turn a previous process was cut short on.
717
+ */
718
+ running: Type.Optional(Type.Boolean()),
680
719
  });
681
720
 
682
721
  /**
@@ -25,7 +25,9 @@ import { createExtensionUiBridge } from "./extension-ui.ts";
25
25
  import type { CommandRow, DebugInfo, ExtensionUiFrame, SessionSummary, ConfigSnapshot, McpStatusSnapshot, PackagesSnapshot } from "./protocol.ts";
26
26
  import type { FlypiMessage } from "../shared/transcript.ts";
27
27
 
28
- export type EventListener = (seq: number, event: AgentSessionEvent) => void;
28
+ /** `at` is when this process saw the event (src/server/event-log.ts); a listener that does not need the
29
+ * clock for its own accounting can ignore it. */
30
+ export type EventListener = (seq: number, event: AgentSessionEvent, at: number) => void;
29
31
  /** What an extension said to the user (`src/server/extension-ui.ts`) — a second channel, not a pi event. */
30
32
  export type ExtensionUiListener = (frame: ExtensionUiFrame) => void;
31
33
  /** Supplies the session for a conversation: production uses `createFlypiSession`, tests use the stub model. */
@@ -63,8 +65,8 @@ export interface Conversation {
63
65
  memory: { block: string | null };
64
66
  /**
65
67
  * The conversation's extension UI bridge: `ctx.ui.notify/select/confirm/input` reach the page through it,
66
- * and it holds the dialogs still waiting for an answer. It outlives the session — `switchSession` binds a
67
- * new session to the same bridge, so a dialog survives the chat being reopened.
68
+ * and it holds the dialogs still waiting for an answer. It belongs to the conversation and to nothing
69
+ * else — one per conversation, for the whole life of the session it answers.
68
70
  */
69
71
  extensionUi: ReturnType<typeof createExtensionUiBridge>;
70
72
  /** Sockets watching this conversation's extension output. Detached with the socket, like event listeners. */
@@ -126,24 +128,30 @@ export class SessionRegistry {
126
128
  );
127
129
  }
128
130
 
129
- /** Rebind a conversation to an existing session file. The conversationId — what the client holds — does not change. */
130
- async switchSession(conversationId: string, sessionPath: string): Promise<Conversation> {
131
- const conversation = this.get(conversationId);
131
+ /**
132
+ * The conversation answering a session file, opened if there is none. **One session file, one
133
+ * conversation** — this is the whole mapping, and it is what makes a client's chr(c) safe to leave: a
134
+ * conversation is never moved onto another file and never taken away because someone looked at something
135
+ * else.
136
+ *
137
+ * Two readers of the same file would be two writers on it, so a file another conversation is already
138
+ * answering hands *that* conversation back — including a mid-turn one, whose answer is on its way to a
139
+ * page that may or may not be looking at it. Nothing is disposed here: a background turn keeps running
140
+ * and its events keep landing in its own log, which is what a client that comes back resumes.
141
+ */
142
+ async switchSession(sessionPath: string): Promise<Conversation> {
132
143
  const target = this.sessionFileIn(sessionPath);
133
- this.teardown(conversation);
134
- conversation.session = await this.createSession({
135
- sessionManager: SessionManager.open(target, this.paths.sessionsDir),
136
- conversationId,
137
- onMemoryBlock: (block) => (conversation.memory.block = block),
138
- extensionUi: conversation.extensionUi.uiContext,
139
- });
140
- conversation.memory.block = null;
141
- this.watch(conversation);
142
- // The transcript a client is looking at becomes the opened session's, from the start — so the
143
- // log restarts and the seed above is the whole of it. The client re-reads it with `resume`.
144
- conversation.log.reset();
145
- this.seedHistory(conversation);
146
- return conversation;
144
+ const live = this.findByFile(target);
145
+ if (live) return live;
146
+ return this.bind(SessionManager.open(target, this.paths.sessionsDir));
147
+ }
148
+
149
+ /** The conversation answering a session file, or undefined. There is at most one, by construction. */
150
+ findByFile(sessionFile: string): Conversation | undefined {
151
+ for (const conversation of this.conversations.values()) {
152
+ if (conversation.session.sessionFile === sessionFile) return conversation;
153
+ }
154
+ return undefined;
147
155
  }
148
156
 
149
157
  /**
@@ -171,7 +179,9 @@ export class SessionRegistry {
171
179
 
172
180
  get(conversationId: string): Conversation {
173
181
  const conversation = this.conversations.get(conversationId);
174
- if (!conversation) throw new Error(`unknown conversation ${conversationId}`);
182
+ // The code is what a page acts on: it holds a conversation id across reconnects, and after a restart
183
+ // every command it sends names a conversation this process never had (ui/lib/app-state.svelte.ts).
184
+ if (!conversation) throw Object.assign(new Error(`unknown conversation ${conversationId}`), { code: "unknownConversation" });
175
185
  return conversation;
176
186
  }
177
187
 
@@ -237,6 +247,11 @@ export class SessionRegistry {
237
247
  * The session files flypi owns, for the sidebar's list. pi's own list, so flypi keeps no second index
238
248
  * of conversations (docs/decisions.md: one source of truth) — which is also why a chat you have only
239
249
  * started, with nothing written, has no row: pi creates the file on its first entry.
250
+ *
251
+ * `running` is the one thing pi's list cannot know: whether a conversation in *this* process is
252
+ * mid-turn on that file. A turn keeps running with nobody looking at it, and that flag is the only
253
+ * sign of it on screen. It says nothing about a turn a previous process was cut short on — that is
254
+ * the daemon's `runs` record, reported when the session is opened.
240
255
  */
241
256
  async listSessions(): Promise<SessionSummary[]> {
242
257
  const sessions = await SessionManager.list(this.paths.root, this.paths.sessionsDir);
@@ -246,6 +261,7 @@ export class SessionRegistry {
246
261
  modified: session.modified.toISOString(),
247
262
  messageCount: session.messageCount,
248
263
  firstMessage: session.firstMessage,
264
+ running: this.findByFile(session.path)?.session.isIdle === false,
249
265
  }));
250
266
  }
251
267
 
@@ -258,16 +274,14 @@ export class SessionRegistry {
258
274
  * Delete a session file for good. Anything that was showing it goes first: pi appends lazily, so a
259
275
  * live session bound to a deleted file would write it back and the row would come straight back.
260
276
  * A turn still running is refused rather than cut short — that work would be lost silently.
261
- * ponytail: one user, one browser. Another tab holding the same session loses its conversation id
262
- * when this runs; if tabs ever share the server, tell them over the socket instead.
263
277
  */
264
278
  async deleteSession(sessionPath: string): Promise<void> {
265
279
  const target = this.sessionFileIn(sessionPath);
266
- for (const conversation of this.conversations.values()) {
267
- if (conversation.session.sessionFile !== target) continue;
268
- if (!conversation.session.isIdle) throw new Error(`session ${target} is mid-turn; abort it first`);
269
- this.teardown(conversation);
270
- this.conversations.delete(conversation.id);
280
+ const holder = this.findByFile(target);
281
+ if (holder) {
282
+ if (!holder.session.isIdle) throw new Error(`session ${target} is mid-turn; abort it first`);
283
+ this.teardown(holder);
284
+ this.conversations.delete(holder.id);
271
285
  }
272
286
  rmSync(target); // no force: a file that is not there is an error frame, not a silent success
273
287
  }
@@ -447,7 +461,8 @@ export class SessionRegistry {
447
461
  });
448
462
  const conversation: Conversation = {
449
463
  id,
450
- log: new EventLog<AgentSessionEvent>(this.options.logCapacity),
464
+ // The same clock eviction uses, so a scripted clock in a test stamps the events it drives.
465
+ log: new EventLog<AgentSessionEvent>(this.options.logCapacity, this.now),
451
466
  listeners: new Set(),
452
467
  messages: new Map(),
453
468
  session: await this.createSession({
@@ -530,7 +545,7 @@ export class SessionRegistry {
530
545
  }
531
546
  const buffered = conversation.log.push(event);
532
547
  if (event.type === "message_end") conversation.messages.set(buffered.seq, event.message);
533
- for (const listener of conversation.listeners) listener(buffered.seq, event);
548
+ for (const listener of conversation.listeners) listener(buffered.seq, event, buffered.at);
534
549
  });
535
550
  }
536
551
 
@@ -2,8 +2,11 @@
2
2
  * Live model speed metrics, read out of the same pi event stream the transcript mapper reads
3
3
  * (`src/shared/transcript.ts`).
4
4
  *
5
- * pi reports tokens, never tokens/second, so the only clock available is when the events arrived here.
6
- * That decides what each number honestly means:
5
+ * pi reports tokens, never tokens/second, so the only clock available is when the events were **seen**. That
6
+ * clock is the backend's: every event frame carries an `at` stamp taken when the server logged it
7
+ * (src/server/event-log.ts), and a replay carries the same stamp. Timing the arrival here instead made a
8
+ * replayed response — a reconnect, or a session opened late — arrive as one burst, which reported a
9
+ * thousands-of-tokens/second rate for a model that streamed at twenty. What each number honestly means:
7
10
  *
8
11
  * - **prefill** — the provider request up to the first content token, over the prompt tokens that
9
12
  * request carried. pi emits `turn_start` once per provider request, which is the closest the stream
@@ -18,8 +21,9 @@
18
21
  * cannot pull either rate down.
19
22
  *
20
23
  * `now` is injectable for the same reason the transcript reducer takes one: the numbers are testable
21
- * without a clock. A response with no measurable window (both events inside one ms, which a local model
22
- * does) reports `null`, not a huge number — an unknown rate is shown as unknown.
24
+ * without a clock. The caller passes the event's own stamp, so `now()` is one fixed instant per event. A
25
+ * response with no measurable window (both events inside one ms, which a local model does) reports `null`,
26
+ * not a huge number — an unknown rate is shown as unknown.
23
27
  *
24
28
  * A response that is still streaming is counted only from what the provider reported about it. Providers
25
29
  * differ in when they report: some carry usage in every chunk, so those numbers move as the answer streams;
@@ -1 +0,0 @@
1
- .revealed.svelte-19l53a6{color:var(--ink)}.memory-body.svelte-3w3u32{color:var(--ink);flex-basis:100%}.memory-row.svelte-3w3u32{align-items:flex-start}.memory-why.svelte-3w3u32,.memory-history.svelte-3w3u32,.memory-uses.svelte-3w3u32{gap:.25rem;width:100%;margin:.3rem 0 0;padding:0;list-style:none;display:grid}.memory-why.svelte-3w3u32 li:where(.svelte-3w3u32),.memory-history.svelte-3w3u32 li:where(.svelte-3w3u32){flex-wrap:wrap;align-items:center;gap:.4rem;display:flex}.search.svelte-3w3u32{font:inherit;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:4px;padding:.2rem .3rem}.preimage.svelte-3w3u32 summary:where(.svelte-3w3u32){color:var(--muted);cursor:pointer;font-size:11px}.preimage.svelte-3w3u32 pre:where(.svelte-3w3u32){max-height:12rem}.wiki-page.svelte-3w3u32 h4:where(.svelte-3w3u32){margin:.8rem 0 .2rem;font-size:13px}:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--bg:#fbfbfd;--panel:#fff;--line:#e3e4e9;--ink:#17181d;--muted:#6b6f7a;--accent:#2f6f4f;--error:#a3262a;--surface:#f4f5f7;--code:#f6f7f9;--queue:#f2f7f4}:root[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;--bg:#13151a;--panel:#1b1e25;--line:#2c303a;--ink:#e6e8ee;--muted:#98a0ae;--accent:#6cb892;--error:#e08084;--surface:#232730;--code:#1f232b;--queue:#1d2b24}*{box-sizing:border-box}body{background:var(--bg);color:var(--ink);margin:0}.layout{grid-template-columns:var(--sidebar-w,15rem) .5rem minmax(0, 1fr) .5rem var(--activity-w,22rem);gap:.25rem;height:100vh;padding:.75rem;font:14px/1.5 ui-sans-serif,system-ui,sans-serif;display:grid}.updating{text-align:center;height:100vh;color:var(--muted);place-content:center;gap:.3rem;padding:1rem;font:14px/1.5 ui-sans-serif,system-ui,sans-serif;display:grid}.updating h2{color:var(--ink);margin:0 0 .3rem;font-size:1.1rem}.updating p{margin:0}.sidebar,.chat,.activity-panel,.settings-view{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:.75rem;overflow:auto}.gutter{cursor:col-resize;touch-action:none;background:var(--line);border-radius:3px}.gutter:hover,.gutter:focus-visible{background:var(--accent)}.activity-panel{flex-direction:column;display:flex}.sidebar h1{margin:0 0 .5rem;font-size:1rem}.sidebar .port{color:var(--muted);font-weight:400}.sidebar .update{color:var(--muted);flex-wrap:wrap;align-items:center;gap:.3rem;margin:0 0 .5rem;font-size:.8rem;display:flex}.panel-head{flex-wrap:wrap;align-items:center;gap:.35rem;margin-bottom:.5rem;display:flex}.panel-head h1{flex:1;margin:0}.panel-actions{gap:.35rem;margin-bottom:.5rem;display:flex}.icon{flex:none;padding:.2rem .35rem}.panel-head .logo{border-radius:6px;flex:none;width:2.5rem;height:2.5rem}.icon svg{width:1.05rem;height:1.05rem;display:block}.field{color:var(--muted);margin-bottom:.5rem;font-size:12px;display:block}.field select{width:100%;margin-top:.15rem}.sessions{margin:.5rem 0;padding:0;list-style:none}.sessions li{align-items:center;gap:.15rem;display:flex}.sessions li>:first-child{flex:1;min-width:0}.sessions .remove{opacity:.6}.sessions .remove:hover{opacity:1}.sessions .session{text-align:left;white-space:nowrap;text-overflow:ellipsis;background:0 0;border:0;border-radius:6px;width:100%;overflow:hidden}.sessions .session:hover{background:var(--surface)}.sessions .session.current{background:var(--queue)}.sessions input.name{background:var(--queue);width:100%;color:inherit;border:0;border-radius:6px;font-weight:600}.sessions input.name:hover{background:var(--queue)}button,select,input,textarea{font:inherit;color:inherit}button{border:1px solid var(--line);background:var(--surface);cursor:pointer;border-radius:6px;padding:.25rem .5rem}button:hover{border-color:var(--accent)}input:not([type=checkbox]),select,textarea{border:1px solid var(--line);background:var(--surface);border-radius:6px;padding:.3rem .4rem}textarea{width:100%}.chat{flex-direction:column;display:flex}.messages{flex:1;margin:0;padding:0;list-style:none;overflow:auto}.msg{border-bottom:1px solid var(--line);padding:.4rem 0}.msg .role{text-transform:uppercase;letter-spacing:.04em;color:var(--muted);font-size:11px;display:block}.msg[data-role=user] .role{color:var(--accent)}.usage{color:var(--muted);float:right;font-size:11px}.msg .fork{opacity:.45;margin-top:.2rem}.msg:hover .fork{opacity:.8}.msg .fork:hover{opacity:1}.markdown pre,.tool-output,.thinking pre,.system-prompt,.orphan-result,.memory-block,.cache-usage,.cfg-log{background:var(--code);border:1px solid var(--line);white-space:pre-wrap;border-radius:6px;padding:.4rem;font-family:ui-monospace,monospace;font-size:12.5px;overflow:auto}.markdown pre{margin:.35rem 0}.markdown code{font-family:ui-monospace,monospace}details.thinking summary{cursor:pointer;color:var(--muted);font-size:12px}.tool-card{border:1px solid var(--line);border-radius:6px;margin:.35rem 0;padding:.35rem .5rem}.tool-card[data-state=running]{border-color:var(--accent)}.tool-card[data-state=error]{border-color:var(--error)}.tool-card header{align-items:baseline;gap:.5rem;display:flex}.tool-name{font-weight:600}.tool-args{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;flex:1;font-size:12px;overflow:hidden}.tool-state{color:var(--muted);font-size:11px}.queue{margin:.35rem 0 0;padding:0;list-style:none}.queued{border-left:3px solid var(--accent);background:var(--queue);padding:.25rem .4rem;font-size:12.5px}.extension-output{margin:.35rem 0 0;padding:0;list-style:none}.extension-note{border-left:3px solid var(--accent);background:var(--queue);white-space:pre-wrap;padding:.25rem .4rem;font-size:12.5px}.extension-note[data-level=warning]{border-left-color:var(--muted)}.extension-note[data-level=error]{border-left-color:var(--error)}.extension-dialog{border:1px solid var(--line);background:var(--surface);border-radius:8px;margin:.35rem 0 0;padding:.4rem .5rem}.extension-dialog .ask{white-space:pre-wrap;margin:0 0 .35rem;font-size:13px}.answers{flex-wrap:wrap;align-items:center;gap:.35rem;display:flex}.answer-input{align-items:center;gap:.35rem;display:flex}.commands{flex-wrap:wrap;gap:.3rem;margin:.35rem 0 0;padding:0;list-style:none;display:flex}.command{border:1px solid var(--line);background:var(--surface);cursor:pointer;border-radius:6px;padding:.15rem .4rem;font-size:12px}.command .desc{color:var(--muted);margin-left:.3rem}.attachments{flex-wrap:wrap;gap:.35rem;margin:0 0 .35rem;padding:0;list-style:none;display:flex}.attachment{border:1px solid var(--line);background:var(--surface);border-radius:6px;align-items:center;gap:.35rem;padding:.2rem .35rem;font-size:12px;display:flex}.attachment .thumb{object-fit:cover;border-radius:4px;flex:none;width:1.5rem;height:1.5rem}.attachment .name{text-overflow:ellipsis;white-space:nowrap;max-width:14rem;overflow:hidden}.attachment .remove{color:var(--muted);cursor:pointer;background:0 0;border:0;padding:0 .15rem}.attachment .remove:hover{color:var(--error)}.attachment-image{border:1px solid var(--line);border-radius:6px;max-width:min(100%,32rem);max-height:24rem;margin:.35rem 0;display:block}.error{color:var(--error)}.muted{color:var(--muted)}.notice{color:var(--muted);border-left:2px solid var(--line);padding:.15rem 0 .15rem .5rem}.picker{gap:.5rem;margin-top:.5rem;display:flex}.picker .field{flex:1;margin-bottom:0}.composer{border-top:1px solid var(--line);padding-top:.5rem}.controls{flex-wrap:wrap;align-items:center;gap:.5rem;margin-top:.35rem;display:flex}.controls .stop{border-color:var(--error);color:var(--error)}.offline{color:var(--muted);font-size:12px}.metrics{color:var(--muted);flex-wrap:wrap;align-items:center;gap:.6rem;margin-left:auto;font-size:11.5px;display:flex}.metrics b{color:var(--ink);font-variant-numeric:tabular-nums;font-weight:600}.gauge{flex:none;width:1.15rem;height:1.15rem;position:relative}.gauge svg{width:100%;height:100%;position:absolute;inset:0;transform:rotate(-90deg)}.gauge circle{fill:none;stroke-width:3px}.gauge .track{stroke:var(--line)}.gauge .fill{stroke:var(--accent);stroke-linecap:round;transition:stroke-dasharray .3s}.activity-panel h2{text-transform:uppercase;color:var(--muted);margin:0 0 .5rem;font-size:12px}.activity-panel h2 .count{font-weight:400}.activity-panel footer{border-top:1px solid var(--line);padding-top:.5rem}.activity{flex:1;margin:0;padding:0;list-style:none;overflow:auto}.activity li{border-bottom:1px solid var(--line);padding:.3rem 0}.activity li .tool-card{margin:0}.activity-empty{color:var(--muted);font-size:12px}.settings-view{grid-column:3/-1}.settings-view .panel-head h2{flex:1;margin:0;font-size:1rem}.settings-view .cfg-note{color:var(--muted);margin:0 0 .5rem;font-size:12px}.settings-view .cfg-note code{font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace}.cfg-stack{gap:1rem;max-width:46rem;display:grid}.cfg-section{align-items:center;gap:.5rem;margin:.9rem 0 .25rem;display:flex}.cfg-section h3{flex:1;margin:0;font-size:13px}.cfg-list.cfg-plain{border:0}.cfg-list.cfg-plain li{border-bottom:0;padding-left:0}.cfg-list{border:1px solid var(--line);border-radius:6px;margin:0;padding:0;list-style:none}.cfg-list li{border-bottom:1px solid var(--line);flex-wrap:wrap;align-items:center;gap:.4rem;padding:.3rem .4rem;display:flex}.cfg-list li:last-child{border-bottom:0}.cfg-list code{min-width:8rem;color:var(--muted);overflow-wrap:anywhere;flex:1;font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace}.cfg-list .cfg-name{color:inherit;font:inherit;text-align:left;cursor:pointer;background:0 0;border:0;padding:0;font-weight:600}.cfg-list .field{flex:none;margin:0}.cfg-list .field select{width:auto;margin-top:0}.cfg-list .icon{opacity:.6;flex:none}.cfg-list .icon:hover{opacity:1}.cfg-list .icon:disabled{opacity:.2;cursor:default}.cfg-list .remove{color:var(--error)}.cfg-default{color:var(--accent);white-space:nowrap;align-items:center;gap:.25rem;font-size:11px;font-weight:600;display:flex}.cfg-default select{font:inherit;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:4px;padding:.1rem .2rem;font-weight:400}.cfg-list .cfg-off,.cfg-list .cfg-none{color:var(--muted);font-size:11px}.cfg-list .cfg-state{color:var(--muted);white-space:nowrap;font-size:11px}.cfg-list .cfg-state.connected{color:var(--accent)}.cfg-list .cfg-state.failed,.cfg-list .cfg-state.needs-auth,.cfg-list .cfg-state-error{color:var(--error)}.cfg-log{max-height:12rem;margin:.4rem 0 0}.cfg-signin{border-top:1px solid var(--line);flex:1 0 100%;margin-top:.3rem;padding-top:.35rem}.cfg-signin p{overflow-wrap:anywhere;margin:.2rem 0;font-size:12px}.cfg-signin-form{flex-wrap:wrap;align-items:end;gap:.4rem;display:flex}.cfg-signin-form .field{flex:18rem;margin:0}.cfg-signin-form input{background:var(--panel);width:100%;color:var(--ink);border:1px solid var(--line);border-radius:4px;margin-top:.15rem;padding:.2rem .3rem;font:11px/1.4 ui-monospace,monospace}.cfg-modal{background:var(--panel);width:min(30rem,92vw);color:var(--ink);border:1px solid var(--line);border-radius:8px;max-height:78vh;margin:12vh auto auto;padding:.9rem;overflow:auto}.cfg-modal::backdrop{background:#0000008c}.cfg-modal .cfg-section{margin-top:0}.cfg-modal .field{margin-bottom:.35rem}.cfg-modal input,.cfg-modal select{width:100%;font:inherit;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:4px;margin-top:.15rem;padding:.2rem .3rem}.cfg-modal input:disabled{color:var(--muted)}.cfg-inline{align-items:end;gap:.5rem;display:flex}.cfg-inline .field:first-child{flex:1}.cfg-install{align-items:end;gap:.5rem;margin-bottom:.4rem;display:flex}.cfg-install .field{flex:18rem;margin:0}.cfg-install input{background:var(--panel);width:100%;color:var(--ink);border:1px solid var(--line);border-radius:4px;margin-top:.15rem;padding:.2rem .3rem;font:11px/1.4 ui-monospace,monospace}.cfg-modal textarea,.cfg-raw{background:var(--panel);width:100%;color:var(--ink);border:1px solid var(--line);resize:vertical;border-radius:4px;margin-top:.15rem;padding:.2rem .3rem;font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace}.cfg-modal details{margin:.45rem 0}.cfg-modal summary{color:var(--accent);cursor:pointer;font-size:12px}.cfg-modal details .cfg-note{margin:.35rem 0}.cfg-modal .error{font-size:12px}.cfg-modal .cfg-note{color:var(--muted);margin:0 0 .6rem;font-size:12px}.cfg-enabled{white-space:nowrap;align-items:center;gap:.25rem;display:flex}.cfg-enabled input{margin:0;width:auto!important}.cfg-actions{gap:.4rem;display:flex}.cfg-actions .remove{color:var(--error)}.linklike{color:var(--accent);font:inherit;background:0 0;border:0;padding:0;text-decoration:underline}.cfg-errors{max-width:46rem;margin:0 0 .5rem;padding:0;font-size:12px;list-style:none}