snow-flow 10.0.185 → 10.0.186-dev.682

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 (62) hide show
  1. package/bin/index.js.map +9 -9
  2. package/bin/worker.js.map +7 -7
  3. package/mcp/servicenow-unified.js +116 -116
  4. package/package.json +1 -1
  5. package/parsers-config.ts +2 -1
  6. package/src/bun/index.ts +10 -9
  7. package/src/cli/cmd/agent.ts +3 -3
  8. package/src/cli/cmd/auth.ts +46 -0
  9. package/src/cli/cmd/import.ts +2 -2
  10. package/src/cli/cmd/session.ts +9 -12
  11. package/src/cli/cmd/tui/component/prompt/autocomplete.tsx +2 -1
  12. package/src/cli/cmd/tui/component/prompt/index.tsx +19 -6
  13. package/src/cli/cmd/tui/component/spinner.tsx +24 -0
  14. package/src/cli/cmd/tui/context/exit.tsx +1 -1
  15. package/src/cli/cmd/tui/routes/home.tsx +16 -2
  16. package/src/cli/cmd/tui/routes/session/index.tsx +122 -53
  17. package/src/cli/cmd/tui/routes/session/permission.tsx +9 -1
  18. package/src/cli/cmd/tui/routes/session/sidebar.tsx +9 -1
  19. package/src/cli/cmd/tui/thread.ts +4 -1
  20. package/src/cli/cmd/tui/ui/dialog-export-options.tsx +1 -1
  21. package/src/cli/cmd/tui/util/clipboard.ts +3 -3
  22. package/src/cli/cmd/tui/worker.ts +6 -1
  23. package/src/config/config.ts +28 -0
  24. package/src/context/context-db.ts +437 -0
  25. package/src/format/formatter.ts +14 -5
  26. package/src/global/index.ts +3 -4
  27. package/src/mcp/index.ts +7 -2
  28. package/src/mcp/oauth-callback.ts +7 -15
  29. package/src/mcp/oauth-provider.ts +34 -3
  30. package/src/project/project.ts +8 -4
  31. package/src/provider/models.ts +1 -1
  32. package/src/provider/provider.ts +88 -9
  33. package/src/provider/transform.ts +7 -2
  34. package/src/servicenow/servicenow-mcp-unified/tools/agile/snow_agile_capacity_plan.ts +20 -7
  35. package/src/servicenow/servicenow-mcp-unified/tools/agile/snow_agile_retrospective.ts +6 -8
  36. package/src/servicenow/servicenow-mcp-unified/tools/agile/snow_agile_sprint_manage.ts +46 -28
  37. package/src/servicenow/servicenow-mcp-unified/tools/agile/snow_agile_team_manage.ts +53 -41
  38. package/src/servicenow/servicenow-mcp-unified/tools/agile/snow_agile_velocity_report.ts +8 -1
  39. package/src/servicenow/servicenow-mcp-unified/tools/automation/snow_schedule_script_job.ts +388 -243
  40. package/src/session/compaction.ts +126 -23
  41. package/src/session/message-v2.ts +33 -10
  42. package/src/session/processor.ts +29 -17
  43. package/src/session/prompt.ts +34 -6
  44. package/src/share/share-next.ts +2 -2
  45. package/src/shell/shell.ts +2 -1
  46. package/src/tool/edit.ts +15 -1
  47. package/src/tool/registry.ts +9 -1
  48. package/src/tool/truncation.ts +17 -0
  49. package/src/tool/websearch.ts +1 -1
  50. package/src/tool/websearch.txt +2 -2
  51. package/src/tool/write.ts +3 -4
  52. package/src/util/filesystem.ts +36 -7
  53. package/src/util/keybind.ts +1 -1
  54. package/src/util/log.ts +8 -5
  55. package/src/util/token.ts +28 -0
  56. package/test/cli/plugin-auth-picker.test.ts +120 -0
  57. package/test/fixture/fixture.ts +3 -0
  58. package/test/mcp/oauth-auto-connect.test.ts +197 -0
  59. package/test/project/project.test.ts +47 -0
  60. package/test/provider/provider.test.ts +2 -0
  61. package/test/provider/transform.test.ts +32 -0
  62. package/test/tool/edit.test.ts +679 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
- "version": "10.0.185",
3
+ "version": "10.0.186-dev.682",
4
4
  "name": "snow-flow",
5
5
  "description": "Snow-Flow - ServiceNow Multi-Agent Development Framework powered by AI",
6
6
  "license": "Elastic-2.0",
package/parsers-config.ts CHANGED
@@ -214,7 +214,8 @@ export default {
214
214
  },
215
215
  {
216
216
  filetype: "clojure",
217
- wasm: "https://github.com/sogaiu/tree-sitter-clojure/releases/download/v0.0.13/tree-sitter-clojure.wasm",
217
+ // temporarily using fork to fix issues
218
+ wasm: "https://github.com/anomalyco/tree-sitter-clojure/releases/download/v0.0.1/tree-sitter-clojure.wasm",
218
219
  queries: {
219
220
  highlights: [
220
221
  "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/clojure/highlights.scm",
package/src/bun/index.ts CHANGED
@@ -66,14 +66,14 @@ export namespace BunProc {
66
66
  using _ = await Lock.write("bun-install")
67
67
 
68
68
  const mod = path.join(Global.Path.cache, "node_modules", pkg)
69
- const pkgjson = Bun.file(path.join(Global.Path.cache, "package.json"))
70
- const parsed = await pkgjson.json().catch(async () => {
71
- const result = { dependencies: {} }
72
- await Bun.write(pkgjson.name!, JSON.stringify(result, null, 2))
69
+ const pkgjsonPath = path.join(Global.Path.cache, "package.json")
70
+ const parsed = await Filesystem.readJson<{ dependencies: Record<string, string> }>(pkgjsonPath).catch(async () => {
71
+ const result = { dependencies: {} as Record<string, string> }
72
+ await Filesystem.writeJson(pkgjsonPath, result)
73
73
  return result
74
74
  })
75
- const dependencies = parsed.dependencies ?? {}
76
- if (!parsed.dependencies) parsed.dependencies = dependencies
75
+ if (!parsed.dependencies) parsed.dependencies = {} as Record<string, string>
76
+ const dependencies = parsed.dependencies
77
77
  const modExists = await Filesystem.exists(mod)
78
78
  if (dependencies[pkg] === version && modExists) return mod
79
79
 
@@ -120,15 +120,16 @@ export namespace BunProc {
120
120
  // This ensures subsequent starts use the cached version until explicitly updated
121
121
  let resolvedVersion = version
122
122
  if (version === "latest") {
123
- const installedPkgJson = Bun.file(path.join(mod, "package.json"))
124
- const installedPkg = await installedPkgJson.json().catch(() => null)
123
+ const installedPkg = await Filesystem.readJson<{ version?: string }>(path.join(mod, "package.json")).catch(
124
+ () => null,
125
+ )
125
126
  if (installedPkg?.version) {
126
127
  resolvedVersion = installedPkg.version
127
128
  }
128
129
  }
129
130
 
130
131
  parsed.dependencies[pkg] = resolvedVersion
131
- await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2))
132
+ await Filesystem.writeJson(pkgjsonPath, parsed)
132
133
  return mod
133
134
  }
134
135
  }
@@ -6,6 +6,7 @@ import { Agent } from "../../agent/agent"
6
6
  import { Provider } from "../../provider/provider"
7
7
  import path from "path"
8
8
  import fs from "fs/promises"
9
+ import { Filesystem } from "../../util/filesystem"
9
10
  import matter from "gray-matter"
10
11
  import { Instance } from "../../project/instance"
11
12
  import { EOL } from "os"
@@ -202,8 +203,7 @@ const AgentCreateCommand = cmd({
202
203
 
203
204
  await fs.mkdir(targetPath, { recursive: true })
204
205
 
205
- const file = Bun.file(filePath)
206
- if (await file.exists()) {
206
+ if (await Filesystem.exists(filePath)) {
207
207
  if (isFullyNonInteractive) {
208
208
  console.error(`Error: Agent file already exists: ${filePath}`)
209
209
  process.exit(1)
@@ -212,7 +212,7 @@ const AgentCreateCommand = cmd({
212
212
  throw new UI.CancelledError()
213
213
  }
214
214
 
215
- await Bun.write(filePath, content)
215
+ await Filesystem.write(filePath, content)
216
216
 
217
217
  if (isFullyNonInteractive) {
218
218
  console.log(filePath)
@@ -159,6 +159,38 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string):
159
159
  return false
160
160
  }
161
161
 
162
+ /**
163
+ * Build a deduplicated list of plugin-registered auth providers that are not
164
+ * already present in models.dev, respecting enabled/disabled provider lists.
165
+ * Pure function with no side effects; safe to test without mocking.
166
+ */
167
+ export function resolvePluginProviders(input: {
168
+ hooks: Hooks[]
169
+ existingProviders: Record<string, unknown>
170
+ disabled: Set<string>
171
+ enabled?: Set<string>
172
+ providerNames: Record<string, string | undefined>
173
+ }): Array<{ id: string; name: string }> {
174
+ const seen = new Set<string>()
175
+ const result: Array<{ id: string; name: string }> = []
176
+
177
+ for (const hook of input.hooks) {
178
+ if (!hook.auth) continue
179
+ const id = hook.auth.provider
180
+ if (seen.has(id)) continue
181
+ seen.add(id)
182
+ if (Object.hasOwn(input.existingProviders, id)) continue
183
+ if (input.disabled.has(id)) continue
184
+ if (input.enabled && !input.enabled.has(id)) continue
185
+ result.push({
186
+ id,
187
+ name: input.providerNames[id] ?? id,
188
+ })
189
+ }
190
+
191
+ return result
192
+ }
193
+
162
194
  export const AuthCommand = cmd({
163
195
  command: "auth",
164
196
  describe: "manage credentials",
@@ -277,6 +309,15 @@ export const AuthLoginCommand = cmd({
277
309
  openrouter: 5,
278
310
  vercel: 6,
279
311
  }
312
+ const pluginProviders = resolvePluginProviders({
313
+ hooks: await Plugin.list(),
314
+ existingProviders: providers,
315
+ disabled,
316
+ enabled,
317
+ providerNames: Object.fromEntries(
318
+ Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name]),
319
+ ),
320
+ })
280
321
  let provider = await prompts.autocomplete({
281
322
  message: "Select provider",
282
323
  maxItems: 8,
@@ -298,6 +339,11 @@ export const AuthLoginCommand = cmd({
298
339
  }[x.id],
299
340
  })),
300
341
  ),
342
+ ...pluginProviders.map((x) => ({
343
+ label: x.name,
344
+ value: x.id,
345
+ hint: "plugin",
346
+ })),
301
347
  {
302
348
  value: "other",
303
349
  label: "Other",
@@ -5,6 +5,7 @@ import { bootstrap } from "../bootstrap"
5
5
  import { Storage } from "../../storage/storage"
6
6
  import { Instance } from "../../project/instance"
7
7
  import { EOL } from "os"
8
+ import { Filesystem } from "../../util/filesystem"
8
9
 
9
10
  export const ImportCommand = cmd({
10
11
  command: "import <file>",
@@ -66,8 +67,7 @@ export const ImportCommand = cmd({
66
67
  }),
67
68
  }
68
69
  } else {
69
- const file = Bun.file(args.file)
70
- exportData = await file.json().catch(() => {})
70
+ exportData = await Filesystem.readJson<NonNullable<typeof exportData>>(args.file).catch(() => undefined)
71
71
  if (!exportData) {
72
72
  process.stdout.write(`File not found: ${args.file}`)
73
73
  process.stdout.write(EOL)
@@ -61,26 +61,23 @@ export const SessionListCommand = cmd({
61
61
  },
62
62
  handler: async (args) => {
63
63
  await bootstrap(process.cwd(), async () => {
64
- const sessions = []
65
- for await (const session of Session.list()) {
66
- if (!session.parentID) {
67
- sessions.push(session)
68
- }
64
+ const sessions: Session.Info[] = []
65
+ let count = 0
66
+ for await (const s of Session.list()) {
67
+ sessions.push(s)
68
+ count++
69
+ if (args.maxCount && count >= args.maxCount) break
69
70
  }
70
71
 
71
- sessions.sort((a, b) => b.time.updated - a.time.updated)
72
-
73
- const limitedSessions = args.maxCount ? sessions.slice(0, args.maxCount) : sessions
74
-
75
- if (limitedSessions.length === 0) {
72
+ if (sessions.length === 0) {
76
73
  return
77
74
  }
78
75
 
79
76
  let output: string
80
77
  if (args.format === "json") {
81
- output = formatSessionJSON(limitedSessions)
78
+ output = formatSessionJSON(sessions)
82
79
  } else {
83
- output = formatSessionTable(limitedSessions)
80
+ output = formatSessionTable(sessions)
84
81
  }
85
82
 
86
83
  const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table"
@@ -236,7 +236,8 @@ export function Autocomplete(props: {
236
236
  const width = props.anchor().width - 4
237
237
  options.push(
238
238
  ...sortedFiles.map((item): AutocompleteOption => {
239
- let url = `file://${process.cwd()}/${item}`
239
+ const baseDir = (sync.data.path?.directory || process.cwd()).replace(/\/+$/, "")
240
+ let url = `file://${baseDir}/${item}`
240
241
  let filename = item
241
242
  if (lineRange && !item.endsWith("/")) {
242
243
  filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
@@ -519,12 +519,25 @@ export function Prompt(props: PromptProps) {
519
519
  promptModelWarning()
520
520
  return
521
521
  }
522
- const sessionID = props.sessionID
523
- ? props.sessionID
524
- : await (async () => {
525
- const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id)
526
- return sessionID
527
- })()
522
+
523
+ let sessionID = props.sessionID
524
+ if (sessionID == null) {
525
+ const res = await sdk.client.session.create({})
526
+
527
+ if (res.error) {
528
+ console.log("Creating a session failed:", res.error)
529
+
530
+ toast.show({
531
+ message: "Creating a session failed. Open console for more details.",
532
+ variant: "error",
533
+ })
534
+
535
+ return
536
+ }
537
+
538
+ sessionID = res.data.id
539
+ }
540
+
528
541
  const messageID = Identifier.ascending("message")
529
542
  let inputText = store.prompt.input
530
543
 
@@ -0,0 +1,24 @@
1
+ import { Show } from "solid-js"
2
+ import { useTheme } from "../context/theme"
3
+ import { useKV } from "../context/kv"
4
+ import type { JSX } from "@opentui/solid"
5
+ import type { RGBA } from "@opentui/core"
6
+ import "opentui-spinner/solid"
7
+
8
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
9
+
10
+ export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
11
+ const { theme } = useTheme()
12
+ const kv = useKV()
13
+ const color = () => props.color ?? theme.textMuted
14
+ return (
15
+ <Show when={kv.get("animations_enabled", true)} fallback={<text fg={color()}>⋯ {props.children}</text>}>
16
+ <box flexDirection="row" gap={1}>
17
+ <spinner frames={frames} interval={80} color={color()} />
18
+ <Show when={props.children}>
19
+ <text fg={color()}>{props.children}</text>
20
+ </Show>
21
+ </box>
22
+ </Show>
23
+ )
24
+ }
@@ -10,13 +10,13 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
10
10
  // Reset window title before destroying renderer
11
11
  renderer.setTerminalTitle("")
12
12
  renderer.destroy()
13
- await input.onExit?.()
14
13
  if (reason) {
15
14
  const formatted = FormatError(reason) ?? FormatUnknownError(reason)
16
15
  if (formatted) {
17
16
  process.stderr.write(formatted + "\n")
18
17
  }
19
18
  }
19
+ await input.onExit?.()
20
20
  process.exit(0)
21
21
  }
22
22
  },
@@ -1,5 +1,5 @@
1
1
  import { Prompt, type PromptRef } from "@tui/component/prompt"
2
- import { createMemo, Match, onMount, Show, Switch } from "solid-js"
2
+ import { createEffect, createMemo, Match, on, onMount, Show, Switch } from "solid-js"
3
3
  import { useTheme } from "@tui/context/theme"
4
4
  import { useKeybind } from "@tui/context/keybind"
5
5
  import { Logo } from "../component/logo"
@@ -14,6 +14,7 @@ import { usePromptRef } from "../context/prompt"
14
14
  import { Installation } from "@/installation"
15
15
  import { useKV } from "../context/kv"
16
16
  import { useCommandDialog } from "../component/dialog-command"
17
+ import { useLocal } from "../context/local"
17
18
 
18
19
  // TODO: what is the best way to do this?
19
20
  let once = false
@@ -76,6 +77,7 @@ export function Home() {
76
77
 
77
78
  let prompt: PromptRef
78
79
  const args = useArgs()
80
+ const local = useLocal()
79
81
  onMount(() => {
80
82
  if (once) return
81
83
  if (route.initialPrompt) {
@@ -84,9 +86,21 @@ export function Home() {
84
86
  } else if (args.prompt) {
85
87
  prompt.set({ input: args.prompt, parts: [] })
86
88
  once = true
87
- prompt.submit()
88
89
  }
89
90
  })
91
+
92
+ // Wait for sync and model store to be ready before auto-submitting --prompt
93
+ createEffect(
94
+ on(
95
+ () => sync.ready && local.model.ready,
96
+ (ready) => {
97
+ if (!ready) return
98
+ if (!args.prompt) return
99
+ if (prompt.current?.input !== args.prompt) return
100
+ prompt.submit()
101
+ },
102
+ ),
103
+ )
90
104
  const directory = useDirectory()
91
105
 
92
106
  const keybind = useKeybind()
@@ -7,6 +7,7 @@ import {
7
7
  For,
8
8
  Match,
9
9
  on,
10
+ onMount,
10
11
  Show,
11
12
  Switch,
12
13
  useContext,
@@ -16,6 +17,7 @@ import path from "path"
16
17
  import { useRoute, useRouteData } from "@tui/context/route"
17
18
  import { useSync } from "@tui/context/sync"
18
19
  import { SplitBorder } from "@tui/component/border"
20
+ import { Spinner } from "@tui/component/spinner"
19
21
  import { useTheme } from "@tui/context/theme"
20
22
  import {
21
23
  BoxRenderable,
@@ -95,6 +97,7 @@ const context = createContext<{
95
97
  showThinking: () => boolean
96
98
  showTimestamps: () => boolean
97
99
  showDetails: () => boolean
100
+ showGenericToolOutput: () => boolean
98
101
  diffWrapMode: () => "word" | "none"
99
102
  sync: ReturnType<typeof useSync>
100
103
  }>()
@@ -148,6 +151,7 @@ export function Session() {
148
151
  const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
149
152
  const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
150
153
  const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
154
+ const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false)
151
155
 
152
156
  const wide = createMemo(() => dimensions().width > 120)
153
157
  const sidebarVisible = createMemo(() => {
@@ -549,6 +553,15 @@ export function Session() {
549
553
  dialog.clear()
550
554
  },
551
555
  },
556
+ {
557
+ title: showGenericToolOutput() ? "Hide generic tool output" : "Show generic tool output",
558
+ value: "session.toggle.generic_tool_output",
559
+ category: "Session",
560
+ onSelect: (dialog) => {
561
+ setShowGenericToolOutput((prev) => !prev)
562
+ dialog.clear()
563
+ },
564
+ },
552
565
  {
553
566
  title: "Toggle session scrollbar",
554
567
  value: "session.toggle.scrollbar",
@@ -943,6 +956,7 @@ export function Session() {
943
956
  showThinking,
944
957
  showTimestamps,
945
958
  showDetails,
959
+ showGenericToolOutput,
946
960
  diffWrapMode,
947
961
  sync,
948
962
  }}
@@ -1230,6 +1244,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
1230
1244
  const local = useLocal()
1231
1245
  const { theme } = useTheme()
1232
1246
  const sync = useSync()
1247
+ const keybind = useKeybind()
1233
1248
  const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? [])
1234
1249
 
1235
1250
  const final = createMemo(() => {
@@ -1261,6 +1276,14 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
1261
1276
  )
1262
1277
  }}
1263
1278
  </For>
1279
+ <Show when={props.parts.some((x) => x.type === "tool" && x.tool === "task")}>
1280
+ <box paddingTop={1} paddingLeft={3}>
1281
+ <text fg={theme.text}>
1282
+ {keybind.print("session_child_cycle")}
1283
+ <span style={{ fg: theme.textMuted }}> view subagents</span>
1284
+ </text>
1285
+ </box>
1286
+ </Show>
1264
1287
  <Show when={props.message.error && props.message.error.name !== "MessageAbortedError"}>
1265
1288
  <box
1266
1289
  border={["left"]}
@@ -1462,10 +1485,40 @@ type ToolProps<T extends Tool.Info> = {
1462
1485
  part: ToolPart
1463
1486
  }
1464
1487
  function GenericTool(props: ToolProps<any>) {
1488
+ const { theme } = useTheme()
1489
+ const ctx = use()
1490
+ const output = createMemo(() => props.output?.trim() ?? "")
1491
+ const [expanded, setExpanded] = createSignal(false)
1492
+ const lines = createMemo(() => output().split("\n"))
1493
+ const maxLines = 3
1494
+ const overflow = createMemo(() => lines().length > maxLines)
1495
+ const limited = createMemo(() => {
1496
+ if (expanded() || !overflow()) return output()
1497
+ return [...lines().slice(0, maxLines), "…"].join("\n")
1498
+ })
1499
+
1465
1500
  return (
1466
- <InlineTool icon="⚙" pending="Writing command..." complete={true} part={props.part}>
1467
- {props.tool} {input(props.input)}
1468
- </InlineTool>
1501
+ <Show
1502
+ when={props.output && ctx.showGenericToolOutput()}
1503
+ fallback={
1504
+ <InlineTool icon="⚙" pending="Writing command..." complete={true} part={props.part}>
1505
+ {props.tool} {input(props.input)}
1506
+ </InlineTool>
1507
+ }
1508
+ >
1509
+ <BlockTool
1510
+ title={`# ${props.tool} ${input(props.input)}`}
1511
+ part={props.part}
1512
+ onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
1513
+ >
1514
+ <box gap={1}>
1515
+ <text fg={theme.text}>{limited()}</text>
1516
+ <Show when={overflow()}>
1517
+ <text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
1518
+ </Show>
1519
+ </box>
1520
+ </BlockTool>
1521
+ </Show>
1469
1522
  )
1470
1523
  }
1471
1524
 
@@ -1485,6 +1538,7 @@ function InlineTool(props: {
1485
1538
  iconColor?: RGBA
1486
1539
  complete: any
1487
1540
  pending: string
1541
+ spinner?: boolean
1488
1542
  children: JSX.Element
1489
1543
  part: ToolPart
1490
1544
  }) {
@@ -1541,11 +1595,18 @@ function InlineTool(props: {
1541
1595
  }
1542
1596
  }}
1543
1597
  >
1544
- <text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
1545
- <Show fallback={<>~ {props.pending}</>} when={props.complete}>
1546
- <span style={{ fg: props.iconColor }}>{props.icon}</span> {props.children}
1547
- </Show>
1548
- </text>
1598
+ <Switch>
1599
+ <Match when={props.spinner}>
1600
+ <Spinner color={fg()} children={props.children} />
1601
+ </Match>
1602
+ <Match when={true}>
1603
+ <text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
1604
+ <Show fallback={<>~ {props.pending}</>} when={props.complete}>
1605
+ <span style={{ fg: props.iconColor }}>{props.icon}</span> {props.children}
1606
+ </Show>
1607
+ </text>
1608
+ </Match>
1609
+ </Switch>
1549
1610
  <Show when={error() && !denied()}>
1550
1611
  <text fg={theme.error}>{error()}</text>
1551
1612
  </Show>
@@ -1784,55 +1845,63 @@ function WebSearch(props: ToolProps<any>) {
1784
1845
 
1785
1846
  function Task(props: ToolProps<typeof TaskTool>) {
1786
1847
  const { theme } = useTheme()
1787
- const keybind = useKeybind()
1788
1848
  const { navigate } = useRoute()
1789
- const local = useLocal()
1849
+ const sync = useSync()
1850
+
1851
+ onMount(() => {
1852
+ if (props.metadata.sessionId && !sync.data.message[props.metadata.sessionId]?.length)
1853
+ sync.session.sync(props.metadata.sessionId)
1854
+ })
1855
+
1856
+ const messages = createMemo(() => sync.data.message[props.metadata.sessionId ?? ""] ?? [])
1857
+
1858
+ const tools = createMemo(() => {
1859
+ return messages().flatMap((msg) =>
1860
+ (sync.data.part[msg.id] ?? [])
1861
+ .filter((part): part is ToolPart => part.type === "tool")
1862
+ .map((part) => ({ tool: part.tool, state: part.state })),
1863
+ )
1864
+ })
1865
+
1866
+ const current = createMemo(() => tools().findLast((x) => (x.state as any).title))
1790
1867
 
1791
- const current = createMemo(() => props.metadata.summary?.findLast((x) => x.state.status !== "pending"))
1792
- const color = createMemo(() => local.agent.color(props.input.subagent_type ?? "unknown"))
1868
+ const isRunning = createMemo(() => props.part.state.status === "running")
1869
+
1870
+ const duration = createMemo(() => {
1871
+ const first = messages().find((x) => x.role === "user")?.time.created
1872
+ const assistant = messages().findLast((x) => x.role === "assistant")?.time.completed
1873
+ if (!first || !assistant) return 0
1874
+ return assistant - first
1875
+ })
1793
1876
 
1794
1877
  return (
1795
- <Switch>
1796
- <Match when={props.metadata.summary?.length}>
1797
- <BlockTool
1798
- title={"# " + Locale.titlecase(props.input.subagent_type ?? "unknown") + " Task"}
1799
- onClick={
1800
- props.metadata.sessionId
1801
- ? () => navigate({ type: "session", sessionID: props.metadata.sessionId! })
1802
- : undefined
1803
- }
1804
- part={props.part}
1805
- >
1806
- <box>
1807
- <text style={{ fg: theme.textMuted }}>
1808
- {props.input.description} ({props.metadata.summary?.length} toolcalls)
1809
- </text>
1810
- <Show when={current()}>
1811
- <text style={{ fg: current()!.state.status === "error" ? theme.error : theme.textMuted }}>
1812
- └ {Locale.titlecase(current()!.tool)}{" "}
1813
- {current()!.state.status === "completed" ? current()!.state.title : ""}
1814
- </text>
1815
- </Show>
1816
- </box>
1817
- <text fg={theme.text}>
1818
- {keybind.print("session_child_cycle")}
1819
- <span style={{ fg: theme.textMuted }}> view subagents</span>
1820
- </text>
1821
- </BlockTool>
1822
- </Match>
1823
- <Match when={true}>
1824
- <InlineTool
1825
- icon="◉"
1826
- iconColor={color()}
1827
- pending="Delegating..."
1828
- complete={props.input.subagent_type ?? props.input.description}
1829
- part={props.part}
1830
- >
1831
- <span style={{ fg: theme.text }}>{Locale.titlecase(props.input.subagent_type ?? "unknown")}</span> Task "
1832
- {props.input.description}"
1833
- </InlineTool>
1834
- </Match>
1835
- </Switch>
1878
+ <InlineTool
1879
+ icon="≡"
1880
+ spinner={isRunning()}
1881
+ complete={props.input.description}
1882
+ pending="Delegating..."
1883
+ part={props.part}
1884
+ >
1885
+ {props.input.description}
1886
+ <Show when={isRunning() && tools().length > 0}>
1887
+ {" "}
1888
+ · {tools().length} toolcalls
1889
+ <Show fallback={"\n⤷ Running..."} when={current()}>
1890
+ {(item) => {
1891
+ const title = createMemo(() => (item().state as any).title)
1892
+ return (
1893
+ <>
1894
+ {"\n"}⤷ {Locale.titlecase(item().tool)} {title()}
1895
+ </>
1896
+ )
1897
+ }}
1898
+ </Show>
1899
+ </Show>
1900
+ <Show when={duration() && props.part.state.status === "completed"}>
1901
+ {"\n "}
1902
+ {tools().length} toolcalls · {Locale.duration(duration())}
1903
+ </Show>
1904
+ </InlineTool>
1836
1905
  )
1837
1906
  }
1838
1907
 
@@ -69,7 +69,15 @@ function EditBody(props: { request: PermissionRequest }) {
69
69
  <text fg={theme.textMuted}>Edit {normalizePath(filepath())}</text>
70
70
  </box>
71
71
  <Show when={diff()}>
72
- <scrollbox height="100%">
72
+ <scrollbox
73
+ height="100%"
74
+ verticalScrollbarOptions={{
75
+ trackOptions: {
76
+ backgroundColor: theme.background,
77
+ foregroundColor: theme.borderActive,
78
+ },
79
+ }}
80
+ >
73
81
  <diff
74
82
  diff={diff()}
75
83
  view={view()}
@@ -80,7 +80,15 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
80
80
  paddingRight={2}
81
81
  position={props.overlay ? "absolute" : "relative"}
82
82
  >
83
- <scrollbox flexGrow={1}>
83
+ <scrollbox
84
+ flexGrow={1}
85
+ verticalScrollbarOptions={{
86
+ trackOptions: {
87
+ backgroundColor: theme.background,
88
+ foregroundColor: theme.borderActive,
89
+ },
90
+ }}
91
+ >
84
92
  <box flexShrink={0} gap={1} paddingRight={1}>
85
93
  <box paddingRight={1}>
86
94
  <text fg={theme.text}>
@@ -3,10 +3,12 @@ import { tui } from "./app"
3
3
  import { Rpc } from "@/util/rpc"
4
4
  import { type rpc } from "./worker"
5
5
  import path from "path"
6
+ import { fileURLToPath } from "url"
6
7
  import { UI } from "@/cli/ui"
7
8
  import { iife } from "@/util/iife"
8
9
  import { Log } from "@/util/log"
9
10
  import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
11
+ import { Filesystem } from "@/util/filesystem"
10
12
  import type { Event } from "@opencode-ai/sdk/v2"
11
13
  import type { EventSource } from "./context/sdk"
12
14
 
@@ -131,7 +133,7 @@ export const TuiThreadCommand = cmd({
131
133
  const distWorker = new URL("./cli/cmd/tui/worker.js", import.meta.url)
132
134
  const workerPath = await iife(async () => {
133
135
  if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
134
- if (await Bun.file(distWorker).exists()) return distWorker
136
+ if (await Filesystem.exists(fileURLToPath(distWorker))) return distWorker
135
137
  return localWorker
136
138
  })
137
139
 
@@ -199,5 +201,6 @@ export const TuiThreadCommand = cmd({
199
201
  onExit,
200
202
  onUpgrade,
201
203
  })
204
+ process.exit(0)
202
205
  },
203
206
  })