Skills, mods, and hooks for working with tailfin sessions and tasks inside Claude Code

mod/register.js 282 lines1const PANE = "tailfin-inbox";
2const TASKS_URL = "http://127.0.0.1:7421/tasks";
3const POLL_INTERVAL_MS = 5_000;
4const BRIEFING_PREVIEW_ROWS = 2;
5const ELLIPSIS = "...";
6// Pane padding, card border and card padding, on both sides, plus one spare.
7const CARD_CHROME_COLUMNS = 7;
8const STATES = ["ready", "opened", "waiting", "done"];
9const STATE_LABELS = {
10 ready: "Ready",
11 opened: "Opened",
12 waiting: "Waiting",
13 done: "Done",
14};
15const STATE_COLORS = {
16 ready: "green",
17 opened: "cyan",
18 waiting: "#f97316",
19 done: "gray",
20};
21const SESSION_ID_PATTERN =
22 /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
23
24let tasks = [];
25let isDaemonOnline = false;
26let hasLoaded = false;
27const briefedAtByTaskId = new Map();
28
29// A second claude://resume imports the session again as a duplicate tab.
30const openedSessionIds = new Set();
31
32export function register(on) {
33 on("session.start", async ($, e, next) => {
34 const saved = await $.store.get("openedSessionIds");
35 if (Array.isArray(saved)) {
36 for (const sessionId of saved) openedSessionIds.add(sessionId);
37 }
38
39 $.clock.every(POLL_INTERVAL_MS, () => refresh($));
40 refresh($);
41
42 await $.command.register({
43 name: "inbox",
44 description: "Open the tailfin task inbox",
45 });
46 return next(e);
47 });
48
49 on("command.run", { command: "inbox" }, async ($) => {
50 await $.ui.open({
51 id: PANE,
52 title: "tailfin inbox",
53 focus: true,
54 closeOnEscape: true,
55 });
56 return {};
57 });
58
59 on("ui.render", { component: "Pane" }, async ($, e, next) => {
60 if (e.requestId !== PANE) return next(e);
61
62 const elements = $.ui.resolve(e);
63 return elements.Box({
64 flexDirection: "column",
65 rowGap: 1,
66 paddingX: 1,
67 children: [
68 header(elements),
69 ...tasks.map((task) =>
70 taskRow($, elements, task, e.props.bodyColumns),
71 ),
72 ],
73 });
74 });
75}
76
77async function refresh($) {
78 let fetched;
79 try {
80 const response = await $.http.fetch(TASKS_URL);
81 if (!response.ok) throw new Error(`HTTP ${response.status}`);
82 fetched = JSON.parse(response.text).tasks;
83 } catch {
84 isDaemonOnline = false;
85 tasks = [];
86 $.ui.status("");
87 $.ui.invalidate("ui.render");
88 return;
89 }
90
91 for (const task of fetched) {
92 const previous = briefedAtByTaskId.get(task.id);
93 // The first load only records, so briefings from before don't toast.
94 if (hasLoaded && task.briefedAt && task.briefedAt !== previous) {
95 $.ui.toast(`Briefing ready: ${task.title}`);
96 }
97 briefedAtByTaskId.set(task.id, task.briefedAt);
98 }
99 hasLoaded = true;
100 isDaemonOnline = true;
101 tasks = fetched;
102
103 showStatus($);
104 $.ui.invalidate("ui.render");
105}
106
107async function openInDesktop($, task) {
108 // The id goes into a URL, so only a well-formed one is let through.
109 if (!SESSION_ID_PATTERN.test(task.sessionId)) return;
110
111 const cwd = encodeURIComponent(task.workingDirectory);
112 const result = await $.process.run([
113 "open",
114 `claude://resume?session=${task.sessionId}&cwd=${cwd}`,
115 ]);
116 if (result.exitCode !== 0) {
117 $.ui.toast(`Could not open the session: ${result.stderr}`);
118 return;
119 }
120
121 openedSessionIds.add(task.sessionId);
122 await $.store.set("openedSessionIds", [...openedSessionIds]);
123 showStatus($);
124 $.ui.invalidate("ui.render");
125}
126
127function stateOf(task) {
128 if (task.status === "done") return "done";
129 if (task.sessionId && openedSessionIds.has(task.sessionId)) return "opened";
130 if (task.briefing && task.sessionId) return "ready";
131 return "waiting";
132}
133
134function countByState() {
135 const counts = { ready: 0, opened: 0, waiting: 0, done: 0 };
136 for (const task of tasks) counts[stateOf(task)] += 1;
137 return counts;
138}
139
140function summarize() {
141 const counts = countByState();
142 return STATES.map(
143 (state) => `${STATE_LABELS[state]} ${counts[state]}`,
144 ).join(" · ");
145}
146
147// The pane scrolls as a whole, so only the line under the prompt stays put.
148function showStatus($) {
149 if (tasks.every((task) => stateOf(task) === "done")) {
150 $.ui.status("");
151 return;
152 }
153 $.ui.status(`${summarize()} (/inbox)`);
154}
155
156function header({ Box, Text }) {
157 if (!isDaemonOnline) {
158 return Text({
159 dimColor: true,
160 children: [`tailfin daemon is offline (${TASKS_URL})`],
161 });
162 }
163 if (tasks.length === 0) {
164 return Text({ dimColor: true, children: ["No tasks."] });
165 }
166 const counts = countByState();
167 return Box({
168 flexDirection: "row",
169 columnGap: 2,
170 children: STATES.map((state) =>
171 Text({
172 color: STATE_COLORS[state],
173 bold: counts[state] > 0,
174 dimColor: counts[state] === 0,
175 children: [`● ${STATE_LABELS[state]} ${counts[state]}`],
176 }),
177 ),
178 });
179}
180
181// Hangul and CJK take two terminal columns, everything else one.
182function columnsOf(text) {
183 let columns = 0;
184 for (const character of text) {
185 const code = character.codePointAt(0);
186 const isWide =
187 (code >= 0x1100 && code <= 0x115f) ||
188 (code >= 0x2e80 && code <= 0xa4cf) ||
189 (code >= 0xac00 && code <= 0xd7a3) ||
190 (code >= 0xf900 && code <= 0xfaff) ||
191 (code >= 0xff00 && code <= 0xff60) ||
192 (code >= 0x1f300 && code <= 0x1faff);
193 if (isWide) {
194 columns += 2;
195 } else {
196 columns += 1;
197 }
198 }
199 return columns;
200}
201
202/**
203 * Cuts the text into whole rows itself. A Box with a fixed height clips by
204 * pixel, and a third wrapped line then shows up half cut at the edges.
205 */
206function wrapRows(text, width, maxRows) {
207 const rows = [""];
208 for (const character of text) {
209 if (columnsOf(rows[rows.length - 1] + character) <= width) {
210 rows[rows.length - 1] += character;
211 continue;
212 }
213 if (rows.length === maxRows) {
214 let last = rows[rows.length - 1];
215 while (last && columnsOf(last + ELLIPSIS) > width) {
216 last = last.slice(0, -1);
217 }
218 rows[rows.length - 1] = last + ELLIPSIS;
219 return rows;
220 }
221 rows.push(character);
222 }
223 return rows;
224}
225
226function taskRow($, { Box, Text, Button }, task, bodyColumns = 60) {
227 const state = stateOf(task);
228
229 const children = [
230 Box({
231 flexDirection: "row",
232 justifyContent: "space-between",
233 columnGap: 2,
234 children: [
235 Text({ bold: true, children: [task.title] }),
236 Text({
237 color: STATE_COLORS[state],
238 children: [`● ${STATE_LABELS[state]}`],
239 }),
240 ],
241 }),
242 ];
243
244 if (task.briefing) {
245 const width = Math.max(10, bodyColumns - CARD_CHROME_COLUMNS);
246 const rows = wrapRows(
247 task.briefing.replace(/\s+/g, " ").trim(),
248 width,
249 BRIEFING_PREVIEW_ROWS,
250 );
251 for (const row of rows) {
252 children.push(
253 Text({ dimColor: true, wrap: "truncate-end", children: [row] }),
254 );
255 }
256 } else {
257 children.push(
258 Text({ dimColor: true, children: ["Briefing not ready yet."] }),
259 );
260 }
261
262 if (state === "ready" || state === "opened") {
263 children.push(
264 Button({
265 key: `open-${task.id}`,
266 label: "Open",
267 onPress: () => openInDesktop($, task),
268 }),
269 );
270 }
271
272 return Box({
273 key: `task-${task.id}`,
274 flexDirection: "column",
275 borderStyle: "round",
276 borderColor: STATE_COLORS[state],
277 paddingX: 1,
278 paddingY: 1,
279 children,
280 });
281}
282