@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
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