@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.
- package/README.md +95 -0
- package/cli.ts +25 -0
- package/package.json +30 -0
- package/src/agent-api.ts +124 -0
- package/src/auth.ts +16 -0
- package/src/commands/doctor.ts +35 -0
- package/src/commands/feedback.ts +701 -0
- package/src/commands/metadata.ts +208 -0
- package/src/commands/push.ts +208 -0
- package/src/commands/run.ts +1601 -0
- package/src/commands/status.ts +87 -0
- package/src/commands/sync.ts +245 -0
- package/src/commit-message.ts +69 -0
- package/src/config.ts +384 -0
- package/src/diagnostics.ts +578 -0
- package/src/errors.ts +122 -0
- package/src/git.ts +250 -0
- package/src/ink/bark.tsx +288 -0
- package/src/ink/components.tsx +175 -0
- package/src/ink/runtime.tsx +89 -0
- package/src/ink/screens/feedback-screen.tsx +394 -0
- package/src/ink/screens/push-screen.tsx +231 -0
- package/src/ink/screens/run-screen.tsx +291 -0
- package/src/ink/screens/setup-screen.tsx +352 -0
- package/src/ink/screens/sync-screen.tsx +282 -0
- package/src/process.ts +251 -0
- package/src/program.ts +1872 -0
- package/src/runtime.ts +147 -0
- package/src/types.ts +80 -0
- package/src/ui.ts +297 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useState, type ReactNode } from 'react'
|
|
2
|
+
import { Box, Text } from 'ink'
|
|
3
|
+
import { getBootstrapPixelRows, getTelaAsciiHeader } from '../ui.ts'
|
|
4
|
+
|
|
5
|
+
export interface AppFrameProps {
|
|
6
|
+
title: string
|
|
7
|
+
subtitle?: string
|
|
8
|
+
footer?: string
|
|
9
|
+
status?: string
|
|
10
|
+
mascotRows?: readonly string[]
|
|
11
|
+
children: ReactNode
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function AppFrame(props: AppFrameProps): React.ReactElement {
|
|
15
|
+
const mascot = useMemo(() => props.mascotRows ?? getBootstrapPixelRows(), [props.mascotRows])
|
|
16
|
+
const header = useMemo(() => getTelaAsciiHeader(), [])
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<Box flexDirection="column" paddingX={2} paddingY={1}>
|
|
20
|
+
<Box flexDirection="column">
|
|
21
|
+
{header.map((line, index) => (
|
|
22
|
+
<Text key={`header-${index}`} color="white">
|
|
23
|
+
{line}
|
|
24
|
+
</Text>
|
|
25
|
+
))}
|
|
26
|
+
</Box>
|
|
27
|
+
<Box flexDirection="column" marginTop={1}>
|
|
28
|
+
<Text color="white" bold>Tela CLI · {props.title}</Text>
|
|
29
|
+
{props.subtitle ? <Text dimColor>{props.subtitle}</Text> : null}
|
|
30
|
+
{props.status ? <Text color="yellow">{props.status}</Text> : null}
|
|
31
|
+
</Box>
|
|
32
|
+
<Box flexDirection="column" marginTop={1}>
|
|
33
|
+
{mascot.map((row, index) => (
|
|
34
|
+
<Box key={`mascot-${index}`} flexDirection="row">
|
|
35
|
+
{row.split('').map((cell, cellIndex) => {
|
|
36
|
+
const level = Number.parseInt(cell, 16)
|
|
37
|
+
const channel = Math.round((level / 15) * 255)
|
|
38
|
+
const colorValue = `#${channel.toString(16).padStart(2, '0').repeat(3)}`
|
|
39
|
+
return (
|
|
40
|
+
<Text key={`pixel-${index}-${cellIndex}`} backgroundColor={colorValue}>
|
|
41
|
+
{' '}
|
|
42
|
+
</Text>
|
|
43
|
+
)
|
|
44
|
+
})}
|
|
45
|
+
</Box>
|
|
46
|
+
))}
|
|
47
|
+
</Box>
|
|
48
|
+
<Box flexDirection="column" marginTop={1}>
|
|
49
|
+
{props.children}
|
|
50
|
+
</Box>
|
|
51
|
+
{props.footer ? (
|
|
52
|
+
<Box marginTop={1}>
|
|
53
|
+
<Text dimColor>{props.footer}</Text>
|
|
54
|
+
</Box>
|
|
55
|
+
) : null}
|
|
56
|
+
</Box>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function Section(props: {
|
|
61
|
+
title: string
|
|
62
|
+
children: ReactNode
|
|
63
|
+
}): React.ReactElement {
|
|
64
|
+
return (
|
|
65
|
+
<Box flexDirection="column" marginTop={1}>
|
|
66
|
+
<Text color="cyan">{props.title}</Text>
|
|
67
|
+
<Box flexDirection="column" marginLeft={2}>
|
|
68
|
+
{props.children}
|
|
69
|
+
</Box>
|
|
70
|
+
</Box>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function KeyHints(props: {
|
|
75
|
+
items: string[]
|
|
76
|
+
}): React.ReactElement {
|
|
77
|
+
return (
|
|
78
|
+
<Text dimColor>{props.items.join(' ')}</Text>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function SelectList<T>(props: {
|
|
83
|
+
items: Array<{
|
|
84
|
+
key: T
|
|
85
|
+
label: string
|
|
86
|
+
hint?: string
|
|
87
|
+
selected?: boolean
|
|
88
|
+
}>
|
|
89
|
+
currentIndex: number
|
|
90
|
+
multiselect?: boolean
|
|
91
|
+
}): React.ReactElement {
|
|
92
|
+
return (
|
|
93
|
+
<Box flexDirection="column">
|
|
94
|
+
{props.items.map((item, index) => {
|
|
95
|
+
const focused = index === props.currentIndex
|
|
96
|
+
const prefix = focused ? '›' : ' '
|
|
97
|
+
const selected = props.multiselect
|
|
98
|
+
? (item.selected ? '[x]' : '[ ]')
|
|
99
|
+
: ''
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<Text key={String(index)} color={focused ? 'greenBright' : undefined}>
|
|
103
|
+
{`${prefix} ${selected} ${item.label}`.trimEnd()}
|
|
104
|
+
{item.hint ? <Text dimColor>{` — ${item.hint}`}</Text> : null}
|
|
105
|
+
</Text>
|
|
106
|
+
)
|
|
107
|
+
})}
|
|
108
|
+
</Box>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function TextEntry(props: {
|
|
113
|
+
label: string
|
|
114
|
+
value: string
|
|
115
|
+
placeholder?: string
|
|
116
|
+
error?: string
|
|
117
|
+
masked?: boolean
|
|
118
|
+
}): React.ReactElement {
|
|
119
|
+
const displayValue = props.value.length > 0
|
|
120
|
+
? (props.masked ? '*'.repeat(props.value.length) : props.value)
|
|
121
|
+
: (props.placeholder ? `<${props.placeholder}>` : '')
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<Box flexDirection="column">
|
|
125
|
+
<Text>{props.label}</Text>
|
|
126
|
+
<Text color="greenBright">{displayValue || ' '}</Text>
|
|
127
|
+
{props.error ? <Text color="red">{props.error}</Text> : null}
|
|
128
|
+
</Box>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
133
|
+
|
|
134
|
+
export function SpinnerText(props: {
|
|
135
|
+
label: string
|
|
136
|
+
active?: boolean
|
|
137
|
+
}): React.ReactElement {
|
|
138
|
+
const [frameIndex, setFrameIndex] = useState(0)
|
|
139
|
+
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (props.active === false) {
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const interval = setInterval(() => {
|
|
146
|
+
setFrameIndex(current => (current + 1) % SPINNER_FRAMES.length)
|
|
147
|
+
}, 80)
|
|
148
|
+
|
|
149
|
+
return () => {
|
|
150
|
+
clearInterval(interval)
|
|
151
|
+
}
|
|
152
|
+
}, [props.active])
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<Text color="yellow">
|
|
156
|
+
{`${SPINNER_FRAMES[frameIndex]} ${props.label}`}
|
|
157
|
+
</Text>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function Callout(props: {
|
|
162
|
+
tone: 'info' | 'warn' | 'error' | 'success'
|
|
163
|
+
children: ReactNode
|
|
164
|
+
}): React.ReactElement {
|
|
165
|
+
const color = {
|
|
166
|
+
info: 'blueBright',
|
|
167
|
+
warn: 'yellowBright',
|
|
168
|
+
error: 'redBright',
|
|
169
|
+
success: 'greenBright',
|
|
170
|
+
} as const
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<Text color={color[props.tone]}>{props.children}</Text>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { render, type Instance, type RenderOptions } from 'ink'
|
|
3
|
+
|
|
4
|
+
export interface CommandScreenController<T> {
|
|
5
|
+
resolve(value: T, delayMs?: number): void
|
|
6
|
+
reject(error: unknown, delayMs?: number): void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface RenderCommandScreenOptions extends RenderOptions {
|
|
10
|
+
/**
|
|
11
|
+
* When true, the Ink instance erases its rendered output before unmounting
|
|
12
|
+
* so the final frame is not persisted to the terminal scrollback. Useful
|
|
13
|
+
* for transient screens like the bark intro that hand off to the next
|
|
14
|
+
* screen without leaving a visual residue.
|
|
15
|
+
*/
|
|
16
|
+
clearOnExit?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function renderCommandScreen<T>(
|
|
20
|
+
build: (controller: CommandScreenController<T>) => React.ReactElement,
|
|
21
|
+
options: RenderCommandScreenOptions = {},
|
|
22
|
+
): Promise<T> {
|
|
23
|
+
const { clearOnExit, ...renderOptions } = options
|
|
24
|
+
|
|
25
|
+
return new Promise<T>((resolve, reject) => {
|
|
26
|
+
let settled = false
|
|
27
|
+
let instance: Instance | undefined
|
|
28
|
+
|
|
29
|
+
const teardown = () => {
|
|
30
|
+
if (clearOnExit) {
|
|
31
|
+
instance?.clear()
|
|
32
|
+
}
|
|
33
|
+
instance?.unmount()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const settle = (
|
|
37
|
+
handler: (value: T) => void | never,
|
|
38
|
+
payload: T,
|
|
39
|
+
delayMs = 0,
|
|
40
|
+
) => {
|
|
41
|
+
if (settled) {
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
settled = true
|
|
46
|
+
const finish = () => {
|
|
47
|
+
teardown()
|
|
48
|
+
handler(payload)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (delayMs > 0) {
|
|
52
|
+
setTimeout(finish, delayMs)
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
queueMicrotask(finish)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const controller: CommandScreenController<T> = {
|
|
60
|
+
resolve(value, delayMs) {
|
|
61
|
+
settle(resolve, value, delayMs)
|
|
62
|
+
},
|
|
63
|
+
reject(error, delayMs) {
|
|
64
|
+
if (settled) {
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
settled = true
|
|
69
|
+
const finish = () => {
|
|
70
|
+
teardown()
|
|
71
|
+
reject(error)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (delayMs && delayMs > 0) {
|
|
75
|
+
setTimeout(finish, delayMs)
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
queueMicrotask(finish)
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
instance = render(build(controller), {
|
|
84
|
+
exitOnCtrlC: true,
|
|
85
|
+
patchConsole: false,
|
|
86
|
+
...renderOptions,
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
}
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import { Box, Text, useInput } from 'ink'
|
|
3
|
+
import type {
|
|
4
|
+
FeedbackData,
|
|
5
|
+
FeedbackEmptyState,
|
|
6
|
+
FeedbackEntry,
|
|
7
|
+
FeedbackSelectionMode,
|
|
8
|
+
} from '../../commands/feedback.ts'
|
|
9
|
+
import {
|
|
10
|
+
buildClaudeFeedbackPrompt,
|
|
11
|
+
buildFeedbackNextActions,
|
|
12
|
+
buildFeedbackReviewModel,
|
|
13
|
+
describeFeedbackEmptyState,
|
|
14
|
+
describeFeedbackSelectionMode,
|
|
15
|
+
formatFeedbackSelectionSummary,
|
|
16
|
+
getFeedbackEntryTitle,
|
|
17
|
+
} from '../../commands/feedback.ts'
|
|
18
|
+
import { AppFrame, Callout, KeyHints, Section } from '../components.tsx'
|
|
19
|
+
import type { CommandScreenController } from '../runtime.tsx'
|
|
20
|
+
|
|
21
|
+
export interface FeedbackSelectionResult {
|
|
22
|
+
selectedFeedbacks: FeedbackEntry[]
|
|
23
|
+
prompt?: string
|
|
24
|
+
launchedClaude: boolean
|
|
25
|
+
selectionMode: FeedbackSelectionMode
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface FeedbackSelectionScreenProps {
|
|
29
|
+
controller: CommandScreenController<FeedbackSelectionResult>
|
|
30
|
+
data: FeedbackData
|
|
31
|
+
mode: FeedbackSelectionMode
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type FeedbackPhase = 'browse' | 'confirm'
|
|
35
|
+
|
|
36
|
+
function renderCatalog(
|
|
37
|
+
entries: FeedbackEntry[],
|
|
38
|
+
input: {
|
|
39
|
+
currentEntryId?: string
|
|
40
|
+
selectedIds: Set<string>
|
|
41
|
+
selectionEditable: boolean
|
|
42
|
+
selectionVisible: boolean
|
|
43
|
+
},
|
|
44
|
+
): React.ReactElement {
|
|
45
|
+
const actionable = entries.filter(entry => entry.actionable)
|
|
46
|
+
const informational = entries.filter(entry => !entry.actionable)
|
|
47
|
+
|
|
48
|
+
const renderEntry = (entry: FeedbackEntry): React.ReactElement => {
|
|
49
|
+
const focused = entry.id === input.currentEntryId
|
|
50
|
+
const marker = focused ? '›' : ' '
|
|
51
|
+
const checkbox = input.selectionVisible
|
|
52
|
+
? entry.actionable
|
|
53
|
+
? input.selectedIds.has(entry.id) ? '[x]' : '[ ]'
|
|
54
|
+
: '[-]'
|
|
55
|
+
: ''
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<Text key={entry.id} color={focused ? 'greenBright' : undefined}>
|
|
59
|
+
{`${marker} ${checkbox} #${entry.number} [${entry.feedback.status}] ${getFeedbackEntryTitle(entry)}`.trimEnd()}
|
|
60
|
+
<Text dimColor>{` — ${entry.feedback.reason || '<empty>'}`}</Text>
|
|
61
|
+
</Text>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<Box flexDirection="column">
|
|
67
|
+
{actionable.length > 0 ? (
|
|
68
|
+
<>
|
|
69
|
+
<Text color="cyan">Actionable</Text>
|
|
70
|
+
{actionable.map(renderEntry)}
|
|
71
|
+
</>
|
|
72
|
+
) : null}
|
|
73
|
+
{informational.length > 0 ? (
|
|
74
|
+
<>
|
|
75
|
+
{actionable.length > 0 ? <Text> </Text> : null}
|
|
76
|
+
<Text color="cyan">Informational</Text>
|
|
77
|
+
{informational.map(renderEntry)}
|
|
78
|
+
</>
|
|
79
|
+
) : null}
|
|
80
|
+
{entries.length === 0 ? <Text dimColor>No feedback entries found for this commit.</Text> : null}
|
|
81
|
+
{!input.selectionEditable && input.selectionVisible ? (
|
|
82
|
+
<Text dimColor>Selection is locked for this review mode.</Text>
|
|
83
|
+
) : null}
|
|
84
|
+
</Box>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildBrowseFooter(input: {
|
|
89
|
+
mode: FeedbackSelectionMode
|
|
90
|
+
selectionEditable: boolean
|
|
91
|
+
}): string {
|
|
92
|
+
if (input.mode === 'list') {
|
|
93
|
+
return '↑/↓ move · Enter finish review · Esc exit'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (input.selectionEditable) {
|
|
97
|
+
return '↑/↓ move · Space/x toggle · a select-all · n clear · Enter continue/finish · Esc exit'
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return '↑/↓ move · Enter continue · Esc exit'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function formatSelectedTitles(entries: FeedbackEntry[]): string[] {
|
|
104
|
+
return entries.map(entry => `#${entry.number} ${getFeedbackEntryTitle(entry)}`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function emptyStateTone(emptyState: FeedbackEmptyState): 'info' | 'warn' {
|
|
108
|
+
return emptyState === 'none' ? 'warn' : 'info'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function FeedbackSelectionScreen(props: FeedbackSelectionScreenProps): React.ReactElement {
|
|
112
|
+
const review = useMemo(() => buildFeedbackReviewModel(props.data), [props.data])
|
|
113
|
+
const [phase, setPhase] = useState<FeedbackPhase>('browse')
|
|
114
|
+
const [cursor, setCursor] = useState(0)
|
|
115
|
+
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => {
|
|
116
|
+
if (props.mode === 'all' || props.mode === 'default') {
|
|
117
|
+
return new Set(props.data.actionableEntries.map(entry => entry.id))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return new Set<string>()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const currentEntry = props.data.entries[cursor] ?? props.data.entries[0]
|
|
124
|
+
const selectionEditable = props.mode === 'select' || props.mode === 'default'
|
|
125
|
+
const selectionVisible = props.mode !== 'list' && review.emptyState === 'none'
|
|
126
|
+
const selectedFeedbacks = useMemo(() => {
|
|
127
|
+
return props.data.actionableEntries.filter(entry => selectedIds.has(entry.id))
|
|
128
|
+
}, [props.data.actionableEntries, selectedIds])
|
|
129
|
+
const prompt = selectedFeedbacks.length > 0
|
|
130
|
+
? buildClaudeFeedbackPrompt({
|
|
131
|
+
repoRoot: props.data.repoRoot,
|
|
132
|
+
organizationName: props.data.organizationName,
|
|
133
|
+
repository: props.data.repository,
|
|
134
|
+
commitHash: props.data.commitHash,
|
|
135
|
+
selectedFeedbacks,
|
|
136
|
+
})
|
|
137
|
+
: undefined
|
|
138
|
+
|
|
139
|
+
useInput((input, key) => {
|
|
140
|
+
if (phase === 'browse') {
|
|
141
|
+
if ((key.downArrow || input === 'j') && props.data.entries.length > 0) {
|
|
142
|
+
setCursor(current => Math.min(current + 1, props.data.entries.length - 1))
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if ((key.upArrow || input === 'k') && props.data.entries.length > 0) {
|
|
147
|
+
setCursor(current => Math.max(current - 1, 0))
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (key.escape) {
|
|
152
|
+
props.controller.resolve({
|
|
153
|
+
selectedFeedbacks: [],
|
|
154
|
+
launchedClaude: false,
|
|
155
|
+
selectionMode: props.mode,
|
|
156
|
+
})
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (selectionEditable && selectionVisible && (input === ' ' || input === 'x')) {
|
|
161
|
+
if (!currentEntry?.actionable) {
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
setSelectedIds(current => {
|
|
166
|
+
const next = new Set(current)
|
|
167
|
+
if (next.has(currentEntry.id)) {
|
|
168
|
+
next.delete(currentEntry.id)
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
next.add(currentEntry.id)
|
|
172
|
+
}
|
|
173
|
+
return next
|
|
174
|
+
})
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (selectionEditable && selectionVisible && input === 'a') {
|
|
179
|
+
setSelectedIds(new Set(props.data.actionableEntries.map(entry => entry.id)))
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (selectionEditable && selectionVisible && input === 'n') {
|
|
184
|
+
setSelectedIds(new Set())
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (key.return) {
|
|
189
|
+
if (props.mode === 'list' || review.emptyState !== 'none' || selectedFeedbacks.length === 0) {
|
|
190
|
+
props.controller.resolve({
|
|
191
|
+
selectedFeedbacks,
|
|
192
|
+
launchedClaude: false,
|
|
193
|
+
selectionMode: props.mode,
|
|
194
|
+
})
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
setPhase('confirm')
|
|
199
|
+
}
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (phase === 'confirm') {
|
|
204
|
+
if (key.escape) {
|
|
205
|
+
setPhase('browse')
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (key.return) {
|
|
210
|
+
props.controller.resolve({
|
|
211
|
+
selectedFeedbacks,
|
|
212
|
+
prompt,
|
|
213
|
+
launchedClaude: false,
|
|
214
|
+
selectionMode: props.mode,
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<AppFrame
|
|
222
|
+
title="Feedback"
|
|
223
|
+
subtitle="Review commit feedback, refine the selection, and preview the exact Claude task."
|
|
224
|
+
footer={phase === 'browse' ? buildBrowseFooter({ mode: props.mode, selectionEditable }) : 'Enter launch Claude · Esc back'}
|
|
225
|
+
>
|
|
226
|
+
{phase === 'browse' ? (
|
|
227
|
+
<Box flexDirection="row" gap={2}>
|
|
228
|
+
<Box flexDirection="column" width={56}>
|
|
229
|
+
<Section title="Catalog">
|
|
230
|
+
{renderCatalog(props.data.entries, {
|
|
231
|
+
currentEntryId: currentEntry?.id,
|
|
232
|
+
selectedIds,
|
|
233
|
+
selectionEditable,
|
|
234
|
+
selectionVisible,
|
|
235
|
+
})}
|
|
236
|
+
</Section>
|
|
237
|
+
</Box>
|
|
238
|
+
<Box flexDirection="column" flexGrow={1}>
|
|
239
|
+
<Section title="Review summary">
|
|
240
|
+
<Text>Repository: {props.data.organizationName}/{props.data.repository}</Text>
|
|
241
|
+
<Text>Commit: {props.data.commitHash}</Text>
|
|
242
|
+
<Text>Mode: {describeFeedbackSelectionMode(props.mode)}</Text>
|
|
243
|
+
<Text>Feedbacks: {review.summary.totalFeedbacks}</Text>
|
|
244
|
+
<Text>Actionable: {review.summary.actionableCount}</Text>
|
|
245
|
+
<Text>Informational: {review.summary.informationalCount}</Text>
|
|
246
|
+
</Section>
|
|
247
|
+
|
|
248
|
+
<Section title="Detail">
|
|
249
|
+
{currentEntry ? (
|
|
250
|
+
<>
|
|
251
|
+
<Text>Focus: #{currentEntry.number}</Text>
|
|
252
|
+
<Text>Status: {currentEntry.feedback.status}</Text>
|
|
253
|
+
<Text>Reason: {currentEntry.feedback.reason || '<empty>'}</Text>
|
|
254
|
+
<Text>Suggestion: {currentEntry.feedback.suggestion.trim() || '<none>'}</Text>
|
|
255
|
+
<Text>Actionable: {currentEntry.actionable ? 'yes' : 'no'}</Text>
|
|
256
|
+
</>
|
|
257
|
+
) : (
|
|
258
|
+
<Text>No feedback entries found.</Text>
|
|
259
|
+
)}
|
|
260
|
+
</Section>
|
|
261
|
+
|
|
262
|
+
<Section title="Selection">
|
|
263
|
+
<Text>{`Selected actionable suggestions: ${selectedFeedbacks.length}`}</Text>
|
|
264
|
+
<Text>{`Selection mode: ${describeFeedbackSelectionMode(props.mode)}`}</Text>
|
|
265
|
+
</Section>
|
|
266
|
+
|
|
267
|
+
{review.emptyState !== 'none' ? (
|
|
268
|
+
<Callout tone={emptyStateTone(review.emptyState)}>
|
|
269
|
+
{`${describeFeedbackEmptyState(review.emptyState)} Press Enter to finish the review.`}
|
|
270
|
+
</Callout>
|
|
271
|
+
) : null}
|
|
272
|
+
{review.emptyState === 'none' && selectionVisible && selectedFeedbacks.length === 0 ? (
|
|
273
|
+
<Callout tone="warn">
|
|
274
|
+
No suggestions are selected. Press Enter to finish without launching Claude, or choose suggestions first.
|
|
275
|
+
</Callout>
|
|
276
|
+
) : null}
|
|
277
|
+
</Box>
|
|
278
|
+
</Box>
|
|
279
|
+
) : null}
|
|
280
|
+
|
|
281
|
+
{phase === 'confirm' ? (
|
|
282
|
+
<>
|
|
283
|
+
<Section title="Selected suggestions">
|
|
284
|
+
{formatSelectedTitles(selectedFeedbacks).map(line => (
|
|
285
|
+
<Text key={line}>{line}</Text>
|
|
286
|
+
))}
|
|
287
|
+
</Section>
|
|
288
|
+
<Section title="Selection summary">
|
|
289
|
+
{formatFeedbackSelectionSummary({
|
|
290
|
+
selectionMode: props.mode,
|
|
291
|
+
selectedFeedbacks,
|
|
292
|
+
}).split('\n').map((line, index) => (
|
|
293
|
+
<Text key={`selection-${index}`}>{line}</Text>
|
|
294
|
+
))}
|
|
295
|
+
</Section>
|
|
296
|
+
<Section title="Claude task">
|
|
297
|
+
{prompt?.split('\n').map((line, index) => (
|
|
298
|
+
<Text key={`prompt-${index}`}>{line}</Text>
|
|
299
|
+
))}
|
|
300
|
+
</Section>
|
|
301
|
+
<Callout tone="info">
|
|
302
|
+
{`Claude will receive ${selectedFeedbacks.length} selected suggestion${selectedFeedbacks.length === 1 ? '' : 's'}.`}
|
|
303
|
+
</Callout>
|
|
304
|
+
</>
|
|
305
|
+
) : null}
|
|
306
|
+
|
|
307
|
+
{phase === 'browse' ? (
|
|
308
|
+
<KeyHints items={[
|
|
309
|
+
`Commit ${props.data.commitHash}`,
|
|
310
|
+
`${review.summary.totalFeedbacks} feedback item${review.summary.totalFeedbacks === 1 ? '' : 's'}`,
|
|
311
|
+
props.mode === 'list' ? 'Review only' : `${selectedFeedbacks.length} selected`,
|
|
312
|
+
]} />
|
|
313
|
+
) : null}
|
|
314
|
+
</AppFrame>
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function FeedbackClaudeHandoffScreen(props: {
|
|
319
|
+
controller: CommandScreenController<void>
|
|
320
|
+
repoRoot: string
|
|
321
|
+
commitHash: string
|
|
322
|
+
selectedFeedbacks: FeedbackEntry[]
|
|
323
|
+
launchDelayMs?: number
|
|
324
|
+
}): React.ReactElement {
|
|
325
|
+
useEffect(() => {
|
|
326
|
+
props.controller.resolve(undefined, props.launchDelayMs ?? 650)
|
|
327
|
+
}, [props.controller, props.launchDelayMs])
|
|
328
|
+
|
|
329
|
+
return (
|
|
330
|
+
<AppFrame
|
|
331
|
+
title="Feedback"
|
|
332
|
+
subtitle="Handing off to native Claude. Tela will resume after Claude exits."
|
|
333
|
+
status="Launching Claude review…"
|
|
334
|
+
>
|
|
335
|
+
<Section title="Overview">
|
|
336
|
+
<Text>Repo root: {props.repoRoot}</Text>
|
|
337
|
+
<Text>Commit: {props.commitHash}</Text>
|
|
338
|
+
<Text>Selected suggestions: {props.selectedFeedbacks.length}</Text>
|
|
339
|
+
</Section>
|
|
340
|
+
<Section title="Selected suggestions">
|
|
341
|
+
{formatSelectedTitles(props.selectedFeedbacks).map(line => (
|
|
342
|
+
<Text key={line}>{line}</Text>
|
|
343
|
+
))}
|
|
344
|
+
</Section>
|
|
345
|
+
<Callout tone="info">
|
|
346
|
+
Claude will take over this terminal to apply the selected feedback. Exit Claude to return to Tela for the outcome review.
|
|
347
|
+
</Callout>
|
|
348
|
+
</AppFrame>
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function FeedbackOutcomeScreen(props: {
|
|
353
|
+
controller: CommandScreenController<void>
|
|
354
|
+
commitHash: string
|
|
355
|
+
selectionMode: FeedbackSelectionMode
|
|
356
|
+
diffStat: string
|
|
357
|
+
selectedFeedbacks: FeedbackEntry[]
|
|
358
|
+
}): React.ReactElement {
|
|
359
|
+
useEffect(() => {
|
|
360
|
+
props.controller.resolve(undefined, 40)
|
|
361
|
+
}, [props.controller])
|
|
362
|
+
|
|
363
|
+
return (
|
|
364
|
+
<AppFrame
|
|
365
|
+
title="Feedback"
|
|
366
|
+
subtitle="Claude finished applying the selected feedback suggestions."
|
|
367
|
+
>
|
|
368
|
+
<Section title="Summary">
|
|
369
|
+
<Text>Commit: {props.commitHash}</Text>
|
|
370
|
+
<Text>Mode: {describeFeedbackSelectionMode(props.selectionMode)}</Text>
|
|
371
|
+
<Text>Selected suggestions: {props.selectedFeedbacks.length}</Text>
|
|
372
|
+
</Section>
|
|
373
|
+
<Section title="Selected suggestions">
|
|
374
|
+
{formatSelectedTitles(props.selectedFeedbacks).map(line => (
|
|
375
|
+
<Text key={line}>{line}</Text>
|
|
376
|
+
))}
|
|
377
|
+
</Section>
|
|
378
|
+
<Section title="Local diff summary">
|
|
379
|
+
{props.diffStat.split('\n').map((line, index) => (
|
|
380
|
+
<Text key={`diff-${index}`}>{line}</Text>
|
|
381
|
+
))}
|
|
382
|
+
</Section>
|
|
383
|
+
<Section title="Next actions">
|
|
384
|
+
{buildFeedbackNextActions({
|
|
385
|
+
launchedClaude: true,
|
|
386
|
+
actionableCount: props.selectedFeedbacks.length,
|
|
387
|
+
emptyState: 'none',
|
|
388
|
+
}).map(action => (
|
|
389
|
+
<Text key={action}>{action}</Text>
|
|
390
|
+
))}
|
|
391
|
+
</Section>
|
|
392
|
+
</AppFrame>
|
|
393
|
+
)
|
|
394
|
+
}
|