@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,99 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import { useTheme } from "../context/theme"
3
+ import { useDialog } from "../ui/dialog"
4
+ import { createStore } from "solid-js/store"
5
+ import { For } from "solid-js"
6
+ import { useBindings } from "../keymap"
7
+
8
+ export function DialogSessionDeleteFailed(props: {
9
+ session: string
10
+ workspace: string
11
+ onDelete?: () => boolean | void | Promise<boolean | void>
12
+ onRestore?: () => boolean | void | Promise<boolean | void>
13
+ onDone?: () => void
14
+ }) {
15
+ const dialog = useDialog()
16
+ const { theme } = useTheme()
17
+ const [store, setStore] = createStore({
18
+ active: "delete" as "delete" | "restore",
19
+ })
20
+
21
+ const options = [
22
+ {
23
+ id: "delete" as const,
24
+ title: "Delete workspace",
25
+ description: "Delete the workspace and all sessions attached to it.",
26
+ run: props.onDelete,
27
+ },
28
+ {
29
+ id: "restore" as const,
30
+ title: "Restore to new workspace",
31
+ description: "Try to restore this session into a new workspace.",
32
+ run: props.onRestore,
33
+ },
34
+ ]
35
+
36
+ async function confirm() {
37
+ const result = await options.find((item) => item.id === store.active)?.run?.()
38
+ if (result === false) return
39
+ props.onDone?.()
40
+ if (!props.onDone) dialog.clear()
41
+ }
42
+
43
+ useBindings(() => ({
44
+ bindings: [
45
+ { key: "return", desc: "Confirm recovery option", group: "Dialog", cmd: () => void confirm() },
46
+ { key: "left", desc: "Delete broken session", group: "Dialog", cmd: () => setStore("active", "delete") },
47
+ { key: "up", desc: "Delete broken session", group: "Dialog", cmd: () => setStore("active", "delete") },
48
+ { key: "right", desc: "Restore broken session", group: "Dialog", cmd: () => setStore("active", "restore") },
49
+ { key: "down", desc: "Restore broken session", group: "Dialog", cmd: () => setStore("active", "restore") },
50
+ ],
51
+ }))
52
+
53
+ return (
54
+ <box paddingLeft={2} paddingRight={2} gap={1}>
55
+ <box flexDirection="row" justifyContent="space-between">
56
+ <text attributes={TextAttributes.BOLD} fg={theme.text}>
57
+ Failed to Delete Session
58
+ </text>
59
+ <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
60
+ esc
61
+ </text>
62
+ </box>
63
+ <text fg={theme.textMuted} wrapMode="word">
64
+ {`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
65
+ </text>
66
+ <text fg={theme.textMuted} wrapMode="word">
67
+ Choose how you want to recover this broken workspace session.
68
+ </text>
69
+ <box flexDirection="column" paddingBottom={1} gap={1}>
70
+ <For each={options}>
71
+ {(item) => (
72
+ <box
73
+ flexDirection="column"
74
+ paddingLeft={1}
75
+ paddingRight={1}
76
+ paddingTop={1}
77
+ paddingBottom={1}
78
+ backgroundColor={item.id === store.active ? theme.primary : undefined}
79
+ onMouseUp={() => {
80
+ setStore("active", item.id)
81
+ void confirm()
82
+ }}
83
+ >
84
+ <text
85
+ attributes={TextAttributes.BOLD}
86
+ fg={item.id === store.active ? theme.selectedListItemText : theme.text}
87
+ >
88
+ {item.title}
89
+ </text>
90
+ <text fg={item.id === store.active ? theme.selectedListItemText : theme.textMuted} wrapMode="word">
91
+ {item.description}
92
+ </text>
93
+ </box>
94
+ )}
95
+ </For>
96
+ </box>
97
+ </box>
98
+ )
99
+ }
@@ -0,0 +1,364 @@
1
+ import { useDialog } from "../ui/dialog"
2
+ import { DialogSelect } from "../ui/dialog-select"
3
+ import { useRoute } from "../context/route"
4
+ import { useSync } from "../context/sync"
5
+ import { createMemo, createResource, createSignal, onCleanup, onMount } from "solid-js"
6
+ import path from "path"
7
+ import { Locale } from "../util/locale"
8
+ import { useProject } from "../context/project"
9
+ import { useTheme } from "../context/theme"
10
+ import { useSDK } from "../context/sdk"
11
+ import { useLocal } from "../context/local"
12
+ import { DialogSessionRename } from "./dialog-session-rename"
13
+ import { createDebouncedSignal } from "../util/signal"
14
+ import { useToast } from "../ui/toast"
15
+ import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create"
16
+ import { Spinner } from "./spinner"
17
+ import { errorMessage } from "../util/error"
18
+ import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
19
+ import { useCommandShortcut } from "../keymap"
20
+ import { useEvent } from "../context/event"
21
+
22
+ type SessionListFilter = { scope?: "project"; path?: string }
23
+
24
+ export function createDialogSessionListQuery(input: { search?: string; filter: SessionListFilter }) {
25
+ const search = input.search?.trim()
26
+ return {
27
+ roots: true,
28
+ limit: search ? 30 : 100,
29
+ ...(search ? { search } : {}),
30
+ ...input.filter,
31
+ }
32
+ }
33
+
34
+ export function loadDialogSessionList<T>(input: {
35
+ search?: string
36
+ filter: SessionListFilter
37
+ list: (query: ReturnType<typeof createDialogSessionListQuery>) => Promise<{ data?: T[] }>
38
+ }) {
39
+ return input.list(createDialogSessionListQuery(input)).then(
40
+ (result) => result.data,
41
+ () => undefined,
42
+ )
43
+ }
44
+
45
+ export function DialogSessionList() {
46
+ const dialog = useDialog()
47
+ const route = useRoute()
48
+ const sync = useSync()
49
+ const project = useProject()
50
+ const { theme } = useTheme()
51
+ const sdk = useSDK()
52
+ const event = useEvent()
53
+ const local = useLocal()
54
+ const toast = useToast()
55
+ const [toDelete, setToDelete] = createSignal<string>()
56
+ const [deleted, setDeleted] = createSignal(new Set<string>())
57
+ const [search, setSearch] = createDebouncedSignal("", 150)
58
+ const deleteHint = useCommandShortcut("session.delete")
59
+ const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
60
+ const quickSwitch9 = useCommandShortcut("session.quick_switch.9")
61
+
62
+ const [browseResults, { refetch: refetchBrowse }] = createResource(
63
+ () => sync.session.query(),
64
+ (filter) => loadDialogSessionList({ filter, list: (query) => sdk.client.session.list(query) }),
65
+ )
66
+ const [searchResults, { refetch }] = createResource(
67
+ () => ({ query: search(), filter: sync.session.query() }),
68
+ (input) => {
69
+ if (!input.query) return undefined
70
+ return loadDialogSessionList({
71
+ search: input.query,
72
+ filter: input.filter,
73
+ list: (query) => sdk.client.session.list(query),
74
+ })
75
+ },
76
+ )
77
+
78
+ const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
79
+ const sessions = createMemo(() => {
80
+ const result = searchResults() ?? browseResults() ?? sync.data.session
81
+ const synced = new Map(sync.data.session.map((session) => [session.id, session]))
82
+ const ids = new Set(result.map((session) => session.id))
83
+ const extra = [currentSessionID(), ...local.session.pinned()].flatMap((id) => {
84
+ if (!id || ids.has(id)) return []
85
+ const session = synced.get(id)
86
+ if (session) ids.add(id)
87
+ return session ? [session] : []
88
+ })
89
+ const query = search().trim().toLowerCase()
90
+ return [...result.map((session) => synced.get(session.id) ?? session), ...extra]
91
+ .filter((session) => !deleted().has(session.id))
92
+ .filter((session) => !query || session.title.toLowerCase().includes(query))
93
+ })
94
+
95
+ onCleanup(
96
+ event.on("session.deleted", (event) => {
97
+ setDeleted((current) => new Set(current).add(event.properties.info.id))
98
+ }),
99
+ )
100
+
101
+ function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
102
+ const workspace = project.workspace.get(session.workspaceID!)
103
+ const list = () => dialog.replace(() => <DialogSessionList />)
104
+ const warp = async (selection: WorkspaceSelection) => {
105
+ const workspaceID = await (async () => {
106
+ if (selection.type === "none") return null
107
+ if (selection.type === "existing") return selection.workspaceID
108
+ let result
109
+ try {
110
+ result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null })
111
+ } catch (err) {
112
+ toast.show({
113
+ title: "Failed to create workspace",
114
+ message: errorMessage(err),
115
+ variant: "error",
116
+ })
117
+ return
118
+ }
119
+ const workspace = result?.data
120
+ if (!workspace) {
121
+ toast.show({
122
+ title: "Failed to create workspace",
123
+ message: errorMessage(result?.error ?? "no response"),
124
+ variant: "error",
125
+ })
126
+ return
127
+ }
128
+ await project.workspace.sync()
129
+ return workspace.id
130
+ })()
131
+ if (workspaceID === undefined) return
132
+ await warpWorkspaceSession({
133
+ dialog,
134
+ sdk,
135
+ sync,
136
+ project,
137
+ toast,
138
+ sourceWorkspaceID: session.workspaceID,
139
+ workspaceID,
140
+ sessionID: session.id,
141
+ copyChanges: false,
142
+ done: list,
143
+ })
144
+ }
145
+ dialog.replace(() => (
146
+ <DialogSessionDeleteFailed
147
+ session={session.title}
148
+ workspace={workspace?.name ?? session.workspaceID!}
149
+ onDone={list}
150
+ onDelete={async () => {
151
+ const current = currentSessionID()
152
+ const info = current ? sync.data.session.find((item) => item.id === current) : undefined
153
+ const result = await sdk.client.experimental.workspace.remove({ id: session.workspaceID! })
154
+ if (result.error) {
155
+ toast.show({
156
+ variant: "error",
157
+ title: "Failed to delete workspace",
158
+ message: errorMessage(result.error),
159
+ })
160
+ return false
161
+ }
162
+ await project.workspace.sync()
163
+ await sync.session.refresh()
164
+ await refetchBrowse()
165
+ if (search()) await refetch()
166
+ if (info?.workspaceID === session.workspaceID) {
167
+ route.navigate({ type: "home" })
168
+ }
169
+ return true
170
+ }}
171
+ onRestore={() => {
172
+ void openWorkspaceSelect({
173
+ dialog,
174
+ sdk,
175
+ sync,
176
+ project,
177
+ toast,
178
+ onSelect: (selection) => {
179
+ void warp(selection)
180
+ },
181
+ })
182
+ return false
183
+ }}
184
+ />
185
+ ))
186
+ }
187
+
188
+ function orderByRecency(sessionsList: NonNullable<ReturnType<typeof sessions>>) {
189
+ return sessionsList
190
+ .filter((x) => x.parentID === undefined)
191
+ .toSorted((a, b) => b.time.updated - a.time.updated)
192
+ .map((x) => x.id)
193
+ }
194
+
195
+ const browseOrder = createMemo(() => orderByRecency(browseResults() ?? sync.data.session))
196
+
197
+ const quickSwitchHint = createMemo(() => {
198
+ const first = quickSwitch1()
199
+ const last = quickSwitch9()
200
+ if (!first || !last) return undefined
201
+ return quickSwitchRange(first, last)
202
+ })
203
+ const quickSwitchFooterHints = createMemo(() => {
204
+ const hint = quickSwitchHint()
205
+ return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : []
206
+ })
207
+
208
+ const options = createMemo(() => {
209
+ const today = new Date().toDateString()
210
+ const sessionMap = new Map(
211
+ sessions()
212
+ .filter((x) => x.parentID === undefined)
213
+ .map((x) => [x.id, x]),
214
+ )
215
+
216
+ const searchResult = searchResults()
217
+ const order = searchResult ? orderByRecency(sessions()) : browseOrder()
218
+ const current = currentSessionID()
219
+ const displayOrder = current && sessionMap.has(current) && !order.includes(current) ? [...order, current] : order
220
+
221
+ const pinned = local.session.pinned().filter((id) => sessionMap.has(id))
222
+ const pinnedSet = new Set(pinned)
223
+ const slotByID = new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
224
+
225
+ function buildOption(id: string, category: string) {
226
+ const x = sessionMap.get(id)
227
+ if (!x) return undefined
228
+ const directory = x.path
229
+ ? x.directory.endsWith(x.path)
230
+ ? x.directory.slice(0, -x.path.length).replace(/\/$/, "")
231
+ : undefined
232
+ : x.directory
233
+ const footer =
234
+ directory && directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : ""
235
+
236
+ const isDeleting = toDelete() === x.id
237
+ const status = sync.data.session_status?.[x.id]
238
+ const isWorking = status?.type === "busy" || status?.type === "retry"
239
+ const slot = slotByID.get(x.id)
240
+ const gutter = isWorking
241
+ ? () => <Spinner />
242
+ : slot !== undefined
243
+ ? () => <text fg={theme.accent}>{slot}</text>
244
+ : undefined
245
+ return {
246
+ title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title,
247
+ bg: isDeleting ? theme.error : undefined,
248
+ value: x.id,
249
+ category,
250
+ footer,
251
+ gutter,
252
+ }
253
+ }
254
+
255
+ const remaining = displayOrder
256
+ .filter((id) => !pinnedSet.has(id))
257
+ .map((id) => {
258
+ const x = sessionMap.get(id)
259
+ if (!x) return undefined
260
+ const label = new Date(x.time.updated).toDateString()
261
+ return buildOption(id, label === today ? "Today" : label)
262
+ })
263
+ .filter((x) => x !== undefined)
264
+
265
+ return [...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined), ...remaining]
266
+ })
267
+
268
+ onMount(() => {
269
+ dialog.setSize("large")
270
+ })
271
+
272
+ return (
273
+ <DialogSelect
274
+ title="Sessions"
275
+ options={options()}
276
+ skipFilter={true}
277
+ preserveSelection={true}
278
+ current={currentSessionID()}
279
+ onFilter={setSearch}
280
+ onMove={() => {
281
+ setToDelete(undefined)
282
+ }}
283
+ onSelect={(option) => {
284
+ route.navigate({
285
+ type: "session",
286
+ sessionID: option.value,
287
+ })
288
+ dialog.clear()
289
+ }}
290
+ actions={[
291
+ {
292
+ command: "session.pin.toggle",
293
+ title: "pin/unpin",
294
+ onTrigger: (option: { value: string }) => {
295
+ local.session.togglePin(option.value)
296
+ },
297
+ },
298
+ {
299
+ command: "session.delete",
300
+ title: "delete",
301
+ onTrigger: async (option) => {
302
+ if (toDelete() === option.value) {
303
+ const session = sessions().find((item) => item.id === option.value)
304
+ const status = session?.workspaceID ? project.workspace.status(session.workspaceID) : undefined
305
+
306
+ try {
307
+ const result = await sdk.client.session.delete({
308
+ sessionID: option.value,
309
+ })
310
+ if (result.error) {
311
+ if (session?.workspaceID) {
312
+ recover(session)
313
+ } else {
314
+ toast.show({
315
+ variant: "error",
316
+ title: "Failed to delete session",
317
+ message: errorMessage(result.error),
318
+ })
319
+ }
320
+ setToDelete(undefined)
321
+ return
322
+ }
323
+ } catch (err) {
324
+ if (session?.workspaceID) {
325
+ recover(session)
326
+ } else {
327
+ toast.show({
328
+ variant: "error",
329
+ title: "Failed to delete session",
330
+ message: errorMessage(err),
331
+ })
332
+ }
333
+ setToDelete(undefined)
334
+ return
335
+ }
336
+ if (status && status !== "connected") {
337
+ await sync.session.refresh()
338
+ }
339
+ await refetchBrowse()
340
+ if (search()) await refetch()
341
+ setToDelete(undefined)
342
+ return
343
+ }
344
+ setToDelete(option.value)
345
+ },
346
+ },
347
+ {
348
+ command: "session.rename",
349
+ title: "rename",
350
+ onTrigger: async (option) => {
351
+ dialog.replace(() => <DialogSessionRename session={option.value} />)
352
+ },
353
+ },
354
+ ]}
355
+ footerHints={quickSwitchFooterHints()}
356
+ />
357
+ )
358
+ }
359
+
360
+ function quickSwitchRange(first: string, last: string) {
361
+ const prefix = first.slice(0, -1)
362
+ if (first.endsWith("1") && last === `${prefix}9`) return `${prefix}1-9`
363
+ return `${first} through ${last}`
364
+ }
@@ -0,0 +1,31 @@
1
+ import { DialogPrompt } from "../ui/dialog-prompt"
2
+ import { useDialog } from "../ui/dialog"
3
+ import { useSync } from "../context/sync"
4
+ import { createMemo } from "solid-js"
5
+ import { useSDK } from "../context/sdk"
6
+
7
+ interface DialogSessionRenameProps {
8
+ session: string
9
+ }
10
+
11
+ export function DialogSessionRename(props: DialogSessionRenameProps) {
12
+ const dialog = useDialog()
13
+ const sync = useSync()
14
+ const sdk = useSDK()
15
+ const session = createMemo(() => sync.session.get(props.session))
16
+
17
+ return (
18
+ <DialogPrompt
19
+ title="Rename Session"
20
+ value={session()?.title}
21
+ onConfirm={(value) => {
22
+ void sdk.client.session.update({
23
+ sessionID: props.session,
24
+ title: value,
25
+ })
26
+ dialog.clear()
27
+ }}
28
+ onCancel={() => dialog.clear()}
29
+ />
30
+ )
31
+ }
@@ -0,0 +1,70 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
3
+ import { createResource, createMemo, createSignal } from "solid-js"
4
+ import { useDialog } from "../ui/dialog"
5
+ import { useSDK } from "../context/sdk"
6
+ import { useTheme } from "../context/theme"
7
+ import { errorMessage } from "../util/error"
8
+
9
+ export type DialogSkillProps = {
10
+ onSelect: (skill: string) => void
11
+ }
12
+
13
+ export function DialogSkill(props: DialogSkillProps) {
14
+ const dialog = useDialog()
15
+ const sdk = useSDK()
16
+ const { theme } = useTheme()
17
+ dialog.setSize("large")
18
+
19
+ const [loadError, setLoadError] = createSignal<unknown>()
20
+
21
+ const [skills] = createResource(() =>
22
+ sdk.client.app
23
+ .skills({}, { throwOnError: true })
24
+ .then((result) => result.data ?? [])
25
+ // Catch so the rejected resource never reaches the memo below: reading
26
+ // skills() in an errored state re-throws and tears down the dialog.
27
+ .catch((error) => {
28
+ setLoadError(error)
29
+ return undefined
30
+ }),
31
+ )
32
+
33
+ const showError = createMemo(() => Boolean(loadError()))
34
+
35
+ const options = createMemo<DialogSelectOption<string>[]>(() => {
36
+ if (showError()) return []
37
+ const list = skills() ?? []
38
+ const maxWidth = Math.max(0, ...list.map((s) => s.name.length))
39
+ return list.map((skill) => ({
40
+ title: skill.name.padEnd(maxWidth),
41
+ description: skill.description?.replace(/\s+/g, " ").trim(),
42
+ value: skill.name,
43
+ category: "Skills",
44
+ onSelect: () => {
45
+ props.onSelect(skill.name)
46
+ dialog.clear()
47
+ },
48
+ }))
49
+ })
50
+
51
+ return (
52
+ <DialogSelect
53
+ title="Skills"
54
+ placeholder="Search skills..."
55
+ options={options()}
56
+ renderFilter={!showError()}
57
+ locked={showError()}
58
+ emptyView={
59
+ showError() ? (
60
+ <box paddingLeft={4} paddingRight={4}>
61
+ <text fg={theme.error} attributes={TextAttributes.BOLD}>
62
+ Could not load skills
63
+ </text>
64
+ <text fg={theme.textMuted}>{errorMessage(loadError())}</text>
65
+ </box>
66
+ ) : undefined
67
+ }
68
+ />
69
+ )
70
+ }
@@ -0,0 +1,87 @@
1
+ import { useDialog } from "../ui/dialog"
2
+ import { DialogSelect } from "../ui/dialog-select"
3
+ import { createMemo, createSignal } from "solid-js"
4
+ import { Locale } from "../util/locale"
5
+ import { useTheme } from "../context/theme"
6
+ import { usePromptStash, type StashEntry } from "./prompt/stash"
7
+ import { useCommandShortcut } from "../keymap"
8
+
9
+ function getRelativeTime(timestamp: number): string {
10
+ const now = Date.now()
11
+ const diff = now - timestamp
12
+ const seconds = Math.floor(diff / 1000)
13
+ const minutes = Math.floor(seconds / 60)
14
+ const hours = Math.floor(minutes / 60)
15
+ const days = Math.floor(hours / 24)
16
+
17
+ if (seconds < 60) return "just now"
18
+ if (minutes < 60) return `${minutes}m ago`
19
+ if (hours < 24) return `${hours}h ago`
20
+ if (days < 7) return `${days}d ago`
21
+ return Locale.datetime(timestamp)
22
+ }
23
+
24
+ function getStashPreview(input: string, maxLength: number = 50): string {
25
+ const firstLine = input.split("\n")[0].trim()
26
+ return Locale.truncate(firstLine, maxLength)
27
+ }
28
+
29
+ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
30
+ const dialog = useDialog()
31
+ const stash = usePromptStash()
32
+ const { theme } = useTheme()
33
+
34
+ const [toDelete, setToDelete] = createSignal<number>()
35
+ const deleteHint = useCommandShortcut("stash.delete")
36
+
37
+ const options = createMemo(() => {
38
+ const entries = stash.list()
39
+ // Show most recent first
40
+ return entries
41
+ .map((entry, index) => {
42
+ const isDeleting = toDelete() === index
43
+ const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1
44
+ return {
45
+ title: isDeleting ? `Press ${deleteHint()} again to confirm` : getStashPreview(entry.input),
46
+ bg: isDeleting ? theme.error : undefined,
47
+ value: index,
48
+ description: getRelativeTime(entry.timestamp),
49
+ footer: lineCount > 1 ? `~${lineCount} lines` : undefined,
50
+ }
51
+ })
52
+ .toReversed()
53
+ })
54
+
55
+ return (
56
+ <DialogSelect
57
+ title="Stash"
58
+ options={options()}
59
+ onMove={() => {
60
+ setToDelete(undefined)
61
+ }}
62
+ onSelect={(option) => {
63
+ const entries = stash.list()
64
+ const entry = entries[option.value]
65
+ if (entry) {
66
+ stash.remove(option.value)
67
+ props.onSelect(entry)
68
+ }
69
+ dialog.clear()
70
+ }}
71
+ actions={[
72
+ {
73
+ command: "stash.delete",
74
+ title: "delete",
75
+ onTrigger: (option) => {
76
+ if (toDelete() === option.value) {
77
+ stash.remove(option.value)
78
+ setToDelete(undefined)
79
+ return
80
+ }
81
+ setToDelete(option.value)
82
+ },
83
+ },
84
+ ]}
85
+ />
86
+ )
87
+ }