@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.
- package/README.md +5 -0
- package/dist/ui/assets/index-0MqV7dOT.js +102 -0
- package/dist/ui/assets/index-BCcOueOd.css +1 -0
- package/dist/ui/index.html +2 -2
- package/package.json +4 -2
- package/scripts/dev-container.mjs +131 -0
- package/scripts/release-check.mjs +239 -0
- package/src/server/event-log.ts +11 -8
- package/src/server/index.ts +34 -8
- package/src/server/protocol.ts +41 -2
- package/src/server/session-registry.ts +45 -30
- package/src/shared/metrics.ts +8 -4
- package/dist/ui/assets/index-CBuNmrDT.css +0 -1
- package/dist/ui/assets/index-Dv6ZykyY.js +0 -102
package/src/server/protocol.ts
CHANGED
|
@@ -51,10 +51,17 @@ export const NewSessionCommand = Type.Object({
|
|
|
51
51
|
requestId: Type.Optional(RequestId),
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
/**
|
|
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
|
-
|
|
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
|
|
67
|
-
*
|
|
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
|
-
/**
|
|
130
|
-
|
|
131
|
-
|
|
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.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
conversation.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
if (!
|
|
269
|
-
this.teardown(
|
|
270
|
-
this.conversations.delete(
|
|
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
|
-
|
|
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
|
|
package/src/shared/metrics.ts
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
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.
|
|
22
|
-
*
|
|
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}
|