@neurocode-ai/tui 1.18.8

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 (241) hide show
  1. package/bunfig.toml +4 -0
  2. package/package.json +72 -0
  3. package/src/app.tsx +1134 -0
  4. package/src/attention.ts +260 -0
  5. package/src/audio.d.ts +9 -0
  6. package/src/audio.ts +53 -0
  7. package/src/clipboard.ts +124 -0
  8. package/src/component/bg-pulse-render.ts +436 -0
  9. package/src/component/bg-pulse.tsx +99 -0
  10. package/src/component/command-palette.tsx +79 -0
  11. package/src/component/dialog-agent.tsx +31 -0
  12. package/src/component/dialog-console-org.tsx +135 -0
  13. package/src/component/dialog-debug.tsx +90 -0
  14. package/src/component/dialog-mcp.tsx +85 -0
  15. package/src/component/dialog-model.tsx +197 -0
  16. package/src/component/dialog-move-session.tsx +353 -0
  17. package/src/component/dialog-provider.tsx +469 -0
  18. package/src/component/dialog-retry-action.tsx +160 -0
  19. package/src/component/dialog-session-delete-failed.tsx +99 -0
  20. package/src/component/dialog-session-list.tsx +364 -0
  21. package/src/component/dialog-session-rename.tsx +31 -0
  22. package/src/component/dialog-skill.tsx +70 -0
  23. package/src/component/dialog-stash.tsx +87 -0
  24. package/src/component/dialog-status.tsx +168 -0
  25. package/src/component/dialog-tag.tsx +47 -0
  26. package/src/component/dialog-theme-list.tsx +50 -0
  27. package/src/component/dialog-variant.tsx +39 -0
  28. package/src/component/dialog-workspace-create.tsx +308 -0
  29. package/src/component/dialog-workspace-file-changes.tsx +144 -0
  30. package/src/component/dialog-workspace-list.tsx +112 -0
  31. package/src/component/dialog-workspace-unavailable.tsx +69 -0
  32. package/src/component/error-component.tsx +240 -0
  33. package/src/component/logo.tsx +61 -0
  34. package/src/component/plugin-route-missing.tsx +14 -0
  35. package/src/component/prompt/autocomplete.tsx +781 -0
  36. package/src/component/prompt/cwd.ts +0 -0
  37. package/src/component/prompt/frecency.tsx +1 -0
  38. package/src/component/prompt/history.tsx +1 -0
  39. package/src/component/prompt/index.tsx +1713 -0
  40. package/src/component/prompt/local-attachment.ts +48 -0
  41. package/src/component/prompt/move.tsx +205 -0
  42. package/src/component/prompt/stash.tsx +1 -0
  43. package/src/component/prompt/workspace.tsx +137 -0
  44. package/src/component/register-spinner.ts +6 -0
  45. package/src/component/spinner.tsx +26 -0
  46. package/src/component/startup-loading.tsx +63 -0
  47. package/src/component/todo-item.tsx +32 -0
  48. package/src/component/use-connected.tsx +12 -0
  49. package/src/component/workspace-label.tsx +19 -0
  50. package/src/config/index.tsx +129 -0
  51. package/src/config/keybind.ts +471 -0
  52. package/src/context/args.tsx +16 -0
  53. package/src/context/clipboard.tsx +18 -0
  54. package/src/context/data.tsx +569 -0
  55. package/src/context/directory.ts +17 -0
  56. package/src/context/editor.ts +408 -0
  57. package/src/context/epilogue.tsx +6 -0
  58. package/src/context/event.ts +36 -0
  59. package/src/context/exit.tsx +8 -0
  60. package/src/context/helper.tsx +26 -0
  61. package/src/context/kv.tsx +66 -0
  62. package/src/context/local.tsx +542 -0
  63. package/src/context/location.tsx +14 -0
  64. package/src/context/path-format.tsx +24 -0
  65. package/src/context/permission.tsx +26 -0
  66. package/src/context/project.tsx +115 -0
  67. package/src/context/prompt.tsx +18 -0
  68. package/src/context/route.tsx +60 -0
  69. package/src/context/runtime.tsx +62 -0
  70. package/src/context/sdk.tsx +151 -0
  71. package/src/context/sync.tsx +666 -0
  72. package/src/context/theme.tsx +332 -0
  73. package/src/context/thinking.ts +67 -0
  74. package/src/editor-zed.ts +286 -0
  75. package/src/editor.ts +101 -0
  76. package/src/feature-plugins/builtins.ts +36 -0
  77. package/src/feature-plugins/home/footer.tsx +100 -0
  78. package/src/feature-plugins/home/tips-view.tsx +287 -0
  79. package/src/feature-plugins/home/tips.tsx +59 -0
  80. package/src/feature-plugins/sidebar/context.tsx +65 -0
  81. package/src/feature-plugins/sidebar/files.tsx +70 -0
  82. package/src/feature-plugins/sidebar/footer.tsx +98 -0
  83. package/src/feature-plugins/sidebar/lsp.tsx +65 -0
  84. package/src/feature-plugins/sidebar/mcp.tsx +97 -0
  85. package/src/feature-plugins/sidebar/todo.tsx +49 -0
  86. package/src/feature-plugins/system/diff-viewer-file-tree-utils.ts +232 -0
  87. package/src/feature-plugins/system/diff-viewer-file-tree.tsx +162 -0
  88. package/src/feature-plugins/system/diff-viewer-ui.tsx +103 -0
  89. package/src/feature-plugins/system/diff-viewer.tsx +1077 -0
  90. package/src/feature-plugins/system/notifications.ts +94 -0
  91. package/src/feature-plugins/system/plugins.tsx +269 -0
  92. package/src/feature-plugins/system/which-key.tsx +608 -0
  93. package/src/index.tsx +1 -0
  94. package/src/keymap.tsx +290 -0
  95. package/src/logo.ts +11 -0
  96. package/src/parsers-config.ts +386 -0
  97. package/src/plugin/adapters.tsx +355 -0
  98. package/src/plugin/api.ts +52 -0
  99. package/src/plugin/command-shim.ts +109 -0
  100. package/src/plugin/runtime.tsx +81 -0
  101. package/src/plugin/slots.tsx +65 -0
  102. package/src/prompt/display.ts +48 -0
  103. package/src/prompt/frecency.tsx +80 -0
  104. package/src/prompt/history.tsx +111 -0
  105. package/src/prompt/part.ts +29 -0
  106. package/src/prompt/stash.tsx +89 -0
  107. package/src/prompt/traits.ts +29 -0
  108. package/src/routes/home/session-destination.tsx +41 -0
  109. package/src/routes/home.tsx +95 -0
  110. package/src/routes/session/dialog-fork-from-timeline.tsx +76 -0
  111. package/src/routes/session/dialog-message.tsx +109 -0
  112. package/src/routes/session/dialog-subagent.tsx +26 -0
  113. package/src/routes/session/dialog-timeline.tsx +47 -0
  114. package/src/routes/session/footer.tsx +91 -0
  115. package/src/routes/session/index.tsx +2710 -0
  116. package/src/routes/session/permission.tsx +718 -0
  117. package/src/routes/session/question.tsx +514 -0
  118. package/src/routes/session/sidebar.tsx +103 -0
  119. package/src/routes/session/subagent-footer.tsx +132 -0
  120. package/src/runtime.tsx +9 -0
  121. package/src/terminal-win32.ts +130 -0
  122. package/src/theme/assets/aura.json +69 -0
  123. package/src/theme/assets/ayu.json +80 -0
  124. package/src/theme/assets/carbonfox.json +248 -0
  125. package/src/theme/assets/catppuccin-frappe.json +230 -0
  126. package/src/theme/assets/catppuccin-macchiato.json +230 -0
  127. package/src/theme/assets/catppuccin.json +112 -0
  128. package/src/theme/assets/cobalt2.json +225 -0
  129. package/src/theme/assets/cursor.json +249 -0
  130. package/src/theme/assets/dracula.json +219 -0
  131. package/src/theme/assets/everforest.json +241 -0
  132. package/src/theme/assets/flexoki.json +237 -0
  133. package/src/theme/assets/github.json +233 -0
  134. package/src/theme/assets/gruvbox.json +242 -0
  135. package/src/theme/assets/kanagawa.json +77 -0
  136. package/src/theme/assets/lucent-orng.json +234 -0
  137. package/src/theme/assets/material.json +235 -0
  138. package/src/theme/assets/matrix.json +77 -0
  139. package/src/theme/assets/mercury.json +252 -0
  140. package/src/theme/assets/monokai.json +221 -0
  141. package/src/theme/assets/nightowl.json +221 -0
  142. package/src/theme/assets/nord.json +223 -0
  143. package/src/theme/assets/one-dark.json +84 -0
  144. package/src/theme/assets/opencode.json +245 -0
  145. package/src/theme/assets/orng.json +249 -0
  146. package/src/theme/assets/osaka-jade.json +93 -0
  147. package/src/theme/assets/palenight.json +222 -0
  148. package/src/theme/assets/rosepine.json +234 -0
  149. package/src/theme/assets/solarized.json +223 -0
  150. package/src/theme/assets/synthwave84.json +226 -0
  151. package/src/theme/assets/tokyonight.json +243 -0
  152. package/src/theme/assets/vercel.json +245 -0
  153. package/src/theme/assets/vesper.json +218 -0
  154. package/src/theme/assets/zenburn.json +223 -0
  155. package/src/theme/index.ts +1089 -0
  156. package/src/ui/border.ts +21 -0
  157. package/src/ui/dialog-alert.tsx +66 -0
  158. package/src/ui/dialog-confirm.tsx +108 -0
  159. package/src/ui/dialog-export-options.tsx +217 -0
  160. package/src/ui/dialog-help.tsx +40 -0
  161. package/src/ui/dialog-prompt.tsx +126 -0
  162. package/src/ui/dialog-select.tsx +790 -0
  163. package/src/ui/dialog.tsx +231 -0
  164. package/src/ui/link.tsx +34 -0
  165. package/src/ui/spinner.ts +368 -0
  166. package/src/ui/toast.tsx +102 -0
  167. package/src/util/collapse-tool-output.ts +19 -0
  168. package/src/util/error.ts +182 -0
  169. package/src/util/filetype.ts +130 -0
  170. package/src/util/format.ts +20 -0
  171. package/src/util/layout.ts +25 -0
  172. package/src/util/locale.ts +86 -0
  173. package/src/util/model.ts +28 -0
  174. package/src/util/path.ts +12 -0
  175. package/src/util/persistence.ts +33 -0
  176. package/src/util/presentation.ts +38 -0
  177. package/src/util/provider-origin.ts +7 -0
  178. package/src/util/record.ts +3 -0
  179. package/src/util/renderer.ts +7 -0
  180. package/src/util/revert-diff.ts +18 -0
  181. package/src/util/scroll.ts +27 -0
  182. package/src/util/selection.ts +79 -0
  183. package/src/util/session.ts +3 -0
  184. package/src/util/signal.ts +51 -0
  185. package/src/util/system.ts +20 -0
  186. package/src/util/tool-display.ts +13 -0
  187. package/src/util/transcript.ts +112 -0
  188. package/sst-env.d.ts +10 -0
  189. package/test/app-lifecycle.test.tsx +128 -0
  190. package/test/cli/cmd/tui/dialog-workspace-create.test.ts +28 -0
  191. package/test/cli/cmd/tui/model-options.test.ts +32 -0
  192. package/test/cli/cmd/tui/notifications.test.ts +267 -0
  193. package/test/cli/cmd/tui/provider-options.test.ts +41 -0
  194. package/test/cli/cmd/tui/sync-fixture.tsx +70 -0
  195. package/test/cli/cmd/tui/sync-live-hydration.test.tsx +262 -0
  196. package/test/cli/cmd/tui/sync-undefined-messages.test.tsx +43 -0
  197. package/test/cli/cmd/tui/sync.test.tsx +65 -0
  198. package/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap +92 -0
  199. package/test/cli/tui/data.test.tsx +486 -0
  200. package/test/cli/tui/dialog-prompt.test.tsx +147 -0
  201. package/test/cli/tui/diff-viewer-file-tree.test.tsx +200 -0
  202. package/test/cli/tui/diff-viewer.test.tsx +268 -0
  203. package/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +351 -0
  204. package/test/cli/tui/prompt-submit-race.test.ts +98 -0
  205. package/test/cli/tui/thinking.test.ts +36 -0
  206. package/test/cli/tui/use-event.test.tsx +148 -0
  207. package/test/clipboard.test.ts +19 -0
  208. package/test/component/dialog-session-list.test.ts +46 -0
  209. package/test/config.test.tsx +127 -0
  210. package/test/context/local.test.ts +22 -0
  211. package/test/editor.test.ts +32 -0
  212. package/test/feature-plugins/diff-viewer-file-tree-utils.test.ts +323 -0
  213. package/test/fixture/fixture.ts +13 -0
  214. package/test/fixture/tui-environment.tsx +32 -0
  215. package/test/fixture/tui-plugin.ts +36 -0
  216. package/test/fixture/tui-runtime.ts +12 -0
  217. package/test/fixture/tui-sdk.ts +109 -0
  218. package/test/index.test.tsx +6 -0
  219. package/test/keymap.test.tsx +141 -0
  220. package/test/plugin/runtime.test.ts +50 -0
  221. package/test/plugin/slots.test.tsx +38 -0
  222. package/test/prompt/display.test.ts +33 -0
  223. package/test/prompt/history.test.ts +39 -0
  224. package/test/prompt/jsonl.test.ts +24 -0
  225. package/test/prompt/local-attachment.test.ts +43 -0
  226. package/test/prompt/part.test.ts +53 -0
  227. package/test/prompt/persistence.test.ts +23 -0
  228. package/test/prompt/traits.test.ts +25 -0
  229. package/test/runtime.test.tsx +37 -0
  230. package/test/theme.test.ts +81 -0
  231. package/test/util/error.test.ts +49 -0
  232. package/test/util/filetype.test.ts +16 -0
  233. package/test/util/format.test.ts +59 -0
  234. package/test/util/model.test.ts +9 -0
  235. package/test/util/presentation.test.ts +8 -0
  236. package/test/util/renderer.test.ts +30 -0
  237. package/test/util/revert-diff.test.ts +35 -0
  238. package/test/util/session.test.ts +10 -0
  239. package/test/util/tool-display.test.ts +40 -0
  240. package/test/util/transcript.test.ts +421 -0
  241. package/tsconfig.json +10 -0
@@ -0,0 +1,7 @@
1
+ const contains = (consoleManagedProviders: string[] | ReadonlySet<string>, providerID: string) =>
2
+ Array.isArray(consoleManagedProviders)
3
+ ? consoleManagedProviders.includes(providerID)
4
+ : consoleManagedProviders.has(providerID)
5
+
6
+ export const isConsoleManagedProvider = (consoleManagedProviders: string[] | ReadonlySet<string>, providerID: string) =>
7
+ contains(consoleManagedProviders, providerID)
@@ -0,0 +1,3 @@
1
+ export function isRecord(value: unknown): value is Record<string, unknown> {
2
+ return !!value && typeof value === "object" && !Array.isArray(value)
3
+ }
@@ -0,0 +1,7 @@
1
+ import type { CliRenderer } from "@opentui/core"
2
+
3
+ export function destroyRenderer(renderer: Pick<CliRenderer, "isDestroyed" | "setTerminalTitle" | "destroy">) {
4
+ renderer.setTerminalTitle("")
5
+ if (renderer.isDestroyed) return
6
+ renderer.destroy()
7
+ }
@@ -0,0 +1,18 @@
1
+ import { parsePatch } from "diff"
2
+
3
+ export function getRevertDiffFiles(diffText: string) {
4
+ if (!diffText) return []
5
+
6
+ try {
7
+ return parsePatch(diffText).map((patch) => {
8
+ const filename = [patch.newFileName, patch.oldFileName].find((item) => item && item !== "/dev/null") ?? "unknown"
9
+ return {
10
+ filename: filename.replace(/^[ab]\//, ""),
11
+ additions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length, 0),
12
+ deletions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length, 0),
13
+ }
14
+ })
15
+ } catch {
16
+ return []
17
+ }
18
+ }
@@ -0,0 +1,27 @@
1
+ import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core"
2
+
3
+ export type ScrollConfig = {
4
+ scroll_acceleration?: { enabled?: boolean }
5
+ scroll_speed?: number
6
+ }
7
+
8
+ export class CustomSpeedScroll implements ScrollAcceleration {
9
+ constructor(private speed: number) {}
10
+
11
+ tick(_now?: number): number {
12
+ return this.speed
13
+ }
14
+
15
+ reset(): void {}
16
+ }
17
+
18
+ export function getScrollAcceleration(tuiConfig?: ScrollConfig): ScrollAcceleration {
19
+ if (tuiConfig?.scroll_acceleration?.enabled) {
20
+ return new MacOSScrollAccel()
21
+ }
22
+ if (tuiConfig?.scroll_speed !== undefined) {
23
+ return new CustomSpeedScroll(tuiConfig.scroll_speed)
24
+ }
25
+
26
+ return new CustomSpeedScroll(3)
27
+ }
@@ -0,0 +1,79 @@
1
+ import type { ClipboardService } from "../context/clipboard"
2
+
3
+ type Toast = {
4
+ show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
5
+ error: (err: unknown) => void
6
+ }
7
+
8
+ type FocusableSelectionTarget = {
9
+ hasSelection: () => boolean
10
+ getClipboardText?: (text: string) => string
11
+ }
12
+
13
+ type Renderer = {
14
+ getSelection: () => { getSelectedText: () => string; selectedRenderables: FocusableSelectionTarget[] } | null
15
+ clearSelection: () => void
16
+ currentFocusedRenderable?: FocusableSelectionTarget | null
17
+ }
18
+
19
+ type SelectionKeyEvent = {
20
+ ctrl?: boolean
21
+ name: string
22
+ preventDefault: () => void
23
+ stopPropagation: () => void
24
+ }
25
+
26
+ export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardService): boolean {
27
+ const selection = renderer.getSelection()
28
+ if (!selection) return false
29
+
30
+ const text = selection.getSelectedText()
31
+ if (!text) return false
32
+
33
+ const focus = renderer.currentFocusedRenderable
34
+ const clipboardText =
35
+ focus?.getClipboardText && selection.selectedRenderables.includes(focus) ? focus.getClipboardText(text) : text
36
+
37
+ clipboard
38
+ ?.write?.(clipboardText)
39
+ .then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
40
+ .catch(toast.error)
41
+
42
+ renderer.clearSelection()
43
+ return true
44
+ }
45
+
46
+ export function handleSelectionKey(
47
+ renderer: Renderer,
48
+ toast: Toast,
49
+ event: SelectionKeyEvent,
50
+ clipboard: ClipboardService,
51
+ ) {
52
+ const selection = renderer.getSelection()
53
+ if (!selection) return
54
+
55
+ if (event.ctrl && event.name === "c") {
56
+ if (!copy(renderer, toast, clipboard)) {
57
+ renderer.clearSelection()
58
+ return
59
+ }
60
+
61
+ event.preventDefault()
62
+ event.stopPropagation()
63
+ return
64
+ }
65
+
66
+ if (event.name === "escape") {
67
+ renderer.clearSelection()
68
+ event.preventDefault()
69
+ event.stopPropagation()
70
+ return
71
+ }
72
+
73
+ const focus = renderer.currentFocusedRenderable
74
+ if (focus?.hasSelection() && selection.selectedRenderables.includes(focus)) return
75
+
76
+ renderer.clearSelection()
77
+ }
78
+
79
+ export * as Selection from "./selection"
@@ -0,0 +1,3 @@
1
+ export function isDefaultTitle(title: string) {
2
+ return /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
3
+ }
@@ -0,0 +1,51 @@
1
+ import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js"
2
+
3
+ export function createDebouncedSignal<T>(value: T, ms: number): [Accessor<T>, (value: T) => void] {
4
+ const [get, set] = createSignal(value)
5
+ let timer: ReturnType<typeof setTimeout> | undefined
6
+ const debounced = (next: T) => {
7
+ if (timer) clearTimeout(timer)
8
+ timer = setTimeout(() => {
9
+ timer = undefined
10
+ set(() => next)
11
+ }, ms)
12
+ }
13
+ onCleanup(() => {
14
+ if (timer) clearTimeout(timer)
15
+ })
16
+ return [get, debounced]
17
+ }
18
+
19
+ export function createFadeIn(show: Accessor<boolean>, enabled: Accessor<boolean>) {
20
+ const [alpha, setAlpha] = createSignal(show() ? 1 : 0)
21
+ let revealed = show()
22
+
23
+ createEffect(
24
+ on([show, enabled], ([visible, animate]) => {
25
+ if (!visible) {
26
+ setAlpha(0)
27
+ return
28
+ }
29
+
30
+ if (!animate || revealed) {
31
+ revealed = true
32
+ setAlpha(1)
33
+ return
34
+ }
35
+
36
+ const start = performance.now()
37
+ revealed = true
38
+ setAlpha(0)
39
+
40
+ const timer = setInterval(() => {
41
+ const progress = Math.min((performance.now() - start) / 160, 1)
42
+ setAlpha(progress * progress * (3 - 2 * progress))
43
+ if (progress >= 1) clearInterval(timer)
44
+ }, 16)
45
+
46
+ onCleanup(() => clearInterval(timer))
47
+ }),
48
+ )
49
+
50
+ return alpha
51
+ }
@@ -0,0 +1,20 @@
1
+ import { release } from "node:os"
2
+
3
+ export function describeOS() {
4
+ const name =
5
+ process.platform === "darwin"
6
+ ? "macOS"
7
+ : process.platform === "win32"
8
+ ? "Windows"
9
+ : process.platform === "linux"
10
+ ? "Linux"
11
+ : process.platform
12
+ return `${name} ${release()} (${process.arch})`
13
+ }
14
+
15
+ export function describeTerminal() {
16
+ const program = process.env.TERM_PROGRAM || process.env.TERM || "unknown"
17
+ const version = process.env.TERM_PROGRAM_VERSION ? ` ${process.env.TERM_PROGRAM_VERSION}` : ""
18
+ const multiplexer = process.env.TMUX ? " in tmux" : process.env.STY ? " in screen" : ""
19
+ return `${program}${version}${multiplexer}`
20
+ }
@@ -0,0 +1,13 @@
1
+ export function webSearchProviderLabel(provider: unknown) {
2
+ if (provider === "parallel") return "Parallel Web Search"
3
+ if (provider === "exa") return "Exa Web Search"
4
+ return "Web Search"
5
+ }
6
+
7
+ export function toolDisplayMetadata(state: unknown): Record<string, unknown> {
8
+ if (!state || typeof state !== "object" || Array.isArray(state)) return {}
9
+ if (!("status" in state) || state.status === "pending") return {}
10
+ if (!("structured" in state) || !state.structured || typeof state.structured !== "object") return {}
11
+ if (Array.isArray(state.structured)) return {}
12
+ return state.structured as Record<string, unknown>
13
+ }
@@ -0,0 +1,112 @@
1
+ import type { AssistantMessage, Part, Provider, UserMessage } from "@neurocode-ai/sdk/v2"
2
+ import { Locale } from "./locale"
3
+ import * as Model from "./model"
4
+
5
+ export type TranscriptOptions = {
6
+ thinking: boolean
7
+ toolDetails: boolean
8
+ assistantMetadata: boolean
9
+ providers?: Provider[]
10
+ }
11
+
12
+ export type SessionInfo = {
13
+ id: string
14
+ title: string
15
+ time: {
16
+ created: number
17
+ updated: number
18
+ }
19
+ }
20
+
21
+ export type MessageWithParts = {
22
+ info: UserMessage | AssistantMessage
23
+ parts: Part[]
24
+ }
25
+
26
+ export function formatTranscript(
27
+ session: SessionInfo,
28
+ messages: MessageWithParts[],
29
+ options: TranscriptOptions,
30
+ ): string {
31
+ const providers = Model.index(options.providers)
32
+ let transcript = `# ${session.title}\n\n`
33
+ transcript += `**Session ID:** ${session.id}\n`
34
+ transcript += `**Created:** ${new Date(session.time.created).toLocaleString()}\n`
35
+ transcript += `**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n`
36
+ transcript += `---\n\n`
37
+
38
+ for (const msg of messages) {
39
+ transcript += formatMessage(msg.info, msg.parts, options, providers)
40
+ transcript += `---\n\n`
41
+ }
42
+
43
+ return transcript
44
+ }
45
+
46
+ export function formatMessage(
47
+ msg: UserMessage | AssistantMessage,
48
+ parts: Part[],
49
+ options: TranscriptOptions,
50
+ providers?: Provider[] | ReadonlyMap<string, Provider>,
51
+ ): string {
52
+ let result = ""
53
+
54
+ if (msg.role === "user") {
55
+ result += `## User\n\n`
56
+ } else {
57
+ result += formatAssistantHeader(msg, options.assistantMetadata, providers ?? options.providers)
58
+ }
59
+
60
+ for (const part of parts) {
61
+ result += formatPart(part, options)
62
+ }
63
+
64
+ return result
65
+ }
66
+
67
+ export function formatAssistantHeader(
68
+ msg: AssistantMessage,
69
+ includeMetadata: boolean,
70
+ providers?: Provider[] | ReadonlyMap<string, Provider>,
71
+ ): string {
72
+ if (!includeMetadata) {
73
+ return `## Assistant\n\n`
74
+ }
75
+
76
+ const duration =
77
+ msg.time.completed && msg.time.created ? ((msg.time.completed - msg.time.created) / 1000).toFixed(1) + "s" : ""
78
+
79
+ const modelName = Model.name(providers, msg.providerID, msg.modelID)
80
+
81
+ return `## Assistant (${Locale.titlecase(msg.agent)} · ${modelName}${duration ? ` · ${duration}` : ""})\n\n`
82
+ }
83
+
84
+ export function formatPart(part: Part, options: TranscriptOptions): string {
85
+ if (part.type === "text" && !part.synthetic) {
86
+ return `${part.text}\n\n`
87
+ }
88
+
89
+ if (part.type === "reasoning") {
90
+ if (options.thinking) {
91
+ return `_Thinking:_\n\n${part.text}\n\n`
92
+ }
93
+ return ""
94
+ }
95
+
96
+ if (part.type === "tool") {
97
+ let result = `**Tool: ${part.tool}**\n`
98
+ if (options.toolDetails && part.state.input) {
99
+ result += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`\n`
100
+ }
101
+ if (options.toolDetails && part.state.status === "completed" && part.state.output) {
102
+ result += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`\n`
103
+ }
104
+ if (options.toolDetails && part.state.status === "error" && part.state.error) {
105
+ result += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`\n`
106
+ }
107
+ result += `\n`
108
+ return result
109
+ }
110
+
111
+ return ""
112
+ }
package/sst-env.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ /* This file is auto-generated by SST. Do not edit. */
2
+ /* tslint:disable */
3
+ /* eslint-disable */
4
+ /* deno-fmt-ignore-file */
5
+ /* biome-ignore-all lint: auto-generated */
6
+
7
+ /// <reference path="../../sst-env.d.ts" />
8
+
9
+ import "sst"
10
+ export {}
@@ -0,0 +1,128 @@
1
+ import { expect, mock, test } from "bun:test"
2
+ import type { TuiPluginApi } from "@neurocode-ai/plugin/tui"
3
+ import { createTestRenderer } from "@opentui/core/testing"
4
+ import { Effect } from "effect"
5
+ import { AppNodeBuilder } from "@neurocode-ai/core/effect/app-node-builder"
6
+ import { Global } from "@neurocode-ai/core/global"
7
+ import { createTuiResolvedConfig } from "./fixture/tui-runtime"
8
+ import { createEventSource, createFetch, directory, json } from "./fixture/tui-sdk"
9
+
10
+ test("SIGHUP clears title and disposes scoped resources once", async () => {
11
+ const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
12
+ const core = await import("@opentui/core")
13
+ mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
14
+ const titles: string[] = []
15
+ const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
16
+ setup.renderer.setTerminalTitle = (title) => {
17
+ titles.push(title)
18
+ setTitle(title)
19
+ }
20
+ const listeners = new Set(process.listeners("SIGHUP"))
21
+ const events = createEventSource()
22
+ const calls = createFetch()
23
+ let started!: () => void
24
+ const ready = new Promise<void>((resolve) => {
25
+ started = resolve
26
+ })
27
+ let disposes = 0
28
+
29
+ try {
30
+ const { run } = await import("../src/app")
31
+ const task = Effect.runPromise(
32
+ run({
33
+ url: "http://test",
34
+ directory,
35
+ config: createTuiResolvedConfig({ plugin_enabled: {} }),
36
+ fetch: calls.fetch,
37
+ events: events.source,
38
+ args: {},
39
+ pluginHost: {
40
+ async start() {
41
+ started()
42
+ },
43
+ async dispose() {
44
+ disposes++
45
+ },
46
+ },
47
+ }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))),
48
+ )
49
+ await ready
50
+ process.emit("SIGHUP")
51
+ await task
52
+
53
+ expect(setup.renderer.isDestroyed).toBe(true)
54
+ expect(titles.at(-1)).toBe("")
55
+ expect(disposes).toBe(1)
56
+ expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true)
57
+ } finally {
58
+ if (!setup.renderer.isDestroyed) setup.renderer.destroy()
59
+ mock.restore()
60
+ }
61
+ })
62
+
63
+ test("app.exit prints the session epilogue after scoped cleanup", async () => {
64
+ const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
65
+ const core = await import("@opentui/core")
66
+ mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
67
+ const events = createEventSource()
68
+ const calls = createFetch((url) => {
69
+ if (url.pathname === "/session")
70
+ return json([
71
+ {
72
+ id: "dummy",
73
+ title: "Demo session",
74
+ slug: "dummy",
75
+ projectID: "project",
76
+ directory,
77
+ version: "0.0.0-test",
78
+ time: { created: 0, updated: 0 },
79
+ },
80
+ ])
81
+ })
82
+ const originalWrite = process.stdout.write.bind(process.stdout)
83
+ let stdout = ""
84
+ let api: TuiPluginApi | undefined
85
+ let started!: () => void
86
+ const ready = new Promise<void>((resolve) => {
87
+ started = resolve
88
+ })
89
+
90
+ process.stdout.write = ((chunk: string | Uint8Array) => {
91
+ stdout += String(chunk)
92
+ return true
93
+ }) as typeof process.stdout.write
94
+
95
+ try {
96
+ const { run } = await import("../src/app")
97
+ const task = Effect.runPromise(
98
+ run({
99
+ url: "http://test",
100
+ directory,
101
+ config: createTuiResolvedConfig({ plugin_enabled: {} }),
102
+ fetch: calls.fetch,
103
+ events: events.source,
104
+ args: { continue: true },
105
+ pluginHost: {
106
+ async start(input) {
107
+ api = input.api
108
+ started()
109
+ },
110
+ async dispose() {},
111
+ },
112
+ }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))),
113
+ )
114
+
115
+ await ready
116
+ await setup.renderOnce()
117
+ await setup.renderOnce()
118
+ api?.keymap.dispatchCommand("app.exit")
119
+ await task
120
+
121
+ expect(stdout).toContain("Demo session")
122
+ expect(stdout).toContain("opencode -s dummy")
123
+ } finally {
124
+ process.stdout.write = originalWrite
125
+ if (!setup.renderer.isDestroyed) setup.renderer.destroy()
126
+ mock.restore()
127
+ }
128
+ })
@@ -0,0 +1,28 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { recentConnectedWorkspaces } from "../../../../src/component/dialog-workspace-create"
3
+
4
+ describe("recentConnectedWorkspaces", () => {
5
+ test("returns connected workspaces sorted by time used", () => {
6
+ const workspaces = [
7
+ { id: "wrk_a", name: "alpha", timeUsed: 700 },
8
+ { id: "wrk_b", name: "beta", timeUsed: 800 },
9
+ { id: "wrk_c", name: "gamma", timeUsed: 400 },
10
+ { id: "wrk_d", name: "delta", timeUsed: 300 },
11
+ { id: "wrk_e", name: "epsilon", timeUsed: 200 },
12
+ ]
13
+ const status = {
14
+ wrk_a: "connected",
15
+ wrk_b: "disconnected",
16
+ wrk_c: "error",
17
+ wrk_d: "connected",
18
+ wrk_e: "connected",
19
+ } as const
20
+
21
+ const { recent } = recentConnectedWorkspaces({
22
+ workspaces,
23
+ status: (workspaceID) => status[workspaceID as keyof typeof status],
24
+ })
25
+
26
+ expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_a", "wrk_d", "wrk_e"])
27
+ })
28
+ })
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { sortModelOptions } from "../../../../src/component/dialog-model"
3
+
4
+ describe("sortModelOptions", () => {
5
+ test("orders provider-scoped model choices by newest release first", () => {
6
+ const sorted = sortModelOptions(
7
+ [
8
+ { title: "GPT 5.2", releaseDate: "2025-12-11" },
9
+ { title: "GPT 5.4", releaseDate: "2026-03-05" },
10
+ { title: "GPT 5.1", releaseDate: "2025-11-13" },
11
+ ],
12
+ true,
13
+ )
14
+
15
+ expect(sorted.map((model) => model.title)).toEqual(["GPT 5.4", "GPT 5.2", "GPT 5.1"])
16
+ })
17
+
18
+ test("orders regular model choices free-first and then newest-first", () => {
19
+ const sorted = sortModelOptions(
20
+ [
21
+ { title: "GLM 5", releaseDate: "2025-07-28" },
22
+ { title: "GLM 5.1", releaseDate: "2025-12-09" },
23
+ { title: "GLM 5.2", releaseDate: "2026-02-16" },
24
+ { title: "Free old", releaseDate: "2024-01-01", footer: "Free" },
25
+ { title: "Free new", releaseDate: "2025-01-01", footer: "Free" },
26
+ ],
27
+ false,
28
+ )
29
+
30
+ expect(sorted.map((model) => model.title)).toEqual(["Free new", "Free old", "GLM 5.2", "GLM 5.1", "GLM 5"])
31
+ })
32
+ })