dsh-oc-tui 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +21 -563
  2. package/bin/mcp-sse-bridge.mjs +499 -499
  3. package/docs/assets/conversation.png +0 -0
  4. package/docs/assets/cover.png +0 -0
  5. package/docs/assets/home.png +0 -0
  6. package/docs/assets/settings.png +0 -0
  7. package/docs/dsh-std-/346/216/245/345/205/245/350/257/264/346/230/216.md +566 -0
  8. package/docs/user-guide.md +616 -0
  9. package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +607 -593
  10. package/dsh-plugin.json +69 -69
  11. package/lib/boot-block.js +129 -0
  12. package/lib/diagnostics.js +162 -120
  13. package/lib/dsh-version.js +161 -0
  14. package/lib/git-branch.js +157 -157
  15. package/lib/i18n.js +283 -283
  16. package/lib/image.js +2 -1
  17. package/lib/index.js +5183 -4812
  18. package/lib/locales/ar.js +670 -635
  19. package/lib/locales/de.js +639 -604
  20. package/lib/locales/en.js +35 -0
  21. package/lib/locales/es.js +639 -604
  22. package/lib/locales/fr.js +639 -604
  23. package/lib/locales/id.js +641 -606
  24. package/lib/locales/it.js +639 -604
  25. package/lib/locales/ja.js +639 -604
  26. package/lib/locales/ko.js +639 -604
  27. package/lib/locales/nl.js +639 -604
  28. package/lib/locales/pl.js +653 -618
  29. package/lib/locales/pt-BR.js +639 -604
  30. package/lib/locales/ru.js +653 -618
  31. package/lib/locales/tr.js +639 -604
  32. package/lib/locales/vi.js +641 -606
  33. package/lib/locales/zh-CN.js +640 -605
  34. package/lib/locales/zh-TW.js +640 -605
  35. package/lib/markdown.js +101 -27
  36. package/lib/mcp-monitor.js +189 -0
  37. package/lib/mcp-skill.js +93 -93
  38. package/lib/mcp.js +865 -838
  39. package/lib/model-route.js +66 -0
  40. package/lib/providers.js +712 -712
  41. package/lib/screenshot.js +140 -0
  42. package/lib/splash-opencode.js +29 -26
  43. package/lib/startup.js +66 -48
  44. package/lib/term.js +53 -11
  45. package/lib/themes.js +101 -0
  46. package/lib/ui.js +685 -443
  47. package/lib/updates.js +5 -2
  48. package/lib/web-settings.js +52 -21
  49. package/package.json +160 -146
  50. package/skills/mcp-import/SKILL.md +216 -216
@@ -0,0 +1,161 @@
1
+ // Whether this build may run on the dsh that started it.
2
+ //
3
+ // The plugin already declares a supported range in package.json (`engines.dsh`)
4
+ // and dsh's own profile preflight reads it: a host outside that range has this
5
+ // bundle's rows *disabled* before they mount, which is why an in-app message
6
+ // used to be impossible to show. Two gaps are left, and both are what this
7
+ // module closes:
8
+ //
9
+ // * an exempted host (`dsh plugin allow-version`) loads anyway and then fails
10
+ // somewhere deep — a model switch, a settings write — instead of saying why;
11
+ // * a version *inside* the range can still be incompatible, because the range
12
+ // is a promise about a whole line of releases: 0.2.2-rc.1 sat inside
13
+ // `>=0.1.1-rc.2 <0.3.0-0` and broke every model switch, because it moved the
14
+ // route to `agent.model.select()` and made the old waterfall throw.
15
+ //
16
+ // So the manifest carries what was actually run (`dshSupport.tested`) beside the
17
+ // range, and a boot on anything with no evidence refuses to start. The refusal
18
+ // is the point: a half-working interface is worse than one page naming the
19
+ // version to install.
20
+ //
21
+ // Everything here is pure and injectable — no terminal, no settings, no boot —
22
+ // so the verdict can be exercised without a host.
23
+
24
+ import { readFileSync } from 'node:fs'
25
+ import { createRequire } from 'node:module'
26
+ import { compareVersions, detectDshInstall } from './updates.js'
27
+
28
+ const MANIFEST = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
29
+
30
+ // `v0.2.1` and `0.2.1 ` are the same version; an empty or non-string input is
31
+ // "unknown", never "matches".
32
+ export function normalizeDshVersion(value) {
33
+ if (typeof value !== 'string') return null
34
+ const trimmed = value.trim().replace(/^v/i, '')
35
+ return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(trimmed) ? trimmed : null
36
+ }
37
+
38
+ // The one range syntax this plugin's manifest uses: space-separated comparators
39
+ // are ANDed, `||` separates alternatives. `^`, `~` and `x` wildcards are
40
+ // deliberately rejected instead of half-supported — a manifest typo must fail
41
+ // here, where a test can see it, rather than quietly widen what we accept.
42
+ const COMPARATOR = /^(>=|<=|>|<|=)?\s*v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/
43
+
44
+ export function parseDshRange(range) {
45
+ if (typeof range !== 'string' || range.trim() === '') return null
46
+ const alternatives = []
47
+ for (const part of range.split('||')) {
48
+ const comparators = []
49
+ for (const token of part.trim().split(/\s+/).filter(Boolean)) {
50
+ const match = COMPARATOR.exec(token)
51
+ if (!match) return null
52
+ comparators.push({ op: match[1] ?? '=', version: match[2] })
53
+ }
54
+ if (comparators.length === 0) return null
55
+ alternatives.push(comparators)
56
+ }
57
+ return alternatives.length === 0 ? null : alternatives
58
+ }
59
+
60
+ // true / false, or null when either side cannot be understood — the caller must
61
+ // distinguish "does not match" from "cannot tell", because only the first one
62
+ // justifies refusing to start.
63
+ export function satisfiesDshRange(version, range) {
64
+ const alternatives = parseDshRange(range)
65
+ const actual = normalizeDshVersion(version)
66
+ if (!alternatives || !actual) return null
67
+ return alternatives.some((comparators) => comparators.every(({ op, version: bound }) => {
68
+ const order = compareVersions(actual, bound)
69
+ switch (op) {
70
+ case '>=': return order >= 0
71
+ case '>': return order > 0
72
+ case '<=': return order <= 0
73
+ case '<': return order < 0
74
+ default: return order === 0
75
+ }
76
+ }))
77
+ }
78
+
79
+ // What this build declares: the hard range dsh itself enforces, the version to
80
+ // point a user at, and the ones this build was actually run against.
81
+ export function dshSupport(manifest = MANIFEST) {
82
+ const declared = manifest?.dshSupport ?? {}
83
+ const range = typeof manifest?.engines?.dsh === 'string' ? manifest.engines.dsh : ''
84
+ const verified = Array.isArray(declared.tested) ? declared.tested.map(normalizeDshVersion).filter(Boolean) : []
85
+ return {
86
+ range,
87
+ recommended: normalizeDshVersion(declared.recommended) ?? verified.at(-1) ?? '',
88
+ tested: verified,
89
+ }
90
+ }
91
+
92
+ // The verdict for one running version.
93
+ //
94
+ // ok — declared by the range *and* actually run against
95
+ // unverified — inside the declared range, but this build never saw it
96
+ // unsupported — outside the declared range (dsh normally disables us here)
97
+ // unknown — the version could not be read, so there is nothing to judge
98
+ //
99
+ // `unknown` never refuses: bricking a distribution we cannot introspect would be
100
+ // a bug in this module, not a fact about the host.
101
+ export function evaluateDshSupport(actual, support = dshSupport()) {
102
+ const version = normalizeDshVersion(actual)
103
+ const base = { version, ...support }
104
+ if (!version) return { verdict: 'unknown', ...base }
105
+ const inRange = satisfiesDshRange(version, support.range)
106
+ if (inRange === null) return { verdict: 'unknown', ...base }
107
+ if (!inRange) return { verdict: 'unsupported', ...base }
108
+ if (support.tested.length > 0 && !support.tested.includes(version)) return { verdict: 'unverified', ...base }
109
+ return { verdict: 'ok', ...base }
110
+ }
111
+
112
+ // The version of the harness this process is running under.
113
+ //
114
+ // Three sources, most authoritative first:
115
+ //
116
+ // 1. `@deepseek-ai/dsh-app-boot/package.json` resolved from the process entry.
117
+ // That package is what the host itself reads as its runtime version
118
+ // (`getDshRuntimeVersion()` in dsh-app-boot) and it is versioned in lockstep
119
+ // with the CLI, so this answers exactly what a user means by "the dsh
120
+ // version".
121
+ // 2. `@deepseek-ai/dsh/package.json` from the same base, for a host that ships
122
+ // the CLI without app-boot.
123
+ // 3. the `dsh` on PATH, for a host started some other way (a desktop bundle, an
124
+ // SDK runner) — right whenever the process was launched by the CLI that PATH
125
+ // names.
126
+ //
127
+ // Nothing found means "unknown", and the gate lets it through.
128
+ export function runningDshVersion({ entry = process.argv[1], resolve = resolveFromEntry, detect = detectDshInstall } = {}) {
129
+ if (typeof entry === 'string' && entry !== '') {
130
+ for (const spec of ['@deepseek-ai/dsh-app-boot/package.json', '@deepseek-ai/dsh/package.json']) {
131
+ const manifest = resolve(entry, spec)
132
+ const version = normalizeDshVersion(manifest?.version)
133
+ if (version) return { version, source: spec }
134
+ }
135
+ }
136
+ let install
137
+ try { install = detect() } catch { install = null }
138
+ const version = install?.found ? normalizeDshVersion(install.version) : null
139
+ if (version) return { version, source: 'path' }
140
+ return { version: null, source: 'unknown' }
141
+ }
142
+
143
+ function resolveFromEntry(entry, spec) {
144
+ try {
145
+ return JSON.parse(readFileSync(createRequire(entry).resolve(spec), 'utf8'))
146
+ } catch {
147
+ return null
148
+ }
149
+ }
150
+
151
+ // The boot gate: one call that answers "may this build start?" with everything a
152
+ // blocking page needs. `DSH_TUI_IGNORE_DSH_VERSION` is the escape hatch for a
153
+ // maintainer testing against a host that is not published or not yet verified.
154
+ export function dshBootGate({ env = process.env, support = dshSupport(), probe = runningDshVersion } = {}) {
155
+ const override = String(env?.DSH_TUI_IGNORE_DSH_VERSION ?? '').trim()
156
+ if (override !== '' && override !== '0') return null
157
+ const { version, source } = probe()
158
+ const result = evaluateDshSupport(version, support)
159
+ if (result.verdict === 'ok' || result.verdict === 'unknown') return null
160
+ return { ...result, source }
161
+ }
package/lib/git-branch.js CHANGED
@@ -1,157 +1,157 @@
1
- // Git branch switching for the TUI's empty page: listing local and remote
2
- // branches, checking one out, and pulling updates. Everything shells out to
3
- // the `git` on PATH through an argument array (no shell is involved), and
4
- // every entry point is fenced to a real worktree first, so a workspace that
5
- // is not a repository — or a machine with no git at all — reports that
6
- // instead of half-applying anything.
7
- import { spawn } from 'node:child_process'
8
- import { resolve } from 'node:path'
9
- import { t } from './i18n.js'
10
-
11
- // A network operation can hang on an unreachable host long past the point
12
- // where the user has stopped watching the window, so every spawn carries a
13
- // bound; a kill resolves as a failure rather than leaving the promise open.
14
- const GIT_TIMEOUT_MS = 120_000
15
-
16
- // GIT_TERMINAL_PROMPT=0 turns a credential prompt into an immediate error:
17
- // the pipes below already keep git off the terminal, and this makes the
18
- // promise reject instead of blocking on input that can never arrive.
19
- function git(cwd, args) {
20
- return new Promise((resolvePromise) => {
21
- const child = spawn('git', args, {
22
- cwd,
23
- windowsHide: true,
24
- stdio: ['ignore', 'pipe', 'pipe'],
25
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
26
- })
27
- const stdout = []
28
- const stderr = []
29
- let settled = false
30
- const finish = (result) => {
31
- if (settled) return
32
- settled = true
33
- resolvePromise(result)
34
- }
35
- const timer = setTimeout(() => {
36
- child.kill()
37
- finish({ ok: false, spawned: true, stdout: '', stderr: t('git.timedOut') })
38
- }, GIT_TIMEOUT_MS)
39
- child.stdout?.on('data', (chunk) => { stdout.push(chunk) })
40
- child.stderr?.on('data', (chunk) => { stderr.push(chunk) })
41
- child.on('error', (error) => {
42
- clearTimeout(timer)
43
- // A spawn failure (git not on PATH) is not the same as git saying no:
44
- // `spawned: false` lets callers tell the two apart.
45
- finish({ ok: false, spawned: false, stdout: '', stderr: error.message })
46
- })
47
- child.on('close', (code) => {
48
- clearTimeout(timer)
49
- finish({
50
- ok: code === 0,
51
- spawned: true,
52
- stdout: Buffer.concat(stdout).toString('utf8'),
53
- stderr: Buffer.concat(stderr).toString('utf8'),
54
- })
55
- })
56
- })
57
- }
58
-
59
- function lines(text) {
60
- return String(text ?? '')
61
- .split(/\r?\n/)
62
- .map((line) => line.trim())
63
- .filter((line) => line !== '')
64
- }
65
-
66
- // The first line of git's output, which is where its errors put the reason
67
- // ("error: pathspec ... did not match"). Later lines are hints and blank
68
- // padding that would only crowd a one-line report.
69
- function firstLine(text) {
70
- return lines(text)[0] ?? ''
71
- }
72
-
73
- // The last line of the combined output: fetch's "Fetching origin" and pull's
74
- // "From ..." lead, while the line that answers "did anything change" —
75
- // "Already up to date.", the fast-forward diffstat — comes last.
76
- function lastLine(text) {
77
- const all = lines(text)
78
- return all.length > 0 ? all[all.length - 1].slice(0, 72) : ''
79
- }
80
-
81
- // The worktree root, or a throw that says why there is none. Every public
82
- // function starts here. `--show-toplevel` is one spawn that answers both
83
- // questions: it prints the root inside a worktree (an unborn one included)
84
- // and fails inside a bare repository or outside any repository at all.
85
- async function worktreeRoot(cwd) {
86
- const root = await git(resolve(cwd), ['rev-parse', '--show-toplevel'])
87
- if (!root.spawned) throw new Error(t('git.gitMissing'))
88
- if (!root.ok || root.stdout.trim() === '') throw new Error(t('git.notRepo'))
89
- return root.stdout.trim()
90
- }
91
-
92
- // Local branches (the current one included, in git's own alphabetical order)
93
- // and remote-tracking branches. `refs/remotes/*​/HEAD` is a symbolic ref that
94
- // names no branch of its own, so it is filtered out rather than offered as a
95
- // checkout target.
96
- export async function listGitBranches(cwd) {
97
- const root = await worktreeRoot(cwd)
98
- const [local, remote, head] = await Promise.all([
99
- git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']),
100
- git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/remotes']),
101
- // symbolic-ref exits non-zero on a detached or unborn HEAD, which is the
102
- // honest "no current branch" answer the window renders as (detached).
103
- git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']),
104
- ])
105
- for (const result of [local, remote, head]) {
106
- if (!result.ok) throw new Error(firstLine(result.stderr) || t('git.listFailed', { error: '' }))
107
- }
108
- return {
109
- root,
110
- current: head.stdout.trim(),
111
- local: lines(local.stdout),
112
- remote: lines(remote.stdout).filter((name) => !/\/HEAD$/.test(name)),
113
- }
114
- }
115
-
116
- // Check out a branch. A remote-tracking ref is switched through the local
117
- // branch git's own DWIM would create for it (`origin/feature` -> `feature`),
118
- // unless a local branch of that name already exists — then that one wins, so
119
- // the user never lands on a second branch tracking the same upstream.
120
- export async function switchGitBranch(cwd, ref, { remote = false } = {}) {
121
- const root = await worktreeRoot(cwd)
122
- let target = ref
123
- if (remote) {
124
- const slash = ref.indexOf('/')
125
- const local = slash < 0 ? ref : ref.slice(slash + 1)
126
- const locals = await git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads'])
127
- if (!locals.spawned) throw new Error(t('git.gitMissing'))
128
- if (!locals.ok) throw new Error(firstLine(locals.stderr))
129
- const localNames = lines(locals.stdout)
130
- if (!localNames.includes(local)) {
131
- const created = await git(root, ['checkout', '-b', local, '--track', ref])
132
- if (!created.spawned) throw new Error(t('git.gitMissing'))
133
- if (!created.ok) throw new Error(firstLine(created.stderr) || t('git.switchFailed', { branch: ref, error: '' }))
134
- return { branch: local, root }
135
- }
136
- target = local
137
- }
138
- const result = await git(root, ['checkout', target])
139
- if (!result.spawned) throw new Error(t('git.gitMissing'))
140
- if (!result.ok) throw new Error(firstLine(result.stderr) || t('git.switchFailed', { branch: target, error: '' }))
141
- return { branch: target, root }
142
- }
143
-
144
- // Manual pull: refresh every remote-tracking ref, then fast-forward the
145
- // current branch. `--ff-only` is deliberate — a TUI has no way to resolve a
146
- // merge conflict, so a diverged branch must fail with git's own explanation
147
- // rather than stop halfway with conflict markers in the worktree.
148
- export async function pullGitUpdates(cwd) {
149
- const root = await worktreeRoot(cwd)
150
- const fetched = await git(root, ['fetch', '--all', '--prune'])
151
- if (!fetched.spawned) throw new Error(t('git.gitMissing'))
152
- if (!fetched.ok) throw new Error(firstLine(fetched.stderr) || t('git.pullFailed', { error: '' }))
153
- const pulled = await git(root, ['pull', '--ff-only'])
154
- if (!pulled.spawned) throw new Error(t('git.gitMissing'))
155
- if (!pulled.ok) throw new Error(firstLine(pulled.stderr) || t('git.pullFailed', { error: '' }))
156
- return { summary: lastLine(fetched.stderr + '\n' + fetched.stdout + '\n' + pulled.stdout + '\n' + pulled.stderr) }
157
- }
1
+ // Git branch switching for the TUI's empty page: listing local and remote
2
+ // branches, checking one out, and pulling updates. Everything shells out to
3
+ // the `git` on PATH through an argument array (no shell is involved), and
4
+ // every entry point is fenced to a real worktree first, so a workspace that
5
+ // is not a repository — or a machine with no git at all — reports that
6
+ // instead of half-applying anything.
7
+ import { spawn } from 'node:child_process'
8
+ import { resolve } from 'node:path'
9
+ import { t } from './i18n.js'
10
+
11
+ // A network operation can hang on an unreachable host long past the point
12
+ // where the user has stopped watching the window, so every spawn carries a
13
+ // bound; a kill resolves as a failure rather than leaving the promise open.
14
+ const GIT_TIMEOUT_MS = 120_000
15
+
16
+ // GIT_TERMINAL_PROMPT=0 turns a credential prompt into an immediate error:
17
+ // the pipes below already keep git off the terminal, and this makes the
18
+ // promise reject instead of blocking on input that can never arrive.
19
+ function git(cwd, args) {
20
+ return new Promise((resolvePromise) => {
21
+ const child = spawn('git', args, {
22
+ cwd,
23
+ windowsHide: true,
24
+ stdio: ['ignore', 'pipe', 'pipe'],
25
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
26
+ })
27
+ const stdout = []
28
+ const stderr = []
29
+ let settled = false
30
+ const finish = (result) => {
31
+ if (settled) return
32
+ settled = true
33
+ resolvePromise(result)
34
+ }
35
+ const timer = setTimeout(() => {
36
+ child.kill()
37
+ finish({ ok: false, spawned: true, stdout: '', stderr: t('git.timedOut') })
38
+ }, GIT_TIMEOUT_MS)
39
+ child.stdout?.on('data', (chunk) => { stdout.push(chunk) })
40
+ child.stderr?.on('data', (chunk) => { stderr.push(chunk) })
41
+ child.on('error', (error) => {
42
+ clearTimeout(timer)
43
+ // A spawn failure (git not on PATH) is not the same as git saying no:
44
+ // `spawned: false` lets callers tell the two apart.
45
+ finish({ ok: false, spawned: false, stdout: '', stderr: error.message })
46
+ })
47
+ child.on('close', (code) => {
48
+ clearTimeout(timer)
49
+ finish({
50
+ ok: code === 0,
51
+ spawned: true,
52
+ stdout: Buffer.concat(stdout).toString('utf8'),
53
+ stderr: Buffer.concat(stderr).toString('utf8'),
54
+ })
55
+ })
56
+ })
57
+ }
58
+
59
+ function lines(text) {
60
+ return String(text ?? '')
61
+ .split(/\r?\n/)
62
+ .map((line) => line.trim())
63
+ .filter((line) => line !== '')
64
+ }
65
+
66
+ // The first line of git's output, which is where its errors put the reason
67
+ // ("error: pathspec ... did not match"). Later lines are hints and blank
68
+ // padding that would only crowd a one-line report.
69
+ function firstLine(text) {
70
+ return lines(text)[0] ?? ''
71
+ }
72
+
73
+ // The last line of the combined output: fetch's "Fetching origin" and pull's
74
+ // "From ..." lead, while the line that answers "did anything change" —
75
+ // "Already up to date.", the fast-forward diffstat — comes last.
76
+ function lastLine(text) {
77
+ const all = lines(text)
78
+ return all.length > 0 ? all[all.length - 1].slice(0, 72) : ''
79
+ }
80
+
81
+ // The worktree root, or a throw that says why there is none. Every public
82
+ // function starts here. `--show-toplevel` is one spawn that answers both
83
+ // questions: it prints the root inside a worktree (an unborn one included)
84
+ // and fails inside a bare repository or outside any repository at all.
85
+ async function worktreeRoot(cwd) {
86
+ const root = await git(resolve(cwd), ['rev-parse', '--show-toplevel'])
87
+ if (!root.spawned) throw new Error(t('git.gitMissing'))
88
+ if (!root.ok || root.stdout.trim() === '') throw new Error(t('git.notRepo'))
89
+ return root.stdout.trim()
90
+ }
91
+
92
+ // Local branches (the current one included, in git's own alphabetical order)
93
+ // and remote-tracking branches. `refs/remotes/*​/HEAD` is a symbolic ref that
94
+ // names no branch of its own, so it is filtered out rather than offered as a
95
+ // checkout target.
96
+ export async function listGitBranches(cwd) {
97
+ const root = await worktreeRoot(cwd)
98
+ const [local, remote, head] = await Promise.all([
99
+ git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']),
100
+ git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/remotes']),
101
+ // symbolic-ref exits non-zero on a detached or unborn HEAD, which is the
102
+ // honest "no current branch" answer the window renders as (detached).
103
+ git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']),
104
+ ])
105
+ for (const result of [local, remote, head]) {
106
+ if (!result.ok) throw new Error(firstLine(result.stderr) || t('git.listFailed', { error: '' }))
107
+ }
108
+ return {
109
+ root,
110
+ current: head.stdout.trim(),
111
+ local: lines(local.stdout),
112
+ remote: lines(remote.stdout).filter((name) => !/\/HEAD$/.test(name)),
113
+ }
114
+ }
115
+
116
+ // Check out a branch. A remote-tracking ref is switched through the local
117
+ // branch git's own DWIM would create for it (`origin/feature` -> `feature`),
118
+ // unless a local branch of that name already exists — then that one wins, so
119
+ // the user never lands on a second branch tracking the same upstream.
120
+ export async function switchGitBranch(cwd, ref, { remote = false } = {}) {
121
+ const root = await worktreeRoot(cwd)
122
+ let target = ref
123
+ if (remote) {
124
+ const slash = ref.indexOf('/')
125
+ const local = slash < 0 ? ref : ref.slice(slash + 1)
126
+ const locals = await git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads'])
127
+ if (!locals.spawned) throw new Error(t('git.gitMissing'))
128
+ if (!locals.ok) throw new Error(firstLine(locals.stderr))
129
+ const localNames = lines(locals.stdout)
130
+ if (!localNames.includes(local)) {
131
+ const created = await git(root, ['checkout', '-b', local, '--track', ref])
132
+ if (!created.spawned) throw new Error(t('git.gitMissing'))
133
+ if (!created.ok) throw new Error(firstLine(created.stderr) || t('git.switchFailed', { branch: ref, error: '' }))
134
+ return { branch: local, root }
135
+ }
136
+ target = local
137
+ }
138
+ const result = await git(root, ['checkout', target])
139
+ if (!result.spawned) throw new Error(t('git.gitMissing'))
140
+ if (!result.ok) throw new Error(firstLine(result.stderr) || t('git.switchFailed', { branch: target, error: '' }))
141
+ return { branch: target, root }
142
+ }
143
+
144
+ // Manual pull: refresh every remote-tracking ref, then fast-forward the
145
+ // current branch. `--ff-only` is deliberate — a TUI has no way to resolve a
146
+ // merge conflict, so a diverged branch must fail with git's own explanation
147
+ // rather than stop halfway with conflict markers in the worktree.
148
+ export async function pullGitUpdates(cwd) {
149
+ const root = await worktreeRoot(cwd)
150
+ const fetched = await git(root, ['fetch', '--all', '--prune'])
151
+ if (!fetched.spawned) throw new Error(t('git.gitMissing'))
152
+ if (!fetched.ok) throw new Error(firstLine(fetched.stderr) || t('git.pullFailed', { error: '' }))
153
+ const pulled = await git(root, ['pull', '--ff-only'])
154
+ if (!pulled.spawned) throw new Error(t('git.gitMissing'))
155
+ if (!pulled.ok) throw new Error(firstLine(pulled.stderr) || t('git.pullFailed', { error: '' }))
156
+ return { summary: lastLine(fetched.stderr + '\n' + fetched.stdout + '\n' + pulled.stdout + '\n' + pulled.stderr) }
157
+ }