@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,132 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { useMemo, useSyncExternalStore } from 'react'
|
|
4
|
+
import { installedUpstreamLines } from '../contract/upstream.ts'
|
|
5
|
+
import { BUBBLE_WIDTH_OFFSET } from '../core/metrics.ts'
|
|
6
|
+
import type { HomeLogoMessage } from '../model/message.ts'
|
|
7
|
+
import { keyedRegistry } from '../kernel/registry.ts'
|
|
8
|
+
import { textWidth } from '../core/text.ts'
|
|
9
|
+
import { COLORS } from '../theme.ts'
|
|
10
|
+
|
|
11
|
+
export interface HomeLogoContribution {
|
|
12
|
+
id: string
|
|
13
|
+
order?: number
|
|
14
|
+
/** Logo artwork, one string per terminal row. */
|
|
15
|
+
lines: readonly string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const BUILTIN_LOGO_LINES = [
|
|
19
|
+
'▀█▀▀▄ ▄▀▀▀▄ ▀█',
|
|
20
|
+
' █ █ ▄▀▀▀▄ ▄▀▀▀▄ ▀█▀▀▄ ▀▄▄▄ ▄▀▀▀▄ ▄▀▀▀▄ █ ▄▀',
|
|
21
|
+
' █ █ █▀▀▀▀ █▀▀▀▀ █▄▄▀ ▄ █ █▀▀▀▀ █▀▀▀▀ █▀▄',
|
|
22
|
+
'▀▀▀▀ ▀▀▀ ▀▀▀ █ ▀▀▀ ▀▀▀ ▀▀▀ ▀▀ ▀',
|
|
23
|
+
'█ █ ▀▀▀',
|
|
24
|
+
'█▄▄▄█ ▀▀▀▄ ▀█▄▀▄ █▄▀▀▄ ▄▀▀▀▄ ▄▀▀▀ ▄▀▀▀',
|
|
25
|
+
'█ █ ▄▀▀█ █ ▀ █ █ █▀▀▀▀ ▀▀▄ ▀▀▄',
|
|
26
|
+
'▀ ▀ ▀▀ ▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀▀▀',
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
const SLOT = 'home-logo'
|
|
30
|
+
const HOME_LOGO_BUILTIN_ORDER = 1000
|
|
31
|
+
const registry = keyedRegistry<HomeLogoContribution>()
|
|
32
|
+
|
|
33
|
+
registry.register(SLOT, { id: 'builtin', order: HOME_LOGO_BUILTIN_ORDER, lines: BUILTIN_LOGO_LINES }, { order: HOME_LOGO_BUILTIN_ORDER })
|
|
34
|
+
|
|
35
|
+
export function registerHomeLogo(contribution: HomeLogoContribution): () => void {
|
|
36
|
+
return registry.register(SLOT, contribution, { order: contribution.order })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function currentHomeLogo(): HomeLogoContribution | undefined {
|
|
40
|
+
return registry.get(SLOT)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function subscribeHomeLogo(listener: () => void): () => void {
|
|
44
|
+
return registry.subscribe(listener)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function useHomeLogo(): HomeLogoContribution | undefined {
|
|
48
|
+
return useSyncExternalStore(subscribeHomeLogo, currentHomeLogo, currentHomeLogo)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Mix two hex colors linearly; t=0 keeps `from`, t=1 keeps `to`. */
|
|
52
|
+
export function mixHex(from: string, to: string, t: number): string {
|
|
53
|
+
const clamp = Math.max(0, Math.min(1, t))
|
|
54
|
+
const mix = (at: number): string => {
|
|
55
|
+
const a = parseInt(from.slice(at, at + 2), 16)
|
|
56
|
+
const b = parseInt(to.slice(at, at + 2), 16)
|
|
57
|
+
return Math.round(a + (b - a) * clamp).toString(16).padStart(2, '0')
|
|
58
|
+
}
|
|
59
|
+
return `#${mix(1)}${mix(3)}${mix(5)}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface HomeLogoMetrics {
|
|
63
|
+
rows: number
|
|
64
|
+
columns: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Trimmed display extent of the artwork; used for the small-terminal guard. */
|
|
68
|
+
export function homeLogoMetrics(lines: readonly string[]): HomeLogoMetrics {
|
|
69
|
+
let columns = 0
|
|
70
|
+
for (const raw of lines) {
|
|
71
|
+
const width = textWidth(raw.trimEnd())
|
|
72
|
+
if (width > columns) columns = width
|
|
73
|
+
}
|
|
74
|
+
return { rows: lines.length, columns }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One color per artwork row, graded from `top` to `bottom`. */
|
|
78
|
+
export function homeLogoLineColors(lines: readonly string[], top: string, bottom: string): string[] {
|
|
79
|
+
const count = lines.length
|
|
80
|
+
if (count === 0) return []
|
|
81
|
+
if (count === 1) return [top]
|
|
82
|
+
const step = 1 / (count - 1)
|
|
83
|
+
return lines.map((_, i) => mixHex(top, bottom, i * step))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const HOME_LOGO_ID = 'home-logo'
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The home page presents its content in chat-page style: the logo artwork is
|
|
90
|
+
* the first bubble, left-aligned with a transparent background, followed by
|
|
91
|
+
* version banner lines inside the same bubble. `width` is the full list
|
|
92
|
+
* width and `height` the visible viewport; when either cannot hold the
|
|
93
|
+
* artwork, only the artwork lines are culled and the bubble stays.
|
|
94
|
+
*/
|
|
95
|
+
export function useHomeLogoBubble(width: number, height: number): HomeLogoMessage {
|
|
96
|
+
const logo = useHomeLogo()
|
|
97
|
+
return useMemo(() => homeLogoBubble(logo, width, height), [logo, width, height])
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function homeLogoBubble(logo: HomeLogoContribution | undefined, width: number, height: number): HomeLogoMessage {
|
|
101
|
+
const clean = logo?.lines.map(line => line.trimEnd()) ?? []
|
|
102
|
+
const metrics = homeLogoMetrics(clean)
|
|
103
|
+
const fits = clean.length > 0 && width - BUBBLE_WIDTH_OFFSET >= metrics.columns && height >= metrics.rows
|
|
104
|
+
return {
|
|
105
|
+
kind: 'home-logo',
|
|
106
|
+
id: HOME_LOGO_ID,
|
|
107
|
+
lines: fits ? clean : [],
|
|
108
|
+
colors: fits ? homeLogoLineColors(clean, COLORS.homeLogoTop, COLORS.homeLogoBottom) : [],
|
|
109
|
+
info: [dshVersionLine(), `dshtui ${tuiVersion()}`],
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function dshVersionLine(): string {
|
|
114
|
+
// dsh-agent itself does not export ./package.json, so the version comes
|
|
115
|
+
// from the lockstepped harness line any sibling package exposes.
|
|
116
|
+
return `dsh ${installedUpstreamLines()[0] ?? 'unknown'}`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let cachedTuiVersion: string | undefined
|
|
120
|
+
|
|
121
|
+
function tuiVersion(): string {
|
|
122
|
+
if (cachedTuiVersion !== undefined) return cachedTuiVersion
|
|
123
|
+
try {
|
|
124
|
+
const path = fileURLToPath(import.meta.resolve('@xp266/dshtui/package.json'))
|
|
125
|
+
const manifest = JSON.parse(readFileSync(path, 'utf8')) as { version?: string }
|
|
126
|
+
cachedTuiVersion = manifest.version ?? 'unknown'
|
|
127
|
+
} catch {
|
|
128
|
+
// The manifest may be unreadable in unusual install layouts; the bubble still renders.
|
|
129
|
+
cachedTuiVersion = 'unknown'
|
|
130
|
+
}
|
|
131
|
+
return cachedTuiVersion
|
|
132
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react'
|
|
2
|
+
import { errorText } from '../../core/text.ts'
|
|
3
|
+
|
|
4
|
+
export type AsyncActionResult<T> = { ok: true; value: T } | { ok: false }
|
|
5
|
+
|
|
6
|
+
export interface AsyncActionState {
|
|
7
|
+
error: string | null
|
|
8
|
+
setError(error: string | null): void
|
|
9
|
+
clearError(): void
|
|
10
|
+
run<T>(action: () => Promise<T>): Promise<AsyncActionResult<T>>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useAsyncAction(): AsyncActionState {
|
|
14
|
+
const [error, setError] = useState<string | null>(null)
|
|
15
|
+
const clearError = useCallback(() => setError(null), [])
|
|
16
|
+
const run = useCallback(async <T,>(action: () => Promise<T>): Promise<AsyncActionResult<T>> => {
|
|
17
|
+
try {
|
|
18
|
+
return { ok: true, value: await action() }
|
|
19
|
+
} catch (cause) {
|
|
20
|
+
setError(errorText(cause))
|
|
21
|
+
return { ok: false }
|
|
22
|
+
}
|
|
23
|
+
}, [])
|
|
24
|
+
return { error, setError, clearError, run }
|
|
25
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { errorText } from '../../core/text.ts'
|
|
3
|
+
|
|
4
|
+
export interface AsyncListState<T> {
|
|
5
|
+
items: T[]
|
|
6
|
+
loading: boolean
|
|
7
|
+
error: string | null
|
|
8
|
+
reload(): void
|
|
9
|
+
remove(predicate: (item: T) => boolean): void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const LOADING_HINT_DELAY_MS = 120
|
|
13
|
+
|
|
14
|
+
export function useAsyncList<T>(load: () => Promise<T[]>): AsyncListState<T> {
|
|
15
|
+
const [items, setItems] = useState<T[]>([])
|
|
16
|
+
const itemsRef = useRef(items)
|
|
17
|
+
itemsRef.current = items
|
|
18
|
+
const [loading, setLoading] = useState(false)
|
|
19
|
+
const [error, setError] = useState<string | null>(null)
|
|
20
|
+
const loadRef = useRef(load)
|
|
21
|
+
loadRef.current = load
|
|
22
|
+
const cancelRef = useRef<(() => void) | null>(null)
|
|
23
|
+
const hintTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
24
|
+
const clearHintTimer = (): void => {
|
|
25
|
+
if (hintTimerRef.current !== null) {
|
|
26
|
+
clearTimeout(hintTimerRef.current)
|
|
27
|
+
hintTimerRef.current = null
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const reload = useCallback(() => {
|
|
31
|
+
cancelRef.current?.()
|
|
32
|
+
clearHintTimer()
|
|
33
|
+
const cancelled = { current: false }
|
|
34
|
+
cancelRef.current = () => {
|
|
35
|
+
cancelled.current = true
|
|
36
|
+
}
|
|
37
|
+
setLoading(false)
|
|
38
|
+
hintTimerRef.current = setTimeout(() => {
|
|
39
|
+
if (!cancelled.current && itemsRef.current.length === 0) setLoading(true)
|
|
40
|
+
}, LOADING_HINT_DELAY_MS)
|
|
41
|
+
loadRef.current()
|
|
42
|
+
.then(next => {
|
|
43
|
+
if (cancelled.current) return
|
|
44
|
+
itemsRef.current = next
|
|
45
|
+
setItems(next)
|
|
46
|
+
setError(null)
|
|
47
|
+
})
|
|
48
|
+
.catch(cause => {
|
|
49
|
+
if (cancelled.current) return
|
|
50
|
+
setError(errorText(cause))
|
|
51
|
+
})
|
|
52
|
+
.finally(() => {
|
|
53
|
+
if (cancelled.current) return
|
|
54
|
+
clearHintTimer()
|
|
55
|
+
setLoading(false)
|
|
56
|
+
})
|
|
57
|
+
}, [])
|
|
58
|
+
const remove = useCallback((predicate: (item: T) => boolean) => {
|
|
59
|
+
const next = itemsRef.current.filter(item => !predicate(item))
|
|
60
|
+
itemsRef.current = next
|
|
61
|
+
setItems(next)
|
|
62
|
+
}, [])
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
reload()
|
|
65
|
+
return () => {
|
|
66
|
+
cancelRef.current?.()
|
|
67
|
+
clearHintTimer()
|
|
68
|
+
}
|
|
69
|
+
}, [reload])
|
|
70
|
+
return { items, loading, error, reload, remove }
|
|
71
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useCursor } from 'ink'
|
|
2
|
+
import { useCallback } from 'react'
|
|
3
|
+
|
|
4
|
+
export interface CaretPosition {
|
|
5
|
+
x: number
|
|
6
|
+
y: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Ink 7.1.1 rests the hardware cursor one row above where frames without a
|
|
11
|
+
* trailing newline end; this layout fills the viewport, so request one row
|
|
12
|
+
* lower.
|
|
13
|
+
*/
|
|
14
|
+
export function useCaret(): { setCursorPosition(position: CaretPosition | undefined): void } {
|
|
15
|
+
const { setCursorPosition } = useCursor()
|
|
16
|
+
const adjusted = useCallback((position: CaretPosition | undefined) => {
|
|
17
|
+
if (position === undefined) {
|
|
18
|
+
setCursorPosition(undefined)
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
setCursorPosition({ x: position.x, y: position.y + 1 })
|
|
22
|
+
}, [setCursorPosition])
|
|
23
|
+
return { setCursorPosition: adjusted }
|
|
24
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
3
|
+
import type { ChatBridge, ChatToolPresenter, RegistryCommand } from '../../chat/bridge.ts'
|
|
4
|
+
import { initialTurnState, reduceChatEvent } from '../../chat/store.ts'
|
|
5
|
+
import { nextRetryStatus } from '../../chat/retry-status.ts'
|
|
6
|
+
import type { RetryStatus } from '../../chat/retry-status.ts'
|
|
7
|
+
import type { AgentActivity } from '../../chat/store.ts'
|
|
8
|
+
import { normalizeTodos } from '../../chat/todo-view.ts'
|
|
9
|
+
import type { TodoItemLike } from '../../chat/todo-view.ts'
|
|
10
|
+
import type { Message } from '../../model/message.ts'
|
|
11
|
+
import { releaseFields, hasFieldSlots, releaseUnreferenced } from '../../core/fields.ts'
|
|
12
|
+
|
|
13
|
+
const FRAME_MS = 33
|
|
14
|
+
|
|
15
|
+
const IDLE_ACTIVITY: AgentActivity = { running: false, phase: 'awaiting-request', compacting: false }
|
|
16
|
+
const NO_TODOS: TodoItemLike[] = []
|
|
17
|
+
|
|
18
|
+
function todosKey(todos: readonly TodoItemLike[]): string {
|
|
19
|
+
return todos.map(item => `${item.status}:${item.content}`).join('\n')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ChatEvents {
|
|
23
|
+
messages: Message[]
|
|
24
|
+
modelName: string
|
|
25
|
+
setModelName(name: string): void
|
|
26
|
+
updateMessages(fn: (messages: Message[]) => Message[]): void
|
|
27
|
+
resetChat(): void
|
|
28
|
+
activity: AgentActivity
|
|
29
|
+
todos: TodoItemLike[]
|
|
30
|
+
retryStatus?: RetryStatus
|
|
31
|
+
streamedChars: number
|
|
32
|
+
registryCommands: readonly RegistryCommand[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function useChatEvents(bridge: ChatBridge | undefined, dialogOpen: boolean): ChatEvents {
|
|
36
|
+
const [messages, setMessages] = useState<Message[]>([])
|
|
37
|
+
const [modelName, setModelName] = useState('')
|
|
38
|
+
const [activity, setActivity] = useState<AgentActivity>(IDLE_ACTIVITY)
|
|
39
|
+
const [todos, setTodos] = useState<TodoItemLike[]>(NO_TODOS)
|
|
40
|
+
const [retryStatus, setRetryStatus] = useState<RetryStatus | undefined>(undefined)
|
|
41
|
+
const [streamedChars, setStreamedChars] = useState(0)
|
|
42
|
+
const [registryCommands, setRegistryCommands] = useState<readonly RegistryCommand[]>([])
|
|
43
|
+
const streamedCharsRef = useRef(0)
|
|
44
|
+
const chatStateRef = useRef<{ messages: Message[]; turn: ReturnType<typeof initialTurnState> }>({
|
|
45
|
+
messages: [],
|
|
46
|
+
turn: initialTurnState(),
|
|
47
|
+
})
|
|
48
|
+
const pendingEventsRef = useRef<SessionEvent[]>([])
|
|
49
|
+
const retryStatusRef = useRef<RetryStatus | undefined>(undefined)
|
|
50
|
+
const presenterRef = useRef<ChatToolPresenter | undefined>(undefined)
|
|
51
|
+
const dialogOpenRef = useRef(false)
|
|
52
|
+
dialogOpenRef.current = dialogOpen
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
presenterRef.current = bridge?.toolPresenter
|
|
55
|
+
if (bridge === undefined) {
|
|
56
|
+
setRegistryCommands([])
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
setRegistryCommands(bridge.listRegistryCommands())
|
|
60
|
+
return bridge.onRegistryChanged(() => setRegistryCommands(bridge.listRegistryCommands()))
|
|
61
|
+
}, [bridge])
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!bridge) return
|
|
64
|
+
return bridge.subscribe(event => {
|
|
65
|
+
pendingEventsRef.current.push(event)
|
|
66
|
+
})
|
|
67
|
+
}, [bridge])
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
const timer = setInterval(() => {
|
|
70
|
+
if (dialogOpenRef.current) return
|
|
71
|
+
const events = pendingEventsRef.current
|
|
72
|
+
if (events.length === 0) return
|
|
73
|
+
pendingEventsRef.current = []
|
|
74
|
+
let state = chatStateRef.current
|
|
75
|
+
let dirty = false
|
|
76
|
+
let latestTodos: TodoItemLike[] | undefined
|
|
77
|
+
let retry: RetryStatus | undefined = retryStatusRef.current
|
|
78
|
+
let retryDirty = false
|
|
79
|
+
for (const event of events) {
|
|
80
|
+
if (event.type === 'assistant/chunk') {
|
|
81
|
+
const chunk = (event.data as { chunk?: { type?: string; text?: string } }).chunk
|
|
82
|
+
if ((chunk?.type === 'text-delta' || chunk?.type === 'reasoning-delta') && typeof chunk.text === 'string') {
|
|
83
|
+
streamedCharsRef.current += chunk.text.length
|
|
84
|
+
}
|
|
85
|
+
} else if (event.type === 'assistant/message' || event.type === 'turn/end') {
|
|
86
|
+
streamedCharsRef.current = 0
|
|
87
|
+
}
|
|
88
|
+
if (event.type === 'todo/write') {
|
|
89
|
+
latestTodos = normalizeTodos((event.data as { todos?: unknown }).todos)
|
|
90
|
+
}
|
|
91
|
+
const nextRetry = nextRetryStatus(retry, event)
|
|
92
|
+
if (nextRetry !== retry) {
|
|
93
|
+
retry = nextRetry
|
|
94
|
+
retryDirty = true
|
|
95
|
+
}
|
|
96
|
+
const next = reduceChatEvent(state.messages, event, state.turn, presenterRef.current)
|
|
97
|
+
dirty = dirty || next.changed
|
|
98
|
+
state = { messages: next.messages, turn: next.turn }
|
|
99
|
+
}
|
|
100
|
+
chatStateRef.current = state
|
|
101
|
+
if (dirty && hasFieldSlots('message')) {
|
|
102
|
+
let keep = ''
|
|
103
|
+
for (const message of state.messages) {
|
|
104
|
+
if (message.kind === 'bubble') keep += message.content
|
|
105
|
+
}
|
|
106
|
+
releaseUnreferenced('message', keep)
|
|
107
|
+
}
|
|
108
|
+
retryStatusRef.current = retry
|
|
109
|
+
setStreamedChars(previous => previous === streamedCharsRef.current ? previous : streamedCharsRef.current)
|
|
110
|
+
if (dirty) setMessages([...state.messages])
|
|
111
|
+
if (retryDirty) setRetryStatus(retry)
|
|
112
|
+
const turn = state.turn
|
|
113
|
+
setActivity(previous => previous.running === turn.running && previous.phase === turn.phase && previous.compacting === turn.compacting
|
|
114
|
+
? previous
|
|
115
|
+
: { running: turn.running, phase: turn.phase, compacting: turn.compacting })
|
|
116
|
+
if (latestTodos !== undefined) {
|
|
117
|
+
setTodos(previous => todosKey(previous) === todosKey(latestTodos!) ? previous : latestTodos!)
|
|
118
|
+
}
|
|
119
|
+
}, FRAME_MS)
|
|
120
|
+
return () => clearInterval(timer)
|
|
121
|
+
}, [])
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!bridge) return
|
|
124
|
+
setModelName(bridge.modelName())
|
|
125
|
+
}, [bridge])
|
|
126
|
+
return {
|
|
127
|
+
messages,
|
|
128
|
+
modelName,
|
|
129
|
+
setModelName,
|
|
130
|
+
updateMessages(fn) {
|
|
131
|
+
const next = fn(chatStateRef.current.messages)
|
|
132
|
+
chatStateRef.current = { ...chatStateRef.current, messages: next }
|
|
133
|
+
setMessages(next)
|
|
134
|
+
},
|
|
135
|
+
resetChat() {
|
|
136
|
+
setMessages([])
|
|
137
|
+
chatStateRef.current = { messages: [], turn: initialTurnState() }
|
|
138
|
+
pendingEventsRef.current = []
|
|
139
|
+
retryStatusRef.current = undefined
|
|
140
|
+
streamedCharsRef.current = 0
|
|
141
|
+
releaseFields('message')
|
|
142
|
+
setStreamedChars(0)
|
|
143
|
+
setRetryStatus(undefined)
|
|
144
|
+
setActivity(IDLE_ACTIVITY)
|
|
145
|
+
setTodos(NO_TODOS)
|
|
146
|
+
},
|
|
147
|
+
activity,
|
|
148
|
+
todos,
|
|
149
|
+
retryStatus,
|
|
150
|
+
streamedChars,
|
|
151
|
+
registryCommands,
|
|
152
|
+
}
|
|
153
|
+
}
|