@xp266/dshtui 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/bin/dshtui.js +65 -0
- package/cordis.patch.yml +81 -0
- package/lib/contract/index.d.mts +2 -0
- package/lib/contract/index.mjs +5 -0
- package/lib/dialog.d.mts +156 -0
- package/lib/dialog.mjs +3 -0
- package/lib/index-DqnaSXqP.d.mts +823 -0
- package/lib/index.d.mts +17 -0
- package/lib/index.mjs +11404 -0
- package/lib/prod-react-Dzof4qJx.mjs +4 -0
- package/lib/surface-DdxzIgIY.mjs +3126 -0
- package/lib/vendor.d.mts +3 -0
- package/lib/vendor.mjs +4 -0
- package/package.json +128 -0
- package/src/apply-theme.ts +8 -0
- package/src/boot-log.ts +35 -0
- package/src/chat/blocks.ts +41 -0
- package/src/chat/bridge.ts +1076 -0
- package/src/chat/builtin-tool-views.ts +53 -0
- package/src/chat/chat-face.ts +16 -0
- package/src/chat/chat-nodes.ts +53 -0
- package/src/chat/efforts.ts +4 -0
- package/src/chat/interactions.ts +329 -0
- package/src/chat/models.ts +263 -0
- package/src/chat/partial-json.ts +125 -0
- package/src/chat/presets.ts +26 -0
- package/src/chat/question-view.ts +106 -0
- package/src/chat/retry-status.ts +33 -0
- package/src/chat/session-groups.ts +34 -0
- package/src/chat/session-list.ts +311 -0
- package/src/chat/store.ts +636 -0
- package/src/chat/todo-view.ts +66 -0
- package/src/chat/tool-view.ts +311 -0
- package/src/chat/tool-views.ts +114 -0
- package/src/contract/index.ts +704 -0
- package/src/contract/upstream.ts +165 -0
- package/src/core/caret-nonce.ts +19 -0
- package/src/core/composer-layout.ts +65 -0
- package/src/core/edit.ts +152 -0
- package/src/core/field-view.ts +52 -0
- package/src/core/fields.ts +185 -0
- package/src/core/metrics.ts +26 -0
- package/src/core/paste.ts +149 -0
- package/src/core/segments.ts +112 -0
- package/src/core/text.ts +290 -0
- package/src/dialog.ts +11 -0
- package/src/env.ts +38 -0
- package/src/harness-home.ts +22 -0
- package/src/hot-theme.ts +56 -0
- package/src/index.tsx +185 -0
- package/src/kernel/registry.ts +102 -0
- package/src/kernel/surface.ts +33 -0
- package/src/log.ts +249 -0
- package/src/model/message.ts +89 -0
- package/src/model/selection.ts +63 -0
- package/src/performance-guard.ts +28 -0
- package/src/runtime/prod-react.ts +10 -0
- package/src/terminal/background.ts +66 -0
- package/src/terminal/capabilities.ts +54 -0
- package/src/terminal/clipboard-backends.ts +69 -0
- package/src/terminal/clipboard.ts +161 -0
- package/src/terminal/cursor-shape.ts +13 -0
- package/src/terminal/glyphs.ts +44 -0
- package/src/terminal/mouse.ts +154 -0
- package/src/terminal/probe.ts +61 -0
- package/src/terminal/screen.ts +349 -0
- package/src/theme-settings.ts +31 -0
- package/src/theme.ts +319 -0
- package/src/ui/app.tsx +454 -0
- package/src/ui/chrome/caps.tsx +9 -0
- package/src/ui/chrome/hint-service.ts +87 -0
- package/src/ui/chrome/input-status.ts +27 -0
- package/src/ui/chrome/key-gate.tsx +29 -0
- package/src/ui/chrome/status-bar.tsx +147 -0
- package/src/ui/contributions.ts +47 -0
- package/src/ui/dialog/defaults-dialog.tsx +152 -0
- package/src/ui/dialog/dialog-item.tsx +38 -0
- package/src/ui/dialog/dialog.tsx +386 -0
- package/src/ui/dialog/effort-dialog.tsx +45 -0
- package/src/ui/dialog/geometry.ts +131 -0
- package/src/ui/dialog/items.ts +124 -0
- package/src/ui/dialog/list-dialog.tsx +63 -0
- package/src/ui/dialog/models-dialog.tsx +203 -0
- package/src/ui/dialog/presets-dialog.tsx +46 -0
- package/src/ui/dialog/providers-dialog.tsx +313 -0
- package/src/ui/dialog/sessions-dialog.tsx +163 -0
- package/src/ui/dialog/sizes.ts +7 -0
- package/src/ui/dialog/status-lines.ts +11 -0
- package/src/ui/dialog/todo-dialog.tsx +29 -0
- package/src/ui/dialog/use-dialog-input.ts +186 -0
- package/src/ui/extension-point.ts +188 -0
- package/src/ui/home-logo.ts +132 -0
- package/src/ui/hooks/use-async-action.ts +25 -0
- package/src/ui/hooks/use-async-list.ts +71 -0
- package/src/ui/hooks/use-caret.ts +24 -0
- package/src/ui/hooks/use-chat-events.ts +153 -0
- package/src/ui/hooks/use-mouse-selection.ts +352 -0
- package/src/ui/hooks/use-scroll.ts +49 -0
- package/src/ui/hooks/use-terminal-size.ts +20 -0
- package/src/ui/input/commands.ts +118 -0
- package/src/ui/input/composer-bus.ts +14 -0
- package/src/ui/input/composer-fields.ts +111 -0
- package/src/ui/input/composer-keys.ts +21 -0
- package/src/ui/input/composer-paste.ts +20 -0
- package/src/ui/input/input-bar.tsx +279 -0
- package/src/ui/input/use-composer.ts +495 -0
- package/src/ui/key-arbiter.ts +37 -0
- package/src/ui/keymap.ts +15 -0
- package/src/ui/layout-service.ts +77 -0
- package/src/ui/message/layout.ts +763 -0
- package/src/ui/message/md/block.ts +247 -0
- package/src/ui/message/md/engine.ts +430 -0
- package/src/ui/message/md/extensions.ts +58 -0
- package/src/ui/message/md/highlight.ts +626 -0
- package/src/ui/message/md/index.ts +14 -0
- package/src/ui/message/md/inline.ts +97 -0
- package/src/ui/message/md/palette.ts +87 -0
- package/src/ui/message/message-list.tsx +69 -0
- package/src/ui/message/message-row.tsx +137 -0
- package/src/ui/message/message-views.ts +16 -0
- package/src/ui/message/renderers.ts +39 -0
- package/src/ui/message/tool-diff.ts +188 -0
- package/src/ui/message/warmup.ts +41 -0
- package/src/ui/message/wave.ts +98 -0
- package/src/ui/overlay.ts +22 -0
- package/src/ui/panels/approval-panel.tsx +193 -0
- package/src/ui/panels/exports.ts +3 -0
- package/src/ui/panels/question-model.ts +424 -0
- package/src/ui/panels/question-panel.tsx +154 -0
- package/src/ui/panels/surface.tsx +106 -0
- package/src/ui/panels-builtin.tsx +86 -0
- package/src/ui/pointer/builtins.ts +281 -0
- package/src/ui/pointer/registry.ts +25 -0
- package/src/ui/region.tsx +31 -0
- package/src/ui/selection/service.ts +92 -0
- package/src/ui/selection-registry.ts +88 -0
- package/src/ui/selection.tsx +148 -0
- package/src/ui/spinner-tick.tsx +24 -0
- package/src/ui/use-available-windows.ts +31 -0
- package/src/ui/widgets/actions.tsx +84 -0
- package/src/ui/widgets/button.tsx +57 -0
- package/src/ui/widgets/checkbox.tsx +58 -0
- package/src/ui/widgets/header.tsx +36 -0
- package/src/ui/widgets/index.ts +8 -0
- package/src/ui/widgets/input.tsx +76 -0
- package/src/ui/widgets/registry.ts +32 -0
- package/src/ui/widgets/search.tsx +59 -0
- package/src/ui/widgets/select.tsx +97 -0
- package/src/ui/widgets/static.tsx +37 -0
- package/src/ui/widgets/types.ts +1 -0
- package/src/ui/window-services-bridge.ts +71 -0
- package/src/ui/window-services.ts +20 -0
- package/src/ui/windows/models-window.tsx +15 -0
- package/src/ui/windows/providers-window.tsx +14 -0
- package/src/ui/windows/service-window.tsx +18 -0
- package/src/ui/windows/sessions-window.tsx +25 -0
- package/src/ui/windows-builtin.tsx +94 -0
- package/src/ui/windows.ts +18 -0
- package/src/vendor.ts +12 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { inputLayout, moveCaretLine } from '../../core/composer-layout.ts'
|
|
2
|
+
import { INPUT_WIDTH_OFFSET } from '../../core/metrics.ts'
|
|
3
|
+
import { textWidth, wrapLines } from '../../core/text.ts'
|
|
4
|
+
import { editInsert, editBackspace } from '../../core/edit.ts'
|
|
5
|
+
import { caretNonceBold, caretNonceColor, caretNonceText } from '../../core/caret-nonce.ts'
|
|
6
|
+
import type { EditState } from '../../core/edit.ts'
|
|
7
|
+
import { mergeRuns } from '../../core/segments.ts'
|
|
8
|
+
import type { MarkStyle, Segment } from '../../core/segments.ts'
|
|
9
|
+
import { COLORS } from '../../theme.ts'
|
|
10
|
+
import { glyphs } from '../../terminal/glyphs.ts'
|
|
11
|
+
import { panelLegend } from './surface.tsx'
|
|
12
|
+
import { answerSummary } from '../../chat/question-view.ts'
|
|
13
|
+
import type { AskQuestionItemLike, AskUserQuestionAnswerItemLike, AskUserQuestionRequestLike } from '../../chat/interactions.ts'
|
|
14
|
+
|
|
15
|
+
const questionStyle = (): MarkStyle => ({ color: COLORS.panelQuestionText, bold: true })
|
|
16
|
+
const focusedLabelStyle = (): MarkStyle => ({ color: COLORS.workspaceWriteText })
|
|
17
|
+
const descriptionStyle = (): MarkStyle => ({ color: COLORS.toolBodyText })
|
|
18
|
+
const checkStyle = (): MarkStyle => ({ color: COLORS.success })
|
|
19
|
+
const unansweredStyle = (): MarkStyle => ({ color: COLORS.errorText })
|
|
20
|
+
|
|
21
|
+
function seg(text: string, style?: MarkStyle): Segment {
|
|
22
|
+
return { text, style: style ?? {} }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function lineOf(parts: Array<string | Segment>): Segment[] {
|
|
26
|
+
return mergeRuns(parts.map(part => typeof part === 'string' ? seg(part) : part))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function pushWrapped(target: Segment[][], text: string, indent: number, width: number, style?: MarkStyle): void {
|
|
30
|
+
const lines = wrapLines(text, Math.max(4, width - indent))
|
|
31
|
+
target.push(lineOf([' '.repeat(indent), seg(lines[0]!, style)]))
|
|
32
|
+
for (const line of lines.slice(1)) {
|
|
33
|
+
target.push(lineOf([' '.repeat(indent), seg(line, style)]))
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const NUMBER_COL = 2
|
|
38
|
+
export const SINGLE_LABEL_COL = 5
|
|
39
|
+
export const MULTI_LABEL_COL = 9
|
|
40
|
+
export const MAX_FIELD_ROWS = 3
|
|
41
|
+
export const MAX_BODY_ROWS = 14
|
|
42
|
+
export const MAX_DETAIL_ROWS = 7
|
|
43
|
+
|
|
44
|
+
export interface QuestionDraft {
|
|
45
|
+
selected: string[]
|
|
46
|
+
customText: string
|
|
47
|
+
customChecked: boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface QuestionPageState {
|
|
51
|
+
request: AskUserQuestionRequestLike
|
|
52
|
+
drafts: QuestionDraft[]
|
|
53
|
+
page: number
|
|
54
|
+
cursors: number[]
|
|
55
|
+
editing: boolean
|
|
56
|
+
editor: EditState
|
|
57
|
+
detailScroll: number[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function initialPageState(request: AskUserQuestionRequestLike): QuestionPageState {
|
|
61
|
+
return {
|
|
62
|
+
request,
|
|
63
|
+
drafts: request.questions.map(() => ({ selected: [], customText: '', customChecked: false })),
|
|
64
|
+
page: 0,
|
|
65
|
+
cursors: request.questions.map(() => 0),
|
|
66
|
+
editing: false,
|
|
67
|
+
editor: { value: '', cursor: 0 },
|
|
68
|
+
detailScroll: request.questions.map(() => 0),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function scrollDetail(state: QuestionPageState, delta: number): QuestionPageState {
|
|
73
|
+
if (state.editing) return state
|
|
74
|
+
const next = Math.max(0, state.detailScroll[state.page]! + delta)
|
|
75
|
+
if (next === state.detailScroll[state.page]) return state
|
|
76
|
+
return {
|
|
77
|
+
...state,
|
|
78
|
+
detailScroll: state.detailScroll.map((value, index) => index === state.page ? next : value),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function interactiveRowCount(question: AskQuestionItemLike): number {
|
|
83
|
+
return (question.options?.length ?? 0) + 1
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function labelCol(question: AskQuestionItemLike): number {
|
|
87
|
+
return question.multiSelect === true ? MULTI_LABEL_COL : SINGLE_LABEL_COL
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isCustomRow(question: AskQuestionItemLike, row: number): boolean {
|
|
91
|
+
return row >= (question.options?.length ?? 0)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function beginEdit(state: QuestionPageState): QuestionPageState {
|
|
95
|
+
const draft = state.drafts[state.page]!
|
|
96
|
+
return {
|
|
97
|
+
...state,
|
|
98
|
+
editing: true,
|
|
99
|
+
editor: { value: draft.customText, cursor: draft.customText.length },
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function commitEdit(state: QuestionPageState): QuestionPageState {
|
|
104
|
+
const question = state.request.questions[state.page]
|
|
105
|
+
if (question === undefined || !state.editing) return state
|
|
106
|
+
const text = state.editor.value.trim()
|
|
107
|
+
return {
|
|
108
|
+
...state,
|
|
109
|
+
editing: false,
|
|
110
|
+
editor: { value: '', cursor: 0 },
|
|
111
|
+
drafts: state.drafts.map((draft, index) => index === state.page
|
|
112
|
+
? {
|
|
113
|
+
...draft,
|
|
114
|
+
customText: state.editor.value,
|
|
115
|
+
customChecked: text !== '',
|
|
116
|
+
...(question.multiSelect !== true && text !== '' ? { selected: [] } : {}),
|
|
117
|
+
}
|
|
118
|
+
: draft),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function cancelEdit(state: QuestionPageState): QuestionPageState {
|
|
123
|
+
return { ...state, editing: false, editor: { value: '', cursor: 0 } }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function toggleCustomChecked(state: QuestionPageState): QuestionPageState {
|
|
127
|
+
return {
|
|
128
|
+
...state,
|
|
129
|
+
drafts: state.drafts.map((draft, index) => index === state.page
|
|
130
|
+
? { ...draft, customChecked: !draft.customChecked }
|
|
131
|
+
: draft),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function toggleOption(state: QuestionPageState): QuestionPageState {
|
|
136
|
+
const question = state.request.questions[state.page]
|
|
137
|
+
if (question === undefined) return state
|
|
138
|
+
const row = state.cursors[state.page]!
|
|
139
|
+
if (isCustomRow(question, row)) return toggleCustomChecked(state)
|
|
140
|
+
const option = question.options![row]!
|
|
141
|
+
if (question.multiSelect === true) {
|
|
142
|
+
return {
|
|
143
|
+
...state,
|
|
144
|
+
drafts: state.drafts.map((draft, index) => index === state.page
|
|
145
|
+
? {
|
|
146
|
+
...draft,
|
|
147
|
+
selected: draft.selected.includes(option.label)
|
|
148
|
+
? draft.selected.filter(label => label !== option.label)
|
|
149
|
+
: [...draft.selected, option.label],
|
|
150
|
+
}
|
|
151
|
+
: draft),
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
...state,
|
|
156
|
+
drafts: state.drafts.map((draft, index) => index === state.page
|
|
157
|
+
? { ...draft, selected: [option.label], customChecked: false }
|
|
158
|
+
: draft),
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function enterOnRow(state: QuestionPageState): QuestionPageState {
|
|
163
|
+
const question = state.request.questions[state.page]
|
|
164
|
+
if (question === undefined || state.editing) return commitEdit(state)
|
|
165
|
+
const draft = state.drafts[state.page]!
|
|
166
|
+
const row = state.cursors[state.page]!
|
|
167
|
+
if (!isCustomRow(question, row)) return toggleOption(state)
|
|
168
|
+
if (draft.customChecked) return toggleCustomChecked(state)
|
|
169
|
+
return beginEdit(state)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function moveCursor(state: QuestionPageState, delta: -1 | 1): QuestionPageState {
|
|
173
|
+
const question = state.request.questions[state.page]
|
|
174
|
+
if (question === undefined || state.editing) return state
|
|
175
|
+
const count = interactiveRowCount(question)
|
|
176
|
+
if (count === 0) return state
|
|
177
|
+
const next = ((state.cursors[state.page]! + delta) % count + count) % count
|
|
178
|
+
return {
|
|
179
|
+
...state,
|
|
180
|
+
cursors: state.cursors.map((value, index) => index === state.page ? next : value),
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function switchPage(state: QuestionPageState, delta: -1 | 1): QuestionPageState {
|
|
185
|
+
if (state.editing) return state
|
|
186
|
+
const total = state.request.questions.length + 1
|
|
187
|
+
return { ...state, page: Math.max(0, Math.min(total - 1, state.page + delta)) }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function editorInsert(state: QuestionPageState, text: string): QuestionPageState {
|
|
191
|
+
if (!state.editing || text === '') return state
|
|
192
|
+
return { ...state, editor: editInsert(state.editor, text) }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function editorBackspace(state: QuestionPageState): QuestionPageState {
|
|
196
|
+
if (!state.editing) return state
|
|
197
|
+
const next = editBackspace(state.editor)
|
|
198
|
+
return next === null ? state : { ...state, editor: next }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function fieldWidthOf(question: AskQuestionItemLike, innerWidth: number): number {
|
|
202
|
+
return Math.max(8, innerWidth - labelCol(question))
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function editorCaret(state: QuestionPageState, deltaCol: number, deltaLine: number, fieldWidth: number): QuestionPageState {
|
|
206
|
+
if (!state.editing) return state
|
|
207
|
+
const value = state.editor.value
|
|
208
|
+
let cursor = state.editor.cursor
|
|
209
|
+
if (deltaCol !== 0) cursor = Math.max(0, Math.min(value.length, cursor + deltaCol))
|
|
210
|
+
if (deltaLine !== 0) cursor = moveCaretLine(value, cursor, fieldWidth, deltaLine === 1 ? 1 : -1)
|
|
211
|
+
return { ...state, editor: { value, cursor } }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function buildAnswers(state: QuestionPageState): AskUserQuestionAnswerItemLike[] {
|
|
215
|
+
return state.request.questions.map((question, index) => {
|
|
216
|
+
const draft = state.drafts[index]!
|
|
217
|
+
const labels = (question.options ?? []).filter(option => draft.selected.includes(option.label)).map(option => option.label)
|
|
218
|
+
const custom = draft.customText.trim()
|
|
219
|
+
if (draft.customChecked && custom !== '') {
|
|
220
|
+
if (question.multiSelect === true) {
|
|
221
|
+
return { id: question.id, selected: labels, ...(custom === '' ? {} : { custom }) }
|
|
222
|
+
}
|
|
223
|
+
return { id: question.id, selected: [], custom }
|
|
224
|
+
}
|
|
225
|
+
return { id: question.id, selected: labels }
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function reviewAnswerOf(question: AskQuestionItemLike, draft: QuestionDraft): string {
|
|
230
|
+
const labels = (question.options ?? []).filter(option => draft.selected.includes(option.label)).map(option => option.label)
|
|
231
|
+
const custom = draft.customText.trim()
|
|
232
|
+
return answerSummary(question, labels, custom, draft.customChecked)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export interface PanelHitRow {
|
|
236
|
+
line: number
|
|
237
|
+
optionIndex: number
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface PanelLayout {
|
|
241
|
+
lines: Segment[][]
|
|
242
|
+
focusLine: number
|
|
243
|
+
caret: { row: number; col: number } | null
|
|
244
|
+
height: number
|
|
245
|
+
hitRows: PanelHitRow[]
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function questionPanelLayout(state: QuestionPageState, innerWidth: number, scrollWindow: number = MAX_BODY_ROWS): PanelLayout {
|
|
249
|
+
const total = state.request.questions.length + 1
|
|
250
|
+
const isReview = state.page >= state.request.questions.length
|
|
251
|
+
const body: Segment[][] = []
|
|
252
|
+
const hitRows: PanelHitRow[] = []
|
|
253
|
+
let focusLine = 0
|
|
254
|
+
let caret: { row: number; col: number } | null = null
|
|
255
|
+
|
|
256
|
+
if (isReview) {
|
|
257
|
+
body.push(lineOf([seg('Confirm Selection', questionStyle())]))
|
|
258
|
+
body.push([])
|
|
259
|
+
for (const [index, question] of state.request.questions.entries()) {
|
|
260
|
+
pushWrapped(body, `${index + 1}. ${question.question}`, NUMBER_COL, innerWidth)
|
|
261
|
+
const answer = reviewAnswerOf(question, state.drafts[index]!)
|
|
262
|
+
const answerStyle = answer === '(Question not answered)' ? unansweredStyle() : descriptionStyle()
|
|
263
|
+
pushWrapped(body, answer, SINGLE_LABEL_COL, innerWidth, answerStyle)
|
|
264
|
+
}
|
|
265
|
+
body.push([])
|
|
266
|
+
body.push(questionLegend(state.page, total))
|
|
267
|
+
} else {
|
|
268
|
+
const question = state.request.questions[state.page]!
|
|
269
|
+
const multi = question.multiSelect === true
|
|
270
|
+
const editing = state.editing
|
|
271
|
+
const labX = labelCol(question)
|
|
272
|
+
const fieldWidth = Math.max(8, innerWidth - labX)
|
|
273
|
+
const above: Segment[][] = []
|
|
274
|
+
pushWrapped(above, question.question, 0, innerWidth, questionStyle())
|
|
275
|
+
above.push(lineOf([]))
|
|
276
|
+
|
|
277
|
+
const below: Segment[][] = []
|
|
278
|
+
let focusInBelow = -1
|
|
279
|
+
let caretInBelow: { row: number; col: number } | null = null
|
|
280
|
+
const hitsInBelow: PanelHitRow[] = []
|
|
281
|
+
const rows = interactiveRowCount(question)
|
|
282
|
+
const cursorRow = state.cursors[state.page]!
|
|
283
|
+
const draft = state.drafts[state.page]!
|
|
284
|
+
for (let i = 0; i < rows; i++) {
|
|
285
|
+
const focused = cursorRow === i
|
|
286
|
+
const marker = focused ? glyphs.focusMarker : ' '
|
|
287
|
+
const num = `${i + 1}.`
|
|
288
|
+
if (focused) focusInBelow = below.length
|
|
289
|
+
hitsInBelow.push({ line: below.length, optionIndex: i })
|
|
290
|
+
if (i === rows - 1) {
|
|
291
|
+
const checked = draft.customChecked
|
|
292
|
+
const box = multi ? (checked ? glyphs.checkboxOn : glyphs.checkboxOff) : ''
|
|
293
|
+
const tick = !multi && checked ? ` ${glyphs.tick}` : ''
|
|
294
|
+
const value = editing ? state.editor.value : draft.customText
|
|
295
|
+
const cursor = editing ? state.editor.cursor : value.length
|
|
296
|
+
below.push(lineOf([
|
|
297
|
+
seg(marker, focused ? checkStyle() : undefined),
|
|
298
|
+
' ',
|
|
299
|
+
seg(num, focused ? focusedLabelStyle() : undefined),
|
|
300
|
+
...(box === '' ? [] : [seg(' ' + box, focused ? focusedLabelStyle() : undefined)]),
|
|
301
|
+
' ',
|
|
302
|
+
seg('Custom input content', focused ? focusedLabelStyle() : undefined),
|
|
303
|
+
...(tick === '' ? [] : [seg(tick, checkStyle())]),
|
|
304
|
+
...(editing && focused ? [seg(caretNonceText(cursor), { color: caretNonceColor(cursor), bold: caretNonceBold(cursor) })] : []),
|
|
305
|
+
]))
|
|
306
|
+
const field = inputLayout(value, cursor, fieldWidth + INPUT_WIDTH_OFFSET)
|
|
307
|
+
const firstVisible = Math.min(
|
|
308
|
+
Math.max(0, field.cursorRow - (MAX_FIELD_ROWS - 1)),
|
|
309
|
+
Math.max(0, field.lines.length - MAX_FIELD_ROWS),
|
|
310
|
+
)
|
|
311
|
+
const visible = field.lines.slice(firstVisible, firstVisible + MAX_FIELD_ROWS)
|
|
312
|
+
const fieldStart = below.length
|
|
313
|
+
for (const [index, line] of visible.entries()) {
|
|
314
|
+
below.push(lineOf([' '.repeat(labX), seg(line, descriptionStyle()), ...(editing && index === visible.length - 1 ? [seg(' ', descriptionStyle())] : [])]))
|
|
315
|
+
}
|
|
316
|
+
if (editing) {
|
|
317
|
+
caretInBelow = {
|
|
318
|
+
row: fieldStart + field.cursorRow - firstVisible,
|
|
319
|
+
col: labX + field.cursorCol,
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
} else {
|
|
323
|
+
const option = question.options![i]!
|
|
324
|
+
const chosen = draft.selected.includes(option.label)
|
|
325
|
+
const box = multi ? (chosen ? glyphs.checkboxOn : glyphs.checkboxOff) : ''
|
|
326
|
+
const tick = !multi && chosen ? ` ${glyphs.tick}` : ''
|
|
327
|
+
below.push(lineOf([
|
|
328
|
+
seg(marker, focused ? checkStyle() : undefined),
|
|
329
|
+
' ',
|
|
330
|
+
seg(num, focused ? focusedLabelStyle() : undefined),
|
|
331
|
+
...(box === '' ? [] : [seg(' ' + box, focused ? focusedLabelStyle() : undefined)]),
|
|
332
|
+
' ',
|
|
333
|
+
seg(option.label, focused ? focusedLabelStyle() : undefined),
|
|
334
|
+
...(tick === '' ? [] : [seg(tick, checkStyle())]),
|
|
335
|
+
]))
|
|
336
|
+
if (option.description !== undefined && option.description !== '') {
|
|
337
|
+
pushWrapped(below, option.description, labX, innerWidth, descriptionStyle())
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
below.push(lineOf([]))
|
|
342
|
+
below.push(questionLegend(state.page, total))
|
|
343
|
+
|
|
344
|
+
body.push(...above)
|
|
345
|
+
const detail = question.detail === undefined || question.detail === '' || isPlanReview(question)
|
|
346
|
+
? []
|
|
347
|
+
: wrapLines(question.detail, Math.max(8, innerWidth - SINGLE_LABEL_COL))
|
|
348
|
+
if (detail.length > 0) {
|
|
349
|
+
const budget = Math.max(3, scrollWindow - body.length - below.length)
|
|
350
|
+
if (detail.length <= budget) {
|
|
351
|
+
for (const line of detail) {
|
|
352
|
+
body.push(lineOf([' ', seg(line, descriptionStyle())]))
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
const contentRows = Math.max(1, budget - 2)
|
|
356
|
+
const maxStart = detail.length - contentRows
|
|
357
|
+
const start = Math.max(0, Math.min(state.detailScroll[state.page]!, maxStart))
|
|
358
|
+
if (start > 0) {
|
|
359
|
+
body.push(lineOf([seg(`${glyphs.ellipsis} ${start} lines above`, descriptionStyle())]))
|
|
360
|
+
}
|
|
361
|
+
for (const line of detail.slice(start, start + contentRows)) {
|
|
362
|
+
body.push(lineOf([' ', seg(line, descriptionStyle())]))
|
|
363
|
+
}
|
|
364
|
+
if (start + contentRows < detail.length) {
|
|
365
|
+
body.push(lineOf([seg(`${glyphs.ellipsis} ${detail.length - start - contentRows} more lines`, descriptionStyle())]))
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const base = body.length
|
|
370
|
+
body.push(...below)
|
|
371
|
+
if (focusInBelow >= 0) focusLine = base + focusInBelow
|
|
372
|
+
if (caretInBelow !== null) caret = { row: base + caretInBelow.row, col: caretInBelow.col }
|
|
373
|
+
for (const hit of hitsInBelow) {
|
|
374
|
+
hitRows.push({ line: base + hit.line, optionIndex: hit.optionIndex })
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const window = Math.max(3, Math.min(scrollWindow, body.length))
|
|
379
|
+
if (body.length <= window) {
|
|
380
|
+
return { lines: body, focusLine, caret: finalizeCaret(caret, body), height: body.length, hitRows }
|
|
381
|
+
}
|
|
382
|
+
let start = Math.max(0, Math.min(focusLine - Math.floor(window / 2), body.length - window))
|
|
383
|
+
const lines = body.slice(start, start + window)
|
|
384
|
+
const shifted = hitRows
|
|
385
|
+
.map(hit => ({ line: hit.line - start, optionIndex: hit.optionIndex }))
|
|
386
|
+
.filter(hit => hit.line >= 0 && hit.line < lines.length)
|
|
387
|
+
return { lines, focusLine: focusLine - start, caret: finalizeCaret(caret, body, start), height: window, hitRows: shifted }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function finalizeCaret(
|
|
391
|
+
caret: { row: number; col: number } | null,
|
|
392
|
+
body: Segment[][],
|
|
393
|
+
start = 0,
|
|
394
|
+
): { row: number; col: number } | null {
|
|
395
|
+
if (caret === null) return null
|
|
396
|
+
const row = Math.max(0, Math.min(body.length - 1, caret.row)) - start
|
|
397
|
+
return { row, col: caret.col }
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function questionLegend(page: number, total: number): Segment[] {
|
|
401
|
+
const prefix = `${page + 1}/${total}`
|
|
402
|
+
const review = page + 1 === total
|
|
403
|
+
const hints = review
|
|
404
|
+
? [
|
|
405
|
+
{ key: glyphs.pageFlip, description: 'page' },
|
|
406
|
+
{ key: 'enter', description: 'submit' },
|
|
407
|
+
{ key: 'esc', description: 'close' },
|
|
408
|
+
]
|
|
409
|
+
: [
|
|
410
|
+
{ key: glyphs.pageFlip, description: 'page' },
|
|
411
|
+
{ key: glyphs.wrapArrows, description: 'wrap' },
|
|
412
|
+
{ key: 'enter', description: 'select' },
|
|
413
|
+
{ key: 'esc', description: 'close' },
|
|
414
|
+
]
|
|
415
|
+
return panelLegend(hints, { prefix })
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
interface QuestionLike {
|
|
419
|
+
intent?: { kind?: string }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function isPlanReview(question: QuestionLike): boolean {
|
|
423
|
+
return question.intent?.kind === 'plan-review'
|
|
424
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { Box, Text, useInput } from 'ink'
|
|
2
|
+
import { panelAnchorRow } from '../layout-service.ts'
|
|
3
|
+
import { isKeyConsumed } from '../key-arbiter.ts'
|
|
4
|
+
import { useEffect, useState } from 'react'
|
|
5
|
+
import { useCaret } from '../hooks/use-caret.ts'
|
|
6
|
+
import type { Ref } from 'react'
|
|
7
|
+
import { useImperativeHandle } from 'react'
|
|
8
|
+
import { isMouseResidue } from '../../terminal/mouse.ts'
|
|
9
|
+
import { writeCursorShape } from '../../terminal/cursor-shape.ts'
|
|
10
|
+
import { CHROME_MARGIN_X, CHROME_TEXT_X } from '../../core/metrics.ts'
|
|
11
|
+
import type { AskUserQuestionAnswerLike, QuestionPanelRequest } from '../../chat/interactions.ts'
|
|
12
|
+
import {
|
|
13
|
+
buildAnswers,
|
|
14
|
+
cancelEdit,
|
|
15
|
+
commitEdit,
|
|
16
|
+
editorBackspace,
|
|
17
|
+
editorCaret,
|
|
18
|
+
fieldWidthOf,
|
|
19
|
+
editorInsert,
|
|
20
|
+
enterOnRow,
|
|
21
|
+
initialPageState,
|
|
22
|
+
MAX_BODY_ROWS,
|
|
23
|
+
MAX_DETAIL_ROWS,
|
|
24
|
+
moveCursor,
|
|
25
|
+
questionPanelLayout,
|
|
26
|
+
scrollDetail,
|
|
27
|
+
switchPage,
|
|
28
|
+
} from './question-model.ts'
|
|
29
|
+
import type { QuestionPageState } from './question-model.ts'
|
|
30
|
+
import { PanelSurface } from './surface.tsx'
|
|
31
|
+
import type { PanelPointerHandle } from './surface.tsx'
|
|
32
|
+
|
|
33
|
+
export interface QuestionPanelProps {
|
|
34
|
+
handleRef?: Ref<PanelPointerHandle>
|
|
35
|
+
question: QuestionPanelRequest
|
|
36
|
+
background: string
|
|
37
|
+
active: boolean
|
|
38
|
+
columns: number
|
|
39
|
+
innerWidth: number
|
|
40
|
+
blockWidth: number
|
|
41
|
+
rows: number
|
|
42
|
+
onSubmit(answer: AskUserQuestionAnswerLike): void
|
|
43
|
+
onCancel(): void
|
|
44
|
+
onResize(height: number): void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function QuestionPanel({ handleRef, question: panel, background, active, columns, innerWidth, blockWidth, rows, onSubmit, onCancel, onResize }: QuestionPanelProps) {
|
|
48
|
+
const [state, setState] = useState<QuestionPageState>(() => initialPageState(panel.request))
|
|
49
|
+
const { setCursorPosition } = useCaret()
|
|
50
|
+
const layout = questionPanelLayout(state, innerWidth, Math.min(MAX_BODY_ROWS, Math.max(3, rows - 6)))
|
|
51
|
+
const totalHeight = layout.height + 2
|
|
52
|
+
const bodyStart = panelAnchorRow(rows, layout.height)
|
|
53
|
+
useImperativeHandle(handleRef, () => ({
|
|
54
|
+
clickAt(y) {
|
|
55
|
+
if (!active || state.editing) return
|
|
56
|
+
const rel = y - bodyStart
|
|
57
|
+
const hit = layout.hitRows.find(entry => entry.line === rel)
|
|
58
|
+
if (hit === undefined) return
|
|
59
|
+
setState(current => {
|
|
60
|
+
if (current.cursors[current.page] === hit.optionIndex) return enterOnRow(current)
|
|
61
|
+
return {
|
|
62
|
+
...current,
|
|
63
|
+
cursors: current.cursors.map((value, index) => index === current.page ? hit.optionIndex : value),
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
},
|
|
67
|
+
wheel(dir: -1 | 1) {
|
|
68
|
+
if (!active || state.editing) return false
|
|
69
|
+
setState(current => scrollDetail(current, dir === -1 ? -3 : 3))
|
|
70
|
+
return true
|
|
71
|
+
},
|
|
72
|
+
}))
|
|
73
|
+
if (active && state.editing && layout.caret !== null) {
|
|
74
|
+
setCursorPosition({
|
|
75
|
+
x: CHROME_TEXT_X + layout.caret.col,
|
|
76
|
+
y: bodyStart + layout.caret.row,
|
|
77
|
+
})
|
|
78
|
+
} else {
|
|
79
|
+
setCursorPosition(undefined)
|
|
80
|
+
}
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (state.editing) {
|
|
83
|
+
writeCursorShape('show')
|
|
84
|
+
writeCursorShape('beam')
|
|
85
|
+
} else {
|
|
86
|
+
writeCursorShape('hide')
|
|
87
|
+
}
|
|
88
|
+
}, [state.editing])
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
return () => {
|
|
91
|
+
writeCursorShape('show')
|
|
92
|
+
writeCursorShape('reset')
|
|
93
|
+
}
|
|
94
|
+
}, [])
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
setState(initialPageState(panel.request))
|
|
97
|
+
}, [panel.id])
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
onResize(totalHeight)
|
|
100
|
+
}, [totalHeight])
|
|
101
|
+
useInput((input, key) => {
|
|
102
|
+
if (!active || isKeyConsumed()) return
|
|
103
|
+
if (isMouseResidue(input)) return
|
|
104
|
+
if (state.editing) {
|
|
105
|
+
if (key.return) {
|
|
106
|
+
setState(current => commitEdit(current))
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
if (key.escape) {
|
|
110
|
+
setState(current => cancelEdit(current))
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
const fieldWidth = fieldWidthOf(state.request.questions[state.page]!, innerWidth)
|
|
114
|
+
if (key.upArrow) return setState(current => editorCaret(current, 0, -1, fieldWidth))
|
|
115
|
+
if (key.downArrow) return setState(current => editorCaret(current, 0, 1, fieldWidth))
|
|
116
|
+
if (key.leftArrow) return setState(current => editorCaret(current, -1, 0, fieldWidth))
|
|
117
|
+
if (key.rightArrow) return setState(current => editorCaret(current, 1, 0, fieldWidth))
|
|
118
|
+
if (key.home) return setState(current => ({ ...current, editor: { value: current.editor.value, cursor: 0 } }))
|
|
119
|
+
if (key.end) return setState(current => ({ ...current, editor: { value: current.editor.value, cursor: current.editor.value.length } }))
|
|
120
|
+
if (key.backspace || key.delete) return setState(current => editorBackspace(current))
|
|
121
|
+
if (key.ctrl && input === 'j') return setState(current => editorInsert(current, '\n'))
|
|
122
|
+
if (!key.ctrl && !key.meta && input.length > 0) return setState(current => editorInsert(current, input))
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
if (key.escape) {
|
|
126
|
+
onCancel()
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
if (key.leftArrow) return setState(current => switchPage(current, -1))
|
|
130
|
+
if (key.rightArrow) return setState(current => switchPage(current, 1))
|
|
131
|
+
if (key.pageUp) return setState(current => scrollDetail(current, -MAX_DETAIL_ROWS))
|
|
132
|
+
if (key.pageDown) return setState(current => scrollDetail(current, MAX_DETAIL_ROWS))
|
|
133
|
+
if (key.upArrow) return setState(current => moveCursor(current, -1))
|
|
134
|
+
if (key.downArrow) return setState(current => moveCursor(current, 1))
|
|
135
|
+
const isReview = state.page >= state.request.questions.length
|
|
136
|
+
if (key.return) {
|
|
137
|
+
if (isReview) {
|
|
138
|
+
onSubmit({ answers: buildAnswers(state) })
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
setState(current => enterOnRow(current))
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
return (
|
|
145
|
+
<PanelSurface
|
|
146
|
+
columns={columns}
|
|
147
|
+
rows={rows}
|
|
148
|
+
body={layout.lines.map(segments => ({ segments }))}
|
|
149
|
+
bodyStart={bodyStart}
|
|
150
|
+
background={background}
|
|
151
|
+
blockWidth={blockWidth}
|
|
152
|
+
/>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Box, Text } from 'ink'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { mergeRuns } from '../../core/segments.ts'
|
|
4
|
+
import type { Segment } from '../../core/segments.ts'
|
|
5
|
+
import { COLORS } from '../../theme.ts'
|
|
6
|
+
import { CHROME_MARGIN_X, CHROME_PAD_X, CHROME_TEXT_X } from '../../core/metrics.ts'
|
|
7
|
+
import { SelectableText } from '../selection.tsx'
|
|
8
|
+
import { Region } from '../region.tsx'
|
|
9
|
+
import { CapText } from '../chrome/caps.tsx'
|
|
10
|
+
|
|
11
|
+
export interface PanelPointerHandle {
|
|
12
|
+
clickAt(y: number, x: number): void
|
|
13
|
+
wheel(dir: -1 | 1): boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PanelRow {
|
|
17
|
+
segments: Segment[]
|
|
18
|
+
/** Replaces the text row with an interactive node (e.g. a button row). */
|
|
19
|
+
content?: ReactNode
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PanelHint {
|
|
23
|
+
/** Key token, rendered with the key color (e.g. "enter", an arrow glyph). */
|
|
24
|
+
key: string
|
|
25
|
+
/** Action word, rendered with the description color. */
|
|
26
|
+
description: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PanelLegendOptions {
|
|
30
|
+
/** Leading counter text (e.g. "1/2"), rendered with the description color. */
|
|
31
|
+
prefix?: string
|
|
32
|
+
keyColor?: string
|
|
33
|
+
descriptionColor?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build a key-hint row: keys in the panel key color, action words in the
|
|
38
|
+
* muted description color ("enter select esc close").
|
|
39
|
+
*/
|
|
40
|
+
export function panelLegend(hints: readonly PanelHint[], options: PanelLegendOptions = {}): Segment[] {
|
|
41
|
+
const keyStyle = { color: options.keyColor ?? COLORS.panelKeyText }
|
|
42
|
+
const descriptionStyle = { color: options.descriptionColor ?? COLORS.toolBodyText }
|
|
43
|
+
const parts: Array<Segment> = []
|
|
44
|
+
if (options.prefix !== undefined && options.prefix !== '') {
|
|
45
|
+
parts.push({ text: options.prefix, style: descriptionStyle })
|
|
46
|
+
}
|
|
47
|
+
for (const [index, hint] of hints.entries()) {
|
|
48
|
+
if (index > 0 || parts.length > 0) parts.push({ text: ' ', style: descriptionStyle })
|
|
49
|
+
parts.push({ text: hint.key, style: keyStyle })
|
|
50
|
+
parts.push({ text: ' ', style: descriptionStyle })
|
|
51
|
+
parts.push({ text: hint.description, style: descriptionStyle })
|
|
52
|
+
}
|
|
53
|
+
return mergeRuns(parts)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Shared panel chrome: a floating block anchored above the status line with
|
|
58
|
+
* half-block caps, the panel background, and per-row text or interactive
|
|
59
|
+
* content. Builtin panels and plugin panels built through dshtui/dialog
|
|
60
|
+
* share this shell so every interaction panel looks the same.
|
|
61
|
+
*
|
|
62
|
+
* Selection pieces span the row text only, never the pill padding, so
|
|
63
|
+
* dragging over blank panel area selects nothing (same rule as messages).
|
|
64
|
+
*/
|
|
65
|
+
export function PanelSurface({ columns, rows, body, bodyStart, background, blockWidth }: {
|
|
66
|
+
columns: number
|
|
67
|
+
rows: number
|
|
68
|
+
body: PanelRow[]
|
|
69
|
+
bodyStart: number
|
|
70
|
+
background: string
|
|
71
|
+
blockWidth: number
|
|
72
|
+
}) {
|
|
73
|
+
return (
|
|
74
|
+
<Region>
|
|
75
|
+
<Box position="absolute" top={0} left={0} width={columns} height={rows}>
|
|
76
|
+
<Box position="absolute" top={bodyStart - 1} left={CHROME_MARGIN_X} width={blockWidth}>
|
|
77
|
+
<CapText background={background} top width={blockWidth} />
|
|
78
|
+
</Box>
|
|
79
|
+
{body.map((row, index) => {
|
|
80
|
+
const isEmpty = row.segments.every(segment => segment.text.trim() === '')
|
|
81
|
+
return (
|
|
82
|
+
<Box
|
|
83
|
+
key={`panel-row-${index}`}
|
|
84
|
+
position="absolute"
|
|
85
|
+
top={bodyStart + index}
|
|
86
|
+
left={CHROME_MARGIN_X}
|
|
87
|
+
width={blockWidth}
|
|
88
|
+
paddingLeft={CHROME_PAD_X}
|
|
89
|
+
paddingRight={CHROME_PAD_X}
|
|
90
|
+
backgroundColor={background}
|
|
91
|
+
>
|
|
92
|
+
{row.content !== undefined
|
|
93
|
+
? row.content
|
|
94
|
+
: isEmpty
|
|
95
|
+
? <Text>{' '}</Text>
|
|
96
|
+
: <SelectableText y={bodyStart + index} col={CHROME_TEXT_X} segments={row.segments} />}
|
|
97
|
+
</Box>
|
|
98
|
+
)
|
|
99
|
+
})}
|
|
100
|
+
<Box position="absolute" top={bodyStart + body.length} left={CHROME_MARGIN_X} width={blockWidth}>
|
|
101
|
+
<CapText background={background} top={false} width={blockWidth} />
|
|
102
|
+
</Box>
|
|
103
|
+
</Box>
|
|
104
|
+
</Region>
|
|
105
|
+
)
|
|
106
|
+
}
|