@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.
Files changed (159) hide show
  1. package/bin/dshtui.js +65 -0
  2. package/cordis.patch.yml +81 -0
  3. package/lib/contract/index.d.mts +2 -0
  4. package/lib/contract/index.mjs +5 -0
  5. package/lib/dialog.d.mts +156 -0
  6. package/lib/dialog.mjs +3 -0
  7. package/lib/index-DqnaSXqP.d.mts +823 -0
  8. package/lib/index.d.mts +17 -0
  9. package/lib/index.mjs +11404 -0
  10. package/lib/prod-react-Dzof4qJx.mjs +4 -0
  11. package/lib/surface-DdxzIgIY.mjs +3126 -0
  12. package/lib/vendor.d.mts +3 -0
  13. package/lib/vendor.mjs +4 -0
  14. package/package.json +128 -0
  15. package/src/apply-theme.ts +8 -0
  16. package/src/boot-log.ts +35 -0
  17. package/src/chat/blocks.ts +41 -0
  18. package/src/chat/bridge.ts +1076 -0
  19. package/src/chat/builtin-tool-views.ts +53 -0
  20. package/src/chat/chat-face.ts +16 -0
  21. package/src/chat/chat-nodes.ts +53 -0
  22. package/src/chat/efforts.ts +4 -0
  23. package/src/chat/interactions.ts +329 -0
  24. package/src/chat/models.ts +263 -0
  25. package/src/chat/partial-json.ts +125 -0
  26. package/src/chat/presets.ts +26 -0
  27. package/src/chat/question-view.ts +106 -0
  28. package/src/chat/retry-status.ts +33 -0
  29. package/src/chat/session-groups.ts +34 -0
  30. package/src/chat/session-list.ts +311 -0
  31. package/src/chat/store.ts +636 -0
  32. package/src/chat/todo-view.ts +66 -0
  33. package/src/chat/tool-view.ts +311 -0
  34. package/src/chat/tool-views.ts +114 -0
  35. package/src/contract/index.ts +704 -0
  36. package/src/contract/upstream.ts +165 -0
  37. package/src/core/caret-nonce.ts +19 -0
  38. package/src/core/composer-layout.ts +65 -0
  39. package/src/core/edit.ts +152 -0
  40. package/src/core/field-view.ts +52 -0
  41. package/src/core/fields.ts +185 -0
  42. package/src/core/metrics.ts +26 -0
  43. package/src/core/paste.ts +149 -0
  44. package/src/core/segments.ts +112 -0
  45. package/src/core/text.ts +290 -0
  46. package/src/dialog.ts +11 -0
  47. package/src/env.ts +38 -0
  48. package/src/harness-home.ts +22 -0
  49. package/src/hot-theme.ts +56 -0
  50. package/src/index.tsx +185 -0
  51. package/src/kernel/registry.ts +102 -0
  52. package/src/kernel/surface.ts +33 -0
  53. package/src/log.ts +249 -0
  54. package/src/model/message.ts +89 -0
  55. package/src/model/selection.ts +63 -0
  56. package/src/performance-guard.ts +28 -0
  57. package/src/runtime/prod-react.ts +10 -0
  58. package/src/terminal/background.ts +66 -0
  59. package/src/terminal/capabilities.ts +54 -0
  60. package/src/terminal/clipboard-backends.ts +69 -0
  61. package/src/terminal/clipboard.ts +161 -0
  62. package/src/terminal/cursor-shape.ts +13 -0
  63. package/src/terminal/glyphs.ts +44 -0
  64. package/src/terminal/mouse.ts +154 -0
  65. package/src/terminal/probe.ts +61 -0
  66. package/src/terminal/screen.ts +349 -0
  67. package/src/theme-settings.ts +31 -0
  68. package/src/theme.ts +319 -0
  69. package/src/ui/app.tsx +454 -0
  70. package/src/ui/chrome/caps.tsx +9 -0
  71. package/src/ui/chrome/hint-service.ts +87 -0
  72. package/src/ui/chrome/input-status.ts +27 -0
  73. package/src/ui/chrome/key-gate.tsx +29 -0
  74. package/src/ui/chrome/status-bar.tsx +147 -0
  75. package/src/ui/contributions.ts +47 -0
  76. package/src/ui/dialog/defaults-dialog.tsx +152 -0
  77. package/src/ui/dialog/dialog-item.tsx +38 -0
  78. package/src/ui/dialog/dialog.tsx +386 -0
  79. package/src/ui/dialog/effort-dialog.tsx +45 -0
  80. package/src/ui/dialog/geometry.ts +131 -0
  81. package/src/ui/dialog/items.ts +124 -0
  82. package/src/ui/dialog/list-dialog.tsx +63 -0
  83. package/src/ui/dialog/models-dialog.tsx +203 -0
  84. package/src/ui/dialog/presets-dialog.tsx +46 -0
  85. package/src/ui/dialog/providers-dialog.tsx +313 -0
  86. package/src/ui/dialog/sessions-dialog.tsx +163 -0
  87. package/src/ui/dialog/sizes.ts +7 -0
  88. package/src/ui/dialog/status-lines.ts +11 -0
  89. package/src/ui/dialog/todo-dialog.tsx +29 -0
  90. package/src/ui/dialog/use-dialog-input.ts +186 -0
  91. package/src/ui/extension-point.ts +188 -0
  92. package/src/ui/home-logo.ts +132 -0
  93. package/src/ui/hooks/use-async-action.ts +25 -0
  94. package/src/ui/hooks/use-async-list.ts +71 -0
  95. package/src/ui/hooks/use-caret.ts +24 -0
  96. package/src/ui/hooks/use-chat-events.ts +153 -0
  97. package/src/ui/hooks/use-mouse-selection.ts +352 -0
  98. package/src/ui/hooks/use-scroll.ts +49 -0
  99. package/src/ui/hooks/use-terminal-size.ts +20 -0
  100. package/src/ui/input/commands.ts +118 -0
  101. package/src/ui/input/composer-bus.ts +14 -0
  102. package/src/ui/input/composer-fields.ts +111 -0
  103. package/src/ui/input/composer-keys.ts +21 -0
  104. package/src/ui/input/composer-paste.ts +20 -0
  105. package/src/ui/input/input-bar.tsx +279 -0
  106. package/src/ui/input/use-composer.ts +495 -0
  107. package/src/ui/key-arbiter.ts +37 -0
  108. package/src/ui/keymap.ts +15 -0
  109. package/src/ui/layout-service.ts +77 -0
  110. package/src/ui/message/layout.ts +763 -0
  111. package/src/ui/message/md/block.ts +247 -0
  112. package/src/ui/message/md/engine.ts +430 -0
  113. package/src/ui/message/md/extensions.ts +58 -0
  114. package/src/ui/message/md/highlight.ts +626 -0
  115. package/src/ui/message/md/index.ts +14 -0
  116. package/src/ui/message/md/inline.ts +97 -0
  117. package/src/ui/message/md/palette.ts +87 -0
  118. package/src/ui/message/message-list.tsx +69 -0
  119. package/src/ui/message/message-row.tsx +137 -0
  120. package/src/ui/message/message-views.ts +16 -0
  121. package/src/ui/message/renderers.ts +39 -0
  122. package/src/ui/message/tool-diff.ts +188 -0
  123. package/src/ui/message/warmup.ts +41 -0
  124. package/src/ui/message/wave.ts +98 -0
  125. package/src/ui/overlay.ts +22 -0
  126. package/src/ui/panels/approval-panel.tsx +193 -0
  127. package/src/ui/panels/exports.ts +3 -0
  128. package/src/ui/panels/question-model.ts +424 -0
  129. package/src/ui/panels/question-panel.tsx +154 -0
  130. package/src/ui/panels/surface.tsx +106 -0
  131. package/src/ui/panels-builtin.tsx +86 -0
  132. package/src/ui/pointer/builtins.ts +281 -0
  133. package/src/ui/pointer/registry.ts +25 -0
  134. package/src/ui/region.tsx +31 -0
  135. package/src/ui/selection/service.ts +92 -0
  136. package/src/ui/selection-registry.ts +88 -0
  137. package/src/ui/selection.tsx +148 -0
  138. package/src/ui/spinner-tick.tsx +24 -0
  139. package/src/ui/use-available-windows.ts +31 -0
  140. package/src/ui/widgets/actions.tsx +84 -0
  141. package/src/ui/widgets/button.tsx +57 -0
  142. package/src/ui/widgets/checkbox.tsx +58 -0
  143. package/src/ui/widgets/header.tsx +36 -0
  144. package/src/ui/widgets/index.ts +8 -0
  145. package/src/ui/widgets/input.tsx +76 -0
  146. package/src/ui/widgets/registry.ts +32 -0
  147. package/src/ui/widgets/search.tsx +59 -0
  148. package/src/ui/widgets/select.tsx +97 -0
  149. package/src/ui/widgets/static.tsx +37 -0
  150. package/src/ui/widgets/types.ts +1 -0
  151. package/src/ui/window-services-bridge.ts +71 -0
  152. package/src/ui/window-services.ts +20 -0
  153. package/src/ui/windows/models-window.tsx +15 -0
  154. package/src/ui/windows/providers-window.tsx +14 -0
  155. package/src/ui/windows/service-window.tsx +18 -0
  156. package/src/ui/windows/sessions-window.tsx +25 -0
  157. package/src/ui/windows-builtin.tsx +94 -0
  158. package/src/ui/windows.ts +18 -0
  159. 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
+ }
@@ -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
+ }