gentle-pi 2.4.0 → 2.6.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/README.md +292 -25
- package/assets/agents/gentle-ai-worker.md +13 -0
- package/assets/agents/jd-fix-agent.md +18 -0
- package/assets/agents/jd-judge-a.md +1 -1
- package/assets/agents/jd-judge-b.md +1 -1
- package/assets/agents/sdd-apply.md +7 -5
- package/assets/agents/sdd-archive.md +5 -3
- package/assets/agents/sdd-design.md +4 -0
- package/assets/agents/sdd-explore.md +4 -0
- package/assets/agents/sdd-init.md +4 -0
- package/assets/agents/sdd-onboard.md +4 -0
- package/assets/agents/sdd-proposal.md +4 -0
- package/assets/agents/sdd-remediate.md +37 -0
- package/assets/agents/sdd-research.md +26 -3
- package/assets/agents/sdd-spec.md +4 -0
- package/assets/agents/sdd-status.md +9 -75
- package/assets/agents/sdd-sync.md +4 -0
- package/assets/agents/sdd-tasks.md +4 -0
- package/assets/agents/sdd-verify.md +5 -3
- package/assets/chains/sdd-full.chain.md +4 -0
- package/assets/chains/sdd-plan.chain.md +4 -0
- package/assets/chains/sdd-verify.chain.md +4 -0
- package/assets/migrations/managed-assets-v2.5.0.json +7 -0
- package/assets/orchestrator-delegation.md +39 -11
- package/assets/orchestrator.md +5 -5
- package/assets/sdd-orchestrator-workflow.md +54 -21
- package/assets/support/sdd-status-contract.md +34 -90
- package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
- package/docs/delegated-verification.md +25 -0
- package/docs/telemetry.md +94 -0
- package/docs/windows-startup-console-visibility.md +18 -0
- package/extensions/ask-user-choice.ts +159 -25
- package/extensions/codegraph-tools.ts +95 -5
- package/extensions/gentle-agents.ts +1337 -0
- package/extensions/gentle-ai.ts +2916 -386
- package/extensions/gentle-shell.ts +650 -0
- package/extensions/gentle-todo.ts +234 -0
- package/extensions/quiet-tools.ts +2 -1
- package/extensions/runtime-metrics.ts +130 -0
- package/extensions/sdd-init.ts +2 -2
- package/extensions/startup-banner.ts +52 -75
- package/lib/agent-profiles.ts +550 -0
- package/lib/agents-completion-delivery.ts +72 -0
- package/lib/agents-config.ts +315 -0
- package/lib/agents-history.ts +88 -0
- package/lib/agents-messaging.ts +187 -0
- package/lib/agents-protocol.ts +501 -0
- package/lib/agents-runner.ts +1012 -0
- package/lib/agents-thread-view.ts +57 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view-layout.ts +40 -0
- package/lib/agents-view.ts +914 -0
- package/lib/agents-widget.ts +241 -0
- package/lib/gentle-ai-binary.ts +3 -1
- package/lib/gentle-ai-renderer.ts +143 -25
- package/lib/native-choice-list.ts +194 -0
- package/lib/native-fullscreen-interaction.ts +47 -0
- package/lib/native-pointer-region.ts +164 -0
- package/lib/native-review-cli.ts +371 -13
- package/lib/orchestrator-presence.ts +337 -0
- package/lib/profiles-orchestrator.ts +203 -0
- package/lib/review-candidate-view-owner.ts +427 -0
- package/lib/review-candidate-view.ts +150 -48
- package/lib/review-consent-component.ts +247 -0
- package/lib/review-consent-ui.ts +110 -0
- package/lib/review-host-relay.ts +28 -0
- package/lib/review-integration-v2.ts +243 -11
- package/lib/review-last-event-controller.ts +8 -4
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-reminder-receipt.ts +74 -0
- package/lib/review-repository.ts +2 -2
- package/lib/review-risk-assessment.ts +339 -0
- package/lib/review-session-standing-permission-ipc.ts +309 -0
- package/lib/review-session-standing-permission.ts +240 -0
- package/lib/runtime-metrics-children.ts +199 -0
- package/lib/runtime-metrics-delivery.ts +68 -0
- package/lib/runtime-metrics-native.ts +166 -0
- package/lib/runtime-metrics-pi-identity.ts +113 -0
- package/lib/runtime-metrics-policy.ts +51 -0
- package/lib/runtime-metrics.ts +255 -0
- package/lib/sdd-preflight.ts +362 -81
- package/lib/sdd-research-capabilities.ts +228 -0
- package/lib/sdd-status.ts +29 -7
- package/lib/session-worktree-registry.ts +118 -0
- package/lib/shell-bar.ts +184 -0
- package/lib/shell-card.ts +133 -0
- package/lib/shell-changes-view.ts +530 -0
- package/lib/shell-changes.ts +290 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +115 -0
- package/lib/shell-sidebar-banner.ts +11 -0
- package/lib/shell-sidebar-layout.ts +213 -0
- package/lib/shell-sidebar.ts +41 -0
- package/lib/shell-todo.ts +297 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +153 -0
- package/package.json +8 -5
- package/runtime/gentle-ai-binary.mjs +3 -1
- package/runtime/native-review-cli.mjs +370 -12
- package/runtime/review-integration-v2.mjs +243 -11
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +154 -0
- package/scripts/build-runtime-modules.mjs +11 -1
- package/scripts/check-types.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +12 -0
- package/scripts/install-tui-mode-setting.mjs +114 -0
- package/scripts/test-packed-runner.mjs +38 -2
- package/scripts/types-baseline.json +99 -0
- package/scripts/verify-package-files.mjs +8 -2
- package/skills/_shared/review-ledger-contract.md +20 -2
- package/skills/issue-creation/SKILL.md +3 -3
- package/skills/judgment-day/SKILL.md +17 -3
- package/skills/judgment-day/references/prompts-and-formats.md +14 -3
- package/tests/agent-profiles.test.ts +722 -0
- package/tests/agents-completion-delivery.test.ts +94 -0
- package/tests/agents-config.test.ts +205 -0
- package/tests/agents-fake-child.ts +66 -0
- package/tests/agents-grouping.test.ts +179 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-integration.test.ts +100 -0
- package/tests/agents-messaging.test.ts +94 -0
- package/tests/agents-protocol.test.ts +198 -0
- package/tests/agents-queries.test.ts +190 -0
- package/tests/agents-responsive.test.ts +43 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +959 -0
- package/tests/agents-thread-view.test.ts +45 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +685 -0
- package/tests/agents-widget.test.ts +141 -0
- package/tests/artifact-language.test.ts +25 -2
- package/tests/ask-user-choice.test.ts +325 -5
- package/tests/asset-installation-runtime.test.ts +108 -0
- package/tests/autonomous-guard.test.ts +116 -1
- package/tests/codegraph-tools.test.ts +112 -2
- package/tests/delegated-key-learnings-contract.test.ts +1 -1
- package/tests/devbinary/native-review-parity.devtest.ts +110 -0
- package/tests/feature-request-form.test.ts +67 -0
- package/tests/fixtures/agents-messaging-child.mjs +5 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
- package/tests/gentle-agents.test.ts +2168 -0
- package/tests/gentle-ai-binary.test.ts +7 -2
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +103 -0
- package/tests/gentle-ai.test.ts +971 -15
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +818 -0
- package/tests/gentle-todo.test.ts +226 -0
- package/tests/install-tui-mode-setting.test.ts +324 -0
- package/tests/issue-creation-skill.test.ts +22 -0
- package/tests/model-routing-authority.test.ts +12 -0
- package/tests/native-choice-list.test.ts +202 -0
- package/tests/native-fullscreen-interaction.test.ts +125 -0
- package/tests/native-pointer-region.test.ts +245 -0
- package/tests/native-review-capability-contract.test.ts +27 -1
- package/tests/native-review-cli.test.ts +317 -3
- package/tests/native-review-consent.test.ts +91 -0
- package/tests/native-review-parity-runtime.test.ts +8 -2
- package/tests/native-review-parity.test.ts +43 -29
- package/tests/native-sdd-attempt-authority.test.ts +7 -2
- package/tests/orchestrator-budget.test.ts +69 -0
- package/tests/orchestrator-presence.test.ts +389 -0
- package/tests/orchestrator-rdd-ownership.test.ts +9 -0
- package/tests/package-manifest.test.ts +243 -7
- package/tests/profiles-orchestrator.test.ts +208 -0
- package/tests/quiet-tool-rendering.test.ts +97 -37
- package/tests/rdd-aware-verification-contract.test.ts +226 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-agent-end-preflight.test.ts +332 -24
- package/tests/review-candidate-view.test.ts +751 -7
- package/tests/review-consent-ui.test.ts +352 -0
- package/tests/review-contract-prompt.test.ts +17 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +884 -7
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +26 -1
- package/tests/review-host-relay-restart-parity.test.ts +142 -1
- package/tests/review-host-relay-routing.test.ts +384 -8
- package/tests/review-host-relay.test.ts +29 -0
- package/tests/review-integration-v2-forward.test.ts +44 -0
- package/tests/review-integration-v2.test.ts +276 -0
- package/tests/review-last-event-closure.test.ts +112 -3
- package/tests/review-ledger-contract.test.ts +61 -6
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +62 -0
- package/tests/review-repository.test.ts +28 -1
- package/tests/review-risk-assessment.test.ts +626 -0
- package/tests/review-session-standing-permission-controller.test.ts +656 -0
- package/tests/review-session-standing-permission-ipc.test.ts +233 -0
- package/tests/review-session-standing-permission-runtime.test.ts +212 -0
- package/tests/review-session-standing-permission.test.ts +156 -0
- package/tests/runtime-harness.mjs +447 -39
- package/tests/runtime-metrics-children.test.ts +206 -0
- package/tests/runtime-metrics-delivery.test.ts +85 -0
- package/tests/runtime-metrics-extension.test.ts +187 -0
- package/tests/runtime-metrics-native.test.ts +209 -0
- package/tests/runtime-metrics-pi-identity.test.ts +113 -0
- package/tests/runtime-metrics-policy.test.ts +62 -0
- package/tests/runtime-metrics.test.ts +184 -0
- package/tests/sdd-agent-tools.test.ts +10 -1
- package/tests/sdd-execution-routing-contract.test.ts +28 -0
- package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
- package/tests/sdd-native-managed-uptake.test.ts +253 -0
- package/tests/sdd-planning-routing-contract.test.ts +45 -0
- package/tests/sdd-preflight.test.ts +252 -8
- package/tests/sdd-research-capabilities.test.ts +256 -0
- package/tests/sdd-research-live.test.ts +241 -0
- package/tests/sdd-selection-transport.test.ts +504 -0
- package/tests/sdd-status.test.ts +51 -0
- package/tests/session-worktree-registry.test.ts +135 -0
- package/tests/shell-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +139 -0
- package/tests/shell-changes-view.test.ts +609 -0
- package/tests/shell-changes.test.ts +350 -0
- package/tests/shell-prompt.test.ts +140 -0
- package/tests/shell-sidebar-banner.test.ts +23 -0
- package/tests/shell-sidebar-layout.test.ts +387 -0
- package/tests/shell-sidebar.test.ts +50 -0
- package/tests/shell-todo.test.ts +259 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/startup-banner.test.ts +126 -0
- package/tests/telemetry-trigger.test.ts +351 -0
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
2
|
+
import { measureAgentsViewLayout, type AgentsViewLayout } from "./agents-view-layout.ts";
|
|
3
|
+
import { emptyThread, isFinished, TASK_STATUS, type TaskRecord, type TaskStore, type TaskThread, type ThreadItem } from "./agents-protocol.ts";
|
|
4
|
+
import { renderThreadItem, type AgentsThreadTheme } from "./agents-thread-view.ts";
|
|
5
|
+
import { formatElapsed } from "./agents-widget.ts";
|
|
6
|
+
import { createNativePointerScope, type NativePointerRegion } from "./native-pointer-region.ts";
|
|
7
|
+
import { formatTokens } from "./shell-bar.ts";
|
|
8
|
+
import { PresenceCursor, readActivity, type Header, type Target } from "./orchestrator-presence.ts";
|
|
9
|
+
|
|
10
|
+
// Gentle Agents overlay: tasks on the left, the selected task's thread on
|
|
11
|
+
// the right. Only the selected task is subscribed, thread items are rendered
|
|
12
|
+
// once each (they are immutable until replaced), and the viewport shows the
|
|
13
|
+
// tail unless the human scrolls up. Current scope contains direct live children;
|
|
14
|
+
// all sessions is a presence directory, not a history browser. Remote activity
|
|
15
|
+
// is presentation-only and never enters the local TaskStore.
|
|
16
|
+
|
|
17
|
+
export interface AgentsViewTheme extends AgentsThreadTheme {}
|
|
18
|
+
|
|
19
|
+
export const VIEW_SCOPE = {
|
|
20
|
+
SESSION: "session",
|
|
21
|
+
ALL: "all",
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
export type ViewScope = (typeof VIEW_SCOPE)[keyof typeof VIEW_SCOPE];
|
|
25
|
+
|
|
26
|
+
export interface AgentsViewDeps {
|
|
27
|
+
theme: AgentsViewTheme;
|
|
28
|
+
rows: number | (() => number);
|
|
29
|
+
store: TaskStore;
|
|
30
|
+
sessionId?: string;
|
|
31
|
+
presence?: { profile: string; target?: Readonly<Target> };
|
|
32
|
+
now(): number;
|
|
33
|
+
onCancel(task: TaskRecord): void;
|
|
34
|
+
canCancel?(task: TaskRecord): boolean;
|
|
35
|
+
isLocalTask?(task: TaskRecord): boolean;
|
|
36
|
+
onOpen(task: TaskRecord): void;
|
|
37
|
+
onClose(): void;
|
|
38
|
+
requestRender(): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ROLE = {
|
|
42
|
+
FRAME: "border",
|
|
43
|
+
TITLE: "customMessageLabel",
|
|
44
|
+
SELECTED: "accent",
|
|
45
|
+
HOVER: "warning",
|
|
46
|
+
NAME: "text",
|
|
47
|
+
NAME_IDLE: "muted",
|
|
48
|
+
META: "dim",
|
|
49
|
+
KEY: "accent",
|
|
50
|
+
KEY_TEXT: "dim",
|
|
51
|
+
EMPTY: "dim",
|
|
52
|
+
} as const;
|
|
53
|
+
|
|
54
|
+
const GLYPH: Record<string, string> = {
|
|
55
|
+
[TASK_STATUS.QUEUED]: "○",
|
|
56
|
+
[TASK_STATUS.RUNNING]: "◐",
|
|
57
|
+
[TASK_STATUS.WAITING]: "?",
|
|
58
|
+
[TASK_STATUS.COMPLETED]: "✓",
|
|
59
|
+
[TASK_STATUS.FAILED]: "✗",
|
|
60
|
+
[TASK_STATUS.CANCELLED]: "–",
|
|
61
|
+
[TASK_STATUS.TIMED_OUT]: "✗",
|
|
62
|
+
};
|
|
63
|
+
const GLYPH_ROLE: Record<string, string> = {
|
|
64
|
+
[TASK_STATUS.QUEUED]: "muted",
|
|
65
|
+
[TASK_STATUS.RUNNING]: "accent",
|
|
66
|
+
[TASK_STATUS.WAITING]: "warning",
|
|
67
|
+
[TASK_STATUS.COMPLETED]: "success",
|
|
68
|
+
[TASK_STATUS.FAILED]: "error",
|
|
69
|
+
[TASK_STATUS.CANCELLED]: "dim",
|
|
70
|
+
[TASK_STATUS.TIMED_OUT]: "error",
|
|
71
|
+
};
|
|
72
|
+
export const SESSION_FINISHED_TTL_MS = 15 * 60_000;
|
|
73
|
+
const SCOPE_LABEL: Record<ViewScope, string> = { [VIEW_SCOPE.SESSION]: "this session", [VIEW_SCOPE.ALL]: "all sessions" };
|
|
74
|
+
const SCOPE_KEY: Record<ViewScope, string> = { [VIEW_SCOPE.SESSION]: "all sessions", [VIEW_SCOPE.ALL]: "this session" };
|
|
75
|
+
const EMPTY_LIST = "no tasks yet";
|
|
76
|
+
const EMPTY_THREAD = "waiting for the first event";
|
|
77
|
+
const FOLLOW_BUTTON = "[ Follow ]";
|
|
78
|
+
const OPEN_BUTTON = "[ Open session ]";
|
|
79
|
+
const CLOSE_BUTTON = "[× Close]";
|
|
80
|
+
// At the 60-cell split boundary these four controls fit before any hints.
|
|
81
|
+
const STOP_BUTTON = "[Stop]";
|
|
82
|
+
const SCOPE_BUTTON = "[Scope]";
|
|
83
|
+
const KEYS = [
|
|
84
|
+
["j/k", "task"],
|
|
85
|
+
["ctrl+j/k", "scroll"],
|
|
86
|
+
["f", "follow"],
|
|
87
|
+
["o", "open session"],
|
|
88
|
+
["esc", "back"],
|
|
89
|
+
["q", "close"],
|
|
90
|
+
] as const;
|
|
91
|
+
|
|
92
|
+
const EMPTY_COMPONENT: Component = {
|
|
93
|
+
render: () => [],
|
|
94
|
+
invalidate() {},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
interface PointerButtonLayout {
|
|
98
|
+
x: number;
|
|
99
|
+
width: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface PointerLayout extends AgentsViewLayout {
|
|
103
|
+
narrowView: "list" | "details";
|
|
104
|
+
sourceHeight?: number;
|
|
105
|
+
closeButton?: PointerButtonLayout;
|
|
106
|
+
modeButton?: PointerButtonLayout;
|
|
107
|
+
actions?: Array<PointerButtonLayout & { region: NativePointerRegion }>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface SessionGroup {
|
|
111
|
+
id: string;
|
|
112
|
+
sessionId: string | undefined;
|
|
113
|
+
label?: string;
|
|
114
|
+
tasks: TaskRecord[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
type VisibleRow =
|
|
118
|
+
| { id: string; kind: "heading"; group: SessionGroup }
|
|
119
|
+
| { id: string; kind: "task"; group: SessionGroup; task: TaskRecord };
|
|
120
|
+
|
|
121
|
+
function rule(length: number): string {
|
|
122
|
+
return "─".repeat(Math.max(0, length));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function fit(text: string, width: number): string {
|
|
126
|
+
const clipped = truncateToWidth(text, width, "…");
|
|
127
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function taskHeader(task: TaskRecord, now: number): string {
|
|
131
|
+
const parts = [task.agent, task.status, task.model, task.tokens > 0 ? formatTokens(task.tokens) : "", task.cost > 0 ? `$${task.cost.toFixed(2)}` : "", task.startedAt === null ? "" : formatElapsed((task.endedAt ?? now) - task.startedAt)];
|
|
132
|
+
return parts.filter((part) => part.length > 0).join(" · ");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export class AgentsView {
|
|
136
|
+
private readonly deps: AgentsViewDeps;
|
|
137
|
+
private tasks: TaskRecord[] = [];
|
|
138
|
+
private selectedId: string | undefined;
|
|
139
|
+
private hoveredId: string | undefined;
|
|
140
|
+
private narrowView: "list" | "details" = "list";
|
|
141
|
+
private narrowGroup: string | undefined;
|
|
142
|
+
private groupCursor: string | undefined;
|
|
143
|
+
private footerPage = 0;
|
|
144
|
+
private readonly actionRegions = new Map<string, NativePointerRegion>();
|
|
145
|
+
private readonly expanded = new Map<string, boolean>();
|
|
146
|
+
private readonly observedActiveGroups = new Set<string>();
|
|
147
|
+
private listScroll = 0;
|
|
148
|
+
private readonly manualListOffsets = new Map<string, number>();
|
|
149
|
+
private scope: ViewScope;
|
|
150
|
+
private scroll = 0;
|
|
151
|
+
private follow = true;
|
|
152
|
+
private readonly pointerScope = createNativePointerScope();
|
|
153
|
+
private readonly taskRegions = new Map<number, NativePointerRegion>();
|
|
154
|
+
private readonly listRegion: NativePointerRegion;
|
|
155
|
+
private readonly threadRegion: NativePointerRegion;
|
|
156
|
+
private readonly followRegion: NativePointerRegion;
|
|
157
|
+
private readonly openRegion: NativePointerRegion;
|
|
158
|
+
private readonly closeRegion: NativePointerRegion;
|
|
159
|
+
private readonly modeRegion: NativePointerRegion;
|
|
160
|
+
private hoveredControl: "follow" | "open" | "close" | "mode" | undefined;
|
|
161
|
+
private pointerLayout: PointerLayout | undefined;
|
|
162
|
+
// Keyboard input has no terminal width, so retain its last rendered width
|
|
163
|
+
// independently from disposable pointer hit geometry.
|
|
164
|
+
private lastRenderedWidth: number | undefined;
|
|
165
|
+
private closed = false;
|
|
166
|
+
private unsubscribeTask: (() => void) | undefined;
|
|
167
|
+
private subscribedTaskId: string | undefined;
|
|
168
|
+
private readonly unsubscribeSummary: () => void;
|
|
169
|
+
private cache = new WeakMap<ThreadItem, string[]>();
|
|
170
|
+
private cacheWidth = -1;
|
|
171
|
+
private peers: SessionGroup[] = [];
|
|
172
|
+
private remoteThreads = new Map<string, TaskThread>();
|
|
173
|
+
private presenceCursor?: PresenceCursor;
|
|
174
|
+
private presenceTimer?: ReturnType<typeof setTimeout>;
|
|
175
|
+
|
|
176
|
+
// One directory page or one pinned activity per turn; yield between reads.
|
|
177
|
+
// Keep the previous directory until a traversal completes, avoiding page flicker.
|
|
178
|
+
private refreshPresence(): void {
|
|
179
|
+
const source = this.deps.presence;
|
|
180
|
+
if (!source || this.closed) return;
|
|
181
|
+
const groups: SessionGroup[] = [];
|
|
182
|
+
const threads = new Map<string, TaskThread>();
|
|
183
|
+
const seen = new Set<string>();
|
|
184
|
+
let pending: Header[] = [];
|
|
185
|
+
let overflow = true;
|
|
186
|
+
const step = () => {
|
|
187
|
+
if (this.closed) return;
|
|
188
|
+
try {
|
|
189
|
+
if (pending.length) {
|
|
190
|
+
const header = pending.shift()!;
|
|
191
|
+
const id = `${header.sessionHash}:${header.incarnation}`;
|
|
192
|
+
if (!seen.has(id) && header.incarnation !== source.target?.incarnation) {
|
|
193
|
+
seen.add(id);
|
|
194
|
+
const { activity, unavailable } = readActivity(source.profile, header);
|
|
195
|
+
const tasks = (activity?.tasks ?? []).filter(({ summary }) => !isFinished(summary.status as TaskRecord["status"])).map(({ summary, thread }) => {
|
|
196
|
+
const task: TaskRecord = { ...summary, id: `peer:${id}:${summary.id}`, parentSessionId: id,
|
|
197
|
+
status: summary.status as TaskRecord["status"], mode: "background", prompt: "", cwd: "",
|
|
198
|
+
thinking: undefined, sessionPath: null, error: null, result: null, lastStep: "", turns: 0, toolCalls: 0, tokens: 0, cost: 0 };
|
|
199
|
+
threads.set(task.id, { ...emptyThread(), ...thread,
|
|
200
|
+
items: thread.items.map((item) => item.kind === "tool" ? { ...item, args: {} } : item) as ThreadItem[] });
|
|
201
|
+
return task;
|
|
202
|
+
});
|
|
203
|
+
groups.push({ id, sessionId: id, label: `${header.label}${unavailable ? " · unavailable" : ""}`, tasks });
|
|
204
|
+
}
|
|
205
|
+
} else if (overflow) {
|
|
206
|
+
this.presenceCursor ??= new PresenceCursor(source.profile);
|
|
207
|
+
const page = this.presenceCursor.next();
|
|
208
|
+
pending = page.entries.filter((entry) => entry.recent);
|
|
209
|
+
overflow = page.overflow;
|
|
210
|
+
} else {
|
|
211
|
+
this.peers = groups;
|
|
212
|
+
this.remoteThreads = threads;
|
|
213
|
+
this.refreshTasks();
|
|
214
|
+
this.clearFooterLayout();
|
|
215
|
+
this.deps.requestRender();
|
|
216
|
+
this.stopPresenceRefresh();
|
|
217
|
+
this.presenceTimer = setTimeout(() => this.refreshPresence(), 1000);
|
|
218
|
+
this.presenceTimer.unref();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
this.peers = [];
|
|
223
|
+
this.remoteThreads.clear();
|
|
224
|
+
this.refreshTasks();
|
|
225
|
+
this.clearFooterLayout();
|
|
226
|
+
this.deps.requestRender();
|
|
227
|
+
this.stopPresenceRefresh();
|
|
228
|
+
this.presenceTimer = setTimeout(() => this.refreshPresence(), 1000);
|
|
229
|
+
this.presenceTimer.unref();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
this.presenceTimer = setTimeout(step, 0);
|
|
233
|
+
this.presenceTimer.unref();
|
|
234
|
+
};
|
|
235
|
+
step();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private stopPresenceRefresh(): void {
|
|
239
|
+
clearTimeout(this.presenceTimer);
|
|
240
|
+
this.presenceCursor?.close();
|
|
241
|
+
this.presenceCursor = undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
constructor(deps: AgentsViewDeps) {
|
|
245
|
+
this.deps = deps;
|
|
246
|
+
this.scope = VIEW_SCOPE.SESSION;
|
|
247
|
+
this.listRegion = this.pointerScope.wrap(EMPTY_COMPONENT, {
|
|
248
|
+
onWheel: (event) => this.wheelList(event),
|
|
249
|
+
});
|
|
250
|
+
this.threadRegion = this.pointerScope.wrap(EMPTY_COMPONENT, {
|
|
251
|
+
onWheel: (event) => this.wheelThread(event),
|
|
252
|
+
});
|
|
253
|
+
this.followRegion = this.pointerScope.wrap(EMPTY_COMPONENT, {
|
|
254
|
+
onHover: () => this.hoverControl("follow"),
|
|
255
|
+
onLeave: () => this.clearHoveredControl("follow"),
|
|
256
|
+
onClick: (event) => this.clickFollow(event),
|
|
257
|
+
});
|
|
258
|
+
this.openRegion = this.pointerScope.wrap(EMPTY_COMPONENT, {
|
|
259
|
+
onHover: () => this.hoverControl("open"),
|
|
260
|
+
onLeave: () => this.clearHoveredControl("open"),
|
|
261
|
+
onClick: (event) => this.clickOpen(event),
|
|
262
|
+
});
|
|
263
|
+
this.closeRegion = this.pointerScope.wrap(EMPTY_COMPONENT, {
|
|
264
|
+
onHover: () => this.hoverControl("close"),
|
|
265
|
+
onLeave: () => this.clearHoveredControl("close"),
|
|
266
|
+
onClick: (event) => this.clickClose(event),
|
|
267
|
+
});
|
|
268
|
+
this.modeRegion = this.pointerScope.wrap(EMPTY_COMPONENT, {
|
|
269
|
+
onHover: () => this.hoverControl("mode"),
|
|
270
|
+
onLeave: () => this.clearHoveredControl("mode"),
|
|
271
|
+
onClick: (event) => this.clickMode(event),
|
|
272
|
+
});
|
|
273
|
+
this.refreshTasks();
|
|
274
|
+
this.unsubscribeSummary = deps.store.subscribeSummary(() => {
|
|
275
|
+
this.clearFooterLayout();
|
|
276
|
+
this.refreshTasks();
|
|
277
|
+
this.deps.requestRender();
|
|
278
|
+
});
|
|
279
|
+
this.subscribeSelected();
|
|
280
|
+
this.refreshPresence();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
dispose(): void {
|
|
284
|
+
this.closed = true;
|
|
285
|
+
this.stopPresenceRefresh();
|
|
286
|
+
this.pointerLayout = undefined;
|
|
287
|
+
this.pointerScope.dispose();
|
|
288
|
+
this.unsubscribeTask?.();
|
|
289
|
+
this.unsubscribeTask = undefined;
|
|
290
|
+
this.subscribedTaskId = undefined;
|
|
291
|
+
this.unsubscribeSummary();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
mouseObserver(): ReturnType<typeof this.pointerScope.createMouseObserver> {
|
|
295
|
+
return this.pointerScope.createMouseObserver(() => this.deps.requestRender());
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
selectedTask(): TaskRecord | undefined {
|
|
299
|
+
return this.tasks.find((task) => `task:${task.id}` === this.selectedId);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
handleInput(data: string): void {
|
|
303
|
+
if (this.closed) return;
|
|
304
|
+
if (data === "q") return this.close();
|
|
305
|
+
if (matchesKey(data, Key.escape)) return this.back();
|
|
306
|
+
if (data === "F" && !this.isNarrow()) {
|
|
307
|
+
this.setNarrowView(this.narrowView === "list" ? "details" : "list");
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if ((data === "\t" || matchesKey(data, Key.enter)) && this.isNarrow()) {
|
|
311
|
+
if (this.narrowView === "details") this.back();
|
|
312
|
+
else this.activate();
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const rows = this.visibleRows();
|
|
316
|
+
const selected = this.selectedRow(rows);
|
|
317
|
+
const task = this.actionableTask();
|
|
318
|
+
const index = selected ? rows.findIndex((row) => row.id === selected.id) : -1;
|
|
319
|
+
if (this.narrowView === "details" && (data === "j" || data === "k" || matchesKey(data, Key.down) || matchesKey(data, Key.up))) {
|
|
320
|
+
this.scrollBy(data === "j" || matchesKey(data, Key.down) ? 1 : -1);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (data === "j" || matchesKey(data, Key.down)) this.select(index + 1, rows);
|
|
324
|
+
else if (data === "k" || matchesKey(data, Key.up)) this.select(index - 1, rows);
|
|
325
|
+
else if (matchesKey(data, Key.left) && selected?.kind === "heading") this.setExpanded(selected.group, false);
|
|
326
|
+
else if (matchesKey(data, Key.right) && selected?.kind === "heading") this.setExpanded(selected.group, true);
|
|
327
|
+
else if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("j"))) this.scrollBy(this.pageRows());
|
|
328
|
+
else if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("k"))) this.scrollBy(-this.pageRows());
|
|
329
|
+
else if (data === "f" && task) {
|
|
330
|
+
this.follow = true;
|
|
331
|
+
this.deps.requestRender();
|
|
332
|
+
} else if (data === "a" && this.deps.sessionId !== undefined) this.toggleScope();
|
|
333
|
+
else if ((data === "s" || data === "c") && task && this.canCancel(task)) this.deps.onCancel(task);
|
|
334
|
+
else if ((data === "o" || matchesKey(data, Key.enter)) && task && this.canOpen(task)) this.deps.onOpen(task);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
338
|
+
if (this.closed) return undefined;
|
|
339
|
+
const live = this.layout(event.width);
|
|
340
|
+
const liveHeight = live.mode === "fallback" ? Math.min(1, live.height) : live.height;
|
|
341
|
+
if (event.width !== live.width || event.height !== liveHeight) return undefined;
|
|
342
|
+
const layout = this.pointerLayout;
|
|
343
|
+
if (!layout || (layout.sourceHeight !== undefined && layout.sourceHeight !== live.height) || event.width !== layout.width || event.height !== layout.height || live.mode !== layout.mode || layout.narrowView !== this.narrowView || event.x < 0 || event.y < 0 || event.x >= layout.width || event.y >= layout.height) return undefined;
|
|
344
|
+
if (event.y === 0) {
|
|
345
|
+
if (this.isInButton(event.x, layout.modeButton)) return this.modeRegion.handleMouse(event);
|
|
346
|
+
if (this.isInButton(event.x, layout.closeButton)) return this.closeRegion.handleMouse(event);
|
|
347
|
+
}
|
|
348
|
+
if (event.y >= 1 && event.y < 1 + layout.bodyRows) {
|
|
349
|
+
const row = event.y - 1;
|
|
350
|
+
if (layout.mode === "narrow" && event.x >= layout.listX && event.x < layout.listX + layout.listWidth) {
|
|
351
|
+
if (event.type === "wheel") return this.narrowView === "list" ? this.listRegion.handleMouse(event) : this.threadRegion.handleMouse(event);
|
|
352
|
+
const visible = this.narrowView === "list" ? this.visibleRows()[this.listScroll + row] : undefined;
|
|
353
|
+
return visible ? this.taskRegion(row).handleMouse(event) : undefined;
|
|
354
|
+
}
|
|
355
|
+
if (layout.mode === "panes" && event.x >= layout.listX && event.x < layout.listX + layout.listWidth) {
|
|
356
|
+
if (event.type === "wheel") return this.listRegion.handleMouse(event);
|
|
357
|
+
const visible = this.visibleRows()[this.listScroll + row];
|
|
358
|
+
return visible ? this.taskRegion(row).handleMouse(event) : undefined;
|
|
359
|
+
}
|
|
360
|
+
if (layout.mode === "panes" && event.x >= layout.threadX && event.x < layout.threadX + layout.threadWidth && event.type === "wheel") return this.threadRegion.handleMouse(event);
|
|
361
|
+
}
|
|
362
|
+
if (event.y === layout.footerY) {
|
|
363
|
+
for (const action of layout.actions ?? []) if (this.isInButton(event.x, action)) return action.region.handleMouse(event);
|
|
364
|
+
}
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
render(width: number): string[] {
|
|
369
|
+
const layout = this.layout(width);
|
|
370
|
+
this.lastRenderedWidth = layout.width;
|
|
371
|
+
if (layout.width === 0 || layout.height === 0) {
|
|
372
|
+
this.clearFooterLayout();
|
|
373
|
+
return [];
|
|
374
|
+
}
|
|
375
|
+
if (layout.mode === "fallback") {
|
|
376
|
+
this.clearFooterLayout();
|
|
377
|
+
this.closeRegion.setDisabled(false);
|
|
378
|
+
this.closeRegion.render(1);
|
|
379
|
+
this.pointerLayout = { ...layout, height: 1, sourceHeight: layout.height, narrowView: this.narrowView, closeButton: { x: 0, width: 1 } };
|
|
380
|
+
return ["×" + fit(" Close Agents", layout.width - 1)];
|
|
381
|
+
}
|
|
382
|
+
// Retained terminal rows never belong in either live scope.
|
|
383
|
+
const now = this.deps.now();
|
|
384
|
+
if (this.tasks.some((task) => !this.inScope(task, now))) this.refreshTasks();
|
|
385
|
+
if (this.pointerLayout && (this.pointerLayout.width !== layout.width || this.pointerLayout.height !== layout.height || this.pointerLayout.mode !== layout.mode || this.pointerLayout.narrowView !== this.narrowView)) this.clearFooterLayout();
|
|
386
|
+
const theme = this.deps.theme;
|
|
387
|
+
const inner = layout.width - 2;
|
|
388
|
+
const closeLabel = this.closeLabel(layout);
|
|
389
|
+
const modeLabel = this.modeLabel(layout);
|
|
390
|
+
this.closeRegion.setDisabled(closeLabel === undefined);
|
|
391
|
+
this.modeRegion.setDisabled(modeLabel === undefined);
|
|
392
|
+
this.followSelection(layout.bodyRows);
|
|
393
|
+
const closeWidth = visibleWidth(closeLabel ?? "");
|
|
394
|
+
const modeWidth = visibleWidth(modeLabel ?? "");
|
|
395
|
+
const closeX = layout.width - 1 - closeWidth;
|
|
396
|
+
this.pointerLayout = {
|
|
397
|
+
...layout,
|
|
398
|
+
narrowView: this.narrowView,
|
|
399
|
+
closeButton: closeLabel ? { x: closeX, width: closeWidth } : undefined,
|
|
400
|
+
modeButton: modeLabel ? { x: closeX - modeWidth - 1, width: modeWidth } : undefined,
|
|
401
|
+
};
|
|
402
|
+
this.listRegion.render(layout.listWidth);
|
|
403
|
+
this.threadRegion.render(layout.threadWidth);
|
|
404
|
+
if (closeLabel) this.closeRegion.render(closeWidth);
|
|
405
|
+
if (modeLabel) this.modeRegion.render(modeWidth);
|
|
406
|
+
const scope = this.deps.sessionId === undefined ? "" : `${SCOPE_LABEL[this.scope]} · `;
|
|
407
|
+
const controlsWidth = (closeLabel ? closeWidth + 1 : 0) + (modeLabel ? modeWidth + 1 : 0);
|
|
408
|
+
const title = truncateToWidth(`❀ Agents · ${scope}${this.counts()}`, Math.max(0, inner - 3 - controlsWidth), "…");
|
|
409
|
+
const mode = modeLabel ? ` ${theme.fg(this.hoveredControl === "mode" ? "warning" : ROLE.KEY, modeLabel)}` : "";
|
|
410
|
+
const close = closeLabel ? ` ${theme.fg(this.hoveredControl === "close" ? "warning" : ROLE.KEY, closeLabel)}` : "";
|
|
411
|
+
const top = theme.fg(ROLE.FRAME, "╭─ ") + theme.fg(ROLE.TITLE, title) + theme.fg(ROLE.FRAME, ` ${rule(inner - visibleWidth(title) - 3 - controlsWidth)}`) + mode + close + theme.fg(ROLE.FRAME, "╮");
|
|
412
|
+
const detail = layout.mode === "panes" || this.narrowView === "details" ? this.threadWindow(layout.bodyRows, layout.threadWidth) : [];
|
|
413
|
+
const body: string[] = [];
|
|
414
|
+
for (let row = 0; row < layout.bodyRows; row += 1) {
|
|
415
|
+
if (layout.mode === "panes") body.push(`${theme.fg(ROLE.FRAME, "│")} ${fit(this.taskLine(row), layout.listWidth)} ${theme.fg(ROLE.FRAME, "│")} ${fit(detail[row] ?? "", layout.threadWidth)}${theme.fg(ROLE.FRAME, "│")}`);
|
|
416
|
+
else body.push(`${theme.fg(ROLE.FRAME, "│")} ${fit(this.narrowView === "list" ? this.taskLine(row) : detail[row] ?? "", layout.listWidth)} ${theme.fg(ROLE.FRAME, "│")}`);
|
|
417
|
+
}
|
|
418
|
+
const keyHints = this.selectedRow()?.kind === "heading" ? [["←/→", "group"] as const, ...this.keys(layout.mode === "narrow")] : this.keys(layout.mode === "narrow");
|
|
419
|
+
const keys = keyHints.map(([key, label]) => `${theme.fg(ROLE.KEY, key)} ${theme.fg(ROLE.KEY_TEXT, label)}`).join(" ");
|
|
420
|
+
const footer = this.footer(keys, inner - 2);
|
|
421
|
+
const keysLine = `${theme.fg(ROLE.FRAME, "│")} ${fit(footer, inner - 2)} ${theme.fg(ROLE.FRAME, "│")}`;
|
|
422
|
+
return [top, ...body, keysLine, theme.fg(ROLE.FRAME, `╰${rule(inner)}╯`)];
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
invalidate(): void {
|
|
426
|
+
this.clearFooterLayout();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private counts(): string {
|
|
430
|
+
const active = this.tasks.filter((task) => !isFinished(task.status)).length;
|
|
431
|
+
return `${active} active`;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private actionableTask(): TaskRecord | undefined {
|
|
435
|
+
return this.directoryRoot() && this.narrowView === "list" ? undefined : this.selectedTask();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private canCancel(task: TaskRecord): boolean {
|
|
439
|
+
return !this.remoteThreads.has(task.id) && !isFinished(task.status) && (this.deps.canCancel?.(task) ?? true);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private canOpen(task: TaskRecord | undefined): boolean {
|
|
443
|
+
return Boolean(task?.sessionPath) && !this.remoteThreads.has(task!.id);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private keys(narrow = false): ReadonlyArray<readonly [string, string]> {
|
|
447
|
+
const task = this.actionableTask();
|
|
448
|
+
const details = narrow && task ? [["tab", this.narrowView === "list" ? "details" : "back"]] as const : [];
|
|
449
|
+
const stop = task && this.canCancel(task) ? [["s", "Stop selected"]] as const : [];
|
|
450
|
+
const scope = this.deps.sessionId === undefined ? [] : [["a", SCOPE_KEY[this.scope]]] as const;
|
|
451
|
+
return [...KEYS.slice(0, 1), ...details, ...KEYS.slice(1, 3), ...stop, ...scope, ...KEYS.slice(3)];
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// A new scope reads from the top: selection, list window, and thread reset.
|
|
455
|
+
private toggleScope(): void {
|
|
456
|
+
this.clearFooterLayout();
|
|
457
|
+
this.scope = this.scope === VIEW_SCOPE.SESSION ? VIEW_SCOPE.ALL : VIEW_SCOPE.SESSION;
|
|
458
|
+
this.narrowGroup = undefined;
|
|
459
|
+
this.groupCursor = undefined;
|
|
460
|
+
this.manualListOffsets.clear();
|
|
461
|
+
this.narrowView = "list";
|
|
462
|
+
this.tasks = [];
|
|
463
|
+
this.selectedId = undefined;
|
|
464
|
+
this.listScroll = 0;
|
|
465
|
+
this.hoveredId = undefined;
|
|
466
|
+
this.scroll = 0;
|
|
467
|
+
this.follow = true;
|
|
468
|
+
this.refreshTasks();
|
|
469
|
+
this.deps.requestRender();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private inScope(task: TaskRecord, _now: number): boolean {
|
|
473
|
+
return !isFinished(task.status) && (task.parentSessionId === this.deps.sessionId
|
|
474
|
+
|| (this.scope === VIEW_SCOPE.ALL && this.remoteThreads.has(task.id)));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private directoryRoot(): boolean {
|
|
478
|
+
return this.scope === VIEW_SCOPE.ALL && this.isNarrow() && !this.narrowGroup;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Keep the selected row inside the list window, moving the window by the
|
|
482
|
+
// least amount needed; the wheel moves the same window on its own.
|
|
483
|
+
private followSelection(rows: number): void {
|
|
484
|
+
const manual = this.manualListOffsets.get(this.listKey());
|
|
485
|
+
if (manual !== undefined) {
|
|
486
|
+
this.listScroll = Math.min(manual, Math.max(0, this.visibleRows().length - rows));
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const selected = this.selectedRow();
|
|
490
|
+
const index = selected ? this.visibleRows().findIndex((row) => row.id === selected.id) : -1;
|
|
491
|
+
if (index >= 0 && index < this.listScroll) this.listScroll = index;
|
|
492
|
+
else if (index >= this.listScroll + rows) this.listScroll = index - rows + 1;
|
|
493
|
+
this.listScroll = Math.max(0, Math.min(this.listScroll, Math.max(0, this.visibleRows().length - rows)));
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private layout(width = this.pointerLayout?.width ?? this.lastRenderedWidth ?? 80): AgentsViewLayout {
|
|
497
|
+
const rows = typeof this.deps.rows === "function" ? this.deps.rows() : this.deps.rows;
|
|
498
|
+
return measureAgentsViewLayout(width, rows, this.narrowView === "details");
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
private listKey(): string {
|
|
502
|
+
return this.isNarrow() ? this.narrowGroup ?? "root" : "panes";
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
private isNarrow(): boolean {
|
|
506
|
+
return measureAgentsViewLayout(this.lastRenderedWidth ?? 80, 3).mode !== "panes";
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
private back(): void {
|
|
510
|
+
if (this.narrowView === "details") this.setNarrowView("list");
|
|
511
|
+
else if (this.isNarrow() && this.narrowGroup) {
|
|
512
|
+
this.groupCursor = `heading:${this.narrowGroup}`;
|
|
513
|
+
this.narrowGroup = undefined;
|
|
514
|
+
this.listScroll = 0;
|
|
515
|
+
this.clearFooterLayout();
|
|
516
|
+
this.deps.requestRender();
|
|
517
|
+
} else this.close();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private activate(): void {
|
|
521
|
+
const selected = this.selectedRow();
|
|
522
|
+
if (selected?.kind === "heading" && this.isNarrow()) {
|
|
523
|
+
this.narrowGroup = selected.group.id;
|
|
524
|
+
this.listScroll = 0;
|
|
525
|
+
const children = this.visibleRows();
|
|
526
|
+
if (!children.some((row) => row.id === this.selectedId)) this.select(0, children);
|
|
527
|
+
this.clearFooterLayout();
|
|
528
|
+
this.deps.requestRender();
|
|
529
|
+
} else if (this.selectedTask()) this.setNarrowView("details");
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
private bodyRows(): number {
|
|
533
|
+
return this.layout().bodyRows;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
private refreshTasks(): void {
|
|
537
|
+
this.tasks = this.deps.store.list().filter((task) => task.parentSessionId === this.deps.sessionId
|
|
538
|
+
&& !isFinished(task.status) && (this.deps.isLocalTask?.(task) ?? true));
|
|
539
|
+
if (this.scope === VIEW_SCOPE.ALL) this.tasks.push(...this.peers.flatMap((group) => group.tasks));
|
|
540
|
+
const groups = this.sessionGroups();
|
|
541
|
+
const present = new Set(groups.map((group) => group.id));
|
|
542
|
+
for (const id of this.observedActiveGroups) if (!present.has(id)) this.observedActiveGroups.delete(id);
|
|
543
|
+
for (const group of groups) {
|
|
544
|
+
if (group.tasks.some((task) => !isFinished(task.status))) this.observedActiveGroups.add(group.id);
|
|
545
|
+
}
|
|
546
|
+
const rows = this.allRows();
|
|
547
|
+
if (this.narrowGroup && !this.sessionGroups().some((group) => group.id === this.narrowGroup)) this.narrowGroup = undefined;
|
|
548
|
+
if (!this.selectedId || (!this.selectedTask() && !rows.some((row) => row.id === this.selectedId))) {
|
|
549
|
+
this.selectedId = rows.find((row) => row.kind === "task")?.id ?? rows[0]?.id;
|
|
550
|
+
}
|
|
551
|
+
if (!this.selectedTask()) this.narrowView = "list";
|
|
552
|
+
this.listScroll = Math.max(0, Math.min(this.listScroll, Math.max(0, rows.length - this.bodyRows())));
|
|
553
|
+
if (this.hoveredId && !rows.some((row) => row.id === this.hoveredId)) this.hoveredId = undefined;
|
|
554
|
+
this.subscribeSelected();
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private sessionGroups(): SessionGroup[] {
|
|
558
|
+
const groups = new Map<string, SessionGroup>();
|
|
559
|
+
for (const task of this.tasks) {
|
|
560
|
+
const sessionId = task.parentSessionId.trim() || undefined;
|
|
561
|
+
const id = sessionId ? `session:${sessionId}` : `unknown:${task.id}`;
|
|
562
|
+
const group = groups.get(id) ?? { id, sessionId, tasks: [] };
|
|
563
|
+
if (!groups.has(id)) groups.set(id, group);
|
|
564
|
+
group.tasks.push(task);
|
|
565
|
+
}
|
|
566
|
+
for (const group of groups.values()) {
|
|
567
|
+
group.tasks.sort((a, b) => b.createdAt - a.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
568
|
+
}
|
|
569
|
+
if (this.scope === VIEW_SCOPE.ALL) {
|
|
570
|
+
const localId = `session:${this.deps.sessionId}`;
|
|
571
|
+
const local = groups.get(localId) ?? { id: localId, sessionId: this.deps.sessionId, tasks: [] };
|
|
572
|
+
return [local, ...this.peers];
|
|
573
|
+
}
|
|
574
|
+
return [...groups.values()];
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private visibleRows(): VisibleRow[] {
|
|
578
|
+
if (!this.isNarrow() || this.scope === VIEW_SCOPE.SESSION) return this.allRows();
|
|
579
|
+
const groups = this.sessionGroups();
|
|
580
|
+
if (!this.narrowGroup) return groups.map((group) => ({ id: `heading:${group.id}`, kind: "heading", group }));
|
|
581
|
+
const group = groups.find((entry) => entry.id === this.narrowGroup);
|
|
582
|
+
return group?.tasks.map((task) => ({ id: `task:${task.id}`, kind: "task", group, task })) ?? [];
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
private allRows(): VisibleRow[] {
|
|
586
|
+
const rows: VisibleRow[] = [];
|
|
587
|
+
for (const group of this.sessionGroups()) {
|
|
588
|
+
if (this.scope === VIEW_SCOPE.ALL) rows.push({ id: `heading:${group.id}`, kind: "heading", group });
|
|
589
|
+
if (this.scope === VIEW_SCOPE.SESSION || this.isExpanded(group)) {
|
|
590
|
+
for (const task of group.tasks) rows.push({ id: `task:${task.id}`, kind: "task", group, task });
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return rows;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private selectedRow(rows = this.visibleRows()): VisibleRow | undefined {
|
|
597
|
+
if (this.directoryRoot() && this.narrowView === "list") return rows.find((row) => row.id === this.groupCursor) ?? rows[0];
|
|
598
|
+
return rows.find((row) => row.id === this.selectedId);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private isExpanded(group: SessionGroup): boolean {
|
|
602
|
+
return this.expanded.get(group.id) ?? this.observedActiveGroups.has(group.id);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
private setExpanded(group: SessionGroup, expanded: boolean): void {
|
|
606
|
+
if (this.isExpanded(group) === expanded) return;
|
|
607
|
+
this.expanded.set(group.id, expanded);
|
|
608
|
+
if (!expanded) {
|
|
609
|
+
this.selectedId = `heading:${group.id}`;
|
|
610
|
+
this.narrowView = "list";
|
|
611
|
+
}
|
|
612
|
+
this.listScroll = Math.min(this.listScroll, Math.max(0, this.visibleRows().length - this.bodyRows()));
|
|
613
|
+
this.clearFooterLayout();
|
|
614
|
+
this.subscribeSelected();
|
|
615
|
+
this.deps.requestRender();
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
private subscribeSelected(): void {
|
|
619
|
+
const task = this.selectedTask();
|
|
620
|
+
if (task?.id === this.subscribedTaskId) return;
|
|
621
|
+
this.unsubscribeTask?.();
|
|
622
|
+
this.unsubscribeTask = undefined;
|
|
623
|
+
this.subscribedTaskId = task?.id;
|
|
624
|
+
if (!task || this.remoteThreads.has(task.id)) return;
|
|
625
|
+
this.unsubscribeTask = this.deps.store.subscribe(task.id, () => {
|
|
626
|
+
this.clearFooterLayout();
|
|
627
|
+
this.refreshTasks();
|
|
628
|
+
this.deps.requestRender();
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private taskLine(row: number): string {
|
|
633
|
+
const visible = this.visibleRows();
|
|
634
|
+
if (visible.length === 0) return row === 0 ? this.deps.theme.fg(ROLE.EMPTY, EMPTY_LIST) : "";
|
|
635
|
+
const entry = visible[this.listScroll + row];
|
|
636
|
+
if (!entry) return "";
|
|
637
|
+
const theme = this.deps.theme;
|
|
638
|
+
this.taskRegion(row).render(this.pointerLayout?.listWidth ?? 1);
|
|
639
|
+
const selected = entry.id === this.selectedRow()?.id;
|
|
640
|
+
const hovered = entry.id === this.hoveredId;
|
|
641
|
+
const emphasis = selected ? ROLE.SELECTED : hovered ? ROLE.HOVER : undefined;
|
|
642
|
+
const marker = emphasis ? theme.fg(emphasis, selected ? "▸" : "▹") : " ";
|
|
643
|
+
if (entry.kind === "heading") {
|
|
644
|
+
const state = this.isExpanded(entry.group) ? "▾" : "▸";
|
|
645
|
+
return `${marker}${theme.fg(emphasis ?? ROLE.SELECTED, state)} ${theme.fg(emphasis ?? ROLE.NAME_IDLE, this.groupHeading(entry.group))}`;
|
|
646
|
+
}
|
|
647
|
+
const task = entry.task;
|
|
648
|
+
const glyph = theme.fg(GLYPH_ROLE[task.status] ?? ROLE.META, GLYPH[task.status] ?? "?");
|
|
649
|
+
const name = theme.fg(emphasis ?? ROLE.NAME_IDLE, `Subagent ${task.agent}`);
|
|
650
|
+
const time = task.startedAt === null ? "" : theme.fg(ROLE.META, formatElapsed((task.endedAt ?? this.deps.now()) - task.startedAt));
|
|
651
|
+
return `${marker} ${theme.fg(ROLE.META, "└")} ${glyph} ${name} ${time}`;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
private groupHeading(group: SessionGroup): string {
|
|
655
|
+
const count = `${group.tasks.length} ${group.tasks.length === 1 ? "Subagent" : "Subagents"}`;
|
|
656
|
+
if (!this.isExpanded(group)) return `${this.groupTitle(group)} · ${count}`;
|
|
657
|
+
const active = group.tasks.filter((task) => !isFinished(task.status)).length;
|
|
658
|
+
return `${this.groupTitle(group)} · ${count} · ${active} active`;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
private groupTitle(group: SessionGroup): string {
|
|
662
|
+
if (group.label) return group.label;
|
|
663
|
+
if (!group.sessionId) return "Current orchestrator";
|
|
664
|
+
if (group.sessionId === this.deps.sessionId) return "Current orchestrator";
|
|
665
|
+
return `Orchestrator ${group.sessionId.slice(0, 8)}`;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
private threadLines(thread: TaskThread, width: number): string[] {
|
|
669
|
+
if (this.cacheWidth !== width) {
|
|
670
|
+
this.cache = new WeakMap();
|
|
671
|
+
this.cacheWidth = width;
|
|
672
|
+
}
|
|
673
|
+
const lines: string[] = [];
|
|
674
|
+
if (thread.dropped > 0) lines.push(this.deps.theme.fg(ROLE.META, `… ${thread.dropped} earlier items not kept`));
|
|
675
|
+
for (const item of thread.items) {
|
|
676
|
+
let rendered = this.cache.get(item);
|
|
677
|
+
if (!rendered) {
|
|
678
|
+
rendered = renderThreadItem(item, this.deps.theme, width);
|
|
679
|
+
this.cache.set(item, rendered);
|
|
680
|
+
}
|
|
681
|
+
lines.push(...rendered);
|
|
682
|
+
}
|
|
683
|
+
return lines;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
private threadWindow(rows: number, width: number): string[] {
|
|
687
|
+
const task = this.selectedTask();
|
|
688
|
+
if (!task) return [this.deps.theme.fg(ROLE.EMPTY, "Select a task to inspect its thread")];
|
|
689
|
+
const theme = this.deps.theme;
|
|
690
|
+
const header = theme.fg(ROLE.META, truncateToWidth(taskHeader(task, this.deps.now()), width, "…"));
|
|
691
|
+
const lines = this.threadLines(this.remoteThreads.get(task.id) ?? this.deps.store.thread(task.id), width);
|
|
692
|
+
if (lines.length === 0) return [header, theme.fg(ROLE.EMPTY, task.error ?? EMPTY_THREAD)];
|
|
693
|
+
const visible = Math.max(0, rows - 1);
|
|
694
|
+
const maxScroll = Math.max(0, lines.length - visible);
|
|
695
|
+
if (this.follow) this.scroll = maxScroll;
|
|
696
|
+
// A taller/wider presentation may clamp its window, not the saved position.
|
|
697
|
+
const start = Math.min(this.scroll, maxScroll);
|
|
698
|
+
return [header, ...lines.slice(start, start + visible)];
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
private select(index: number, rows = this.visibleRows(), revealSelection = true): void {
|
|
702
|
+
const next = rows[Math.max(0, Math.min(rows.length - 1, index))];
|
|
703
|
+
if (!next) return;
|
|
704
|
+
// Keyboard movement reveals the selection; pointer activation keeps the
|
|
705
|
+
// manually positioned list available for Back, even after height clamping.
|
|
706
|
+
if (revealSelection) this.manualListOffsets.delete(this.listKey());
|
|
707
|
+
if (this.directoryRoot()) {
|
|
708
|
+
this.groupCursor = next.id;
|
|
709
|
+
this.clearFooterLayout();
|
|
710
|
+
this.deps.requestRender();
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (next.id === this.selectedId) return;
|
|
714
|
+
this.selectedId = next.id;
|
|
715
|
+
if (next.kind === "heading") this.narrowView = "list";
|
|
716
|
+
this.scroll = 0;
|
|
717
|
+
this.follow = true;
|
|
718
|
+
this.clearFooterLayout();
|
|
719
|
+
this.subscribeSelected();
|
|
720
|
+
this.deps.requestRender();
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// One page is the thread area: the body minus its header row.
|
|
724
|
+
private pageRows(): number {
|
|
725
|
+
return Math.max(1, this.bodyRows() - 1);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
private scrollBy(delta: number): void {
|
|
729
|
+
this.follow = false;
|
|
730
|
+
this.scroll = Math.max(0, this.scroll + delta);
|
|
731
|
+
this.deps.requestRender();
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
private clearFooterLayout(): void {
|
|
735
|
+
this.pointerLayout = undefined;
|
|
736
|
+
this.hoveredControl = undefined;
|
|
737
|
+
this.pointerScope.invalidate();
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
private footer(keys: string, width: number): string {
|
|
741
|
+
const task = this.actionableTask();
|
|
742
|
+
const controls = [
|
|
743
|
+
{ label: FOLLOW_BUTTON, short: "Follow", region: this.followRegion, enabled: Boolean(task) },
|
|
744
|
+
{ label: OPEN_BUTTON, short: "Open", region: this.openRegion, enabled: this.canOpen(task) },
|
|
745
|
+
];
|
|
746
|
+
if (task && this.canCancel(task)) controls.push({ label: STOP_BUTTON, short: "Stop", region: this.actionRegion("stop", () => this.handleInput("s")), enabled: true });
|
|
747
|
+
if (this.deps.sessionId !== undefined) controls.push({ label: SCOPE_BUTTON, short: "Scope", region: this.actionRegion("scope", () => this.toggleScope()), enabled: true });
|
|
748
|
+
for (const control of controls) control.region.setDisabled(!control.enabled);
|
|
749
|
+
const required = controls.reduce((sum, control) => sum + control.label.length + 1, -1);
|
|
750
|
+
const shown = required <= width ? controls : [controls[this.footerPage % controls.length]];
|
|
751
|
+
const actions: NonNullable<PointerLayout["actions"]> = [];
|
|
752
|
+
let text = "";
|
|
753
|
+
for (const control of shown) {
|
|
754
|
+
const label = required <= width ? control.label : control.short;
|
|
755
|
+
if (text) text += " ";
|
|
756
|
+
actions.push({ x: 2 + visibleWidth(text), width: label.length, region: control.region });
|
|
757
|
+
control.region.render(label.length);
|
|
758
|
+
const hovered = (control.region === this.followRegion && this.hoveredControl === "follow") || (control.region === this.openRegion && this.hoveredControl === "open");
|
|
759
|
+
text += this.deps.theme.fg(!control.enabled ? ROLE.META : hovered ? ROLE.HOVER : ROLE.KEY, label);
|
|
760
|
+
}
|
|
761
|
+
if (required > width) {
|
|
762
|
+
const more = this.actionRegion("more", () => {
|
|
763
|
+
this.footerPage++;
|
|
764
|
+
this.clearFooterLayout();
|
|
765
|
+
this.deps.requestRender();
|
|
766
|
+
});
|
|
767
|
+
more.render(1);
|
|
768
|
+
actions.push({ x: 2 + width - 1, width: 1, region: more });
|
|
769
|
+
text = fit(text, width - 1) + ">";
|
|
770
|
+
} else if (visibleWidth(text) + 1 < width) text += " " + truncateToWidth(keys, width - visibleWidth(text) - 1, "…");
|
|
771
|
+
if (this.pointerLayout) this.pointerLayout.actions = actions;
|
|
772
|
+
return text;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
private actionRegion(id: string, action: () => void): NativePointerRegion {
|
|
776
|
+
let region = this.actionRegions.get(id);
|
|
777
|
+
if (!region) {
|
|
778
|
+
region = this.pointerScope.wrap(EMPTY_COMPONENT, {
|
|
779
|
+
onClick: (event) => {
|
|
780
|
+
if (event.button !== "left") return undefined;
|
|
781
|
+
action();
|
|
782
|
+
return { handled: true, render: true };
|
|
783
|
+
},
|
|
784
|
+
});
|
|
785
|
+
this.actionRegions.set(id, region);
|
|
786
|
+
}
|
|
787
|
+
return region;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
private modeLabel(layout: AgentsViewLayout): string | undefined {
|
|
791
|
+
if (this.narrowView === "details" || (this.isNarrow() && this.narrowGroup)) return layout.width < 24 ? "←" : "[← Back]";
|
|
792
|
+
return !this.isNarrow() && this.selectedTask() ? "[F Fullscreen]" : undefined;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
private closeLabel(layout: AgentsViewLayout): string | undefined {
|
|
796
|
+
if (layout.mode === "fallback") return undefined;
|
|
797
|
+
return layout.width < 60 ? "[×]" : CLOSE_BUTTON;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
private setNarrowView(view: "list" | "details"): void {
|
|
801
|
+
if (view === this.narrowView || (view === "details" && !this.selectedTask())) return;
|
|
802
|
+
this.narrowView = view;
|
|
803
|
+
this.clearFooterLayout();
|
|
804
|
+
this.deps.requestRender();
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
private close(): void {
|
|
808
|
+
if (this.closed) return;
|
|
809
|
+
this.closed = true;
|
|
810
|
+
this.stopPresenceRefresh();
|
|
811
|
+
this.pointerScope.invalidate();
|
|
812
|
+
this.deps.onClose();
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
private isInButton(x: number, button: PointerButtonLayout | undefined): boolean {
|
|
816
|
+
return button !== undefined && x >= button.x && x < button.x + button.width;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
private hoverControl(control: "follow" | "open" | "close" | "mode"): TuiMouseEventResult {
|
|
820
|
+
if (this.hoveredControl === control) return { handled: true };
|
|
821
|
+
this.hoveredControl = control;
|
|
822
|
+
return { handled: true, render: true };
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
private clearHoveredControl(control: "follow" | "open" | "close" | "mode"): void {
|
|
826
|
+
if (this.hoveredControl !== control) return;
|
|
827
|
+
this.hoveredControl = undefined;
|
|
828
|
+
this.deps.requestRender();
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
private clickFollow(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
832
|
+
if (event.button !== "left" || !this.selectedTask()) return undefined;
|
|
833
|
+
this.follow = true;
|
|
834
|
+
return { handled: true, render: true };
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
private clickOpen(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
838
|
+
if (event.button !== "left") return undefined;
|
|
839
|
+
const task = this.selectedTask();
|
|
840
|
+
if (!this.canOpen(task)) return undefined;
|
|
841
|
+
this.deps.onOpen(task!);
|
|
842
|
+
return { handled: true, render: true };
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
private clickClose(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
846
|
+
if (event.button !== "left") return undefined;
|
|
847
|
+
this.close();
|
|
848
|
+
return { handled: true, render: true };
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
private clickMode(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
852
|
+
if (event.button !== "left") return undefined;
|
|
853
|
+
if (this.narrowView === "details" || (this.isNarrow() && this.narrowGroup)) this.back();
|
|
854
|
+
else this.activate();
|
|
855
|
+
return { handled: true, render: true };
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
private taskRegion(row: number): NativePointerRegion {
|
|
859
|
+
let region = this.taskRegions.get(row);
|
|
860
|
+
if (!region) {
|
|
861
|
+
region = this.pointerScope.wrap(EMPTY_COMPONENT, {
|
|
862
|
+
onHover: () => this.hoverTask(row),
|
|
863
|
+
onLeave: () => this.clearHoveredTask(row),
|
|
864
|
+
onClick: (event) => this.clickTask(row, event),
|
|
865
|
+
});
|
|
866
|
+
this.taskRegions.set(row, region);
|
|
867
|
+
}
|
|
868
|
+
return region;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
private hoverTask(row: number): TuiMouseEventResult | undefined {
|
|
872
|
+
const entry = this.visibleRows()[this.listScroll + row];
|
|
873
|
+
if (!entry || this.hoveredId === entry.id) return { handled: true };
|
|
874
|
+
this.hoveredId = entry.id;
|
|
875
|
+
return { handled: true, render: true };
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
private clearHoveredTask(row: number): void {
|
|
879
|
+
const entry = this.visibleRows()[this.listScroll + row];
|
|
880
|
+
if (!entry || this.hoveredId !== entry.id) return;
|
|
881
|
+
this.hoveredId = undefined;
|
|
882
|
+
this.deps.requestRender();
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
private clickTask(row: number, event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
886
|
+
if (event.button !== "left") return undefined;
|
|
887
|
+
const entry = this.visibleRows()[this.listScroll + row];
|
|
888
|
+
if (!entry) return undefined;
|
|
889
|
+
if (this.isNarrow()) {
|
|
890
|
+
this.select(this.listScroll + row, this.visibleRows(), false);
|
|
891
|
+
this.activate();
|
|
892
|
+
} else if (entry.kind === "heading") this.setExpanded(entry.group, !this.isExpanded(entry.group));
|
|
893
|
+
else this.select(this.listScroll + row);
|
|
894
|
+
return { handled: true, render: true };
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
private wheelList(event: TuiMouseEvent): TuiMouseEventResult {
|
|
898
|
+
const delta = event.wheelDelta ?? 0;
|
|
899
|
+
const next = Math.max(0, Math.min(Math.max(0, this.visibleRows().length - this.bodyRows()), this.listScroll + delta));
|
|
900
|
+
if (next === this.listScroll) return { handled: true, render: false };
|
|
901
|
+
this.listScroll = next;
|
|
902
|
+
this.manualListOffsets.set(this.listKey(), next);
|
|
903
|
+
this.clearFooterLayout();
|
|
904
|
+
this.hoveredId = undefined;
|
|
905
|
+
return { handled: true, render: true };
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
private wheelThread(event: TuiMouseEvent): TuiMouseEventResult {
|
|
909
|
+
const delta = event.wheelDelta ?? 0;
|
|
910
|
+
if (delta === 0) return { handled: true, render: false };
|
|
911
|
+
this.scrollBy(delta);
|
|
912
|
+
return { handled: true, render: true };
|
|
913
|
+
}
|
|
914
|
+
}
|