@davstack/tui 0.2.0 → 0.3.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.
@@ -0,0 +1,131 @@
1
+ import React, { useMemo, useState } from "react"
2
+ import { Box, Text, useInput } from "ink"
3
+
4
+ import type { JobRecord, JobStatus } from "@davstack/open-agents/core/jobs"
5
+ import { useAgentJobs } from "../hooks/useAgentJobs.ts"
6
+ import { getRepoRootSafe } from "../lib/package-info.ts"
7
+ import { jobStatusGlyph } from "../lib/agent-glyphs.ts"
8
+ import { inferAgentTitle } from "../lib/agent-title.ts"
9
+ import { useView } from "../state/view-context.tsx"
10
+ import { useNoColor, colorOrUndef } from "../hooks/useNoColor.ts"
11
+ import { ControlsHint } from "../components/ControlsHint.tsx"
12
+
13
+ const AGENTS_CONTROLS =
14
+ "↑/↓ j/k focus enter drill in esc back g agents q quit " +
15
+ "● running ✗ failed ○ done"
16
+
17
+ const STATUS_LABEL: Record<JobStatus, string> = {
18
+ running: "run",
19
+ done: "done",
20
+ failed: "fail",
21
+ cancelled: "cancel",
22
+ }
23
+
24
+ const STATUS_COLOR: Record<JobStatus, string> = {
25
+ running: "yellow",
26
+ done: "gray",
27
+ failed: "red",
28
+ cancelled: "gray",
29
+ }
30
+
31
+ export function AgentsList(): React.ReactElement {
32
+ const repoPath = getRepoRootSafe()
33
+ const { jobs } = useAgentJobs(repoPath)
34
+ const { focusedIdx, setFocusedIdx, showAgent } = useView()
35
+ const [controlsOpen, setControlsOpen] = useState(false)
36
+
37
+ const rawModeSupported = process.stdin.isTTY === true
38
+ useInput(
39
+ (input, key) => {
40
+ if (input === "c") {
41
+ setControlsOpen((v) => !v)
42
+ return
43
+ }
44
+ if (jobs.length === 0) return
45
+ if (key.upArrow || input === "k") {
46
+ setFocusedIdx((focusedIdx - 1 + jobs.length) % jobs.length)
47
+ } else if (key.downArrow || input === "j") {
48
+ setFocusedIdx((focusedIdx + 1) % jobs.length)
49
+ } else if (key.return) {
50
+ const job = jobs[Math.min(focusedIdx, jobs.length - 1)]
51
+ if (job) showAgent(job.id)
52
+ }
53
+ },
54
+ { isActive: rawModeSupported },
55
+ )
56
+
57
+ if (jobs.length === 0) {
58
+ return <AgentsEmptyState controlsOpen={controlsOpen} />
59
+ }
60
+
61
+ const safeFocus = Math.min(focusedIdx, jobs.length - 1)
62
+
63
+ return (
64
+ <Box flexDirection="column">
65
+ {jobs.map((job, i) => (
66
+ <AgentListRow key={job.id} job={job} focused={i === safeFocus} />
67
+ ))}
68
+ <ControlsHint expanded={controlsOpen} controls={AGENTS_CONTROLS} />
69
+ </Box>
70
+ )
71
+ }
72
+
73
+ function AgentListRow({ job, focused }: { job: JobRecord; focused: boolean }): React.ReactElement {
74
+ const noColor = useNoColor()
75
+ const title = useMemo(() => truncateOneLine(inferAgentTitle({ prompt: job.prompt }), 80), [job.prompt])
76
+ const when = formatWhen(job.startedAt)
77
+
78
+ return (
79
+ <Box>
80
+ <Text color={colorOrUndef(focused ? "cyan" : undefined, noColor)}>
81
+ {focused ? "› " : " "}
82
+ </Text>
83
+ <Text color={colorOrUndef(STATUS_COLOR[job.status], noColor)}>
84
+ {jobStatusGlyph(job.status, noColor)}
85
+ </Text>
86
+ <Text> {STATUS_LABEL[job.status].padEnd(7)}</Text>
87
+ <Text bold>{title.padEnd(82)}</Text>
88
+ <Text dimColor>{when}</Text>
89
+ </Box>
90
+ )
91
+ }
92
+
93
+ function AgentsEmptyState({ controlsOpen }: { controlsOpen: boolean }): React.ReactElement {
94
+ return (
95
+ <Box flexDirection="column">
96
+ <Text dimColor>
97
+ No agents have run in this repo yet. Submit one with: explore &quot;&lt;prompt&gt;&quot;
98
+ </Text>
99
+ <ControlsHint expanded={controlsOpen} controls={AGENTS_CONTROLS} />
100
+ </Box>
101
+ )
102
+ }
103
+
104
+ function formatWhen(startedAt: string, now = Date.now()): string {
105
+ const then = new Date(startedAt).getTime()
106
+ const ageMs = now - then
107
+ const dayMs = 24 * 60 * 60 * 1000
108
+ if (ageMs < dayMs) {
109
+ const ageMin = Math.max(0, Math.round(ageMs / 60000))
110
+ if (ageMin < 1) return "just now"
111
+ if (ageMin < 60) return `${ageMin}m ago`
112
+ const ageHr = Math.round(ageMin / 60)
113
+ return `${ageHr}h ago`
114
+ }
115
+ const d = new Date(then)
116
+ return `${ordinal(d.getDate())} ${MONTHS[d.getMonth()]}`
117
+ }
118
+
119
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
120
+
121
+ function ordinal(n: number): string {
122
+ const s = ["th", "st", "nd", "rd"]
123
+ const v = n % 100
124
+ return `${n}${s[(v - 20) % 10] ?? s[v] ?? s[0]}`
125
+ }
126
+
127
+ function truncateOneLine(s: string, n: number): string {
128
+ const one = s.replace(/\s+/g, " ").trim()
129
+ if (one.length <= n) return one
130
+ return one.slice(0, n - 1) + "…"
131
+ }
@@ -10,6 +10,7 @@ import { render } from "ink-testing-library"
10
10
  import { ServerList } from "./ServerList.tsx"
11
11
  import { ViewProvider } from "../state/view-context.tsx"
12
12
  import { DaemonsProvider, useDaemons, type DaemonRow } from "../state/daemons-context.tsx"
13
+ import { AgentsProvider } from "../state/agents-context.tsx"
13
14
  import { QuitProvider } from "../state/quit-context.tsx"
14
15
  import type { DaemonDescriptor } from "../lib/daemon-registry.ts"
15
16
 
@@ -52,32 +53,35 @@ afterEach(() => {
52
53
  active = null
53
54
  })
54
55
 
55
- test("legend advertises the new hotkeys", () => {
56
+ test("controls hint is collapsed by default", () => {
56
57
  const descriptors = [makeDescriptor("logs")]
57
58
  active = render(
58
59
  <ViewProvider>
59
- <DaemonsProvider descriptors={descriptors}>
60
- <QuitProvider>
60
+ <AgentsProvider>
61
+ <DaemonsProvider descriptors={descriptors}>
62
+ <QuitProvider>
61
63
  <ServerList />
62
- </QuitProvider>
63
- </DaemonsProvider>
64
+ </QuitProvider>
65
+ </DaemonsProvider>
66
+ </AgentsProvider>
64
67
  </ViewProvider>,
65
68
  )
66
69
  const frame = active.lastFrame() ?? ""
67
- expect(frame).toContain("s start/stop")
68
- expect(frame).toContain("k takeover")
69
- expect(frame).toContain("1-9 jump")
70
+ expect(frame).toContain("c for controls")
71
+ expect(frame).not.toContain("s start/stop")
70
72
  })
71
73
 
72
74
  test("renders a row per descriptor with the focus marker on idx 0", () => {
73
75
  const descriptors = [makeDescriptor("logs"), makeDescriptor("vitest")]
74
76
  active = render(
75
77
  <ViewProvider>
76
- <DaemonsProvider descriptors={descriptors}>
77
- <QuitProvider>
78
+ <AgentsProvider>
79
+ <DaemonsProvider descriptors={descriptors}>
80
+ <QuitProvider>
78
81
  <ServerList />
79
- </QuitProvider>
80
- </DaemonsProvider>
82
+ </QuitProvider>
83
+ </DaemonsProvider>
84
+ </AgentsProvider>
81
85
  </ViewProvider>,
82
86
  )
83
87
  const frame = active.lastFrame() ?? ""
@@ -107,8 +111,9 @@ test("toggleByKey dispatches stop() for a running daemon", async () => {
107
111
 
108
112
  active = render(
109
113
  <ViewProvider>
110
- <DaemonsProvider descriptors={descriptors}>
111
- <QuitProvider>
114
+ <AgentsProvider>
115
+ <DaemonsProvider descriptors={descriptors}>
116
+ <QuitProvider>
112
117
  <Seed
113
118
  rows={[
114
119
  { descriptor: descriptors[0], status: "running", lines: [], exitCode: null },
@@ -118,8 +123,9 @@ test("toggleByKey dispatches stop() for a running daemon", async () => {
118
123
  />
119
124
  <Capture />
120
125
  <ServerList />
121
- </QuitProvider>
122
- </DaemonsProvider>
126
+ </QuitProvider>
127
+ </DaemonsProvider>
128
+ </AgentsProvider>
123
129
  </ViewProvider>,
124
130
  )
125
131
  await tick()
@@ -145,8 +151,9 @@ test("toggleByKey dispatches start() for an idle daemon", async () => {
145
151
 
146
152
  active = render(
147
153
  <ViewProvider>
148
- <DaemonsProvider descriptors={descriptors}>
149
- <QuitProvider>
154
+ <AgentsProvider>
155
+ <DaemonsProvider descriptors={descriptors}>
156
+ <QuitProvider>
150
157
  <Seed
151
158
  rows={[
152
159
  { descriptor: descriptors[0], status: "idle", lines: [], exitCode: null },
@@ -155,8 +162,9 @@ test("toggleByKey dispatches start() for an idle daemon", async () => {
155
162
  />
156
163
  <Capture />
157
164
  <ServerList />
158
- </QuitProvider>
159
- </DaemonsProvider>
165
+ </QuitProvider>
166
+ </DaemonsProvider>
167
+ </AgentsProvider>
160
168
  </ViewProvider>,
161
169
  )
162
170
  await tick()
@@ -2,7 +2,7 @@
2
2
  // hotkeys (`↑/↓`, `enter`, `s`). Global hotkeys (`1..9`, `esc`, `q`) live
3
3
  // in <GlobalHotkeys>.
4
4
 
5
- import React from "react"
5
+ import React, { useState } from "react"
6
6
  import { Box, Text, useInput } from "ink"
7
7
 
8
8
  import type { DaemonStatus } from "../hooks/useDaemonProcess.ts"
@@ -10,6 +10,11 @@ import { useView } from "../state/view-context.tsx"
10
10
  import { useDaemons, type DaemonRow } from "../state/daemons-context.tsx"
11
11
  import { useHotkeys } from "../hooks/useHotkeys.ts"
12
12
  import { useNoColor, colorOrUndef } from "../hooks/useNoColor.ts"
13
+ import { ControlsHint } from "../components/ControlsHint.tsx"
14
+
15
+ const DAEMONS_CONTROLS =
16
+ "↑/↓ focus enter drill in s start/stop k takeover 1-9 jump g agents q quit " +
17
+ "● running ✗ crashed ⚠ blocked"
13
18
 
14
19
  const STATUS_GLYPH: Record<DaemonStatus, string> = {
15
20
  idle: "○",
@@ -36,6 +41,7 @@ const STATUS_COLOR: Record<DaemonStatus, string> = {
36
41
  export function ServerList(): React.ReactElement {
37
42
  const { rows } = useDaemons()
38
43
  const { focusedIdx, setFocusedIdx, showLog } = useView()
44
+ const [controlsOpen, setControlsOpen] = useState(false)
39
45
  // Hotkeys hook gives us the toggle; quit isn't called here so we pass
40
46
  // a noop — the global useInput owns the q routing.
41
47
  const { onToggleFocused } = useHotkeys(() => {})
@@ -43,6 +49,10 @@ export function ServerList(): React.ReactElement {
43
49
  const rawModeSupported = process.stdin.isTTY === true
44
50
  useInput(
45
51
  (input, key) => {
52
+ if (input === "c") {
53
+ setControlsOpen((v) => !v)
54
+ return
55
+ }
46
56
  if (rows.length === 0) return
47
57
  if (key.upArrow) {
48
58
  setFocusedIdx((focusedIdx - 1 + rows.length) % rows.length)
@@ -66,11 +76,7 @@ export function ServerList(): React.ReactElement {
66
76
  {rows.map((row, i) => (
67
77
  <DaemonListRow key={row.descriptor.key} row={row} focused={i === focusedIdx} />
68
78
  ))}
69
- <Box marginTop={1}>
70
- <Text dimColor>
71
- ↑/↓ focus enter drill in s start/stop k takeover 1-9 jump q quit ● running ✗ crashed ⚠ blocked
72
- </Text>
73
- </Box>
79
+ <ControlsHint expanded={controlsOpen} controls={DAEMONS_CONTROLS} />
74
80
  </Box>
75
81
  )
76
82
  }