@meistrari/tela-cli 0.1.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,231 @@
1
+ import React, { useEffect, useRef, useState } from 'react'
2
+ import { Text, useInput } from 'ink'
3
+ import type { PushPreview } from '../../commands/push.ts'
4
+ import type { PushResult } from '../../types.ts'
5
+ import { AppFrame, Callout, Section, SelectList, SpinnerText, TextEntry } from '../components.tsx'
6
+ import type { CommandScreenController } from '../runtime.tsx'
7
+
8
+ export interface PushScreenResult {
9
+ ok: boolean
10
+ branch?: string
11
+ commitMessage?: string
12
+ commitSha?: string
13
+ retriedCommit?: boolean
14
+ nextActions: string[]
15
+ }
16
+
17
+ export interface PushScreenProps {
18
+ controller: CommandScreenController<PushScreenResult>
19
+ initialMode?: 'manual' | 'claude'
20
+ initialManualMessage?: string
21
+ dryRun?: boolean
22
+ loadPreview: (input: {
23
+ mode: 'manual' | 'claude'
24
+ manualMessage?: string
25
+ }) => Promise<PushPreview>
26
+ execute: (input: {
27
+ commitMessage: string
28
+ messageSource: 'manual' | 'claude' | 'fallback'
29
+ fallbackReason?: string
30
+ }) => Promise<PushResult>
31
+ }
32
+
33
+ type PushPhase = 'mode' | 'loadingPreview' | 'review' | 'running' | 'done'
34
+
35
+ export function PushScreen(props: PushScreenProps): React.ReactElement {
36
+ const [phase, setPhase] = useState<PushPhase>(props.initialMode ? 'loadingPreview' : 'mode')
37
+ const [mode, setMode] = useState<'manual' | 'claude'>(props.initialMode ?? 'manual')
38
+ const [modeIndex, setModeIndex] = useState(props.initialMode === 'claude' ? 1 : 0)
39
+ const [preview, setPreview] = useState<PushPreview | null>(null)
40
+ const [message, setMessage] = useState(props.initialManualMessage ?? '')
41
+ const [result, setResult] = useState<PushResult | null>(null)
42
+ const startedRef = useRef(false)
43
+
44
+ useEffect(() => {
45
+ if (phase !== 'loadingPreview' || startedRef.current) {
46
+ return
47
+ }
48
+
49
+ startedRef.current = true
50
+ void props.loadPreview({
51
+ mode,
52
+ manualMessage: props.initialManualMessage ?? undefined,
53
+ })
54
+ .then((nextPreview) => {
55
+ setPreview(nextPreview)
56
+ setMessage(nextPreview.suggestedMessage ?? props.initialManualMessage ?? '')
57
+ setPhase('review')
58
+ })
59
+ .catch((error) => {
60
+ props.controller.reject(error)
61
+ })
62
+ }, [mode, phase, props])
63
+
64
+ useEffect(() => {
65
+ if (phase === 'review' || phase === 'done') {
66
+ startedRef.current = false
67
+ }
68
+ }, [phase])
69
+
70
+ useEffect(() => {
71
+ if (phase === 'done' && result) {
72
+ props.controller.resolve({
73
+ ok: true,
74
+ branch: result.branch,
75
+ commitMessage: result.message,
76
+ commitSha: result.commitSha,
77
+ retriedCommit: result.retriedCommit,
78
+ nextActions: [
79
+ 'Run `tela feedback --list` to inspect feedback for the pushed commit.',
80
+ 'Use `tela status` if you want to verify the repository is clean afterward.',
81
+ ],
82
+ }, 40)
83
+ }
84
+ }, [phase, props, result])
85
+
86
+ useInput((input, key) => {
87
+ if (phase === 'mode') {
88
+ if (key.downArrow || input === 'j') {
89
+ setModeIndex(current => Math.min(current + 1, 1))
90
+ return
91
+ }
92
+
93
+ if (key.upArrow || input === 'k') {
94
+ setModeIndex(current => Math.max(current - 1, 0))
95
+ return
96
+ }
97
+
98
+ if (key.escape) {
99
+ props.controller.reject(new Error('Push cancelled.'))
100
+ return
101
+ }
102
+
103
+ if (key.return) {
104
+ setMode(modeIndex === 0 ? 'manual' : 'claude')
105
+ setPhase('loadingPreview')
106
+ }
107
+ return
108
+ }
109
+
110
+ if (phase === 'review') {
111
+ if (key.escape) {
112
+ props.controller.reject(new Error('Push cancelled.'))
113
+ return
114
+ }
115
+
116
+ if (key.backspace || key.delete) {
117
+ setMessage(current => current.slice(0, -1))
118
+ return
119
+ }
120
+
121
+ if (key.return) {
122
+ if (props.dryRun) {
123
+ props.controller.resolve({
124
+ ok: true,
125
+ branch: preview?.branch,
126
+ commitMessage: message,
127
+ nextActions: ['Run `tela push` without `--dry-run` when the plan looks correct.'],
128
+ })
129
+ return
130
+ }
131
+
132
+ setPhase('running')
133
+ void props.execute({
134
+ commitMessage: message,
135
+ messageSource: preview?.messageSource ?? (mode === 'manual' ? 'manual' : 'claude'),
136
+ fallbackReason: preview?.fallbackReason,
137
+ })
138
+ .then((nextResult) => {
139
+ setResult(nextResult)
140
+ setPhase('done')
141
+ })
142
+ .catch((error) => {
143
+ props.controller.reject(error)
144
+ })
145
+ return
146
+ }
147
+
148
+ if (input) {
149
+ setMessage(current => `${current}${input}`)
150
+ }
151
+ }
152
+ })
153
+
154
+ return (
155
+ <AppFrame
156
+ title="Push"
157
+ subtitle="Preview the worktree, review the commit message, and publish the branch."
158
+ footer={
159
+ phase === 'mode'
160
+ ? '↑/↓ move · Enter select · Esc cancel'
161
+ : phase === 'review'
162
+ ? 'Type to edit commit message · Enter push · Esc cancel'
163
+ : undefined
164
+ }
165
+ status={phase === 'running' ? 'Creating commit and pushing branch' : undefined}
166
+ >
167
+ {phase === 'mode' ? (
168
+ <Section title="Commit message mode">
169
+ <SelectList
170
+ items={[
171
+ { key: 'manual', label: 'Write the commit message manually' },
172
+ { key: 'claude', label: 'Generate the commit message with Claude' },
173
+ ]}
174
+ currentIndex={modeIndex}
175
+ />
176
+ </Section>
177
+ ) : null}
178
+
179
+ {phase === 'loadingPreview' ? (
180
+ <Section title="Loading preview">
181
+ <SpinnerText label="Inspecting the current repository state" />
182
+ </Section>
183
+ ) : null}
184
+
185
+ {phase === 'review' && preview ? (
186
+ <>
187
+ <Section title="Repository">
188
+ <Text>Repo root: {preview.repoRoot}</Text>
189
+ <Text>Branch: {preview.detached ? '<detached>' : (preview.branch ?? '<unknown>')}</Text>
190
+ <Text>Upstream: {preview.upstream ?? '<none>'}</Text>
191
+ <Text>Changed files: {preview.statusCounts.total}</Text>
192
+ <Text>Staged: {preview.statusCounts.staged}</Text>
193
+ <Text>Unstaged: {preview.statusCounts.unstaged}</Text>
194
+ <Text>Untracked: {preview.statusCounts.untracked}</Text>
195
+ </Section>
196
+ {preview.worktreeStatus ? (
197
+ <Section title="Worktree">
198
+ <Text>{preview.worktreeStatus}</Text>
199
+ </Section>
200
+ ) : null}
201
+ {preview.messageSource === 'fallback' ? (
202
+ <Callout tone="warn">
203
+ {`Using deterministic fallback message because ${preview.fallbackReason ?? 'Claude did not return a usable message'}.`}
204
+ </Callout>
205
+ ) : null}
206
+ <Section title="Commit message">
207
+ <TextEntry label="Message" value={message} placeholder="Commit message" />
208
+ </Section>
209
+ </>
210
+ ) : null}
211
+
212
+ {phase === 'running' ? (
213
+ <Section title="Running">
214
+ <SpinnerText label="Staging, committing, and pushing" />
215
+ </Section>
216
+ ) : null}
217
+
218
+ {phase === 'done' && result ? (
219
+ <>
220
+ <Section title="Result">
221
+ <Text>Branch: {result.branch}</Text>
222
+ <Text>Commit: {result.commitSha}</Text>
223
+ <Text>Upstream: {result.upstream ?? '<set on push>'}</Text>
224
+ <Text>Retry after hook changes: {result.retriedCommit ? 'yes' : 'no'}</Text>
225
+ </Section>
226
+ <Callout tone="success">Push completed successfully.</Callout>
227
+ </>
228
+ ) : null}
229
+ </AppFrame>
230
+ )
231
+ }
@@ -0,0 +1,291 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { Box, Text, useInput } from 'ink'
3
+ import type {
4
+ RunCommandResult,
5
+ RunExecutionControl,
6
+ RunExecutionSnapshot,
7
+ RunStepSummary,
8
+ } from '../../commands/run.ts'
9
+ import { createUserFacingError, toUserFacingError } from '../../errors.ts'
10
+ import { AppFrame, Callout, Section, SpinnerText } from '../components.tsx'
11
+ import type { CommandScreenController } from '../runtime.tsx'
12
+
13
+ export interface RunScreenProps {
14
+ controller: CommandScreenController<RunCommandResult>
15
+ initialSnapshot: RunExecutionSnapshot
16
+ execute: (input: {
17
+ onSnapshot: (snapshot: RunExecutionSnapshot) => void
18
+ onControl: (control: RunExecutionControl) => void
19
+ }) => Promise<RunCommandResult>
20
+ completionDelayMs?: number
21
+ }
22
+
23
+ export interface RunChatHandoffScreenProps {
24
+ controller: CommandScreenController<void>
25
+ snapshot: RunExecutionSnapshot
26
+ launchDelayMs?: number
27
+ }
28
+
29
+ type RunScreenPhase = 'running' | 'done' | 'failed'
30
+
31
+ function appendTerminalStep(
32
+ snapshot: RunExecutionSnapshot,
33
+ status: RunExecutionSnapshot['status'],
34
+ summary: string,
35
+ detail?: string,
36
+ ): RunExecutionSnapshot {
37
+ const lastStep = snapshot.steps.at(-1)
38
+ const steps = lastStep?.summary === summary
39
+ ? snapshot.steps
40
+ : [
41
+ ...snapshot.steps,
42
+ {
43
+ id: `step-${snapshot.steps.length + 1}`,
44
+ kind: 'result' as const,
45
+ summary,
46
+ detail,
47
+ notable: status !== 'success',
48
+ },
49
+ ]
50
+
51
+ return {
52
+ ...snapshot,
53
+ status,
54
+ currentActivity: summary,
55
+ steps,
56
+ activeTools: [],
57
+ execution: {
58
+ ...snapshot.execution,
59
+ status,
60
+ errors: detail ? [detail, ...snapshot.execution.errors] : snapshot.execution.errors,
61
+ },
62
+ }
63
+ }
64
+
65
+ function snapshotFromResult(
66
+ result: RunCommandResult,
67
+ startedAtMs: number,
68
+ ): RunExecutionSnapshot {
69
+ return {
70
+ repoRoot: result.repoRoot,
71
+ mode: result.mode,
72
+ model: result.config.model,
73
+ artifactPath: result.resultPath,
74
+ startedAtMs,
75
+ status: result.execution.status,
76
+ currentActivity: result.steps.at(-1)?.summary ?? 'Run complete.',
77
+ steps: result.steps,
78
+ activeTools: [],
79
+ execution: result.execution,
80
+ }
81
+ }
82
+
83
+ function formatElapsed(startedAtMs: number): string {
84
+ const elapsedMs = Math.max(0, Date.now() - startedAtMs)
85
+ if (elapsedMs < 1000) {
86
+ return `${elapsedMs}ms`
87
+ }
88
+
89
+ const seconds = elapsedMs / 1000
90
+ if (seconds < 60) {
91
+ return `${seconds.toFixed(seconds >= 10 ? 1 : 2)}s`
92
+ }
93
+
94
+ const minutes = Math.floor(seconds / 60)
95
+ const remainder = Math.round(seconds % 60)
96
+ return `${minutes}m ${remainder}s`
97
+ }
98
+
99
+ function renderStep(step: RunStepSummary, showDetails: boolean): React.ReactElement {
100
+ return (
101
+ <Box key={step.id} flexDirection="column" marginBottom={showDetails && step.detail ? 1 : 0}>
102
+ <Text color={step.notable ? 'yellowBright' : undefined}>{`• ${step.summary}`}</Text>
103
+ {showDetails && step.detail ? (
104
+ <Box marginLeft={2} flexDirection="column">
105
+ {step.detail.split('\n').map((line, index) => (
106
+ <Text key={`${step.id}-detail-${index}`} dimColor>{line}</Text>
107
+ ))}
108
+ </Box>
109
+ ) : null}
110
+ </Box>
111
+ )
112
+ }
113
+
114
+ export function RunScreen(props: RunScreenProps): React.ReactElement {
115
+ const [snapshot, setSnapshot] = useState<RunExecutionSnapshot>(props.initialSnapshot)
116
+ const [phase, setPhase] = useState<RunScreenPhase>('running')
117
+ const [showDetails, setShowDetails] = useState(false)
118
+ const [autoScroll, setAutoScroll] = useState(true)
119
+ const [now, setNow] = useState(Date.now())
120
+ const controlRef = useRef<RunExecutionControl | null>(null)
121
+ const cancelStateRef = useRef<'idle' | 'interrupt' | 'kill'>('idle')
122
+ const startedRef = useRef(false)
123
+
124
+ useEffect(() => {
125
+ const interval = setInterval(() => {
126
+ setNow(Date.now())
127
+ }, 250)
128
+
129
+ return () => {
130
+ clearInterval(interval)
131
+ }
132
+ }, [])
133
+
134
+ useEffect(() => {
135
+ if (startedRef.current) {
136
+ return
137
+ }
138
+
139
+ startedRef.current = true
140
+ void props.execute({
141
+ onSnapshot(nextSnapshot) {
142
+ setSnapshot(nextSnapshot)
143
+ },
144
+ onControl(control) {
145
+ controlRef.current = control
146
+ },
147
+ })
148
+ .then((result) => {
149
+ setSnapshot(current => snapshotFromResult(result, current.startedAtMs))
150
+ setPhase('done')
151
+ props.controller.resolve(result, props.completionDelayMs ?? 180)
152
+ })
153
+ .catch((error) => {
154
+ const userError = toUserFacingError(error)
155
+ setSnapshot(current => appendTerminalStep(
156
+ current,
157
+ userError.code === 'run_cancelled' ? 'cancelled' : 'failed',
158
+ userError.summary,
159
+ userError.details,
160
+ ))
161
+ setPhase('failed')
162
+ props.controller.reject(error, props.completionDelayMs ?? 180)
163
+ })
164
+ }, [props])
165
+
166
+ useInput((input, key) => {
167
+ if (input === 'd') {
168
+ setShowDetails(current => !current)
169
+ return
170
+ }
171
+
172
+ if (input === 'a') {
173
+ setAutoScroll(current => !current)
174
+ return
175
+ }
176
+
177
+ if (!key.ctrl || input !== 'c' || phase !== 'running') {
178
+ return
179
+ }
180
+
181
+ if (!controlRef.current) {
182
+ props.controller.reject(createUserFacingError({
183
+ code: 'run_cancelled',
184
+ summary: 'Claude run was cancelled.',
185
+ fixes: ['Run `tela run` again when you are ready to continue.'],
186
+ exitCode: 130,
187
+ }))
188
+ return
189
+ }
190
+
191
+ if (cancelStateRef.current === 'idle') {
192
+ cancelStateRef.current = 'interrupt'
193
+ setSnapshot(current => ({
194
+ ...current,
195
+ currentActivity: 'Interrupt requested. Waiting for Claude to stop…',
196
+ }))
197
+ controlRef.current.interrupt()
198
+ return
199
+ }
200
+
201
+ cancelStateRef.current = 'kill'
202
+ setSnapshot(current => ({
203
+ ...current,
204
+ currentActivity: 'Force stopping Claude…',
205
+ }))
206
+ controlRef.current.kill()
207
+ })
208
+
209
+ const displayedSteps = useMemo(() => {
210
+ return autoScroll ? snapshot.steps.slice(-8) : snapshot.steps
211
+ }, [autoScroll, snapshot.steps])
212
+
213
+ const footer = [
214
+ 'd details',
215
+ autoScroll ? 'a show-all' : 'a auto-scroll',
216
+ 'ctrl+c cancel',
217
+ ].join(' ')
218
+
219
+ return (
220
+ <AppFrame
221
+ title="Run"
222
+ subtitle="Follow the current local agent execution in real time."
223
+ footer={footer}
224
+ status={phase === 'running' ? 'Claude one-shot run in progress' : undefined}
225
+ >
226
+ <Section title="Overview">
227
+ <Text>Repo root: {snapshot.repoRoot}</Text>
228
+ <Text>Mode: {snapshot.mode}</Text>
229
+ <Text>Model: {snapshot.model ?? '<default>'}</Text>
230
+ <Text>Artifact: {snapshot.artifactPath}</Text>
231
+ <Text>Elapsed: {formatElapsed(snapshot.startedAtMs || now)}</Text>
232
+ <Text>Status: {snapshot.status}</Text>
233
+ </Section>
234
+
235
+ <Section title="Activity">
236
+ {phase === 'running'
237
+ ? <SpinnerText label={snapshot.currentActivity} />
238
+ : <Text>{snapshot.currentActivity}</Text>}
239
+ </Section>
240
+
241
+ {snapshot.activeTools.length > 0 ? (
242
+ <Section title="Active tools">
243
+ {snapshot.activeTools.map(tool => (
244
+ <Text key={tool.id}>
245
+ {`${tool.summary} · ${tool.elapsedSeconds}s`}
246
+ </Text>
247
+ ))}
248
+ </Section>
249
+ ) : null}
250
+
251
+ <Section title={autoScroll ? 'Timeline (latest)' : 'Timeline'}>
252
+ {displayedSteps.length > 0 ? displayedSteps.map(step => renderStep(step, showDetails)) : (
253
+ <Text dimColor>No execution steps yet.</Text>
254
+ )}
255
+ </Section>
256
+
257
+ {phase === 'done' ? (
258
+ <Callout tone="success">Run complete. Tela will print the durable recap next.</Callout>
259
+ ) : null}
260
+ {phase === 'failed' ? (
261
+ <Callout tone={snapshot.status === 'cancelled' ? 'warn' : 'error'}>
262
+ {snapshot.status === 'cancelled' ? 'Run cancelled.' : 'Run failed.'}
263
+ </Callout>
264
+ ) : null}
265
+ </AppFrame>
266
+ )
267
+ }
268
+
269
+ export function RunChatHandoffScreen(props: RunChatHandoffScreenProps): React.ReactElement {
270
+ useEffect(() => {
271
+ props.controller.resolve(undefined, props.launchDelayMs ?? 650)
272
+ }, [props])
273
+
274
+ return (
275
+ <AppFrame
276
+ title="Run"
277
+ subtitle="Handing off to native Claude chat. Tela will resume after you exit Claude."
278
+ status="Launching Claude chat…"
279
+ >
280
+ <Section title="Overview">
281
+ <Text>Repo root: {props.snapshot.repoRoot}</Text>
282
+ <Text>Mode: {props.snapshot.mode}</Text>
283
+ <Text>Model: {props.snapshot.model ?? '<default>'}</Text>
284
+ <Text>Artifact: {props.snapshot.artifactPath}</Text>
285
+ </Section>
286
+ <Callout tone="info">
287
+ Claude chat will take over this terminal. Follow the steps there, then exit Claude to let Tela read the result artifact.
288
+ </Callout>
289
+ </AppFrame>
290
+ )
291
+ }