agenthud 0.3.0 → 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 +1332 -235
  2. package/package.json +3 -2
package/dist/index.js CHANGED
@@ -6,15 +6,47 @@ import { render } from "ink";
6
6
  import { existsSync } from "fs";
7
7
 
8
8
  // src/ui/App.tsx
9
- import { useState, useEffect, useCallback } from "react";
10
- import { Box as Box5, Text as Text5, useApp, useInput } from "ink";
9
+ import React, { useState, useEffect, useCallback, useMemo } from "react";
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;
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
+ }
25
+ var BOX = {
26
+ tl: "\u250C",
27
+ tr: "\u2510",
28
+ bl: "\u2514",
29
+ br: "\u2518",
30
+ h: "\u2500",
31
+ v: "\u2502",
32
+ ml: "\u251C",
33
+ mr: "\u2524"
34
+ };
35
+ function createTitleLine(label, suffix = "", panelWidth = DEFAULT_PANEL_WIDTH) {
36
+ const leftPart = BOX.h + " " + label + " ";
37
+ const rightPart = suffix ? " " + suffix + " " + BOX.h : "";
38
+ const dashCount = panelWidth - 1 - leftPart.length - rightPart.length - 1;
39
+ const dashes = BOX.h.repeat(Math.max(0, dashCount));
40
+ return BOX.tl + leftPart + dashes + rightPart + BOX.tr;
41
+ }
42
+ function createBottomLine(panelWidth = DEFAULT_PANEL_WIDTH) {
43
+ return BOX.bl + BOX.h.repeat(getInnerWidth(panelWidth)) + BOX.br;
44
+ }
45
+ function padLine(content, panelWidth = DEFAULT_PANEL_WIDTH) {
46
+ const innerWidth = getInnerWidth(panelWidth);
47
+ const padding = innerWidth - content.length;
48
+ return content + " ".repeat(Math.max(0, padding));
49
+ }
18
50
  var SEPARATOR = "\u2500".repeat(CONTENT_WIDTH);
19
51
  function truncate(text, maxLength) {
20
52
  if (text.length <= maxLength) return text;
@@ -24,12 +56,25 @@ function truncate(text, maxLength) {
24
56
  // src/ui/GitPanel.tsx
25
57
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
26
58
  var MAX_COMMITS = 5;
27
- var MAX_MESSAGE_LENGTH = CONTENT_WIDTH - 10;
28
- function GitPanel({ branch, commits, stats, uncommitted = 0 }) {
59
+ function formatCountdown(seconds) {
60
+ if (seconds == null) return "";
61
+ const padded = String(seconds).padStart(2, " ");
62
+ return `\u21BB ${padded}s`;
63
+ }
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;
29
69
  if (branch === null) {
30
- return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "single", paddingX: 1, width: PANEL_WIDTH, children: [
31
- /* @__PURE__ */ jsx(Box, { marginTop: -1, children: /* @__PURE__ */ jsx(Text, { children: " Git " }) }),
32
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Not a git repository" })
70
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", width, children: [
71
+ /* @__PURE__ */ jsx(Text, { children: createTitleLine("Git", countdownSuffix, width) }),
72
+ /* @__PURE__ */ jsxs(Text, { children: [
73
+ BOX.v,
74
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: padLine(" Not a git repository", width) }),
75
+ BOX.v
76
+ ] }),
77
+ /* @__PURE__ */ jsx(Text, { children: createBottomLine(width) })
33
78
  ] });
34
79
  }
35
80
  const displayCommits = commits.slice(0, MAX_COMMITS);
@@ -37,10 +82,23 @@ function GitPanel({ branch, commits, stats, uncommitted = 0 }) {
37
82
  const commitWord = commits.length === 1 ? "commit" : "commits";
38
83
  const fileWord = stats.files === 1 ? "file" : "files";
39
84
  const hasUncommitted = uncommitted > 0;
40
- return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "single", paddingX: 1, width: PANEL_WIDTH, children: [
41
- /* @__PURE__ */ jsx(Box, { marginTop: -1, children: /* @__PURE__ */ jsx(Text, { children: " Git " }) }),
85
+ let statsSuffix = "";
86
+ if (hasCommits) {
87
+ statsSuffix = ` \xB7 +${stats.added} -${stats.deleted} \xB7 ${commits.length} ${commitWord} \xB7 ${stats.files} ${fileWord}`;
88
+ }
89
+ if (hasUncommitted) {
90
+ statsSuffix += ` \xB7 ${uncommitted} dirty`;
91
+ }
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) }),
42
98
  /* @__PURE__ */ jsxs(Text, { children: [
43
- /* @__PURE__ */ jsx(Text, { color: "green", children: branch }),
99
+ BOX.v,
100
+ " ",
101
+ /* @__PURE__ */ jsx(Text, { color: "green", children: displayBranch }),
44
102
  hasCommits && /* @__PURE__ */ jsxs(Fragment, { children: [
45
103
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: " \xB7 " }),
46
104
  /* @__PURE__ */ jsxs(Text, { color: "green", children: [
@@ -69,101 +127,118 @@ function GitPanel({ branch, commits, stats, uncommitted = 0 }) {
69
127
  uncommitted,
70
128
  " dirty"
71
129
  ] })
72
- ] })
130
+ ] }),
131
+ " ".repeat(branchPadding),
132
+ BOX.v
73
133
  ] }),
74
- hasCommits ? /* @__PURE__ */ jsx(Fragment, { children: displayCommits.map((commit) => /* @__PURE__ */ jsxs(Text, { children: [
75
- "\u2022 ",
76
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: commit.hash.slice(0, 7) }),
77
- " ",
78
- truncate(commit.message, MAX_MESSAGE_LENGTH)
79
- ] }, commit.hash)) }) : /* @__PURE__ */ jsx(Text, { dimColor: true, children: "No commits today" })
134
+ hasCommits ? /* @__PURE__ */ jsx(Fragment, { children: displayCommits.map((commit) => {
135
+ const msg = truncate(commit.message, maxMessageLength);
136
+ const lineLength = 3 + 7 + 1 + msg.length;
137
+ const commitPadding = Math.max(0, innerWidth - lineLength);
138
+ return /* @__PURE__ */ jsxs(Text, { children: [
139
+ BOX.v,
140
+ " \u2022 ",
141
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: commit.hash.slice(0, 7) }),
142
+ " ",
143
+ msg,
144
+ " ".repeat(commitPadding),
145
+ BOX.v
146
+ ] }, commit.hash);
147
+ }) }) : /* @__PURE__ */ jsxs(Text, { children: [
148
+ BOX.v,
149
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: padLine(" No commits today", width) }),
150
+ BOX.v
151
+ ] }),
152
+ /* @__PURE__ */ jsx(Text, { children: createBottomLine(width) })
80
153
  ] });
81
154
  }
82
155
 
83
156
  // src/ui/PlanPanel.tsx
84
157
  import { Box as Box2, Text as Text2 } from "ink";
85
158
  import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
86
- var BOX = { tl: "\u250C", tr: "\u2510", bl: "\u2514", br: "\u2518", h: "\u2500", v: "\u2502" };
87
159
  var PROGRESS_BAR_WIDTH = 10;
88
- var MAX_STEP_LENGTH = CONTENT_WIDTH - 2;
89
- var MAX_DECISION_LENGTH = CONTENT_WIDTH - 2;
90
160
  function createProgressBar(done, total) {
91
161
  if (total === 0) return "\u2591".repeat(PROGRESS_BAR_WIDTH);
92
162
  const filled = Math.round(done / total * PROGRESS_BAR_WIDTH);
93
163
  const empty = PROGRESS_BAR_WIDTH - filled;
94
164
  return "\u2588".repeat(filled) + "\u2591".repeat(empty);
95
165
  }
96
- var INNER_WIDTH = PANEL_WIDTH - 2;
97
- function createTitleLine(done, total) {
166
+ function formatCountdown2(seconds) {
167
+ if (seconds == null) return "";
168
+ const padded = String(seconds).padStart(2, " ");
169
+ return `\u21BB ${padded}s`;
170
+ }
171
+ function createPlanTitleLine(done, total, countdown, panelWidth, suffixOverride) {
98
172
  const label = " Plan ";
99
173
  const count = ` ${done}/${total} `;
100
174
  const bar = createProgressBar(done, total);
101
- const dashCount = PANEL_WIDTH - 3 - label.length - count.length - bar.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;
102
178
  const dashes = BOX.h.repeat(Math.max(0, dashCount));
103
- return BOX.tl + BOX.h + label + dashes + count + bar + BOX.tr;
104
- }
105
- function createBottomLine() {
106
- return BOX.bl + BOX.h.repeat(INNER_WIDTH) + BOX.br;
179
+ return BOX.tl + BOX.h + label + dashes + count + bar + suffix + BOX.tr;
107
180
  }
108
- function padLine(content) {
109
- const padding = INNER_WIDTH - content.length;
110
- return content + " ".repeat(Math.max(0, padding));
181
+ function createSimpleTitleLine(countdown, panelWidth) {
182
+ const label = " Plan ";
183
+ const countdownStr = formatCountdown2(countdown);
184
+ const suffix = countdownStr ? ` ${countdownStr} ` + BOX.h : "";
185
+ const dashCount = panelWidth - 3 - label.length - suffix.length;
186
+ const dashes = BOX.h.repeat(Math.max(0, dashCount));
187
+ return BOX.tl + BOX.h + label + dashes + suffix + BOX.tr;
111
188
  }
112
- function createDecisionsHeader() {
189
+ function createDecisionsHeader(panelWidth) {
113
190
  const label = "\u2500 Decisions ";
114
- const dashCount = PANEL_WIDTH - 1 - label.length - 1;
191
+ const dashCount = panelWidth - 1 - label.length - 1;
115
192
  return label + "\u2500".repeat(dashCount) + "\u2524";
116
193
  }
117
- function PlanPanel({ plan, decisions, error }) {
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;
118
198
  if (error || !plan || !plan.goal || !plan.steps) {
119
- return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", width: PANEL_WIDTH, children: [
120
- /* @__PURE__ */ jsxs2(Text2, { children: [
121
- BOX.tl,
122
- BOX.h,
123
- " Plan ",
124
- BOX.h.repeat(INNER_WIDTH - 7),
125
- BOX.tr
126
- ] }),
199
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", width, children: [
200
+ /* @__PURE__ */ jsx2(Text2, { children: createSimpleTitleLine(countdown, width) }),
127
201
  /* @__PURE__ */ jsxs2(Text2, { children: [
128
202
  BOX.v,
129
- padLine(" " + (error || "No plan found")),
203
+ padLine(" " + (error || "No plan found"), width),
130
204
  BOX.v
131
205
  ] }),
132
- /* @__PURE__ */ jsx2(Text2, { children: createBottomLine() })
206
+ /* @__PURE__ */ jsx2(Text2, { children: createBottomLine(width) })
133
207
  ] });
134
208
  }
135
209
  const doneCount = plan.steps.filter((s) => s.status === "done").length;
136
210
  const totalCount = plan.steps.length;
137
- return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", width: PANEL_WIDTH, children: [
138
- /* @__PURE__ */ jsx2(Text2, { children: createTitleLine(doneCount, totalCount) }),
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) }),
139
214
  /* @__PURE__ */ jsxs2(Text2, { children: [
140
215
  BOX.v,
141
- padLine(" " + truncate(plan.goal, CONTENT_WIDTH)),
216
+ padLine(" " + truncate(plan.goal, contentWidth), width),
142
217
  BOX.v
143
218
  ] }),
144
219
  plan.steps.map((step, index) => {
145
- 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);
146
221
  return /* @__PURE__ */ jsxs2(Text2, { children: [
147
222
  BOX.v,
148
- padLine(stepText),
223
+ padLine(stepText, width),
149
224
  BOX.v
150
- ] }, index);
225
+ ] }, `step-${index}`);
151
226
  }),
152
227
  decisions.length > 0 && /* @__PURE__ */ jsxs2(Fragment2, { children: [
153
228
  /* @__PURE__ */ jsxs2(Text2, { children: [
154
229
  "\u251C",
155
- /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: createDecisionsHeader() })
230
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: createDecisionsHeader(width) })
156
231
  ] }),
157
232
  decisions.map((decision, index) => {
158
- const decText = " \u2022 " + truncate(decision.decision, MAX_DECISION_LENGTH);
233
+ const decText = " \u2022 " + truncate(decision.decision, maxDecisionLength);
159
234
  return /* @__PURE__ */ jsxs2(Text2, { children: [
160
235
  BOX.v,
161
- /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: padLine(decText) }),
236
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: padLine(decText, width) }),
162
237
  BOX.v
163
- ] }, index);
238
+ ] }, `decision-${index}`);
164
239
  })
165
240
  ] }),
166
- /* @__PURE__ */ jsx2(Text2, { children: createBottomLine() })
241
+ /* @__PURE__ */ jsx2(Text2, { children: createBottomLine(width) })
167
242
  ] });
168
243
  }
169
244
 
@@ -182,30 +257,59 @@ function formatRelativeTime(timestamp) {
182
257
  if (diffHours < 24) return `${diffHours}h ago`;
183
258
  return `${diffDays}d ago`;
184
259
  }
260
+ function createSeparator(panelWidth) {
261
+ return BOX.ml + BOX.h.repeat(getInnerWidth(panelWidth)) + BOX.mr;
262
+ }
185
263
  function TestPanel({
186
264
  results,
187
265
  isOutdated,
188
266
  commitsBehind,
189
- error
267
+ error,
268
+ width = DEFAULT_PANEL_WIDTH,
269
+ isRunning = false,
270
+ justCompleted = false
190
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();
191
281
  if (error || !results) {
192
- return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", borderStyle: "single", paddingX: 1, width: PANEL_WIDTH, children: [
193
- /* @__PURE__ */ jsx3(Box3, { marginTop: -1, children: /* @__PURE__ */ jsx3(Text3, { children: " Tests " }) }),
194
- /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: error || "No test results" })
282
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", width, children: [
283
+ /* @__PURE__ */ jsx3(Text3, { children: createTitleLine("Tests", titleSuffix, width) }),
284
+ /* @__PURE__ */ jsxs3(Text3, { children: [
285
+ BOX.v,
286
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: padLine(" " + (error || "No test results"), width) }),
287
+ BOX.v
288
+ ] }),
289
+ /* @__PURE__ */ jsx3(Text3, { children: createBottomLine(width) })
195
290
  ] });
196
291
  }
197
292
  const hasFailures = results.failures.length > 0;
198
- const relativeTime = formatRelativeTime(results.timestamp);
199
- return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", borderStyle: "single", paddingX: 1, width: PANEL_WIDTH, children: [
200
- /* @__PURE__ */ jsx3(Box3, { marginTop: -1, children: /* @__PURE__ */ jsx3(Text3, { children: " Tests " }) }),
201
- isOutdated && /* @__PURE__ */ jsxs3(Text3, { color: "yellow", children: [
202
- "\u26A0 Outdated (",
203
- commitsBehind,
204
- " ",
205
- commitsBehind === 1 ? "commit" : "commits",
206
- " behind)"
293
+ const relativeTime = titleSuffix;
294
+ let summaryLength = 1 + 2 + String(results.passed).length + " passed".length;
295
+ if (results.failed > 0) {
296
+ summaryLength += 2 + 2 + String(results.failed).length + " failed".length;
297
+ }
298
+ if (results.skipped > 0) {
299
+ summaryLength += 2 + 2 + String(results.skipped).length + " skipped".length;
300
+ }
301
+ summaryLength += " \xB7 ".length + results.hash.length;
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) }),
305
+ isOutdated && /* @__PURE__ */ jsxs3(Text3, { children: [
306
+ BOX.v,
307
+ /* @__PURE__ */ jsx3(Text3, { color: "yellow", children: padLine(` \u26A0 Outdated (${commitsBehind} ${commitsBehind === 1 ? "commit" : "commits"} behind)`, width) }),
308
+ BOX.v
207
309
  ] }),
208
310
  /* @__PURE__ */ jsxs3(Text3, { children: [
311
+ BOX.v,
312
+ " ",
209
313
  /* @__PURE__ */ jsxs3(Text3, { color: "green", children: [
210
314
  "\u2713 ",
211
315
  results.passed,
@@ -228,68 +332,270 @@ function TestPanel({
228
332
  ] })
229
333
  ] }),
230
334
  /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
231
- " ",
232
- "\xB7 ",
233
- results.hash,
234
335
  " \xB7 ",
235
- relativeTime
236
- ] })
336
+ results.hash
337
+ ] }),
338
+ " ".repeat(summaryPadding),
339
+ BOX.v
237
340
  ] }),
238
341
  hasFailures && /* @__PURE__ */ jsxs3(Fragment3, { children: [
239
- /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: SEPARATOR }),
240
- results.failures.map((failure, index) => /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
241
- /* @__PURE__ */ jsxs3(Text3, { color: "red", children: [
342
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: createSeparator(width) }),
343
+ results.failures.map((failure, index) => {
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);
348
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
349
+ /* @__PURE__ */ jsxs3(Text3, { children: [
350
+ BOX.v,
351
+ " ",
352
+ /* @__PURE__ */ jsxs3(Text3, { color: "red", children: [
353
+ "\u2717 ",
354
+ fileName
355
+ ] }),
356
+ " ".repeat(filePadding),
357
+ BOX.v
358
+ ] }),
359
+ /* @__PURE__ */ jsxs3(Text3, { children: [
360
+ BOX.v,
361
+ " ",
362
+ "\u2022 ",
363
+ testName,
364
+ " ".repeat(testPadding),
365
+ BOX.v
366
+ ] })
367
+ ] }, `failure-${index}`);
368
+ })
369
+ ] }),
370
+ /* @__PURE__ */ jsx3(Text3, { children: createBottomLine(width) })
371
+ ] });
372
+ }
373
+
374
+ // src/ui/GenericPanel.tsx
375
+ import { Box as Box4, Text as Text4 } from "ink";
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: [
242
481
  "\u2717 ",
243
- truncate(failure.file, CONTENT_WIDTH - 2)
244
- ] }),
245
- /* @__PURE__ */ jsxs3(Text3, { children: [
246
- " ",
247
- "\u2022 ",
248
- truncate(failure.name, CONTENT_WIDTH - 4)
482
+ stats.failed,
483
+ " failed"
249
484
  ] })
250
- ] }, index))
251
- ] })
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) })
252
539
  ] });
253
540
  }
254
541
 
255
542
  // src/ui/WelcomePanel.tsx
256
- import { Box as Box4, Text as Text4 } from "ink";
257
- import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
543
+ import { Box as Box5, Text as Text5 } from "ink";
544
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
258
545
  function WelcomePanel() {
259
- return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", borderStyle: "single", paddingX: 1, width: PANEL_WIDTH, children: [
260
- /* @__PURE__ */ jsx4(Box4, { marginTop: -1, children: /* @__PURE__ */ jsx4(Text4, { children: " Welcome to agenthud " }) }),
261
- /* @__PURE__ */ jsx4(Text4, { children: " " }),
262
- /* @__PURE__ */ jsx4(Text4, { children: " No .agenthud/ directory found." }),
263
- /* @__PURE__ */ jsx4(Text4, { children: " " }),
264
- /* @__PURE__ */ jsx4(Text4, { children: " Quick setup:" }),
265
- /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: " npx agenthud init" }),
266
- /* @__PURE__ */ jsx4(Text4, { children: " " }),
267
- /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: " Or visit: github.com/neochoon/agenthud" }),
268
- /* @__PURE__ */ jsx4(Text4, { children: " " }),
269
- /* @__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" })
270
557
  ] });
271
558
  }
272
559
 
273
560
  // src/data/git.ts
274
- 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);
275
564
  var execFn = (command, options2) => nodeExecSync(command, options2);
276
- function getCurrentBranch() {
565
+ function getUncommittedCount() {
277
566
  try {
278
- const result = execFn("git branch --show-current", {
567
+ const result = execFn("git status --porcelain", {
279
568
  encoding: "utf-8"
280
569
  });
281
- return result.trim();
570
+ const lines = result.trim().split("\n").filter(Boolean);
571
+ return lines.length;
282
572
  } catch {
283
- return null;
573
+ return 0;
284
574
  }
285
575
  }
286
- function getTodayCommits() {
576
+ var DEFAULT_COMMANDS = {
577
+ branch: "git branch --show-current",
578
+ commits: 'git log --since=midnight --format="%h|%aI|%s"',
579
+ stats: 'git log --since=midnight --numstat --format=""'
580
+ };
581
+ function getGitData(config) {
582
+ const commands = {
583
+ branch: config.command?.branch || DEFAULT_COMMANDS.branch,
584
+ commits: config.command?.commits || DEFAULT_COMMANDS.commits,
585
+ stats: config.command?.stats || DEFAULT_COMMANDS.stats
586
+ };
587
+ let branch = null;
287
588
  try {
288
- const result = execFn('git log --since=midnight --format="%h|%aI|%s"', {
289
- encoding: "utf-8"
290
- });
589
+ const result = execFn(commands.branch, { encoding: "utf-8" });
590
+ branch = result.trim();
591
+ } catch {
592
+ branch = null;
593
+ }
594
+ let commits = [];
595
+ try {
596
+ const result = execFn(commands.commits, { encoding: "utf-8" });
291
597
  const lines = result.trim().split("\n").filter(Boolean);
292
- return lines.map((line) => {
598
+ commits = lines.map((line) => {
293
599
  const [hash, timestamp, ...messageParts] = line.split("|");
294
600
  return {
295
601
  hash,
@@ -298,14 +604,11 @@ function getTodayCommits() {
298
604
  };
299
605
  });
300
606
  } catch {
301
- return [];
607
+ commits = [];
302
608
  }
303
- }
304
- function getTodayStats() {
609
+ let stats = { added: 0, deleted: 0, files: 0 };
305
610
  try {
306
- const result = execFn('git log --since=midnight --numstat --format=""', {
307
- encoding: "utf-8"
308
- });
611
+ const result = execFn(commands.stats, { encoding: "utf-8" });
309
612
  const lines = result.trim().split("\n").filter(Boolean);
310
613
  let added = 0;
311
614
  let deleted = 0;
@@ -320,34 +623,75 @@ function getTodayStats() {
320
623
  deleted += parseInt(deletedStr, 10) || 0;
321
624
  if (filename) filesSet.add(filename);
322
625
  }
323
- return { added, deleted, files: filesSet.size };
626
+ stats = { added, deleted, files: filesSet.size };
324
627
  } catch {
325
- return { added: 0, deleted: 0, files: 0 };
628
+ stats = { added: 0, deleted: 0, files: 0 };
326
629
  }
630
+ const uncommitted = getUncommittedCount();
631
+ return { branch, commits, stats, uncommitted };
327
632
  }
328
- function getUncommittedCount() {
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;
329
640
  try {
330
- const result = execFn("git status --porcelain", {
331
- encoding: "utf-8"
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
+ };
332
657
  });
333
- const lines = result.trim().split("\n").filter(Boolean);
334
- return lines.length;
335
658
  } catch {
336
- return 0;
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 };
337
681
  }
682
+ const uncommitted = getUncommittedCount();
683
+ return { branch, commits, stats, uncommitted };
338
684
  }
339
685
 
340
686
  // src/data/plan.ts
341
687
  import { readFileSync as nodeReadFileSync } from "fs";
342
- import { join } from "path";
343
- var AGENT_DIR = ".agenthud";
344
- var PLAN_FILE = "plan.json";
345
- var DECISIONS_FILE = "decisions.json";
688
+ import { join, dirname } from "path";
346
689
  var MAX_DECISIONS = 3;
347
690
  var readFileFn = (path) => nodeReadFileSync(path, "utf-8");
348
- function getPlanData(dir = process.cwd()) {
349
- const planPath = join(dir, AGENT_DIR, PLAN_FILE);
350
- const decisionsPath = join(dir, AGENT_DIR, DECISIONS_FILE);
691
+ function getPlanDataWithConfig(config) {
692
+ const planPath = config.source;
693
+ const planDir = dirname(planPath);
694
+ const decisionsPath = join(planDir, "decisions.json");
351
695
  let plan = null;
352
696
  let decisions = [];
353
697
  let error;
@@ -376,7 +720,7 @@ function getPlanData(dir = process.cwd()) {
376
720
  import { readFileSync as nodeReadFileSync2 } from "fs";
377
721
  import { execSync as nodeExecSync2 } from "child_process";
378
722
  import { join as join2 } from "path";
379
- var AGENT_DIR2 = ".agenthud";
723
+ var AGENT_DIR = ".agenthud";
380
724
  var TEST_RESULTS_FILE = "test-results.json";
381
725
  var readFileFn2 = (path) => nodeReadFileSync2(path, "utf-8");
382
726
  var getHeadHashFn = () => {
@@ -389,7 +733,7 @@ var getCommitCountFn = (fromHash) => {
389
733
  return parseInt(result, 10) || 0;
390
734
  };
391
735
  function getTestData(dir = process.cwd()) {
392
- const testResultsPath = join2(dir, AGENT_DIR2, TEST_RESULTS_FILE);
736
+ const testResultsPath = join2(dir, AGENT_DIR, TEST_RESULTS_FILE);
393
737
  let results = null;
394
738
  let isOutdated = false;
395
739
  let commitsBehind = 0;
@@ -418,68 +762,735 @@ function getTestData(dir = process.cwd()) {
418
762
  return { results, isOutdated, commitsBehind, error };
419
763
  }
420
764
 
421
- // src/ui/App.tsx
422
- import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
423
- var REFRESH_INTERVAL = 5e3;
424
- var REFRESH_SECONDS = REFRESH_INTERVAL / 1e3;
425
- function useGitData() {
426
- const [data, setData] = useState(() => ({
427
- branch: getCurrentBranch(),
428
- commits: getTodayCommits(),
429
- stats: getTodayStats(),
430
- uncommitted: getUncommittedCount()
431
- }));
432
- const refresh = useCallback(() => {
433
- setData({
434
- branch: getCurrentBranch(),
435
- commits: getTodayCommits(),
436
- stats: getTodayStats(),
437
- uncommitted: getUncommittedCount()
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
+
927
+ // src/runner/command.ts
928
+ import { execSync as nodeExecSync4 } from "child_process";
929
+ var execFn3 = (command, options2) => nodeExecSync4(command, options2);
930
+ function parseVitestOutput(output) {
931
+ try {
932
+ const data = JSON.parse(output);
933
+ if (typeof data.numPassedTests !== "number" || typeof data.numFailedTests !== "number") {
934
+ return null;
935
+ }
936
+ const failures = [];
937
+ for (const testResult of data.testResults || []) {
938
+ for (const assertion of testResult.assertionResults || []) {
939
+ if (assertion.status === "failed") {
940
+ failures.push({
941
+ file: testResult.name,
942
+ name: assertion.title
943
+ });
944
+ }
945
+ }
946
+ }
947
+ return {
948
+ passed: data.numPassedTests,
949
+ failed: data.numFailedTests,
950
+ skipped: data.numPendingTests || 0,
951
+ failures
952
+ };
953
+ } catch {
954
+ return null;
955
+ }
956
+ }
957
+ function getHeadHash() {
958
+ try {
959
+ return execFn3("git rev-parse --short HEAD", {
960
+ encoding: "utf-8",
961
+ stdio: ["pipe", "pipe", "pipe"]
962
+ }).trim();
963
+ } catch {
964
+ return "unknown";
965
+ }
966
+ }
967
+ function runTestCommand(command) {
968
+ let output;
969
+ try {
970
+ output = execFn3(command, {
971
+ encoding: "utf-8",
972
+ stdio: ["pipe", "pipe", "pipe"]
438
973
  });
439
- }, []);
440
- return [data, refresh];
974
+ } catch (error) {
975
+ const message = error instanceof Error ? error.message : String(error);
976
+ return {
977
+ results: null,
978
+ isOutdated: false,
979
+ commitsBehind: 0,
980
+ error: message
981
+ };
982
+ }
983
+ const parsed = parseVitestOutput(output);
984
+ if (!parsed) {
985
+ return {
986
+ results: null,
987
+ isOutdated: false,
988
+ commitsBehind: 0,
989
+ error: "Failed to parse test output"
990
+ };
991
+ }
992
+ const hash = getHeadHash();
993
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
994
+ const results = {
995
+ hash,
996
+ timestamp,
997
+ passed: parsed.passed,
998
+ failed: parsed.failed,
999
+ skipped: parsed.skipped,
1000
+ failures: parsed.failures
1001
+ };
1002
+ return {
1003
+ results,
1004
+ isOutdated: false,
1005
+ commitsBehind: 0
1006
+ };
441
1007
  }
442
- function usePlanData() {
443
- const [data, setData] = useState(() => getPlanData());
444
- const refresh = useCallback(() => {
445
- setData(getPlanData());
446
- }, []);
447
- return [data, refresh];
1008
+
1009
+ // src/config/parser.ts
1010
+ import {
1011
+ existsSync as nodeExistsSync,
1012
+ readFileSync as nodeReadFileSync4
1013
+ } from "fs";
1014
+ import { parse as parseYaml } from "yaml";
1015
+ var fs = {
1016
+ existsSync: nodeExistsSync,
1017
+ readFileSync: (path) => nodeReadFileSync4(path, "utf-8")
1018
+ };
1019
+ var DEFAULT_WIDTH = 70;
1020
+ var MIN_WIDTH = 50;
1021
+ var MAX_WIDTH = 120;
1022
+ var CONFIG_PATH = ".agenthud/config.yaml";
1023
+ function parseInterval(interval) {
1024
+ if (!interval || interval === "manual") {
1025
+ return null;
1026
+ }
1027
+ const match = interval.match(/^(\d+)(s|m)$/);
1028
+ if (!match) {
1029
+ return null;
1030
+ }
1031
+ const value = parseInt(match[1], 10);
1032
+ const unit = match[2];
1033
+ if (unit === "s") {
1034
+ return value * 1e3;
1035
+ } else if (unit === "m") {
1036
+ return value * 60 * 1e3;
1037
+ }
1038
+ return null;
448
1039
  }
449
- function useTestData() {
450
- const [data, setData] = useState(() => getTestData());
451
- const refresh = useCallback(() => {
452
- setData(getTestData());
453
- }, []);
454
- return [data, refresh];
1040
+ function getDefaultConfig() {
1041
+ return {
1042
+ panels: {
1043
+ git: {
1044
+ enabled: true,
1045
+ interval: 3e4
1046
+ // 30s
1047
+ },
1048
+ plan: {
1049
+ enabled: true,
1050
+ interval: 1e4,
1051
+ // 10s
1052
+ source: ".agenthud/plan.json"
1053
+ },
1054
+ tests: {
1055
+ enabled: true,
1056
+ interval: null
1057
+ // manual
1058
+ }
1059
+ },
1060
+ panelOrder: ["git", "plan", "tests"],
1061
+ width: DEFAULT_WIDTH
1062
+ };
1063
+ }
1064
+ var BUILTIN_PANELS = ["git", "plan", "tests"];
1065
+ var VALID_RENDERERS = ["list", "progress", "status"];
1066
+ function parseConfig() {
1067
+ const warnings = [];
1068
+ const defaultConfig = getDefaultConfig();
1069
+ if (!fs.existsSync(CONFIG_PATH)) {
1070
+ return { config: defaultConfig, warnings };
1071
+ }
1072
+ let rawConfig;
1073
+ try {
1074
+ const content = fs.readFileSync(CONFIG_PATH);
1075
+ rawConfig = parseYaml(content);
1076
+ } catch (error) {
1077
+ const message = error instanceof Error ? error.message : String(error);
1078
+ warnings.push(`Failed to parse config: ${message}`);
1079
+ return { config: defaultConfig, warnings };
1080
+ }
1081
+ if (!rawConfig || typeof rawConfig !== "object") {
1082
+ return { config: defaultConfig, warnings };
1083
+ }
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
+ }
1097
+ const panels = parsed.panels;
1098
+ if (!panels || typeof panels !== "object") {
1099
+ return { config, warnings };
1100
+ }
1101
+ const customPanels = {};
1102
+ const panelOrder = [];
1103
+ for (const panelName of Object.keys(panels)) {
1104
+ panelOrder.push(panelName);
1105
+ const panelConfig = panels[panelName];
1106
+ if (!panelConfig || typeof panelConfig !== "object") {
1107
+ continue;
1108
+ }
1109
+ if (panelName === "git") {
1110
+ if (typeof panelConfig.enabled === "boolean") {
1111
+ config.panels.git.enabled = panelConfig.enabled;
1112
+ }
1113
+ if (typeof panelConfig.interval === "string") {
1114
+ const interval = parseInterval(panelConfig.interval);
1115
+ if (interval === null && panelConfig.interval !== "manual") {
1116
+ warnings.push(`Invalid interval '${panelConfig.interval}' for git panel, using default`);
1117
+ } else {
1118
+ config.panels.git.interval = interval;
1119
+ }
1120
+ }
1121
+ continue;
1122
+ }
1123
+ if (panelName === "plan") {
1124
+ if (typeof panelConfig.enabled === "boolean") {
1125
+ config.panels.plan.enabled = panelConfig.enabled;
1126
+ }
1127
+ if (typeof panelConfig.interval === "string") {
1128
+ const interval = parseInterval(panelConfig.interval);
1129
+ if (interval === null && panelConfig.interval !== "manual") {
1130
+ warnings.push(`Invalid interval '${panelConfig.interval}' for plan panel, using default`);
1131
+ } else {
1132
+ config.panels.plan.interval = interval;
1133
+ }
1134
+ }
1135
+ if (typeof panelConfig.source === "string") {
1136
+ config.panels.plan.source = panelConfig.source;
1137
+ }
1138
+ continue;
1139
+ }
1140
+ if (panelName === "tests") {
1141
+ if (typeof panelConfig.enabled === "boolean") {
1142
+ config.panels.tests.enabled = panelConfig.enabled;
1143
+ }
1144
+ if (typeof panelConfig.interval === "string") {
1145
+ const interval = parseInterval(panelConfig.interval);
1146
+ if (interval === null && panelConfig.interval !== "manual") {
1147
+ warnings.push(`Invalid interval '${panelConfig.interval}' for tests panel, using default`);
1148
+ } else {
1149
+ config.panels.tests.interval = interval;
1150
+ }
1151
+ }
1152
+ if (typeof panelConfig.command === "string") {
1153
+ config.panels.tests.command = panelConfig.command;
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);
1189
+ }
1190
+ }
1191
+ config.panelOrder = panelOrder;
1192
+ return { config, warnings };
1193
+ }
1194
+
1195
+ // src/ui/App.tsx
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;
1203
+ function generateHotkeys(config, actions) {
1204
+ const hotkeys = [];
1205
+ const usedKeys = /* @__PURE__ */ new Set(["r", "q"]);
1206
+ if (config.panels.tests.enabled && config.panels.tests.interval === null && actions.tests) {
1207
+ const name = "tests";
1208
+ for (const char of name.toLowerCase()) {
1209
+ if (!usedKeys.has(char)) {
1210
+ usedKeys.add(char);
1211
+ hotkeys.push({
1212
+ key: char,
1213
+ label: "run tests",
1214
+ action: actions.tests
1215
+ });
1216
+ break;
1217
+ }
1218
+ }
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
+ }
1237
+ return hotkeys;
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`;
455
1250
  }
456
1251
  function WelcomeApp() {
457
- return /* @__PURE__ */ jsx5(WelcomePanel, {});
1252
+ return /* @__PURE__ */ jsx6(WelcomePanel, {});
458
1253
  }
459
1254
  function DashboardApp({ mode }) {
460
1255
  const { exit } = useApp();
461
- const [gitData, refreshGit] = useGitData();
462
- const [planData, refreshPlan] = usePlanData();
463
- const [testData, refreshTest] = useTestData();
464
- const [countdown, setCountdown] = useState(REFRESH_SECONDS);
465
- const refreshAll = useCallback(() => {
466
- refreshGit();
467
- refreshPlan();
468
- refreshTest();
469
- setCountdown(REFRESH_SECONDS);
470
- }, [refreshGit, refreshPlan, refreshTest]);
1256
+ const { config, warnings } = useMemo(() => parseConfig(), []);
1257
+ const gitIntervalSeconds = config.panels.git.interval ? config.panels.git.interval / 1e3 : null;
1258
+ const planIntervalSeconds = config.panels.plan.interval ? config.panels.plan.interval / 1e3 : null;
1259
+ const [gitData, setGitData] = useState(() => getGitData(config.panels.git));
1260
+ const refreshGit = useCallback(() => {
1261
+ setGitData(getGitData(config.panels.git));
1262
+ }, [config.panels.git]);
1263
+ const [planData, setPlanData] = useState(() => getPlanDataWithConfig(config.panels.plan));
1264
+ const refreshPlan = useCallback(() => {
1265
+ setPlanData(getPlanDataWithConfig(config.panels.plan));
1266
+ }, [config.panels.plan]);
1267
+ const getTestDataFromConfig = useCallback(() => {
1268
+ if (config.panels.tests.command) {
1269
+ return runTestCommand(config.panels.tests.command);
1270
+ }
1271
+ return getTestData();
1272
+ }, [config.panels.tests.command]);
1273
+ const [testData, setTestData] = useState(() => getTestDataFromConfig());
1274
+ const refreshTest = useCallback(() => {
1275
+ setTestData(getTestDataFromConfig());
1276
+ }, [getTestDataFromConfig]);
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;
1291
+ });
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 () => {
1394
+ if (config.panels.git.enabled) {
1395
+ void refreshGitAsync();
1396
+ setCountdowns((prev) => ({ ...prev, git: gitIntervalSeconds }));
1397
+ }
1398
+ if (config.panels.plan.enabled) {
1399
+ refreshPlanWithFeedback();
1400
+ setCountdowns((prev) => ({ ...prev, plan: planIntervalSeconds }));
1401
+ }
1402
+ if (config.panels.tests.enabled) {
1403
+ void refreshTestAsync();
1404
+ }
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
+ ]);
1425
+ const hotkeys = useMemo(
1426
+ () => generateHotkeys(config, {
1427
+ tests: () => void refreshTestAsync(),
1428
+ customPanels: customPanelActionsAsync
1429
+ }),
1430
+ [config, refreshTestAsync, customPanelActionsAsync]
1431
+ );
471
1432
  useEffect(() => {
472
1433
  if (mode !== "watch") return;
473
- const interval = setInterval(refreshAll, REFRESH_INTERVAL);
474
- return () => clearInterval(interval);
475
- }, [mode, refreshAll]);
1434
+ const timers = [];
1435
+ if (config.panels.git.enabled && config.panels.git.interval !== null) {
1436
+ timers.push(
1437
+ setInterval(() => {
1438
+ void refreshGitAsync();
1439
+ setCountdowns((prev) => ({ ...prev, git: gitIntervalSeconds }));
1440
+ }, config.panels.git.interval)
1441
+ );
1442
+ }
1443
+ if (config.panels.plan.enabled && config.panels.plan.interval !== null) {
1444
+ timers.push(
1445
+ setInterval(() => {
1446
+ refreshPlanWithFeedback();
1447
+ setCountdowns((prev) => ({ ...prev, plan: planIntervalSeconds }));
1448
+ }, config.panels.plan.interval)
1449
+ );
1450
+ }
1451
+ if (config.panels.tests.enabled && config.panels.tests.interval !== null) {
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
+ }
1466
+ }
1467
+ return () => timers.forEach((t) => clearInterval(t));
1468
+ }, [
1469
+ mode,
1470
+ config,
1471
+ refreshGitAsync,
1472
+ refreshPlanWithFeedback,
1473
+ refreshTestAsync,
1474
+ refreshCustomPanelAsync,
1475
+ gitIntervalSeconds,
1476
+ planIntervalSeconds
1477
+ ]);
476
1478
  useEffect(() => {
477
1479
  if (mode !== "watch") return;
478
1480
  const tick = setInterval(() => {
479
- setCountdown((prev) => prev > 1 ? prev - 1 : REFRESH_SECONDS);
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
+ });
480
1491
  }, 1e3);
481
1492
  return () => clearInterval(tick);
482
- }, [mode]);
1493
+ }, [mode, customPanelNames]);
483
1494
  useInput(
484
1495
  (input) => {
485
1496
  if (input === "q") {
@@ -488,52 +1499,112 @@ function DashboardApp({ mode }) {
488
1499
  if (input === "r") {
489
1500
  refreshAll();
490
1501
  }
1502
+ for (const hotkey of hotkeys) {
1503
+ if (input === hotkey.key) {
1504
+ hotkey.action();
1505
+ break;
1506
+ }
1507
+ }
491
1508
  },
492
1509
  { isActive: mode === "watch" }
493
1510
  );
494
- return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", children: [
495
- /* @__PURE__ */ jsx5(Box5, { children: /* @__PURE__ */ jsx5(
496
- GitPanel,
497
- {
498
- branch: gitData.branch,
499
- commits: gitData.commits,
500
- stats: gitData.stats,
501
- uncommitted: gitData.uncommitted
502
- }
503
- ) }),
504
- /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx5(
505
- PlanPanel,
506
- {
507
- plan: planData.plan,
508
- decisions: planData.decisions,
509
- error: planData.error
510
- }
511
- ) }),
512
- /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx5(
513
- TestPanel,
514
- {
515
- results: testData.results,
516
- isOutdated: testData.isOutdated,
517
- commitsBehind: testData.commitsBehind,
518
- error: testData.error
519
- }
520
- ) }),
521
- mode === "watch" && /* @__PURE__ */ jsx5(Box5, { marginTop: 1, width: PANEL_WIDTH, children: /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
522
- "\u21BB ",
523
- countdown,
524
- "s \xB7 ",
525
- /* @__PURE__ */ jsx5(Text5, { color: "cyan", children: "q:" }),
526
- " quit \xB7 ",
527
- /* @__PURE__ */ jsx5(Text5, { color: "cyan", children: "r:" }),
528
- " refresh"
529
- ] }) })
1511
+ const statusBarItems = [];
1512
+ for (const hotkey of hotkeys) {
1513
+ statusBarItems.push(`${hotkey.key}: ${hotkey.label}`);
1514
+ }
1515
+ statusBarItems.push("r: refresh all");
1516
+ statusBarItems.push("q: quit");
1517
+ return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", children: [
1518
+ warnings.length > 0 && /* @__PURE__ */ jsx6(Box6, { marginBottom: 1, children: /* @__PURE__ */ jsxs6(Text6, { color: "yellow", children: [
1519
+ "\u26A0 ",
1520
+ warnings.join(", ")
1521
+ ] }) }),
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}`);
1539
+ }
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}`);
1553
+ }
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}`);
1568
+ }
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: [
1594
+ index > 0 && " \xB7 ",
1595
+ /* @__PURE__ */ jsxs6(Text6, { color: "cyan", children: [
1596
+ item.split(":")[0],
1597
+ ":"
1598
+ ] }),
1599
+ item.split(":").slice(1).join(":")
1600
+ ] }, index)) }) })
530
1601
  ] });
531
1602
  }
532
1603
  function App({ mode, agentDirExists: agentDirExists2 = true }) {
533
1604
  if (!agentDirExists2) {
534
- return /* @__PURE__ */ jsx5(WelcomeApp, {});
1605
+ return /* @__PURE__ */ jsx6(WelcomeApp, {});
535
1606
  }
536
- return /* @__PURE__ */ jsx5(DashboardApp, { mode });
1607
+ return /* @__PURE__ */ jsx6(DashboardApp, { mode });
537
1608
  }
538
1609
 
539
1610
  // src/cli.ts
@@ -553,17 +1624,17 @@ function parseArgs(args) {
553
1624
 
554
1625
  // src/commands/init.ts
555
1626
  import {
556
- existsSync as nodeExistsSync,
1627
+ existsSync as nodeExistsSync2,
557
1628
  mkdirSync as nodeMkdirSync,
558
1629
  writeFileSync as nodeWriteFileSync,
559
- readFileSync as nodeReadFileSync3,
1630
+ readFileSync as nodeReadFileSync5,
560
1631
  appendFileSync as nodeAppendFileSync
561
1632
  } from "fs";
562
- var fs = {
563
- existsSync: nodeExistsSync,
1633
+ var fs2 = {
1634
+ existsSync: nodeExistsSync2,
564
1635
  mkdirSync: nodeMkdirSync,
565
1636
  writeFileSync: nodeWriteFileSync,
566
- readFileSync: (path) => nodeReadFileSync3(path, "utf-8"),
1637
+ readFileSync: (path) => nodeReadFileSync5(path, "utf-8"),
567
1638
  appendFileSync: nodeAppendFileSync
568
1639
  };
569
1640
  var AGENT_STATE_SECTION = `## Agent State
@@ -572,48 +1643,74 @@ Maintain \`.agenthud/\` directory:
572
1643
  - Update \`plan.json\` when plan changes
573
1644
  - Append to \`decisions.json\` for key decisions
574
1645
  `;
1646
+ var DEFAULT_CONFIG = `# agenthud configuration
1647
+ panels:
1648
+ git:
1649
+ enabled: true
1650
+ interval: 30s
1651
+ command:
1652
+ branch: git branch --show-current
1653
+ commits: git log --since=midnight --pretty=format:"%h|%aI|%s"
1654
+ stats: git log --since=midnight --numstat --pretty=format:""
1655
+
1656
+ plan:
1657
+ enabled: true
1658
+ interval: 10s
1659
+ source: .agenthud/plan.json
1660
+
1661
+ tests:
1662
+ enabled: true
1663
+ interval: manual
1664
+ command: npx vitest run --reporter=json
1665
+ `;
575
1666
  function runInit() {
576
1667
  const result = {
577
1668
  created: [],
578
1669
  skipped: []
579
1670
  };
580
- if (!fs.existsSync(".agenthud")) {
581
- fs.mkdirSync(".agenthud", { recursive: true });
1671
+ if (!fs2.existsSync(".agenthud")) {
1672
+ fs2.mkdirSync(".agenthud", { recursive: true });
582
1673
  result.created.push(".agenthud/");
583
1674
  } else {
584
1675
  result.skipped.push(".agenthud/");
585
1676
  }
586
- if (!fs.existsSync(".agenthud/plan.json")) {
587
- fs.writeFileSync(".agenthud/plan.json", "{}\n");
1677
+ if (!fs2.existsSync(".agenthud/plan.json")) {
1678
+ fs2.writeFileSync(".agenthud/plan.json", "{}\n");
588
1679
  result.created.push(".agenthud/plan.json");
589
1680
  } else {
590
1681
  result.skipped.push(".agenthud/plan.json");
591
1682
  }
592
- if (!fs.existsSync(".agenthud/decisions.json")) {
593
- fs.writeFileSync(".agenthud/decisions.json", "[]\n");
1683
+ if (!fs2.existsSync(".agenthud/decisions.json")) {
1684
+ fs2.writeFileSync(".agenthud/decisions.json", "[]\n");
594
1685
  result.created.push(".agenthud/decisions.json");
595
1686
  } else {
596
1687
  result.skipped.push(".agenthud/decisions.json");
597
1688
  }
598
- if (!fs.existsSync(".gitignore")) {
599
- fs.writeFileSync(".gitignore", ".agenthud/\n");
1689
+ if (!fs2.existsSync(".agenthud/config.yaml")) {
1690
+ fs2.writeFileSync(".agenthud/config.yaml", DEFAULT_CONFIG);
1691
+ result.created.push(".agenthud/config.yaml");
1692
+ } else {
1693
+ result.skipped.push(".agenthud/config.yaml");
1694
+ }
1695
+ if (!fs2.existsSync(".gitignore")) {
1696
+ fs2.writeFileSync(".gitignore", ".agenthud/\n");
600
1697
  result.created.push(".gitignore");
601
1698
  } else {
602
- const content = fs.readFileSync(".gitignore");
1699
+ const content = fs2.readFileSync(".gitignore");
603
1700
  if (!content.includes(".agenthud/")) {
604
- fs.appendFileSync(".gitignore", "\n.agenthud/\n");
1701
+ fs2.appendFileSync(".gitignore", "\n.agenthud/\n");
605
1702
  result.created.push(".gitignore");
606
1703
  } else {
607
1704
  result.skipped.push(".gitignore");
608
1705
  }
609
1706
  }
610
- if (!fs.existsSync("CLAUDE.md")) {
611
- fs.writeFileSync("CLAUDE.md", AGENT_STATE_SECTION);
1707
+ if (!fs2.existsSync("CLAUDE.md")) {
1708
+ fs2.writeFileSync("CLAUDE.md", AGENT_STATE_SECTION);
612
1709
  result.created.push("CLAUDE.md");
613
1710
  } else {
614
- const content = fs.readFileSync("CLAUDE.md");
1711
+ const content = fs2.readFileSync("CLAUDE.md");
615
1712
  if (!content.includes("## Agent State")) {
616
- fs.appendFileSync("CLAUDE.md", "\n" + AGENT_STATE_SECTION);
1713
+ fs2.appendFileSync("CLAUDE.md", "\n" + AGENT_STATE_SECTION);
617
1714
  result.created.push("CLAUDE.md");
618
1715
  } else {
619
1716
  result.skipped.push("CLAUDE.md");