@yannelli/paseo-linear-plugin 1.0.0 → 1.1.0-beta.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/OVERVIEW.md +19 -1
- package/README.md +77 -6
- package/client/access.tsx +66 -0
- package/client/agent-options.ts +12 -2
- package/client/agent-preferences.ts +112 -0
- package/client/browser.tsx +12 -9
- package/client/graph-labels.ts +137 -0
- package/client/launch-choices.ts +18 -5
- package/client/launch-plan.ts +88 -39
- package/client/launch.tsx +9 -4
- package/client/live-agents.tsx +407 -0
- package/client/live-cursor.tsx +291 -0
- package/client/live-feed.tsx +151 -0
- package/client/live-graph.tsx +434 -0
- package/client/live-header.tsx +312 -0
- package/client/live-issues.tsx +345 -0
- package/client/live-map.tsx +345 -0
- package/client/live-panel.tsx +444 -0
- package/client/live-timeline.ts +341 -0
- package/client/pill.tsx +393 -0
- package/client/project-init.tsx +168 -0
- package/client/settings-access.tsx +56 -0
- package/client/settings-live.tsx +121 -0
- package/client/settings-projects.tsx +9 -0
- package/index.client.tsx +39 -1
- package/index.server.ts +24 -4
- package/package.json +1 -1
- package/server/agent-runs.ts +299 -0
- package/server/handlers.ts +12 -1
- package/server/init.ts +64 -0
- package/server/links.ts +153 -0
- package/server/live.ts +334 -0
- package/server/stores.ts +119 -0
- package/server/sync.ts +127 -0
- package/shared/activity.ts +308 -0
- package/shared/graph-geometry.ts +122 -0
- package/shared/graph-model.ts +319 -0
- package/shared/links.ts +252 -0
- package/shared/live.ts +200 -0
- package/shared/map-model.ts +192 -0
- package/shared/project-setup.ts +55 -0
- package/shared/prompts.ts +16 -1
- package/shared/settings.ts +57 -1
- package/shared/shell-activity.ts +309 -0
- package/shared/shell-lexer.ts +180 -0
- package/shared/subagents.ts +106 -0
- package/shared/todo-sync.ts +158 -0
package/server/sync.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
2
|
+
import type { WorkflowState } from "../shared/linear";
|
|
3
|
+
import { syncNowRpc } from "../shared/live";
|
|
4
|
+
import { AGENT_LABELS, isInternalAgent } from "../shared/prompts";
|
|
5
|
+
import { type LinearSettings, projectEnabled } from "../shared/settings";
|
|
6
|
+
import { planStatusMoves, progressByKey, type TodoLike } from "../shared/todo-sync";
|
|
7
|
+
import type { Paseo } from "./agent-runs";
|
|
8
|
+
import type { LinearAccess } from "./handlers";
|
|
9
|
+
import { agentInfo } from "./live";
|
|
10
|
+
|
|
11
|
+
const STATES_MAX_AGE_MS = 10 * 60_000;
|
|
12
|
+
|
|
13
|
+
export interface SyncDependencies {
|
|
14
|
+
access: LinearAccess;
|
|
15
|
+
readSettings(): Promise<LinearSettings | null>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SyncResult {
|
|
19
|
+
moves: { identifier: string; from: WorkflowState; to: WorkflowState }[];
|
|
20
|
+
skipped: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function latestTodos(items: readonly { type: string; items?: unknown }[]): TodoLike[] | null {
|
|
24
|
+
for (let index = items.length - 1; index >= 0; index -= 1) {
|
|
25
|
+
const item = items[index];
|
|
26
|
+
if (item?.type === "todo" && Array.isArray(item.items)) return item.items as TodoLike[];
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function registerSync(server: PluginServerContext, dependencies: SyncDependencies) {
|
|
32
|
+
const { access, readSettings } = dependencies;
|
|
33
|
+
const teamStates = new Map<string, { at: number; states: Map<string, WorkflowState[]> }>();
|
|
34
|
+
const queues = new Map<string, Promise<unknown>>();
|
|
35
|
+
|
|
36
|
+
async function statesFor(projectId: string | null, teamId: string) {
|
|
37
|
+
const { linear, fingerprint } = await access.connect(projectId);
|
|
38
|
+
const cached = teamStates.get(fingerprint);
|
|
39
|
+
if (cached && Date.now() - cached.at < STATES_MAX_AGE_MS && cached.states.has(teamId)) {
|
|
40
|
+
return cached.states.get(teamId) ?? [];
|
|
41
|
+
}
|
|
42
|
+
const catalog = await linear.catalog();
|
|
43
|
+
const states = new Map(catalog.teams.map((team) => [team.id, team.states]));
|
|
44
|
+
teamStates.set(fingerprint, { at: Date.now(), states });
|
|
45
|
+
return states.get(teamId) ?? [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Reads Linear fresh, not the response cache, so a manual move since the last turn counts.
|
|
49
|
+
async function syncIssue(
|
|
50
|
+
identifier: string,
|
|
51
|
+
projectId: string | null,
|
|
52
|
+
todos: readonly TodoLike[],
|
|
53
|
+
): Promise<SyncResult> {
|
|
54
|
+
const progress = progressByKey(todos);
|
|
55
|
+
if (progress.size === 0) return { moves: [], skipped: "No todo starts with an issue key." };
|
|
56
|
+
const { linear } = await access.connect(projectId);
|
|
57
|
+
const issue = await linear.getIssue(identifier);
|
|
58
|
+
const states = await statesFor(projectId, issue.team.id);
|
|
59
|
+
const moves = planStatusMoves({ parent: issue, progress, states });
|
|
60
|
+
const applied: SyncResult["moves"] = [];
|
|
61
|
+
for (const move of moves) {
|
|
62
|
+
await access.mutate(projectId, (service) =>
|
|
63
|
+
service.updateIssue(move.issueId, { stateId: move.to.id }),
|
|
64
|
+
);
|
|
65
|
+
applied.push({ identifier: move.identifier, from: move.from, to: move.to });
|
|
66
|
+
}
|
|
67
|
+
if (applied.length > 0) {
|
|
68
|
+
console.log(`Linear sync moved ${applied.map((m) => `${m.identifier} to ${m.to.name}`).join(", ")}`);
|
|
69
|
+
}
|
|
70
|
+
return { moves: applied, skipped: null };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// One sync per issue at a time, so two turns ending together cannot move an issue twice.
|
|
74
|
+
function queued(identifier: string, run: () => Promise<SyncResult>): Promise<SyncResult> {
|
|
75
|
+
const previous = queues.get(identifier) ?? Promise.resolve();
|
|
76
|
+
const next = previous.catch(() => undefined).then(run);
|
|
77
|
+
queues.set(identifier, next);
|
|
78
|
+
void next.finally(() => {
|
|
79
|
+
if (queues.get(identifier) === next) queues.delete(identifier);
|
|
80
|
+
});
|
|
81
|
+
return next;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function syncAgent(
|
|
85
|
+
paseo: Paseo,
|
|
86
|
+
agentId: string,
|
|
87
|
+
todos: readonly TodoLike[] | null,
|
|
88
|
+
): Promise<SyncResult> {
|
|
89
|
+
const agent = await agentInfo(paseo, agentId);
|
|
90
|
+
const settings = await readSettings();
|
|
91
|
+
if (settings && !projectEnabled(settings.access, agent.projectId)) {
|
|
92
|
+
return { moves: [], skipped: "Linear is off for this project." };
|
|
93
|
+
}
|
|
94
|
+
const identifier = agent.labels[AGENT_LABELS.issue];
|
|
95
|
+
if (!identifier || isInternalAgent(agent.labels)) {
|
|
96
|
+
return { moves: [], skipped: "This agent is not linked to a Linear issue." };
|
|
97
|
+
}
|
|
98
|
+
if (agent.labels[AGENT_LABELS.action] !== "implement") {
|
|
99
|
+
return { moves: [], skipped: "Only implementing agents update Linear statuses." };
|
|
100
|
+
}
|
|
101
|
+
let items = todos;
|
|
102
|
+
if (!items) {
|
|
103
|
+
const page = await paseo.agents
|
|
104
|
+
.ref(agentId)
|
|
105
|
+
.timeline.refetch({ direction: "tail", limit: 400, projection: "projected" });
|
|
106
|
+
items = latestTodos(page.entries.map((entry) => entry.item));
|
|
107
|
+
}
|
|
108
|
+
if (!items) return { moves: [], skipped: "The agent has no todos yet." };
|
|
109
|
+
const projectId = agent.labels[AGENT_LABELS.project] ?? null;
|
|
110
|
+
const todoItems = items;
|
|
111
|
+
return queued(identifier, () => syncIssue(identifier, projectId, todoItems));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
server.on("agent.turn_ended", async (event, { paseo }) => {
|
|
115
|
+
const settings = await readSettings();
|
|
116
|
+
if (!settings?.live.syncTodos) return;
|
|
117
|
+
const todos = latestTodos(event.timeline);
|
|
118
|
+
if (!todos) return;
|
|
119
|
+
try {
|
|
120
|
+
await syncAgent(paseo, event.agent.id, todos);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
console.error("Linear sync failed", error);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
server.handle(syncNowRpc, async ({ agentId }, { paseo }) => syncAgent(paseo, agentId, null));
|
|
127
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { displayCommand, shellActivity } from "./shell-activity";
|
|
2
|
+
import { isSubagentCall, mergeSubagent, type SubagentRun, subagentOf } from "./subagents";
|
|
3
|
+
import { type TodoLike, todoKey, todoStatus } from "./todo-sync";
|
|
4
|
+
|
|
5
|
+
// Structural view of Paseo timeline items, so shared code needs no protocol import.
|
|
6
|
+
export interface TimelineItemLike {
|
|
7
|
+
type: string;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type TouchKind = "read" | "edit" | "write" | "search";
|
|
12
|
+
export type EventKind = TouchKind | "list" | "shell" | "agent" | "tool";
|
|
13
|
+
export type ToolStatus = "running" | "completed" | "failed" | "canceled";
|
|
14
|
+
|
|
15
|
+
export interface FileTouch {
|
|
16
|
+
path: string;
|
|
17
|
+
reads: number;
|
|
18
|
+
edits: number;
|
|
19
|
+
added: number;
|
|
20
|
+
removed: number;
|
|
21
|
+
created: boolean;
|
|
22
|
+
/** Position of the last touch in the timeline. Higher is more recent. */
|
|
23
|
+
order: number;
|
|
24
|
+
last: TouchKind;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ActivityEvent {
|
|
28
|
+
id: string;
|
|
29
|
+
kind: EventKind;
|
|
30
|
+
text: string;
|
|
31
|
+
/** The command as run, for shell events. */
|
|
32
|
+
detail: string | null;
|
|
33
|
+
path: string | null;
|
|
34
|
+
/** Every file the event read or changed; `path` is the first. */
|
|
35
|
+
paths: string[];
|
|
36
|
+
status: ToolStatus;
|
|
37
|
+
added: number;
|
|
38
|
+
removed: number;
|
|
39
|
+
exitCode: number | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DirTouch {
|
|
43
|
+
/** Folder with a trailing slash; "" is the repository root. */
|
|
44
|
+
path: string;
|
|
45
|
+
/** Searched recursively, not only listed. */
|
|
46
|
+
deep: boolean;
|
|
47
|
+
order: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What the agent works on now: a file, or an issue whose todo it just started. */
|
|
51
|
+
export type AgentFocus = { kind: "file"; path: string } | { kind: "issue"; key: string };
|
|
52
|
+
|
|
53
|
+
export interface Activity {
|
|
54
|
+
focus: AgentFocus | null;
|
|
55
|
+
files: FileTouch[];
|
|
56
|
+
dirs: DirTouch[];
|
|
57
|
+
/** Subagents started inside the provider, in launch order. */
|
|
58
|
+
subagents: SubagentRun[];
|
|
59
|
+
/** Newest first. */
|
|
60
|
+
events: ActivityEvent[];
|
|
61
|
+
current: ActivityEvent | null;
|
|
62
|
+
todos: TodoLike[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const MAX_EVENTS = 40;
|
|
66
|
+
// Bookkeeping tools: the todos already show what they do.
|
|
67
|
+
const HIDDEN_TOOLS = /^(Task(Create|Update|List|Get|Stop|Output)|TodoWrite|ToolSearch|advisor|task_notification)$/;
|
|
68
|
+
|
|
69
|
+
/** A repo-relative path, or null when the path is outside the agent's folder. */
|
|
70
|
+
export function relativePath(path: string, cwd: string): string | null {
|
|
71
|
+
let value = path.trim().replace(/\\/g, "/");
|
|
72
|
+
const root = cwd.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
73
|
+
if (root && value.startsWith(`${root}/`)) value = value.slice(root.length + 1);
|
|
74
|
+
else if (value.startsWith("/") || /^[A-Za-z]:\//.test(value)) return null;
|
|
75
|
+
value = value.replace(/^(\.\/)+/, "");
|
|
76
|
+
if (!value || value.split("/").includes("..")) return null;
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function text(value: unknown): string | null {
|
|
81
|
+
return typeof value === "string" ? value : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function lineCount(value: string | null): number {
|
|
85
|
+
if (!value) return 0;
|
|
86
|
+
return value.replace(/\n$/, "").split("\n").length;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function diffCounts(detail: Record<string, unknown>): { added: number; removed: number } {
|
|
90
|
+
const diff = text(detail.unifiedDiff);
|
|
91
|
+
if (diff) {
|
|
92
|
+
let added = 0;
|
|
93
|
+
let removed = 0;
|
|
94
|
+
for (const line of diff.split("\n")) {
|
|
95
|
+
if (line.startsWith("+") && !line.startsWith("+++")) added += 1;
|
|
96
|
+
else if (line.startsWith("-") && !line.startsWith("---")) removed += 1;
|
|
97
|
+
}
|
|
98
|
+
return { added, removed };
|
|
99
|
+
}
|
|
100
|
+
return { added: lineCount(text(detail.newString)), removed: lineCount(text(detail.oldString)) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function toolStatus(value: unknown): ToolStatus {
|
|
104
|
+
return value === "running" || value === "failed" || value === "canceled" ? value : "completed";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function baseName(path: string): string {
|
|
108
|
+
return path.split("/").pop() ?? path;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Tool calls stream as repeated items with one callId. The last copy wins, in first position.
|
|
112
|
+
function latestToolCalls(items: readonly TimelineItemLike[]) {
|
|
113
|
+
const order: string[] = [];
|
|
114
|
+
const byId = new Map<string, TimelineItemLike>();
|
|
115
|
+
const started = new Map<string, number>();
|
|
116
|
+
items.forEach((item, index) => {
|
|
117
|
+
if (item.type !== "tool_call") return;
|
|
118
|
+
const id = text(item.callId) ?? `index-${index}`;
|
|
119
|
+
if (!byId.has(id)) {
|
|
120
|
+
order.push(id);
|
|
121
|
+
started.set(id, index);
|
|
122
|
+
}
|
|
123
|
+
byId.set(id, item);
|
|
124
|
+
});
|
|
125
|
+
return order.map((id) => ({ id, item: byId.get(id) as TimelineItemLike, start: started.get(id) ?? 0 }));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface FocusMark {
|
|
129
|
+
path: string;
|
|
130
|
+
/** Index of the item where the call started. */
|
|
131
|
+
at: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The newest point at which the in-progress todo moved to another issue key.
|
|
135
|
+
function todoFocus(items: readonly TimelineItemLike[]): { key: string; at: number } | null {
|
|
136
|
+
let focus: { key: string; at: number } | null = null;
|
|
137
|
+
let active: string | null = null;
|
|
138
|
+
items.forEach((item, index) => {
|
|
139
|
+
if (item.type !== "todo" || !Array.isArray(item.items)) return;
|
|
140
|
+
const doing = (item.items as TodoLike[]).find((todo) => todoStatus(todo) === "in_progress");
|
|
141
|
+
const key = doing ? todoKey(doing.text) : null;
|
|
142
|
+
if (key && key !== active) focus = { key, at: index };
|
|
143
|
+
active = key;
|
|
144
|
+
});
|
|
145
|
+
return focus;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function deriveActivity(items: readonly TimelineItemLike[], cwd: string): Activity {
|
|
149
|
+
const files = new Map<string, FileTouch>();
|
|
150
|
+
const dirs = new Map<string, DirTouch>();
|
|
151
|
+
const events: ActivityEvent[] = [];
|
|
152
|
+
const subagents = new Map<string, SubagentRun>();
|
|
153
|
+
const subagentEvents = new Map<string, ActivityEvent>();
|
|
154
|
+
const resolve = (path: string) => relativePath(path, cwd);
|
|
155
|
+
let todos: TodoLike[] = [];
|
|
156
|
+
for (const item of items) {
|
|
157
|
+
if (item.type === "todo" && Array.isArray(item.items)) todos = item.items as TodoLike[];
|
|
158
|
+
}
|
|
159
|
+
const touch = (path: string, kind: TouchKind, order: number) => {
|
|
160
|
+
const entry = files.get(path) ?? {
|
|
161
|
+
path,
|
|
162
|
+
reads: 0,
|
|
163
|
+
edits: 0,
|
|
164
|
+
added: 0,
|
|
165
|
+
removed: 0,
|
|
166
|
+
created: false,
|
|
167
|
+
order,
|
|
168
|
+
last: kind,
|
|
169
|
+
};
|
|
170
|
+
entry.order = order;
|
|
171
|
+
entry.last = kind;
|
|
172
|
+
files.set(path, entry);
|
|
173
|
+
return entry;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
let fileFocus: FocusMark | null = null;
|
|
177
|
+
let runningFocus: FocusMark | null = null;
|
|
178
|
+
latestToolCalls(items).forEach(({ id, item, start }, order) => {
|
|
179
|
+
if (HIDDEN_TOOLS.test(text(item.name) ?? "")) return;
|
|
180
|
+
const signal = subagentOf(item, resolve);
|
|
181
|
+
if (!signal && isSubagentCall(item)) return;
|
|
182
|
+
if (signal) {
|
|
183
|
+
// The launch call and the subagent item are one run, so they make one feed row.
|
|
184
|
+
const run = mergeSubagent(subagents, signal, order);
|
|
185
|
+
const known = subagentEvents.get(run.key);
|
|
186
|
+
const row: ActivityEvent = known ?? {
|
|
187
|
+
id,
|
|
188
|
+
kind: "agent",
|
|
189
|
+
text: run.description ? `${run.type}: ${run.description}` : run.type,
|
|
190
|
+
detail: null,
|
|
191
|
+
path: null,
|
|
192
|
+
paths: [],
|
|
193
|
+
status: run.status,
|
|
194
|
+
added: 0,
|
|
195
|
+
removed: 0,
|
|
196
|
+
exitCode: null,
|
|
197
|
+
};
|
|
198
|
+
row.status = run.status;
|
|
199
|
+
row.paths = run.targets;
|
|
200
|
+
row.path = run.targets[0] ?? null;
|
|
201
|
+
if (!known) {
|
|
202
|
+
subagentEvents.set(run.key, row);
|
|
203
|
+
events.push(row);
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const detail = (item.detail ?? {}) as Record<string, unknown>;
|
|
208
|
+
const status = toolStatus(item.status);
|
|
209
|
+
const event: ActivityEvent = {
|
|
210
|
+
id,
|
|
211
|
+
kind: "tool",
|
|
212
|
+
text: text(item.name) ?? "Tool call",
|
|
213
|
+
detail: null,
|
|
214
|
+
path: null,
|
|
215
|
+
paths: [],
|
|
216
|
+
status,
|
|
217
|
+
added: 0,
|
|
218
|
+
removed: 0,
|
|
219
|
+
exitCode: null,
|
|
220
|
+
};
|
|
221
|
+
const raw = text(detail.filePath);
|
|
222
|
+
const path = raw ? relativePath(raw, cwd) : null;
|
|
223
|
+
if (path) event.paths = [path];
|
|
224
|
+
if (detail.type === "read" && raw) {
|
|
225
|
+
event.kind = "read";
|
|
226
|
+
event.path = path;
|
|
227
|
+
event.text = `Read ${path ?? baseName(raw)}`;
|
|
228
|
+
if (path) touch(path, "read", order).reads += 1;
|
|
229
|
+
} else if ((detail.type === "edit" || detail.type === "write") && raw) {
|
|
230
|
+
const write = detail.type === "write";
|
|
231
|
+
const counts = write
|
|
232
|
+
? { added: lineCount(text(detail.content)), removed: 0 }
|
|
233
|
+
: diffCounts(detail);
|
|
234
|
+
event.kind = write ? "write" : "edit";
|
|
235
|
+
event.path = path;
|
|
236
|
+
event.added = counts.added;
|
|
237
|
+
event.removed = counts.removed;
|
|
238
|
+
event.text = `${write ? "Wrote" : "Edited"} ${path ?? baseName(raw)}`;
|
|
239
|
+
if (path && status !== "failed") {
|
|
240
|
+
const entry = touch(path, event.kind, order);
|
|
241
|
+
entry.edits += 1;
|
|
242
|
+
entry.added += counts.added;
|
|
243
|
+
entry.removed += counts.removed;
|
|
244
|
+
if (write && entry.reads === 0 && entry.edits === 1) entry.created = true;
|
|
245
|
+
}
|
|
246
|
+
} else if (detail.type === "search") {
|
|
247
|
+
event.kind = "search";
|
|
248
|
+
event.text = `Searched ${JSON.stringify(text(detail.query) ?? "")}`;
|
|
249
|
+
const hits = Array.isArray(detail.filePaths) ? detail.filePaths : [];
|
|
250
|
+
for (const hit of hits) {
|
|
251
|
+
const hitPath = typeof hit === "string" ? relativePath(hit, cwd) : null;
|
|
252
|
+
if (hitPath && !files.has(hitPath)) touch(hitPath, "search", order);
|
|
253
|
+
}
|
|
254
|
+
} else if (detail.type === "shell") {
|
|
255
|
+
const command = text(detail.command) ?? "";
|
|
256
|
+
const start = relativePath(text(detail.cwd) ?? "", cwd) ?? "";
|
|
257
|
+
const shell = shellActivity(command, cwd, start);
|
|
258
|
+
event.exitCode = typeof detail.exitCode === "number" ? detail.exitCode : null;
|
|
259
|
+
event.detail = displayCommand(command, cwd);
|
|
260
|
+
event.text = shell.summary;
|
|
261
|
+
event.paths = [...shell.writes.map((write) => write.path), ...shell.reads];
|
|
262
|
+
event.path = event.paths[0] ?? null;
|
|
263
|
+
event.kind = "shell";
|
|
264
|
+
if (shell.writes.length > 0) event.kind = "write";
|
|
265
|
+
else if (shell.reads.length > 0) event.kind = "read";
|
|
266
|
+
else if (shell.pattern !== null) event.kind = "search";
|
|
267
|
+
else if (shell.dirs.length > 0) event.kind = "list";
|
|
268
|
+
for (const read of shell.reads) touch(read, "read", order).reads += 1;
|
|
269
|
+
// A failed write may not have happened; reads count either way, as grep exits 1 on no match.
|
|
270
|
+
const succeeded = status === "completed" && (event.exitCode ?? 0) === 0;
|
|
271
|
+
for (const write of succeeded ? shell.writes : []) {
|
|
272
|
+
const entry = touch(write.path, write.mode, order);
|
|
273
|
+
entry.edits += 1;
|
|
274
|
+
if (write.mode === "write" && entry.reads === 0 && entry.edits === 1) entry.created = true;
|
|
275
|
+
}
|
|
276
|
+
for (const dir of shell.dirs) {
|
|
277
|
+
const known = dirs.get(dir.path);
|
|
278
|
+
dirs.set(dir.path, { path: dir.path, deep: dir.deep || (known?.deep ?? false), order });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const lead = event.path ?? event.paths[0];
|
|
282
|
+
if (lead && (event.kind === "read" || event.kind === "edit" || event.kind === "write")) {
|
|
283
|
+
if (!fileFocus || start >= fileFocus.at) fileFocus = { path: lead, at: start };
|
|
284
|
+
if (status === "running" && (!runningFocus || start >= runningFocus.at)) runningFocus = { path: lead, at: start };
|
|
285
|
+
}
|
|
286
|
+
events.push(event);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const recent = events.slice(-MAX_EVENTS).reverse();
|
|
290
|
+
const current = recent.find((event) => event.status === "running") ?? recent[0] ?? null;
|
|
291
|
+
// A running call wins; otherwise the newer of the last file and the last todo change.
|
|
292
|
+
const issueFocus = todoFocus(items);
|
|
293
|
+
const lastFile = fileFocus as FocusMark | null;
|
|
294
|
+
const running = runningFocus as FocusMark | null;
|
|
295
|
+
let focus: AgentFocus | null = null;
|
|
296
|
+
if (running) focus = { kind: "file", path: running.path };
|
|
297
|
+
else if (issueFocus && (!lastFile || issueFocus.at > lastFile.at)) focus = { kind: "issue", key: issueFocus.key };
|
|
298
|
+
else if (lastFile) focus = { kind: "file", path: lastFile.path };
|
|
299
|
+
return {
|
|
300
|
+
focus,
|
|
301
|
+
files: [...files.values()].sort((a, b) => b.order - a.order),
|
|
302
|
+
dirs: [...dirs.values()],
|
|
303
|
+
subagents: [...subagents.values()],
|
|
304
|
+
events: recent,
|
|
305
|
+
current,
|
|
306
|
+
todos,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Points, routes along them, and label placement for the Linear Live graph. Pure, so tests can
|
|
2
|
+
// check them without a renderer.
|
|
3
|
+
|
|
4
|
+
export interface Point {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface Route {
|
|
10
|
+
points: Point[];
|
|
11
|
+
/** Each point's share of the whole distance, from 0 to 1. */
|
|
12
|
+
stops: number[];
|
|
13
|
+
length: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Where a route is at a share of its distance, from 0 to 1. */
|
|
17
|
+
export function pointAt(path: Route, share: number): Point {
|
|
18
|
+
const at = Math.min(1, Math.max(0, share));
|
|
19
|
+
for (let index = 1; index < path.stops.length; index += 1) {
|
|
20
|
+
const stop = path.stops[index] as number;
|
|
21
|
+
if (at > stop) continue;
|
|
22
|
+
const before = path.stops[index - 1] as number;
|
|
23
|
+
const from = path.points[index - 1] as Point;
|
|
24
|
+
const to = path.points[index] as Point;
|
|
25
|
+
const part = stop > before ? (at - before) / (stop - before) : 1;
|
|
26
|
+
return { x: from.x + (to.x - from.x) * part, y: from.y + (to.y - from.y) * part };
|
|
27
|
+
}
|
|
28
|
+
return path.points[path.points.length - 1] as Point;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A route through the points without zero-length steps, or null when there is nowhere to go. */
|
|
32
|
+
export function route(points: readonly Point[]): Route | null {
|
|
33
|
+
const kept: Point[] = [];
|
|
34
|
+
const steps = [0];
|
|
35
|
+
for (const point of points) {
|
|
36
|
+
const last = kept[kept.length - 1];
|
|
37
|
+
if (!last) kept.push(point);
|
|
38
|
+
else {
|
|
39
|
+
const step = Math.hypot(point.x - last.x, point.y - last.y);
|
|
40
|
+
if (step < 0.5) continue;
|
|
41
|
+
kept.push(point);
|
|
42
|
+
steps.push((steps[steps.length - 1] as number) + step);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const length = steps[steps.length - 1] as number;
|
|
46
|
+
if (kept.length < 2) return null;
|
|
47
|
+
return { points: kept, stops: steps.map((step) => step / length), length };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type LabelSide = "below" | "above" | "left" | "right";
|
|
51
|
+
|
|
52
|
+
export interface Placed extends Point {
|
|
53
|
+
side: LabelSide;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Where a label of this size goes on its node's side. */
|
|
57
|
+
export function labelBox(at: Placed, radius: number, width: number, height: number): { left: number; top: number } {
|
|
58
|
+
if (at.side === "right") return { left: at.x + radius + 5, top: at.y - height / 2 };
|
|
59
|
+
if (at.side === "left") return { left: at.x - radius - 5 - width, top: at.y - height / 2 };
|
|
60
|
+
if (at.side === "above") return { left: at.x - width / 2, top: at.y - radius - 3 - height };
|
|
61
|
+
return { left: at.x - width / 2, top: at.y + radius + 3 };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The sides to try for a label, best first: the outward side, then the others. */
|
|
65
|
+
export function labelSides(side: LabelSide): LabelSide[] {
|
|
66
|
+
if (side === "left" || side === "right") return [side, "above", "below"];
|
|
67
|
+
return side === "below" ? ["below", "above", "right", "left"] : ["above", "below", "right", "left"];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface LabelRequest {
|
|
71
|
+
id: string;
|
|
72
|
+
width: number;
|
|
73
|
+
height: number;
|
|
74
|
+
/** Where the label may go, best first. */
|
|
75
|
+
spots: readonly { left: number; top: number }[];
|
|
76
|
+
/** A shorter label to try when the full one has no free spot. */
|
|
77
|
+
fallback?: { width: number; height: number; spots: readonly { left: number; top: number }[] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface PlacedLabel {
|
|
81
|
+
left: number;
|
|
82
|
+
top: number;
|
|
83
|
+
/** The fallback was used. */
|
|
84
|
+
short: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Places labels in request order at the first spot that overlaps no node or placed label,
|
|
88
|
+
// moved inside the box when it would cross an edge. A label with no free spot is hidden.
|
|
89
|
+
export function placeLabels(
|
|
90
|
+
requests: readonly LabelRequest[],
|
|
91
|
+
width: number,
|
|
92
|
+
height: number,
|
|
93
|
+
nodes: readonly { at: Point; radius: number }[] = [],
|
|
94
|
+
gap = 3,
|
|
95
|
+
): Map<string, PlacedLabel> {
|
|
96
|
+
const placed = nodes.map(({ at, radius }) => ({
|
|
97
|
+
left: at.x - radius,
|
|
98
|
+
right: at.x + radius,
|
|
99
|
+
top: at.y - radius,
|
|
100
|
+
bottom: at.y + radius,
|
|
101
|
+
}));
|
|
102
|
+
const shown = new Map<string, PlacedLabel>();
|
|
103
|
+
for (const request of requests) {
|
|
104
|
+
const tries = [{ ...request, short: false }];
|
|
105
|
+
if (request.fallback) tries.push({ ...request, ...request.fallback, short: true });
|
|
106
|
+
find: for (const attempt of tries) {
|
|
107
|
+
for (const spot of attempt.spots) {
|
|
108
|
+
const left = Math.min(Math.max(0, width - attempt.width), Math.max(0, spot.left));
|
|
109
|
+
const top = Math.min(Math.max(0, height - attempt.height), Math.max(0, spot.top));
|
|
110
|
+
const box = { left: left - gap, right: left + attempt.width + gap, top, bottom: top + attempt.height };
|
|
111
|
+
const hit = placed.some(
|
|
112
|
+
(other) => box.left < other.right && other.left < box.right && box.top < other.bottom && other.top < box.bottom,
|
|
113
|
+
);
|
|
114
|
+
if (hit) continue;
|
|
115
|
+
placed.push(box);
|
|
116
|
+
shown.set(request.id, { left, top, short: attempt.short });
|
|
117
|
+
break find;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return shown;
|
|
122
|
+
}
|