dsh-code 1.0.7 → 1.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 (89) hide show
  1. package/README.en.md +70 -24
  2. package/README.md +71 -25
  3. package/bin/deepseek.mjs +202 -39
  4. package/cordis.patch.yml +13 -4
  5. package/lib/index.mjs +5002 -1061
  6. package/lib/session-query.mjs +3 -2
  7. package/lib/startup.mjs +4 -4
  8. package/lib/{theme-DCT8Y2xf.mjs → theme-B3orFUYz.mjs} +665 -20
  9. package/lib/types/app.d.ts +120 -63
  10. package/lib/types/attachments.d.ts +16 -7
  11. package/lib/types/authorization-panel.d.ts +3 -3
  12. package/lib/types/git-workflow.d.ts +91 -2
  13. package/lib/types/history.d.ts +10 -0
  14. package/lib/types/i18n.d.ts +39 -0
  15. package/lib/types/index.d.ts +74 -2
  16. package/lib/types/input-split.d.ts +1 -1
  17. package/lib/types/kernel-panels.d.ts +89 -32
  18. package/lib/types/language-panel.d.ts +12 -0
  19. package/lib/types/locales/en.d.ts +498 -0
  20. package/lib/types/locales/zh.d.ts +9 -0
  21. package/lib/types/mentions.d.ts +7 -3
  22. package/lib/types/models.d.ts +14 -0
  23. package/lib/types/panel-accent.d.ts +28 -0
  24. package/lib/types/rainbow.d.ts +69 -0
  25. package/lib/types/render/animations.d.ts +42 -0
  26. package/lib/types/render/inspector.d.ts +26 -0
  27. package/lib/types/render/lines.d.ts +21 -1
  28. package/lib/types/render/markdown.d.ts +1 -1
  29. package/lib/types/render/projection.d.ts +95 -4
  30. package/lib/types/render/status.d.ts +12 -9
  31. package/lib/types/render/text.d.ts +6 -0
  32. package/lib/types/render/usage.d.ts +113 -0
  33. package/lib/types/session-directory.d.ts +42 -1
  34. package/lib/types/session-switch.d.ts +8 -0
  35. package/lib/types/startup.d.ts +1 -1
  36. package/lib/types/terminal-title.d.ts +8 -0
  37. package/lib/types/theme-panel.d.ts +2 -2
  38. package/lib/types/theme.d.ts +271 -52
  39. package/lib/types/update-panel.d.ts +27 -6
  40. package/lib/types/update.d.ts +10 -1
  41. package/lib/types/version.d.ts +6 -3
  42. package/package.json +26 -7
  43. package/src/app.ts +1426 -627
  44. package/src/approval.ts +166 -166
  45. package/src/attachments.ts +65 -19
  46. package/src/authorization-panel.ts +24 -18
  47. package/src/editor-keys.ts +371 -371
  48. package/src/fork.ts +11 -7
  49. package/src/git-workflow.ts +229 -3
  50. package/src/history.ts +14 -0
  51. package/src/i18n.ts +68 -0
  52. package/src/index.ts +503 -128
  53. package/src/input-split.ts +27 -7
  54. package/src/kernel-panels.ts +528 -113
  55. package/src/keyboard.ts +5 -4
  56. package/src/language-panel.ts +53 -0
  57. package/src/locales/en.ts +538 -0
  58. package/src/locales/zh.ts +537 -0
  59. package/src/mentions.ts +8 -4
  60. package/src/models.ts +264 -212
  61. package/src/panel-accent.ts +41 -0
  62. package/src/presets.ts +1 -1
  63. package/src/provider-settings.ts +1 -1
  64. package/src/rainbow.ts +218 -0
  65. package/src/render/animations.ts +104 -6
  66. package/src/render/editor.ts +20 -20
  67. package/src/render/export.ts +116 -95
  68. package/src/render/inspector.ts +42 -0
  69. package/src/render/lines.ts +628 -415
  70. package/src/render/markdown.ts +15 -3
  71. package/src/render/projection.ts +429 -19
  72. package/src/render/status.ts +119 -62
  73. package/src/render/text.ts +15 -0
  74. package/src/render/tool-preview.ts +77 -77
  75. package/src/render/usage.ts +430 -0
  76. package/src/render/width.ts +2 -2
  77. package/src/session-directory.ts +90 -9
  78. package/src/session-query.ts +8 -4
  79. package/src/session-switch.ts +14 -0
  80. package/src/startup.ts +3 -3
  81. package/src/store.ts +19 -1
  82. package/src/subagents.ts +229 -229
  83. package/src/terminal-title.ts +22 -5
  84. package/src/theme-panel.ts +17 -21
  85. package/src/theme.ts +281 -33
  86. package/src/update-panel.ts +148 -31
  87. package/src/update.ts +19 -3
  88. package/src/version.ts +63 -20
  89. package/src/whale-glyph.ts +23 -23
package/src/approval.ts CHANGED
@@ -1,166 +1,166 @@
1
- /**
2
- * The terminal approval answerer: one `approval/request` waterfall listener
3
- * that renders the pending question as a y/n bar and resolves the decision
4
- * back into the waterfall. Mirrors the web host's composer takeover — the
5
- * service (audit pair, policy gate, fail-closed defaults) all live in
6
- * dsh-base; this module only answers for agents this TUI owns.
7
- *
8
- * Vocabulary note: a client answerer may only ever resolve `'allowed-once'`
9
- * or `'rejected'`; `'cancelled'` belongs to the request signal and
10
- * `'unavailable'` to the fail-closed waterfall default.
11
- *
12
- * @module @deepseek-ai/dsh-code/approval
13
- */
14
-
15
- import type { Context } from '@deepseek-ai/cordis'
16
- import type { Agent } from '@deepseek-ai/dsh-agent'
17
- import type { ApprovalOutcome, ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
18
-
19
- /** The answer values a client answerer may resolve with. */
20
- export type ApprovalAnswer = 'allowed-once' | 'rejected'
21
-
22
- /** One pending approval question, derived from the request for rendering. */
23
- export interface PendingApproval {
24
- /** The asker's human-readable explanation, or a generic fallback. */
25
- headline: string
26
- /** The tool the question is about. */
27
- toolName: string
28
- /** Command-line preview resolved from the paired streaming tool call. */
29
- command: string
30
- /** Resolve the ask; calling twice is inert (one-shot latch). */
31
- answer(outcome: ApprovalAnswer): void
32
- }
33
-
34
- /** The pending-question snapshot the renderer subscribes to. */
35
- export interface ApprovalSnapshot {
36
- /** The question on screen (queue head), or undefined when none is asked. */
37
- pending: PendingApproval | undefined
38
- /** Presentational: an answer was submitted, the ask has not settled yet. */
39
- answered: boolean
40
- /** Further asks waiting behind the on-screen one (FIFO, Codex-style). */
41
- queued: number
42
- }
43
-
44
- /** Store the pending question lands in; the renderer reads, the answerer writes. */
45
- export interface ApprovalStore {
46
- /** Subscribe to pending-state changes; returns the unsubscribe function. */
47
- subscribe(listener: () => void): () => void
48
- /** Read the current snapshot (identity-stable between changes). */
49
- getSnapshot(): ApprovalSnapshot
50
- }
51
-
52
- /**
53
- * Create the approval store and mount the answerer listener on the context.
54
- * The listener claims only requests for `owns`-owned agents and defers every
55
- * other request back into the waterfall (`next()`), so sibling answerers stay
56
- * usable. An aborted ask never reaches the human. Plugin teardown removes the
57
- * listener; the service then fails its own question closed.
58
- * @param ctx - plugin context whose event bus carries `approval/request`.
59
- * @param owns - agents this terminal answers for.
60
- * @param preview - resolves a tool-call preview for a pending request (the
61
- * request contract carries no arguments; the UI self-serves from the
62
- * transcript projection via `callId`).
63
- * @returns the store the renderer subscribes to.
64
- */
65
- export function mountApprovalAnswerer(
66
- ctx: Context,
67
- owns: (agent: Agent) => boolean,
68
- preview: (request: ApprovalRequest) => string,
69
- ): ApprovalStore {
70
- /** One live ask: its pending view plus the one-shot settle plumbing. */
71
- interface Slot {
72
- readonly pending: PendingApproval
73
- answered: boolean
74
- }
75
- const queue: Slot[] = []
76
- let snapshot: ApprovalSnapshot = { pending: undefined, answered: false, queued: 0 }
77
- const listeners = new Set<() => void>()
78
- const publish = (): void => {
79
- const head = queue[0]
80
- snapshot = {
81
- pending: head === undefined ? undefined : head.pending,
82
- answered: head !== undefined && head.answered,
83
- queued: Math.max(0, queue.length - 1),
84
- }
85
- for (const listener of listeners) listener()
86
- }
87
- const removeSlot = (slot: Slot): void => {
88
- const at = queue.indexOf(slot)
89
- if (at !== -1) queue.splice(at, 1)
90
- }
91
-
92
- ctx.on('approval/request', (request: ApprovalRequest, next: () => Promise<ApprovalOutcome>) => {
93
- if (!owns(request.agent)) return next()
94
- // An already-aborted ask never reaches the human (mirrors the host bridge).
95
- if (request.signal?.aborted === true) return Promise.resolve<ApprovalOutcome>('cancelled')
96
-
97
- let resolved = false
98
- let settle!: (outcome: ApprovalOutcome) => void
99
- // Established BEFORE the abort listener mounts: a synchronous throw
100
- // between the listener registration and a later construction site would
101
- // otherwise leave a subsequent abort invoking an unassigned settle from
102
- // inside the AbortSignal listener (an uncaughtException).
103
- const settled = new Promise<ApprovalOutcome>((resolve) => {
104
- settle = resolve
105
- })
106
- const signal = request.signal
107
- const onAbort = (): void => withdraw()
108
- // Detach on every settle so an answered ask never retains a listener on
109
- // the tool call's signal (long turns ask many times; each ask must let go).
110
- const detachAbort = (): void => {
111
- if (signal !== undefined) signal.removeEventListener('abort', onAbort)
112
- }
113
- const withdraw = (): void => {
114
- if (resolved) return
115
- resolved = true
116
- detachAbort()
117
- removeSlot(slot)
118
- publish()
119
- // The service's signal race would conclude 'cancelled' anyway; settle
120
- // the same way so this listener never dangles a pending promise.
121
- settle('cancelled')
122
- }
123
- if (signal !== undefined) {
124
- signal.addEventListener('abort', onAbort, { once: true })
125
- }
126
- const slot: Slot = {
127
- answered: false,
128
- pending: {
129
- headline: request.reason ?? `tool ${request.toolName} asks for your approval`,
130
- toolName: request.toolName,
131
- command: preview(request),
132
- answer: (outcome: ApprovalAnswer): void => {
133
- // One-shot latch: a second keypress after submission is inert.
134
- if (resolved) return
135
- resolved = true
136
- detachAbort()
137
- slot.answered = true
138
- publish()
139
- settle(outcome)
140
- },
141
- },
142
- }
143
- queue.push(slot)
144
- publish()
145
-
146
- return settled.then((outcome) => {
147
- if (outcome !== 'cancelled') {
148
- removeSlot(slot)
149
- publish()
150
- }
151
- return outcome
152
- })
153
- })
154
-
155
- return {
156
- subscribe(listener: () => void): () => void {
157
- listeners.add(listener)
158
- return () => {
159
- listeners.delete(listener)
160
- }
161
- },
162
- getSnapshot(): ApprovalSnapshot {
163
- return snapshot
164
- },
165
- }
166
- }
1
+ /**
2
+ * The terminal approval answerer: one `approval/request` waterfall listener
3
+ * that renders the pending question as a y/n bar and resolves the decision
4
+ * back into the waterfall. Mirrors the web host's composer takeover — the
5
+ * service (audit pair, policy gate, fail-closed defaults) all live in
6
+ * dsh-base; this module only answers for agents this TUI owns.
7
+ *
8
+ * Vocabulary note: a client answerer may only ever resolve `'allowed-once'`
9
+ * or `'rejected'`; `'cancelled'` belongs to the request signal and
10
+ * `'unavailable'` to the fail-closed waterfall default.
11
+ *
12
+ * @module @deepseek-ai/dsh-code/approval
13
+ */
14
+
15
+ import type { Context } from '@deepseek-ai/cordis'
16
+ import type { Agent } from '@deepseek-ai/dsh-agent'
17
+ import type { ApprovalOutcome, ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
18
+
19
+ /** The answer values a client answerer may resolve with. */
20
+ export type ApprovalAnswer = 'allowed-once' | 'rejected'
21
+
22
+ /** One pending approval question, derived from the request for rendering. */
23
+ export interface PendingApproval {
24
+ /** The asker's human-readable explanation, or a generic fallback. */
25
+ headline: string
26
+ /** The tool the question is about. */
27
+ toolName: string
28
+ /** Command-line preview resolved from the paired streaming tool call. */
29
+ command: string
30
+ /** Resolve the ask; calling twice is inert (one-shot latch). */
31
+ answer(outcome: ApprovalAnswer): void
32
+ }
33
+
34
+ /** The pending-question snapshot the renderer subscribes to. */
35
+ export interface ApprovalSnapshot {
36
+ /** The question on screen (queue head), or undefined when none is asked. */
37
+ pending: PendingApproval | undefined
38
+ /** Presentational: an answer was submitted, the ask has not settled yet. */
39
+ answered: boolean
40
+ /** Further asks waiting behind the on-screen one (FIFO, Codex-style). */
41
+ queued: number
42
+ }
43
+
44
+ /** Store the pending question lands in; the renderer reads, the answerer writes. */
45
+ export interface ApprovalStore {
46
+ /** Subscribe to pending-state changes; returns the unsubscribe function. */
47
+ subscribe(listener: () => void): () => void
48
+ /** Read the current snapshot (identity-stable between changes). */
49
+ getSnapshot(): ApprovalSnapshot
50
+ }
51
+
52
+ /**
53
+ * Create the approval store and mount the answerer listener on the context.
54
+ * The listener claims only requests for `owns`-owned agents and defers every
55
+ * other request back into the waterfall (`next()`), so sibling answerers stay
56
+ * usable. An aborted ask never reaches the human. Plugin teardown removes the
57
+ * listener; the service then fails its own question closed.
58
+ * @param ctx - plugin context whose event bus carries `approval/request`.
59
+ * @param owns - agents this terminal answers for.
60
+ * @param preview - resolves a tool-call preview for a pending request (the
61
+ * request contract carries no arguments; the UI self-serves from the
62
+ * transcript projection via `callId`).
63
+ * @returns the store the renderer subscribes to.
64
+ */
65
+ export function mountApprovalAnswerer(
66
+ ctx: Context,
67
+ owns: (agent: Agent) => boolean,
68
+ preview: (request: ApprovalRequest) => string,
69
+ ): ApprovalStore {
70
+ /** One live ask: its pending view plus the one-shot settle plumbing. */
71
+ interface Slot {
72
+ readonly pending: PendingApproval
73
+ answered: boolean
74
+ }
75
+ const queue: Slot[] = []
76
+ let snapshot: ApprovalSnapshot = { pending: undefined, answered: false, queued: 0 }
77
+ const listeners = new Set<() => void>()
78
+ const publish = (): void => {
79
+ const head = queue[0]
80
+ snapshot = {
81
+ pending: head === undefined ? undefined : head.pending,
82
+ answered: head !== undefined && head.answered,
83
+ queued: Math.max(0, queue.length - 1),
84
+ }
85
+ for (const listener of listeners) listener()
86
+ }
87
+ const removeSlot = (slot: Slot): void => {
88
+ const at = queue.indexOf(slot)
89
+ if (at !== -1) queue.splice(at, 1)
90
+ }
91
+
92
+ ctx.on('approval/request', (request: ApprovalRequest, next: () => Promise<ApprovalOutcome>) => {
93
+ if (!owns(request.agent)) return next()
94
+ // An already-aborted ask never reaches the human (mirrors the host bridge).
95
+ if (request.signal?.aborted === true) return Promise.resolve<ApprovalOutcome>('cancelled')
96
+
97
+ let resolved = false
98
+ let settle!: (outcome: ApprovalOutcome) => void
99
+ // Established BEFORE the abort listener mounts: a synchronous throw
100
+ // between the listener registration and a later construction site would
101
+ // otherwise leave a subsequent abort invoking an unassigned settle from
102
+ // inside the AbortSignal listener (an uncaughtException).
103
+ const settled = new Promise<ApprovalOutcome>((resolve) => {
104
+ settle = resolve
105
+ })
106
+ const signal = request.signal
107
+ const onAbort = (): void => withdraw()
108
+ // Detach on every settle so an answered ask never retains a listener on
109
+ // the tool call's signal (long turns ask many times; each ask must let go).
110
+ const detachAbort = (): void => {
111
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort)
112
+ }
113
+ const withdraw = (): void => {
114
+ if (resolved) return
115
+ resolved = true
116
+ detachAbort()
117
+ removeSlot(slot)
118
+ publish()
119
+ // The service's signal race would conclude 'cancelled' anyway; settle
120
+ // the same way so this listener never dangles a pending promise.
121
+ settle('cancelled')
122
+ }
123
+ if (signal !== undefined) {
124
+ signal.addEventListener('abort', onAbort, { once: true })
125
+ }
126
+ const slot: Slot = {
127
+ answered: false,
128
+ pending: {
129
+ headline: request.reason ?? `tool ${request.toolName} asks for your approval`,
130
+ toolName: request.toolName,
131
+ command: preview(request),
132
+ answer: (outcome: ApprovalAnswer): void => {
133
+ // One-shot latch: a second keypress after submission is inert.
134
+ if (resolved) return
135
+ resolved = true
136
+ detachAbort()
137
+ slot.answered = true
138
+ publish()
139
+ settle(outcome)
140
+ },
141
+ },
142
+ }
143
+ queue.push(slot)
144
+ publish()
145
+
146
+ return settled.then((outcome) => {
147
+ if (outcome !== 'cancelled') {
148
+ removeSlot(slot)
149
+ publish()
150
+ }
151
+ return outcome
152
+ })
153
+ })
154
+
155
+ return {
156
+ subscribe(listener: () => void): () => void {
157
+ listeners.add(listener)
158
+ return () => {
159
+ listeners.delete(listener)
160
+ }
161
+ },
162
+ getSnapshot(): ApprovalSnapshot {
163
+ return snapshot
164
+ },
165
+ }
166
+ }
@@ -52,17 +52,41 @@ export function looksLikeImagePath(path: string): boolean {
52
52
  return IMAGE_EXTENSIONS.has(extname(path).toLowerCase())
53
53
  }
54
54
 
55
+ /** Strip one layer of ASCII or Unicode quotes and shell-escaped spaces. */
56
+ export function unwrapDroppedPath(token: string): string {
57
+ const trimmed = token.trim()
58
+ const wrapped = /^[\u2018\u201C"'](.+)[\u2019\u201D"']$/u.exec(trimmed)
59
+ return (wrapped?.[1] ?? trimmed).replace(/\\ /gu, ' ')
60
+ }
61
+
62
+ /** Absolute/relative drop with a dotted leaf — including unquoted spaces. */
63
+ export function looksLikeFilesystemDrop(path: string): boolean {
64
+ if (path.startsWith('file://')) return true
65
+ if (!/^(?:\/|[A-Za-z]:[\\/]|\\\\|\.\.?\/)/u.test(path)) return false
66
+ return looksLikeImagePath(path) || /\.[A-Za-z0-9]{1,16}$/u.test(path)
67
+ }
68
+
55
69
  /**
56
- * Parse a paste/drop into its image and file paths: image-suffixed tokens
57
- * stay images, other path-shaped tokens ride as file attachments (0.1.5
58
- * file blocks), and anything that is neither leaves both empty — the caller
59
- * then treats the paste as plain text.
70
+ * A composer draft that is a filesystem path, not a slash command.
71
+ * `/usage` stays a command; `/Users/foo.png` and `C:\temp\a.png` are drops.
72
+ */
73
+ export function looksLikePathDraft(value: string): boolean {
74
+ const path = unwrapDroppedPath(value)
75
+ if (looksLikeFilesystemDrop(path)) return true
76
+ return /^\/(?:Users|home|tmp|var|etc|opt|mnt|Volumes)\//u.test(path)
77
+ }
78
+
79
+ /**
80
+ * Parse a paste or file-drop into image and file paths: image-suffixed
81
+ * tokens stay images, other path-shaped tokens ride as file attachments
82
+ * (0.1.5 file blocks), and anything that is neither leaves both empty —
83
+ * the caller then treats the paste as plain text.
60
84
  *
61
85
  * File tokens are held to an absolute-path-with-shape bar (drive/backslash
62
- * or a dot-suffixed leaf after a separator): a dropped terminal path always
86
+ * or a dotted leaf after a separator): a dropped terminal path always
63
87
  * carries one of those, while prose, slash commands, and option flags never
64
- * do. A POSIX absolute path without any dot-suffixed leaf falls through as
65
- * text — the @ mention route still attaches such files deliberately.
88
+ * do. A POSIX absolute path without a dotted leaf falls through as text —
89
+ * the @ mention route still attaches such files deliberately.
66
90
  */
67
91
  export function parsePastedAttachmentPaths(input: string): { readonly images: readonly string[]; readonly files: readonly string[] } {
68
92
  const text = input.trim()
@@ -73,27 +97,49 @@ export function parsePastedAttachmentPaths(input: string): { readonly images: re
73
97
  /^[A-Za-z]:[\\/]/u.test(path)
74
98
  || /^\\\\/u.test(path)
75
99
  || (/^\/|^\.\.?\//u.test(path) && /\.[A-Za-z0-9]{1,16}$/u.test(path))
76
- const matcher = /"([^"]+)"|'([^']+)'|(\S+)/gu
77
- for (const match of text.matchAll(matcher)) {
78
- const token = match[1] ?? match[2] ?? match[3]
79
- if (token === undefined) continue
80
- let path = token
100
+ const classify = (raw: string): 'image' | 'file' | 'reject' => {
101
+ let path = unwrapDroppedPath(raw)
81
102
  if (path.startsWith('file://')) {
82
103
  try {
83
104
  path = fileURLToPath(path)
84
105
  } catch {
85
- return { images: [], files: [] }
106
+ return 'reject'
107
+ }
108
+ } else if (/%[0-9A-Fa-f]{2}/u.test(path)) {
109
+ try {
110
+ path = decodeURIComponent(path)
111
+ } catch {
112
+ // Keep the raw token when it is not a valid percent-encoding.
86
113
  }
87
- if (looksLikeImagePath(path)) images.push(path)
88
- else files.push(path)
89
- continue
90
114
  }
91
115
  if (looksLikeImagePath(path)) {
92
116
  images.push(path)
93
- continue
117
+ return 'image'
118
+ }
119
+ if (looksLikeDroppedFile(path) || looksLikeFilesystemDrop(path)) {
120
+ files.push(path)
121
+ return 'file'
94
122
  }
95
- if (!looksLikeDroppedFile(path)) return { images: [], files: [] }
96
- files.push(path)
123
+ return 'reject'
124
+ }
125
+ // A single dropped path with spaces often arrives unquoted (macOS, Windows
126
+ // Explorer, VS Code sendText). Use the whole paste only when there is one
127
+ // path start and the text actually contains spaces (or wrapping quotes) —
128
+ // two unquoted `C:\a.png D:\b.txt` tokens must still split.
129
+ const whole = unwrapDroppedPath(text)
130
+ const pathStarts = text.match(/(?:^|[\s"'])(?:\/|[A-Za-z]:[\\/]|\\\\|\.\.?\/|file:\/\/)/gu) ?? []
131
+ const spacedSingleton = pathStarts.length <= 1
132
+ && looksLikeFilesystemDrop(whole)
133
+ && (/\s/u.test(whole) || /^[\u2018\u201C"']/u.test(text))
134
+ if (spacedSingleton) {
135
+ classify(whole)
136
+ return { images, files }
137
+ }
138
+ const matcher = /"([^"]+)"|'([^']+)'|(\S+)/gu
139
+ for (const match of text.matchAll(matcher)) {
140
+ const token = match[1] ?? match[2] ?? match[3]
141
+ if (token === undefined) continue
142
+ if (classify(token) === 'reject') return { images: [], files: [] }
97
143
  }
98
144
  return { images, files }
99
145
  }
@@ -13,7 +13,9 @@ import type { CredentialKey } from '@deepseek-ai/dsh-credentials'
13
13
  import type { ProviderAuthorizationRow } from './authorization.ts'
14
14
  import { panelViewport } from './render/inspector.ts'
15
15
  import { displayText, singleLineText, truncateColumns } from './render/text.ts'
16
+ import { panelAccent } from './panel-accent.ts'
16
17
  import { getPalette, inkColor } from './theme.ts'
18
+ import { t } from './i18n.ts'
17
19
 
18
20
  interface PromptReply {
19
21
  readonly resolve: (value: string) => void
@@ -73,14 +75,17 @@ export function ProviderAuthorizationPanel(props: ProviderAuthorizationPanelProp
73
75
  decline()
74
76
  }
75
77
 
78
+ const propsRef = useRef(props)
79
+ propsRef.current = props
80
+ const rowKey = props.row.key
76
81
  useEffect(() => () => {
77
82
  controllerRef.current?.abort()
78
- props.cancel(props.row.key)
83
+ propsRef.current.cancel(rowKey)
79
84
  const reply = replyRef.current
80
85
  replyRef.current = undefined
81
86
  reply?.detach()
82
87
  reply?.reject(new AuthorizationDeclinedError())
83
- }, [props.row.key])
88
+ }, [rowKey])
84
89
 
85
90
  const start = (method: string): void => {
86
91
  setPhase('running')
@@ -154,7 +159,7 @@ export function ProviderAuthorizationPanel(props: ProviderAuthorizationPanelProp
154
159
  setCursor(current => (current + 1) % props.row.methods.length)
155
160
  return
156
161
  }
157
- if (key.return) start(props.row.methods[cursor]?.id ?? props.row.methods[0]!.id)
162
+ if (key.return) start(props.row.methods[cursor]?.id ?? props.row.methods[0].id)
158
163
  return
159
164
  }
160
165
 
@@ -182,7 +187,7 @@ export function ProviderAuthorizationPanel(props: ProviderAuthorizationPanelProp
182
187
  setPromptCursor(current => (current + 1) % prompt.options.length)
183
188
  return
184
189
  }
185
- if (key.return) answer(prompt.options[promptCursor]?.id ?? prompt.options[0]!.id)
190
+ if (key.return) answer(prompt.options[promptCursor]?.id ?? prompt.options[0].id)
186
191
  return
187
192
  }
188
193
  if (key.backspace || key.delete) {
@@ -197,7 +202,7 @@ export function ProviderAuthorizationPanel(props: ProviderAuthorizationPanelProp
197
202
  })
198
203
 
199
204
  if (viewport.maxHeight === 0 || viewport.compact) {
200
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('provider login · esc cancel', viewport.contentColumns))
205
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.auth.compact'), viewport.contentColumns))
201
206
  }
202
207
 
203
208
  const rows: Array<{ key: string; text: string; color?: string; bold?: boolean }> = []
@@ -229,18 +234,19 @@ export function ProviderAuthorizationPanel(props: ProviderAuthorizationPanelProp
229
234
  rows.push({ key: 'draft', text: ` ${shown}▏`, color: inkColor(getPalette().text) })
230
235
  }
231
236
  } else {
232
- rows.push({ key: 'waiting', text: ' waiting for provider…', color: inkColor(getPalette().dim) })
237
+ rows.push({ key: 'waiting', text: ` ${t('panel.auth.waiting')}`, color: inkColor(getPalette().dim) })
233
238
  }
234
239
  if (copyState !== undefined) rows.push({ key: 'copy', text: ` ${singleLineText(copyState)}`, color: inkColor(copyState === 'copied' ? getPalette().success : getPalette().error) })
235
240
  }
236
241
  const visible = rows.slice(Math.max(0, rows.length - viewport.bodyRows))
237
242
  const footer = phase === 'methods'
238
- ? '↑↓ choose · enter continue · esc/q back'
239
- : 'enter answer · c copy URL/code · esc cancel login'
243
+ ? t('panel.auth.methodsFooter')
244
+ : t('panel.auth.answerFooter')
245
+ const accent = panelAccent('auth-login', getPalette().brand)
240
246
  return createElement(
241
247
  Box,
242
- { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
243
- createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model · login ${displayText(props.row.label)}`, viewport.contentColumns)),
248
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(accent.border) },
249
+ createElement(Text, { color: inkColor(accent.title), bold: true, wrap: 'truncate-end' }, truncateColumns(t('panel.auth.title', { provider: displayText(props.row.label) }), viewport.contentColumns)),
244
250
  ...visible.map(row => createElement(Text, { key: row.key, color: row.color, bold: row.bold, wrap: 'truncate-end' }, truncateColumns(row.text, viewport.contentColumns))),
245
251
  createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(footer, viewport.contentColumns)),
246
252
  )
@@ -248,9 +254,9 @@ export function ProviderAuthorizationPanel(props: ProviderAuthorizationPanelProp
248
254
 
249
255
  export function ProviderAuthorizationLogoutPanel({ row, confirm, done, back }: {
250
256
  row: ProviderAuthorizationRow
251
- confirm(row: ProviderAuthorizationRow): Promise<void>
252
- done(): void
253
- back(): void
257
+ confirm: (row: ProviderAuthorizationRow) => Promise<void>
258
+ done: () => void
259
+ back: () => void
254
260
  }): ReactElement {
255
261
  const stdout = useStdout().stdout
256
262
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
@@ -270,14 +276,14 @@ export function ProviderAuthorizationLogoutPanel({ row, confirm, done, back }: {
270
276
  setError(reason instanceof Error ? reason.message : String(reason))
271
277
  })
272
278
  })
273
- if (viewport.maxHeight === 0 || viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('y logout · n/esc back', viewport.contentColumns))
279
+ if (viewport.maxHeight === 0 || viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.auth.logoutCompact'), viewport.contentColumns))
274
280
  return createElement(
275
281
  Box,
276
282
  { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().warn) },
277
- createElement(Text, { color: inkColor(getPalette().warn), bold: true, wrap: 'truncate-end' }, truncateColumns('/model · logout provider', viewport.contentColumns)),
278
- createElement(Text, { wrap: 'truncate-end' }, truncateColumns(` remove ${displayText(row.label)} login record`, viewport.contentColumns)),
279
- createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(' provider endpoint and model configuration stay unchanged', viewport.contentColumns)),
283
+ createElement(Text, { color: inkColor(getPalette().warn), bold: true, wrap: 'truncate-end' }, truncateColumns(t('panel.auth.logoutTitle'), viewport.contentColumns)),
284
+ createElement(Text, { wrap: 'truncate-end' }, truncateColumns(` ${t('panel.auth.removeRecord', { provider: displayText(row.label) })}`, viewport.contentColumns)),
285
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(` ${t('panel.auth.unchanged')}`, viewport.contentColumns)),
280
286
  error === undefined ? undefined : createElement(Text, { color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${singleLineText(error)}`, viewport.contentColumns)),
281
- createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(busy ? 'working' : 'y confirm · n/esc back', viewport.contentColumns)),
287
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(busy ? t('panel.auth.working') : t('panel.auth.logoutFooter'), viewport.contentColumns)),
282
288
  )
283
289
  }