@mjasnikovs/pi-task 0.39.2 → 0.39.3

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.
@@ -3,7 +3,7 @@ import { effectiveSetting, offeredLevels } from '../shared/reasoning-capability.
3
3
  import { resolveModel, specOf } from '../shared/model-resolve.js';
4
4
  import { MODEL_INHERIT } from './group-models.js';
5
5
  import { PairPicker } from './option-picker.js';
6
- import { registerBridgeCommand } from '../remote/bridge.js';
6
+ import { registerBridgeCommand, isRemoteOrigin, publishNote } from '../remote/bridge.js';
7
7
  import { readPkgVersion } from '../shared/pkg-version.js';
8
8
  import { SEARCH_PROVIDERS, SEARCH_PROVIDER_LABELS, providerForLabel } from '../workers/search-types.js';
9
9
  import { COMMAND_TIMEOUT_OPTIONS, DEBUG_LOG_OPTIONS, getConfig, sanitizeDebugLogs, saveConfig, STREAM_INACTIVITY_OPTIONS } from './config.js';
@@ -836,7 +836,10 @@ async function handleTaskConfig(_args, ctx, getTools = () => []) {
836
836
  // it can only be read here, when the menu opens, never at registration.
837
837
  const tools = getTools();
838
838
  const catalog = liveCatalog(ctx);
839
- if (ctx.mode !== 'tui') {
839
+ // The panel is a TUI component. A browser caller has no way to draw it and no
840
+ // way to close it, so awaiting it there parks the remote on the host's
841
+ // terminal — the failure issue #1 fixed for /task-plan.
842
+ if (ctx.mode !== 'tui' || isRemoteOrigin(ctx)) {
840
843
  // Built from panelItems and reading the SAME `format` the panel does,
841
844
  // so the two renderings cannot disagree about what a setting says. A
842
845
  // second walk of the same tables is the one place nobody would notice
@@ -850,6 +853,9 @@ async function handleTaskConfig(_args, ctx, getTools = () => []) {
850
853
  `[${i.label.trim()}]`
851
854
  : `${(i.headlessLabel ?? i.label).padEnd(22)} ${i.currentValue}`);
852
855
  ctx.ui.notify(lines.join(' | '), 'info');
856
+ // One row per line: the `|`-joined form is for a terminal that has one
857
+ // line to spare, and the browser has a whole transcript.
858
+ publishNote(lines.join('\n'));
853
859
  return;
854
860
  }
855
861
  // Built ONCE and shared by the renderer, the dispatch and the refresh, so
@@ -17,6 +17,17 @@ export declare function getBridge(): BridgeState;
17
17
  /** Resolve the remote side of a pending prompt. First call wins; later calls
18
18
  * (duplicate frames, a second browser, local-after-remote) are ignored. */
19
19
  export declare function answerPrompt(id: string, value: string | undefined): void;
20
+ /**
21
+ * Settle every parked ask with undefined. Its one caller is the remote `/new`,
22
+ * whose reset wipes the prompt slot and tells the browser to close its card,
23
+ * leaving the resolver here with no surface on any device — and, on a host with
24
+ * no TUI, no local half either.
25
+ *
26
+ * Deliberately NOT on every `session_start`: that also fires for the /task
27
+ * handoff's own newSession, where cancelling would read to the planner as the
28
+ * user skipping a question they were never shown.
29
+ */
30
+ export declare function cancelPendingPrompts(): void;
20
31
  export interface AskSpec {
21
32
  /** Themed, possibly multi-line title for the local TUI input. */
22
33
  localTitle: string;
@@ -86,6 +97,17 @@ export interface AskSpec {
86
97
  value: string;
87
98
  }[];
88
99
  }
100
+ /** A read-only display fanned out to both surfaces by {@link SessionUI.show}. */
101
+ export interface ShowSpec {
102
+ /** Title for the local terminal editor. */
103
+ localTitle: string;
104
+ /** Full text the local editor opens with. */
105
+ localText: string;
106
+ /** Card header on the browser. Plain text. */
107
+ question: string;
108
+ /** Card body on the browser, markdown-rendered in the panel. */
109
+ body: string;
110
+ }
89
111
  /** Wraps a live command ctx and fans interactions out to local TUI + browsers. */
90
112
  export declare class SessionUI {
91
113
  private readonly ctx;
@@ -95,6 +117,22 @@ export declare class SessionUI {
95
117
  get hasUI(): boolean;
96
118
  /** Race the local input against a remote answer; first to settle wins. */
97
119
  ask(spec: AskSpec): Promise<string | undefined>;
120
+ /**
121
+ * Show text that only needs dismissing. Locally that is the terminal editor;
122
+ * remotely it is a dismiss-only prompt card, and it has to be a prompt rather
123
+ * than a `viewer` because only the prompt slot is in the reconnect snapshot
124
+ * and only a prompt can settle the caller from the browser.
125
+ *
126
+ * pi's `ui.editor` takes no AbortSignal, so a remote dismissal cannot close
127
+ * the terminal editor the way a remote answer aborts a local ask. It is left
128
+ * open and whatever it eventually returns is discarded — the run has moved on.
129
+ */
130
+ show(spec: ShowSpec): Promise<void>;
131
+ /**
132
+ * The shared half of ask() and show(): publish one prompt card, open the
133
+ * local dialog beside it, and take whichever settles first.
134
+ */
135
+ private race;
98
136
  /**
99
137
  * The local-TUI half of ask(). With `spec.options` it renders the boxed
100
138
  * picker (each answer in its own bounding box, the first/recommended one
@@ -126,7 +164,33 @@ export declare function interruptAgent(): void;
126
164
  * transient toast since they don't need to linger.
127
165
  */
128
166
  export declare function publishLifecycleNotice(message: string, level: 'info' | 'warning' | 'error'): void;
129
- export declare function publishViewer(title: string, text: string): void;
167
+ /**
168
+ * Say it on BOTH surfaces. The local notify is attempted first and its failure
169
+ * absorbed — a stale ctx must not cost the remote the message, which is the one
170
+ * surface still there when nobody is at the terminal.
171
+ */
172
+ export declare function notifyBoth(ctx: Pick<ExtensionCommandContext, 'ui'>, message: string, level: 'info' | 'warning' | 'error'): void;
173
+ /** {@link notifyBoth} for something that must outlive a toast — see
174
+ * {@link publishRunNotice}. */
175
+ export declare function notifyRun(ctx: Pick<ExtensionCommandContext, 'ui'>, message: string, level: 'info' | 'warning' | 'error'): void;
176
+ /**
177
+ * A run outcome the remote must still have later: what a gate decided, what a
178
+ * commit failed to do, what a resume refused. Unlike publishLifecycleNotice it
179
+ * persists at EVERY level, because "still there in the morning" is the reason
180
+ * these are published at all — a warning that vanishes after four seconds tells
181
+ * an absent user nothing.
182
+ */
183
+ export declare function publishRunNotice(message: string, level: 'info' | 'warning' | 'error'): void;
184
+ /** Commit text to the remote transcript. Unlike a notify it is in the reconnect
185
+ * snapshot, so it is still there after a refresh. */
186
+ export declare function publishNote(text: string): void;
187
+ /**
188
+ * True when this ctx reached the handler through the browser rather than the
189
+ * terminal. A command that can only draw itself on the host TUI must check it:
190
+ * awaiting a local overlay for a remote caller parks the browser on a dialog
191
+ * nobody is sitting in front of.
192
+ */
193
+ export declare function isRemoteOrigin(ctx: ExtensionCommandContext): boolean;
130
194
  /**
131
195
  * Wraps an event-scoped ExtensionContext so it can be used as a command ctx
132
196
  * for commands that don't need newSession (e.g. /task-resume, /task-list,
@@ -1,6 +1,6 @@
1
1
  import { broadcast as wsBroadcast } from './broadcast.js';
2
2
  import { pushNotify } from './push.js';
3
- import { setPrompt, clearPrompt, addError } from './session-state.js';
3
+ import { setPrompt, clearPrompt, addError, addSystemNote, getState } from './session-state.js';
4
4
  import { askQuestionBox } from '../task/question-box.js';
5
5
  const g = globalThis;
6
6
  export function getBridge() {
@@ -26,6 +26,22 @@ export function answerPrompt(id, value) {
26
26
  b.pending.delete(id);
27
27
  settle(value);
28
28
  }
29
+ /**
30
+ * Settle every parked ask with undefined. Its one caller is the remote `/new`,
31
+ * whose reset wipes the prompt slot and tells the browser to close its card,
32
+ * leaving the resolver here with no surface on any device — and, on a host with
33
+ * no TUI, no local half either.
34
+ *
35
+ * Deliberately NOT on every `session_start`: that also fires for the /task
36
+ * handoff's own newSession, where cancelling would read to the planner as the
37
+ * user skipping a question they were never shown.
38
+ */
39
+ export function cancelPendingPrompts() {
40
+ const b = getBridge();
41
+ for (const settle of b.pending.values())
42
+ settle(undefined);
43
+ b.pending.clear();
44
+ }
29
45
  /** Wraps a live command ctx and fans interactions out to local TUI + browsers. */
30
46
  export class SessionUI {
31
47
  ctx;
@@ -42,35 +58,78 @@ export class SessionUI {
42
58
  }
43
59
  /** Race the local input against a remote answer; first to settle wins. */
44
60
  async ask(spec) {
45
- const b = this.bridge;
46
- const id = String(b.nextId++);
47
- const ac = new AbortController();
48
- const remote = new Promise(resolve => {
49
- b.pending.set(id, resolve);
50
- });
51
- const prompt = {
61
+ return this.race(id => ({
52
62
  type: 'prompt',
53
63
  id,
54
64
  question: spec.question,
55
65
  recommended: spec.recommended,
56
66
  ...(spec.recommended2 !== undefined && { recommended2: spec.recommended2 }),
57
- ...(spec.actions !== undefined && spec.actions.length > 0 && { actions: spec.actions }),
67
+ ...(spec.actions !== undefined
68
+ && spec.actions.length > 0 && { actions: spec.actions }),
58
69
  allowSkip: spec.allowSkip
59
- };
60
- setPrompt(prompt);
61
- // Reaches a backgrounded/suspended phone, which the in-page UI can't. The
62
- // service worker drops the banner if a window is visible+focused (sw.ts),
63
- // so we always push and let delivery-time visibility decide.
64
- void pushNotify('pi needs your input', spec.question, 'pi-prompt').catch(() => { });
70
+ }), spec.question, signal => this.askLocal(spec, signal));
71
+ }
72
+ /**
73
+ * Show text that only needs dismissing. Locally that is the terminal editor;
74
+ * remotely it is a dismiss-only prompt card, and it has to be a prompt rather
75
+ * than a `viewer` because only the prompt slot is in the reconnect snapshot
76
+ * and only a prompt can settle the caller from the browser.
77
+ *
78
+ * pi's `ui.editor` takes no AbortSignal, so a remote dismissal cannot close
79
+ * the terminal editor the way a remote answer aborts a local ask. It is left
80
+ * open and whatever it eventually returns is discarded — the run has moved on.
81
+ */
82
+ async show(spec) {
83
+ // Nothing to wait for: with no local editor the only surface is the
84
+ // browser, and a DISPLAY that blocks a run until someone opens one is
85
+ // worse than a note nobody dismisses.
86
+ if (!this.ctx.hasUI) {
87
+ publishNote(`${spec.question}\n${spec.body}`);
88
+ return;
89
+ }
90
+ await this.race(id => ({
91
+ type: 'prompt',
92
+ id,
93
+ question: spec.question,
94
+ recommended: spec.body,
95
+ dismissOnly: true,
96
+ allowSkip: false
97
+ }),
98
+ // No push: a read-only card is not a request for input, and waking a
99
+ // phone to say it is would be a lie.
100
+ null, () => this.ctx.ui.editor(spec.localTitle, spec.localText));
101
+ }
102
+ /**
103
+ * The shared half of ask() and show(): publish one prompt card, open the
104
+ * local dialog beside it, and take whichever settles first.
105
+ */
106
+ async race(build, pushBody, local) {
107
+ const b = this.bridge;
108
+ const id = String(b.nextId++);
109
+ const ac = new AbortController();
110
+ const remote = new Promise(resolve => {
111
+ b.pending.set(id, resolve);
112
+ });
113
+ // The slot holds one prompt. Whatever is in it is put BACK when this one
114
+ // settles, or a /task-list would leave a parked question unanswerable
115
+ // from every browser — off the card and out of the snapshot both.
116
+ const displaced = getState().prompt;
117
+ setPrompt(build(id));
118
+ if (pushBody !== null) {
119
+ // Reaches a backgrounded/suspended phone, which the in-page UI can't. The
120
+ // service worker drops the banner if a window is visible+focused (sw.ts),
121
+ // so we always push and let delivery-time visibility decide.
122
+ void pushNotify('pi needs your input', pushBody, 'pi-prompt').catch(() => { });
123
+ }
65
124
  // Local: resolves to a value, or to undefined on cancel or abort. The
66
125
  // catch is belt-and-braces — a rejection here would surface as an
67
126
  // unhandled one rather than as a lost race.
68
- const local = this.ctx.hasUI ?
69
- this.askLocal(spec, ac.signal).catch(() => undefined)
127
+ const localSide = this.ctx.hasUI ?
128
+ local(ac.signal).catch(() => undefined)
70
129
  : new Promise(() => { });
71
130
  try {
72
131
  const winner = await Promise.race([
73
- local.then(v => ({ from: 'local', v })),
132
+ localSide.then(v => ({ from: 'local', v })),
74
133
  remote.then(v => ({ from: 'remote', v }))
75
134
  ]);
76
135
  if (winner.from === 'remote')
@@ -80,6 +139,8 @@ export class SessionUI {
80
139
  finally {
81
140
  b.pending.delete(id);
82
141
  clearPrompt(id);
142
+ if (displaced && b.pending.has(displaced.id))
143
+ setPrompt(displaced);
83
144
  }
84
145
  }
85
146
  /**
@@ -141,11 +202,61 @@ export function publishLifecycleNotice(message, level) {
141
202
  else
142
203
  publishNotify(message, level);
143
204
  }
144
- export function publishViewer(title, text) {
145
- getBridge().broadcast({ type: 'viewer', title, text });
205
+ /**
206
+ * Say it on BOTH surfaces. The local notify is attempted first and its failure
207
+ * absorbed — a stale ctx must not cost the remote the message, which is the one
208
+ * surface still there when nobody is at the terminal.
209
+ */
210
+ export function notifyBoth(ctx, message, level) {
211
+ try {
212
+ ctx.ui.notify(message, level);
213
+ }
214
+ catch {
215
+ /* stale ctx — the remote half below is what matters */
216
+ }
217
+ publishLifecycleNotice(message, level);
218
+ }
219
+ /** {@link notifyBoth} for something that must outlive a toast — see
220
+ * {@link publishRunNotice}. */
221
+ export function notifyRun(ctx, message, level) {
222
+ try {
223
+ ctx.ui.notify(message, level);
224
+ }
225
+ catch {
226
+ /* stale ctx — the remote half below is what matters */
227
+ }
228
+ publishRunNotice(message, level);
229
+ }
230
+ /**
231
+ * A run outcome the remote must still have later: what a gate decided, what a
232
+ * commit failed to do, what a resume refused. Unlike publishLifecycleNotice it
233
+ * persists at EVERY level, because "still there in the morning" is the reason
234
+ * these are published at all — a warning that vanishes after four seconds tells
235
+ * an absent user nothing.
236
+ */
237
+ export function publishRunNotice(message, level) {
238
+ if (level === 'error')
239
+ addError(message);
240
+ else
241
+ addSystemNote(message);
242
+ }
243
+ /** Commit text to the remote transcript. Unlike a notify it is in the reconnect
244
+ * snapshot, so it is still there after a refresh. */
245
+ export function publishNote(text) {
246
+ addSystemNote(text);
146
247
  }
147
248
  // ─── Shimmed ctx ─────────────────────────────────────────────────────────────
148
249
  const SHIMMED_MARKER = '__piRemoteShimmed';
250
+ const REMOTE_ORIGIN_MARKER = '__piRemoteOrigin';
251
+ /**
252
+ * True when this ctx reached the handler through the browser rather than the
253
+ * terminal. A command that can only draw itself on the host TUI must check it:
254
+ * awaiting a local overlay for a remote caller parks the browser on a dialog
255
+ * nobody is sitting in front of.
256
+ */
257
+ export function isRemoteOrigin(ctx) {
258
+ return ctx[REMOTE_ORIGIN_MARKER] === true;
259
+ }
149
260
  /**
150
261
  * Wraps an event-scoped ExtensionContext so it can be used as a command ctx
151
262
  * for commands that don't need newSession (e.g. /task-resume, /task-list,
@@ -178,7 +289,12 @@ export function registerBridgeCommand(pi, name, def) {
178
289
  const wrapped = {
179
290
  ...def,
180
291
  handler: (args, ctx) => {
181
- b.currentCtx = ctx; // keep latest live ctx for remote dispatch
292
+ // A remote-dispatched ctx is a clone of this very field. Storing it
293
+ // back would make the next remote line clone the clone — one more
294
+ // prototype link per command, for the life of the session — and would
295
+ // leave the marker on the ctx a later TERMINAL command is handed.
296
+ if (!isRemoteOrigin(ctx))
297
+ b.currentCtx = ctx;
182
298
  return def.handler(args, ctx);
183
299
  }
184
300
  };
@@ -242,8 +358,12 @@ export function dispatchRemoteLine(text, opts) {
242
358
  // sync throws and async rejections from the (often async) command handler
243
359
  // as a toast instead of crashing or becoming an unhandled rejection.
244
360
  const toastErr = (err) => publishNotify(`/${name} failed: ${err.message}`, 'error');
361
+ // A prototype clone rather than the ctx itself: the marker must not leak onto
362
+ // the shared currentCtx and make the NEXT terminal invocation look remote.
363
+ const remoteCtx = Object.create(b.currentCtx);
364
+ remoteCtx[REMOTE_ORIGIN_MARKER] = true;
245
365
  try {
246
- const result = handler(args, b.currentCtx);
366
+ const result = handler(args, remoteCtx);
247
367
  if (result instanceof Promise)
248
368
  result.catch(toastErr);
249
369
  }
@@ -14,6 +14,14 @@ export interface PromptMessage {
14
14
  label: string;
15
15
  value: string;
16
16
  }[];
17
+ /**
18
+ * The card only DISPLAYS. `recommended` carries the body, and the buttons
19
+ * collapse to a single Close that answers with an empty string. Its one
20
+ * producer is /task-plan's answer to a question the USER asked, which needs
21
+ * dismissing rather than answering — and which needs to be a prompt, not a
22
+ * broadcast-only frame, because only a prompt is in the reconnect snapshot.
23
+ */
24
+ dismissOnly?: boolean;
17
25
  allowSkip: boolean;
18
26
  }
19
27
  export interface PromptResolvedMessage {
@@ -58,11 +66,6 @@ export interface HeldMessage {
58
66
  /** True while a task run owns the session (drives the composer placeholder). */
59
67
  runActive: boolean;
60
68
  }
61
- export interface ViewerMessage {
62
- type: 'viewer';
63
- title: string;
64
- text: string;
65
- }
66
69
  export interface ContextUsage {
67
70
  tokens?: number;
68
71
  contextWindow?: number;
@@ -87,7 +90,7 @@ export type { SnapshotMessage } from './session-state.js';
87
90
  /** Server → browser messages. The live text_delta / tool_* / agent_* /
88
91
  * user_message deltas are emitted by the SessionState mutators
89
92
  * and not all enumerated here; the snapshot below carries the full state. */
90
- export type ServerMessage = PromptMessage | PromptResolvedMessage | WidgetMessage | NotifyMessage | ViewerMessage | HeldMessage | ContextMessage | ResetMessage | import('./session-state.js').SnapshotMessage;
93
+ export type ServerMessage = PromptMessage | PromptResolvedMessage | WidgetMessage | NotifyMessage | HeldMessage | ContextMessage | ResetMessage | import('./session-state.js').SnapshotMessage;
91
94
  /** Browser → server messages. */
92
95
  export interface ClientChatMessage {
93
96
  type: 'message';
@@ -1,5 +1,5 @@
1
1
  import { getConfig } from '../config/config.js';
2
- import { getBridge, dispatchRemoteLine, dispatchRemoteNewSession, makeShimmedCtx, interruptAgent, registerBridgeCommand, registerRemoteOnlyCommand, publishNotify } from './bridge.js';
2
+ import { getBridge, dispatchRemoteLine, dispatchRemoteNewSession, makeShimmedCtx, interruptAgent, registerBridgeCommand, registerRemoteOnlyCommand, publishNotify, cancelPendingPrompts, notifyBoth } from './bridge.js';
3
3
  import { setupEvents } from './events.js';
4
4
  import { reset, addUserTurn, setHeld, getState } from './session-state.js';
5
5
  import { html } from './ui.js';
@@ -45,6 +45,12 @@ export function registerRemote(pi) {
45
45
  return S.server;
46
46
  S.server = await startServer(text => {
47
47
  if (text === '/new') {
48
+ // The reset inside a new session clears the browser's prompt
49
+ // card, so a parked ask has lost its last surface. Only HERE:
50
+ // session_start also fires for the /task handoff's own
51
+ // newSession, and cancelling there reads to the planner as the
52
+ // user skipping a question they never saw.
53
+ cancelPendingPrompts();
48
54
  dispatchRemoteNewSession(newCtx => {
49
55
  S.send = (msg, opts) => {
50
56
  void (opts ?
@@ -89,7 +95,7 @@ export function registerRemote(pi) {
89
95
  if (getConfig().remote) {
90
96
  // Optional feature: a bind failure must never take pi down. Degrade
91
97
  // to a one-line warning and keep the agent running without remote.
92
- void ensureServer().catch(err => ctx.ui.notify(`Remote UI unavailable: ${err.message}`, 'warning'));
98
+ void ensureServer().catch(err => notifyBoth(ctx, `Remote UI unavailable: ${err.message}`, 'warning'));
93
99
  }
94
100
  });
95
101
  pi.on('session_shutdown', (event, _ctx) => {
@@ -124,7 +130,7 @@ export function registerRemote(pi) {
124
130
  description: 'Show the remote QR code & URLs.',
125
131
  handler: async (args, ctx) => {
126
132
  if (!getConfig().remote) {
127
- ctx.ui.notify('Remote is disabled — enable it in /task-config.', 'info');
133
+ notifyBoth(ctx, 'Remote is disabled — enable it in /task-config.', 'info');
128
134
  return;
129
135
  }
130
136
  if (args.trim() === 'stop') {
@@ -134,10 +140,10 @@ export function registerRemote(pi) {
134
140
  S.server = null;
135
141
  S.serveResult = null;
136
142
  void teardownTailscaleServe(port).catch(() => { });
137
- ctx.ui.notify('Remote server stopped', 'info');
143
+ notifyBoth(ctx, 'Remote server stopped', 'info');
138
144
  }
139
145
  else {
140
- ctx.ui.notify('Remote server is not running', 'warning');
146
+ notifyBoth(ctx, 'Remote server is not running', 'warning');
141
147
  }
142
148
  return;
143
149
  }
@@ -193,10 +199,10 @@ export function registerRemote(pi) {
193
199
  })
194
200
  .catch(() => { });
195
201
  }
196
- ctx.ui.notify(`Remote running at ${primaryUrl}`, 'info');
202
+ notifyBoth(ctx, `Remote running at ${primaryUrl}`, 'info');
197
203
  }
198
204
  catch (err) {
199
- ctx.ui.notify(`Remote UI unavailable: ${err.message}`, 'error');
205
+ notifyBoth(ctx, `Remote UI unavailable: ${err.message}`, 'error');
200
206
  }
201
207
  }
202
208
  });
@@ -198,7 +198,12 @@ export function setPrompt(prompt) {
198
198
  }
199
199
  export function clearPrompt(id) {
200
200
  const s = getState();
201
- s.prompt = null;
201
+ // Only the prompt that is actually on screen. Two asks can be live at once —
202
+ // dispatchRemoteLine starts a command whatever else is running — and the slot
203
+ // holds one, so clearing unconditionally would drop the survivor out of the
204
+ // snapshot and leave a reconnecting browser unable to answer it.
205
+ if (s.prompt?.id === id)
206
+ s.prompt = null;
202
207
  s.sink({ type: 'prompt_resolved', id });
203
208
  }
204
209
  export function setContext(context) {
@@ -101,9 +101,6 @@ export function clientScript(wsUrl) {
101
101
  const promptRecText = document.getElementById('prompt-rec-text');
102
102
  const promptInput = document.getElementById('prompt-input');
103
103
  const promptButtons = document.getElementById('prompt-buttons');
104
- const viewer = document.getElementById('viewer');
105
- const viewerBody = document.getElementById('viewer-body');
106
- document.getElementById('viewer-close').onclick = function () { viewer.style.display = 'none'; };
107
104
  let activePromptId = null;
108
105
  let activeRecommended = '';
109
106
  let activeRecommended2 = '';
@@ -832,13 +829,16 @@ export function clientScript(wsUrl) {
832
829
  return out;
833
830
  }
834
831
 
835
- function renderButtons(buttons, stacked) {
832
+ // omitCancel is for the dismiss-only card: there is nothing to cancel, and a
833
+ // Cancel task button beside a read-only answer would offer to abort the run
834
+ // when the only real choice is to close.
835
+ function renderButtons(buttons, stacked, omitCancel) {
836
836
  promptButtons.className = stacked ? 'row stacked' : 'row';
837
837
  promptButtons.innerHTML = '';
838
838
  for (let i = 0; i < buttons.length; i++) promptButtons.appendChild(buttons[i]);
839
839
  const actions = makeActionBtns();
840
840
  for (let i = 0; i < actions.length; i++) promptButtons.appendChild(actions[i]);
841
- promptButtons.appendChild(makeCancelBtn());
841
+ if (!omitCancel) promptButtons.appendChild(makeCancelBtn());
842
842
  }
843
843
 
844
844
  // Manual-entry view: empty textarea + Submit, reachable from the
@@ -881,7 +881,14 @@ export function clientScript(wsUrl) {
881
881
  activeRecommended = msg.recommended || '';
882
882
  activeRecommended2 = msg.recommended2 || '';
883
883
  activeActions = msg.actions || [];
884
- if (msg.recommended) {
884
+ if (msg.dismissOnly) {
885
+ // Mode C: nothing to answer. The body goes in the panel so it renders as
886
+ // markdown, and Close is the only way out.
887
+ promptInput.style.display = 'none';
888
+ promptRec.style.display = 'block';
889
+ setContent(promptRecText, msg.recommended || '');
890
+ renderButtons([makeBtn('Close', 'primary', function () { answer(''); })], false, true);
891
+ } else if (msg.recommended) {
885
892
  // Mode A: recommendation(s) present. Render markdown in the panel so a
886
893
  // recommendation with code/emphasis reads the same as an assistant bubble.
887
894
  setContent(promptRecText, msg.recommended);
@@ -1086,10 +1093,6 @@ export function clientScript(wsUrl) {
1086
1093
  case 'notify':
1087
1094
  showToast(msg.message, msg.level);
1088
1095
  break;
1089
- case 'viewer':
1090
- viewerBody.textContent = msg.text;
1091
- viewer.style.display = 'block';
1092
- break;
1093
1096
  case 'reset':
1094
1097
  // A new session started — wipe the previous session's transcript.
1095
1098
  chatLog.innerHTML = '';
@@ -1 +1 @@
1
- export declare const STYLES = " :root {\n --base: #1e1e2e; --mantle: #181825; --crust: #11111b;\n --surface0: #313244; --surface1: #45475a; --surface2: #585b70;\n --text: #cdd6f4; --subtext1: #a6adc8; --subtext0: #7f849c;\n --mauve: #cba6f7; --blue: #89b4fa; --green: #a6e3a1; --red: #f38ba8;\n --yellow: #f9e2af; --peach: #fab387; --teal: #94e2d5;\n }\n *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n body {\n background: var(--base); color: var(--text);\n font-family: ui-monospace, monospace;\n /* --app-h is set by setAppHeight (visualViewport.height, falling back to\n window.innerHeight) so the column height is a stable pixel value across\n an orientation change. 100dvh is the fallback for first paint / no-JS. */\n height: var(--app-h, 100dvh);\n display: flex; flex-direction: column; overflow: hidden;\n padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px)\n 0px env(safe-area-inset-left, 0px);\n }\n #context-bar { height: 4px; background: var(--surface0); flex-shrink: 0; }\n #context-bar-fill { height: 100%; background: var(--mauve); width: 0%; transition: width 0.4s ease; }\n #header {\n background: var(--mantle); padding: 8px 16px;\n display: flex; justify-content: space-between; align-items: center;\n font-size: 13px; flex-shrink: 0; border-bottom: 1px solid var(--surface0);\n }\n #header .title { font-weight: bold; color: var(--mauve); letter-spacing: 0.05em;\n position: relative; animation: glitch 5s steps(1) infinite; }\n @keyframes glitch {\n 0%, 88%, 100% { text-shadow: none; transform: translate(0, 0); }\n 90% { text-shadow: -1px 0 var(--red), 1px 0 var(--teal); transform: translate(1px, -1px); }\n 92% { text-shadow: 1px 0 var(--red), -1px 0 var(--blue); transform: translate(-1px, 1px); }\n 94% { text-shadow: -1px 0 var(--blue), 1px 0 var(--red); transform: translate(1px, 0); }\n 96% { text-shadow: 1px 0 var(--teal), -1px 0 var(--red); transform: translate(-1px, 0); }\n }\n @media (prefers-reduced-motion: reduce) { #header .title { animation: none; } }\n #header .hgroup { display: flex; align-items: center; gap: 10px; }\n #bell {\n background: none; border: none; color: var(--subtext1); cursor: pointer;\n font-size: 15px; line-height: 1; padding: 2px; font-family: inherit;\n }\n #bell:hover { color: var(--text); }\n #bell.on { color: var(--mauve); }\n /* Header status chip: connection dot + model name + context usage. */\n #status-chip { display: flex; align-items: center; gap: 7px; font-size: 11px; color: var(--subtext0); }\n #status-dot {\n width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;\n background: var(--surface2); transition: background 0.2s ease;\n }\n #status-dot.idle { background: var(--green); }\n #status-dot.running { background: var(--mauve); animation: dot-pulse 1.2s ease-in-out infinite; }\n #status-dot.disconnected { background: var(--red); }\n @keyframes dot-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }\n @media (prefers-reduced-motion: reduce) { #status-dot.running { animation: none; } }\n #status-model { color: var(--subtext1); }\n #status-model:empty { display: none; }\n #status-ctx { color: var(--subtext0); font-variant-numeric: tabular-nums; }\n #status-ctx:empty { display: none; }\n /* Notification bell dropdown: a push toggle row + the recent-toast history. */\n #notif-panel {\n display: none; position: fixed; z-index: 80;\n top: calc(env(safe-area-inset-top, 0px) + 42px);\n right: calc(env(safe-area-inset-right, 0px) + 12px);\n width: min(320px, calc(100vw - 24px));\n background: var(--mantle); border: 1px solid var(--surface1); border-radius: 8px;\n box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); overflow: hidden;\n }\n #notif-panel.open { display: block; }\n #notif-toggle-row {\n display: flex; align-items: center; justify-content: space-between;\n padding: 8px 12px; border-bottom: 1px solid var(--surface0);\n }\n #notif-title { font-size: 12px; color: var(--subtext1); font-weight: 700; }\n #notif-toggle {\n background: var(--surface1); color: var(--text); border: none; border-radius: 6px;\n padding: 4px 10px; font-family: inherit; font-size: 11px; cursor: pointer;\n }\n #notif-toggle:hover { filter: brightness(1.1); }\n #notif-toggle.on { background: var(--mauve); color: var(--crust); font-weight: 700; }\n #notif-list { max-height: 40dvh; overflow-y: auto; }\n #notif-empty { padding: 14px 12px; color: var(--subtext0); font-size: 11px; text-align: center; }\n .notif-item {\n display: flex; align-items: baseline; gap: 8px; padding: 7px 12px;\n border-bottom: 1px solid var(--surface0); font-size: 12px;\n }\n .notif-item:last-child { border-bottom: none; }\n .notif-item .notif-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; background: var(--blue); align-self: center; }\n .notif-item.warning .notif-dot { background: var(--peach); }\n .notif-item.error .notif-dot { background: var(--red); }\n .notif-item .notif-msg { flex: 1; min-width: 0; color: var(--text); overflow-wrap: anywhere; word-break: break-word; }\n .notif-item .notif-time { flex-shrink: 0; color: var(--subtext0); font-size: 10px; font-variant-numeric: tabular-nums; }\n #chat-wrap { position: relative; flex: 1; min-height: 0; display: flex; }\n #chat-log {\n flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; padding: 16px;\n display: flex; flex-direction: column; gap: 8px;\n }\n /* Floating jump-to-latest button \u2014 only shown when scrolled away from the\n bottom (toggled via .visible from the scroll handler). */\n #scroll-bottom {\n display: none; position: absolute; bottom: 16px; right: 16px; z-index: 40;\n width: 36px; height: 36px; border-radius: 50%; cursor: pointer;\n background: var(--surface1); color: var(--text); border: 1px solid var(--surface2);\n font-family: inherit; font-size: 18px; line-height: 1; padding: 0;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);\n }\n #scroll-bottom:hover { background: var(--surface2); color: var(--mauve); }\n #scroll-bottom.visible { display: block; }\n #chat-log::-webkit-scrollbar { width: 6px; }\n #chat-log::-webkit-scrollbar-track { background: transparent; }\n #chat-log::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 3px; }\n .bubble {\n max-width: 82%; padding: 8px 12px; border-radius: 8px;\n line-height: 1.6; white-space: pre-wrap; word-break: break-word; font-size: 13px;\n }\n .bubble.user { background: var(--surface1); color: var(--text); align-self: flex-end; }\n .bubble.assistant { background: var(--surface0); color: var(--text); align-self: flex-start; position: relative; }\n .bubble.error {\n background: var(--crust); color: var(--red); align-self: stretch;\n max-width: 100%; border: 1px solid var(--red); font-size: 12px;\n }\n /* Persistent inline system note (e.g. context compaction) \u2014 a muted centered\n divider, distinct from chat bubbles. */\n .sysnote {\n align-self: center; color: var(--subtext0); font-size: 11px;\n font-family: ui-monospace, monospace; letter-spacing: 0.5px;\n padding: 2px 10px; opacity: 0.85;\n }\n .bubble.thinking {\n display: flex; gap: 5px; align-items: center; padding: 10px 14px;\n }\n .bubble.thinking .spinner {\n color: var(--mauve); font-size: 15px; line-height: 1;\n font-family: ui-monospace, monospace;\n }\n /* Collapsed reasoning block (\"\u273B Thinking\u2026 (n lines)\"), muted + italic. */\n .thinking-block { align-self: flex-start; max-width: 90%; font-size: 12px; }\n .thinking-block > summary {\n color: var(--subtext0); font-style: italic; cursor: pointer; list-style: none;\n user-select: none; display: flex; align-items: center; gap: 8px; padding: 2px 0;\n }\n .thinking-block > summary::-webkit-details-marker { display: none; }\n .thinking-block .thinking-spin {\n color: var(--mauve); font-style: normal; font-family: ui-monospace, monospace;\n }\n .thinking-block .thinking-body {\n color: var(--subtext0); font-style: italic; white-space: pre-wrap;\n word-break: break-word; line-height: 1.5; margin: 4px 0 0 4px;\n padding: 4px 0 2px 12px; border-left: 2px solid var(--surface1);\n }\n /* Copy buttons: on code-block headers and (floating) on finished assistant\n bubbles. Wired by one delegated click handler in the client script. */\n .copy-btn {\n background: transparent; border: none; color: var(--subtext0); cursor: pointer;\n font-family: inherit; font-size: 11px; padding: 2px 6px; border-radius: 4px;\n line-height: 1.4;\n }\n .copy-btn:hover { color: var(--text); background: var(--surface1); }\n .copy-btn.copied { color: var(--green); }\n .bubble-copy {\n position: absolute; top: 4px; right: 4px; opacity: 0;\n background: var(--surface1); transition: opacity 0.12s ease;\n }\n .bubble.assistant:hover .bubble-copy { opacity: 1; }\n /* Touch devices have no hover \u2014 keep the button faintly visible. */\n @media (hover: none) { .bubble-copy { opacity: 0.55; } }\n .tool-call {\n background: var(--crust); border-radius: 6px; align-self: flex-start;\n max-width: 90%; font-size: 12px; border: 1px solid var(--surface0);\n }\n .tool-call summary {\n padding: 6px 10px; color: var(--subtext1); cursor: pointer;\n user-select: none; list-style: none;\n display: flex; align-items: center; gap: 8px;\n }\n .tool-call summary::-webkit-details-marker { display: none; }\n .tool-call summary::before { content: \"\u25B6\"; flex-shrink: 0; font-size: 9px; color: var(--subtext0); }\n .tool-call[open] > summary::before { content: \"\u25BC\"; }\n .tool-call.error > summary { color: var(--red); }\n /* The summary is a single line: the label ellipsizes (full text in the title\n tooltip / on expand), badges and timing stay pinned to the right. */\n .tool-label {\n flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n font-family: ui-monospace, monospace;\n }\n .tool-badge { flex-shrink: 0; color: var(--subtext0); font-size: 11px; }\n .tool-elapsed { flex-shrink: 0; color: var(--subtext0); font-size: 11px; }\n .tool-call pre {\n padding: 8px 12px; overflow-y: auto;\n color: var(--subtext1); font-size: 11px; max-height: 280px;\n border-top: 1px solid var(--surface0);\n white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word;\n }\n .tool-spin { color: var(--mauve); flex-shrink: 0; font-family: ui-monospace, monospace; font-size: 13px; }\n /* Line diff for edit/write tools (tints derived from --green/--red). */\n .tool-diff { border-top: 1px solid var(--surface0); overflow-x: auto; }\n .diff { font-family: ui-monospace, monospace; font-size: 11px; padding: 4px 0; }\n .diff-line { white-space: pre; padding: 0 10px; }\n .diff-sign { display: inline-block; width: 1ch; margin-right: 8px; color: var(--subtext0); }\n .diff-add { background: color-mix(in srgb, var(--green) 14%, transparent); color: var(--green); }\n .diff-del { background: color-mix(in srgb, var(--red) 14%, transparent); color: var(--red); }\n .diff-ctx { color: var(--subtext1); }\n .code-block {\n background: var(--crust); border: 1px solid var(--surface0);\n border-radius: 6px; overflow: hidden; margin: 4px 0;\n align-self: stretch; max-width: 100%; font-size: 12px;\n }\n /* Header row above a code block: language label on the left, copy button on\n the right, on its own surface bar. */\n .code-head {\n display: flex; align-items: center; justify-content: space-between;\n background: var(--surface0);\n }\n .code-lang {\n color: var(--subtext0); font-size: 10px; padding: 3px 10px; letter-spacing: 0.05em;\n }\n .code-head .copy-btn { padding: 3px 10px; }\n .code-block code {\n display: block; padding: 10px 12px; overflow-x: auto;\n color: var(--text); white-space: pre; line-height: 1.55;\n }\n .hl-kw { color: var(--mauve); }\n .hl-str { color: var(--green); }\n .hl-cmt { color: var(--subtext0); font-style: italic; }\n .hl-num { color: var(--blue); }\n .hl-fn { color: var(--yellow); }\n /* Hand-rolled markdown, applied only to assistant bubbles + the recommendation\n panel (both get the .md class from setContent). Block elements lay themselves\n out, so switch off the container's pre-wrap for these. */\n .md { white-space: normal; }\n .md > :first-child { margin-top: 0; }\n .md > :last-child { margin-bottom: 0; }\n .md .md-h { color: var(--mauve); font-weight: 700; line-height: 1.3; margin: 10px 0 4px; }\n .md .md-h1 { font-size: 1.35em; }\n .md .md-h2 { font-size: 1.2em; }\n .md .md-h3 { font-size: 1.08em; }\n .md .md-h4, .md .md-h5, .md .md-h6 { font-size: 1em; }\n .md .md-p { margin: 6px 0; }\n .md .md-list { margin: 6px 0; padding-left: 22px; }\n .md .md-list li { margin: 2px 0; }\n .md .md-task { list-style: none; margin-left: -22px; }\n .md .md-check { color: var(--green); }\n .md .md-quote { border-left: 3px solid var(--surface2); margin: 6px 0; padding: 2px 0 2px 10px; color: var(--subtext1); }\n .md .md-hr { border: none; border-top: 1px solid var(--surface1); margin: 10px 0; }\n .md a { color: var(--blue); text-decoration: underline; }\n .md strong { color: var(--text); font-weight: 700; }\n .md em { font-style: italic; }\n .md del { color: var(--subtext0); }\n .md .md-code {\n background: var(--crust); border: 1px solid var(--surface0);\n border-radius: 4px; padding: 1px 4px; font-size: 0.92em;\n }\n .md .md-table { border-collapse: collapse; margin: 8px 0; font-size: 0.95em; display: block; overflow-x: auto; }\n .md .md-table th, .md .md-table td { border: 1px solid var(--surface1); padding: 4px 8px; text-align: left; }\n .md .md-table th { background: var(--surface0); color: var(--subtext1); }\n #input-bar {\n background: var(--mantle); padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0px));\n display: flex; gap: 8px; flex-shrink: 0;\n border-top: 1px solid var(--surface0);\n position: relative;\n }\n /* Held mid-run input: what you typed is waiting for the next task turn.\n In normal flow, NOT absolutely positioned \u2014 as an overlay it would cover\n the task widget's progress row above the composer. */\n #held-bar {\n display: flex; align-items: center; gap: 8px; flex-shrink: 0;\n background: var(--mantle); border-top: 1px solid var(--surface1);\n padding: 6px 16px; font-size: 12px;\n }\n #held-label { color: var(--yellow); white-space: nowrap; }\n #held-text {\n color: var(--subtext0); overflow: hidden; text-overflow: ellipsis;\n white-space: nowrap; flex: 1;\n }\n #held-clear {\n background: none; border: none; color: var(--subtext0);\n cursor: pointer; font-size: 13px; padding: 0 2px;\n }\n #held-clear:hover { color: var(--red); }\n #cmd-suggestions {\n display: none; position: absolute; bottom: 100%; left: 16px; right: 16px;\n background: var(--mantle); border: 1px solid var(--surface1);\n border-bottom: none; border-radius: 8px 8px 0 0;\n overflow: hidden; z-index: 10;\n }\n .cmd-item {\n display: flex; align-items: baseline; gap: 10px;\n padding: 7px 12px; cursor: pointer; font-size: 12px;\n border-bottom: 1px solid var(--surface0);\n }\n .cmd-item:last-child { border-bottom: none; }\n .cmd-item:hover, .cmd-item.active { background: var(--surface0); }\n .cmd-item .cmd-name { color: var(--blue); font-weight: bold; flex-shrink: 0; }\n .cmd-item .cmd-desc { color: var(--subtext0); }\n #input {\n flex: 1; background: var(--surface0); color: var(--text);\n border: none; border-radius: 6px; padding: 8px 12px;\n font-family: inherit; font-size: 13px; resize: none;\n outline: none; line-height: 1.5; min-height: 36px; max-height: 120px;\n }\n #input::placeholder { color: var(--subtext0); }\n #input:focus { box-shadow: 0 0 0 1px var(--mauve); }\n #send {\n background: var(--blue); color: var(--crust); border: none;\n border-radius: 6px; padding: 8px 16px; font-weight: bold;\n cursor: pointer; font-size: 13px; font-family: inherit;\n white-space: nowrap; align-self: flex-end;\n }\n #send:disabled, #input:disabled { opacity: 0.45; cursor: not-allowed; }\n /* While the agent runs, Send becomes a red Stop; the armed (tap-to-confirm)\n state brightens it and adds a halo, mirroring the prompt card's cancel. */\n #send.stop { background: var(--red); color: var(--crust); }\n #send.stop.armed {\n filter: brightness(1.12);\n box-shadow: 0 0 0 2px color-mix(in srgb, var(--red) 45%, transparent);\n }\n #reconnect-overlay {\n display: none; position: fixed; inset: 0;\n background: rgba(30,30,46,0.88); color: var(--subtext1);\n justify-content: center; align-items: center;\n font-size: 13px; z-index: 100; letter-spacing: 0.03em;\n }\n #reconnect-overlay.visible { display: flex; }\n /* Trailing stream indicator: the same braille spinner as the thinking bubble\n (one ticker paints every .spin), inline at the end of the streaming text. */\n .cursor {\n color: var(--mauve); margin-left: 2px;\n font-family: ui-monospace, monospace;\n }\n #status-panel { padding: 6px 12px; border-bottom: 1px solid var(--surface1);\n color: var(--subtext1); white-space: pre-wrap; font-size: 13px; display: none; }\n /* Structured task widget (progress bar + phase badge + elapsed). Replaces the\n plain text lines when the server sends a structured data payload. */\n #status-panel.structured { white-space: normal; display: block; }\n /* Title gets its own line and wraps, clamped to 2 \u2014 never squeezed to a stub\n by the meta row beside it on a narrow screen. */\n .widget-title { display: -webkit-box; -webkit-box-orient: vertical;\n -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; color: var(--text);\n font-size: 13px; line-height: 1.3; word-break: break-word; }\n .widget-meta { display: flex; align-items: center; gap: 8px; margin-top: 5px; }\n .widget-phase { flex-shrink: 0; background: var(--surface0); color: var(--mauve);\n border-radius: 4px; padding: 1px 7px; font-size: 10px; letter-spacing: 0.03em;\n text-transform: uppercase; }\n .widget-step { flex-shrink: 0; color: var(--subtext0); font-size: 11px;\n font-variant-numeric: tabular-nums; }\n .widget-elapsed { flex-shrink: 0; margin-left: auto; color: var(--subtext0);\n font-size: 11px; font-variant-numeric: tabular-nums; }\n .widget-bar { height: 4px; background: var(--surface0); border-radius: 2px;\n overflow: hidden; margin-top: 6px; }\n /* Fill carries a soft highlight band that sweeps left\u2192right through the mauve,\n so the bar reads as \"working\" even while the step count holds still. The\n gradient tile is 2x the fill width and shifts one full period per cycle,\n so the loop is seamless. Base color stays mauve for reduced-motion. */\n .widget-bar-fill { height: 100%; border-radius: 2px;\n background: linear-gradient(90deg,\n var(--mauve) 0%, var(--mauve) 38%, #ecdcfd 50%,\n var(--mauve) 62%, var(--mauve) 100%);\n background-size: 200% 100%;\n box-shadow: 0 0 6px rgba(203, 166, 247, 0.45);\n animation: bar-flow 2.4s linear infinite;\n transition: width 0.3s ease; }\n @keyframes bar-flow {\n from { background-position: 200% 0; }\n to { background-position: 0% 0; }\n }\n @media (prefers-reduced-motion: reduce) {\n .widget-bar-fill { animation: none; background: var(--mauve); }\n }\n /* Current action \u2014 the terminal's \u21B3 worker trailer, one dim ellipsized line. */\n .widget-action { margin-top: 6px; color: var(--subtext0); font-size: 11px;\n overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n font-variant-numeric: tabular-nums; }\n /* Dim per-turn timestamp shown under a committed turn's bubble. */\n .turn-time { font-size: 10px; color: var(--subtext0); opacity: 0.55; padding: 0 4px;\n margin-top: -2px; }\n .turn-time.user { align-self: flex-end; }\n .turn-time.assistant, .turn-time.system { align-self: flex-start; }\n .turn-time.system { align-self: center; }\n #prompt-card { position: fixed; left: 0; right: 0; bottom: 0; background: var(--mantle);\n border-top: 2px solid var(--mauve); padding: 16px 14px calc(16px + env(safe-area-inset-bottom, 0px));\n display: none; z-index: 50; max-height: 80dvh; overflow-y: auto; }\n #prompt-card .q-label { color: var(--mauve); font-size: 11px; font-weight: 700;\n text-transform: uppercase; letter-spacing: .6px; margin-bottom: 6px; }\n #prompt-card .q { color: var(--text); margin-bottom: 12px; white-space: pre-wrap;\n font-size: 15px; line-height: 1.5; }\n #prompt-card .rec-panel { background: var(--surface0); border-left: 3px solid var(--green);\n border-radius: 6px; padding: 10px 12px; margin-bottom: 12px; }\n #prompt-card .rec-label { color: var(--green); font-size: 11px; font-weight: 700;\n text-transform: uppercase; letter-spacing: .5px; margin-bottom: 4px; }\n #prompt-card .rec-text { color: var(--text); font-size: 15px; line-height: 1.5;\n white-space: pre-wrap; overflow-wrap: anywhere; }\n #prompt-card textarea { width: 100%; background: var(--surface0); color: var(--text);\n border: 1px solid var(--surface2); border-radius: 6px; padding: 10px; font-size: 15px;\n font-family: inherit; line-height: 1.5; resize: vertical; margin-bottom: 4px; }\n #prompt-card .row { display: flex; gap: 8px; margin-top: 12px; align-items: stretch;\n flex-wrap: wrap; }\n /* Recommendation answers can be long sentences, so stack them as a readable list. */\n #prompt-card .row.stacked { flex-direction: column; align-items: stretch; }\n #prompt-card .row.stacked button { flex: none; text-align: left; }\n #prompt-card .row.stacked button.cancel { align-self: center; text-align: center; }\n #prompt-card button { padding: 11px 16px; border-radius: 8px; border: none; cursor: pointer;\n font-family: inherit; font-size: 14px; font-weight: 600; transition: filter .15s ease; }\n #prompt-card button:hover { filter: brightness(1.08); }\n #prompt-card button.primary { background: var(--green); color: var(--crust);\n font-weight: 700; flex: 1; min-width: 160px; }\n #prompt-card button.secondary { background: var(--surface1); color: var(--text);\n flex: 1; min-width: 160px; }\n #prompt-card button.cancel { margin-left: auto; align-self: center; background: transparent;\n color: var(--subtext0); font-size: 12px; font-weight: 500; padding: 8px 10px; }\n #prompt-card button.cancel:hover { color: var(--red); filter: none; }\n #prompt-card button.cancel.armed { background: var(--red); color: var(--crust); font-weight: 700; }\n .toast { position: fixed; top: calc(env(safe-area-inset-top, 0px) + 12px);\n right: calc(env(safe-area-inset-right, 0px) + 12px); max-width: calc(100vw - 24px);\n padding: 8px 12px; border-radius: 6px; overflow-wrap: anywhere; word-break: break-word;\n background: var(--surface1); color: var(--text); z-index: 60; }\n .toast.warning { background: var(--peach); color: var(--crust); }\n .toast.error { background: var(--red); color: var(--crust); }\n #viewer { position: fixed; inset: 24px; background: var(--mantle); border: 1px solid var(--surface2);\n border-radius: 8px; padding: 16px; overflow: auto; white-space: pre-wrap;\n overflow-wrap: anywhere; word-break: break-word; display: none; z-index: 70; }\n #viewer .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: var(--subtext0); }\n /* Desktop: center the transcript/status/input in a readable column instead of\n hugging the left edge. The scrollbar stays at the true window edge; only the\n content is inset. Mobile (below 960px) is unchanged. */\n @media (min-width: 960px) {\n #chat-log { padding-left: calc((100% - 920px) / 2); padding-right: calc((100% - 920px) / 2); }\n #status-panel { padding-left: calc((100% - 920px) / 2 + 12px); padding-right: calc((100% - 920px) / 2 + 12px); }\n #input-bar { padding-left: calc((100% - 920px) / 2); padding-right: calc((100% - 920px) / 2); }\n #cmd-suggestions { left: calc((100% - 920px) / 2 + 16px); right: calc((100% - 920px) / 2 + 16px); }\n #held-bar { padding-left: calc((100% - 920px) / 2 + 16px); padding-right: calc((100% - 920px) / 2 + 16px); }\n }";
1
+ export declare const STYLES = " :root {\n --base: #1e1e2e; --mantle: #181825; --crust: #11111b;\n --surface0: #313244; --surface1: #45475a; --surface2: #585b70;\n --text: #cdd6f4; --subtext1: #a6adc8; --subtext0: #7f849c;\n --mauve: #cba6f7; --blue: #89b4fa; --green: #a6e3a1; --red: #f38ba8;\n --yellow: #f9e2af; --peach: #fab387; --teal: #94e2d5;\n }\n *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n body {\n background: var(--base); color: var(--text);\n font-family: ui-monospace, monospace;\n /* --app-h is set by setAppHeight (visualViewport.height, falling back to\n window.innerHeight) so the column height is a stable pixel value across\n an orientation change. 100dvh is the fallback for first paint / no-JS. */\n height: var(--app-h, 100dvh);\n display: flex; flex-direction: column; overflow: hidden;\n padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px)\n 0px env(safe-area-inset-left, 0px);\n }\n #context-bar { height: 4px; background: var(--surface0); flex-shrink: 0; }\n #context-bar-fill { height: 100%; background: var(--mauve); width: 0%; transition: width 0.4s ease; }\n #header {\n background: var(--mantle); padding: 8px 16px;\n display: flex; justify-content: space-between; align-items: center;\n font-size: 13px; flex-shrink: 0; border-bottom: 1px solid var(--surface0);\n }\n #header .title { font-weight: bold; color: var(--mauve); letter-spacing: 0.05em;\n position: relative; animation: glitch 5s steps(1) infinite; }\n @keyframes glitch {\n 0%, 88%, 100% { text-shadow: none; transform: translate(0, 0); }\n 90% { text-shadow: -1px 0 var(--red), 1px 0 var(--teal); transform: translate(1px, -1px); }\n 92% { text-shadow: 1px 0 var(--red), -1px 0 var(--blue); transform: translate(-1px, 1px); }\n 94% { text-shadow: -1px 0 var(--blue), 1px 0 var(--red); transform: translate(1px, 0); }\n 96% { text-shadow: 1px 0 var(--teal), -1px 0 var(--red); transform: translate(-1px, 0); }\n }\n @media (prefers-reduced-motion: reduce) { #header .title { animation: none; } }\n #header .hgroup { display: flex; align-items: center; gap: 10px; }\n #bell {\n background: none; border: none; color: var(--subtext1); cursor: pointer;\n font-size: 15px; line-height: 1; padding: 2px; font-family: inherit;\n }\n #bell:hover { color: var(--text); }\n #bell.on { color: var(--mauve); }\n /* Header status chip: connection dot + model name + context usage. */\n #status-chip { display: flex; align-items: center; gap: 7px; font-size: 11px; color: var(--subtext0); }\n #status-dot {\n width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;\n background: var(--surface2); transition: background 0.2s ease;\n }\n #status-dot.idle { background: var(--green); }\n #status-dot.running { background: var(--mauve); animation: dot-pulse 1.2s ease-in-out infinite; }\n #status-dot.disconnected { background: var(--red); }\n @keyframes dot-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }\n @media (prefers-reduced-motion: reduce) { #status-dot.running { animation: none; } }\n #status-model { color: var(--subtext1); }\n #status-model:empty { display: none; }\n #status-ctx { color: var(--subtext0); font-variant-numeric: tabular-nums; }\n #status-ctx:empty { display: none; }\n /* Notification bell dropdown: a push toggle row + the recent-toast history. */\n #notif-panel {\n display: none; position: fixed; z-index: 80;\n top: calc(env(safe-area-inset-top, 0px) + 42px);\n right: calc(env(safe-area-inset-right, 0px) + 12px);\n width: min(320px, calc(100vw - 24px));\n background: var(--mantle); border: 1px solid var(--surface1); border-radius: 8px;\n box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); overflow: hidden;\n }\n #notif-panel.open { display: block; }\n #notif-toggle-row {\n display: flex; align-items: center; justify-content: space-between;\n padding: 8px 12px; border-bottom: 1px solid var(--surface0);\n }\n #notif-title { font-size: 12px; color: var(--subtext1); font-weight: 700; }\n #notif-toggle {\n background: var(--surface1); color: var(--text); border: none; border-radius: 6px;\n padding: 4px 10px; font-family: inherit; font-size: 11px; cursor: pointer;\n }\n #notif-toggle:hover { filter: brightness(1.1); }\n #notif-toggle.on { background: var(--mauve); color: var(--crust); font-weight: 700; }\n #notif-list { max-height: 40dvh; overflow-y: auto; }\n #notif-empty { padding: 14px 12px; color: var(--subtext0); font-size: 11px; text-align: center; }\n .notif-item {\n display: flex; align-items: baseline; gap: 8px; padding: 7px 12px;\n border-bottom: 1px solid var(--surface0); font-size: 12px;\n }\n .notif-item:last-child { border-bottom: none; }\n .notif-item .notif-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; background: var(--blue); align-self: center; }\n .notif-item.warning .notif-dot { background: var(--peach); }\n .notif-item.error .notif-dot { background: var(--red); }\n .notif-item .notif-msg { flex: 1; min-width: 0; color: var(--text); overflow-wrap: anywhere; word-break: break-word; }\n .notif-item .notif-time { flex-shrink: 0; color: var(--subtext0); font-size: 10px; font-variant-numeric: tabular-nums; }\n #chat-wrap { position: relative; flex: 1; min-height: 0; display: flex; }\n #chat-log {\n flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; padding: 16px;\n display: flex; flex-direction: column; gap: 8px;\n }\n /* Floating jump-to-latest button \u2014 only shown when scrolled away from the\n bottom (toggled via .visible from the scroll handler). */\n #scroll-bottom {\n display: none; position: absolute; bottom: 16px; right: 16px; z-index: 40;\n width: 36px; height: 36px; border-radius: 50%; cursor: pointer;\n background: var(--surface1); color: var(--text); border: 1px solid var(--surface2);\n font-family: inherit; font-size: 18px; line-height: 1; padding: 0;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);\n }\n #scroll-bottom:hover { background: var(--surface2); color: var(--mauve); }\n #scroll-bottom.visible { display: block; }\n #chat-log::-webkit-scrollbar { width: 6px; }\n #chat-log::-webkit-scrollbar-track { background: transparent; }\n #chat-log::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 3px; }\n .bubble {\n max-width: 82%; padding: 8px 12px; border-radius: 8px;\n line-height: 1.6; white-space: pre-wrap; word-break: break-word; font-size: 13px;\n }\n .bubble.user { background: var(--surface1); color: var(--text); align-self: flex-end; }\n .bubble.assistant { background: var(--surface0); color: var(--text); align-self: flex-start; position: relative; }\n .bubble.error {\n background: var(--crust); color: var(--red); align-self: stretch;\n max-width: 100%; border: 1px solid var(--red); font-size: 12px;\n }\n /* Persistent inline system note (e.g. context compaction) \u2014 a muted centered\n divider, distinct from chat bubbles. */\n .sysnote {\n /* publishNote sends multi-line text (the /task-config table); textContent\n keeps the newlines only if the box does not collapse them. */\n white-space: pre-wrap;\n align-self: center; color: var(--subtext0); font-size: 11px;\n font-family: ui-monospace, monospace; letter-spacing: 0.5px;\n padding: 2px 10px; opacity: 0.85;\n }\n .bubble.thinking {\n display: flex; gap: 5px; align-items: center; padding: 10px 14px;\n }\n .bubble.thinking .spinner {\n color: var(--mauve); font-size: 15px; line-height: 1;\n font-family: ui-monospace, monospace;\n }\n /* Collapsed reasoning block (\"\u273B Thinking\u2026 (n lines)\"), muted + italic. */\n .thinking-block { align-self: flex-start; max-width: 90%; font-size: 12px; }\n .thinking-block > summary {\n color: var(--subtext0); font-style: italic; cursor: pointer; list-style: none;\n user-select: none; display: flex; align-items: center; gap: 8px; padding: 2px 0;\n }\n .thinking-block > summary::-webkit-details-marker { display: none; }\n .thinking-block .thinking-spin {\n color: var(--mauve); font-style: normal; font-family: ui-monospace, monospace;\n }\n .thinking-block .thinking-body {\n color: var(--subtext0); font-style: italic; white-space: pre-wrap;\n word-break: break-word; line-height: 1.5; margin: 4px 0 0 4px;\n padding: 4px 0 2px 12px; border-left: 2px solid var(--surface1);\n }\n /* Copy buttons: on code-block headers and (floating) on finished assistant\n bubbles. Wired by one delegated click handler in the client script. */\n .copy-btn {\n background: transparent; border: none; color: var(--subtext0); cursor: pointer;\n font-family: inherit; font-size: 11px; padding: 2px 6px; border-radius: 4px;\n line-height: 1.4;\n }\n .copy-btn:hover { color: var(--text); background: var(--surface1); }\n .copy-btn.copied { color: var(--green); }\n .bubble-copy {\n position: absolute; top: 4px; right: 4px; opacity: 0;\n background: var(--surface1); transition: opacity 0.12s ease;\n }\n .bubble.assistant:hover .bubble-copy { opacity: 1; }\n /* Touch devices have no hover \u2014 keep the button faintly visible. */\n @media (hover: none) { .bubble-copy { opacity: 0.55; } }\n .tool-call {\n background: var(--crust); border-radius: 6px; align-self: flex-start;\n max-width: 90%; font-size: 12px; border: 1px solid var(--surface0);\n }\n .tool-call summary {\n padding: 6px 10px; color: var(--subtext1); cursor: pointer;\n user-select: none; list-style: none;\n display: flex; align-items: center; gap: 8px;\n }\n .tool-call summary::-webkit-details-marker { display: none; }\n .tool-call summary::before { content: \"\u25B6\"; flex-shrink: 0; font-size: 9px; color: var(--subtext0); }\n .tool-call[open] > summary::before { content: \"\u25BC\"; }\n .tool-call.error > summary { color: var(--red); }\n /* The summary is a single line: the label ellipsizes (full text in the title\n tooltip / on expand), badges and timing stay pinned to the right. */\n .tool-label {\n flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n font-family: ui-monospace, monospace;\n }\n .tool-badge { flex-shrink: 0; color: var(--subtext0); font-size: 11px; }\n .tool-elapsed { flex-shrink: 0; color: var(--subtext0); font-size: 11px; }\n .tool-call pre {\n padding: 8px 12px; overflow-y: auto;\n color: var(--subtext1); font-size: 11px; max-height: 280px;\n border-top: 1px solid var(--surface0);\n white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word;\n }\n .tool-spin { color: var(--mauve); flex-shrink: 0; font-family: ui-monospace, monospace; font-size: 13px; }\n /* Line diff for edit/write tools (tints derived from --green/--red). */\n .tool-diff { border-top: 1px solid var(--surface0); overflow-x: auto; }\n .diff { font-family: ui-monospace, monospace; font-size: 11px; padding: 4px 0; }\n .diff-line { white-space: pre; padding: 0 10px; }\n .diff-sign { display: inline-block; width: 1ch; margin-right: 8px; color: var(--subtext0); }\n .diff-add { background: color-mix(in srgb, var(--green) 14%, transparent); color: var(--green); }\n .diff-del { background: color-mix(in srgb, var(--red) 14%, transparent); color: var(--red); }\n .diff-ctx { color: var(--subtext1); }\n .code-block {\n background: var(--crust); border: 1px solid var(--surface0);\n border-radius: 6px; overflow: hidden; margin: 4px 0;\n align-self: stretch; max-width: 100%; font-size: 12px;\n }\n /* Header row above a code block: language label on the left, copy button on\n the right, on its own surface bar. */\n .code-head {\n display: flex; align-items: center; justify-content: space-between;\n background: var(--surface0);\n }\n .code-lang {\n color: var(--subtext0); font-size: 10px; padding: 3px 10px; letter-spacing: 0.05em;\n }\n .code-head .copy-btn { padding: 3px 10px; }\n .code-block code {\n display: block; padding: 10px 12px; overflow-x: auto;\n color: var(--text); white-space: pre; line-height: 1.55;\n }\n .hl-kw { color: var(--mauve); }\n .hl-str { color: var(--green); }\n .hl-cmt { color: var(--subtext0); font-style: italic; }\n .hl-num { color: var(--blue); }\n .hl-fn { color: var(--yellow); }\n /* Hand-rolled markdown, applied only to assistant bubbles + the recommendation\n panel (both get the .md class from setContent). Block elements lay themselves\n out, so switch off the container's pre-wrap for these. */\n .md { white-space: normal; }\n .md > :first-child { margin-top: 0; }\n .md > :last-child { margin-bottom: 0; }\n .md .md-h { color: var(--mauve); font-weight: 700; line-height: 1.3; margin: 10px 0 4px; }\n .md .md-h1 { font-size: 1.35em; }\n .md .md-h2 { font-size: 1.2em; }\n .md .md-h3 { font-size: 1.08em; }\n .md .md-h4, .md .md-h5, .md .md-h6 { font-size: 1em; }\n .md .md-p { margin: 6px 0; }\n .md .md-list { margin: 6px 0; padding-left: 22px; }\n .md .md-list li { margin: 2px 0; }\n .md .md-task { list-style: none; margin-left: -22px; }\n .md .md-check { color: var(--green); }\n .md .md-quote { border-left: 3px solid var(--surface2); margin: 6px 0; padding: 2px 0 2px 10px; color: var(--subtext1); }\n .md .md-hr { border: none; border-top: 1px solid var(--surface1); margin: 10px 0; }\n .md a { color: var(--blue); text-decoration: underline; }\n .md strong { color: var(--text); font-weight: 700; }\n .md em { font-style: italic; }\n .md del { color: var(--subtext0); }\n .md .md-code {\n background: var(--crust); border: 1px solid var(--surface0);\n border-radius: 4px; padding: 1px 4px; font-size: 0.92em;\n }\n .md .md-table { border-collapse: collapse; margin: 8px 0; font-size: 0.95em; display: block; overflow-x: auto; }\n .md .md-table th, .md .md-table td { border: 1px solid var(--surface1); padding: 4px 8px; text-align: left; }\n .md .md-table th { background: var(--surface0); color: var(--subtext1); }\n #input-bar {\n background: var(--mantle); padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0px));\n display: flex; gap: 8px; flex-shrink: 0;\n border-top: 1px solid var(--surface0);\n position: relative;\n }\n /* Held mid-run input: what you typed is waiting for the next task turn.\n In normal flow, NOT absolutely positioned \u2014 as an overlay it would cover\n the task widget's progress row above the composer. */\n #held-bar {\n display: flex; align-items: center; gap: 8px; flex-shrink: 0;\n background: var(--mantle); border-top: 1px solid var(--surface1);\n padding: 6px 16px; font-size: 12px;\n }\n #held-label { color: var(--yellow); white-space: nowrap; }\n #held-text {\n color: var(--subtext0); overflow: hidden; text-overflow: ellipsis;\n white-space: nowrap; flex: 1;\n }\n #held-clear {\n background: none; border: none; color: var(--subtext0);\n cursor: pointer; font-size: 13px; padding: 0 2px;\n }\n #held-clear:hover { color: var(--red); }\n #cmd-suggestions {\n display: none; position: absolute; bottom: 100%; left: 16px; right: 16px;\n background: var(--mantle); border: 1px solid var(--surface1);\n border-bottom: none; border-radius: 8px 8px 0 0;\n overflow: hidden; z-index: 10;\n }\n .cmd-item {\n display: flex; align-items: baseline; gap: 10px;\n padding: 7px 12px; cursor: pointer; font-size: 12px;\n border-bottom: 1px solid var(--surface0);\n }\n .cmd-item:last-child { border-bottom: none; }\n .cmd-item:hover, .cmd-item.active { background: var(--surface0); }\n .cmd-item .cmd-name { color: var(--blue); font-weight: bold; flex-shrink: 0; }\n .cmd-item .cmd-desc { color: var(--subtext0); }\n #input {\n flex: 1; background: var(--surface0); color: var(--text);\n border: none; border-radius: 6px; padding: 8px 12px;\n font-family: inherit; font-size: 13px; resize: none;\n outline: none; line-height: 1.5; min-height: 36px; max-height: 120px;\n }\n #input::placeholder { color: var(--subtext0); }\n #input:focus { box-shadow: 0 0 0 1px var(--mauve); }\n #send {\n background: var(--blue); color: var(--crust); border: none;\n border-radius: 6px; padding: 8px 16px; font-weight: bold;\n cursor: pointer; font-size: 13px; font-family: inherit;\n white-space: nowrap; align-self: flex-end;\n }\n #send:disabled, #input:disabled { opacity: 0.45; cursor: not-allowed; }\n /* While the agent runs, Send becomes a red Stop; the armed (tap-to-confirm)\n state brightens it and adds a halo, mirroring the prompt card's cancel. */\n #send.stop { background: var(--red); color: var(--crust); }\n #send.stop.armed {\n filter: brightness(1.12);\n box-shadow: 0 0 0 2px color-mix(in srgb, var(--red) 45%, transparent);\n }\n #reconnect-overlay {\n display: none; position: fixed; inset: 0;\n background: rgba(30,30,46,0.88); color: var(--subtext1);\n justify-content: center; align-items: center;\n font-size: 13px; z-index: 100; letter-spacing: 0.03em;\n }\n #reconnect-overlay.visible { display: flex; }\n /* Trailing stream indicator: the same braille spinner as the thinking bubble\n (one ticker paints every .spin), inline at the end of the streaming text. */\n .cursor {\n color: var(--mauve); margin-left: 2px;\n font-family: ui-monospace, monospace;\n }\n #status-panel { padding: 6px 12px; border-bottom: 1px solid var(--surface1);\n color: var(--subtext1); white-space: pre-wrap; font-size: 13px; display: none; }\n /* Structured task widget (progress bar + phase badge + elapsed). Replaces the\n plain text lines when the server sends a structured data payload. */\n #status-panel.structured { white-space: normal; display: block; }\n /* Title gets its own line and wraps, clamped to 2 \u2014 never squeezed to a stub\n by the meta row beside it on a narrow screen. */\n .widget-title { display: -webkit-box; -webkit-box-orient: vertical;\n -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; color: var(--text);\n font-size: 13px; line-height: 1.3; word-break: break-word; }\n .widget-meta { display: flex; align-items: center; gap: 8px; margin-top: 5px; }\n .widget-phase { flex-shrink: 0; background: var(--surface0); color: var(--mauve);\n border-radius: 4px; padding: 1px 7px; font-size: 10px; letter-spacing: 0.03em;\n text-transform: uppercase; }\n .widget-step { flex-shrink: 0; color: var(--subtext0); font-size: 11px;\n font-variant-numeric: tabular-nums; }\n .widget-elapsed { flex-shrink: 0; margin-left: auto; color: var(--subtext0);\n font-size: 11px; font-variant-numeric: tabular-nums; }\n .widget-bar { height: 4px; background: var(--surface0); border-radius: 2px;\n overflow: hidden; margin-top: 6px; }\n /* Fill carries a soft highlight band that sweeps left\u2192right through the mauve,\n so the bar reads as \"working\" even while the step count holds still. The\n gradient tile is 2x the fill width and shifts one full period per cycle,\n so the loop is seamless. Base color stays mauve for reduced-motion. */\n .widget-bar-fill { height: 100%; border-radius: 2px;\n background: linear-gradient(90deg,\n var(--mauve) 0%, var(--mauve) 38%, #ecdcfd 50%,\n var(--mauve) 62%, var(--mauve) 100%);\n background-size: 200% 100%;\n box-shadow: 0 0 6px rgba(203, 166, 247, 0.45);\n animation: bar-flow 2.4s linear infinite;\n transition: width 0.3s ease; }\n @keyframes bar-flow {\n from { background-position: 200% 0; }\n to { background-position: 0% 0; }\n }\n @media (prefers-reduced-motion: reduce) {\n .widget-bar-fill { animation: none; background: var(--mauve); }\n }\n /* Current action \u2014 the terminal's \u21B3 worker trailer, one dim ellipsized line. */\n .widget-action { margin-top: 6px; color: var(--subtext0); font-size: 11px;\n overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n font-variant-numeric: tabular-nums; }\n /* Dim per-turn timestamp shown under a committed turn's bubble. */\n .turn-time { font-size: 10px; color: var(--subtext0); opacity: 0.55; padding: 0 4px;\n margin-top: -2px; }\n .turn-time.user { align-self: flex-end; }\n .turn-time.assistant, .turn-time.system { align-self: flex-start; }\n .turn-time.system { align-self: center; }\n #prompt-card { position: fixed; left: 0; right: 0; bottom: 0; background: var(--mantle);\n border-top: 2px solid var(--mauve); padding: 16px 14px calc(16px + env(safe-area-inset-bottom, 0px));\n display: none; z-index: 50; max-height: 80dvh; overflow-y: auto; }\n #prompt-card .q-label { color: var(--mauve); font-size: 11px; font-weight: 700;\n text-transform: uppercase; letter-spacing: .6px; margin-bottom: 6px; }\n #prompt-card .q { color: var(--text); margin-bottom: 12px; white-space: pre-wrap;\n font-size: 15px; line-height: 1.5; }\n #prompt-card .rec-panel { background: var(--surface0); border-left: 3px solid var(--green);\n border-radius: 6px; padding: 10px 12px; margin-bottom: 12px; }\n #prompt-card .rec-label { color: var(--green); font-size: 11px; font-weight: 700;\n text-transform: uppercase; letter-spacing: .5px; margin-bottom: 4px; }\n #prompt-card .rec-text { color: var(--text); font-size: 15px; line-height: 1.5;\n white-space: pre-wrap; overflow-wrap: anywhere; }\n #prompt-card textarea { width: 100%; background: var(--surface0); color: var(--text);\n border: 1px solid var(--surface2); border-radius: 6px; padding: 10px; font-size: 15px;\n font-family: inherit; line-height: 1.5; resize: vertical; margin-bottom: 4px; }\n #prompt-card .row { display: flex; gap: 8px; margin-top: 12px; align-items: stretch;\n flex-wrap: wrap; }\n /* Recommendation answers can be long sentences, so stack them as a readable list. */\n #prompt-card .row.stacked { flex-direction: column; align-items: stretch; }\n #prompt-card .row.stacked button { flex: none; text-align: left; }\n #prompt-card .row.stacked button.cancel { align-self: center; text-align: center; }\n #prompt-card button { padding: 11px 16px; border-radius: 8px; border: none; cursor: pointer;\n font-family: inherit; font-size: 14px; font-weight: 600; transition: filter .15s ease; }\n #prompt-card button:hover { filter: brightness(1.08); }\n #prompt-card button.primary { background: var(--green); color: var(--crust);\n font-weight: 700; flex: 1; min-width: 160px; }\n #prompt-card button.secondary { background: var(--surface1); color: var(--text);\n flex: 1; min-width: 160px; }\n #prompt-card button.cancel { margin-left: auto; align-self: center; background: transparent;\n color: var(--subtext0); font-size: 12px; font-weight: 500; padding: 8px 10px; }\n #prompt-card button.cancel:hover { color: var(--red); filter: none; }\n #prompt-card button.cancel.armed { background: var(--red); color: var(--crust); font-weight: 700; }\n .toast { position: fixed; top: calc(env(safe-area-inset-top, 0px) + 12px);\n right: calc(env(safe-area-inset-right, 0px) + 12px); max-width: calc(100vw - 24px);\n padding: 8px 12px; border-radius: 6px; overflow-wrap: anywhere; word-break: break-word;\n background: var(--surface1); color: var(--text); z-index: 60; }\n .toast.warning { background: var(--peach); color: var(--crust); }\n .toast.error { background: var(--red); color: var(--crust); }\n /* Desktop: center the transcript/status/input in a readable column instead of\n hugging the left edge. The scrollbar stays at the true window edge; only the\n content is inset. Mobile (below 960px) is unchanged. */\n @media (min-width: 960px) {\n #chat-log { padding-left: calc((100% - 920px) / 2); padding-right: calc((100% - 920px) / 2); }\n #status-panel { padding-left: calc((100% - 920px) / 2 + 12px); padding-right: calc((100% - 920px) / 2 + 12px); }\n #input-bar { padding-left: calc((100% - 920px) / 2); padding-right: calc((100% - 920px) / 2); }\n #cmd-suggestions { left: calc((100% - 920px) / 2 + 16px); right: calc((100% - 920px) / 2 + 16px); }\n #held-bar { padding-left: calc((100% - 920px) / 2 + 16px); padding-right: calc((100% - 920px) / 2 + 16px); }\n }";