@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davstack/tui",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Long-running terminal UI that spawns, owns, and surfaces the davstack daemons (vitest-server, playwright-server, logs-server).",
@@ -18,11 +18,14 @@
18
18
  "commander": "^12.0.0",
19
19
  "ink": "^5.0.1",
20
20
  "react": "^18.3.1",
21
- "tsx": "^4.19.0"
21
+ "tsx": "^4.19.0",
22
+ "@davstack/open-agents": "1.2.0"
22
23
  },
23
24
  "devDependencies": {
25
+ "@types/node": "^25.9.1",
24
26
  "@types/react": "^18.3.3",
25
- "ink-testing-library": "^4.0.0"
27
+ "ink-testing-library": "^4.0.0",
28
+ "typescript": "^5.0.0"
26
29
  },
27
30
  "publishConfig": {
28
31
  "access": "public"
package/src/App.tsx CHANGED
@@ -8,6 +8,7 @@ import { daemonRegistry, type DaemonDescriptor } from "./lib/daemon-registry.ts"
8
8
  import { installGlobalTeardown } from "./lib/global-teardown.ts"
9
9
 
10
10
  import { ViewProvider } from "./state/view-context.tsx"
11
+ import { AgentsProvider } from "./state/agents-context.tsx"
11
12
  import { DaemonsProvider } from "./state/daemons-context.tsx"
12
13
  import { QuitProvider, useQuit } from "./state/quit-context.tsx"
13
14
 
@@ -44,7 +45,8 @@ export function App({
44
45
 
45
46
  return (
46
47
  <ViewProvider>
47
- <DaemonsProvider descriptors={filtered}>
48
+ <AgentsProvider>
49
+ <DaemonsProvider descriptors={filtered}>
48
50
  <QuitProvider>
49
51
  <DescriptorSync descriptors={filtered} />
50
52
  {filtered.map((d) => (
@@ -61,7 +63,8 @@ export function App({
61
63
  )}
62
64
  </QuitController>
63
65
  </QuitProvider>
64
- </DaemonsProvider>
66
+ </DaemonsProvider>
67
+ </AgentsProvider>
65
68
  </ViewProvider>
66
69
  )
67
70
  }
package/src/cli.ts CHANGED
@@ -13,11 +13,31 @@ import { runCheck, formatResult, exitCodeFor } from "./commands/check.ts"
13
13
 
14
14
  function runStart(opts: { noColor?: boolean }): void {
15
15
  if (opts.noColor) process.env.DAVSTACK_NO_COLOR = "1"
16
- // Wipe stale shell scrollback so the Ink UI takes over a clean screen.
17
- // Sequence: ED(2) clears viewport, ED(3) clears scrollback (xterm/WT),
18
- // CUP homes the cursor.
19
- process.stdout.write("\x1b[2J\x1b[3J\x1b[H")
20
- render(React.createElement(App))
16
+ // Use the terminal's alternate screen buffer so view transitions
17
+ // (drill in/back) don't leak prior frames into scrollback when Ink
18
+ // shrinks its render region. Falls back to a one-shot scrollback
19
+ // wipe when stdout isn't a TTY or DAVSTACK_NO_ALTSCREEN is set.
20
+ const altScreen =
21
+ process.stdout.isTTY === true && process.env.DAVSTACK_NO_ALTSCREEN !== "1"
22
+ if (altScreen) {
23
+ process.stdout.write("\x1b[?1049h\x1b[H")
24
+ } else {
25
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H")
26
+ }
27
+ const restore = () => {
28
+ if (altScreen) process.stdout.write("\x1b[?1049l")
29
+ }
30
+ process.once("exit", restore)
31
+ process.once("SIGINT", () => {
32
+ restore()
33
+ process.exit(130)
34
+ })
35
+ process.once("SIGTERM", () => {
36
+ restore()
37
+ process.exit(143)
38
+ })
39
+ const ink = render(React.createElement(App))
40
+ ink.waitUntilExit().finally(restore)
21
41
  }
22
42
 
23
43
  const program = new Command()
@@ -1,6 +1,5 @@
1
- // Persistent bottom bar — renders in both list and log views. Reads
2
- // rows + current view from context to derive pills and which one is
3
- // focused (highlighted bold + inverse).
1
+ // Persistent bottom bar — daemon pills only. Running-agent pills used
2
+ // to live here too but the dedicated `g` view supersedes them.
4
3
 
5
4
  import React from "react"
6
5
 
@@ -18,11 +17,13 @@ function statusToPill(s: DaemonRow["status"]): PillStatus {
18
17
  export function BottomBar(): React.ReactElement {
19
18
  const { rows } = useDaemons()
20
19
  const { view } = useView()
20
+
21
21
  const pills: DaemonPill[] = rows.map((r, i) => ({
22
22
  key: String(i + 1),
23
23
  daemonKey: r.descriptor.key,
24
24
  label: r.descriptor.label,
25
25
  status: statusToPill(r.status),
26
26
  }))
27
+
27
28
  return <StatusBar daemons={pills} focusedKey={view.kind === "log" ? view.key : undefined} />
28
29
  }
@@ -0,0 +1,19 @@
1
+ // Collapsed-by-default keyboard hint row. Default shows `c for controls`;
2
+ // toggled state inlines the full hint string. Toggle is owned by the
3
+ // parent view via useInput so each view decides what `c` means.
4
+
5
+ import React from "react"
6
+ import { Box, Text } from "ink"
7
+
8
+ export interface ControlsHintProps {
9
+ expanded: boolean
10
+ controls: string
11
+ }
12
+
13
+ export function ControlsHint({ expanded, controls }: ControlsHintProps): React.ReactElement {
14
+ return (
15
+ <Box marginTop={1}>
16
+ <Text dimColor>{expanded ? controls : "c for controls"}</Text>
17
+ </Box>
18
+ )
19
+ }
@@ -6,6 +6,8 @@ import { Box, Text, useStdout } from "ink"
6
6
 
7
7
  import { ServerList } from "../views/ServerList.tsx"
8
8
  import { ServerLogView } from "../views/ServerLogView.tsx"
9
+ import { AgentsList } from "../views/AgentsList.tsx"
10
+ import { AgentTimelineView } from "../views/AgentTimelineView.tsx"
9
11
  import { useView } from "../state/view-context.tsx"
10
12
  import { useDaemons } from "../state/daemons-context.tsx"
11
13
  import { getPackageVersion, getRepoRootSafe } from "../lib/package-info.ts"
@@ -20,6 +22,12 @@ export function MainView({ discoveryDone, hasAnyDaemon }: MainViewProps): React.
20
22
  const { view } = useView()
21
23
  const { rows } = useDaemons()
22
24
 
25
+ if (view.kind === "agent") {
26
+ return <AgentTimelineView jobId={view.id} />
27
+ }
28
+ if (view.kind === "agents") {
29
+ return <AgentsList />
30
+ }
23
31
  if (!discoveryDone) {
24
32
  return <Text dimColor>scanning .davstack/config…</Text>
25
33
  }
@@ -0,0 +1,130 @@
1
+ // Tiny markdown renderer for Ink. Handles the subset our agent spec
2
+ // files actually use: headings, bullets, fenced code blocks, inline
3
+ // **bold** and `code`. Anything fancier (tables, links, blockquotes)
4
+ // falls through as plain text rather than blowing up the layout.
5
+
6
+ import React from "react"
7
+ import { Text } from "ink"
8
+
9
+ export interface MarkdownProps {
10
+ source: string
11
+ maxLines?: number
12
+ }
13
+
14
+ interface InlineToken {
15
+ kind: "text" | "bold" | "code"
16
+ value: string
17
+ }
18
+
19
+ export function Markdown({ source, maxLines }: MarkdownProps): React.ReactElement {
20
+ const rawLines = React.useMemo(
21
+ () => source.replace(/\r\n/g, "\n").split("\n"),
22
+ [source],
23
+ )
24
+ const sliced = maxLines != null ? rawLines.slice(0, maxLines) : rawLines
25
+ let inCodeFence = false
26
+ const elements: React.ReactNode[] = []
27
+ for (let i = 0; i < sliced.length; i += 1) {
28
+ const line = sliced[i]!
29
+ if (/^\s*```/.test(line)) {
30
+ inCodeFence = !inCodeFence
31
+ elements.push(
32
+ <Text key={i} dimColor>
33
+ {line}
34
+ </Text>,
35
+ )
36
+ continue
37
+ }
38
+ if (inCodeFence) {
39
+ elements.push(
40
+ <Text key={i} color="yellow" dimColor>
41
+ {line}
42
+ </Text>,
43
+ )
44
+ continue
45
+ }
46
+ elements.push(renderLine(i, line))
47
+ }
48
+ return <>{elements}</>
49
+ }
50
+
51
+ function renderLine(key: number, line: string): React.ReactNode {
52
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line)
53
+ if (heading) {
54
+ const level = heading[1]!.length
55
+ const color = level === 1 ? "cyan" : level === 2 ? "yellow" : undefined
56
+ return (
57
+ <Text key={key} bold color={color}>
58
+ {heading[2]!}
59
+ </Text>
60
+ )
61
+ }
62
+ if (/^\s*---+\s*$/.test(line)) {
63
+ return (
64
+ <Text key={key} dimColor>
65
+ {"─".repeat(60)}
66
+ </Text>
67
+ )
68
+ }
69
+ const bullet = /^(\s*)([-*])\s+(.*)$/.exec(line)
70
+ if (bullet) {
71
+ return (
72
+ <Text key={key}>
73
+ {bullet[1]}
74
+ <Text color="cyan">• </Text>
75
+ {renderInline(bullet[3]!)}
76
+ </Text>
77
+ )
78
+ }
79
+ if (line.trim().length === 0) {
80
+ return <Text key={key}> </Text>
81
+ }
82
+ return <Text key={key}>{renderInline(line)}</Text>
83
+ }
84
+
85
+ function renderInline(text: string): React.ReactNode {
86
+ const tokens = tokenizeInline(text)
87
+ return tokens.map((t, i) => {
88
+ if (t.kind === "bold")
89
+ return (
90
+ <Text key={i} bold>
91
+ {t.value}
92
+ </Text>
93
+ )
94
+ if (t.kind === "code")
95
+ return (
96
+ <Text key={i} color="yellow">
97
+ {t.value}
98
+ </Text>
99
+ )
100
+ return <Text key={i}>{t.value}</Text>
101
+ })
102
+ }
103
+
104
+ function tokenizeInline(text: string): InlineToken[] {
105
+ const out: InlineToken[] = []
106
+ let i = 0
107
+ while (i < text.length) {
108
+ if (text.startsWith("**", i)) {
109
+ const end = text.indexOf("**", i + 2)
110
+ if (end > i + 2) {
111
+ out.push({ kind: "bold", value: text.slice(i + 2, end) })
112
+ i = end + 2
113
+ continue
114
+ }
115
+ }
116
+ if (text[i] === "`") {
117
+ const end = text.indexOf("`", i + 1)
118
+ if (end > i + 1) {
119
+ out.push({ kind: "code", value: text.slice(i + 1, end) })
120
+ i = end + 1
121
+ continue
122
+ }
123
+ }
124
+ let j = i + 1
125
+ while (j < text.length && !text.startsWith("**", j) && text[j] !== "`") j += 1
126
+ out.push({ kind: "text", value: text.slice(i, j) })
127
+ i = j
128
+ }
129
+ return out
130
+ }
@@ -18,6 +18,11 @@ export interface DaemonPill {
18
18
  status: DaemonStatus
19
19
  }
20
20
 
21
+ export interface AgentPill {
22
+ jobId: string
23
+ label: string
24
+ }
25
+
21
26
  const STATUS_GLYPH: Record<DaemonStatus, string> = {
22
27
  "not-running": "○",
23
28
  running: "●",
@@ -34,9 +39,11 @@ const STATUS_COLOR: Record<DaemonStatus, string | undefined> = {
34
39
 
35
40
  interface StatusBarProps {
36
41
  daemons: DaemonPill[]
42
+ agents?: AgentPill[]
37
43
  // Daemon key currently being viewed (log view). The matching pill is
38
44
  // rendered bold + inverse so users can see which one they're in.
39
45
  focusedKey?: string
46
+ focusedAgentId?: string
40
47
  }
41
48
 
42
49
  // Exported for unit tests — avoids depending on ANSI escape detection
@@ -45,14 +52,24 @@ export function isPillFocused(pill: DaemonPill, focusedKey: string | undefined):
45
52
  return focusedKey !== undefined && pill.daemonKey === focusedKey
46
53
  }
47
54
 
48
- export function StatusBar({ daemons, focusedKey }: StatusBarProps): React.ReactElement {
55
+ export function isAgentPillFocused(pill: AgentPill, focusedAgentId: string | undefined): boolean {
56
+ return focusedAgentId !== undefined && pill.jobId === focusedAgentId
57
+ }
58
+
59
+ export function StatusBar({
60
+ daemons,
61
+ agents = [],
62
+ focusedKey,
63
+ focusedAgentId,
64
+ }: StatusBarProps): React.ReactElement {
49
65
  const noColor = useNoColor()
50
66
  return (
51
67
  <Box flexDirection="row">
52
68
  {daemons.map((d, i) => {
53
69
  const focused = isPillFocused(d, focusedKey)
70
+ const marginRight = i === daemons.length - 1 && agents.length === 0 ? 0 : 2
54
71
  return (
55
- <Box key={d.key} marginRight={i === daemons.length - 1 ? 0 : 2}>
72
+ <Box key={d.key} marginRight={marginRight}>
56
73
  <Text inverse={focused} bold={focused}>
57
74
  {d.key} {d.label}{" "}
58
75
  <Text color={colorOrUndef(STATUS_COLOR[d.status], noColor)}>
@@ -62,6 +79,21 @@ export function StatusBar({ daemons, focusedKey }: StatusBarProps): React.ReactE
62
79
  </Box>
63
80
  )
64
81
  })}
82
+ {agents.length > 0 ? (
83
+ <Box marginRight={2}>
84
+ <Text dimColor>|</Text>
85
+ </Box>
86
+ ) : null}
87
+ {agents.map((a, i) => {
88
+ const focused = isAgentPillFocused(a, focusedAgentId)
89
+ return (
90
+ <Box key={a.jobId} marginRight={i === agents.length - 1 ? 0 : 2}>
91
+ <Text inverse={focused} bold={focused}>
92
+ <Text color={colorOrUndef("yellow", noColor)}>{a.label}</Text>
93
+ </Text>
94
+ </Box>
95
+ )
96
+ })}
65
97
  </Box>
66
98
  )
67
99
  }
@@ -0,0 +1,10 @@
1
+ import type { JobRecord } from "@davstack/open-agents/core/jobs"
2
+ import type { AgentAdapter } from "@davstack/open-agents/adapters/types"
3
+ import { cursorAdapter } from "@davstack/open-agents/adapters/cursor"
4
+ import { geminiAdapter } from "@davstack/open-agents/adapters/gemini"
5
+
6
+ export function adapterForJob(job: Pick<JobRecord, "model">): AgentAdapter {
7
+ const m = job.model.toLowerCase()
8
+ if (m.startsWith("gemini-")) return geminiAdapter
9
+ return cursorAdapter
10
+ }
@@ -0,0 +1,149 @@
1
+ // Hook test for useAgentJobs: fixture jobs dir under tmp OPEN_AGENTS_HOME,
2
+ // ink-testing-library probe, mtime poll refresh.
3
+
4
+ import fs from "node:fs/promises"
5
+ import os from "node:os"
6
+ import path from "node:path"
7
+ import React from "react"
8
+ import { afterEach, beforeEach, expect, test, vi } from "vitest"
9
+ import { render } from "ink-testing-library"
10
+ import { Text } from "ink"
11
+
12
+ import type { JobRecord } from "@davstack/open-agents/core/jobs"
13
+ import { jobsDir } from "@davstack/open-agents/core/paths"
14
+
15
+ import { useAgentJobs, type UseAgentJobsResult } from "./useAgentJobs.ts"
16
+
17
+ const origHome = process.env.OPEN_AGENTS_HOME
18
+ let tmpRoot = ""
19
+ let repoPath = ""
20
+
21
+ beforeEach(async () => {
22
+ vi.useFakeTimers()
23
+ tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "davstack-tui-jobs-"))
24
+ process.env.OPEN_AGENTS_HOME = tmpRoot
25
+ repoPath = path.join(tmpRoot, "repo")
26
+ await fs.mkdir(repoPath, { recursive: true })
27
+ })
28
+
29
+ afterEach(async () => {
30
+ vi.useRealTimers()
31
+ if (origHome === undefined) delete process.env.OPEN_AGENTS_HOME
32
+ else process.env.OPEN_AGENTS_HOME = origHome
33
+ await fs.rm(tmpRoot, { recursive: true, force: true })
34
+ })
35
+
36
+ async function writeJob(opts: {
37
+ id: string
38
+ status?: JobRecord["status"]
39
+ startedAt?: string
40
+ model?: string
41
+ }): Promise<void> {
42
+ const dir = jobsDir(repoPath)
43
+ await fs.mkdir(dir, { recursive: true })
44
+ const record: JobRecord = {
45
+ id: opts.id,
46
+ repoPath,
47
+ prompt: "fixture prompt",
48
+ model: opts.model ?? "composer-2.5",
49
+ status: opts.status ?? "done",
50
+ startedAt: opts.startedAt ?? new Date().toISOString(),
51
+ rawLogPath: path.join(dir, "logs", `${opts.id}.ndjson`),
52
+ }
53
+ await fs.writeFile(path.join(dir, `${opts.id}.json`), JSON.stringify(record, null, 2))
54
+ }
55
+
56
+ function Probe({
57
+ onRender,
58
+ }: {
59
+ onRender: (r: UseAgentJobsResult) => void
60
+ }): React.ReactElement {
61
+ const r = useAgentJobs(repoPath)
62
+ onRender(r)
63
+ return React.createElement(Text, null, `n=${r.jobs.length}`)
64
+ }
65
+
66
+ let active: ReturnType<typeof render> | null = null
67
+ afterEach(() => {
68
+ active?.unmount()
69
+ active = null
70
+ })
71
+
72
+ async function flush(): Promise<void> {
73
+ await Promise.resolve()
74
+ await vi.runOnlyPendingTimersAsync()
75
+ await Promise.resolve()
76
+ }
77
+
78
+ test("lists fixture jobs from tmp OPEN_AGENTS_HOME jobs dir", async () => {
79
+ await writeJob({ id: "job-a", status: "done", startedAt: "2026-05-26T10:00:00.000Z" })
80
+ await writeJob({ id: "job-b", status: "running", startedAt: "2026-05-26T11:00:00.000Z" })
81
+
82
+ let captured: UseAgentJobsResult | null = null
83
+ active = render(
84
+ React.createElement(Probe, {
85
+ onRender: (r) => {
86
+ captured = r
87
+ },
88
+ }),
89
+ )
90
+ await flush()
91
+
92
+ expect(captured!.jobs.map((j) => j.id)).toEqual(["job-b", "job-a"])
93
+ })
94
+
95
+ test("returns empty list when jobs dir does not exist yet", async () => {
96
+ let captured: UseAgentJobsResult | null = null
97
+ active = render(
98
+ React.createElement(Probe, {
99
+ onRender: (r) => {
100
+ captured = r
101
+ },
102
+ }),
103
+ )
104
+ await flush()
105
+ expect(captured!.jobs).toEqual([])
106
+ })
107
+
108
+ test("mtime poll picks up a newly written job", async () => {
109
+ await writeJob({ id: "job-1" })
110
+ let captured: UseAgentJobsResult | null = null
111
+ active = render(
112
+ React.createElement(Probe, {
113
+ onRender: (r) => {
114
+ captured = r
115
+ },
116
+ }),
117
+ )
118
+ await flush()
119
+ expect(captured!.jobs).toHaveLength(1)
120
+
121
+ await writeJob({ id: "job-2", status: "running" })
122
+ const dir = jobsDir(repoPath)
123
+ const now = new Date()
124
+ await fs.utimes(dir, now, now)
125
+
126
+ await vi.advanceTimersByTimeAsync(500)
127
+ await flush()
128
+
129
+ expect(captured!.jobs.map((j) => j.id).sort()).toEqual(["job-1", "job-2"])
130
+ })
131
+
132
+ test("ignores non-job json files in the jobs dir", async () => {
133
+ const dir = jobsDir(repoPath)
134
+ await fs.mkdir(dir, { recursive: true })
135
+ await fs.writeFile(path.join(dir, "README.json"), '{"nope":true}', "utf8")
136
+ await writeJob({ id: "job-only" })
137
+
138
+ let captured: UseAgentJobsResult | null = null
139
+ active = render(
140
+ React.createElement(Probe, {
141
+ onRender: (r) => {
142
+ captured = r
143
+ },
144
+ }),
145
+ )
146
+ await flush()
147
+
148
+ expect(captured!.jobs.map((j) => j.id)).toEqual(["job-only"])
149
+ })
@@ -0,0 +1,53 @@
1
+ import fs from "node:fs"
2
+ import { useEffect, useMemo, useRef, useState } from "react"
3
+
4
+ import { listJobs, type JobRecord } from "@davstack/open-agents/core/jobs"
5
+ import { jobsDir } from "@davstack/open-agents/core/paths"
6
+
7
+ export interface UseAgentJobsResult {
8
+ jobs: JobRecord[]
9
+ }
10
+
11
+ function jobStableKey(j: JobRecord): string {
12
+ return `${j.id}:${j.status}:${j.finishedAt ?? ""}`
13
+ }
14
+
15
+ export function useAgentJobs(repoPath: string): UseAgentJobsResult {
16
+ const [jobs, setJobs] = useState<JobRecord[]>([])
17
+ const [tick, setTick] = useState(0)
18
+ const lastMtimeRef = useRef(0)
19
+ const lastKeysRef = useRef("")
20
+
21
+ useEffect(() => {
22
+ const id = setInterval(() => {
23
+ try {
24
+ const st = fs.statSync(jobsDir(repoPath))
25
+ const m = st.mtimeMs
26
+ if (m === lastMtimeRef.current) return
27
+ lastMtimeRef.current = m
28
+ setTick((t) => t + 1)
29
+ } catch {
30
+ /* dir not yet created */
31
+ }
32
+ }, 500)
33
+ return () => clearInterval(id)
34
+ }, [repoPath])
35
+
36
+ useEffect(() => {
37
+ const next = listJobs(repoPath, { limit: 20 })
38
+ const keys = next.map(jobStableKey).join("|")
39
+ if (keys === lastKeysRef.current) return
40
+ lastKeysRef.current = keys
41
+ setJobs(next)
42
+ }, [repoPath, tick])
43
+
44
+ const sorted = useMemo(() => sortAgentJobs(jobs), [jobs])
45
+
46
+ return { jobs: sorted }
47
+ }
48
+
49
+ function sortAgentJobs(jobs: JobRecord[]): JobRecord[] {
50
+ const running = jobs.filter((j) => j.status === "running")
51
+ const rest = jobs.filter((j) => j.status !== "running")
52
+ return [...running, ...rest]
53
+ }