@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,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
+ }