@mjasnikovs/pi-task 0.20.2 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/remote/protocol.d.ts +14 -2
- package/dist/remote/protocol.js +2 -0
- package/dist/remote/register.d.ts +8 -0
- package/dist/remote/register.js +29 -15
- package/dist/remote/server.d.ts +1 -1
- package/dist/remote/server.js +5 -1
- package/dist/remote/session-state.d.ts +11 -0
- package/dist/remote/session-state.js +14 -1
- package/dist/remote/ui-script.js +39 -4
- package/dist/remote/ui-styles.d.ts +1 -1
- package/dist/remote/ui-styles.js +18 -0
- package/dist/remote/ui.js +5 -0
- package/dist/task/auto-orchestrator.js +6 -0
- package/dist/task/cancel-input.d.ts +36 -12
- package/dist/task/cancel-input.js +102 -17
- package/dist/task/dropped-input.d.ts +14 -0
- package/dist/task/dropped-input.js +25 -0
- package/dist/task/mid-run-input.d.ts +53 -0
- package/dist/task/mid-run-input.js +101 -0
- package/dist/task/orchestrator.js +55 -11
- package/package.json +1 -1
|
@@ -40,6 +40,14 @@ export interface NotifyMessage {
|
|
|
40
40
|
message: string;
|
|
41
41
|
level: 'info' | 'warning' | 'error';
|
|
42
42
|
}
|
|
43
|
+
/** Lines the user typed while a task run owned the session, waiting for the next
|
|
44
|
+
* task turn. Shown in the composer so a held message is never a silent one. */
|
|
45
|
+
export interface HeldMessage {
|
|
46
|
+
type: 'held';
|
|
47
|
+
texts: string[];
|
|
48
|
+
/** True while a task run owns the session (drives the composer placeholder). */
|
|
49
|
+
runActive: boolean;
|
|
50
|
+
}
|
|
43
51
|
export interface ViewerMessage {
|
|
44
52
|
type: 'viewer';
|
|
45
53
|
title: string;
|
|
@@ -67,7 +75,7 @@ export type { SnapshotMessage } from './session-state.js';
|
|
|
67
75
|
/** Server → browser messages. The live text_delta / tool_* / agent_* /
|
|
68
76
|
* user_message deltas are emitted by the SessionState mutators
|
|
69
77
|
* and not all enumerated here; the snapshot below carries the full state. */
|
|
70
|
-
export type ServerMessage = PromptMessage | PromptResolvedMessage | WidgetMessage | NotifyMessage | ViewerMessage | ContextMessage | ResetMessage | import('./session-state.js').SnapshotMessage;
|
|
78
|
+
export type ServerMessage = PromptMessage | PromptResolvedMessage | WidgetMessage | NotifyMessage | ViewerMessage | HeldMessage | ContextMessage | ResetMessage | import('./session-state.js').SnapshotMessage;
|
|
71
79
|
/** Browser → server messages. */
|
|
72
80
|
export interface ClientChatMessage {
|
|
73
81
|
type: 'message';
|
|
@@ -82,5 +90,9 @@ export interface ClientPromptAnswer {
|
|
|
82
90
|
export interface ClientInterrupt {
|
|
83
91
|
type: 'interrupt';
|
|
84
92
|
}
|
|
85
|
-
|
|
93
|
+
/** Drop everything held for the next task turn (the composer's ✕). */
|
|
94
|
+
export interface ClientClearHeld {
|
|
95
|
+
type: 'clear_held';
|
|
96
|
+
}
|
|
97
|
+
export type ClientMessage = ClientChatMessage | ClientPromptAnswer | ClientInterrupt | ClientClearHeld;
|
|
86
98
|
export declare function isClientMessage(x: unknown): x is ClientMessage;
|
package/dist/remote/protocol.js
CHANGED
|
@@ -8,6 +8,8 @@ export function isClientMessage(x) {
|
|
|
8
8
|
return typeof m.text === 'string';
|
|
9
9
|
if (m.type === 'interrupt')
|
|
10
10
|
return true;
|
|
11
|
+
if (m.type === 'clear_held')
|
|
12
|
+
return true;
|
|
11
13
|
if (m.type === 'prompt_answer') {
|
|
12
14
|
return typeof m.id === 'string' && (m.value === undefined || typeof m.value === 'string');
|
|
13
15
|
}
|
|
@@ -1,2 +1,10 @@
|
|
|
1
1
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
/**
|
|
3
|
+
* Where a plain (non-slash) browser line goes. Exported so the decision is
|
|
4
|
+
* tested as SHIPPED rather than as a copy — the three branches are the whole of
|
|
5
|
+
* issue #8, and the middle one is the regression.
|
|
6
|
+
*/
|
|
7
|
+
export declare function routePlainLine(plain: string, send: (text: string, opts?: {
|
|
8
|
+
deliverAs: 'steer' | 'followUp';
|
|
9
|
+
}) => void): void;
|
|
2
10
|
export declare function registerRemote(pi: ExtensionAPI): void;
|
package/dist/remote/register.js
CHANGED
|
@@ -1,16 +1,40 @@
|
|
|
1
1
|
import { getConfig } from '../config/config.js';
|
|
2
2
|
import { getBridge, dispatchRemoteLine, dispatchRemoteNewSession, makeShimmedCtx, interruptAgent, registerBridgeCommand, registerRemoteOnlyCommand, publishNotify } from './bridge.js';
|
|
3
3
|
import { setupEvents } from './events.js';
|
|
4
|
-
import { reset, addUserTurn } from './session-state.js';
|
|
4
|
+
import { reset, addUserTurn, setHeld } from './session-state.js';
|
|
5
5
|
import { html } from './ui.js';
|
|
6
6
|
import { qrLines } from './qr.js';
|
|
7
7
|
import { startServer, formatAddresses } from './server.js';
|
|
8
8
|
import { ensureTailscaleServe, teardownTailscaleServe, planRemoteUrls, hostFromResult } from './tailscale.js';
|
|
9
9
|
import { isAgentIdle } from './state.js';
|
|
10
|
+
import { holdInput, isRunActive, clearHeldInput, heldInput, setHeldInputListener } from '../task/mid-run-input.js';
|
|
10
11
|
const _g = globalThis;
|
|
11
12
|
if (!_g.__piRemote)
|
|
12
13
|
_g.__piRemote = { server: null, send: null, serveResult: null };
|
|
13
14
|
const S = _g.__piRemote;
|
|
15
|
+
/**
|
|
16
|
+
* Where a plain (non-slash) browser line goes. Exported so the decision is
|
|
17
|
+
* tested as SHIPPED rather than as a copy — the three branches are the whole of
|
|
18
|
+
* issue #8, and the middle one is the regression.
|
|
19
|
+
*/
|
|
20
|
+
export function routePlainLine(plain, send) {
|
|
21
|
+
addUserTurn(plain);
|
|
22
|
+
if (!isAgentIdle()) {
|
|
23
|
+
// A live turn: steer it (inject into the current generation) so the
|
|
24
|
+
// nudge lands immediately.
|
|
25
|
+
send(plain, { deliverAs: 'steer' });
|
|
26
|
+
}
|
|
27
|
+
else if (isRunActive()) {
|
|
28
|
+
// Idle, but a task run owns the session — which is most of a run, since
|
|
29
|
+
// the spec phases and every gate are child processes. Sending here opens
|
|
30
|
+
// a SECOND turn beside the run; that is what killed a live run on pi
|
|
31
|
+
// 0.82.1. Hold it for the next task turn instead.
|
|
32
|
+
holdInput(plain);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
send(plain);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
14
38
|
export function registerRemote(pi) {
|
|
15
39
|
async function ensureServer() {
|
|
16
40
|
if (S.server)
|
|
@@ -27,21 +51,9 @@ export function registerRemote(pi) {
|
|
|
27
51
|
return;
|
|
28
52
|
}
|
|
29
53
|
dispatchRemoteLine(text, {
|
|
30
|
-
onPlain: plain =>
|
|
31
|
-
addUserTurn(plain);
|
|
32
|
-
if (isAgentIdle()) {
|
|
33
|
-
S.send?.(plain);
|
|
34
|
-
}
|
|
35
|
-
else {
|
|
36
|
-
// Mid-run: steer the live turn (inject the message into the
|
|
37
|
-
// current generation) rather than queueing it for after, so a
|
|
38
|
-
// remote nudge lands immediately — matching the composer's
|
|
39
|
-
// "delivered mid-run" affordance.
|
|
40
|
-
S.send?.(plain, { deliverAs: 'steer' });
|
|
41
|
-
}
|
|
42
|
-
}
|
|
54
|
+
onPlain: plain => routePlainLine(plain, (t, opts) => S.send?.(t, opts))
|
|
43
55
|
});
|
|
44
|
-
}, wsUrl => html(wsUrl), interruptAgent);
|
|
56
|
+
}, wsUrl => html(wsUrl), interruptAgent, clearHeldInput);
|
|
45
57
|
// Hands-off HTTPS: point Tailscale serve at our port so phones get a
|
|
46
58
|
// secure context. Best-effort — any failure degrades to the http URL.
|
|
47
59
|
S.serveResult = await ensureTailscaleServe(S.server.port).catch(() => ({ state: 'unavailable' }));
|
|
@@ -55,6 +67,8 @@ export function registerRemote(pi) {
|
|
|
55
67
|
const bridge = getBridge();
|
|
56
68
|
reset();
|
|
57
69
|
setupEvents(pi);
|
|
70
|
+
// Mirror held mid-run input into the browser composer.
|
|
71
|
+
setHeldInputListener(() => setHeld(heldInput(), isRunActive()));
|
|
58
72
|
// Seed a shimmed ctx so commands that don't need newSession (/task-list,
|
|
59
73
|
// /task-cancel, /task-auto-cancel) work immediately from the remote without
|
|
60
74
|
// any terminal interaction. Only overwrite if null or already shimmed —
|
package/dist/remote/server.d.ts
CHANGED
|
@@ -42,5 +42,5 @@ export declare function formatAddresses(ips: LocalIPs, port: number, tsHost?: st
|
|
|
42
42
|
* real server) escapes as an uncaughtException that crashes pi (issue #7).
|
|
43
43
|
* Retrying the real bind has no probe and no window. */
|
|
44
44
|
export declare function listenWithRetry(server: import('node:http').Server, start: number, max: number): Promise<number>;
|
|
45
|
-
export declare function startServer(onMessage: MessageCallback, getHtml: (wsUrl: string) => string, onInterrupt?: () => void): Promise<ServerHandle>;
|
|
45
|
+
export declare function startServer(onMessage: MessageCallback, getHtml: (wsUrl: string) => string, onInterrupt?: () => void, onClearHeld?: () => void): Promise<ServerHandle>;
|
|
46
46
|
export {};
|
package/dist/remote/server.js
CHANGED
|
@@ -94,7 +94,7 @@ export function listenWithRetry(server, start, max) {
|
|
|
94
94
|
server.listen(port, '0.0.0.0');
|
|
95
95
|
});
|
|
96
96
|
}
|
|
97
|
-
export async function startServer(onMessage, getHtml, onInterrupt) {
|
|
97
|
+
export async function startServer(onMessage, getHtml, onInterrupt, onClearHeld) {
|
|
98
98
|
const ips = getLocalIPs();
|
|
99
99
|
const ip = ips.primary;
|
|
100
100
|
// The bound port isn't known until listenWithRetry succeeds, and wsUrl
|
|
@@ -211,6 +211,10 @@ export async function startServer(onMessage, getHtml, onInterrupt) {
|
|
|
211
211
|
answerPrompt(msg.id, msg.value);
|
|
212
212
|
return;
|
|
213
213
|
}
|
|
214
|
+
if (msg.type === 'clear_held') {
|
|
215
|
+
onClearHeld?.();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
214
218
|
// type === 'message': ignore while a prompt is pending (composer is
|
|
215
219
|
// disabled in the browser; this is the server-side guard).
|
|
216
220
|
if (getState().prompt)
|
|
@@ -18,6 +18,8 @@ export interface SnapshotMessage {
|
|
|
18
18
|
prompt: PromptMessage | null;
|
|
19
19
|
context: ContextUsage | null;
|
|
20
20
|
model: string | null;
|
|
21
|
+
held: string[];
|
|
22
|
+
heldRunActive: boolean;
|
|
21
23
|
}
|
|
22
24
|
interface SessionState {
|
|
23
25
|
history: HistoryBuffer;
|
|
@@ -29,6 +31,13 @@ interface SessionState {
|
|
|
29
31
|
context: ContextUsage | null;
|
|
30
32
|
/** Human-readable active model name (e.g. "Qwen3.6 27B"), for the header chip. */
|
|
31
33
|
model: string | null;
|
|
34
|
+
/** Lines typed while a task run owned the session, waiting for the next task
|
|
35
|
+
* turn to steer. Mirrors src/task/mid-run-input.ts so the browser can show
|
|
36
|
+
* what is pending instead of leaving the user guessing. */
|
|
37
|
+
held: string[];
|
|
38
|
+
/** True while a task run owns the session, so the composer can say what a
|
|
39
|
+
* typed line will do BEFORE the user types it. */
|
|
40
|
+
heldRunActive: boolean;
|
|
32
41
|
/** tool start timestamps (ms), keyed by toolCallId — kept off the serialized
|
|
33
42
|
* parts so it never reaches the client; used only to compute elapsedMs. */
|
|
34
43
|
toolStarts: Record<string, number>;
|
|
@@ -51,6 +60,8 @@ export declare function updateTool(toolCallId: string, partialResult: unknown):
|
|
|
51
60
|
export declare function endTool(toolCallId: string, toolName: string, result: unknown, isError: boolean): void;
|
|
52
61
|
export declare function agentEnd(context: ContextUsage, model?: string): void;
|
|
53
62
|
export declare function addUserTurn(text: string): void;
|
|
63
|
+
/** Mirror the held-input list to every browser. */
|
|
64
|
+
export declare function setHeld(texts: string[], runActive: boolean): void;
|
|
54
65
|
export declare function addError(message: string): void;
|
|
55
66
|
/** A persistent inline note (committed to the transcript so it survives reconnect)
|
|
56
67
|
* plus a live delta so connected clients render it immediately. */
|
|
@@ -22,6 +22,8 @@ function fresh() {
|
|
|
22
22
|
prompt: null,
|
|
23
23
|
context: null,
|
|
24
24
|
model: null,
|
|
25
|
+
held: [],
|
|
26
|
+
heldRunActive: false,
|
|
25
27
|
toolStarts: {},
|
|
26
28
|
sink: wsBroadcast
|
|
27
29
|
};
|
|
@@ -157,6 +159,13 @@ export function addUserTurn(text) {
|
|
|
157
159
|
s.history.addUserMessage(text);
|
|
158
160
|
s.sink({ type: 'user_message', text });
|
|
159
161
|
}
|
|
162
|
+
/** Mirror the held-input list to every browser. */
|
|
163
|
+
export function setHeld(texts, runActive) {
|
|
164
|
+
const s = getState();
|
|
165
|
+
s.held = [...texts];
|
|
166
|
+
s.heldRunActive = runActive;
|
|
167
|
+
s.sink({ type: 'held', texts: s.held, runActive });
|
|
168
|
+
}
|
|
160
169
|
export function addError(message) {
|
|
161
170
|
const s = getState();
|
|
162
171
|
s.history.addError(message);
|
|
@@ -197,6 +206,8 @@ export function setContext(context) {
|
|
|
197
206
|
/** Wipe everything (new session) and tell connected clients to clear. */
|
|
198
207
|
export function reset() {
|
|
199
208
|
const s = getState();
|
|
209
|
+
s.held = [];
|
|
210
|
+
s.heldRunActive = false;
|
|
200
211
|
s.history = new HistoryBuffer(20);
|
|
201
212
|
s.live = null;
|
|
202
213
|
s.agentRunning = false;
|
|
@@ -220,6 +231,8 @@ export function snapshot() {
|
|
|
220
231
|
taskWidgetData: s.taskWidgetData,
|
|
221
232
|
prompt: s.prompt,
|
|
222
233
|
context: s.context,
|
|
223
|
-
model: s.model
|
|
234
|
+
model: s.model,
|
|
235
|
+
held: [...s.held],
|
|
236
|
+
heldRunActive: s.heldRunActive
|
|
224
237
|
};
|
|
225
238
|
}
|
package/dist/remote/ui-script.js
CHANGED
|
@@ -13,6 +13,10 @@ export function clientScript(wsUrl) {
|
|
|
13
13
|
const inputEl = document.getElementById('input');
|
|
14
14
|
const sendBtn = document.getElementById('send');
|
|
15
15
|
const contextFill = document.getElementById('context-bar-fill');
|
|
16
|
+
const heldBar = document.getElementById('held-bar');
|
|
17
|
+
const heldLabel = document.getElementById('held-label');
|
|
18
|
+
const heldText = document.getElementById('held-text');
|
|
19
|
+
const heldClear = document.getElementById('held-clear');
|
|
16
20
|
function setContextBar(usage) {
|
|
17
21
|
if (usage && usage.percent != null) contextFill.style.width = usage.percent + '%';
|
|
18
22
|
setStatusChip(usage);
|
|
@@ -308,6 +312,24 @@ export function clientScript(wsUrl) {
|
|
|
308
312
|
s.appendChild(e);
|
|
309
313
|
}
|
|
310
314
|
|
|
315
|
+
// Lines typed while a task run owns the session. They are NOT sent yet: the
|
|
316
|
+
// run's phases are child processes, so sending would open a second turn
|
|
317
|
+
// beside the run. Held here and steered into the next task turn.
|
|
318
|
+
let held = [];
|
|
319
|
+
let runHolding = false;
|
|
320
|
+
function renderHeld() {
|
|
321
|
+
if (!held.length) { heldBar.style.display = 'none'; return; }
|
|
322
|
+
heldBar.style.display = 'flex';
|
|
323
|
+
heldLabel.textContent = held.length === 1
|
|
324
|
+
? 'waiting for the task turn:'
|
|
325
|
+
: held.length + ' waiting for the task turn:';
|
|
326
|
+
heldText.textContent = held.join(' · ');
|
|
327
|
+
}
|
|
328
|
+
heldClear.addEventListener('click', () => {
|
|
329
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
330
|
+
ws.send(JSON.stringify({ type: 'clear_held' }));
|
|
331
|
+
});
|
|
332
|
+
|
|
311
333
|
// Reconcile the composer (input + Send/Stop button) with the current state.
|
|
312
334
|
// The input is disabled ONLY while disconnected or a prompt card is open — a
|
|
313
335
|
// running agent no longer locks it, so messages can steer the live turn.
|
|
@@ -316,8 +338,10 @@ export function clientScript(wsUrl) {
|
|
|
316
338
|
const promptOpen = activePromptId !== null;
|
|
317
339
|
inputEl.disabled = !connected || promptOpen;
|
|
318
340
|
inputEl.placeholder = agentRunning
|
|
319
|
-
? 'message the agent
|
|
320
|
-
:
|
|
341
|
+
? 'message the agent \\u2014 steers the live turn'
|
|
342
|
+
: (runHolding
|
|
343
|
+
? 'task running \\u2014 held for the next task turn'
|
|
344
|
+
: 'type a message\\u2026 (/ for commands)');
|
|
321
345
|
if (agentRunning && !promptOpen) {
|
|
322
346
|
// Send morphs into a red Stop that interrupts the running turn.
|
|
323
347
|
sendBtn.classList.add('stop');
|
|
@@ -858,6 +882,12 @@ export function clientScript(wsUrl) {
|
|
|
858
882
|
|
|
859
883
|
function handleMsg(msg) {
|
|
860
884
|
switch (msg.type) {
|
|
885
|
+
case 'held':
|
|
886
|
+
held = msg.texts || [];
|
|
887
|
+
runHolding = !!msg.runActive;
|
|
888
|
+
renderHeld();
|
|
889
|
+
refreshComposer();
|
|
890
|
+
break;
|
|
861
891
|
case 'snapshot': {
|
|
862
892
|
// Authoritative full state on every (re)connect: replace the WHOLE view.
|
|
863
893
|
// This is what kills duplicated transcript / stale-orphaned widgets —
|
|
@@ -878,6 +908,9 @@ export function clientScript(wsUrl) {
|
|
|
878
908
|
setModelName(msg.model);
|
|
879
909
|
if (msg.context) setContextBar(msg.context); else contextFill.style.width = '0%';
|
|
880
910
|
agentRunning = !!msg.agentRunning;
|
|
911
|
+
held = msg.held || [];
|
|
912
|
+
runHolding = !!msg.heldRunActive;
|
|
913
|
+
renderHeld();
|
|
881
914
|
turnHadContent = !!(msg.live && msg.live.parts && msg.live.parts.length);
|
|
882
915
|
if (msg.prompt) showPrompt(msg.prompt);
|
|
883
916
|
refreshComposer();
|
|
@@ -1066,8 +1099,10 @@ export function clientScript(wsUrl) {
|
|
|
1066
1099
|
// user_message back to every client (us included), which renders the
|
|
1067
1100
|
// bubble. Don't render it here too, or the sender sees it twice.
|
|
1068
1101
|
// Mid-run the message steers the live turn (no state change here); when
|
|
1069
|
-
// idle, optimistically show the spinner until agent_start lands.
|
|
1070
|
-
|
|
1102
|
+
// idle, optimistically show the spinner until agent_start lands. While a
|
|
1103
|
+
// task run holds the session nothing starts now — the line is held for the
|
|
1104
|
+
// next task turn, so a spinner would be a lie.
|
|
1105
|
+
if (!agentRunning && !runHolding) showThinking();
|
|
1071
1106
|
}
|
|
1072
1107
|
|
|
1073
1108
|
sendBtn.addEventListener('click', onSendClick);
|
|
@@ -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 from window.innerHeight (see setAppHeight) so the column\n height is a stable pixel value across an orientation change. 100dvh is a\n fallback for first paint / no-JS: iOS Safari interpolates dvh during the\n rotation animation, which makes the whole flex column resize repeatedly\n (\"spazzing out\") \u2014 a fixed px height does not. */\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 (the surface bar that used to live on .code-lang). */\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 #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 inline at the end of the streaming text (not a green blinking block). */\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 truncated to a stub\n the way the old single-row flex layout squeezed it on narrow/mobile widths. */\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 }";
|
|
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 from window.innerHeight (see setAppHeight) so the column\n height is a stable pixel value across an orientation change. 100dvh is a\n fallback for first paint / no-JS: iOS Safari interpolates dvh during the\n rotation animation, which makes the whole flex column resize repeatedly\n (\"spazzing out\") \u2014 a fixed px height does not. */\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 (the surface bar that used to live on .code-lang). */\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 #held-bar {\n position: absolute; bottom: 100%; left: 16px; right: 16px;\n display: flex; align-items: center; gap: 8px;\n background: var(--mantle); border: 1px solid var(--surface1);\n border-bottom: none; border-radius: 8px 8px 0 0;\n padding: 6px 10px; font-size: 12px; z-index: 9;\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 inline at the end of the streaming text (not a green blinking block). */\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 truncated to a stub\n the way the old single-row flex layout squeezed it on narrow/mobile widths. */\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 }";
|
package/dist/remote/ui-styles.js
CHANGED
|
@@ -260,6 +260,24 @@ export const STYLES = ` :root {
|
|
|
260
260
|
border-top: 1px solid var(--surface0);
|
|
261
261
|
position: relative;
|
|
262
262
|
}
|
|
263
|
+
/* Held mid-run input: what you typed is waiting for the next task turn. */
|
|
264
|
+
#held-bar {
|
|
265
|
+
position: absolute; bottom: 100%; left: 16px; right: 16px;
|
|
266
|
+
display: flex; align-items: center; gap: 8px;
|
|
267
|
+
background: var(--mantle); border: 1px solid var(--surface1);
|
|
268
|
+
border-bottom: none; border-radius: 8px 8px 0 0;
|
|
269
|
+
padding: 6px 10px; font-size: 12px; z-index: 9;
|
|
270
|
+
}
|
|
271
|
+
#held-label { color: var(--yellow); white-space: nowrap; }
|
|
272
|
+
#held-text {
|
|
273
|
+
color: var(--subtext0); overflow: hidden; text-overflow: ellipsis;
|
|
274
|
+
white-space: nowrap; flex: 1;
|
|
275
|
+
}
|
|
276
|
+
#held-clear {
|
|
277
|
+
background: none; border: none; color: var(--subtext0);
|
|
278
|
+
cursor: pointer; font-size: 13px; padding: 0 2px;
|
|
279
|
+
}
|
|
280
|
+
#held-clear:hover { color: var(--red); }
|
|
263
281
|
#cmd-suggestions {
|
|
264
282
|
display: none; position: absolute; bottom: 100%; left: 16px; right: 16px;
|
|
265
283
|
background: var(--mantle); border: 1px solid var(--surface1);
|
package/dist/remote/ui.js
CHANGED
|
@@ -59,6 +59,11 @@ ${STYLES}
|
|
|
59
59
|
</div>
|
|
60
60
|
<div id="status-panel"></div>
|
|
61
61
|
<div id="input-bar">
|
|
62
|
+
<div id="held-bar" style="display:none">
|
|
63
|
+
<span id="held-label"></span>
|
|
64
|
+
<span id="held-text"></span>
|
|
65
|
+
<button id="held-clear" type="button" title="Discard">✕</button>
|
|
66
|
+
</div>
|
|
62
67
|
<div id="cmd-suggestions"></div>
|
|
63
68
|
<textarea id="input" placeholder="type a message… (/ for commands)" rows="1" disabled></textarea>
|
|
64
69
|
<button id="send" disabled>Send</button>
|
|
@@ -23,6 +23,8 @@ import { findPhantomImports, rewritePhantomSpecifiers } from '../workers/phantom
|
|
|
23
23
|
import { runPhaseChild, prependHint, USER_CANCELLED } from './child-runner.js';
|
|
24
24
|
import { requestCancel, resetCancel, isCancelRequested, cancelCheckpoint } from './cancel-points.js';
|
|
25
25
|
import { armCancelListener, disarmCancelListener } from './cancel-input.js';
|
|
26
|
+
import { beginRun, endRun } from './mid-run-input.js';
|
|
27
|
+
import { reportDroppedInput } from './dropped-input.js';
|
|
26
28
|
import { refineExistingFilesBlock } from './phases.js';
|
|
27
29
|
import { SessionUI, registerBridgeCommand, publishLifecycleNotice } from '../remote/bridge.js';
|
|
28
30
|
import { pushNotify } from '../remote/push.js';
|
|
@@ -1565,6 +1567,7 @@ async function handleTaskAuto(args, ctx) {
|
|
|
1565
1567
|
return;
|
|
1566
1568
|
}
|
|
1567
1569
|
autoRunning = true;
|
|
1570
|
+
beginRun(); // the whole loop owns the session, not just the task inside it
|
|
1568
1571
|
// Take delivery of a typed /task-auto-cancel for the WHOLE run, planning
|
|
1569
1572
|
// included — planning is children too, so the host is not streaming and the
|
|
1570
1573
|
// ordinary command path cannot reach us.
|
|
@@ -1602,6 +1605,7 @@ async function handleTaskAuto(args, ctx) {
|
|
|
1602
1605
|
}
|
|
1603
1606
|
finally {
|
|
1604
1607
|
autoRunning = false;
|
|
1608
|
+
reportDroppedInput(endRun(), ctx);
|
|
1605
1609
|
disarmCancelListener();
|
|
1606
1610
|
}
|
|
1607
1611
|
}
|
|
@@ -1631,6 +1635,7 @@ async function handleTaskAutoResume(args, ctx) {
|
|
|
1631
1635
|
const id = candidate.id;
|
|
1632
1636
|
await updateTaskFrontMatter(cwd, id, { state: 'in_progress' });
|
|
1633
1637
|
autoRunning = true;
|
|
1638
|
+
beginRun(); // the whole loop owns the session, not just the task inside it
|
|
1634
1639
|
armTerminalCancel(ctx);
|
|
1635
1640
|
try {
|
|
1636
1641
|
// Reuse the interrupted run's research-cache id, dropping only the entries whose
|
|
@@ -1650,6 +1655,7 @@ async function handleTaskAutoResume(args, ctx) {
|
|
|
1650
1655
|
}
|
|
1651
1656
|
finally {
|
|
1652
1657
|
autoRunning = false;
|
|
1658
|
+
reportDroppedInput(endRun(), ctx);
|
|
1653
1659
|
disarmCancelListener();
|
|
1654
1660
|
}
|
|
1655
1661
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Raw-terminal delivery for
|
|
2
|
+
* Raw-terminal delivery for input typed while a run owns the main loop.
|
|
3
3
|
*
|
|
4
4
|
* THE PROBLEM. While a /task-auto run is in flight, the host's interactive main
|
|
5
5
|
* loop is parked at `await session.prompt("/task-auto …")` and never loops back
|
|
@@ -23,10 +23,25 @@
|
|
|
23
23
|
* ourselves and `consume` the keystroke so the line is never queued for a
|
|
24
24
|
* post-run replay of the confusing "no loop is running" message.
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
26
|
+
* WHAT ELSE IT NOW CARRIES. The same interception makes the terminal behave like
|
|
27
|
+
* the browser for everything else typed mid-run, instead of feeding pi's queue:
|
|
28
|
+
*
|
|
29
|
+
* - a bridge slash command (/task-list, /task-auto-cancel, …) runs immediately,
|
|
30
|
+
* exactly as dispatchRemoteLine runs it for a browser;
|
|
31
|
+
* - a plain line is HELD (src/task/mid-run-input.ts) and steered into the next
|
|
32
|
+
* task turn, rather than starting a competing turn or being replayed after
|
|
33
|
+
* the run.
|
|
34
|
+
*
|
|
35
|
+
* Three things are deliberately NOT intercepted, and each would be a regression:
|
|
36
|
+
* - a keystroke that is not a submit, so typing and history are untouched;
|
|
37
|
+
* - anything typed while a prompt/dialog is open (the raw listener sees keys
|
|
38
|
+
* BEFORE the focused component, so swallowing here would eat the user's
|
|
39
|
+
* answer to a clarify question or the ESC-steer input);
|
|
40
|
+
* - a submit while the agent is streaming — pi's own submit path already
|
|
41
|
+
* steers the live turn, which is the behaviour we want.
|
|
42
|
+
*
|
|
43
|
+
* Deliberately no bare-key shortcut — ESC already means "interrupt the turn"
|
|
44
|
+
* during the implementation turn, and hijacking it would break steerUntilDone.
|
|
30
45
|
*/
|
|
31
46
|
import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
32
47
|
/**
|
|
@@ -45,17 +60,26 @@ export declare function isCancelSubmission(data: string, editorText: string): bo
|
|
|
45
60
|
* Prefer the arm/rearm/disarm trio below over calling this directly: the
|
|
46
61
|
* listener does not survive a session replacement.
|
|
47
62
|
*/
|
|
48
|
-
export declare function installCancelListener(ctx: ExtensionCommandContext, onCancel
|
|
49
|
-
/**
|
|
50
|
-
|
|
63
|
+
export declare function installCancelListener(ctx: ExtensionCommandContext, onCancel?: (live: ExtensionCommandContext) => void): () => void;
|
|
64
|
+
/**
|
|
65
|
+
* Begin intercepting mid-run terminal input. Refcounted, because runs nest:
|
|
66
|
+
* /task-auto arms for its loop and every task inside it arms again, so a plain
|
|
67
|
+
* "replace" would let the first inner run's end silently un-arm the rest of the
|
|
68
|
+
* loop. The first `onCancel` wins — /task-auto passes one so a typed
|
|
69
|
+
* /task-auto-cancel can post its acknowledgement through the live ctx; a plain
|
|
70
|
+
* /task passes none and lets the generic bridge dispatch handle that command
|
|
71
|
+
* like any other.
|
|
72
|
+
*/
|
|
73
|
+
export declare function armCancelListener(ctx: ExtensionCommandContext, onCancel?: (live: ExtensionCommandContext) => void): void;
|
|
51
74
|
/**
|
|
52
75
|
* Re-point the armed listener at a replacement ctx. A no-op when nothing is
|
|
53
|
-
* armed, so the runner can call it unconditionally on every session swap
|
|
54
|
-
* (including for a plain /task, which has no cancel listener of its own).
|
|
76
|
+
* armed, so the runner can call it unconditionally on every session swap.
|
|
55
77
|
*/
|
|
56
78
|
export declare function rearmCancelListener(ctx: ExtensionCommandContext): void;
|
|
57
|
-
/** Stop listening, so
|
|
58
|
-
*
|
|
79
|
+
/** Stop listening once the outermost run ends, so input typed after the run goes
|
|
80
|
+
* back through pi's ordinary path. */
|
|
59
81
|
export declare function disarmCancelListener(): void;
|
|
82
|
+
/** Force-disarm regardless of depth (tests, and hard teardown). */
|
|
83
|
+
export declare function forceDisarmCancelListener(): void;
|
|
60
84
|
/** Whether a listener is currently armed (tests). */
|
|
61
85
|
export declare function isCancelListenerArmed(): boolean;
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { holdInput, isRunActive } from './mid-run-input.js';
|
|
2
|
+
import { getBridge } from '../remote/bridge.js';
|
|
3
|
+
import { getState } from '../remote/session-state.js';
|
|
1
4
|
/** The command this listener delivers. Accepts a leading slash only — matching
|
|
2
5
|
* bare "task-auto-cancel" would fire on prose about the command. */
|
|
3
6
|
const CANCEL_RE = /^\/task-auto-cancel\s*$/;
|
|
@@ -13,6 +16,15 @@ function isSubmitKey(data) {
|
|
|
13
16
|
export function isCancelSubmission(data, editorText) {
|
|
14
17
|
return isSubmitKey(data) && CANCEL_RE.test(editorText.trim());
|
|
15
18
|
}
|
|
19
|
+
/** Best-effort toast; a failed notification must never break interception. */
|
|
20
|
+
function notify(ctx, message) {
|
|
21
|
+
try {
|
|
22
|
+
ctx.ui.notify(message, 'info');
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
/* cosmetic only */
|
|
26
|
+
}
|
|
27
|
+
}
|
|
16
28
|
/**
|
|
17
29
|
* Watch raw terminal input for a submitted /task-auto-cancel and call `onCancel`
|
|
18
30
|
* the moment it is typed, however deep in a run we are.
|
|
@@ -34,6 +46,8 @@ export function installCancelListener(ctx, onCancel) {
|
|
|
34
46
|
let unsubscribe = () => { };
|
|
35
47
|
try {
|
|
36
48
|
unsubscribe = ui.onTerminalInput(data => {
|
|
49
|
+
if (!isSubmitKey(data))
|
|
50
|
+
return undefined;
|
|
37
51
|
let text;
|
|
38
52
|
try {
|
|
39
53
|
text = getText();
|
|
@@ -43,20 +57,65 @@ export function installCancelListener(ctx, onCancel) {
|
|
|
43
57
|
// take the run down with it — fall through to normal handling.
|
|
44
58
|
return undefined;
|
|
45
59
|
}
|
|
46
|
-
|
|
60
|
+
const trimmed = text.trim();
|
|
61
|
+
if (trimmed.length === 0)
|
|
47
62
|
return undefined;
|
|
48
|
-
|
|
49
|
-
|
|
63
|
+
const swallow = () => {
|
|
64
|
+
// Clear what we swallowed, so the editor does not sit there
|
|
65
|
+
// holding a line the user believes they submitted.
|
|
66
|
+
try {
|
|
67
|
+
setText?.('');
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
/* cosmetic only */
|
|
71
|
+
}
|
|
72
|
+
return { consume: true };
|
|
73
|
+
};
|
|
74
|
+
if (CANCEL_RE.test(trimmed) && onCancel) {
|
|
75
|
+
const r = swallow();
|
|
76
|
+
// Hand back the ctx this listener is installed on: after a session
|
|
77
|
+
// replacement the original is stale and using it throws.
|
|
78
|
+
onCancel(ctx);
|
|
79
|
+
return r;
|
|
80
|
+
}
|
|
81
|
+
// A dialog owns the keyboard — this Enter is the user's ANSWER.
|
|
82
|
+
if (getState().prompt !== null)
|
|
83
|
+
return undefined;
|
|
84
|
+
// Outside a run, pi's own handling is correct and unblocked.
|
|
85
|
+
if (!isRunActive())
|
|
86
|
+
return undefined;
|
|
87
|
+
// Streaming: pi's submit path already steers the live turn.
|
|
50
88
|
try {
|
|
51
|
-
|
|
89
|
+
if (!ctx.isIdle())
|
|
90
|
+
return undefined;
|
|
52
91
|
}
|
|
53
92
|
catch {
|
|
54
|
-
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
if (trimmed.startsWith('/')) {
|
|
96
|
+
const space = trimmed.indexOf(' ');
|
|
97
|
+
const name = (space === -1 ? trimmed.slice(1) : trimmed.slice(1, space)).trim();
|
|
98
|
+
const args = space === -1 ? '' : trimmed.slice(space + 1).trim();
|
|
99
|
+
const handler = getBridge().commands.get(name);
|
|
100
|
+
// Unknown to the bridge (a pi builtin like /model): leave it to pi.
|
|
101
|
+
if (!handler)
|
|
102
|
+
return undefined;
|
|
103
|
+
const r = swallow();
|
|
104
|
+
try {
|
|
105
|
+
const result = handler(args, ctx);
|
|
106
|
+
if (result instanceof Promise) {
|
|
107
|
+
result.catch((err) => notify(ctx, `/${name} failed: ${String(err)}`));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
notify(ctx, `/${name} failed: ${String(err)}`);
|
|
112
|
+
}
|
|
113
|
+
return r;
|
|
55
114
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return
|
|
115
|
+
const r = swallow();
|
|
116
|
+
holdInput(trimmed);
|
|
117
|
+
notify(ctx, 'Held for the running task — it lands on the next task turn.');
|
|
118
|
+
return r;
|
|
60
119
|
});
|
|
61
120
|
}
|
|
62
121
|
catch {
|
|
@@ -85,25 +144,51 @@ export function installCancelListener(ctx, onCancel) {
|
|
|
85
144
|
* inside the runner, which must not learn about /task-auto's cancel plumbing.
|
|
86
145
|
*/
|
|
87
146
|
let armed = null;
|
|
88
|
-
/**
|
|
147
|
+
/**
|
|
148
|
+
* Begin intercepting mid-run terminal input. Refcounted, because runs nest:
|
|
149
|
+
* /task-auto arms for its loop and every task inside it arms again, so a plain
|
|
150
|
+
* "replace" would let the first inner run's end silently un-arm the rest of the
|
|
151
|
+
* loop. The first `onCancel` wins — /task-auto passes one so a typed
|
|
152
|
+
* /task-auto-cancel can post its acknowledgement through the live ctx; a plain
|
|
153
|
+
* /task passes none and lets the generic bridge dispatch handle that command
|
|
154
|
+
* like any other.
|
|
155
|
+
*/
|
|
89
156
|
export function armCancelListener(ctx, onCancel) {
|
|
90
|
-
|
|
91
|
-
|
|
157
|
+
if (armed) {
|
|
158
|
+
armed.depth++;
|
|
159
|
+
if (!armed.onCancel && onCancel) {
|
|
160
|
+
// Upgrade in place: keep the same install, gain the ack callback.
|
|
161
|
+
armed.dispose();
|
|
162
|
+
armed.dispose = installCancelListener(ctx, onCancel);
|
|
163
|
+
armed.onCancel = onCancel;
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
armed = { dispose: installCancelListener(ctx, onCancel), onCancel, depth: 1 };
|
|
92
168
|
}
|
|
93
169
|
/**
|
|
94
170
|
* Re-point the armed listener at a replacement ctx. A no-op when nothing is
|
|
95
|
-
* armed, so the runner can call it unconditionally on every session swap
|
|
96
|
-
* (including for a plain /task, which has no cancel listener of its own).
|
|
171
|
+
* armed, so the runner can call it unconditionally on every session swap.
|
|
97
172
|
*/
|
|
98
173
|
export function rearmCancelListener(ctx) {
|
|
99
174
|
if (!armed)
|
|
100
175
|
return;
|
|
101
176
|
armed.dispose();
|
|
102
|
-
armed =
|
|
177
|
+
armed.dispose = installCancelListener(ctx, armed.onCancel);
|
|
103
178
|
}
|
|
104
|
-
/** Stop listening, so
|
|
105
|
-
*
|
|
179
|
+
/** Stop listening once the outermost run ends, so input typed after the run goes
|
|
180
|
+
* back through pi's ordinary path. */
|
|
106
181
|
export function disarmCancelListener() {
|
|
182
|
+
if (!armed)
|
|
183
|
+
return;
|
|
184
|
+
armed.depth--;
|
|
185
|
+
if (armed.depth > 0)
|
|
186
|
+
return;
|
|
187
|
+
armed.dispose();
|
|
188
|
+
armed = null;
|
|
189
|
+
}
|
|
190
|
+
/** Force-disarm regardless of depth (tests, and hard teardown). */
|
|
191
|
+
export function forceDisarmCancelListener() {
|
|
107
192
|
armed?.dispose();
|
|
108
193
|
armed = null;
|
|
109
194
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tell the user when mid-run input never reached the agent.
|
|
3
|
+
*
|
|
4
|
+
* A line held for the next task turn (src/task/mid-run-input.ts) normally lands
|
|
5
|
+
* as a steer. When the run ends before any turn starts — a cancel, a failure, a
|
|
6
|
+
* final task with nothing left to implement — the held text has nowhere to go.
|
|
7
|
+
* Delivering it afterwards would recreate the surprise of pi's queue replaying
|
|
8
|
+
* lines into a finished run (issue #8), so it is dropped; dropping it QUIETLY
|
|
9
|
+
* would be the same trap. Both surfaces get told, with the text quoted back so
|
|
10
|
+
* it can be re-sent by hand.
|
|
11
|
+
*/
|
|
12
|
+
import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
13
|
+
export declare function formatDroppedInput(dropped: readonly string[]): string | null;
|
|
14
|
+
export declare function reportDroppedInput(dropped: readonly string[], ctx?: ExtensionCommandContext): void;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { publishNotify } from '../remote/bridge.js';
|
|
2
|
+
/** Trim a held line to something that fits in a toast. */
|
|
3
|
+
function preview(text) {
|
|
4
|
+
const oneLine = text.replace(/\s+/g, ' ').trim();
|
|
5
|
+
return oneLine.length > 80 ? `${oneLine.slice(0, 77)}…` : oneLine;
|
|
6
|
+
}
|
|
7
|
+
export function formatDroppedInput(dropped) {
|
|
8
|
+
if (dropped.length === 0)
|
|
9
|
+
return null;
|
|
10
|
+
const head = preview(dropped[0]);
|
|
11
|
+
const rest = dropped.length > 1 ? ` (+${dropped.length - 1} more)` : '';
|
|
12
|
+
return `The run ended before your message could be delivered: "${head}"${rest}`;
|
|
13
|
+
}
|
|
14
|
+
export function reportDroppedInput(dropped, ctx) {
|
|
15
|
+
const message = formatDroppedInput(dropped);
|
|
16
|
+
if (message === null)
|
|
17
|
+
return;
|
|
18
|
+
try {
|
|
19
|
+
ctx?.ui.notify(message, 'warning');
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// A stale ctx after session replacement must not swallow the remote copy.
|
|
23
|
+
}
|
|
24
|
+
publishNotify(message, 'warning');
|
|
25
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a typed message DOES while a task run owns the session.
|
|
3
|
+
*
|
|
4
|
+
* Before this module the two surfaces disagreed, and both were wrong whenever the
|
|
5
|
+
* host session was idle — which is most of a run, since the spec phases and every
|
|
6
|
+
* gate are child `pi` processes, not host turns:
|
|
7
|
+
*
|
|
8
|
+
* - browser → `sendUserMessage` with no delivery mode, which starts a SECOND
|
|
9
|
+
* turn alongside the run. Reproduced live on pi 0.82.1 (issue #8):
|
|
10
|
+
* that turn ran the write tool into the project, and when it was
|
|
11
|
+
* still streaming as the pipeline delivered its spec the run died
|
|
12
|
+
* with "TASK_0001 failed: Agent is already processing."
|
|
13
|
+
* - terminal → pi's `pendingUserInputs` queue, drained only by the main loop
|
|
14
|
+
* that our own command handler is parked inside — so the line sat
|
|
15
|
+
* there silently and then fired minutes later against a finished
|
|
16
|
+
* run.
|
|
17
|
+
*
|
|
18
|
+
* The shared rule both surfaces now follow:
|
|
19
|
+
*
|
|
20
|
+
* agent streaming → steer the live turn (unchanged; this half always worked)
|
|
21
|
+
* agent idle, run active → HOLD it here, show it as pending, and deliver it as
|
|
22
|
+
* a steer when the next task turn starts
|
|
23
|
+
* no run active → ordinary message, unchanged
|
|
24
|
+
*
|
|
25
|
+
* Holding is what makes the two surfaces agree AND keeps the run safe: a held
|
|
26
|
+
* line never becomes a competing turn, and it is never silently replayed after
|
|
27
|
+
* the run has finished.
|
|
28
|
+
*/
|
|
29
|
+
export declare function setHeldInputListener(fn: (() => void) | null): void;
|
|
30
|
+
/** Mark a task run as owning the session (refcounted; call from a finally). */
|
|
31
|
+
export declare function beginRun(): void;
|
|
32
|
+
/**
|
|
33
|
+
* End a run. Returns anything still held, which the caller MUST report: that
|
|
34
|
+
* text missed its delivery window, and replaying it into a finished run is the
|
|
35
|
+
* exact surprise this module exists to remove — but dropping it silently would
|
|
36
|
+
* be the same trap the terminal queue was.
|
|
37
|
+
*/
|
|
38
|
+
export declare function endRun(): string[];
|
|
39
|
+
export declare function isRunActive(): boolean;
|
|
40
|
+
/** For tests: forget all state. */
|
|
41
|
+
export declare function resetMidRunInput(): void;
|
|
42
|
+
/** Hold a line for the next task turn. Blank lines are ignored. */
|
|
43
|
+
export declare function holdInput(text: string): void;
|
|
44
|
+
/** The lines currently waiting (a copy — mutating it must not affect state). */
|
|
45
|
+
export declare function heldInput(): string[];
|
|
46
|
+
export declare function clearHeldInput(): void;
|
|
47
|
+
/**
|
|
48
|
+
* Take everything held, as one steer payload, and clear it. Returns null when
|
|
49
|
+
* nothing is waiting. Clearing BEFORE delivery is deliberate: a delivery that
|
|
50
|
+
* throws must not leave the same text queued to be delivered again on the next
|
|
51
|
+
* turn.
|
|
52
|
+
*/
|
|
53
|
+
export declare function takeHeldInput(): string | null;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a typed message DOES while a task run owns the session.
|
|
3
|
+
*
|
|
4
|
+
* Before this module the two surfaces disagreed, and both were wrong whenever the
|
|
5
|
+
* host session was idle — which is most of a run, since the spec phases and every
|
|
6
|
+
* gate are child `pi` processes, not host turns:
|
|
7
|
+
*
|
|
8
|
+
* - browser → `sendUserMessage` with no delivery mode, which starts a SECOND
|
|
9
|
+
* turn alongside the run. Reproduced live on pi 0.82.1 (issue #8):
|
|
10
|
+
* that turn ran the write tool into the project, and when it was
|
|
11
|
+
* still streaming as the pipeline delivered its spec the run died
|
|
12
|
+
* with "TASK_0001 failed: Agent is already processing."
|
|
13
|
+
* - terminal → pi's `pendingUserInputs` queue, drained only by the main loop
|
|
14
|
+
* that our own command handler is parked inside — so the line sat
|
|
15
|
+
* there silently and then fired minutes later against a finished
|
|
16
|
+
* run.
|
|
17
|
+
*
|
|
18
|
+
* The shared rule both surfaces now follow:
|
|
19
|
+
*
|
|
20
|
+
* agent streaming → steer the live turn (unchanged; this half always worked)
|
|
21
|
+
* agent idle, run active → HOLD it here, show it as pending, and deliver it as
|
|
22
|
+
* a steer when the next task turn starts
|
|
23
|
+
* no run active → ordinary message, unchanged
|
|
24
|
+
*
|
|
25
|
+
* Holding is what makes the two surfaces agree AND keeps the run safe: a held
|
|
26
|
+
* line never becomes a competing turn, and it is never silently replayed after
|
|
27
|
+
* the run has finished.
|
|
28
|
+
*/
|
|
29
|
+
/** Held lines, oldest first. Delivered as one steer at the next turn start. */
|
|
30
|
+
let held = [];
|
|
31
|
+
/** Depth, not a boolean: /task-auto brackets its whole loop and each task inside
|
|
32
|
+
* it brackets its own run, so a plain flag would clear on the first inner end
|
|
33
|
+
* and leave the rest of the loop looking idle. */
|
|
34
|
+
let runDepth = 0;
|
|
35
|
+
/** Fired whenever the held list changes, so the surfaces can re-render. */
|
|
36
|
+
let onChange = null;
|
|
37
|
+
export function setHeldInputListener(fn) {
|
|
38
|
+
onChange = fn;
|
|
39
|
+
}
|
|
40
|
+
/** Mark a task run as owning the session (refcounted; call from a finally). */
|
|
41
|
+
export function beginRun() {
|
|
42
|
+
runDepth++;
|
|
43
|
+
if (runDepth === 1)
|
|
44
|
+
onChange?.(); // surfaces switch to "held" mode
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* End a run. Returns anything still held, which the caller MUST report: that
|
|
48
|
+
* text missed its delivery window, and replaying it into a finished run is the
|
|
49
|
+
* exact surprise this module exists to remove — but dropping it silently would
|
|
50
|
+
* be the same trap the terminal queue was.
|
|
51
|
+
*/
|
|
52
|
+
export function endRun() {
|
|
53
|
+
runDepth = Math.max(0, runDepth - 1);
|
|
54
|
+
if (runDepth > 0)
|
|
55
|
+
return [];
|
|
56
|
+
const dropped = held;
|
|
57
|
+
held = [];
|
|
58
|
+
onChange?.();
|
|
59
|
+
return dropped;
|
|
60
|
+
}
|
|
61
|
+
export function isRunActive() {
|
|
62
|
+
return runDepth > 0;
|
|
63
|
+
}
|
|
64
|
+
/** For tests: forget all state. */
|
|
65
|
+
export function resetMidRunInput() {
|
|
66
|
+
held = [];
|
|
67
|
+
runDepth = 0;
|
|
68
|
+
onChange = null;
|
|
69
|
+
}
|
|
70
|
+
/** Hold a line for the next task turn. Blank lines are ignored. */
|
|
71
|
+
export function holdInput(text) {
|
|
72
|
+
const trimmed = text.trim();
|
|
73
|
+
if (trimmed.length === 0)
|
|
74
|
+
return;
|
|
75
|
+
held.push(trimmed);
|
|
76
|
+
onChange?.();
|
|
77
|
+
}
|
|
78
|
+
/** The lines currently waiting (a copy — mutating it must not affect state). */
|
|
79
|
+
export function heldInput() {
|
|
80
|
+
return [...held];
|
|
81
|
+
}
|
|
82
|
+
export function clearHeldInput() {
|
|
83
|
+
if (held.length === 0)
|
|
84
|
+
return;
|
|
85
|
+
held = [];
|
|
86
|
+
onChange?.();
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Take everything held, as one steer payload, and clear it. Returns null when
|
|
90
|
+
* nothing is waiting. Clearing BEFORE delivery is deliberate: a delivery that
|
|
91
|
+
* throws must not leave the same text queued to be delivered again on the next
|
|
92
|
+
* turn.
|
|
93
|
+
*/
|
|
94
|
+
export function takeHeldInput() {
|
|
95
|
+
if (held.length === 0)
|
|
96
|
+
return null;
|
|
97
|
+
const payload = held.join('\n\n');
|
|
98
|
+
held = [];
|
|
99
|
+
onChange?.();
|
|
100
|
+
return payload;
|
|
101
|
+
}
|
|
@@ -36,7 +36,9 @@ import { findDeliveryPhantoms, formatApiOverrideBanner } from '../workers/phanto
|
|
|
36
36
|
import { titleForDisplay } from './parsers.js';
|
|
37
37
|
import { USER_CANCELLED } from './child-runner.js';
|
|
38
38
|
import { cancelCheckpoint } from './cancel-points.js';
|
|
39
|
-
import { rearmCancelListener } from './cancel-input.js';
|
|
39
|
+
import { armCancelListener, disarmCancelListener, rearmCancelListener } from './cancel-input.js';
|
|
40
|
+
import { beginRun, endRun, takeHeldInput } from './mid-run-input.js';
|
|
41
|
+
import { reportDroppedInput } from './dropped-input.js';
|
|
40
42
|
import { formatTimings } from './timings.js';
|
|
41
43
|
import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
|
|
42
44
|
// ─── Module-level state ──────────────────────────────────────────────────────
|
|
@@ -151,6 +153,10 @@ export class TaskRunner {
|
|
|
151
153
|
async run() {
|
|
152
154
|
const cwd = this._cwd;
|
|
153
155
|
const ctx = this._ctx;
|
|
156
|
+
// Mid-run input holds instead of starting a competing turn from here on,
|
|
157
|
+
// and the terminal interception is armed for the same window.
|
|
158
|
+
beginRun();
|
|
159
|
+
armCancelListener(ctx);
|
|
154
160
|
// Initialise or resume the TASK file.
|
|
155
161
|
let id;
|
|
156
162
|
let title;
|
|
@@ -280,6 +286,8 @@ export class TaskRunner {
|
|
|
280
286
|
finally {
|
|
281
287
|
this._disposeWidget();
|
|
282
288
|
clearActiveTask(this);
|
|
289
|
+
disarmCancelListener();
|
|
290
|
+
reportDroppedInput(endRun(), ctx);
|
|
283
291
|
}
|
|
284
292
|
}
|
|
285
293
|
/** Stop the phase widget — clearing both the terminal and remote surfaces —
|
|
@@ -290,7 +298,7 @@ export class TaskRunner {
|
|
|
290
298
|
this._stopWidget?.();
|
|
291
299
|
this._stopWidget = null;
|
|
292
300
|
}
|
|
293
|
-
async _deliverSpec(
|
|
301
|
+
async _deliverSpec(_ctx) {
|
|
294
302
|
const spec = this._specForDelivery();
|
|
295
303
|
// Keep the rich status block alive across the implementation turn (the phase
|
|
296
304
|
// widget was disposed at handoff). Awaited (/task-auto) stays armed across all
|
|
@@ -316,12 +324,11 @@ export class TaskRunner {
|
|
|
316
324
|
throw new Error('extension not initialised (no ExtensionAPI captured)');
|
|
317
325
|
}
|
|
318
326
|
armImplWidget(meta, { oneShot: true });
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
}
|
|
327
|
+
// Always name a delivery mode. pi ignores it when the session is idle and
|
|
328
|
+
// uses it when something else is streaming — so this one call is correct
|
|
329
|
+
// in both cases, where an isIdle() check is a check-then-act race that
|
|
330
|
+
// loses to any turn starting in between (issue #8).
|
|
331
|
+
piApi.sendUserMessage(spec, { deliverAs: 'followUp' });
|
|
325
332
|
}
|
|
326
333
|
/**
|
|
327
334
|
* The spec as the implementer should receive it (Layer B). Layer A strips phantom
|
|
@@ -472,7 +479,7 @@ export async function resumeAcrossCompactions(ctx) {
|
|
|
472
479
|
while (resumes < MAX_COMPACTION_RESUMES
|
|
473
480
|
&& !wasInterrupted(ctx)
|
|
474
481
|
&& endedAtCompactionBoundary(ctx)) {
|
|
475
|
-
await ctx.sendUserMessage(CONTINUE_AFTER_COMPACTION);
|
|
482
|
+
await ctx.sendUserMessage(CONTINUE_AFTER_COMPACTION, { deliverAs: 'followUp' });
|
|
476
483
|
await ctx.waitForIdle();
|
|
477
484
|
resumes++;
|
|
478
485
|
}
|
|
@@ -582,7 +589,7 @@ export async function steerUntilDone(ctx, promptSteer, watchdog) {
|
|
|
582
589
|
const steer = await ask(ctx);
|
|
583
590
|
if (steer === undefined || steer.trim().length === 0)
|
|
584
591
|
return true; // pause
|
|
585
|
-
await ctx.sendUserMessage(steer);
|
|
592
|
+
await ctx.sendUserMessage(steer, { deliverAs: 'followUp' });
|
|
586
593
|
await ctx.waitForIdle();
|
|
587
594
|
}
|
|
588
595
|
return false;
|
|
@@ -615,7 +622,8 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
|
|
|
615
622
|
// /task-auto-cancel has to survive. No-op unless a run armed one.
|
|
616
623
|
rearmCancelListener(newCtx);
|
|
617
624
|
const runner = new TaskRunner(newCtx, cwd, rawPrompt, opts.resumeId, async (spec) => {
|
|
618
|
-
|
|
625
|
+
// Queue-or-run: never throws, whatever else is on the session (issue #8).
|
|
626
|
+
await newCtx.sendUserMessage(spec, { deliverAs: 'followUp' });
|
|
619
627
|
if (opts.waitForImplementation) {
|
|
620
628
|
await newCtx.waitForIdle();
|
|
621
629
|
// A threshold auto-compaction parks the turn at idle WITHOUT
|
|
@@ -709,6 +717,26 @@ export async function markResumable(cwd, taskId) {
|
|
|
709
717
|
* paused/failed) leaves the task resumable and tells the user to /task-resume.
|
|
710
718
|
*/
|
|
711
719
|
export async function runGatedTask(ctx, cwd, raw, opts = {}) {
|
|
720
|
+
// The GATES are part of the run, and they are child processes with the host
|
|
721
|
+
// session idle — the same hold window as the spec phases. Bracketing only
|
|
722
|
+
// TaskRunner would leave verify/enforce looking like "no run", which a live
|
|
723
|
+
// run on pi 0.82.1 showed as runActive=false while the widget still read
|
|
724
|
+
// "verifying work" (issue #8). The body has many early returns, so the
|
|
725
|
+
// bracket lives in this wrapper rather than in a dozen places.
|
|
726
|
+
beginRun();
|
|
727
|
+
// Arm the raw-stdin interception for the WHOLE run. Without this a plain
|
|
728
|
+
// /task had no terminal path at all — only /task-auto armed one — so a line
|
|
729
|
+
// typed during it went into pi's queue and fired after the run (seen live).
|
|
730
|
+
armCancelListener(ctx);
|
|
731
|
+
try {
|
|
732
|
+
await runGatedTaskInner(ctx, cwd, raw, opts);
|
|
733
|
+
}
|
|
734
|
+
finally {
|
|
735
|
+
disarmCancelListener();
|
|
736
|
+
reportDroppedInput(endRun(), ctx);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
async function runGatedTaskInner(ctx, cwd, raw, opts = {}) {
|
|
712
740
|
const abort = new AbortController();
|
|
713
741
|
const deps = opts.deps
|
|
714
742
|
?? buildGateDeps({
|
|
@@ -912,6 +940,22 @@ async function handleTaskCancel(_args, ctx) {
|
|
|
912
940
|
export function registerTask(pi) {
|
|
913
941
|
piApi = pi;
|
|
914
942
|
setupImplWidget(pi);
|
|
943
|
+
// Deliver whatever the user typed while the run held the session, at the
|
|
944
|
+
// first moment there is a live turn to steer. agent_start fires as streaming
|
|
945
|
+
// begins, so `steer` is accepted here; when nothing is held this is a no-op,
|
|
946
|
+
// which is every turn outside a run.
|
|
947
|
+
pi.on('agent_start', () => {
|
|
948
|
+
const held = takeHeldInput();
|
|
949
|
+
if (held === null)
|
|
950
|
+
return;
|
|
951
|
+
try {
|
|
952
|
+
pi.sendUserMessage(held, { deliverAs: 'steer' });
|
|
953
|
+
publishNotify('Delivered your message to the running task.', 'info');
|
|
954
|
+
}
|
|
955
|
+
catch (err) {
|
|
956
|
+
publishNotify(`Could not deliver your message: ${err.message}`, 'warning');
|
|
957
|
+
}
|
|
958
|
+
});
|
|
915
959
|
registerBridgeCommand(pi, 'task', {
|
|
916
960
|
description: 'Start a new task. Usage: /task <prompt>',
|
|
917
961
|
handler: handleTask
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|