@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
package/src/index.tsx
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import './runtime/prod-react.ts'
|
|
2
|
+
import { setColorLevel } from './terminal/capabilities.ts'
|
|
3
|
+
import { probeColorLevel } from './terminal/probe.ts'
|
|
4
|
+
import { render } from 'ink'
|
|
5
|
+
import { startPerformanceGuard } from './performance-guard.ts'
|
|
6
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
7
|
+
import z from '@deepseek-ai/schemastery'
|
|
8
|
+
import { App } from './ui/app.tsx'
|
|
9
|
+
import { createChatBridge } from './chat/bridge.ts'
|
|
10
|
+
import type { ChatBridge } from './chat/bridge.ts'
|
|
11
|
+
import { createScreenCapture } from './terminal/screen.ts'
|
|
12
|
+
import { writeCursorShape } from './terminal/cursor-shape.ts'
|
|
13
|
+
import { startHotTheme } from './hot-theme.ts'
|
|
14
|
+
import { applyTheme } from './apply-theme.ts'
|
|
15
|
+
import { registerPalette } from './theme.ts'
|
|
16
|
+
import { detectBackgroundMode } from './terminal/background.ts'
|
|
17
|
+
import { registerThemeSettings } from './theme-settings.ts'
|
|
18
|
+
import type { ThemeSettingsScope } from './theme-settings.ts'
|
|
19
|
+
import { warmLanguages, onLanguagesWarm, clearHighlightCache } from './ui/message/md/highlight.ts'
|
|
20
|
+
import { clearMarkdownBlockCache } from './ui/message/md/engine.ts'
|
|
21
|
+
import { clearLayoutCache } from './ui/message/layout.ts'
|
|
22
|
+
import { warmRenderPipeline } from './ui/message/warmup.ts'
|
|
23
|
+
import { createTuiExtensionPoint, exposeRuntimeFaces } from './ui/extension-point.ts'
|
|
24
|
+
import { registerBuiltinToolViews } from './chat/builtin-tool-views.ts'
|
|
25
|
+
import { closeBootLog, emitBootLine, openBootLog } from './boot-log.ts'
|
|
26
|
+
import { configureLogs, error as logError, installCrashHandlers, warn } from './log.ts'
|
|
27
|
+
import { env } from './env.ts'
|
|
28
|
+
import { upstreamDriftSummary, UPSTREAM_SUPPORTED_RANGE } from './contract/upstream.ts'
|
|
29
|
+
|
|
30
|
+
export const name = '@xp266/dshtui'
|
|
31
|
+
|
|
32
|
+
export interface Config {
|
|
33
|
+
theme: 'auto' | 'dark' | 'light'
|
|
34
|
+
/** Palette overrides applied on top of the built-in dark and light themes (palette key -> hex). */
|
|
35
|
+
colors: Record<string, string>
|
|
36
|
+
maxFps: number
|
|
37
|
+
bootListTimeout: number
|
|
38
|
+
alternateScreen: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const Config: z<Config> = z.object({
|
|
42
|
+
theme: z.union(['auto', 'dark', 'light']).default('auto'),
|
|
43
|
+
colors: z.dict(z.string()).default({}),
|
|
44
|
+
maxFps: z.number().default(240),
|
|
45
|
+
bootListTimeout: z.number().default(10000),
|
|
46
|
+
alternateScreen: z.boolean().default(true),
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const DEFAULT_CONFIG: Config = { theme: 'auto', colors: {}, maxFps: 240, bootListTimeout: 10000, alternateScreen: true }
|
|
50
|
+
|
|
51
|
+
async function initTheme(scope: ThemeSettingsScope | undefined, theme: Config['theme'], colors: Config['colors']): Promise<void> {
|
|
52
|
+
if (Object.keys(colors).length > 0) {
|
|
53
|
+
registerPalette({ id: 'dshtui-config', order: Number.MAX_SAFE_INTEGER, colors })
|
|
54
|
+
}
|
|
55
|
+
const saved = scope?.get().mode
|
|
56
|
+
applyTheme(saved === 'dark' || saved === 'light'
|
|
57
|
+
? saved
|
|
58
|
+
: theme === 'auto'
|
|
59
|
+
? await detectBackgroundMode()
|
|
60
|
+
: theme)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const inject = ['agentLoop', 'agents', 'sessions', 'workspaceRegistry', 'llm', 'settings', 'credentials', 'agentDefaultModel']
|
|
64
|
+
|
|
65
|
+
export function apply(ctx: Context, config: Config = Config(DEFAULT_CONFIG)) {
|
|
66
|
+
ctx.effect(() => {
|
|
67
|
+
configureLogs({ stderr: env.debug, file: env.logFile, dir: env.logDir, level: env.logLevel, maxFileBytes: env.logMaxBytes })
|
|
68
|
+
const restorePerformance = startPerformanceGuard()
|
|
69
|
+
const disposeCrashHandlers = installCrashHandlers({ exit: env.crashExit })
|
|
70
|
+
const drift = upstreamDriftSummary()
|
|
71
|
+
if (drift !== undefined) {
|
|
72
|
+
warn('upstream', `upstream version drift (supported: ${UPSTREAM_SUPPORTED_RANGE}): ${drift.kind} ${drift.versions.join(', ')}`)
|
|
73
|
+
}
|
|
74
|
+
const capture = createScreenCapture()
|
|
75
|
+
let bridge: ChatBridge | undefined
|
|
76
|
+
let app: ReturnType<typeof render> | undefined
|
|
77
|
+
let hotTheme: ReturnType<typeof startHotTheme> | undefined
|
|
78
|
+
let themeTick = 0
|
|
79
|
+
let disposed = false
|
|
80
|
+
let lastColumns = 0
|
|
81
|
+
let lastRows = 0
|
|
82
|
+
let stopSizePoll: (() => void) | undefined
|
|
83
|
+
let exposeFaces: (() => void) | undefined
|
|
84
|
+
const buildAppNode = () => <App bridge={bridge} screen={capture} themeTick={themeTick} />
|
|
85
|
+
const rerender = (): void => {
|
|
86
|
+
themeTick += 1
|
|
87
|
+
app?.rerender(buildAppNode())
|
|
88
|
+
}
|
|
89
|
+
// NOTE: the extension object must come from createTuiExtensionPoint itself;
|
|
90
|
+
// ctx.get('tui') here would run before provide() and stay undefined forever.
|
|
91
|
+
const { extension: extensionPoint, dispose: disposeExtensionPoint } = createTuiExtensionPoint(ctx, { onContributionsChanged: rerender })
|
|
92
|
+
const disposeBuiltinToolViews = registerBuiltinToolViews()
|
|
93
|
+
warmLanguages()
|
|
94
|
+
onLanguagesWarm(() => {
|
|
95
|
+
clearHighlightCache()
|
|
96
|
+
clearMarkdownBlockCache()
|
|
97
|
+
clearLayoutCache()
|
|
98
|
+
warmRenderPipeline()
|
|
99
|
+
})
|
|
100
|
+
const start = (): void => {
|
|
101
|
+
if (disposed || app !== undefined) return
|
|
102
|
+
app = render(buildAppNode(), {
|
|
103
|
+
stdout: capture.stream,
|
|
104
|
+
alternateScreen: config.alternateScreen,
|
|
105
|
+
exitOnCtrlC: false,
|
|
106
|
+
incrementalRendering: false,
|
|
107
|
+
maxFps: config.maxFps,
|
|
108
|
+
kittyKeyboard: { mode: 'auto' },
|
|
109
|
+
})
|
|
110
|
+
lastColumns = capture.stream.columns
|
|
111
|
+
lastRows = capture.stream.rows
|
|
112
|
+
const onResize = () => {
|
|
113
|
+
lastColumns = capture.stream.columns
|
|
114
|
+
lastRows = capture.stream.rows
|
|
115
|
+
capture.stream.write('\x1b[2J\x1b[H')
|
|
116
|
+
app!.clear()
|
|
117
|
+
app!.rerender(buildAppNode())
|
|
118
|
+
}
|
|
119
|
+
capture.stream.on('resize', onResize)
|
|
120
|
+
const sizePoll = setInterval(() => {
|
|
121
|
+
if (capture.stream.columns !== lastColumns || capture.stream.rows !== lastRows) {
|
|
122
|
+
onResize()
|
|
123
|
+
// Terminals that never emit a resize event still get React-side
|
|
124
|
+
// size updates through this synthetic dispatch.
|
|
125
|
+
capture.stream.emit('resize')
|
|
126
|
+
}
|
|
127
|
+
}, 1000)
|
|
128
|
+
sizePoll.unref()
|
|
129
|
+
stopSizePoll = (): void => {
|
|
130
|
+
clearInterval(sizePoll)
|
|
131
|
+
capture.stream.off('resize', onResize)
|
|
132
|
+
}
|
|
133
|
+
if (disposed) {
|
|
134
|
+
stopSizePoll()
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
hotTheme = startHotTheme(rerender)
|
|
138
|
+
}
|
|
139
|
+
const themeScope = registerThemeSettings(ctx)
|
|
140
|
+
openBootLog()
|
|
141
|
+
if (drift !== undefined) {
|
|
142
|
+
emitBootLine(`warning: upstream version drift (${drift.kind})`)
|
|
143
|
+
}
|
|
144
|
+
emitBootLine('terminal: probing color support')
|
|
145
|
+
void (async () => {
|
|
146
|
+
const probed = await probeColorLevel()
|
|
147
|
+
if (probed !== undefined) setColorLevel(probed)
|
|
148
|
+
emitBootLine('theme: applying initial theme')
|
|
149
|
+
await initTheme(themeScope, config.theme, config.colors)
|
|
150
|
+
emitBootLine('chat bridge: connecting harness services')
|
|
151
|
+
bridge = await createChatBridge(ctx)
|
|
152
|
+
exposeFaces = exposeRuntimeFaces(extensionPoint, bridge)
|
|
153
|
+
emitBootLine('sessions: loading session list')
|
|
154
|
+
await Promise.race([
|
|
155
|
+
bridge.listSessions().catch(() => {}),
|
|
156
|
+
new Promise<void>(resolve => {
|
|
157
|
+
setTimeout(resolve, config.bootListTimeout).unref()
|
|
158
|
+
}),
|
|
159
|
+
])
|
|
160
|
+
emitBootLine('ready: starting interface')
|
|
161
|
+
closeBootLog()
|
|
162
|
+
})()
|
|
163
|
+
.catch(error => {
|
|
164
|
+
logError('boot', 'chat bridge init failed', {
|
|
165
|
+
message: error instanceof Error ? error.message : String(error),
|
|
166
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
167
|
+
})
|
|
168
|
+
closeBootLog()
|
|
169
|
+
})
|
|
170
|
+
.finally(() => start())
|
|
171
|
+
return () => {
|
|
172
|
+
disposed = true
|
|
173
|
+
restorePerformance()
|
|
174
|
+
exposeFaces?.()
|
|
175
|
+
disposeBuiltinToolViews()
|
|
176
|
+
disposeExtensionPoint()
|
|
177
|
+
stopSizePoll?.()
|
|
178
|
+
hotTheme?.stop()
|
|
179
|
+
writeCursorShape('reset')
|
|
180
|
+
bridge?.dispose()
|
|
181
|
+
app?.unmount()
|
|
182
|
+
disposeCrashHandlers()
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
type Listener = () => void
|
|
2
|
+
|
|
3
|
+
export interface KeyedOptions {
|
|
4
|
+
order?: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface Layer<T> {
|
|
8
|
+
key: string
|
|
9
|
+
order: number
|
|
10
|
+
value: T
|
|
11
|
+
prev: Layer<T> | undefined
|
|
12
|
+
dead: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface KeyedEntry<T> {
|
|
16
|
+
key: string
|
|
17
|
+
order: number
|
|
18
|
+
value: T
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface KeyedRegistry<T> {
|
|
22
|
+
register(key: string, value: T, options?: KeyedOptions): () => void
|
|
23
|
+
entries(): KeyedEntry<T>[]
|
|
24
|
+
values(): T[]
|
|
25
|
+
get(key: string): T | undefined
|
|
26
|
+
has(key: string): boolean
|
|
27
|
+
subscribe(listener: Listener): () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Keyed contribution registry with layered override semantics: registering
|
|
32
|
+
* an existing key displaces the previous layer, and disposing a layer
|
|
33
|
+
* restores the nearest live layer below it, so an overriding plugin that
|
|
34
|
+
* unmounts hands the slot back to whatever it replaced.
|
|
35
|
+
*
|
|
36
|
+
* `get(key)` resolves to the live layer with the lowest order (the one that
|
|
37
|
+
* would win a dispatch loop), not merely the most recently registered one, so
|
|
38
|
+
* single-key lookups agree with the iteration order plugins observe.
|
|
39
|
+
*/
|
|
40
|
+
export function keyedRegistry<T>(compareKeys: (a: string, b: string) => number = (a, b) => (a < b ? -1 : a > b ? 1 : 0)): KeyedRegistry<T> {
|
|
41
|
+
const map = new Map<string, Layer<T>>()
|
|
42
|
+
const listeners = new Set<Listener>()
|
|
43
|
+
|
|
44
|
+
function notify(): void {
|
|
45
|
+
for (const listener of [...listeners]) {
|
|
46
|
+
try {
|
|
47
|
+
listener()
|
|
48
|
+
} catch {
|
|
49
|
+
// One broken listener must not starve the others or corrupt registry state.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function winningLayer(key: string): Layer<T> | undefined {
|
|
55
|
+
let best: Layer<T> | undefined
|
|
56
|
+
let layer = map.get(key)
|
|
57
|
+
while (layer !== undefined) {
|
|
58
|
+
if (!layer.dead && (best === undefined || layer.order < best.order)) best = layer
|
|
59
|
+
layer = layer.prev
|
|
60
|
+
}
|
|
61
|
+
return best
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
register(key, value, options) {
|
|
66
|
+
const prev = map.get(key)
|
|
67
|
+
const layer: Layer<T> = { key, order: options?.order ?? 100, value, prev, dead: false }
|
|
68
|
+
map.set(key, layer)
|
|
69
|
+
notify()
|
|
70
|
+
return () => {
|
|
71
|
+
if (layer.dead) return
|
|
72
|
+
layer.dead = true
|
|
73
|
+
if (map.get(key) !== layer) return
|
|
74
|
+
let restore = layer.prev
|
|
75
|
+
while (restore !== undefined && restore.dead) restore = restore.prev
|
|
76
|
+
if (restore === undefined) map.delete(key)
|
|
77
|
+
else map.set(key, restore)
|
|
78
|
+
notify()
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
entries() {
|
|
82
|
+
return [...map.values()]
|
|
83
|
+
.map(layer => ({ key: layer.key, order: layer.order, value: layer.value }))
|
|
84
|
+
.sort((a, b) => a.order - b.order || compareKeys(a.key, b.key))
|
|
85
|
+
},
|
|
86
|
+
values() {
|
|
87
|
+
return this.entries().map(entry => entry.value)
|
|
88
|
+
},
|
|
89
|
+
get(key) {
|
|
90
|
+
return winningLayer(key)?.value
|
|
91
|
+
},
|
|
92
|
+
has(key) {
|
|
93
|
+
return map.has(key)
|
|
94
|
+
},
|
|
95
|
+
subscribe(listener) {
|
|
96
|
+
listeners.add(listener)
|
|
97
|
+
return () => {
|
|
98
|
+
listeners.delete(listener)
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { clearHighlightCache } from '../ui/message/md/highlight.ts'
|
|
2
|
+
import { clearMarkdownBlockCache } from '../ui/message/md/engine.ts'
|
|
3
|
+
import { clearLayoutCache } from '../ui/message/layout.ts'
|
|
4
|
+
|
|
5
|
+
type Listener = () => void
|
|
6
|
+
|
|
7
|
+
const listeners = new Set<Listener>()
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Single invalidation point for every rendered-surface cache (message layout,
|
|
11
|
+
* markdown blocks, syntax highlight) plus the rerender notification. Theme
|
|
12
|
+
* switches, hot reloads, and contribution changes all funnel through here so
|
|
13
|
+
* no cache can be missed when the surface changes.
|
|
14
|
+
*/
|
|
15
|
+
export function bumpSurface(): void {
|
|
16
|
+
clearLayoutCache()
|
|
17
|
+
clearHighlightCache()
|
|
18
|
+
clearMarkdownBlockCache()
|
|
19
|
+
for (const listener of [...listeners]) {
|
|
20
|
+
try {
|
|
21
|
+
listener()
|
|
22
|
+
} catch {
|
|
23
|
+
// isolated: one broken subscriber must not block the others
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function onSurfaceChanged(listener: Listener): () => void {
|
|
29
|
+
listeners.add(listener)
|
|
30
|
+
return () => {
|
|
31
|
+
listeners.delete(listener)
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { appendFileSync, chmodSync, mkdirSync, renameSync, statSync, unlinkSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { resolveDshHome } from './harness-home.ts'
|
|
4
|
+
|
|
5
|
+
const LOG_LEVELS = ['debug', 'info', 'warn', 'error'] as const
|
|
6
|
+
export type LogLevel = (typeof LOG_LEVELS)[number]
|
|
7
|
+
|
|
8
|
+
export interface LogLine {
|
|
9
|
+
level: LogLevel
|
|
10
|
+
tag: string
|
|
11
|
+
message: string
|
|
12
|
+
fields?: Record<string, unknown>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CrashReport {
|
|
16
|
+
kind: 'uncaughtException' | 'unhandledRejection' | 'boot'
|
|
17
|
+
message: string
|
|
18
|
+
stack?: string
|
|
19
|
+
cause?: string
|
|
20
|
+
fields?: Record<string, unknown>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface LogSink {
|
|
24
|
+
line(entry: LogLine): void
|
|
25
|
+
crash(report: CrashReport): void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface LogConfig {
|
|
29
|
+
dir?: string
|
|
30
|
+
stderr?: boolean
|
|
31
|
+
file?: boolean
|
|
32
|
+
level?: LogLevel
|
|
33
|
+
maxFileBytes?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_MAX_FILE_BYTES = 5 * 1024 * 1024
|
|
37
|
+
|
|
38
|
+
const state = {
|
|
39
|
+
stderrEnabled: false,
|
|
40
|
+
fileEnabled: false,
|
|
41
|
+
minLevel: 'info' as LogLevel,
|
|
42
|
+
maxFileBytes: DEFAULT_MAX_FILE_BYTES,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const sinks = new Set<LogSink>()
|
|
46
|
+
let fileSink: LogSink | undefined
|
|
47
|
+
|
|
48
|
+
function levelRank(level: LogLevel): number {
|
|
49
|
+
return LOG_LEVELS.indexOf(level)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function fieldsText(fields: Record<string, unknown> | undefined): string {
|
|
53
|
+
return fields === undefined ? '' : ` ${JSON.stringify(fields)}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function formatLine(entry: LogLine): string {
|
|
57
|
+
return `${new Date().toISOString()} [${entry.level}] ${entry.tag} ${entry.message}${fieldsText(entry.fields)}\n`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function formatCrash(report: CrashReport): string {
|
|
61
|
+
return [
|
|
62
|
+
`=== dshtui crash: ${report.kind} ===`,
|
|
63
|
+
`time: ${new Date().toISOString()}`,
|
|
64
|
+
`node: ${process.version}`,
|
|
65
|
+
`message: ${report.message}${fieldsText(report.fields)}`,
|
|
66
|
+
report.stack === undefined ? '' : `stack:\n${report.stack}`,
|
|
67
|
+
report.cause === undefined ? '' : `cause: ${report.cause}`,
|
|
68
|
+
'',
|
|
69
|
+
].join('\n')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function writeStderr(line: string): void {
|
|
73
|
+
if (!state.stderrEnabled) return
|
|
74
|
+
try {
|
|
75
|
+
process.stderr.write(line)
|
|
76
|
+
} catch {
|
|
77
|
+
// Diagnostics must never take the UI down.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resolveLogFile(dir: string | undefined): string {
|
|
82
|
+
return join(dir ?? join(resolveDshHome(), 'logs'), 'dshtui.log')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function rotateIfNeeded(logFile: string): void {
|
|
86
|
+
let size: number
|
|
87
|
+
try {
|
|
88
|
+
size = statSync(logFile).size
|
|
89
|
+
} catch {
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
if (size < state.maxFileBytes) return
|
|
93
|
+
try {
|
|
94
|
+
unlinkSync(`${logFile}.1`)
|
|
95
|
+
} catch {
|
|
96
|
+
// No previous rotation to clear.
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
renameSync(logFile, `${logFile}.1`)
|
|
100
|
+
} catch {
|
|
101
|
+
// Keep appending to the oversized file rather than losing diagnostics.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function fileSinkFor(logFile: string): LogSink {
|
|
106
|
+
return {
|
|
107
|
+
line(entry) {
|
|
108
|
+
if (!state.fileEnabled) return
|
|
109
|
+
try {
|
|
110
|
+
rotateIfNeeded(logFile)
|
|
111
|
+
try {
|
|
112
|
+
chmodSync(logFile, 0o600)
|
|
113
|
+
} catch {
|
|
114
|
+
// The log may not exist yet; the create mode below still guards it.
|
|
115
|
+
}
|
|
116
|
+
appendFileSync(logFile, formatLine(entry), { mode: 0o600 })
|
|
117
|
+
} catch {
|
|
118
|
+
// Diagnostics must never take the UI down.
|
|
119
|
+
}
|
|
120
|
+
if (entry.level === 'warn' || entry.level === 'error') {
|
|
121
|
+
writeStderr(`[dshtui] ${entry.message}${fieldsText(entry.fields)}\n`)
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
crash(report) {
|
|
125
|
+
if (!state.fileEnabled) return
|
|
126
|
+
const line = formatCrash(report)
|
|
127
|
+
try {
|
|
128
|
+
rotateIfNeeded(logFile)
|
|
129
|
+
try {
|
|
130
|
+
chmodSync(logFile, 0o600)
|
|
131
|
+
} catch {
|
|
132
|
+
// The log may not exist yet; the create mode below still guards it.
|
|
133
|
+
}
|
|
134
|
+
appendFileSync(logFile, line, { mode: 0o600 })
|
|
135
|
+
} catch {
|
|
136
|
+
// Diagnostics must never take the UI down.
|
|
137
|
+
}
|
|
138
|
+
writeStderr(line)
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function removeFileSink(): void {
|
|
144
|
+
if (fileSink === undefined) return
|
|
145
|
+
sinks.delete(fileSink)
|
|
146
|
+
fileSink = undefined
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function registerLogSink(sink: LogSink): () => void {
|
|
150
|
+
sinks.add(sink)
|
|
151
|
+
return () => {
|
|
152
|
+
sinks.delete(sink)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function configureLogs(options: LogConfig = {}): void {
|
|
157
|
+
if (options.stderr !== undefined) state.stderrEnabled = options.stderr
|
|
158
|
+
if (options.level !== undefined) state.minLevel = options.level
|
|
159
|
+
if (options.maxFileBytes !== undefined) state.maxFileBytes = Math.max(1, options.maxFileBytes)
|
|
160
|
+
state.fileEnabled = options.file ?? state.fileEnabled
|
|
161
|
+
removeFileSink()
|
|
162
|
+
if (state.fileEnabled) {
|
|
163
|
+
const logFile = resolveLogFile(options.dir)
|
|
164
|
+
try {
|
|
165
|
+
mkdirSync(dirname(logFile), { recursive: true, mode: 0o700 })
|
|
166
|
+
fileSink = fileSinkFor(logFile)
|
|
167
|
+
sinks.add(fileSink)
|
|
168
|
+
} catch {
|
|
169
|
+
state.fileEnabled = false
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function dispatch(entry: LogLine): void {
|
|
175
|
+
if (levelRank(entry.level) < levelRank(state.minLevel)) return
|
|
176
|
+
for (const sink of sinks) {
|
|
177
|
+
try {
|
|
178
|
+
sink.line(entry)
|
|
179
|
+
} catch {
|
|
180
|
+
// Diagnostics must never take the UI down.
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function dispatchCrash(report: CrashReport): void {
|
|
186
|
+
for (const sink of sinks) {
|
|
187
|
+
try {
|
|
188
|
+
sink.crash(report)
|
|
189
|
+
} catch {
|
|
190
|
+
// Diagnostics must never take the UI down.
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function log(level: LogLevel, tag: string, message: string, fields?: Record<string, unknown>): void {
|
|
196
|
+
dispatch({ level, tag, message, fields })
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function debug(tag: string, message: string, fields?: Record<string, unknown>): void {
|
|
200
|
+
log('debug', tag, message, fields)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function info(tag: string, message: string, fields?: Record<string, unknown>): void {
|
|
204
|
+
log('info', tag, message, fields)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function warn(tag: string, message: string, fields?: Record<string, unknown>): void {
|
|
208
|
+
log('warn', tag, message, fields)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function error(tag: string, message: string, fields?: Record<string, unknown>): void {
|
|
212
|
+
log('error', tag, message, fields)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function stringifyError(cause: unknown): string {
|
|
216
|
+
if (cause instanceof Error) return cause.message
|
|
217
|
+
return String(cause)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function writeCrashReport(report: CrashReport): void {
|
|
221
|
+
dispatchCrash(report)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function installCrashHandlers(options: { exit?: boolean } = {}): () => void {
|
|
225
|
+
const onUncaught = (cause: unknown): void => {
|
|
226
|
+
writeCrashReport({
|
|
227
|
+
kind: 'uncaughtException',
|
|
228
|
+
message: stringifyError(cause),
|
|
229
|
+
stack: cause instanceof Error ? cause.stack : undefined,
|
|
230
|
+
})
|
|
231
|
+
if (options.exit === true) process.exit(1)
|
|
232
|
+
}
|
|
233
|
+
const onRejected = (cause: unknown): void => {
|
|
234
|
+
writeCrashReport({
|
|
235
|
+
kind: 'unhandledRejection',
|
|
236
|
+
message: stringifyError(cause),
|
|
237
|
+
stack: cause instanceof Error ? cause.stack : undefined,
|
|
238
|
+
})
|
|
239
|
+
if (options.exit === true) process.exit(1)
|
|
240
|
+
}
|
|
241
|
+
process.on('uncaughtException', onUncaught)
|
|
242
|
+
process.on('unhandledRejection', onRejected)
|
|
243
|
+
return () => {
|
|
244
|
+
process.off('uncaughtException', onUncaught)
|
|
245
|
+
process.off('unhandledRejection', onRejected)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export { LOG_LEVELS }
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { CustomMessage, ToolReadView } from '../contract/index.ts'
|
|
2
|
+
|
|
3
|
+
export type { CustomMessage, ToolReadView } from '../contract/index.ts'
|
|
4
|
+
|
|
5
|
+
export interface BubbleMessage {
|
|
6
|
+
kind: 'bubble'
|
|
7
|
+
id: string
|
|
8
|
+
role: 'user' | 'assistant' | 'error'
|
|
9
|
+
content: string
|
|
10
|
+
streaming?: boolean
|
|
11
|
+
origin?: 'command'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CollapsibleMessage {
|
|
15
|
+
kind: 'collapsible'
|
|
16
|
+
id: string
|
|
17
|
+
label: string
|
|
18
|
+
body: string
|
|
19
|
+
running: boolean
|
|
20
|
+
collapsed: boolean
|
|
21
|
+
bodyCol?: number
|
|
22
|
+
thinking?: boolean
|
|
23
|
+
streaming?: boolean
|
|
24
|
+
startedAt?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type DiffLineKind = 'ctx' | 'del' | 'add'
|
|
28
|
+
|
|
29
|
+
export interface DiffLine {
|
|
30
|
+
kind: DiffLineKind
|
|
31
|
+
text: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Generic tool card: every tool shares one bubble shape. The header carries
|
|
36
|
+
* the tool name plus a parameter summary; the body shows the full arguments
|
|
37
|
+
* and result (or a diff / read window when the tool protocol produced one).
|
|
38
|
+
* No per-tool customization exists at this layer.
|
|
39
|
+
*/
|
|
40
|
+
export interface ToolCardMessage {
|
|
41
|
+
kind: 'tool-card'
|
|
42
|
+
id: string
|
|
43
|
+
tool: string
|
|
44
|
+
/** Header text: tool name plus optional parameter summary. */
|
|
45
|
+
label: string
|
|
46
|
+
/** Full call-side arguments, rendered verbatim while running (and after, for full disclosure). */
|
|
47
|
+
argsBody: string
|
|
48
|
+
/** Full settled result text from the tool protocol. */
|
|
49
|
+
resultBody?: string
|
|
50
|
+
bodyCol?: number
|
|
51
|
+
diff?: { path: string; hunks: readonly (readonly DiffLine[])[]; backgrounds?: boolean }
|
|
52
|
+
/** Structured read window replacing the text result body. */
|
|
53
|
+
read?: ToolReadView
|
|
54
|
+
/** True when the settled result reported isError without a structured error; the result text renders as the error. */
|
|
55
|
+
failed?: boolean
|
|
56
|
+
/** Sub-calls dispatched under this call (code mode); rendered as a child list. */
|
|
57
|
+
nested?: readonly ToolCardMessage[]
|
|
58
|
+
error?: string
|
|
59
|
+
exitCode?: number
|
|
60
|
+
signal?: string
|
|
61
|
+
running: boolean
|
|
62
|
+
streaming?: boolean
|
|
63
|
+
startedAt?: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface CompactionMessage {
|
|
67
|
+
kind: 'compaction'
|
|
68
|
+
id: string
|
|
69
|
+
compactionId: string
|
|
70
|
+
running: boolean
|
|
71
|
+
summary: string
|
|
72
|
+
error?: string
|
|
73
|
+
streaming?: boolean
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface HomeLogoMessage {
|
|
77
|
+
kind: 'home-logo'
|
|
78
|
+
id: string
|
|
79
|
+
/** Artwork lines after culling; empty when the terminal cannot fit the logo. */
|
|
80
|
+
lines: readonly string[]
|
|
81
|
+
/** One gradient color per artwork line, aligned with `lines`. */
|
|
82
|
+
colors: readonly string[]
|
|
83
|
+
/** Version banner lines rendered under the artwork inside the same bubble. */
|
|
84
|
+
info: readonly string[]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type MessageKind = 'bubble' | 'collapsible' | 'tool-card' | 'compaction' | 'custom' | 'home-logo'
|
|
88
|
+
|
|
89
|
+
export type Message = BubbleMessage | CollapsibleMessage | ToolCardMessage | CompactionMessage | CustomMessage | HomeLogoMessage
|