@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,97 @@
|
|
|
1
|
+
import type { Token, Tokens } from 'marked'
|
|
2
|
+
import { mergeRuns } from '../../../core/segments.ts'
|
|
3
|
+
import type { MarkStyle, Segment } from '../../../core/segments.ts'
|
|
4
|
+
import { markdownExtensions } from './extensions.ts'
|
|
5
|
+
import type { MdPalette } from './palette.ts'
|
|
6
|
+
|
|
7
|
+
function mergeStyle(base: MarkStyle | undefined, extra: MarkStyle): MarkStyle {
|
|
8
|
+
const merged: MarkStyle = {}
|
|
9
|
+
const color = extra.color ?? base?.color
|
|
10
|
+
if (color !== undefined) merged.color = color
|
|
11
|
+
if (base?.bold === true || extra.bold === true) merged.bold = true
|
|
12
|
+
if (base?.italic === true || extra.italic === true) merged.italic = true
|
|
13
|
+
if (base?.strike === true || extra.strike === true) merged.strike = true
|
|
14
|
+
if (base?.underline === true || extra.underline === true) merged.underline = true
|
|
15
|
+
return merged
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function renderInline(tokens: Token[], palette: MdPalette, base?: MarkStyle): Segment[] {
|
|
19
|
+
const out: Segment[] = []
|
|
20
|
+
const emit = (text: string, style: MarkStyle): void => {
|
|
21
|
+
if (text !== '') out.push({ text, style })
|
|
22
|
+
}
|
|
23
|
+
for (const token of tokens) {
|
|
24
|
+
const contributed = markdownExtensions.markdownInlineOf(token)
|
|
25
|
+
if (contributed !== undefined) {
|
|
26
|
+
out.push(contributed.render(token, { palette, base }))
|
|
27
|
+
continue
|
|
28
|
+
}
|
|
29
|
+
switch (token.type) {
|
|
30
|
+
case 'strong': {
|
|
31
|
+
const t = token as Tokens.Strong
|
|
32
|
+
out.push(...renderInline(t.tokens, palette, mergeStyle(base, palette.bold)))
|
|
33
|
+
break
|
|
34
|
+
}
|
|
35
|
+
case 'em': {
|
|
36
|
+
const t = token as Tokens.Em
|
|
37
|
+
out.push(...renderInline(t.tokens, palette, mergeStyle(base, palette.italic)))
|
|
38
|
+
break
|
|
39
|
+
}
|
|
40
|
+
case 'del': {
|
|
41
|
+
const t = token as Tokens.Del
|
|
42
|
+
out.push(...renderInline(t.tokens, palette, mergeStyle(base, palette.strike)))
|
|
43
|
+
break
|
|
44
|
+
}
|
|
45
|
+
case 'codespan': {
|
|
46
|
+
const t = token as Tokens.Codespan
|
|
47
|
+
emit(t.text.replace(/\n/gm, ' '), mergeStyle(base, palette.inlineCode))
|
|
48
|
+
break
|
|
49
|
+
}
|
|
50
|
+
case 'link': {
|
|
51
|
+
const t = token as Tokens.Link
|
|
52
|
+
out.push(...renderInline(t.tokens.length > 0 ? t.tokens : [{ type: 'text', raw: t.text, text: t.text }], palette, mergeStyle(base, palette.link)))
|
|
53
|
+
break
|
|
54
|
+
}
|
|
55
|
+
case 'image': {
|
|
56
|
+
const t = token as Tokens.Image
|
|
57
|
+
emit(t.text, base ?? palette.plain)
|
|
58
|
+
break
|
|
59
|
+
}
|
|
60
|
+
case 'br': {
|
|
61
|
+
emit('\n', palette.plain)
|
|
62
|
+
break
|
|
63
|
+
}
|
|
64
|
+
case 'escape': {
|
|
65
|
+
const t = token as Tokens.Escape
|
|
66
|
+
emit(t.text, base ?? palette.plain)
|
|
67
|
+
break
|
|
68
|
+
}
|
|
69
|
+
case 'inline-links':
|
|
70
|
+
case 'url': {
|
|
71
|
+
const t = token as Tokens.Generic & { href: string; text: string }
|
|
72
|
+
emit(t.text !== '' ? t.text : t.href, mergeStyle(base, palette.link))
|
|
73
|
+
break
|
|
74
|
+
}
|
|
75
|
+
case 'html': {
|
|
76
|
+
const t = token as Tokens.HTML
|
|
77
|
+
emit(t.raw.replace(/<br\s*\/?>/gi, '\n'), base ?? palette.plain)
|
|
78
|
+
break
|
|
79
|
+
}
|
|
80
|
+
case 'text': {
|
|
81
|
+
const t = token as Tokens.Text
|
|
82
|
+
if (t.tokens !== undefined && t.tokens.length > 0) {
|
|
83
|
+
out.push(...renderInline(t.tokens, palette, base))
|
|
84
|
+
} else {
|
|
85
|
+
emit(t.text, base ?? palette.plain)
|
|
86
|
+
}
|
|
87
|
+
break
|
|
88
|
+
}
|
|
89
|
+
default: {
|
|
90
|
+
const raw = (token as Tokens.Generic).raw
|
|
91
|
+
if (typeof raw === 'string') emit(raw, base ?? palette.plain)
|
|
92
|
+
break
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return mergeRuns(out)
|
|
97
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { MarkStyle } from '../../../core/segments.ts'
|
|
2
|
+
import { COLORS } from '../../../theme.ts'
|
|
3
|
+
|
|
4
|
+
export interface MdPalette {
|
|
5
|
+
plain: MarkStyle
|
|
6
|
+
bold: MarkStyle
|
|
7
|
+
italic: MarkStyle
|
|
8
|
+
strike: MarkStyle
|
|
9
|
+
link: MarkStyle
|
|
10
|
+
inlineCode: MarkStyle
|
|
11
|
+
quoteBar: MarkStyle
|
|
12
|
+
hr: MarkStyle
|
|
13
|
+
listMarker: MarkStyle
|
|
14
|
+
taskDone: MarkStyle
|
|
15
|
+
taskTodo: MarkStyle
|
|
16
|
+
heading(level: number): MarkStyle
|
|
17
|
+
codePlain: MarkStyle
|
|
18
|
+
codeFallback: MarkStyle
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ColorSpec {
|
|
22
|
+
link: string
|
|
23
|
+
inlineCode: string
|
|
24
|
+
quoteBar: string
|
|
25
|
+
hr: string
|
|
26
|
+
list: string
|
|
27
|
+
taskDone: string
|
|
28
|
+
taskTodo: string
|
|
29
|
+
h1: string
|
|
30
|
+
h3: string
|
|
31
|
+
codePlain: string
|
|
32
|
+
codeFallback: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const MD_SPEC: ColorSpec = {
|
|
36
|
+
link: 'mdLink',
|
|
37
|
+
inlineCode: 'mdInlineCode',
|
|
38
|
+
quoteBar: 'mdQuoteBar',
|
|
39
|
+
hr: 'mdHr',
|
|
40
|
+
list: 'mdList',
|
|
41
|
+
taskDone: 'mdTaskDone',
|
|
42
|
+
taskTodo: 'mdTaskTodo',
|
|
43
|
+
h1: 'mdH1',
|
|
44
|
+
h3: 'mdH3',
|
|
45
|
+
codePlain: 'mdCodePlain',
|
|
46
|
+
codeFallback: 'mdCodeFallback',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const THINK_SPEC: ColorSpec = {
|
|
50
|
+
link: 'thinkLink',
|
|
51
|
+
inlineCode: 'thinkInlineCode',
|
|
52
|
+
quoteBar: 'mdQuoteBar',
|
|
53
|
+
hr: 'thinkHr',
|
|
54
|
+
list: 'thinkList',
|
|
55
|
+
taskDone: 'thinkTaskDone',
|
|
56
|
+
taskTodo: 'thinkTaskTodo',
|
|
57
|
+
h1: 'thinkH1',
|
|
58
|
+
h3: 'thinkH1',
|
|
59
|
+
codePlain: 'thinkCodePlain',
|
|
60
|
+
codeFallback: 'thinkCodeFallback',
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function buildPalette(spec: ColorSpec): MdPalette {
|
|
64
|
+
const at = (key: string): MarkStyle => ({ color: (COLORS as unknown as Record<string, string>)[key] })
|
|
65
|
+
return {
|
|
66
|
+
plain: {},
|
|
67
|
+
bold: { bold: true },
|
|
68
|
+
italic: { italic: true },
|
|
69
|
+
strike: { strike: true },
|
|
70
|
+
link: { ...at(spec.link), underline: true },
|
|
71
|
+
inlineCode: at(spec.inlineCode),
|
|
72
|
+
quoteBar: at(spec.quoteBar),
|
|
73
|
+
hr: at(spec.hr),
|
|
74
|
+
listMarker: at(spec.list),
|
|
75
|
+
taskDone: at(spec.taskDone),
|
|
76
|
+
taskTodo: at(spec.taskTodo),
|
|
77
|
+
heading(level) {
|
|
78
|
+
return { color: (COLORS as unknown as Record<string, string>)[level <= 2 ? spec.h1 : spec.h3], bold: true }
|
|
79
|
+
},
|
|
80
|
+
codePlain: at(spec.codePlain),
|
|
81
|
+
codeFallback: at(spec.codeFallback),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createMdPalette(thinking: boolean): MdPalette {
|
|
86
|
+
return buildPalette(thinking ? THINK_SPEC : MD_SPEC)
|
|
87
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Box, useInput } from 'ink'
|
|
2
|
+
import { isKeyConsumed } from '../key-arbiter.ts'
|
|
3
|
+
import type { Message } from '../../model/message.ts'
|
|
4
|
+
import { rowIndexFor, scrollbarGeometry } from './layout.ts'
|
|
5
|
+
import { MessageRow } from './message-row.tsx'
|
|
6
|
+
import { COLORS } from '../../theme.ts'
|
|
7
|
+
import { scrollbarColumn } from '../layout-service.ts'
|
|
8
|
+
import { Region } from '../region.tsx'
|
|
9
|
+
|
|
10
|
+
interface MessageListProps {
|
|
11
|
+
messages: Message[]
|
|
12
|
+
height: number
|
|
13
|
+
width: number
|
|
14
|
+
scrollTop: number
|
|
15
|
+
onScroll: (next: number) => void
|
|
16
|
+
interactive?: boolean
|
|
17
|
+
themeTick?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function MessageList({ messages, height, width, scrollTop, onScroll, interactive = true, themeTick = 0 }: MessageListProps) {
|
|
21
|
+
const index = rowIndexFor(messages, width)
|
|
22
|
+
const total = index.total
|
|
23
|
+
const halfPage = Math.max(1, Math.ceil(height / 2))
|
|
24
|
+
useInput((input, key) => {
|
|
25
|
+
if (!interactive || isKeyConsumed()) return
|
|
26
|
+
const maxScroll = Math.max(0, total - height)
|
|
27
|
+
const scroll = (delta: number): void => {
|
|
28
|
+
onScroll(Math.max(0, Math.min(maxScroll, scrollTop + delta)))
|
|
29
|
+
}
|
|
30
|
+
if (key.pageUp) scroll(-(height - 2))
|
|
31
|
+
if (key.pageDown) scroll(height - 2)
|
|
32
|
+
if (key.ctrl && input === 'u') scroll(-halfPage)
|
|
33
|
+
if (key.ctrl && input === 'd') scroll(halfPage)
|
|
34
|
+
})
|
|
35
|
+
const rows = []
|
|
36
|
+
const endRow = Math.min(scrollTop + height, total)
|
|
37
|
+
for (let row = scrollTop; row < endRow; row++) {
|
|
38
|
+
const info = index.rowAt(row)
|
|
39
|
+
if (info) rows.push(<MessageRow key={`${info.messageId}:${info.lineNo}`} info={info} row={row} themeTick={themeTick} />)
|
|
40
|
+
}
|
|
41
|
+
const scrollbar = scrollbarGeometry(total, height, scrollTop)
|
|
42
|
+
return (
|
|
43
|
+
<Box width={width} height={height} flexDirection="column" overflow="hidden">
|
|
44
|
+
<Region y={-scrollTop}>
|
|
45
|
+
{rows}
|
|
46
|
+
</Region>
|
|
47
|
+
{scrollbar !== null && (
|
|
48
|
+
<>
|
|
49
|
+
<Box
|
|
50
|
+
position="absolute"
|
|
51
|
+
top={0}
|
|
52
|
+
left={scrollbarColumn(width)}
|
|
53
|
+
width={1}
|
|
54
|
+
height={height}
|
|
55
|
+
backgroundColor={COLORS.scrollTrackBackground}
|
|
56
|
+
/>
|
|
57
|
+
<Box
|
|
58
|
+
position="absolute"
|
|
59
|
+
top={scrollbar.top}
|
|
60
|
+
left={scrollbarColumn(width)}
|
|
61
|
+
width={1}
|
|
62
|
+
height={scrollbar.height}
|
|
63
|
+
backgroundColor={COLORS.scrollThumbBackground}
|
|
64
|
+
/>
|
|
65
|
+
</>
|
|
66
|
+
)}
|
|
67
|
+
</Box>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Box, Text } from 'ink'
|
|
2
|
+
import { memo } from 'react'
|
|
3
|
+
import { COLORS } from '../../theme.ts'
|
|
4
|
+
import { glyphs } from '../../terminal/glyphs.ts'
|
|
5
|
+
import { headerSymbol, HEADER_LABEL_COL } from './layout.ts'
|
|
6
|
+
import type { RowInfo } from './layout.ts'
|
|
7
|
+
import { SelectableText } from '../selection.tsx'
|
|
8
|
+
import { useSpinnerTick } from '../spinner-tick.tsx'
|
|
9
|
+
import { wavePalette, waveSegments } from './wave.ts'
|
|
10
|
+
|
|
11
|
+
function TickGlyph({ y, col, color }: { y: number; col: number; color?: string }): ReturnType<typeof SelectableText> {
|
|
12
|
+
const tick = useSpinnerTick()
|
|
13
|
+
return (
|
|
14
|
+
<SelectableText
|
|
15
|
+
y={y}
|
|
16
|
+
col={col}
|
|
17
|
+
text={`${glyphs.spinnerFrames[tick % glyphs.spinnerFrames.length]} `}
|
|
18
|
+
color={color}
|
|
19
|
+
messageLayer
|
|
20
|
+
/>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function WaveText({ info, row, color }: { info: RowInfo; row: number; color: string }): ReturnType<typeof SelectableText> {
|
|
25
|
+
const tick = useSpinnerTick()
|
|
26
|
+
const base = info.segments?.[0]?.style.bold === true ? { bold: true } : {}
|
|
27
|
+
return (
|
|
28
|
+
<SelectableText
|
|
29
|
+
y={row}
|
|
30
|
+
col={info.colStart}
|
|
31
|
+
segments={waveSegments(info.text, tick, wavePalette(color), color, base)}
|
|
32
|
+
messageLayer
|
|
33
|
+
flow
|
|
34
|
+
/>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface MessageRowProps {
|
|
39
|
+
info: RowInfo
|
|
40
|
+
row: number
|
|
41
|
+
themeTick?: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The row component itself consumes no context: only the spinner and wave
|
|
45
|
+
// subcomponents subscribe to the tick, so memoized rows that are not
|
|
46
|
+
// animating stay untouched on every tick.
|
|
47
|
+
export const MessageRow = memo(
|
|
48
|
+
function MessageRow({ info, row, themeTick = 0 }: MessageRowProps) {
|
|
49
|
+
switch (info.kind) {
|
|
50
|
+
case 'pad':
|
|
51
|
+
return (
|
|
52
|
+
<Box marginLeft={2} width={info.backgroundWidth} backgroundColor={backgroundFor(info.role)}>
|
|
53
|
+
<Text>{' '.repeat(info.backgroundWidth)}</Text>
|
|
54
|
+
</Box>
|
|
55
|
+
)
|
|
56
|
+
case 'text': {
|
|
57
|
+
const col = info.colStart
|
|
58
|
+
const marginLeft = col >= 2 ? 2 : col
|
|
59
|
+
const paddingLeft = col >= 2 ? col - 2 : 0
|
|
60
|
+
const baseColor = info.role === 'error' ? COLORS.errorText : info.muted ? COLORS.toolBodyText : undefined
|
|
61
|
+
const waveColor = info.segments?.[0]?.style.color ?? baseColor
|
|
62
|
+
return (
|
|
63
|
+
<Box
|
|
64
|
+
marginLeft={marginLeft}
|
|
65
|
+
width={info.backgroundWidth}
|
|
66
|
+
paddingLeft={info.spinner ? 0 : paddingLeft}
|
|
67
|
+
backgroundColor={info.lineBg ?? (info.background ? backgroundFor(info.role) : undefined)}
|
|
68
|
+
>
|
|
69
|
+
{info.spinner && <TickGlyph y={row} col={col - 2} color={info.accent ?? baseColor} />}
|
|
70
|
+
{info.wave && waveColor !== undefined ? (
|
|
71
|
+
<WaveText info={info} row={row} color={waveColor} />
|
|
72
|
+
) : info.segments !== undefined && info.segments.length > 0 ? (
|
|
73
|
+
<SelectableText y={row} col={col} segments={info.segments} color={baseColor} messageLayer flow />
|
|
74
|
+
) : (
|
|
75
|
+
<SelectableText
|
|
76
|
+
text={info.text || ' '}
|
|
77
|
+
y={row}
|
|
78
|
+
col={col}
|
|
79
|
+
color={baseColor}
|
|
80
|
+
messageLayer
|
|
81
|
+
flow
|
|
82
|
+
/>
|
|
83
|
+
)}
|
|
84
|
+
</Box>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
case 'header': {
|
|
88
|
+
const symbol = headerSymbol(info.collapsed)
|
|
89
|
+
const color = COLORS.toolLabel
|
|
90
|
+
return (
|
|
91
|
+
<Box>
|
|
92
|
+
{info.spinner ? (
|
|
93
|
+
<>
|
|
94
|
+
<SelectableText y={row} col={0} text={' '} color={color} messageLayer />
|
|
95
|
+
<TickGlyph y={row} col={2} color={color} />
|
|
96
|
+
</>
|
|
97
|
+
) : (
|
|
98
|
+
<SelectableText y={row} col={0} text={` ${symbol} `} color={color} messageLayer />
|
|
99
|
+
)}
|
|
100
|
+
<SelectableText y={row} col={HEADER_LABEL_COL} text={info.label} color={color} messageLayer />
|
|
101
|
+
</Box>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
case 'blank':
|
|
105
|
+
return <Text> </Text>
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
(prev, next) => {
|
|
109
|
+
if (prev.row !== next.row) return false
|
|
110
|
+
if (prev.themeTick !== next.themeTick) return false
|
|
111
|
+
const a = prev.info
|
|
112
|
+
const b = next.info
|
|
113
|
+
return (
|
|
114
|
+
a.kind === b.kind &&
|
|
115
|
+
a.text === b.text &&
|
|
116
|
+
a.label === b.label &&
|
|
117
|
+
a.role === b.role &&
|
|
118
|
+
a.muted === b.muted &&
|
|
119
|
+
a.background === b.background &&
|
|
120
|
+
a.backgroundWidth === b.backgroundWidth &&
|
|
121
|
+
a.colStart === b.colStart &&
|
|
122
|
+
a.selectable === b.selectable &&
|
|
123
|
+
a.clickable === b.clickable &&
|
|
124
|
+
a.collapsed === b.collapsed &&
|
|
125
|
+
a.thinking === b.thinking &&
|
|
126
|
+
a.segKey === b.segKey &&
|
|
127
|
+
a.spinner === b.spinner &&
|
|
128
|
+
a.wave === b.wave &&
|
|
129
|
+
a.accent === b.accent &&
|
|
130
|
+
a.lineBg === b.lineBg
|
|
131
|
+
)
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
function backgroundFor(role: RowInfo['role']): string {
|
|
136
|
+
return role === 'user' ? COLORS.userBubbleBackground : COLORS.aiBubbleBackground
|
|
137
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { keyedRegistry } from '../../kernel/registry.ts'
|
|
2
|
+
import type { MessageViewContribution } from '../../contract/index.ts'
|
|
3
|
+
|
|
4
|
+
const views = keyedRegistry<MessageViewContribution>()
|
|
5
|
+
|
|
6
|
+
export function registerMessageView(contribution: MessageViewContribution): () => void {
|
|
7
|
+
return views.register(contribution.view, contribution, { order: contribution.order })
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function messageViewOf(view: string): MessageViewContribution | undefined {
|
|
11
|
+
return views.get(view)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function subscribeMessageViews(listener: () => void): () => void {
|
|
15
|
+
return views.subscribe(listener)
|
|
16
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { keyedRegistry } from '../../kernel/registry.ts'
|
|
2
|
+
import type { Message, MessageKind } from '../../model/message.ts'
|
|
3
|
+
import type { Segment } from '../../core/segments.ts'
|
|
4
|
+
|
|
5
|
+
export interface MessageRendererResult {
|
|
6
|
+
lines: string[]
|
|
7
|
+
rows?: Segment[][] | null
|
|
8
|
+
bgs?: (string | undefined)[] | null
|
|
9
|
+
/** Custom messages only: override the default view-key label. */
|
|
10
|
+
customLabel?: string
|
|
11
|
+
customMuted?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface MessageRendererContribution {
|
|
15
|
+
/** Message kind to take over: 'bubble' | 'collapsible' | 'tool-card' | 'compaction' | 'custom'. */
|
|
16
|
+
kind: MessageKind
|
|
17
|
+
order?: number
|
|
18
|
+
render(message: Message, width: number): MessageRendererResult | undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const renderers = keyedRegistry<MessageRendererContribution>()
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Kind-keyed takeover of builtin message body rendering. Builtin rendering
|
|
25
|
+
* stays inline in layout.ts and applies only when no override is registered,
|
|
26
|
+
* so a plugin can re-present todo bubbles, plan text, diff bodies, and even
|
|
27
|
+
* assistant bubbles. Disposal restores the builtin path.
|
|
28
|
+
*/
|
|
29
|
+
export function registerMessageRenderer(contribution: MessageRendererContribution): () => void {
|
|
30
|
+
return renderers.register(contribution.kind, contribution, { order: contribution.order })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function messageRendererOf(kind: MessageKind): MessageRendererContribution | undefined {
|
|
34
|
+
return renderers.get(kind)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function subscribeMessageRenderers(listener: () => void): () => void {
|
|
38
|
+
return renderers.subscribe(listener)
|
|
39
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { wrapSegments } from '../../core/segments.ts'
|
|
2
|
+
import type { Segment } from '../../core/segments.ts'
|
|
3
|
+
import { COLORS } from '../../theme.ts'
|
|
4
|
+
import { highlightCodeBlock, LANGUAGE_ALIASES } from './md/highlight.ts'
|
|
5
|
+
import type { DiffLine } from '../../model/message.ts'
|
|
6
|
+
|
|
7
|
+
const FILENAME_LANGUAGES: Record<string, string> = {
|
|
8
|
+
makefile: 'makefile',
|
|
9
|
+
dockerfile: 'docker',
|
|
10
|
+
'cmakelists.txt': 'cmake',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function languageFromPath(path: string): string {
|
|
14
|
+
const name = path.replace(/\\/g, '/').split('/').pop() ?? ''
|
|
15
|
+
const filename = FILENAME_LANGUAGES[name.toLowerCase()]
|
|
16
|
+
if (filename !== undefined) return filename
|
|
17
|
+
const dot = name.lastIndexOf('.')
|
|
18
|
+
if (dot < 0) return ''
|
|
19
|
+
return LANGUAGE_ALIASES[name.slice(dot + 1).toLowerCase()] ?? ''
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SNIFF_RULES: readonly (readonly [RegExp, string])[] = [
|
|
23
|
+
[/^#!.*\b(ba|z|k)?sh\b/, 'bash'],
|
|
24
|
+
[/^\s*<\?php/, 'php'],
|
|
25
|
+
[/^\s*<!DOCTYPE\s+html/i, 'markup'],
|
|
26
|
+
[/^\s*[{\[][\s\S]*"[^"]+"\s*:/, 'json'],
|
|
27
|
+
[/\b(?:def\s+\w+\s*\(|elif\s|from\s+\w+\s+import\b|__name__\s*==)/, 'python'],
|
|
28
|
+
[/\b(?:package\s+\w+[;\s]|func\s+\w*\(|import\s+"|\bnil\b)/, 'go'],
|
|
29
|
+
[/\bfn\s+\w+\s*\(|\blet\s+mut\b|\bimpl\s+\w+\s+for\b/, 'rust'],
|
|
30
|
+
[/\b(?:interface\s+\w+\s*[<{]|type\s+\w+\s*=\s*[^=]|:\s*(?:string|number|boolean)\b|export\s+(?:default|const|function|class|interface)\b|import\s+[\w{*}\s,]+\s+from\s+)/, 'typescript'],
|
|
31
|
+
[/\b(?:require\(|module\.exports|console\.log\(|function\s+\w+\s*\()/, 'javascript'],
|
|
32
|
+
[/=>|\bconst\s+\w+\s*=|\blet\s+\w+\s*=/, 'javascript'],
|
|
33
|
+
[/^\s*(?:SELECT\b|INSERT\s+INTO\b|CREATE\s+TABLE\b)/im, 'sql'],
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
export function sniffLanguage(content: string): string {
|
|
37
|
+
const sample = content.length > 4000 ? content.slice(0, 4000) : content
|
|
38
|
+
for (const [pattern, lang] of SNIFF_RULES) {
|
|
39
|
+
if (pattern.test(sample)) return lang
|
|
40
|
+
}
|
|
41
|
+
return ''
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ToolDiffBody {
|
|
45
|
+
lines: string[]
|
|
46
|
+
rows: Segment[][]
|
|
47
|
+
bgs: (string | undefined)[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const PLAIN_STYLE = { color: COLORS.mdCodePlain }
|
|
51
|
+
|
|
52
|
+
function splitHighlightedLines(source: string, lang: string, streamId: string): Segment[][] {
|
|
53
|
+
if (source === '') return []
|
|
54
|
+
const highlighted = highlightCodeBlock(source, lang, false, streamId)
|
|
55
|
+
const segments = highlighted ?? [{ text: source, style: PLAIN_STYLE }]
|
|
56
|
+
const out: Segment[][] = [[]]
|
|
57
|
+
for (const segment of segments) {
|
|
58
|
+
const parts = segment.text.split('\n')
|
|
59
|
+
for (let p = 0; p < parts.length; p++) {
|
|
60
|
+
if (p > 0) out.push([])
|
|
61
|
+
const part = parts[p]!
|
|
62
|
+
if (part !== '') out[out.length - 1]!.push({ text: part, style: segment.style })
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ToolDiffLike {
|
|
69
|
+
tool?: string
|
|
70
|
+
path: string
|
|
71
|
+
hunks: readonly (readonly DiffLine[])[]
|
|
72
|
+
error?: string
|
|
73
|
+
/**
|
|
74
|
+
* Paint whole-line red/green backgrounds behind removed and added lines.
|
|
75
|
+
* Defaults to true when the diff contains any removal: a pure-addition card
|
|
76
|
+
* (a fresh file write) would otherwise read as one noisy column of green.
|
|
77
|
+
* Contributions set this explicitly to override the heuristic either way.
|
|
78
|
+
*/
|
|
79
|
+
backgrounds?: boolean
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function hasRemovals(hunks: readonly (readonly DiffLine[])[]): boolean {
|
|
83
|
+
return hunks.some(hunk => hunk.some(line => line.kind === 'del'))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function renderToolDiffBody(message: ToolDiffLike, width: number): ToolDiffBody {
|
|
87
|
+
const budget = Math.max(4, width)
|
|
88
|
+
const bodyWidth = Math.max(2, budget - 2)
|
|
89
|
+
const lines: string[] = []
|
|
90
|
+
const rows: Segment[][] = []
|
|
91
|
+
const bgs: (string | undefined)[] = []
|
|
92
|
+
const sourceText = message.hunks.map(hunk => hunk.map(line => line.text).join('\n')).join('\n')
|
|
93
|
+
const fromPath = message.path === '' ? '' : languageFromPath(message.path)
|
|
94
|
+
const lang = fromPath !== '' ? fromPath : sniffLanguage(sourceText)
|
|
95
|
+
const withBackgrounds = message.backgrounds ?? hasRemovals(message.hunks)
|
|
96
|
+
for (const [hunkIndex, hunk] of message.hunks.entries()) {
|
|
97
|
+
const perLine = splitHighlightedLines(
|
|
98
|
+
hunk.map(line => line.text).join('\n'),
|
|
99
|
+
lang,
|
|
100
|
+
`${message.tool ?? ''}\u0000${message.path}\u0000${hunkIndex}`,
|
|
101
|
+
)
|
|
102
|
+
for (let index = 0; index < hunk.length; index++) {
|
|
103
|
+
const line = hunk[index]!
|
|
104
|
+
let bg: string | undefined
|
|
105
|
+
let prefix: Segment[]
|
|
106
|
+
if (line.kind === 'add') {
|
|
107
|
+
bg = withBackgrounds ? COLORS.diffAddedBackground : undefined
|
|
108
|
+
prefix = [{ text: '+', style: { color: COLORS.diffAdded } }, { text: ' ', style: {} }]
|
|
109
|
+
} else if (line.kind === 'del') {
|
|
110
|
+
bg = withBackgrounds ? COLORS.diffRemovedBackground : undefined
|
|
111
|
+
prefix = [{ text: '-', style: { color: COLORS.diffRemoved } }, { text: ' ', style: {} }]
|
|
112
|
+
} else {
|
|
113
|
+
prefix = [{ text: ' ', style: {} }]
|
|
114
|
+
}
|
|
115
|
+
const wrapped = wrapSegments(perLine[index] ?? [], bodyWidth)
|
|
116
|
+
for (let r = 0; r < wrapped.length; r++) {
|
|
117
|
+
const codeRow = wrapped[r] ?? []
|
|
118
|
+
const row = r === 0 ? [...prefix, ...codeRow] : [{ text: ' ', style: {} }, ...codeRow]
|
|
119
|
+
rows.push(row)
|
|
120
|
+
lines.push(row.map(segment => segment.text).join(''))
|
|
121
|
+
bgs.push(bg)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (message.error !== undefined && message.error !== '') {
|
|
126
|
+
rows.push([])
|
|
127
|
+
lines.push('')
|
|
128
|
+
bgs.push(undefined)
|
|
129
|
+
const wrapped = wrapSegments([{ text: message.error, style: { color: COLORS.errorText } }], bodyWidth)
|
|
130
|
+
for (const segs of wrapped) {
|
|
131
|
+
rows.push(segs)
|
|
132
|
+
lines.push(segs.map(segment => segment.text).join(''))
|
|
133
|
+
bgs.push(undefined)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { lines, rows, bgs }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface ToolReadLike {
|
|
140
|
+
path?: string
|
|
141
|
+
lines: readonly { number: number; text: string }[]
|
|
142
|
+
offset?: number
|
|
143
|
+
totalLines?: number
|
|
144
|
+
lang?: string
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The read card body: a line-number gutter beside syntax-highlighted code,
|
|
149
|
+
* closed by a "lines N-M of total" footer when the window stops short of the
|
|
150
|
+
* end of the file. The gutter is part of the row segments, so the code column
|
|
151
|
+
* stays aligned across wraps.
|
|
152
|
+
*/
|
|
153
|
+
export function renderToolReadBody(view: ToolReadLike, width: number): ToolDiffBody {
|
|
154
|
+
const budget = Math.max(4, width)
|
|
155
|
+
const lines: string[] = []
|
|
156
|
+
const rows: Segment[][] = []
|
|
157
|
+
const bgs: (string | undefined)[] = []
|
|
158
|
+
const gutterWidth = view.lines.reduce((max, line) => Math.max(max, String(line.number).length), 1)
|
|
159
|
+
const codeWidth = Math.max(2, budget - gutterWidth - 1)
|
|
160
|
+
const source = view.lines.map(line => line.text).join('\n')
|
|
161
|
+
const fromPath = view.path === undefined || view.path === '' ? '' : languageFromPath(view.path)
|
|
162
|
+
const lang = view.lang !== undefined && view.lang !== ''
|
|
163
|
+
? view.lang
|
|
164
|
+
: fromPath !== '' ? fromPath : sniffLanguage(source)
|
|
165
|
+
const perLine = splitHighlightedLines(source, lang, `read\u0000${view.path ?? ''}`)
|
|
166
|
+
for (const [index, line] of view.lines.entries()) {
|
|
167
|
+
const gutter = String(line.number).padStart(gutterWidth)
|
|
168
|
+
const prefix: Segment[] = [{ text: gutter, style: { color: COLORS.toolBodyText } }, { text: ' ', style: {} }]
|
|
169
|
+
const wrapped = wrapSegments(perLine[index] ?? [], codeWidth)
|
|
170
|
+
for (let r = 0; r < wrapped.length; r++) {
|
|
171
|
+
const row = r === 0 ? [...prefix, ...(wrapped[r] ?? [])] : [{ text: `${' '.repeat(gutterWidth)} `, style: {} }, ...(wrapped[r] ?? [])]
|
|
172
|
+
rows.push(row)
|
|
173
|
+
lines.push(row.map(segment => segment.text).join(''))
|
|
174
|
+
bgs.push(undefined)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const first = view.lines[0]?.number ?? view.offset ?? 1
|
|
178
|
+
const last = view.lines.length === 0 ? first - 1 : view.lines[view.lines.length - 1]!.number
|
|
179
|
+
if (view.totalLines !== undefined && view.totalLines > last) {
|
|
180
|
+
const text = view.lines.length === 0
|
|
181
|
+
? `... empty window at line ${first} of ${view.totalLines}`
|
|
182
|
+
: `... lines ${first}-${last} of ${view.totalLines}`
|
|
183
|
+
rows.push([{ text, style: { color: COLORS.toolBodyText } }])
|
|
184
|
+
lines.push(text)
|
|
185
|
+
bgs.push(undefined)
|
|
186
|
+
}
|
|
187
|
+
return { lines, rows, bgs }
|
|
188
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { renderMarkdown } from './md/index.ts'
|
|
2
|
+
import { buildRowIndex } from './layout.ts'
|
|
3
|
+
import type { Message } from '../../model/message.ts'
|
|
4
|
+
|
|
5
|
+
const MARKDOWN_FIXTURE = [
|
|
6
|
+
'# Heading',
|
|
7
|
+
'',
|
|
8
|
+
'Paragraph with **bold**, *italic*, `inline code` and a [link](https://example.com).',
|
|
9
|
+
'',
|
|
10
|
+
'- list item one',
|
|
11
|
+
'- list item two',
|
|
12
|
+
'',
|
|
13
|
+
'```ts',
|
|
14
|
+
'export function warm(input: string): number {',
|
|
15
|
+
' return input.length + 1',
|
|
16
|
+
'}',
|
|
17
|
+
'```',
|
|
18
|
+
'',
|
|
19
|
+
'> quoted text',
|
|
20
|
+
].join('\n')
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Run the markdown and row-layout pipeline once on a small fixture so the
|
|
24
|
+
* first real message does not pay one-time lexer, highlighter, and JIT
|
|
25
|
+
* initialization. Call during idle after the syntax languages finish warming.
|
|
26
|
+
*/
|
|
27
|
+
export function warmRenderPipeline(): void {
|
|
28
|
+
renderMarkdown(MARKDOWN_FIXTURE, 96)
|
|
29
|
+
renderMarkdown(MARKDOWN_FIXTURE, 72)
|
|
30
|
+
const messages: Message[] = [
|
|
31
|
+
{ kind: 'home-logo', id: 'warm-logo', lines: ['██ ████'], colors: ['#7aa2f7'], info: ['dsh 0.0.0', 'dshtui 0.0.0'] },
|
|
32
|
+
{ kind: 'bubble', id: 'warm-user', role: 'user', content: 'warm up the render pipeline' },
|
|
33
|
+
{ kind: 'bubble', id: 'warm-ai', role: 'assistant', content: MARKDOWN_FIXTURE },
|
|
34
|
+
{ kind: 'collapsible', id: 'warm-think', label: 'Thinking', body: 'thinking fixture body', running: false, collapsed: false, thinking: true },
|
|
35
|
+
{ kind: 'tool-card', id: 'warm-diff', tool: 'edit', label: 'edit src/warm.ts', argsBody: '', diff: { path: 'src/warm.ts', hunks: [[{ kind: 'ctx', text: 'const keep = 1' }, { kind: 'add', text: 'const added = 2' }]] }, running: false },
|
|
36
|
+
{ kind: 'tool-card', id: 'warm-card', tool: 'bash', label: 'bash[ls]', argsBody: '{\n "command": "ls"\n}', resultBody: 'src\ntests', running: false },
|
|
37
|
+
{ kind: 'bubble', id: 'warm-cmd', role: 'assistant', content: 'command output', origin: 'command' },
|
|
38
|
+
]
|
|
39
|
+
const index = buildRowIndex(messages, 96)
|
|
40
|
+
for (let row = 0; row < index.total; row++) index.rowAt(row)
|
|
41
|
+
}
|