dsh-ntr 0.1.6 → 0.2.1

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.
@@ -0,0 +1,72 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Pending ACP permission asks for the staged DSH conversation, rendered as
4
+ * composer-width entries above the composer card (`conversation.input.dock`).
5
+ * The slot itself spans the conversation column; the dock mirrors the host's
6
+ * InputBar width formula so the cards line up with the input box.
7
+ *
8
+ * The host broadcasts every ask as `permission/request`, carrying the DSH chat
9
+ * session id when the ask belongs to a conversation. This dock is the primary
10
+ * answer surface: it renders exactly the asks whose `dshSessionId` is the
11
+ * conversation it was mounted for, and nothing otherwise. There is no
12
+ * ACP-mode gate — the frame set itself is the gate, so an ordinary
13
+ * conversation shows this card if and only if it actually has an ask.
14
+ *
15
+ * The ask's description and options are the agent's own: buttons appear in the
16
+ * agent's order with the agent's names (including "Always Allow"), and
17
+ * `kind` is not rendered. A separate cancel button answers with no option,
18
+ * which the host forwards as `cancelled`. No Enter/Escape handlers: the
19
+ * composer owns its keyboard, and these are ordinary focusable buttons.
20
+ *
21
+ * Multiple asks stack in ARRIVAL order, oldest first: the question ignored
22
+ * longest stays at the top. The list is capped in height by CSS and scrolls.
23
+ * Asks without a chat identity never appear here — they stay in the
24
+ * settings-panel ledger, the cross-session mirror.
25
+ */
26
+ import { useEffect, useState } from 'react';
27
+ import { ensureAcpStyle, useUi } from "./acp-ui.js";
28
+ import { deadlineRemainingLabel, PERMISSION_DEADLINE_EXPIRED, permissionsForSession, } from "./store.js";
29
+ /** Tick interval for the countdown (seconds are all the label shows). */
30
+ const COUNTDOWN_TICK_MS = 1000;
31
+ export function PermissionDock({ acp, sessionId }) {
32
+ ensureAcpStyle();
33
+ const ui = useUi(acp);
34
+ const dshSessionId = String(sessionId);
35
+ const pending = permissionsForSession(ui.permissions, dshSessionId);
36
+ const counting = pending.some(ask => ask.timeoutMode !== 'wait' && ask.deadlineAt !== undefined);
37
+ const [now, setNow] = useState(() => Date.now());
38
+ useEffect(() => {
39
+ if (!counting)
40
+ return;
41
+ setNow(Date.now());
42
+ const timer = setInterval(() => { setNow(Date.now()); }, COUNTDOWN_TICK_MS);
43
+ return () => { clearInterval(timer); };
44
+ }, [counting]);
45
+ if (pending.length === 0)
46
+ return null;
47
+ return (_jsx("div", { className: "ntr-permission-dock", role: "region", "aria-label": acp.label('待处理的权限请求', 'Pending permission requests'), children: pending.map(permission => (_jsx(PermissionAskCard, { acp: acp, permission: permission, now: now }, permission.requestId))) }));
48
+ }
49
+ function PermissionAskCard(props) {
50
+ const { acp, permission, now } = props;
51
+ const [busy, setBusy] = useState(false);
52
+ const [error, setError] = useState();
53
+ const remaining = deadlineRemainingLabel(permission.deadlineAt, now, permission.timeoutMode);
54
+ const expired = remaining === PERMISSION_DEADLINE_EXPIRED;
55
+ const respond = (optionId) => {
56
+ if (busy)
57
+ return;
58
+ setBusy(true);
59
+ setError(undefined);
60
+ void acp.respondPermission(permission.requestId, optionId)
61
+ // `respondPermission` reports to the store's notice too, but that notice
62
+ // lives on the settings page; this card pins the failure where the user
63
+ // is looking.
64
+ .catch((failure) => {
65
+ setError(failure instanceof Error ? failure.message : String(failure));
66
+ })
67
+ .finally(() => { setBusy(false); });
68
+ };
69
+ return (_jsxs("div", { className: "ntr-permission-card", children: [_jsxs("div", { className: "ntr-permission-head", children: [_jsx("span", { className: "ntr-permission-tool", children: permission.toolName }), permission.reason !== undefined && (_jsx("span", { className: "ntr-permission-reason", title: permission.reason, children: permission.reason })), remaining !== undefined && (_jsx("span", { className: `ntr-permission-deadline${expired ? ' is-expired' : ''}`, children: expired
70
+ ? acp.label('已超时', 'expired')
71
+ : acp.label(`剩余 ${remaining}`, `${remaining} left`) }))] }), _jsxs("div", { className: "ntr-permission-actions", children: [permission.options.map(option => (_jsx("button", { type: "button", className: "ntr-permission-option", disabled: busy, title: option.name, onClick: () => { respond(option.optionId); }, children: option.name }, option.optionId))), _jsx("button", { type: "button", className: "ntr-permission-cancel", disabled: busy, title: acp.label('取消这次询问(不选择任何选项)', 'Cancel this ask without choosing an option'), onClick: () => { respond(); }, children: acp.label('取消', 'Cancel') })] }), error !== undefined && (_jsx("div", { className: "ntr-permission-error", role: "alert", children: error }))] }));
72
+ }
@@ -61,10 +61,10 @@ import { useEffect, useState } from 'react';
61
61
  import { AcpChip, AcpMenu, AcpShatterParts, buildAcpModelId, closeAcpMenu, ensureAcpStyle, openAcpMenu, openAcpSettingsDialog, setAcpMenuError, splitAcpModelId, useAcpMenu, useUi, } from "./acp-ui.js";
62
62
  import { playAgentLockAnim, useAgentLockPhase } from "./agent-lock-anim.js";
63
63
  const STATE_COLORS = {
64
- ready: '#22c55e',
65
- starting: '#eab308',
66
- error: '#ef4444',
67
- stopped: '#9ca3af',
64
+ ready: 'var(--dsw-alias-state-success-primary, #22c55e)',
65
+ starting: 'var(--dsw-alias-state-warn-primary, #eab308)',
66
+ error: 'var(--dsw-alias-state-error-primary, #ef4444)',
67
+ stopped: 'var(--dsw-alias-state-idle-primary, #9ca3af)',
68
68
  };
69
69
  function agentStateText(acp, state) {
70
70
  switch (state) {
@@ -6,6 +6,39 @@
6
6
  * Deliberately framework-free: React binds through `useSyncExternalStore`.
7
7
  */
8
8
  import { DEFAULT_TAG_HALO } from "../protocol.js";
9
+ /** Returned by {@link deadlineRemainingLabel} once the deadline has passed. */
10
+ export const PERMISSION_DEADLINE_EXPIRED = 'expired';
11
+ /**
12
+ * The asks that belong to one DSH chat conversation. An ask without a
13
+ * `dshSessionId` belongs to no conversation (an `/acp` RPC-only session), so it
14
+ * is deliberately absent from every conversation's dock and stays in the
15
+ * settings-panel ledger.
16
+ */
17
+ export function permissionsForSession(permissions, dshSessionId) {
18
+ return permissions.filter(permission => permission.dshSessionId === dshSessionId);
19
+ }
20
+ /**
21
+ * Compact countdown for one ask, or `undefined` when there is nothing to count
22
+ * down (`wait` mode, or a frame without a deadline). Pure so the formatting is
23
+ * testable without a clock: the dock passes `Date.now()` on its tick.
24
+ *
25
+ * Past the deadline the answer is {@link PERMISSION_DEADLINE_EXPIRED} rather
26
+ * than a negative number — the host has already armed the expiry, and a card
27
+ * counting backwards would claim a deadline that no longer exists.
28
+ */
29
+ export function deadlineRemainingLabel(deadlineAt, now, mode) {
30
+ if (mode === 'wait' || deadlineAt === undefined)
31
+ return undefined;
32
+ const remainingMs = deadlineAt - now;
33
+ if (remainingMs <= 0)
34
+ return PERMISSION_DEADLINE_EXPIRED;
35
+ const seconds = Math.ceil(remainingMs / 1000);
36
+ if (seconds < 60)
37
+ return `${String(seconds)}s`;
38
+ const minutes = Math.floor(seconds / 60);
39
+ const rest = seconds % 60;
40
+ return rest === 0 ? `${String(minutes)}m` : `${String(minutes)}m ${String(rest)}s`;
41
+ }
9
42
  const MAX_BLOCKS = 400;
10
43
  /** How many observed ACP transcripts stay live at once (insertion-ordered eviction). */
11
44
  const MAX_WATCHED = 6;
@@ -298,7 +331,6 @@ export class AcpStore {
298
331
  seatCatalogs: {},
299
332
  probingModels: {},
300
333
  probeErrors: {},
301
- approvalRouted: false,
302
334
  chatSessions: [],
303
335
  tagHalo: { ...DEFAULT_TAG_HALO },
304
336
  approvalDeadlines: undefined,
@@ -501,7 +533,7 @@ export class AcpStore {
501
533
  /** Snapshot refresh used on panel open and after a stream outage. */
502
534
  async resync() {
503
535
  try {
504
- const [{ agents, approvalRouted }, { sessions }, defaultAgentInfo, detectResult, presetResult, tagHaloResult, deadlinesResult] = await Promise.all([
536
+ const [{ agents }, { sessions }, defaultAgentInfo, detectResult, presetResult, tagHaloResult, deadlinesResult] = await Promise.all([
505
537
  this.call('agents/list'),
506
538
  this.call('sessions/list'),
507
539
  // The plugin's OWN default ACP agent — a plugin-private id, not a DSH
@@ -526,7 +558,6 @@ export class AcpStore {
526
558
  sessions,
527
559
  defaultAgent: defaultAgentInfo?.agentId,
528
560
  detectedAgents,
529
- approvalRouted: approvalRouted === true,
530
561
  preset: presetResult?.preset ?? undefined,
531
562
  tagHalo: tagHaloResult?.halo ?? { ...DEFAULT_TAG_HALO },
532
563
  approvalDeadlines: deadlinesResult.deadlines,
@@ -1244,12 +1275,21 @@ export class AcpStore {
1244
1275
  this.flashError(error);
1245
1276
  }
1246
1277
  }
1278
+ /**
1279
+ * Answer one ask (`optionId` absent = cancel it). A failure is reported
1280
+ * through the store's notice — the settings panel renders that — AND
1281
+ * rethrown, so the conversation dock can pin the message next to the card
1282
+ * that failed instead of leaving it on a settings page the user may not
1283
+ * have open. Callers that cannot show it (the settings panel) swallow it
1284
+ * after the notice.
1285
+ */
1247
1286
  async respondPermission(requestId, optionId) {
1248
1287
  try {
1249
1288
  await this.call('permission/respond', { requestId, ...(optionId === undefined ? {} : { optionId }) });
1250
1289
  }
1251
1290
  catch (error) {
1252
1291
  this.flashError(error);
1292
+ throw error;
1253
1293
  }
1254
1294
  }
1255
1295
  async stopAgent(agentId) {
@@ -1601,16 +1641,22 @@ export class AcpStore {
1601
1641
  return this.loadAgentCatalog(event.agentId);
1602
1642
  }
1603
1643
  case 'permission/request': {
1604
- const title = typeof event.toolCall?.title === 'string'
1605
- ? String(event.toolCall.title)
1606
- : this.locale('工具调用', 'tool call');
1644
+ // Verbatim copy of the host's frame. The host decides what the ask is
1645
+ // called (`toolName` / `reason`) and which options exist; deriving a
1646
+ // title from `toolCall` here used to make the card say something the
1647
+ // host never said.
1648
+ const permission = {
1649
+ requestId: event.requestId,
1650
+ sessionId: event.sessionId,
1651
+ ...(event.dshSessionId === undefined ? {} : { dshSessionId: event.dshSessionId }),
1652
+ toolName: event.toolName,
1653
+ ...(event.reason === undefined ? {} : { reason: event.reason }),
1654
+ options: event.options,
1655
+ timeoutMode: event.timeoutMode,
1656
+ ...(event.deadlineAt === undefined ? {} : { deadlineAt: event.deadlineAt }),
1657
+ };
1607
1658
  this.mutate({
1608
- permissions: [...this.state.permissions.filter(p => p.requestId !== event.requestId), {
1609
- requestId: event.requestId,
1610
- sessionId: event.sessionId,
1611
- title,
1612
- options: event.options,
1613
- }],
1659
+ permissions: [...this.state.permissions.filter(p => p.requestId !== event.requestId), permission],
1614
1660
  });
1615
1661
  return Promise.resolve();
1616
1662
  }