@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,165 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
|
|
4
|
+
export const UPSTREAM_FLOOR_VERSION = '0.1.0-rc.6'
|
|
5
|
+
|
|
6
|
+
export const UPSTREAM_CEILING_VERSION = '0.2.0'
|
|
7
|
+
|
|
8
|
+
export const UPSTREAM_FRAMEWORK_MAJORS: Record<string, number> = {
|
|
9
|
+
'@deepseek-ai/cordis': 4,
|
|
10
|
+
'@deepseek-ai/schemastery': 3,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const UPSTREAM_BLESSED_PACKAGES = [
|
|
14
|
+
'@deepseek-ai/cordis',
|
|
15
|
+
'@deepseek-ai/schemastery',
|
|
16
|
+
'@deepseek-ai/dsh-agent',
|
|
17
|
+
'@deepseek-ai/dsh-agent-default-model',
|
|
18
|
+
'@deepseek-ai/dsh-agent-loop',
|
|
19
|
+
'@deepseek-ai/dsh-agent-presets',
|
|
20
|
+
'@deepseek-ai/dsh-attachment',
|
|
21
|
+
'@deepseek-ai/dsh-credentials',
|
|
22
|
+
'@deepseek-ai/dsh-llm',
|
|
23
|
+
'@deepseek-ai/dsh-session',
|
|
24
|
+
'@deepseek-ai/dsh-settings',
|
|
25
|
+
] as const
|
|
26
|
+
|
|
27
|
+
export interface UpstreamDriftEntry {
|
|
28
|
+
package: string
|
|
29
|
+
installed: string | undefined
|
|
30
|
+
expected: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type UpstreamChannel = 'alpha' | 'beta' | 'rc' | 'stable'
|
|
34
|
+
|
|
35
|
+
export type UpstreamVersionTuple = readonly [number, number, number, UpstreamChannel, number]
|
|
36
|
+
|
|
37
|
+
export function parseUpstreamVersion(version: string | undefined): UpstreamVersionTuple | undefined {
|
|
38
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-(alpha|beta|rc)\.(\d+))?$/u.exec(version ?? '')
|
|
39
|
+
if (match === null) return undefined
|
|
40
|
+
const channel = (match[4] ?? 'stable') as UpstreamChannel
|
|
41
|
+
const revision = match[5] === undefined ? 0 : Number(match[5])
|
|
42
|
+
return [Number(match[1]), Number(match[2]), Number(match[3]), channel, revision]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function compareVersions(a: UpstreamVersionTuple, b: UpstreamVersionTuple): number {
|
|
46
|
+
for (const index of [0, 1, 2] as const) {
|
|
47
|
+
if (a[index] > b[index]) return 1
|
|
48
|
+
if (a[index] < b[index]) return -1
|
|
49
|
+
}
|
|
50
|
+
const channelRank = { alpha: 0, beta: 1, rc: 2, stable: 3 } as const
|
|
51
|
+
const channelOrder = channelRank[a[3]] - channelRank[b[3]]
|
|
52
|
+
return channelOrder !== 0 ? channelOrder : a[4] - b[4]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const UPSTREAM_SUPPORTED_RANGE = `>=${UPSTREAM_FLOOR_VERSION} <${UPSTREAM_CEILING_VERSION}`
|
|
56
|
+
|
|
57
|
+
export function isCompatibleUpstreamVersion(version: string | undefined): boolean {
|
|
58
|
+
const parsed = parseUpstreamVersion(version)
|
|
59
|
+
const floor = parseUpstreamVersion(UPSTREAM_FLOOR_VERSION)
|
|
60
|
+
const ceiling = parseUpstreamVersion(UPSTREAM_CEILING_VERSION)
|
|
61
|
+
if (parsed === undefined || floor === undefined || ceiling === undefined) return false
|
|
62
|
+
const exclusiveCeiling: UpstreamVersionTuple = [ceiling[0], ceiling[1], ceiling[2], 'alpha', 0]
|
|
63
|
+
return compareVersions(parsed, floor) >= 0 && compareVersions(parsed, exclusiveCeiling) < 0
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolvePackageJson(packageName: string): string | undefined {
|
|
67
|
+
try {
|
|
68
|
+
const path = import.meta.resolve(`${packageName}/package.json`)
|
|
69
|
+
return path.startsWith('file:') ? fileURLToPath(path) : path
|
|
70
|
+
} catch {
|
|
71
|
+
// The package is not installed in this profile; it counts as missing.
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let cachedVersions: Record<string, string | undefined> | undefined
|
|
77
|
+
|
|
78
|
+
export function installedUpstreamVersions(): Record<string, string | undefined> {
|
|
79
|
+
if (cachedVersions !== undefined) return cachedVersions
|
|
80
|
+
const result: Record<string, string | undefined> = {}
|
|
81
|
+
for (const packageName of UPSTREAM_BLESSED_PACKAGES) {
|
|
82
|
+
let version: string | undefined
|
|
83
|
+
const path = resolvePackageJson(packageName)
|
|
84
|
+
if (path !== undefined) {
|
|
85
|
+
try {
|
|
86
|
+
version = (JSON.parse(readFileSync(path, 'utf8')) as { version?: string }).version
|
|
87
|
+
} catch {
|
|
88
|
+
// An unreadable package.json counts as an unknown version.
|
|
89
|
+
version = undefined
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
result[packageName] = version
|
|
93
|
+
}
|
|
94
|
+
cachedVersions = Object.freeze(result)
|
|
95
|
+
return cachedVersions
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function installedUpstreamLines(
|
|
99
|
+
installedVersions: Readonly<Record<string, string | undefined>> = installedUpstreamVersions(),
|
|
100
|
+
): string[] {
|
|
101
|
+
const lines = new Set<string>()
|
|
102
|
+
for (const packageName of UPSTREAM_BLESSED_PACKAGES) {
|
|
103
|
+
if (UPSTREAM_FRAMEWORK_MAJORS[packageName] !== undefined) continue
|
|
104
|
+
const version = installedVersions[packageName]
|
|
105
|
+
if (version !== undefined && parseUpstreamVersion(version) !== undefined) lines.add(version)
|
|
106
|
+
}
|
|
107
|
+
return [...lines].sort((a, b) => compareVersions(parseUpstreamVersion(a)!, parseUpstreamVersion(b)!))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function upstreamDrift(
|
|
111
|
+
installedVersions: Readonly<Record<string, string | undefined>> = installedUpstreamVersions(),
|
|
112
|
+
): UpstreamDriftEntry[] {
|
|
113
|
+
const drift: UpstreamDriftEntry[] = []
|
|
114
|
+
for (const [packageName, installed] of Object.entries(installedVersions)) {
|
|
115
|
+
const frameworkMajor = UPSTREAM_FRAMEWORK_MAJORS[packageName]
|
|
116
|
+
const matches = frameworkMajor !== undefined
|
|
117
|
+
? Number((installed ?? '').split('.')[0]) === frameworkMajor
|
|
118
|
+
: isCompatibleUpstreamVersion(installed)
|
|
119
|
+
if (!matches) {
|
|
120
|
+
drift.push({
|
|
121
|
+
package: packageName,
|
|
122
|
+
installed,
|
|
123
|
+
expected: frameworkMajor !== undefined ? `major ${frameworkMajor}` : UPSTREAM_SUPPORTED_RANGE,
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return drift
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type UpstreamDriftKind = 'newer' | 'older' | 'mixed' | 'broken'
|
|
131
|
+
|
|
132
|
+
export interface UpstreamDriftSummary {
|
|
133
|
+
kind: UpstreamDriftKind
|
|
134
|
+
versions: string[]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function upstreamDriftSummary(
|
|
138
|
+
installedVersions: Readonly<Record<string, string | undefined>> = installedUpstreamVersions(),
|
|
139
|
+
): UpstreamDriftSummary | undefined {
|
|
140
|
+
const drift = upstreamDrift(installedVersions)
|
|
141
|
+
if (drift.length === 0) return undefined
|
|
142
|
+
const harness = drift.filter(entry => UPSTREAM_FRAMEWORK_MAJORS[entry.package] === undefined)
|
|
143
|
+
const versions = [...new Set(drift.map(entry => entry.installed ?? 'missing'))]
|
|
144
|
+
let kind: UpstreamDriftKind
|
|
145
|
+
if (harness.length === 0) {
|
|
146
|
+
kind = 'broken'
|
|
147
|
+
} else {
|
|
148
|
+
const parsed = harness
|
|
149
|
+
.map(entry => parseUpstreamVersion(entry.installed))
|
|
150
|
+
.filter((entry): entry is UpstreamVersionTuple => entry !== undefined)
|
|
151
|
+
if (parsed.length !== harness.length) {
|
|
152
|
+
kind = 'broken'
|
|
153
|
+
} else {
|
|
154
|
+
const floor = parseUpstreamVersion(UPSTREAM_FLOOR_VERSION)!
|
|
155
|
+
const ceiling = parseUpstreamVersion(UPSTREAM_CEILING_VERSION)!
|
|
156
|
+
const exclusiveCeiling: UpstreamVersionTuple = [ceiling[0], ceiling[1], ceiling[2], 'alpha', 0]
|
|
157
|
+
const allNewer = parsed.every(entry => compareVersions(entry, exclusiveCeiling) >= 0)
|
|
158
|
+
const allOlder = parsed.every(entry => compareVersions(entry, floor) < 0)
|
|
159
|
+
if (allNewer) kind = 'newer'
|
|
160
|
+
else if (allOlder) kind = 'older'
|
|
161
|
+
else kind = 'mixed'
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return { kind, versions }
|
|
165
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ink 7.1.1 places the caret one row high on the rewrite path; every caret
|
|
3
|
+
* move changes these frame bytes so the diff renderer stays on the rewrite
|
|
4
|
+
* path that useCaret() compensates for. The variant span exceeds any
|
|
5
|
+
* terminal width, so no frame follows the misplaced cursor-only path.
|
|
6
|
+
*/
|
|
7
|
+
const NONCE_SPAN = 240
|
|
8
|
+
|
|
9
|
+
export function caretNonceColor(cursor: number): string {
|
|
10
|
+
return `ansi256(${16 + (cursor % NONCE_SPAN)})`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function caretNonceText(cursor: number): string {
|
|
14
|
+
return Math.floor(cursor / NONCE_SPAN) % 2 === 0 ? ' ' : '\u00a0'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function caretNonceBold(cursor: number): boolean {
|
|
18
|
+
return Math.floor(cursor / (NONCE_SPAN * 2)) % 2 === 0
|
|
19
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { CHROME_FRAME_ROWS, INPUT_WIDTH_OFFSET } from './metrics.ts'
|
|
2
|
+
import { colToCharIndex, lineBreaks, locToPoint, textWidth, wrapLines } from './text.ts'
|
|
3
|
+
|
|
4
|
+
export const INPUT_MAX_CONTENT_ROWS = 8
|
|
5
|
+
|
|
6
|
+
export interface InputLayout {
|
|
7
|
+
lines: string[]
|
|
8
|
+
cursorRow: number
|
|
9
|
+
cursorCol: number
|
|
10
|
+
realRows: number
|
|
11
|
+
barHeight: number
|
|
12
|
+
visibleStart: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function inputLayout(value: string, cursor: number, width: number): InputLayout {
|
|
16
|
+
const contentWidth = width - INPUT_WIDTH_OFFSET
|
|
17
|
+
const lines: string[] = []
|
|
18
|
+
const segmentStarts: number[] = []
|
|
19
|
+
let charOffset = 0
|
|
20
|
+
for (const raw of value.split('\n')) {
|
|
21
|
+
const wrapped = wrapLines(raw, contentWidth)
|
|
22
|
+
let offset = 0
|
|
23
|
+
for (const segment of wrapped) {
|
|
24
|
+
lines.push(segment)
|
|
25
|
+
segmentStarts.push(charOffset + offset)
|
|
26
|
+
offset += segment.length
|
|
27
|
+
}
|
|
28
|
+
charOffset += raw.length + 1
|
|
29
|
+
}
|
|
30
|
+
const clamped = Math.max(0, Math.min(cursor, value.length))
|
|
31
|
+
let cursorRow = lines.length - 1
|
|
32
|
+
let cursorCol = 0
|
|
33
|
+
for (let i = 0; i < lines.length; i++) {
|
|
34
|
+
const start = segmentStarts[i]!
|
|
35
|
+
const end = start + lines[i]!.length
|
|
36
|
+
if (clamped < end || i === lines.length - 1 || clamped < segmentStarts[i + 1]!) {
|
|
37
|
+
cursorRow = i
|
|
38
|
+
cursorCol = textWidth(lines[i]!.slice(0, clamped - start))
|
|
39
|
+
break
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const realRows = Math.min(lines.length, INPUT_MAX_CONTENT_ROWS - 1)
|
|
43
|
+
const maxVisibleReal = INPUT_MAX_CONTENT_ROWS - 1
|
|
44
|
+
const visibleStart = Math.min(
|
|
45
|
+
Math.max(0, cursorRow - (maxVisibleReal - 1)),
|
|
46
|
+
Math.max(0, lines.length - maxVisibleReal),
|
|
47
|
+
)
|
|
48
|
+
return {
|
|
49
|
+
lines,
|
|
50
|
+
cursorRow,
|
|
51
|
+
cursorCol,
|
|
52
|
+
realRows,
|
|
53
|
+
barHeight: realRows + CHROME_FRAME_ROWS,
|
|
54
|
+
visibleStart,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function moveCaretLine(value: string, cursor: number, wrapWidth: number, delta: -1 | 1): number {
|
|
59
|
+
const breaks = lineBreaks(value, wrapWidth)
|
|
60
|
+
const point = locToPoint(value, wrapWidth, cursor)
|
|
61
|
+
const targetRow = point.row + delta
|
|
62
|
+
if (targetRow < 0 || targetRow >= breaks.length) return cursor
|
|
63
|
+
const target = breaks[targetRow]!
|
|
64
|
+
return target.start + colToCharIndex(value.slice(target.start, target.end), point.col)
|
|
65
|
+
}
|
package/src/core/edit.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { segmentGraphemes } from './text.ts'
|
|
2
|
+
|
|
3
|
+
export interface EditState {
|
|
4
|
+
value: string
|
|
5
|
+
cursor: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function clamp(state: EditState, cursor: number): EditState {
|
|
9
|
+
return { value: state.value, cursor: Math.max(0, Math.min(cursor, state.value.length)) }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Span {
|
|
13
|
+
start: number
|
|
14
|
+
end: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function spanAtOrBefore(value: string, cursor: number): Span | undefined {
|
|
18
|
+
for (const segment of segmentGraphemes(value)) {
|
|
19
|
+
const end = segment.index + segment.segment.length
|
|
20
|
+
if (end >= cursor) return { start: segment.index, end }
|
|
21
|
+
}
|
|
22
|
+
return undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function spanAtOrAfter(value: string, cursor: number): Span | undefined {
|
|
26
|
+
for (const segment of segmentGraphemes(value)) {
|
|
27
|
+
if (segment.index >= cursor) return { start: segment.index, end: segment.index + segment.segment.length }
|
|
28
|
+
}
|
|
29
|
+
return undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function editInsert(state: EditState, text: string): EditState {
|
|
33
|
+
if (text === '') return clamp(state, state.cursor)
|
|
34
|
+
const cursor = Math.max(0, Math.min(state.cursor, state.value.length))
|
|
35
|
+
return {
|
|
36
|
+
value: state.value.slice(0, cursor) + text + state.value.slice(cursor),
|
|
37
|
+
cursor: cursor + text.length,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function editBackspace(state: EditState): EditState | null {
|
|
42
|
+
if (state.cursor <= 0) return null
|
|
43
|
+
const span = spanAtOrBefore(state.value, state.cursor)
|
|
44
|
+
if (span === undefined) return null
|
|
45
|
+
return {
|
|
46
|
+
value: state.value.slice(0, span.start) + state.value.slice(span.end),
|
|
47
|
+
cursor: span.start,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function editDelete(state: EditState): EditState | null {
|
|
52
|
+
if (state.cursor >= state.value.length) return null
|
|
53
|
+
const span = spanAtOrAfter(state.value, state.cursor)
|
|
54
|
+
if (span === undefined) return null
|
|
55
|
+
return {
|
|
56
|
+
value: state.value.slice(0, state.cursor) + state.value.slice(span.end),
|
|
57
|
+
cursor: state.cursor,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function editCursorLeft(state: EditState): EditState {
|
|
62
|
+
if (state.cursor <= 0) return state
|
|
63
|
+
const span = spanAtOrBefore(state.value, state.cursor)
|
|
64
|
+
return clamp(state, span === undefined ? state.cursor - 1 : span.start)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function editCursorRight(state: EditState): EditState {
|
|
68
|
+
if (state.cursor >= state.value.length) return state
|
|
69
|
+
const span = spanAtOrAfter(state.value, state.cursor)
|
|
70
|
+
return clamp(state, span === undefined ? state.cursor + 1 : span.end)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const SEPARATOR_RANGES: ReadonlyArray<readonly [number, number]> = [
|
|
74
|
+
[0x2010, 0x2027],
|
|
75
|
+
[0x3000, 0x303f],
|
|
76
|
+
[0xff01, 0xff0f],
|
|
77
|
+
[0xff1a, 0xff20],
|
|
78
|
+
[0xff3b, 0xff40],
|
|
79
|
+
[0xff5b, 0xff65],
|
|
80
|
+
[0xffe0, 0xffe4],
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
function isAsciiWordChar(code: number): boolean {
|
|
84
|
+
return (code >= 97 && code <= 122) || (code >= 65 && code <= 90) || (code >= 48 && code <= 57) || code === 95
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isLineBreakAt(value: string, index: number): boolean {
|
|
88
|
+
const code = value.charCodeAt(index)
|
|
89
|
+
return code === 10 || code === 13
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isSeparatorAt(value: string, index: number): boolean {
|
|
93
|
+
const code = value.charCodeAt(index)
|
|
94
|
+
if (code === 10 || code === 13) return true
|
|
95
|
+
if (isAsciiWordChar(code)) return false
|
|
96
|
+
if (code >= 0x80) {
|
|
97
|
+
for (const [start, end] of SEPARATOR_RANGES) {
|
|
98
|
+
if (code >= start && code <= end) return true
|
|
99
|
+
}
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
if (code === 0x20) return true
|
|
103
|
+
return code >= 0x21 && code <= 0x7e
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function wordLeftBoundary(value: string, cursor: number): number {
|
|
107
|
+
let i = cursor - 1
|
|
108
|
+
while (i >= 0 && !isLineBreakAt(value, i) && isSeparatorAt(value, i)) i--
|
|
109
|
+
while (i >= 0 && !isSeparatorAt(value, i)) i--
|
|
110
|
+
return i + 1
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function wordRightBoundary(value: string, cursor: number): number {
|
|
114
|
+
if (cursor >= value.length) return cursor
|
|
115
|
+
if (!isSeparatorAt(value, cursor)) {
|
|
116
|
+
let i = cursor
|
|
117
|
+
while (i < value.length && !isSeparatorAt(value, i)) i++
|
|
118
|
+
return i
|
|
119
|
+
}
|
|
120
|
+
let i = cursor
|
|
121
|
+
while (i < value.length && !isLineBreakAt(value, i) && isSeparatorAt(value, i)) i++
|
|
122
|
+
while (i < value.length && !isSeparatorAt(value, i)) i++
|
|
123
|
+
return i
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function editCursorWordLeft(state: EditState): EditState {
|
|
127
|
+
const boundary = wordLeftBoundary(state.value, state.cursor)
|
|
128
|
+
return boundary >= state.cursor ? state : { value: state.value, cursor: boundary }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function editCursorWordRight(state: EditState): EditState {
|
|
132
|
+
const boundary = wordRightBoundary(state.value, state.cursor)
|
|
133
|
+
return boundary <= state.cursor ? state : { value: state.value, cursor: boundary }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function editDeleteWordLeft(state: EditState): EditState | null {
|
|
137
|
+
const boundary = wordLeftBoundary(state.value, state.cursor)
|
|
138
|
+
if (boundary >= state.cursor) return null
|
|
139
|
+
return {
|
|
140
|
+
value: state.value.slice(0, boundary) + state.value.slice(state.cursor),
|
|
141
|
+
cursor: boundary,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function editDeleteWordRight(state: EditState): EditState | null {
|
|
146
|
+
const boundary = wordRightBoundary(state.value, state.cursor)
|
|
147
|
+
if (boundary <= state.cursor) return null
|
|
148
|
+
return {
|
|
149
|
+
value: state.value.slice(0, state.cursor) + state.value.slice(boundary),
|
|
150
|
+
cursor: state.cursor,
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { fieldSlotOf, fieldStyleOf, hasFieldChar, isFieldChar } from './fields.ts'
|
|
2
|
+
import type { FieldStyle } from './fields.ts'
|
|
3
|
+
import type { MarkStyle, Segment } from './segments.ts'
|
|
4
|
+
|
|
5
|
+
export function expandFieldChars(text: string): string {
|
|
6
|
+
if (!hasFieldChar(text)) return text
|
|
7
|
+
let out = ''
|
|
8
|
+
for (const char of text) {
|
|
9
|
+
if (!isFieldChar(char)) {
|
|
10
|
+
out += char
|
|
11
|
+
continue
|
|
12
|
+
}
|
|
13
|
+
const slot = fieldSlotOf(char)
|
|
14
|
+
// A slotless field char (reclaimed or foreign sentinel) would render as
|
|
15
|
+
// a raw block glyph; drop it instead of emitting the code point.
|
|
16
|
+
out += slot === undefined ? '' : slot.label
|
|
17
|
+
}
|
|
18
|
+
return out
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function fieldRowSegments(text: string, base?: MarkStyle): Segment[] {
|
|
22
|
+
const out: Segment[] = []
|
|
23
|
+
let plain = ''
|
|
24
|
+
const flushPlain = (): void => {
|
|
25
|
+
if (plain === '') return
|
|
26
|
+
out.push({ text: plain, style: base ?? {} })
|
|
27
|
+
plain = ''
|
|
28
|
+
}
|
|
29
|
+
for (let i = 0; i < text.length; i++) {
|
|
30
|
+
const char = text[i]!
|
|
31
|
+
const slot = fieldSlotOf(char)
|
|
32
|
+
if (slot === undefined) {
|
|
33
|
+
// A slotless field char renders as a raw block glyph; omit it and keep
|
|
34
|
+
// the surrounding plain run merged.
|
|
35
|
+
if (!isFieldChar(char)) plain += char
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
flushPlain()
|
|
39
|
+
const style = fieldStyleOf(slot)
|
|
40
|
+
out.push({ text: slot.label, style: styleToMark(style) })
|
|
41
|
+
}
|
|
42
|
+
flushPlain()
|
|
43
|
+
return out
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function styleToMark(style: FieldStyle): MarkStyle {
|
|
47
|
+
return {
|
|
48
|
+
color: style.color,
|
|
49
|
+
background: style.background,
|
|
50
|
+
...(style.bold === true ? { bold: true } : {}),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import stringWidth from 'string-width'
|
|
2
|
+
import { COLORS } from '../theme.ts'
|
|
3
|
+
|
|
4
|
+
const FIELD_CHAR_BASE = 0xe000
|
|
5
|
+
const FIELD_CHAR_END = 0xf8ff
|
|
6
|
+
const FIELD_CHAR_COUNT = FIELD_CHAR_END - FIELD_CHAR_BASE + 1
|
|
7
|
+
|
|
8
|
+
export type FieldOwner = 'composer' | 'message'
|
|
9
|
+
|
|
10
|
+
export interface FieldStyle {
|
|
11
|
+
color: string
|
|
12
|
+
background: string
|
|
13
|
+
bold?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface FieldKindDef {
|
|
17
|
+
kind: string
|
|
18
|
+
style(): FieldStyle
|
|
19
|
+
/** Text injected into the sent message for a field of this kind; defaults to the label. */
|
|
20
|
+
expand?(data: unknown): string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SpecialFieldCreateInput {
|
|
24
|
+
kind: string
|
|
25
|
+
label: string
|
|
26
|
+
owner?: FieldOwner
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SpecialFieldFactory {
|
|
30
|
+
create(input: SpecialFieldCreateInput): string | null
|
|
31
|
+
release(char: string): void
|
|
32
|
+
/** Protect a chip from the automatic reclaim scans until the disposer runs. */
|
|
33
|
+
pin(char: string): (() => void) | undefined
|
|
34
|
+
isFieldChar(char: string): boolean
|
|
35
|
+
labelOf(char: string): string | undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface FieldSlot {
|
|
39
|
+
kind: string
|
|
40
|
+
label: string
|
|
41
|
+
owner: FieldOwner
|
|
42
|
+
pins: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const slots = new Map<number, FieldSlot>()
|
|
46
|
+
const kindDefs = new Map<string, FieldKindDef>()
|
|
47
|
+
const labelWidthCache = new Map<string, number>()
|
|
48
|
+
let cursor = 0
|
|
49
|
+
|
|
50
|
+
export function specialFieldStyle(): FieldStyle {
|
|
51
|
+
return { color: COLORS.specialFieldText, background: COLORS.specialFieldBackground, bold: true }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function registerFieldKind(def: FieldKindDef): () => void {
|
|
55
|
+
kindDefs.set(def.kind, def)
|
|
56
|
+
return () => {
|
|
57
|
+
if (kindDefs.get(def.kind) === def) kindDefs.delete(def.kind)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function fieldStyleOf(slot: FieldSlot): FieldStyle {
|
|
62
|
+
const def = kindDefs.get(slot.kind)
|
|
63
|
+
if (def !== undefined) return def.style()
|
|
64
|
+
return specialFieldStyle()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function fieldExpandOf(kind: string): ((data: unknown) => string) | undefined {
|
|
68
|
+
return kindDefs.get(kind)?.expand
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function specialFieldFactory(): SpecialFieldFactory {
|
|
72
|
+
return {
|
|
73
|
+
create: input => allocateField(input.kind, input.label, input.owner ?? 'composer'),
|
|
74
|
+
release: releaseField,
|
|
75
|
+
pin: pinField,
|
|
76
|
+
isFieldChar,
|
|
77
|
+
labelOf: char => fieldSlotOf(char)?.label,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function isFieldCode(code: number): boolean {
|
|
82
|
+
return code >= FIELD_CHAR_BASE && code <= FIELD_CHAR_END
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function hasFieldChar(text: string): boolean {
|
|
86
|
+
for (let i = 0; i < text.length; i++) {
|
|
87
|
+
if (isFieldCode(text.charCodeAt(i))) return true
|
|
88
|
+
}
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isFieldChar(char: string): boolean {
|
|
93
|
+
return char.length === 1 && isFieldCode(char.charCodeAt(0))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function fieldCodeWidth(code: number): number {
|
|
97
|
+
const slot = slots.get(code - FIELD_CHAR_BASE)
|
|
98
|
+
if (slot === undefined) return 1
|
|
99
|
+
return labelWidth(slot.label)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function labelWidth(label: string): number {
|
|
103
|
+
const hit = labelWidthCache.get(label)
|
|
104
|
+
if (hit !== undefined) return hit
|
|
105
|
+
const width = stringWidth(label)
|
|
106
|
+
if (labelWidthCache.size >= 4096) labelWidthCache.clear()
|
|
107
|
+
labelWidthCache.set(label, width)
|
|
108
|
+
return width
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function allocateField(kind: string, label: string, owner: FieldOwner): string | null {
|
|
112
|
+
for (let i = 0; i < FIELD_CHAR_COUNT; i++) {
|
|
113
|
+
const index = (cursor + i) % FIELD_CHAR_COUNT
|
|
114
|
+
if (slots.has(index)) continue
|
|
115
|
+
cursor = (index + 1) % FIELD_CHAR_COUNT
|
|
116
|
+
slots.set(index, { kind, label, owner, pins: 0 })
|
|
117
|
+
return String.fromCharCode(FIELD_CHAR_BASE + index)
|
|
118
|
+
}
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function fieldSlotOf(char: string): FieldSlot | undefined {
|
|
123
|
+
if (char.length !== 1) return undefined
|
|
124
|
+
const code = char.charCodeAt(0)
|
|
125
|
+
if (!isFieldCode(code)) return undefined
|
|
126
|
+
return slots.get(code - FIELD_CHAR_BASE)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function releaseField(char: string): void {
|
|
130
|
+
if (char.length !== 1) return
|
|
131
|
+
const index = char.charCodeAt(0) - FIELD_CHAR_BASE
|
|
132
|
+
const slot = slots.get(index)
|
|
133
|
+
if (slot === undefined || slot.pins > 0) return
|
|
134
|
+
slots.delete(index)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Hold a field slot against the automatic reclaim scans (composer edits and
|
|
139
|
+
* message-list rebuilds drop every slot whose char is absent from the live
|
|
140
|
+
* text). Callers that create chips outside the composer value — custom
|
|
141
|
+
* windows, overlays, background tasks — must pin them and call the returned
|
|
142
|
+
* disposer when done.
|
|
143
|
+
*/
|
|
144
|
+
export function pinField(char: string): (() => void) | undefined {
|
|
145
|
+
if (char.length !== 1) return undefined
|
|
146
|
+
const slot = slots.get(char.charCodeAt(0) - FIELD_CHAR_BASE)
|
|
147
|
+
if (slot === undefined) return undefined
|
|
148
|
+
slot.pins += 1
|
|
149
|
+
return () => {
|
|
150
|
+
slot.pins -= 1
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function hasFieldSlots(owner: FieldOwner): boolean {
|
|
155
|
+
for (const slot of slots.values()) {
|
|
156
|
+
if (slot.owner === owner) return true
|
|
157
|
+
}
|
|
158
|
+
return false
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function releaseUnreferenced(owner: FieldOwner, keepText: string): void {
|
|
162
|
+
for (const [index, slot] of slots) {
|
|
163
|
+
if (slot.owner !== owner || slot.pins > 0) continue
|
|
164
|
+
const char = String.fromCharCode(FIELD_CHAR_BASE + index)
|
|
165
|
+
if (!keepText.includes(char)) slots.delete(index)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function releaseFields(owner: FieldOwner): void {
|
|
170
|
+
for (const [index, slot] of slots) {
|
|
171
|
+
if (slot.owner === owner && slot.pins === 0) slots.delete(index)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function imageChipLabel(count: number): string {
|
|
176
|
+
return `[${count} images]`
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function linesChipLabel(count: number): string {
|
|
180
|
+
return `[${count} lines]`
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function charactersChipLabel(count: number): string {
|
|
184
|
+
return `[${count} characters]`
|
|
185
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const CHROME_MARGIN_X = 2
|
|
2
|
+
export const CHROME_PAD_X = 2
|
|
3
|
+
export const CHROME_TEXT_X = CHROME_MARGIN_X + CHROME_PAD_X
|
|
4
|
+
export const CHROME_FRAME_ROWS = 4
|
|
5
|
+
|
|
6
|
+
export const MESSAGE_INPUT_GAP_ROWS = 1
|
|
7
|
+
export const HINT_INPUT_GAP_ROWS = 1
|
|
8
|
+
|
|
9
|
+
export const INPUT_WIDTH_OFFSET = CHROME_MARGIN_X * 2 + CHROME_PAD_X * 2
|
|
10
|
+
|
|
11
|
+
export const BUBBLE_WIDTH_OFFSET = 8
|
|
12
|
+
export const HEADER_LABEL_COL = 4
|
|
13
|
+
export const SCROLLBAR_COL_FROM_EDGE = 3
|
|
14
|
+
export const SCROLLBAR_GAP_COLS = 1
|
|
15
|
+
|
|
16
|
+
export function inputFrameTop(rows: number, realRows: number): number {
|
|
17
|
+
return rows - CHROME_FRAME_ROWS - realRows
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function inputStatusRow(rows: number): number {
|
|
21
|
+
return rows - CHROME_FRAME_ROWS + 1
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function hintBlockTop(rows: number, inputHeight: number, visibleCount: number): number {
|
|
25
|
+
return Math.max(0, rows - inputHeight - MESSAGE_INPUT_GAP_ROWS - visibleCount)
|
|
26
|
+
}
|