agenthud 0.3.1 → 0.4.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.
Files changed (2) hide show
  1. package/dist/index.js +864 -159
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -7,15 +7,21 @@ import { existsSync } from "fs";
7
7
 
8
8
  // src/ui/App.tsx
9
9
  import React, { useState, useEffect, useCallback, useMemo } from "react";
10
- import { Box as Box5, Text as Text5, useApp, useInput } from "ink";
10
+ import { Box as Box6, Text as Text6, useApp, useInput } from "ink";
11
11
 
12
12
  // src/ui/GitPanel.tsx
13
13
  import { Box, Text } from "ink";
14
14
 
15
15
  // src/ui/constants.ts
16
- var PANEL_WIDTH = 60;
17
- var CONTENT_WIDTH = PANEL_WIDTH - 4;
18
- var INNER_WIDTH = PANEL_WIDTH - 2;
16
+ var DEFAULT_PANEL_WIDTH = 70;
17
+ var CONTENT_WIDTH = DEFAULT_PANEL_WIDTH - 4;
18
+ var INNER_WIDTH = DEFAULT_PANEL_WIDTH - 2;
19
+ function getContentWidth(panelWidth) {
20
+ return panelWidth - 4;
21
+ }
22
+ function getInnerWidth(panelWidth) {
23
+ return panelWidth - 2;
24
+ }
19
25
  var BOX = {
20
26
  tl: "\u250C",
21
27
  tr: "\u2510",
@@ -26,18 +32,19 @@ var BOX = {
26
32
  ml: "\u251C",
27
33
  mr: "\u2524"
28
34
  };
29
- function createTitleLine(label, suffix = "") {
35
+ function createTitleLine(label, suffix = "", panelWidth = DEFAULT_PANEL_WIDTH) {
30
36
  const leftPart = BOX.h + " " + label + " ";
31
37
  const rightPart = suffix ? " " + suffix + " " + BOX.h : "";
32
- const dashCount = PANEL_WIDTH - 1 - leftPart.length - rightPart.length - 1;
38
+ const dashCount = panelWidth - 1 - leftPart.length - rightPart.length - 1;
33
39
  const dashes = BOX.h.repeat(Math.max(0, dashCount));
34
40
  return BOX.tl + leftPart + dashes + rightPart + BOX.tr;
35
41
  }
36
- function createBottomLine() {
37
- return BOX.bl + BOX.h.repeat(INNER_WIDTH) + BOX.br;
42
+ function createBottomLine(panelWidth = DEFAULT_PANEL_WIDTH) {
43
+ return BOX.bl + BOX.h.repeat(getInnerWidth(panelWidth)) + BOX.br;
38
44
  }
39
- function padLine(content) {
40
- const padding = INNER_WIDTH - content.length;
45
+ function padLine(content, panelWidth = DEFAULT_PANEL_WIDTH) {
46
+ const innerWidth = getInnerWidth(panelWidth);
47
+ const padding = innerWidth - content.length;
41
48
  return content + " ".repeat(Math.max(0, padding));
42
49
  }
43
50
  var SEPARATOR = "\u2500".repeat(CONTENT_WIDTH);
@@ -49,22 +56,25 @@ function truncate(text, maxLength) {
49
56
  // src/ui/GitPanel.tsx
50
57
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
51
58
  var MAX_COMMITS = 5;
52
- var MAX_MESSAGE_LENGTH = CONTENT_WIDTH - 10;
53
59
  function formatCountdown(seconds) {
54
60
  if (seconds == null) return "";
55
- return `\u21BB ${seconds}s`;
61
+ const padded = String(seconds).padStart(2, " ");
62
+ return `\u21BB ${padded}s`;
56
63
  }
57
- function GitPanel({ branch, commits, stats, uncommitted = 0, countdown }) {
58
- const countdownSuffix = formatCountdown(countdown);
64
+ function GitPanel({ branch, commits, stats, uncommitted = 0, countdown, width = DEFAULT_PANEL_WIDTH, isRunning = false, justRefreshed = false }) {
65
+ const countdownSuffix = isRunning ? "running..." : formatCountdown(countdown);
66
+ const innerWidth = getInnerWidth(width);
67
+ const contentWidth = getContentWidth(width);
68
+ const maxMessageLength = contentWidth - 10;
59
69
  if (branch === null) {
60
- return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", width: PANEL_WIDTH, children: [
61
- /* @__PURE__ */ jsx(Text, { children: createTitleLine("Git", countdownSuffix) }),
70
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", width, children: [
71
+ /* @__PURE__ */ jsx(Text, { children: createTitleLine("Git", countdownSuffix, width) }),
62
72
  /* @__PURE__ */ jsxs(Text, { children: [
63
73
  BOX.v,
64
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: padLine(" Not a git repository") }),
74
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: padLine(" Not a git repository", width) }),
65
75
  BOX.v
66
76
  ] }),
67
- /* @__PURE__ */ jsx(Text, { children: createBottomLine() })
77
+ /* @__PURE__ */ jsx(Text, { children: createBottomLine(width) })
68
78
  ] });
69
79
  }
70
80
  const displayCommits = commits.slice(0, MAX_COMMITS);
@@ -72,20 +82,23 @@ function GitPanel({ branch, commits, stats, uncommitted = 0, countdown }) {
72
82
  const commitWord = commits.length === 1 ? "commit" : "commits";
73
83
  const fileWord = stats.files === 1 ? "file" : "files";
74
84
  const hasUncommitted = uncommitted > 0;
75
- let branchLineLength = 1 + branch.length;
85
+ let statsSuffix = "";
76
86
  if (hasCommits) {
77
- branchLineLength += ` \xB7 +${stats.added} -${stats.deleted} \xB7 ${commits.length} ${commitWord} \xB7 ${stats.files} ${fileWord}`.length;
87
+ statsSuffix = ` \xB7 +${stats.added} -${stats.deleted} \xB7 ${commits.length} ${commitWord} \xB7 ${stats.files} ${fileWord}`;
78
88
  }
79
89
  if (hasUncommitted) {
80
- branchLineLength += ` \xB7 ${uncommitted} dirty`.length;
90
+ statsSuffix += ` \xB7 ${uncommitted} dirty`;
81
91
  }
82
- const branchPadding = Math.max(0, INNER_WIDTH - branchLineLength);
83
- return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", width: PANEL_WIDTH, children: [
84
- /* @__PURE__ */ jsx(Text, { children: createTitleLine("Git", countdownSuffix) }),
92
+ const availableForBranch = innerWidth - 1 - statsSuffix.length;
93
+ const displayBranch = availableForBranch > 3 ? truncate(branch, availableForBranch) : truncate(branch, 10);
94
+ const branchLineLength = 1 + displayBranch.length + statsSuffix.length;
95
+ const branchPadding = Math.max(0, innerWidth - branchLineLength);
96
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", width, children: [
97
+ /* @__PURE__ */ jsx(Text, { children: createTitleLine("Git", countdownSuffix, width) }),
85
98
  /* @__PURE__ */ jsxs(Text, { children: [
86
99
  BOX.v,
87
100
  " ",
88
- /* @__PURE__ */ jsx(Text, { color: "green", children: branch }),
101
+ /* @__PURE__ */ jsx(Text, { color: "green", children: displayBranch }),
89
102
  hasCommits && /* @__PURE__ */ jsxs(Fragment, { children: [
90
103
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: " \xB7 " }),
91
104
  /* @__PURE__ */ jsxs(Text, { color: "green", children: [
@@ -119,9 +132,9 @@ function GitPanel({ branch, commits, stats, uncommitted = 0, countdown }) {
119
132
  BOX.v
120
133
  ] }),
121
134
  hasCommits ? /* @__PURE__ */ jsx(Fragment, { children: displayCommits.map((commit) => {
122
- const msg = truncate(commit.message, MAX_MESSAGE_LENGTH);
135
+ const msg = truncate(commit.message, maxMessageLength);
123
136
  const lineLength = 3 + 7 + 1 + msg.length;
124
- const commitPadding = Math.max(0, INNER_WIDTH - lineLength);
137
+ const commitPadding = Math.max(0, innerWidth - lineLength);
125
138
  return /* @__PURE__ */ jsxs(Text, { children: [
126
139
  BOX.v,
127
140
  " \u2022 ",
@@ -133,10 +146,10 @@ function GitPanel({ branch, commits, stats, uncommitted = 0, countdown }) {
133
146
  ] }, commit.hash);
134
147
  }) }) : /* @__PURE__ */ jsxs(Text, { children: [
135
148
  BOX.v,
136
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: padLine(" No commits today") }),
149
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: padLine(" No commits today", width) }),
137
150
  BOX.v
138
151
  ] }),
139
- /* @__PURE__ */ jsx(Text, { children: createBottomLine() })
152
+ /* @__PURE__ */ jsx(Text, { children: createBottomLine(width) })
140
153
  ] });
141
154
  }
142
155
 
@@ -144,8 +157,6 @@ function GitPanel({ branch, commits, stats, uncommitted = 0, countdown }) {
144
157
  import { Box as Box2, Text as Text2 } from "ink";
145
158
  import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
146
159
  var PROGRESS_BAR_WIDTH = 10;
147
- var MAX_STEP_LENGTH = CONTENT_WIDTH - 2;
148
- var MAX_DECISION_LENGTH = CONTENT_WIDTH - 2;
149
160
  function createProgressBar(done, total) {
150
161
  if (total === 0) return "\u2591".repeat(PROGRESS_BAR_WIDTH);
151
162
  const filled = Math.round(done / total * PROGRESS_BAR_WIDTH);
@@ -154,75 +165,80 @@ function createProgressBar(done, total) {
154
165
  }
155
166
  function formatCountdown2(seconds) {
156
167
  if (seconds == null) return "";
157
- return `\u21BB ${seconds}s`;
168
+ const padded = String(seconds).padStart(2, " ");
169
+ return `\u21BB ${padded}s`;
158
170
  }
159
- function createPlanTitleLine(done, total, countdown) {
171
+ function createPlanTitleLine(done, total, countdown, panelWidth, suffixOverride) {
160
172
  const label = " Plan ";
161
173
  const count = ` ${done}/${total} `;
162
174
  const bar = createProgressBar(done, total);
163
- const countdownStr = formatCountdown2(countdown);
164
- const suffix = countdownStr ? ` \xB7 ${countdownStr} ` + BOX.h : "";
165
- const dashCount = PANEL_WIDTH - 3 - label.length - count.length - bar.length - suffix.length;
175
+ const suffixStr = suffixOverride || formatCountdown2(countdown);
176
+ const suffix = suffixStr ? ` \xB7 ${suffixStr} ` + BOX.h : "";
177
+ const dashCount = panelWidth - 3 - label.length - count.length - bar.length - suffix.length;
166
178
  const dashes = BOX.h.repeat(Math.max(0, dashCount));
167
179
  return BOX.tl + BOX.h + label + dashes + count + bar + suffix + BOX.tr;
168
180
  }
169
- function createSimpleTitleLine(countdown) {
181
+ function createSimpleTitleLine(countdown, panelWidth) {
170
182
  const label = " Plan ";
171
183
  const countdownStr = formatCountdown2(countdown);
172
184
  const suffix = countdownStr ? ` ${countdownStr} ` + BOX.h : "";
173
- const dashCount = PANEL_WIDTH - 3 - label.length - suffix.length;
185
+ const dashCount = panelWidth - 3 - label.length - suffix.length;
174
186
  const dashes = BOX.h.repeat(Math.max(0, dashCount));
175
187
  return BOX.tl + BOX.h + label + dashes + suffix + BOX.tr;
176
188
  }
177
- function createDecisionsHeader() {
189
+ function createDecisionsHeader(panelWidth) {
178
190
  const label = "\u2500 Decisions ";
179
- const dashCount = PANEL_WIDTH - 1 - label.length - 1;
191
+ const dashCount = panelWidth - 1 - label.length - 1;
180
192
  return label + "\u2500".repeat(dashCount) + "\u2524";
181
193
  }
182
- function PlanPanel({ plan, decisions, error, countdown }) {
194
+ function PlanPanel({ plan, decisions, error, countdown, width = DEFAULT_PANEL_WIDTH, justRefreshed = false, relativeTime }) {
195
+ const contentWidth = getContentWidth(width);
196
+ const maxStepLength = contentWidth - 2;
197
+ const maxDecisionLength = contentWidth - 2;
183
198
  if (error || !plan || !plan.goal || !plan.steps) {
184
- return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", width: PANEL_WIDTH, children: [
185
- /* @__PURE__ */ jsx2(Text2, { children: createSimpleTitleLine(countdown) }),
199
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", width, children: [
200
+ /* @__PURE__ */ jsx2(Text2, { children: createSimpleTitleLine(countdown, width) }),
186
201
  /* @__PURE__ */ jsxs2(Text2, { children: [
187
202
  BOX.v,
188
- padLine(" " + (error || "No plan found")),
203
+ padLine(" " + (error || "No plan found"), width),
189
204
  BOX.v
190
205
  ] }),
191
- /* @__PURE__ */ jsx2(Text2, { children: createBottomLine() })
206
+ /* @__PURE__ */ jsx2(Text2, { children: createBottomLine(width) })
192
207
  ] });
193
208
  }
194
209
  const doneCount = plan.steps.filter((s) => s.status === "done").length;
195
210
  const totalCount = plan.steps.length;
196
- return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", width: PANEL_WIDTH, children: [
197
- /* @__PURE__ */ jsx2(Text2, { children: createPlanTitleLine(doneCount, totalCount, countdown) }),
211
+ const titleSuffix = justRefreshed ? "just now" : relativeTime || void 0;
212
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", width, children: [
213
+ /* @__PURE__ */ jsx2(Text2, { children: createPlanTitleLine(doneCount, totalCount, countdown, width, titleSuffix) }),
198
214
  /* @__PURE__ */ jsxs2(Text2, { children: [
199
215
  BOX.v,
200
- padLine(" " + truncate(plan.goal, CONTENT_WIDTH)),
216
+ padLine(" " + truncate(plan.goal, contentWidth), width),
201
217
  BOX.v
202
218
  ] }),
203
219
  plan.steps.map((step, index) => {
204
- const stepText = " " + (step.status === "done" ? "\u2713" : step.status === "in-progress" ? "\u2192" : "\u25CB") + " " + truncate(step.step, MAX_STEP_LENGTH);
220
+ const stepText = " " + (step.status === "done" ? "\u2713" : step.status === "in-progress" ? "\u2192" : "\u25CB") + " " + truncate(step.step, maxStepLength);
205
221
  return /* @__PURE__ */ jsxs2(Text2, { children: [
206
222
  BOX.v,
207
- padLine(stepText),
223
+ padLine(stepText, width),
208
224
  BOX.v
209
- ] }, index);
225
+ ] }, `step-${index}`);
210
226
  }),
211
227
  decisions.length > 0 && /* @__PURE__ */ jsxs2(Fragment2, { children: [
212
228
  /* @__PURE__ */ jsxs2(Text2, { children: [
213
229
  "\u251C",
214
- /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: createDecisionsHeader() })
230
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: createDecisionsHeader(width) })
215
231
  ] }),
216
232
  decisions.map((decision, index) => {
217
- const decText = " \u2022 " + truncate(decision.decision, MAX_DECISION_LENGTH);
233
+ const decText = " \u2022 " + truncate(decision.decision, maxDecisionLength);
218
234
  return /* @__PURE__ */ jsxs2(Text2, { children: [
219
235
  BOX.v,
220
- /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: padLine(decText) }),
236
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: padLine(decText, width) }),
221
237
  BOX.v
222
- ] }, index);
238
+ ] }, `decision-${index}`);
223
239
  })
224
240
  ] }),
225
- /* @__PURE__ */ jsx2(Text2, { children: createBottomLine() })
241
+ /* @__PURE__ */ jsx2(Text2, { children: createBottomLine(width) })
226
242
  ] });
227
243
  }
228
244
 
@@ -241,28 +257,40 @@ function formatRelativeTime(timestamp) {
241
257
  if (diffHours < 24) return `${diffHours}h ago`;
242
258
  return `${diffDays}d ago`;
243
259
  }
244
- function createSeparator() {
245
- return BOX.ml + BOX.h.repeat(INNER_WIDTH) + BOX.mr;
260
+ function createSeparator(panelWidth) {
261
+ return BOX.ml + BOX.h.repeat(getInnerWidth(panelWidth)) + BOX.mr;
246
262
  }
247
263
  function TestPanel({
248
264
  results,
249
265
  isOutdated,
250
266
  commitsBehind,
251
- error
267
+ error,
268
+ width = DEFAULT_PANEL_WIDTH,
269
+ isRunning = false,
270
+ justCompleted = false
252
271
  }) {
272
+ const innerWidth = getInnerWidth(width);
273
+ const contentWidth = getContentWidth(width);
274
+ const getTitleSuffix = () => {
275
+ if (isRunning) return "running...";
276
+ if (justCompleted) return "just now";
277
+ if (results) return formatRelativeTime(results.timestamp);
278
+ return "";
279
+ };
280
+ const titleSuffix = getTitleSuffix();
253
281
  if (error || !results) {
254
- return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", width: PANEL_WIDTH, children: [
255
- /* @__PURE__ */ jsx3(Text3, { children: createTitleLine("Tests", "") }),
282
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", width, children: [
283
+ /* @__PURE__ */ jsx3(Text3, { children: createTitleLine("Tests", titleSuffix, width) }),
256
284
  /* @__PURE__ */ jsxs3(Text3, { children: [
257
285
  BOX.v,
258
- /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: padLine(" " + (error || "No test results")) }),
286
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: padLine(" " + (error || "No test results"), width) }),
259
287
  BOX.v
260
288
  ] }),
261
- /* @__PURE__ */ jsx3(Text3, { children: createBottomLine() })
289
+ /* @__PURE__ */ jsx3(Text3, { children: createBottomLine(width) })
262
290
  ] });
263
291
  }
264
292
  const hasFailures = results.failures.length > 0;
265
- const relativeTime = formatRelativeTime(results.timestamp);
293
+ const relativeTime = titleSuffix;
266
294
  let summaryLength = 1 + 2 + String(results.passed).length + " passed".length;
267
295
  if (results.failed > 0) {
268
296
  summaryLength += 2 + 2 + String(results.failed).length + " failed".length;
@@ -271,12 +299,12 @@ function TestPanel({
271
299
  summaryLength += 2 + 2 + String(results.skipped).length + " skipped".length;
272
300
  }
273
301
  summaryLength += " \xB7 ".length + results.hash.length;
274
- const summaryPadding = Math.max(0, INNER_WIDTH - summaryLength);
275
- return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", width: PANEL_WIDTH, children: [
276
- /* @__PURE__ */ jsx3(Text3, { children: createTitleLine("Tests", relativeTime) }),
302
+ const summaryPadding = Math.max(0, innerWidth - summaryLength);
303
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", width, children: [
304
+ /* @__PURE__ */ jsx3(Text3, { children: createTitleLine("Tests", relativeTime, width) }),
277
305
  isOutdated && /* @__PURE__ */ jsxs3(Text3, { children: [
278
306
  BOX.v,
279
- /* @__PURE__ */ jsx3(Text3, { color: "yellow", children: padLine(` \u26A0 Outdated (${commitsBehind} ${commitsBehind === 1 ? "commit" : "commits"} behind)`) }),
307
+ /* @__PURE__ */ jsx3(Text3, { color: "yellow", children: padLine(` \u26A0 Outdated (${commitsBehind} ${commitsBehind === 1 ? "commit" : "commits"} behind)`, width) }),
280
308
  BOX.v
281
309
  ] }),
282
310
  /* @__PURE__ */ jsxs3(Text3, { children: [
@@ -311,12 +339,12 @@ function TestPanel({
311
339
  BOX.v
312
340
  ] }),
313
341
  hasFailures && /* @__PURE__ */ jsxs3(Fragment3, { children: [
314
- /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: createSeparator() }),
342
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: createSeparator(width) }),
315
343
  results.failures.map((failure, index) => {
316
- const fileName = truncate(failure.file, CONTENT_WIDTH - 3);
317
- const filePadding = Math.max(0, INNER_WIDTH - 3 - fileName.length);
318
- const testName = truncate(failure.name, CONTENT_WIDTH - 5);
319
- const testPadding = Math.max(0, INNER_WIDTH - 5 - testName.length);
344
+ const fileName = truncate(failure.file, contentWidth - 3);
345
+ const filePadding = Math.max(0, innerWidth - 3 - fileName.length);
346
+ const testName = truncate(failure.name, contentWidth - 5);
347
+ const testPadding = Math.max(0, innerWidth - 5 - testName.length);
320
348
  return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
321
349
  /* @__PURE__ */ jsxs3(Text3, { children: [
322
350
  BOX.v,
@@ -336,33 +364,203 @@ function TestPanel({
336
364
  " ".repeat(testPadding),
337
365
  BOX.v
338
366
  ] })
339
- ] }, index);
367
+ ] }, `failure-${index}`);
340
368
  })
341
369
  ] }),
342
- /* @__PURE__ */ jsx3(Text3, { children: createBottomLine() })
370
+ /* @__PURE__ */ jsx3(Text3, { children: createBottomLine(width) })
343
371
  ] });
344
372
  }
345
373
 
346
- // src/ui/WelcomePanel.tsx
374
+ // src/ui/GenericPanel.tsx
347
375
  import { Box as Box4, Text as Text4 } from "ink";
348
- import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
376
+ import { Fragment as Fragment4, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
377
+ var PROGRESS_BAR_WIDTH2 = 10;
378
+ function createProgressBar2(done, total) {
379
+ if (total === 0) return "\u2591".repeat(PROGRESS_BAR_WIDTH2);
380
+ const filled = Math.round(done / total * PROGRESS_BAR_WIDTH2);
381
+ const empty = PROGRESS_BAR_WIDTH2 - filled;
382
+ return "\u2588".repeat(filled) + "\u2591".repeat(empty);
383
+ }
384
+ function formatTitleSuffix(countdown, relativeTime) {
385
+ if (countdown != null) {
386
+ const padded = String(countdown).padStart(2, " ");
387
+ return `\u21BB ${padded}s`;
388
+ }
389
+ if (relativeTime) return relativeTime;
390
+ return "";
391
+ }
392
+ function createProgressTitleLine(title, done, total, panelWidth, countdown, relativeTime) {
393
+ const label = ` ${title} `;
394
+ const count = ` ${done}/${total} `;
395
+ const bar = createProgressBar2(done, total);
396
+ const suffix = formatTitleSuffix(countdown, relativeTime);
397
+ const suffixPart = suffix ? ` \xB7 ${suffix} ` + BOX.h : "";
398
+ const dashCount = panelWidth - 3 - label.length - count.length - bar.length - suffixPart.length;
399
+ const dashes = BOX.h.repeat(Math.max(0, dashCount));
400
+ return BOX.tl + BOX.h + label + dashes + count + bar + suffixPart + BOX.tr;
401
+ }
402
+ function ListRenderer({ data, width }) {
403
+ const items = data.items || [];
404
+ const contentWidth = getContentWidth(width);
405
+ if (items.length === 0 && !data.summary) {
406
+ return /* @__PURE__ */ jsxs4(Text4, { children: [
407
+ BOX.v,
408
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: padLine(" No data", width) }),
409
+ BOX.v
410
+ ] });
411
+ }
412
+ return /* @__PURE__ */ jsxs4(Fragment4, { children: [
413
+ data.summary && /* @__PURE__ */ jsxs4(Text4, { children: [
414
+ BOX.v,
415
+ padLine(" " + truncate(data.summary, contentWidth), width),
416
+ BOX.v
417
+ ] }),
418
+ items.map((item, index) => /* @__PURE__ */ jsxs4(Text4, { children: [
419
+ BOX.v,
420
+ padLine(" \u2022 " + truncate(item.text, contentWidth - 3), width),
421
+ BOX.v
422
+ ] }, `list-item-${index}`)),
423
+ items.length === 0 && data.summary && null
424
+ ] });
425
+ }
426
+ function ProgressRenderer({ data, width }) {
427
+ const items = data.items || [];
428
+ const contentWidth = getContentWidth(width);
429
+ return /* @__PURE__ */ jsxs4(Fragment4, { children: [
430
+ data.summary && /* @__PURE__ */ jsxs4(Text4, { children: [
431
+ BOX.v,
432
+ padLine(" " + truncate(data.summary, contentWidth), width),
433
+ BOX.v
434
+ ] }),
435
+ items.map((item, index) => {
436
+ const icon = item.status === "done" ? "\u2713" : item.status === "failed" ? "\u2717" : "\u25CB";
437
+ const line = ` ${icon} ${truncate(item.text, contentWidth - 3)}`;
438
+ return /* @__PURE__ */ jsxs4(Text4, { children: [
439
+ BOX.v,
440
+ padLine(line, width),
441
+ BOX.v
442
+ ] }, `progress-item-${index}`);
443
+ }),
444
+ items.length === 0 && !data.summary && /* @__PURE__ */ jsxs4(Text4, { children: [
445
+ BOX.v,
446
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: padLine(" No data", width) }),
447
+ BOX.v
448
+ ] })
449
+ ] });
450
+ }
451
+ function StatusRenderer({ data, width }) {
452
+ const stats = data.stats || { passed: 0, failed: 0 };
453
+ const items = data.items?.filter((i) => i.status === "failed") || [];
454
+ const innerWidth = getInnerWidth(width);
455
+ const contentWidth = getContentWidth(width);
456
+ let summaryLength = 1 + 2 + String(stats.passed).length + " passed".length;
457
+ if (stats.failed > 0) {
458
+ summaryLength += 2 + 2 + String(stats.failed).length + " failed".length;
459
+ }
460
+ if (stats.skipped && stats.skipped > 0) {
461
+ summaryLength += 2 + 2 + String(stats.skipped).length + " skipped".length;
462
+ }
463
+ const summaryPadding = Math.max(0, innerWidth - summaryLength);
464
+ return /* @__PURE__ */ jsxs4(Fragment4, { children: [
465
+ data.summary && /* @__PURE__ */ jsxs4(Text4, { children: [
466
+ BOX.v,
467
+ padLine(" " + truncate(data.summary, contentWidth), width),
468
+ BOX.v
469
+ ] }),
470
+ /* @__PURE__ */ jsxs4(Text4, { children: [
471
+ BOX.v,
472
+ " ",
473
+ /* @__PURE__ */ jsxs4(Text4, { color: "green", children: [
474
+ "\u2713 ",
475
+ stats.passed,
476
+ " passed"
477
+ ] }),
478
+ stats.failed > 0 && /* @__PURE__ */ jsxs4(Fragment4, { children: [
479
+ " ",
480
+ /* @__PURE__ */ jsxs4(Text4, { color: "red", children: [
481
+ "\u2717 ",
482
+ stats.failed,
483
+ " failed"
484
+ ] })
485
+ ] }),
486
+ stats.skipped && stats.skipped > 0 && /* @__PURE__ */ jsxs4(Fragment4, { children: [
487
+ " ",
488
+ /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
489
+ "\u25CB ",
490
+ stats.skipped,
491
+ " skipped"
492
+ ] })
493
+ ] }),
494
+ " ".repeat(summaryPadding),
495
+ BOX.v
496
+ ] }),
497
+ items.length > 0 && items.map((item, index) => /* @__PURE__ */ jsxs4(Text4, { children: [
498
+ BOX.v,
499
+ padLine(" \u2022 " + truncate(item.text, contentWidth - 3), width),
500
+ BOX.v
501
+ ] }, `status-item-${index}`))
502
+ ] });
503
+ }
504
+ function GenericPanel({
505
+ data,
506
+ renderer = "list",
507
+ countdown,
508
+ relativeTime,
509
+ error,
510
+ width = DEFAULT_PANEL_WIDTH,
511
+ isRunning = false,
512
+ justRefreshed = false
513
+ }) {
514
+ const suffix = isRunning ? "running..." : formatTitleSuffix(countdown, relativeTime);
515
+ const suffixColor = isRunning ? "yellow" : justRefreshed ? "green" : void 0;
516
+ const progress = data.progress || { done: 0, total: 0 };
517
+ if (error) {
518
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", width, children: [
519
+ /* @__PURE__ */ jsx4(Text4, { children: createTitleLine(data.title, suffix, width) }),
520
+ /* @__PURE__ */ jsxs4(Text4, { children: [
521
+ BOX.v,
522
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: padLine(" " + error, width) }),
523
+ BOX.v
524
+ ] }),
525
+ /* @__PURE__ */ jsx4(Text4, { children: createBottomLine(width) })
526
+ ] });
527
+ }
528
+ if (renderer === "progress") {
529
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", width, children: [
530
+ /* @__PURE__ */ jsx4(Text4, { children: createProgressTitleLine(data.title, progress.done, progress.total, width, countdown, relativeTime) }),
531
+ /* @__PURE__ */ jsx4(ProgressRenderer, { data, width }),
532
+ /* @__PURE__ */ jsx4(Text4, { children: createBottomLine(width) })
533
+ ] });
534
+ }
535
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", width, children: [
536
+ /* @__PURE__ */ jsx4(Text4, { children: createTitleLine(data.title, suffix, width) }),
537
+ renderer === "status" ? /* @__PURE__ */ jsx4(StatusRenderer, { data, width }) : /* @__PURE__ */ jsx4(ListRenderer, { data, width }),
538
+ /* @__PURE__ */ jsx4(Text4, { children: createBottomLine(width) })
539
+ ] });
540
+ }
541
+
542
+ // src/ui/WelcomePanel.tsx
543
+ import { Box as Box5, Text as Text5 } from "ink";
544
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
349
545
  function WelcomePanel() {
350
- return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", borderStyle: "single", paddingX: 1, width: PANEL_WIDTH, children: [
351
- /* @__PURE__ */ jsx4(Box4, { marginTop: -1, children: /* @__PURE__ */ jsx4(Text4, { children: " Welcome to agenthud " }) }),
352
- /* @__PURE__ */ jsx4(Text4, { children: " " }),
353
- /* @__PURE__ */ jsx4(Text4, { children: " No .agenthud/ directory found." }),
354
- /* @__PURE__ */ jsx4(Text4, { children: " " }),
355
- /* @__PURE__ */ jsx4(Text4, { children: " Quick setup:" }),
356
- /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: " npx agenthud init" }),
357
- /* @__PURE__ */ jsx4(Text4, { children: " " }),
358
- /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: " Or visit: github.com/neochoon/agenthud" }),
359
- /* @__PURE__ */ jsx4(Text4, { children: " " }),
360
- /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: " Press q to quit" })
546
+ return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", borderStyle: "single", paddingX: 1, width: DEFAULT_PANEL_WIDTH, children: [
547
+ /* @__PURE__ */ jsx5(Box5, { marginTop: -1, children: /* @__PURE__ */ jsx5(Text5, { children: " Welcome to agenthud " }) }),
548
+ /* @__PURE__ */ jsx5(Text5, { children: " " }),
549
+ /* @__PURE__ */ jsx5(Text5, { children: " No .agenthud/ directory found." }),
550
+ /* @__PURE__ */ jsx5(Text5, { children: " " }),
551
+ /* @__PURE__ */ jsx5(Text5, { children: " Quick setup:" }),
552
+ /* @__PURE__ */ jsx5(Text5, { color: "cyan", children: " npx agenthud init" }),
553
+ /* @__PURE__ */ jsx5(Text5, { children: " " }),
554
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: " Or visit: github.com/neochoon/agenthud" }),
555
+ /* @__PURE__ */ jsx5(Text5, { children: " " }),
556
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: " Press q to quit" })
361
557
  ] });
362
558
  }
363
559
 
364
560
  // src/data/git.ts
365
- import { execSync as nodeExecSync } from "child_process";
561
+ import { execSync as nodeExecSync, exec as nodeExec } from "child_process";
562
+ import { promisify } from "util";
563
+ var execAsync = promisify(nodeExec);
366
564
  var execFn = (command, options2) => nodeExecSync(command, options2);
367
565
  function getUncommittedCount() {
368
566
  try {
@@ -432,6 +630,58 @@ function getGitData(config) {
432
630
  const uncommitted = getUncommittedCount();
433
631
  return { branch, commits, stats, uncommitted };
434
632
  }
633
+ async function getGitDataAsync(config) {
634
+ const commands = {
635
+ branch: config.command?.branch || DEFAULT_COMMANDS.branch,
636
+ commits: config.command?.commits || DEFAULT_COMMANDS.commits,
637
+ stats: config.command?.stats || DEFAULT_COMMANDS.stats
638
+ };
639
+ let branch = null;
640
+ try {
641
+ const { stdout } = await execAsync(commands.branch);
642
+ branch = stdout.trim();
643
+ } catch {
644
+ branch = null;
645
+ }
646
+ let commits = [];
647
+ try {
648
+ const { stdout } = await execAsync(commands.commits);
649
+ const lines = stdout.trim().split("\n").filter(Boolean);
650
+ commits = lines.map((line) => {
651
+ const [hash, timestamp, ...messageParts] = line.split("|");
652
+ return {
653
+ hash,
654
+ message: messageParts.join("|"),
655
+ timestamp: new Date(timestamp)
656
+ };
657
+ });
658
+ } catch {
659
+ commits = [];
660
+ }
661
+ let stats = { added: 0, deleted: 0, files: 0 };
662
+ try {
663
+ const { stdout } = await execAsync(commands.stats);
664
+ const lines = stdout.trim().split("\n").filter(Boolean);
665
+ let added = 0;
666
+ let deleted = 0;
667
+ const filesSet = /* @__PURE__ */ new Set();
668
+ for (const line of lines) {
669
+ const [addedStr, deletedStr, filename] = line.split(" ");
670
+ if (addedStr === "-" || deletedStr === "-") {
671
+ if (filename) filesSet.add(filename);
672
+ continue;
673
+ }
674
+ added += parseInt(addedStr, 10) || 0;
675
+ deleted += parseInt(deletedStr, 10) || 0;
676
+ if (filename) filesSet.add(filename);
677
+ }
678
+ stats = { added, deleted, files: filesSet.size };
679
+ } catch {
680
+ stats = { added: 0, deleted: 0, files: 0 };
681
+ }
682
+ const uncommitted = getUncommittedCount();
683
+ return { branch, commits, stats, uncommitted };
684
+ }
435
685
 
436
686
  // src/data/plan.ts
437
687
  import { readFileSync as nodeReadFileSync } from "fs";
@@ -512,9 +762,171 @@ function getTestData(dir = process.cwd()) {
512
762
  return { results, isOutdated, commitsBehind, error };
513
763
  }
514
764
 
765
+ // src/data/custom.ts
766
+ import { execSync as nodeExecSync3, exec as nodeExec2 } from "child_process";
767
+ import { readFileSync as nodeReadFileSync3, promises as fsPromises } from "fs";
768
+ import { promisify as promisify2 } from "util";
769
+ var execAsync2 = promisify2(nodeExec2);
770
+ var execFn2 = (cmd, options2) => nodeExecSync3(cmd, options2);
771
+ var readFileFn3 = (path) => nodeReadFileSync3(path, "utf-8");
772
+ function capitalizeFirst(str) {
773
+ return str.charAt(0).toUpperCase() + str.slice(1);
774
+ }
775
+ function getCustomPanelData(name, panelConfig) {
776
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
777
+ const defaultData = {
778
+ title: capitalizeFirst(name)
779
+ };
780
+ if (panelConfig.command) {
781
+ try {
782
+ const output = execFn2(panelConfig.command, { encoding: "utf-8" }).trim();
783
+ try {
784
+ const parsed = JSON.parse(output);
785
+ return {
786
+ data: {
787
+ title: parsed.title || capitalizeFirst(name),
788
+ summary: parsed.summary,
789
+ items: parsed.items,
790
+ progress: parsed.progress,
791
+ stats: parsed.stats
792
+ },
793
+ timestamp
794
+ };
795
+ } catch {
796
+ const lines = output.split("\n").filter((l) => l.trim());
797
+ return {
798
+ data: {
799
+ title: capitalizeFirst(name),
800
+ items: lines.map((text) => ({ text }))
801
+ },
802
+ timestamp
803
+ };
804
+ }
805
+ } catch (error) {
806
+ const message = error instanceof Error ? error.message : String(error);
807
+ return {
808
+ data: defaultData,
809
+ error: `Command failed: ${message.split("\n")[0]}`,
810
+ timestamp
811
+ };
812
+ }
813
+ }
814
+ if (panelConfig.source) {
815
+ try {
816
+ const content = readFileFn3(panelConfig.source);
817
+ const parsed = JSON.parse(content);
818
+ return {
819
+ data: {
820
+ title: parsed.title || capitalizeFirst(name),
821
+ summary: parsed.summary,
822
+ items: parsed.items,
823
+ progress: parsed.progress,
824
+ stats: parsed.stats
825
+ },
826
+ timestamp
827
+ };
828
+ } catch (error) {
829
+ const message = error instanceof Error ? error.message : String(error);
830
+ if (message.includes("ENOENT")) {
831
+ return {
832
+ data: defaultData,
833
+ error: "File not found",
834
+ timestamp
835
+ };
836
+ }
837
+ return {
838
+ data: defaultData,
839
+ error: "Invalid JSON",
840
+ timestamp
841
+ };
842
+ }
843
+ }
844
+ return {
845
+ data: defaultData,
846
+ error: "No command or source configured",
847
+ timestamp
848
+ };
849
+ }
850
+ async function getCustomPanelDataAsync(name, panelConfig) {
851
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
852
+ const defaultData = {
853
+ title: capitalizeFirst(name)
854
+ };
855
+ if (panelConfig.command) {
856
+ try {
857
+ const { stdout } = await execAsync2(panelConfig.command);
858
+ const output = stdout.trim();
859
+ try {
860
+ const parsed = JSON.parse(output);
861
+ return {
862
+ data: {
863
+ title: parsed.title || capitalizeFirst(name),
864
+ summary: parsed.summary,
865
+ items: parsed.items,
866
+ progress: parsed.progress,
867
+ stats: parsed.stats
868
+ },
869
+ timestamp
870
+ };
871
+ } catch {
872
+ const lines = output.split("\n").filter((l) => l.trim());
873
+ return {
874
+ data: {
875
+ title: capitalizeFirst(name),
876
+ items: lines.map((text) => ({ text }))
877
+ },
878
+ timestamp
879
+ };
880
+ }
881
+ } catch (error) {
882
+ const message = error instanceof Error ? error.message : String(error);
883
+ return {
884
+ data: defaultData,
885
+ error: `Command failed: ${message.split("\n")[0]}`,
886
+ timestamp
887
+ };
888
+ }
889
+ }
890
+ if (panelConfig.source) {
891
+ try {
892
+ const content = await fsPromises.readFile(panelConfig.source, "utf-8");
893
+ const parsed = JSON.parse(content);
894
+ return {
895
+ data: {
896
+ title: parsed.title || capitalizeFirst(name),
897
+ summary: parsed.summary,
898
+ items: parsed.items,
899
+ progress: parsed.progress,
900
+ stats: parsed.stats
901
+ },
902
+ timestamp
903
+ };
904
+ } catch (error) {
905
+ const message = error instanceof Error ? error.message : String(error);
906
+ if (message.includes("ENOENT")) {
907
+ return {
908
+ data: defaultData,
909
+ error: "File not found",
910
+ timestamp
911
+ };
912
+ }
913
+ return {
914
+ data: defaultData,
915
+ error: "Invalid JSON",
916
+ timestamp
917
+ };
918
+ }
919
+ }
920
+ return {
921
+ data: defaultData,
922
+ error: "No command or source configured",
923
+ timestamp
924
+ };
925
+ }
926
+
515
927
  // src/runner/command.ts
516
- import { execSync as nodeExecSync3 } from "child_process";
517
- var execFn2 = (command, options2) => nodeExecSync3(command, options2);
928
+ import { execSync as nodeExecSync4 } from "child_process";
929
+ var execFn3 = (command, options2) => nodeExecSync4(command, options2);
518
930
  function parseVitestOutput(output) {
519
931
  try {
520
932
  const data = JSON.parse(output);
@@ -544,7 +956,7 @@ function parseVitestOutput(output) {
544
956
  }
545
957
  function getHeadHash() {
546
958
  try {
547
- return execFn2("git rev-parse --short HEAD", {
959
+ return execFn3("git rev-parse --short HEAD", {
548
960
  encoding: "utf-8",
549
961
  stdio: ["pipe", "pipe", "pipe"]
550
962
  }).trim();
@@ -555,7 +967,7 @@ function getHeadHash() {
555
967
  function runTestCommand(command) {
556
968
  let output;
557
969
  try {
558
- output = execFn2(command, {
970
+ output = execFn3(command, {
559
971
  encoding: "utf-8",
560
972
  stdio: ["pipe", "pipe", "pipe"]
561
973
  });
@@ -597,13 +1009,16 @@ function runTestCommand(command) {
597
1009
  // src/config/parser.ts
598
1010
  import {
599
1011
  existsSync as nodeExistsSync,
600
- readFileSync as nodeReadFileSync3
1012
+ readFileSync as nodeReadFileSync4
601
1013
  } from "fs";
602
1014
  import { parse as parseYaml } from "yaml";
603
1015
  var fs = {
604
1016
  existsSync: nodeExistsSync,
605
- readFileSync: (path) => nodeReadFileSync3(path, "utf-8")
1017
+ readFileSync: (path) => nodeReadFileSync4(path, "utf-8")
606
1018
  };
1019
+ var DEFAULT_WIDTH = 70;
1020
+ var MIN_WIDTH = 50;
1021
+ var MAX_WIDTH = 120;
607
1022
  var CONFIG_PATH = ".agenthud/config.yaml";
608
1023
  function parseInterval(interval) {
609
1024
  if (!interval || interval === "manual") {
@@ -641,10 +1056,13 @@ function getDefaultConfig() {
641
1056
  interval: null
642
1057
  // manual
643
1058
  }
644
- }
1059
+ },
1060
+ panelOrder: ["git", "plan", "tests"],
1061
+ width: DEFAULT_WIDTH
645
1062
  };
646
1063
  }
647
- var VALID_PANELS = ["git", "plan", "tests"];
1064
+ var BUILTIN_PANELS = ["git", "plan", "tests"];
1065
+ var VALID_RENDERERS = ["list", "progress", "status"];
648
1066
  function parseConfig() {
649
1067
  const warnings = [];
650
1068
  const defaultConfig = getDefaultConfig();
@@ -664,16 +1082,26 @@ function parseConfig() {
664
1082
  return { config: defaultConfig, warnings };
665
1083
  }
666
1084
  const parsed = rawConfig;
1085
+ const config = getDefaultConfig();
1086
+ if (typeof parsed.width === "number") {
1087
+ if (parsed.width < MIN_WIDTH) {
1088
+ warnings.push(`Width ${parsed.width} is too small, using minimum of ${MIN_WIDTH}`);
1089
+ config.width = MIN_WIDTH;
1090
+ } else if (parsed.width > MAX_WIDTH) {
1091
+ warnings.push(`Width ${parsed.width} is too large, using maximum of ${MAX_WIDTH}`);
1092
+ config.width = MAX_WIDTH;
1093
+ } else {
1094
+ config.width = parsed.width;
1095
+ }
1096
+ }
667
1097
  const panels = parsed.panels;
668
1098
  if (!panels || typeof panels !== "object") {
669
- return { config: defaultConfig, warnings };
1099
+ return { config, warnings };
670
1100
  }
671
- const config = getDefaultConfig();
1101
+ const customPanels = {};
1102
+ const panelOrder = [];
672
1103
  for (const panelName of Object.keys(panels)) {
673
- if (!VALID_PANELS.includes(panelName)) {
674
- warnings.push(`Unknown panel '${panelName}' in config`);
675
- continue;
676
- }
1104
+ panelOrder.push(panelName);
677
1105
  const panelConfig = panels[panelName];
678
1106
  if (!panelConfig || typeof panelConfig !== "object") {
679
1107
  continue;
@@ -690,6 +1118,7 @@ function parseConfig() {
690
1118
  config.panels.git.interval = interval;
691
1119
  }
692
1120
  }
1121
+ continue;
693
1122
  }
694
1123
  if (panelName === "plan") {
695
1124
  if (typeof panelConfig.enabled === "boolean") {
@@ -706,6 +1135,7 @@ function parseConfig() {
706
1135
  if (typeof panelConfig.source === "string") {
707
1136
  config.panels.plan.source = panelConfig.source;
708
1137
  }
1138
+ continue;
709
1139
  }
710
1140
  if (panelName === "tests") {
711
1141
  if (typeof panelConfig.enabled === "boolean") {
@@ -722,16 +1152,57 @@ function parseConfig() {
722
1152
  if (typeof panelConfig.command === "string") {
723
1153
  config.panels.tests.command = panelConfig.command;
724
1154
  }
1155
+ continue;
1156
+ }
1157
+ const customPanel = {
1158
+ enabled: typeof panelConfig.enabled === "boolean" ? panelConfig.enabled : true,
1159
+ interval: 3e4,
1160
+ // default 30s
1161
+ renderer: "list"
1162
+ // default
1163
+ };
1164
+ if (typeof panelConfig.interval === "string") {
1165
+ const interval = parseInterval(panelConfig.interval);
1166
+ customPanel.interval = interval;
1167
+ }
1168
+ if (typeof panelConfig.command === "string") {
1169
+ customPanel.command = panelConfig.command;
1170
+ }
1171
+ if (typeof panelConfig.source === "string") {
1172
+ customPanel.source = panelConfig.source;
1173
+ }
1174
+ if (typeof panelConfig.renderer === "string") {
1175
+ if (VALID_RENDERERS.includes(panelConfig.renderer)) {
1176
+ customPanel.renderer = panelConfig.renderer;
1177
+ } else {
1178
+ warnings.push(`Invalid renderer '${panelConfig.renderer}' for custom panel, using 'list'`);
1179
+ }
1180
+ }
1181
+ customPanels[panelName] = customPanel;
1182
+ }
1183
+ if (Object.keys(customPanels).length > 0) {
1184
+ config.customPanels = customPanels;
1185
+ }
1186
+ for (const builtIn of BUILTIN_PANELS) {
1187
+ if (!panelOrder.includes(builtIn)) {
1188
+ panelOrder.push(builtIn);
725
1189
  }
726
1190
  }
1191
+ config.panelOrder = panelOrder;
727
1192
  return { config, warnings };
728
1193
  }
729
1194
 
730
1195
  // src/ui/App.tsx
731
- import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1196
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
1197
+ var DEFAULT_VISUAL_STATE = {
1198
+ isRunning: false,
1199
+ justRefreshed: false,
1200
+ justCompleted: false
1201
+ };
1202
+ var FEEDBACK_DURATION = 1500;
732
1203
  function generateHotkeys(config, actions) {
733
1204
  const hotkeys = [];
734
- const usedKeys = /* @__PURE__ */ new Set();
1205
+ const usedKeys = /* @__PURE__ */ new Set(["r", "q"]);
735
1206
  if (config.panels.tests.enabled && config.panels.tests.interval === null && actions.tests) {
736
1207
  const name = "tests";
737
1208
  for (const char of name.toLowerCase()) {
@@ -746,10 +1217,39 @@ function generateHotkeys(config, actions) {
746
1217
  }
747
1218
  }
748
1219
  }
1220
+ if (config.customPanels && actions.customPanels) {
1221
+ for (const [name, panelConfig] of Object.entries(config.customPanels)) {
1222
+ if (panelConfig.enabled && panelConfig.interval === null && actions.customPanels[name]) {
1223
+ for (const char of name.toLowerCase()) {
1224
+ if (!usedKeys.has(char)) {
1225
+ usedKeys.add(char);
1226
+ hotkeys.push({
1227
+ key: char,
1228
+ label: `run ${name}`,
1229
+ action: actions.customPanels[name]
1230
+ });
1231
+ break;
1232
+ }
1233
+ }
1234
+ }
1235
+ }
1236
+ }
749
1237
  return hotkeys;
750
1238
  }
1239
+ function formatRelativeTime2(timestamp) {
1240
+ const now = Date.now();
1241
+ const then = new Date(timestamp).getTime();
1242
+ const diffMs = now - then;
1243
+ const diffMins = Math.floor(diffMs / 6e4);
1244
+ const diffHours = Math.floor(diffMs / 36e5);
1245
+ const diffDays = Math.floor(diffMs / 864e5);
1246
+ if (diffMins < 1) return "just now";
1247
+ if (diffMins < 60) return `${diffMins}m ago`;
1248
+ if (diffHours < 24) return `${diffHours}h ago`;
1249
+ return `${diffDays}d ago`;
1250
+ }
751
1251
  function WelcomeApp() {
752
- return /* @__PURE__ */ jsx5(WelcomePanel, {});
1252
+ return /* @__PURE__ */ jsx6(WelcomePanel, {});
753
1253
  }
754
1254
  function DashboardApp({ mode }) {
755
1255
  const { exit } = useApp();
@@ -774,26 +1274,160 @@ function DashboardApp({ mode }) {
774
1274
  const refreshTest = useCallback(() => {
775
1275
  setTestData(getTestDataFromConfig());
776
1276
  }, [getTestDataFromConfig]);
777
- const [countdowns, setCountdowns] = useState({
778
- git: gitIntervalSeconds,
779
- plan: planIntervalSeconds
1277
+ const customPanelNames = useMemo(
1278
+ () => Object.keys(config.customPanels || {}),
1279
+ [config.customPanels]
1280
+ );
1281
+ const [customPanelData, setCustomPanelData] = useState(() => {
1282
+ const data = {};
1283
+ if (config.customPanels) {
1284
+ for (const [name, panelConfig] of Object.entries(config.customPanels)) {
1285
+ if (panelConfig.enabled) {
1286
+ data[name] = getCustomPanelData(name, panelConfig);
1287
+ }
1288
+ }
1289
+ }
1290
+ return data;
780
1291
  });
781
- const refreshAll = useCallback(() => {
1292
+ const refreshCustomPanel = useCallback(
1293
+ (name) => {
1294
+ if (config.customPanels && config.customPanels[name]) {
1295
+ setCustomPanelData((prev) => ({
1296
+ ...prev,
1297
+ [name]: getCustomPanelData(name, config.customPanels[name])
1298
+ }));
1299
+ }
1300
+ },
1301
+ [config.customPanels]
1302
+ );
1303
+ const initialCountdowns = useMemo(() => {
1304
+ const countdowns2 = {
1305
+ git: gitIntervalSeconds,
1306
+ plan: planIntervalSeconds
1307
+ };
1308
+ if (config.customPanels) {
1309
+ for (const [name, panelConfig] of Object.entries(config.customPanels)) {
1310
+ countdowns2[name] = panelConfig.interval ? panelConfig.interval / 1e3 : null;
1311
+ }
1312
+ }
1313
+ return countdowns2;
1314
+ }, [gitIntervalSeconds, planIntervalSeconds, config.customPanels]);
1315
+ const [countdowns, setCountdowns] = useState(initialCountdowns);
1316
+ const initialVisualStates = useMemo(() => {
1317
+ const states = {
1318
+ git: { ...DEFAULT_VISUAL_STATE },
1319
+ plan: { ...DEFAULT_VISUAL_STATE },
1320
+ tests: { ...DEFAULT_VISUAL_STATE }
1321
+ };
1322
+ for (const name of customPanelNames) {
1323
+ states[name] = { ...DEFAULT_VISUAL_STATE };
1324
+ }
1325
+ return states;
1326
+ }, [customPanelNames]);
1327
+ const [visualStates, setVisualStates] = useState(initialVisualStates);
1328
+ const setVisualState = useCallback((panel, update) => {
1329
+ setVisualStates((prev) => ({
1330
+ ...prev,
1331
+ [panel]: { ...prev[panel], ...update }
1332
+ }));
1333
+ }, []);
1334
+ const clearFeedback = useCallback((panel, key) => {
1335
+ setTimeout(() => {
1336
+ setVisualState(panel, { [key]: false });
1337
+ }, FEEDBACK_DURATION);
1338
+ }, [setVisualState]);
1339
+ const refreshGitAsync = useCallback(async () => {
1340
+ setVisualState("git", { isRunning: true });
1341
+ try {
1342
+ const data = await getGitDataAsync(config.panels.git);
1343
+ setGitData(data);
1344
+ } finally {
1345
+ setVisualState("git", { isRunning: false, justRefreshed: true });
1346
+ clearFeedback("git", "justRefreshed");
1347
+ }
1348
+ }, [config.panels.git, setVisualState, clearFeedback]);
1349
+ const refreshCustomPanelAsync = useCallback(
1350
+ async (name) => {
1351
+ if (config.customPanels && config.customPanels[name]) {
1352
+ setVisualState(name, { isRunning: true });
1353
+ try {
1354
+ const result = await getCustomPanelDataAsync(name, config.customPanels[name]);
1355
+ setCustomPanelData((prev) => ({
1356
+ ...prev,
1357
+ [name]: result
1358
+ }));
1359
+ } finally {
1360
+ setVisualState(name, { isRunning: false, justRefreshed: true });
1361
+ clearFeedback(name, "justRefreshed");
1362
+ }
1363
+ }
1364
+ },
1365
+ [config.customPanels, setVisualState, clearFeedback]
1366
+ );
1367
+ const refreshTestAsync = useCallback(async () => {
1368
+ setVisualState("tests", { isRunning: true });
1369
+ try {
1370
+ await new Promise((resolve) => {
1371
+ setTimeout(() => {
1372
+ setTestData(getTestDataFromConfig());
1373
+ resolve();
1374
+ }, 0);
1375
+ });
1376
+ } finally {
1377
+ setVisualState("tests", { isRunning: false, justCompleted: true });
1378
+ clearFeedback("tests", "justCompleted");
1379
+ }
1380
+ }, [getTestDataFromConfig, setVisualState, clearFeedback]);
1381
+ const refreshPlanWithFeedback = useCallback(() => {
1382
+ setPlanData(getPlanDataWithConfig(config.panels.plan));
1383
+ setVisualState("plan", { justRefreshed: true });
1384
+ clearFeedback("plan", "justRefreshed");
1385
+ }, [config.panels.plan, setVisualState, clearFeedback]);
1386
+ const customPanelActionsAsync = useMemo(() => {
1387
+ const actions = {};
1388
+ for (const name of customPanelNames) {
1389
+ actions[name] = () => void refreshCustomPanelAsync(name);
1390
+ }
1391
+ return actions;
1392
+ }, [customPanelNames, refreshCustomPanelAsync]);
1393
+ const refreshAll = useCallback(async () => {
782
1394
  if (config.panels.git.enabled) {
783
- refreshGit();
1395
+ void refreshGitAsync();
784
1396
  setCountdowns((prev) => ({ ...prev, git: gitIntervalSeconds }));
785
1397
  }
786
1398
  if (config.panels.plan.enabled) {
787
- refreshPlan();
1399
+ refreshPlanWithFeedback();
788
1400
  setCountdowns((prev) => ({ ...prev, plan: planIntervalSeconds }));
789
1401
  }
790
1402
  if (config.panels.tests.enabled) {
791
- refreshTest();
1403
+ void refreshTestAsync();
792
1404
  }
793
- }, [refreshGit, refreshPlan, refreshTest, config, gitIntervalSeconds, planIntervalSeconds]);
1405
+ for (const name of customPanelNames) {
1406
+ if (config.customPanels[name].enabled) {
1407
+ void refreshCustomPanelAsync(name);
1408
+ const interval = config.customPanels[name].interval;
1409
+ setCountdowns((prev) => ({
1410
+ ...prev,
1411
+ [name]: interval ? interval / 1e3 : null
1412
+ }));
1413
+ }
1414
+ }
1415
+ }, [
1416
+ refreshGitAsync,
1417
+ refreshPlanWithFeedback,
1418
+ refreshTestAsync,
1419
+ refreshCustomPanelAsync,
1420
+ config,
1421
+ gitIntervalSeconds,
1422
+ planIntervalSeconds,
1423
+ customPanelNames
1424
+ ]);
794
1425
  const hotkeys = useMemo(
795
- () => generateHotkeys(config, { tests: refreshTest }),
796
- [config, refreshTest]
1426
+ () => generateHotkeys(config, {
1427
+ tests: () => void refreshTestAsync(),
1428
+ customPanels: customPanelActionsAsync
1429
+ }),
1430
+ [config, refreshTestAsync, customPanelActionsAsync]
797
1431
  );
798
1432
  useEffect(() => {
799
1433
  if (mode !== "watch") return;
@@ -801,7 +1435,7 @@ function DashboardApp({ mode }) {
801
1435
  if (config.panels.git.enabled && config.panels.git.interval !== null) {
802
1436
  timers.push(
803
1437
  setInterval(() => {
804
- refreshGit();
1438
+ void refreshGitAsync();
805
1439
  setCountdowns((prev) => ({ ...prev, git: gitIntervalSeconds }));
806
1440
  }, config.panels.git.interval)
807
1441
  );
@@ -809,26 +1443,54 @@ function DashboardApp({ mode }) {
809
1443
  if (config.panels.plan.enabled && config.panels.plan.interval !== null) {
810
1444
  timers.push(
811
1445
  setInterval(() => {
812
- refreshPlan();
1446
+ refreshPlanWithFeedback();
813
1447
  setCountdowns((prev) => ({ ...prev, plan: planIntervalSeconds }));
814
1448
  }, config.panels.plan.interval)
815
1449
  );
816
1450
  }
817
1451
  if (config.panels.tests.enabled && config.panels.tests.interval !== null) {
818
- timers.push(setInterval(refreshTest, config.panels.tests.interval));
1452
+ timers.push(setInterval(() => void refreshTestAsync(), config.panels.tests.interval));
1453
+ }
1454
+ if (config.customPanels) {
1455
+ for (const [name, panelConfig] of Object.entries(config.customPanels)) {
1456
+ if (panelConfig.enabled && panelConfig.interval !== null) {
1457
+ const intervalSeconds = panelConfig.interval / 1e3;
1458
+ timers.push(
1459
+ setInterval(() => {
1460
+ void refreshCustomPanelAsync(name);
1461
+ setCountdowns((prev) => ({ ...prev, [name]: intervalSeconds }));
1462
+ }, panelConfig.interval)
1463
+ );
1464
+ }
1465
+ }
819
1466
  }
820
1467
  return () => timers.forEach((t) => clearInterval(t));
821
- }, [mode, config, refreshGit, refreshPlan, refreshTest, gitIntervalSeconds, planIntervalSeconds]);
1468
+ }, [
1469
+ mode,
1470
+ config,
1471
+ refreshGitAsync,
1472
+ refreshPlanWithFeedback,
1473
+ refreshTestAsync,
1474
+ refreshCustomPanelAsync,
1475
+ gitIntervalSeconds,
1476
+ planIntervalSeconds
1477
+ ]);
822
1478
  useEffect(() => {
823
1479
  if (mode !== "watch") return;
824
1480
  const tick = setInterval(() => {
825
- setCountdowns((prev) => ({
826
- git: prev.git !== null && prev.git > 1 ? prev.git - 1 : prev.git,
827
- plan: prev.plan !== null && prev.plan > 1 ? prev.plan - 1 : prev.plan
828
- }));
1481
+ setCountdowns((prev) => {
1482
+ const next = {
1483
+ git: prev.git !== null && prev.git > 1 ? prev.git - 1 : prev.git,
1484
+ plan: prev.plan !== null && prev.plan > 1 ? prev.plan - 1 : prev.plan
1485
+ };
1486
+ for (const name of customPanelNames) {
1487
+ next[name] = prev[name] !== null && prev[name] > 1 ? prev[name] - 1 : prev[name];
1488
+ }
1489
+ return next;
1490
+ });
829
1491
  }, 1e3);
830
1492
  return () => clearInterval(tick);
831
- }, [mode]);
1493
+ }, [mode, customPanelNames]);
832
1494
  useInput(
833
1495
  (input) => {
834
1496
  if (input === "q") {
@@ -850,44 +1512,87 @@ function DashboardApp({ mode }) {
850
1512
  for (const hotkey of hotkeys) {
851
1513
  statusBarItems.push(`${hotkey.key}: ${hotkey.label}`);
852
1514
  }
853
- statusBarItems.push("r: refresh");
1515
+ statusBarItems.push("r: refresh all");
854
1516
  statusBarItems.push("q: quit");
855
- return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", children: [
856
- warnings.length > 0 && /* @__PURE__ */ jsx5(Box5, { marginBottom: 1, children: /* @__PURE__ */ jsxs5(Text5, { color: "yellow", children: [
1517
+ return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", children: [
1518
+ warnings.length > 0 && /* @__PURE__ */ jsx6(Box6, { marginBottom: 1, children: /* @__PURE__ */ jsxs6(Text6, { color: "yellow", children: [
857
1519
  "\u26A0 ",
858
1520
  warnings.join(", ")
859
1521
  ] }) }),
860
- config.panels.git.enabled && /* @__PURE__ */ jsx5(Box5, { children: /* @__PURE__ */ jsx5(
861
- GitPanel,
862
- {
863
- branch: gitData.branch,
864
- commits: gitData.commits,
865
- stats: gitData.stats,
866
- uncommitted: gitData.uncommitted,
867
- countdown: mode === "watch" ? countdowns.git : null
1522
+ config.panelOrder.map((panelName, index) => {
1523
+ const isFirst = index === 0;
1524
+ if (panelName === "git" && config.panels.git.enabled) {
1525
+ const gitVisual = visualStates.git || DEFAULT_VISUAL_STATE;
1526
+ return /* @__PURE__ */ jsx6(Box6, { marginTop: isFirst ? 0 : 1, children: /* @__PURE__ */ jsx6(
1527
+ GitPanel,
1528
+ {
1529
+ branch: gitData.branch,
1530
+ commits: gitData.commits,
1531
+ stats: gitData.stats,
1532
+ uncommitted: gitData.uncommitted,
1533
+ countdown: mode === "watch" ? countdowns.git : null,
1534
+ width: config.width,
1535
+ isRunning: gitVisual.isRunning,
1536
+ justRefreshed: gitVisual.justRefreshed
1537
+ }
1538
+ ) }, `panel-git-${index}`);
868
1539
  }
869
- ) }),
870
- config.panels.plan.enabled && /* @__PURE__ */ jsx5(Box5, { marginTop: config.panels.git.enabled ? 1 : 0, children: /* @__PURE__ */ jsx5(
871
- PlanPanel,
872
- {
873
- plan: planData.plan,
874
- decisions: planData.decisions,
875
- error: planData.error,
876
- countdown: mode === "watch" ? countdowns.plan : null
1540
+ if (panelName === "plan" && config.panels.plan.enabled) {
1541
+ const planVisual = visualStates.plan || DEFAULT_VISUAL_STATE;
1542
+ return /* @__PURE__ */ jsx6(Box6, { marginTop: isFirst ? 0 : 1, children: /* @__PURE__ */ jsx6(
1543
+ PlanPanel,
1544
+ {
1545
+ plan: planData.plan,
1546
+ decisions: planData.decisions,
1547
+ error: planData.error,
1548
+ countdown: mode === "watch" ? countdowns.plan : null,
1549
+ width: config.width,
1550
+ justRefreshed: planVisual.justRefreshed
1551
+ }
1552
+ ) }, `panel-plan-${index}`);
877
1553
  }
878
- ) }),
879
- config.panels.tests.enabled && /* @__PURE__ */ jsx5(Box5, { marginTop: config.panels.git.enabled || config.panels.plan.enabled ? 1 : 0, children: /* @__PURE__ */ jsx5(
880
- TestPanel,
881
- {
882
- results: testData.results,
883
- isOutdated: testData.isOutdated,
884
- commitsBehind: testData.commitsBehind,
885
- error: testData.error
1554
+ if (panelName === "tests" && config.panels.tests.enabled) {
1555
+ const testsVisual = visualStates.tests || DEFAULT_VISUAL_STATE;
1556
+ return /* @__PURE__ */ jsx6(Box6, { marginTop: isFirst ? 0 : 1, children: /* @__PURE__ */ jsx6(
1557
+ TestPanel,
1558
+ {
1559
+ results: testData.results,
1560
+ isOutdated: testData.isOutdated,
1561
+ commitsBehind: testData.commitsBehind,
1562
+ error: testData.error,
1563
+ width: config.width,
1564
+ isRunning: testsVisual.isRunning,
1565
+ justCompleted: testsVisual.justCompleted
1566
+ }
1567
+ ) }, `panel-tests-${index}`);
886
1568
  }
887
- ) }),
888
- mode === "watch" && /* @__PURE__ */ jsx5(Box5, { marginTop: 1, width: PANEL_WIDTH, children: /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: statusBarItems.map((item, index) => /* @__PURE__ */ jsxs5(React.Fragment, { children: [
1569
+ const customConfig = config.customPanels?.[panelName];
1570
+ if (customConfig && customConfig.enabled) {
1571
+ const result = customPanelData[panelName];
1572
+ if (!result) return null;
1573
+ const customVisual = visualStates[panelName] || DEFAULT_VISUAL_STATE;
1574
+ const isManual = customConfig.interval === null;
1575
+ const relativeTime = isManual ? formatRelativeTime2(result.timestamp) : void 0;
1576
+ const countdown = !isManual && mode === "watch" ? countdowns[panelName] : null;
1577
+ return /* @__PURE__ */ jsx6(Box6, { marginTop: isFirst ? 0 : 1, children: /* @__PURE__ */ jsx6(
1578
+ GenericPanel,
1579
+ {
1580
+ data: result.data,
1581
+ renderer: customConfig.renderer,
1582
+ countdown,
1583
+ relativeTime,
1584
+ error: result.error,
1585
+ width: config.width,
1586
+ isRunning: customVisual.isRunning,
1587
+ justRefreshed: customVisual.justRefreshed
1588
+ }
1589
+ ) }, `panel-${panelName}-${index}`);
1590
+ }
1591
+ return null;
1592
+ }),
1593
+ mode === "watch" && /* @__PURE__ */ jsx6(Box6, { marginTop: 1, width: config.width, children: /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: statusBarItems.map((item, index) => /* @__PURE__ */ jsxs6(React.Fragment, { children: [
889
1594
  index > 0 && " \xB7 ",
890
- /* @__PURE__ */ jsxs5(Text5, { color: "cyan", children: [
1595
+ /* @__PURE__ */ jsxs6(Text6, { color: "cyan", children: [
891
1596
  item.split(":")[0],
892
1597
  ":"
893
1598
  ] }),
@@ -897,9 +1602,9 @@ function DashboardApp({ mode }) {
897
1602
  }
898
1603
  function App({ mode, agentDirExists: agentDirExists2 = true }) {
899
1604
  if (!agentDirExists2) {
900
- return /* @__PURE__ */ jsx5(WelcomeApp, {});
1605
+ return /* @__PURE__ */ jsx6(WelcomeApp, {});
901
1606
  }
902
- return /* @__PURE__ */ jsx5(DashboardApp, { mode });
1607
+ return /* @__PURE__ */ jsx6(DashboardApp, { mode });
903
1608
  }
904
1609
 
905
1610
  // src/cli.ts
@@ -922,14 +1627,14 @@ import {
922
1627
  existsSync as nodeExistsSync2,
923
1628
  mkdirSync as nodeMkdirSync,
924
1629
  writeFileSync as nodeWriteFileSync,
925
- readFileSync as nodeReadFileSync4,
1630
+ readFileSync as nodeReadFileSync5,
926
1631
  appendFileSync as nodeAppendFileSync
927
1632
  } from "fs";
928
1633
  var fs2 = {
929
1634
  existsSync: nodeExistsSync2,
930
1635
  mkdirSync: nodeMkdirSync,
931
1636
  writeFileSync: nodeWriteFileSync,
932
- readFileSync: (path) => nodeReadFileSync4(path, "utf-8"),
1637
+ readFileSync: (path) => nodeReadFileSync5(path, "utf-8"),
933
1638
  appendFileSync: nodeAppendFileSync
934
1639
  };
935
1640
  var AGENT_STATE_SECTION = `## Agent State