clauderipple 0.9.4 → 0.10.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 (118) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +31 -0
  3. package/dist/app/package.json +1 -1
  4. package/dist/cli/src/hooks/agent-title.js +7 -4
  5. package/dist/cli/src/index.js +43 -0
  6. package/dist/cli/src/remote.js +263 -0
  7. package/dist/cli/src/schtasks.js +48 -40
  8. package/dist/cli/src/settings.js +2 -1
  9. package/dist/mod/hooks/i18n.ts +252 -6
  10. package/dist/mod/hooks/judge.ts +35 -17
  11. package/dist/mod/hooks/modes.ts +56 -1
  12. package/dist/mod/hooks/register.tsx +238 -32
  13. package/dist/mod/hooks/session.ts +98 -1
  14. package/dist/mod/hooks/settings.js +7 -3
  15. package/dist/mod/hooks/settings.ts +9 -4
  16. package/dist/mod/hooks/stats.ts +25 -1
  17. package/dist/mod/hooks/ui/agents.tsx +858 -79
  18. package/dist/mod/hooks/ui/alerts.tsx +20 -17
  19. package/dist/mod/hooks/ui/art.ts +33 -7
  20. package/dist/mod/hooks/ui/band.tsx +262 -81
  21. package/dist/mod/hooks/ui/flat.ts +265 -0
  22. package/dist/mod/hooks/ui/kit.tsx +70 -55
  23. package/dist/mod/hooks/ui/look.ts +129 -0
  24. package/dist/mod/hooks/ui/settings.tsx +71 -46
  25. package/dist/mod/hooks/ui/setup.tsx +15 -13
  26. package/dist/mod/hooks/ui/theme.ts +3 -0
  27. package/dist/mod/hooks/ui/tools.ts +124 -0
  28. package/dist/mod/hooks/ui/view.ts +116 -3
  29. package/dist/mod/hooks/workers.ts +33 -6
  30. package/dist/mod/tests/register.test.tsx +155 -11
  31. package/dist/mod/tests/ui.test.tsx +375 -27
  32. package/dist/mod/types/index.d.ts +68 -2
  33. package/dist/remote/package.json +28 -0
  34. package/dist/remote/src/away.js +85 -0
  35. package/dist/remote/src/bridge-client.js +295 -0
  36. package/dist/remote/src/composer-menu.js +11 -0
  37. package/dist/remote/src/config.js +102 -0
  38. package/dist/remote/src/desktop.js +1018 -0
  39. package/dist/remote/src/e2ee.js +126 -0
  40. package/dist/remote/src/e2ee.ts +126 -0
  41. package/dist/remote/src/eventlog.js +298 -0
  42. package/dist/remote/src/expo-push.js +127 -0
  43. package/dist/remote/src/folders.js +79 -0
  44. package/dist/remote/src/fsutil.js +39 -0
  45. package/dist/remote/src/handoff.js +38 -0
  46. package/dist/remote/src/host-service.js +168 -0
  47. package/dist/remote/src/images.js +215 -0
  48. package/dist/remote/src/local-control.js +138 -0
  49. package/dist/remote/src/main.js +264 -0
  50. package/dist/remote/src/permissions.js +124 -0
  51. package/dist/remote/src/power.js +76 -0
  52. package/dist/remote/src/processes.js +24 -0
  53. package/dist/remote/src/protocol.js +32 -0
  54. package/dist/remote/src/protocol.ts +284 -0
  55. package/dist/remote/src/push-content.js +32 -0
  56. package/dist/remote/src/push.js +134 -0
  57. package/dist/remote/src/receipts.js +111 -0
  58. package/dist/remote/src/relay.js +438 -0
  59. package/dist/remote/src/router.js +208 -0
  60. package/dist/remote/src/sdk-launcher.js +18 -0
  61. package/dist/remote/src/server.js +706 -0
  62. package/dist/remote/src/service.js +157 -0
  63. package/dist/remote/src/sessions.js +1800 -0
  64. package/dist/remote/src/sidebar-storage.js +211 -0
  65. package/dist/remote/src/sidebar.js +173 -0
  66. package/dist/remote/src/tool-details.js +205 -0
  67. package/dist/remote/src/tool-details.ts +153 -0
  68. package/dist/remote/src/worktree.js +99 -0
  69. package/dist/remote/web/app.ts +746 -0
  70. package/dist/remote/web/conn.ts +193 -0
  71. package/dist/remote/web/demo.ts +134 -0
  72. package/dist/remote/web/dom.ts +124 -0
  73. package/dist/remote/web/esc.ts +4 -0
  74. package/dist/remote/web/i18n.ts +374 -0
  75. package/dist/remote/web/icon.svg +1 -0
  76. package/dist/remote/web/index.html +24 -0
  77. package/dist/remote/web/manifest.webmanifest +10 -0
  78. package/dist/remote/web/mascot.ts +60 -0
  79. package/dist/remote/web/md.ts +78 -0
  80. package/dist/remote/web/notify.ts +55 -0
  81. package/dist/remote/web/pushstate.ts +27 -0
  82. package/dist/remote/web/secure.ts +109 -0
  83. package/dist/remote/web/sidebar.ts +106 -0
  84. package/dist/remote/web/style.css +311 -0
  85. package/dist/remote/web/sw.ts +48 -0
  86. package/dist/remote/web/timeline.ts +224 -0
  87. package/dist/remote/web/views.ts +323 -0
  88. package/dist/router/src/admin.js +227 -4
  89. package/dist/router/src/config.js +30 -0
  90. package/dist/router/src/index.js +39 -0
  91. package/dist/router/src/ingress/translate.js +6 -2
  92. package/dist/router/src/phone.js +82 -0
  93. package/dist/router/src/presets.js +3 -0
  94. package/dist/router/src/providers/anthropic-usage.js +33 -1
  95. package/dist/router/src/providers/chatgpt/index.js +41 -11
  96. package/dist/router/src/providers/chatgpt/translate.js +147 -7
  97. package/dist/router/src/providers/google/index.js +26 -0
  98. package/dist/router/src/providers/google/prompt-refusal.js +29 -0
  99. package/dist/router/src/providers/google/usage.js +257 -0
  100. package/dist/router/src/providers/grok/index.js +18 -0
  101. package/dist/router/src/providers/grok/usage.js +77 -0
  102. package/dist/router/src/providers/openai/index.js +29 -5
  103. package/dist/router/src/providers/openai/translate.js +260 -27
  104. package/dist/router/src/providers/opencode-usage.js +116 -0
  105. package/dist/router/src/proxy.js +339 -258
  106. package/dist/router/src/reasoning.js +157 -0
  107. package/dist/router/src/remote-host.js +320 -0
  108. package/dist/router/src/remote-tunnel.js +320 -0
  109. package/dist/router/src/requestlog.js +48 -39
  110. package/dist/router/src/thinking.js +20 -9
  111. package/dist/router/src/usage-history.js +227 -0
  112. package/dist/router/src/version.js +1 -1
  113. package/dist/ui/app.js +436 -59
  114. package/dist/ui/i18n.js +184 -0
  115. package/dist/ui/index.html +24 -5
  116. package/dist/ui/style.css +39 -0
  117. package/docs/ARCHITECTURE.md +164 -12
  118. package/package.json +7 -1
@@ -0,0 +1,129 @@
1
+ // The skin (2026-10-14): which pictures the desktop draws with and what colours its host Text takes.
2
+ // The deep (art.ts) is the first look: dark water on either theme, 리플이's glow, drawn controls in
3
+ // glass. Flat (flat.ts) is the app's own surfaces: a hairline card that follows the theme, the
4
+ // host's own ink, the accent only where something is on. Those two share one layout and change
5
+ // paint alone: every size is art.ts's and every cell count the same under both, so band.tsx's
6
+ // arithmetic and the blank presses laid over the pictures (kit.tsx) hold whatever the skin. The
7
+ // terminal draws text and knows nothing of skins.
8
+
9
+ import type { Skin } from '../../types'
10
+ import * as deep from './art'
11
+ import type { Glyph, Icon, PillLook, Sea, TileArt, Tone } from './art'
12
+ import { flatPictures } from './flat'
13
+ import { rippleSource } from './parts'
14
+ import type { Face } from './ripple'
15
+ import { textTone } from './theme'
16
+ import type { Appearance } from './theme'
17
+ import type { Frame } from './view'
18
+
19
+ /** Props a host Text takes for a role on a capsule: a colour of ours, or the host's own ink. */
20
+ export type Word = { color?: string; dimColor?: boolean }
21
+
22
+ /** Props a Box takes: a capsule, a tile, a control lit under the pointer. */
23
+ export type Surface = { borderStyle?: string; borderColor?: string; backgroundColor?: string; hover?: { borderColor?: string; backgroundColor?: string } }
24
+
25
+ /** The pictures a skin paints, each at art.ts's size. */
26
+ export type Paint = {
27
+ /** Every capsule's backdrop, `width` × `height` px; '' when the skin paints none and the Box's own colour shows. */
28
+ sea: (width: number, height: number, sea: Sea) => string
29
+ plate: (width: number, height: number) => string
30
+ switch: (on: boolean, busy?: boolean) => string
31
+ word: (label: string, on: boolean, busy?: boolean) => { source: string; cells: number }
32
+ extraHover: (widthPx: number) => string
33
+ pill: (label: string, look: PillLook, tone?: Tone, glyph?: Glyph) => { source: string; cells: number }
34
+ segmented: (labels: readonly string[], cells: readonly number[], chosen: number, tone?: Tone, colors?: readonly string[]) => string
35
+ slider: (count: number, cellsPerStop: number, chosen: number) => string
36
+ well: (width: number, height: number) => string
37
+ close: (lit?: boolean) => string
38
+ meter: (lit: number, look: 'auto' | 'manual' | 'quiet' | 'deciding') => string
39
+ ring: (percent: number, tone: Tone | 'ink') => string
40
+ clock: (share: number, tone: Tone | 'soft') => string
41
+ gauge: (kind: string) => string
42
+ icon: (icon: Icon, on: boolean, tone?: Tone) => string
43
+ agents: (count: number, on: boolean) => string
44
+ rule: () => string
45
+ tile: (art: TileArt) => string
46
+ beads: (step: number, total: number) => { source: string; width: number }
47
+ }
48
+
49
+ export type Pictures = Paint & {
50
+ /** 리플이's document at `size` px, in the skin's palette (parts.tsx `rippleSource`). */
51
+ mascot: (face: Face, size: number, percent?: number, dim?: boolean) => string
52
+ }
53
+
54
+ export type Look = {
55
+ skin: Skin
56
+ /** Host Text on a capsule: bright for values, soft for names, quiet for asides. */
57
+ ink: Word
58
+ sub: Word
59
+ mute: Word
60
+ /** The text colour of a tone: a card's title in its light, a figure gone amber. */
61
+ tone: (tone: Tone) => string
62
+ capsule: Surface
63
+ tile: Surface
64
+ /** A control under the pointer (kit.tsx `lit`): its hover alone. */
65
+ lit: Surface
66
+ art: Pictures
67
+ }
68
+
69
+ const DEEP_PAINT: Paint = {
70
+ sea: deep.seaArt, plate: deep.plateArt, switch: deep.switchArt, word: deep.wordArt, extraHover: deep.extraHoverArt,
71
+ pill: deep.pillArt, segmented: deep.segmentedArt, slider: deep.sliderArt, well: deep.wellArt, close: deep.closeArt,
72
+ meter: deep.meterArt, ring: deep.ringArt, clock: deep.clockArt, gauge: deep.gaugeArt, icon: deep.iconArt,
73
+ agents: deep.agentsArt, rule: deep.ruleArt, tile: deep.tileArt, beads: deep.beadsArt,
74
+ }
75
+
76
+ /** The deep: the capsule's edge a warm grey that reads on a light page and a dark one alike, lights white on the water. */
77
+ const DEEP: Look = {
78
+ skin: 'deep',
79
+ ink: { color: deep.INK },
80
+ sub: { color: deep.SUB },
81
+ mute: { color: deep.MUTE },
82
+ tone: tone => deep.TONE[tone].text,
83
+ capsule: { borderStyle: 'round', borderColor: 'rgba(128,124,116,0.55)' },
84
+ tile: { borderStyle: 'round', borderColor: 'rgba(255,255,255,0.1)', backgroundColor: 'rgba(255,255,255,0.045)', hover: { borderColor: 'rgba(255,255,255,0.26)', backgroundColor: 'rgba(255,255,255,0.075)' } },
85
+ lit: { borderStyle: 'round', borderColor: 'rgba(0,0,0,0)', hover: { backgroundColor: 'rgba(255,255,255,0.09)', borderColor: 'rgba(255,255,255,0.16)' } },
86
+ // 리플이 drawn for the deep: the dark palette, light ink and lavender tone, whatever the theme.
87
+ art: { ...DEEP_PAINT, mascot: (face, size, percent, dim) => rippleSource(face, size, 'dark', percent, dim) },
88
+ }
89
+
90
+ /**
91
+ * Flat: the capsule painted in the app's own surface (flat.ts `sea`, white on a light app as its
92
+ * prompt box is), edged in a grey that sits on either theme, the host's own ink for values and the
93
+ * app's quiet text colour for names and asides, and the pictures in the theme's palette. The
94
+ * host's `dimColor` was too faint to read on a light page (2026-10-16). The engine takes a theme
95
+ * key, a name or hex here (not the app's CSS variables) and does not say which theme is on, so
96
+ * with appearance auto the quiet words are a mid tone between the app's two. One per appearance.
97
+ */
98
+ const FLATS = new Map<Appearance, Look>()
99
+
100
+ /** The app's quiet text (text-400, read from its stylesheet 2026-10-16) per theme, and between them. */
101
+ const QUIET: Record<Appearance, string> = { light: '#73726c', dark: '#9c9a92', auto: '#86847d' }
102
+
103
+ function flat(appearance: Appearance): Look {
104
+ let look = FLATS.get(appearance)
105
+ if (look) return look
106
+ const text = textTone(appearance)
107
+ look = {
108
+ skin: 'flat',
109
+ ink: {},
110
+ sub: { color: QUIET[appearance] },
111
+ mute: { color: QUIET[appearance] },
112
+ // One accent: the deep's decorative tones (the judge's teal, the mode's amber) are the accent
113
+ // here, and amber, red and green stay what they mean.
114
+ tone: tone => (tone === 'amber' || tone === 'ember' ? text.warn : tone === 'red' ? text.bad : tone === 'green' ? text.ok : text.accent),
115
+ capsule: { borderStyle: 'round', borderColor: 'rgba(128,124,116,0.3)' },
116
+ tile: { borderStyle: 'round', borderColor: 'rgba(128,124,116,0.22)', backgroundColor: 'rgba(128,124,116,0.04)', hover: { borderColor: 'rgba(128,124,116,0.4)', backgroundColor: 'rgba(128,124,116,0.08)' } },
117
+ lit: { borderStyle: 'round', borderColor: 'rgba(0,0,0,0)', hover: { backgroundColor: 'rgba(128,124,116,0.12)', borderColor: 'rgba(0,0,0,0)' } },
118
+ art: { ...flatPictures(appearance), mascot: (face, size, percent, dim) => rippleSource(face, size, appearance, percent, dim) },
119
+ }
120
+ FLATS.set(appearance, look)
121
+ return look
122
+ }
123
+
124
+ /**
125
+ * The look a frame draws in: flat's own surfaces for flat, the deep for everything else.
126
+ */
127
+ export function lookOf(frame: Frame): Look {
128
+ return frame.skin === 'flat' ? flat(frame.appearance) : DEEP
129
+ }
@@ -10,10 +10,13 @@
10
10
  import { t } from '../i18n'
11
11
  import type { RenderNode } from 'claude-code'
12
12
 
13
- import type { Settings } from '../../types'
14
- import { CELL_PX, INK, MUTE, SUB, TILE_PX, TONE, tileArt } from './art'
13
+ import type { Settings, Skin } from '../../types'
14
+ import { SKINS } from '../settings'
15
+ import { CELL_PX, TILE_PX } from './art'
15
16
  import type { TileArt } from './art'
16
17
  import { capsule, pill, pic, ripple, segmented, slider, toggle, well } from './kit'
18
+ import { lookOf } from './look'
19
+ import type { Word } from './look'
17
20
  import { action, actions, canDraw, dismiss, segmented as textSegmented, speak, toggle as textToggle } from './parts'
18
21
  import type { Els } from './parts'
19
22
  import { chOf, displayWidth, textTone } from './theme'
@@ -21,7 +24,7 @@ import type { Frame, SettingsActions, SettingsView } from './view'
21
24
  import { isRich } from './view'
22
25
 
23
26
  /** Every name a row of quick settings or setup shows: the name column is the widest of them. */
24
- const ROW_NAMES = ['settings.effortAuto', 'settings.lean', 'settings.judge', 'settings.key', 'settings.keepWarm', 'settings.workers', 'settings.mode', 'band.effort', 'band.cache',
27
+ const ROW_NAMES = ['settings.effortAuto', 'settings.lean', 'settings.judge', 'settings.key', 'settings.keepWarm', 'settings.workers', 'settings.mode', 'settings.skin', 'band.effort', 'band.cache',
25
28
  'workers.rung.light', 'workers.rung.standard', 'workers.rung.hard', 'workers.rung.plan'] as const
26
29
 
27
30
  /** The name column's width in cells: snug to this language's names, so controls sit close to them. */
@@ -73,7 +76,7 @@ export function effortRows(el: Els, frame: Frame, d: Settings, a: SettingsAction
73
76
  }
74
77
 
75
78
  /** The Jev key field and its test, when TypeSafe's Jev judges and no key is known. */
76
- function needsKey(view: SettingsView): boolean {
79
+ export function needsKey(view: SettingsView): boolean {
77
80
  return view.settings.judge === 'jev' && view.settings.jevSource === 'typesafe' && !['env', 'file', 'pasted'].includes(view.jev.source ?? '')
78
81
  }
79
82
 
@@ -151,7 +154,7 @@ export function keepWarmRow(el: Els, frame: Frame, d: Settings, a: SettingsActio
151
154
  return row(el, frame, 'warm', t(frame.lang, 'settings.keepWarm'), textToggle(el, frame, 'set-warm', d.keepWarm ? t(frame.lang, 'band.on') : t(frame.lang, 'band.off'), d.keepWarm, () => a.patch({ keepWarm: !d.keepWarm })), t(frame.lang, 'settings.keepWarmHint'))
152
155
  }
153
156
 
154
- function modeItems(frame: Frame, d: Settings): { value: string; label: string }[] {
157
+ export function modeItems(frame: Frame, d: Settings): { value: string; label: string }[] {
155
158
  return [{ value: '', label: t(frame.lang, 'settings.modeNone') }, ...d.modes.map(m => ({ value: m.id, label: m.name }))]
156
159
  }
157
160
 
@@ -165,11 +168,6 @@ function judgeName(frame: Frame, judge: Settings['judge']): string {
165
168
  // name in bright ink with its switch at the right when it has one, one line on what it does, then
166
169
  // its control. The pictures replace the old line icons, which said nothing at 15 px.
167
170
 
168
- /** A tile's edge and glass: light on the water, brighter under the pointer. */
169
- const TILE_EDGE = 'rgba(255,255,255,0.1)'
170
- const TILE_EDGE_HOT = 'rgba(255,255,255,0.26)'
171
- const TILE_GLASS = 'rgba(255,255,255,0.045)'
172
- const TILE_GLASS_HOT = 'rgba(255,255,255,0.075)'
173
171
  /** The least a tile takes, in cells: two side by side from about 84 cells, one a row below. */
174
172
  const TILE_MIN = 40
175
173
 
@@ -186,19 +184,21 @@ function tileInner(frame: Frame): number {
186
184
 
187
185
  type Tile = { id: string; art: TileArt; title: string; head?: RenderNode; line: string; body: RenderNode[] }
188
186
 
189
- function tile(el: Els, x: Tile): RenderNode {
187
+ /** A tile in the skin's edge and glass (look.ts `tile`). */
188
+ function tile(el: Els, frame: Frame, x: Tile): RenderNode {
190
189
  const { Box, Text } = el
190
+ const k = lookOf(frame)
191
191
  return (
192
- <Box key={'tile-' + x.id} position="relative" flexDirection="row" alignItems="flex-start" columnGap={2} flexGrow={1} width={0} minWidth={TILE_MIN} paddingX={1.5} paddingY={1} borderStyle="round" borderColor={TILE_EDGE} backgroundColor={TILE_GLASS} hover={{ borderColor: TILE_EDGE_HOT, backgroundColor: TILE_GLASS_HOT }}>
193
- {pic(el, 'tile-art-' + x.id, tileArt(x.art), x.title, TILE_PX, TILE_PX)}
192
+ <Box key={'tile-' + x.id} position="relative" flexDirection="row" alignItems="flex-start" columnGap={2} flexGrow={1} width={0} minWidth={TILE_MIN} paddingX={1.5} paddingY={1} {...k.tile}>
193
+ {pic(el, 'tile-art-' + x.id, k.art.tile(x.art), x.title, TILE_PX, TILE_PX)}
194
194
  <Box key={'tile-body-' + x.id} flexDirection="column" rowGap={1} flexGrow={1} flexShrink={1} minWidth={0}>
195
195
  <Box flexDirection="row" alignItems="center" columnGap={1} width="100%" minWidth={0}>
196
196
  <Box flexGrow={1} flexShrink={1} minWidth={0}>
197
- <Text bold color={INK} wrap="wrap">{x.title}</Text>
197
+ <Text bold {...k.ink} wrap="wrap">{x.title}</Text>
198
198
  </Box>
199
199
  {x.head ?? null}
200
200
  </Box>
201
- <Text color={SUB} wrap="wrap">{x.line}</Text>
201
+ <Text {...k.sub} wrap="wrap">{x.line}</Text>
202
202
  {x.body}
203
203
  </Box>
204
204
  </Box>
@@ -206,9 +206,9 @@ function tile(el: Els, x: Tile): RenderNode {
206
206
  }
207
207
 
208
208
  /** A quiet line under a control. */
209
- function note(el: Els, key: string, text: string, color = MUTE): RenderNode {
209
+ function note(el: Els, frame: Frame, key: string, text: string, word?: Word): RenderNode {
210
210
  const { Text } = el
211
- return <Text key={key} color={color} wrap="wrap">{text}</Text>
211
+ return <Text key={key} {...(word ?? lookOf(frame).mute)} wrap="wrap">{text}</Text>
212
212
  }
213
213
 
214
214
  /** Automatic effort: its switch, and while on the lean between cheaper and smarter. */
@@ -216,21 +216,22 @@ export function effortTile(el: Els, frame: Frame, view: SettingsView, a: Setting
216
216
  const { Box, Text } = el
217
217
  const L = frame.lang
218
218
  const d = view.settings
219
+ const k = lookOf(frame)
219
220
  const body = d.effortAuto
220
221
  ? [
221
222
  <Box key="lean" flexDirection="row" alignItems="center" columnGap={1} flexWrap="wrap">
222
- <Text color={MUTE}>{t(L, 'settings.cheaper')}</Text>
223
- {slider(el, 'bias', BIAS, d.bias, bias => a.patch({ bias }), 3)}
224
- <Text color={MUTE}>{t(L, 'settings.smarter')}</Text>
223
+ <Text {...k.mute}>{t(L, 'settings.cheaper')}</Text>
224
+ {slider(el, frame, 'bias', BIAS, d.bias, bias => a.patch({ bias }), 3)}
225
+ <Text {...k.mute}>{t(L, 'settings.smarter')}</Text>
225
226
  </Box>,
226
- note(el, 'lean-note', t(L, 'settings.leanHint')),
227
+ note(el, frame, 'lean-note', t(L, 'settings.leanHint')),
227
228
  ]
228
- : [note(el, 'auto-off', t(L, 'settings.autoOffNote'))]
229
- return tile(el, {
229
+ : [note(el, frame, 'auto-off', t(L, 'settings.autoOffNote'))]
230
+ return tile(el, frame, {
230
231
  id: 'effort',
231
232
  art: { kind: 'effort', on: d.effortAuto },
232
233
  title: t(L, 'settings.effortAuto'),
233
- head: toggle(el, 'set-auto', d.effortAuto, t(L, d.effortAuto ? 'band.on' : 'band.off'), () => a.patch({ effortAuto: !d.effortAuto })),
234
+ head: toggle(el, frame, 'set-auto', d.effortAuto, t(L, d.effortAuto ? 'band.on' : 'band.off'), () => a.patch({ effortAuto: !d.effortAuto })),
234
235
  line: t(L, 'settings.autoLine'),
235
236
  body,
236
237
  })
@@ -241,34 +242,35 @@ export function judgeTile(el: Els, frame: Frame, view: SettingsView, a: Settings
241
242
  const { Box, Text } = el
242
243
  const L = frame.lang
243
244
  const d = view.settings
245
+ const k = lookOf(frame)
244
246
  const body: RenderNode[] = [
245
- segmented(el, 'judge', [
247
+ segmented(el, frame, 'judge', [
246
248
  { value: 'haiku', label: t(L, 'settings.haiku') },
247
249
  { value: 'jev', label: t(L, 'settings.jev') },
248
250
  { value: 'model', label: t(L, 'settings.routerModel') },
249
251
  ] as const, d.judge, judge => a.patch({ judge }), 'teal'),
250
252
  ]
251
- if (d.judge === 'model' && d.judgeModel) body.push(note(el, 'judge-model-name', t(L, 'settings.routerModel') + ' · ' + d.judgeModel))
253
+ if (d.judge === 'model' && d.judgeModel) body.push(note(el, frame, 'judge-model-name', t(L, 'settings.routerModel') + ' · ' + d.judgeModel))
252
254
  if (needsKey(view)) {
253
255
  const jev = view.jev
254
256
  const placeholder = t(L, 'settings.keyPaste')
255
257
  const field = 'Input' in el
256
- ? well(el, 'jev-key', tileInner(frame), <el.Input key="jev-key" placeholder={placeholder} value="" submitLabel={t(L, 'settings.saveKey')} onSubmit={(key: string) => a.jevKey(key)} />, placeholder)
257
- : <Text color={MUTE}>{placeholder}</Text>
258
+ ? well(el, frame, 'jev-key', tileInner(frame), <el.Input key="jev-key" placeholder={placeholder} value="" submitLabel={t(L, 'settings.saveKey')} onSubmit={(key: string) => a.jevKey(key)} />, placeholder)
259
+ : <Text {...k.mute}>{placeholder}</Text>
258
260
  const result =
259
- jev.test.state === 'testing' ? <Text color={SUB}>{t(L, 'settings.testing')}</Text>
260
- : jev.test.state === 'ok' ? <Text color={TONE.green.text} wrap="wrap">{'✓ ' + (jev.test.message ?? t(L, 'settings.keyWorks'))}</Text>
261
- : jev.test.state === 'fail' ? <Text color={TONE.red.text} wrap="wrap">{'✕ ' + (jev.test.message ?? t(L, 'settings.keyRejected'))}</Text>
261
+ jev.test.state === 'testing' ? <Text {...k.sub}>{t(L, 'settings.testing')}</Text>
262
+ : jev.test.state === 'ok' ? <Text color={k.tone('green')} wrap="wrap">{'✓ ' + (jev.test.message ?? t(L, 'settings.keyWorks'))}</Text>
263
+ : jev.test.state === 'fail' ? <Text color={k.tone('red')} wrap="wrap">{'✕ ' + (jev.test.message ?? t(L, 'settings.keyRejected'))}</Text>
262
264
  : null
263
265
  body.push(
264
266
  field,
265
267
  <Box key="jev-row" flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={1} rowGap={1}>
266
- {pill(el, 'jev-test', t(L, 'settings.test'), 'ghost', a.testJev, 'teal')}
268
+ {pill(el, frame, 'jev-test', t(L, 'settings.test'), 'ghost', a.testJev, 'teal')}
267
269
  {result}
268
270
  </Box>,
269
271
  )
270
272
  }
271
- return tile(el, { id: 'judge', art: { kind: 'judge', judge: d.judge }, title: t(L, 'settings.judge'), line: t(L, 'settings.judgeLine'), body })
273
+ return tile(el, frame, { id: 'judge', art: { kind: 'judge', judge: d.judge }, title: t(L, 'settings.judge'), line: t(L, 'settings.judgeLine'), body })
272
274
  }
273
275
 
274
276
  /** The worker set that is on: Base or a named mode. */
@@ -278,22 +280,43 @@ export function modeTile(el: Els, frame: Frame, view: SettingsView, a: SettingsA
278
280
  const items = modeItems(frame, d)
279
281
  const current = d.activeMode ?? ''
280
282
  const control = items.length <= 5 || !('Select' in el)
281
- ? segmented(el, 'mode', items, current, id => a.setMode?.(id), 'amber')
283
+ ? segmented(el, frame, 'mode', items, current, id => a.setMode?.(id), 'amber')
282
284
  : <el.Select key="mode" options={items} value={current} onSelect={(value: string) => a.setMode?.(value)} />
283
- return tile(el, { id: 'mode', art: { kind: 'mode', on: current !== '' }, title: t(L, 'settings.mode'), line: t(L, 'settings.modeLine'), body: [control] })
285
+ return tile(el, frame, { id: 'mode', art: { kind: 'mode', on: current !== '' }, title: t(L, 'settings.mode'), line: t(L, 'settings.modeLine'), body: [control] })
284
286
  }
285
287
 
286
288
  /** Keep warm: its switch, and what each ping costs. */
287
289
  export function warmTile(el: Els, frame: Frame, view: SettingsView, a: SettingsActions): RenderNode {
288
290
  const L = frame.lang
289
291
  const d = view.settings
290
- return tile(el, {
292
+ return tile(el, frame, {
291
293
  id: 'warm',
292
294
  art: { kind: 'warm', on: d.keepWarm },
293
295
  title: t(L, 'settings.keepWarm'),
294
- head: toggle(el, 'set-warm', d.keepWarm, t(L, d.keepWarm ? 'band.on' : 'band.off'), () => a.patch({ keepWarm: !d.keepWarm })),
296
+ head: toggle(el, frame, 'set-warm', d.keepWarm, t(L, d.keepWarm ? 'band.on' : 'band.off'), () => a.patch({ keepWarm: !d.keepWarm })),
295
297
  line: t(L, 'settings.warmLine'),
296
- body: [note(el, 'warm-cost', t(L, 'settings.warmCostShort'))],
298
+ body: [note(el, frame, 'warm-cost', t(L, 'settings.warmCostShort'))],
299
+ })
300
+ }
301
+
302
+ /** The two skins by name, in the order they are offered. */
303
+ function skinOptions(frame: Frame): { value: Skin; label: string }[] {
304
+ return SKINS.map(skin => ({ value: skin, label: t(frame.lang, skin === 'deep' ? 'settings.skinDeep' : 'settings.skinFlat') }))
305
+ }
306
+
307
+ /**
308
+ * The skin: how the desktop draws the band and the cards (look.ts), picked here or on the
309
+ * dashboard's Look card. Only the desktop shows it: the terminal draws text whatever it is.
310
+ */
311
+ export function skinTile(el: Els, frame: Frame, view: SettingsView, a: SettingsActions): RenderNode {
312
+ const L = frame.lang
313
+ const d = view.settings
314
+ return tile(el, frame, {
315
+ id: 'skin',
316
+ art: { kind: 'skin', skin: d.skin },
317
+ title: t(L, 'settings.skin'),
318
+ line: t(L, 'settings.skinLine'),
319
+ body: [segmented(el, frame, 'skin', skinOptions(frame), d.skin, skin => a.patch({ skin }))],
297
320
  })
298
321
  }
299
322
 
@@ -304,29 +327,30 @@ export function settingsSay(frame: Frame, d: Settings): string {
304
327
  return d.keepWarm ? effort + ' ' + t(L, 'settings.sayWarm') : effort
305
328
  }
306
329
 
307
- /** Rows the quick settings take on the desktop, about: for the sea's height. */
330
+ /** Rows the quick settings take on the desktop, about: for the sea's height (five tiles, three rows of two or five of one). */
308
331
  function panelRows(frame: Frame, view: SettingsView): number {
309
- return (tilesAcross(frame) === 2 ? 20 : 36) + (needsKey(view) ? 4 : 0)
332
+ return (tilesAcross(frame) === 2 ? 30 : 45) + (needsKey(view) ? 4 : 0)
310
333
  }
311
334
 
312
335
  function settingsDrawn(el: Els, frame: Frame, view: SettingsView, a: SettingsActions): RenderNode {
313
336
  const { Box, Text } = el
314
337
  const L = frame.lang
315
338
  const d = view.settings
339
+ const k = lookOf(frame)
316
340
  const face = 48
317
341
  const head = (
318
342
  <Box key="settings-head" flexDirection="row" alignItems="center" columnGap={2} width="100%" minWidth={0}>
319
- {ripple(el, 'settings', d.effortAuto ? 'calm' : 'drowsy', t(L, 'band.brand'), face)}
343
+ {ripple(el, frame, 'settings', d.effortAuto ? 'calm' : 'drowsy', t(L, 'band.brand'), face)}
320
344
  <Box key="settings-say" flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
321
345
  <Box flexDirection="row" alignItems="center" columnGap={1} flexWrap="wrap">
322
- <Text bold color={INK}>{t(L, 'settings.quick')}</Text>
323
- <Text color={MUTE}>{t(L, 'band.brand')}</Text>
346
+ <Text bold {...k.ink}>{t(L, 'settings.quick')}</Text>
347
+ <Text {...k.mute}>{t(L, 'band.brand')}</Text>
324
348
  </Box>
325
- <Text color={SUB} wrap="wrap">{settingsSay(frame, d)}</Text>
349
+ <Text {...k.sub} wrap="wrap">{settingsSay(frame, d)}</Text>
326
350
  </Box>
327
351
  <Box key="settings-exits" flexDirection="row" alignItems="center" columnGap={1} flexShrink={0}>
328
- {pill(el, 'all-settings', t(L, 'settings.openAll'), 'ghost', a.openAllSettings, 'violet', 'open')}
329
- {pill(el, 'close', t(L, 'settings.close'), 'ghost', a.close, 'violet', 'close')}
352
+ {pill(el, frame, 'all-settings', t(L, 'settings.openAll'), 'ghost', a.openAllSettings, 'violet', 'open')}
353
+ {pill(el, frame, 'close', t(L, 'settings.close'), 'ghost', a.close, 'violet', 'close')}
330
354
  </Box>
331
355
  </Box>
332
356
  )
@@ -336,6 +360,7 @@ function settingsDrawn(el: Els, frame: Frame, view: SettingsView, a: SettingsAct
336
360
  {judgeTile(el, frame, view, a)}
337
361
  {modeTile(el, frame, view, a)}
338
362
  {warmTile(el, frame, view, a)}
363
+ {skinTile(el, frame, view, a)}
339
364
  </Box>
340
365
  )
341
366
  return capsule(el, frame, {
@@ -10,7 +10,8 @@ import { t } from '../i18n'
10
10
  import type { RenderNode } from 'claude-code'
11
11
  import { RUNGS } from '../settings'
12
12
 
13
- import { CELL_PX, INK, MUTE, SUB, TONE, beadsArt } from './art'
13
+ import { CELL_PX } from './art'
14
+ import { lookOf } from './look'
14
15
  import { capsule, pic, pill, ripple } from './kit'
15
16
  import { GREET_SIZE, action, actions, canDraw, speak } from './parts'
16
17
  import type { Els } from './parts'
@@ -19,14 +20,14 @@ import { chOf } from './theme'
19
20
  import type { Frame, SettingsActions, SettingsView, SetupActions, SetupView } from './view'
20
21
  import { targetLabel } from './view'
21
22
 
22
- const STEPS = [
23
+ export const STEPS = [
23
24
  { title: 'setup.judgeTitle', blurb: 'setup.judgeBlurb' },
24
25
  { title: 'setup.workersTitle', blurb: 'setup.workersNote' },
25
26
  { title: 'setup.cacheTitle', blurb: 'setup.cacheBlurb' },
26
27
  ] as const
27
28
 
28
29
  /** `Sol · Astra · …`: the four workers' models, lowest first. */
29
- function workersSummary(lang: Frame['lang'], d: SetupView['draft']): string {
30
+ export function workersSummary(lang: Frame['lang'], d: SetupView['draft']): string {
30
31
  const general = d.rules.general
31
32
  return RUNGS.map(rung => general.rungs[rung]?.map(step => targetLabel(step.target, lang)).join(' → ') || '—').join(' · ')
32
33
  }
@@ -73,7 +74,8 @@ export function renderSetup(el: Els, frame: Frame, view: SetupView, a: SetupActi
73
74
  // The desktop: a capsule of the deep, the step's control the quick settings' own tile (who
74
75
  // judges, keep warm), what was chosen when done, Next lit and the rest ghost buttons.
75
76
  const L = frame.lang
76
- const beads = beadsArt(view.step, 3)
77
+ const k = lookOf(frame)
78
+ const beads = k.art.beads(view.step, 3)
77
79
  const summary = done
78
80
  ? [
79
81
  [t(L, 'band.effort'), t(L, 'setup.effortSummary', { state: t(L, d.effortAuto ? 'band.on' : 'band.off').toLowerCase(), judge: d.judge === 'haiku' ? t(L, 'settings.haiku') : d.judge === 'jev' ? t(L, 'settings.jev') : d.judgeModel })],
@@ -87,8 +89,8 @@ export function renderSetup(el: Els, frame: Frame, view: SetupView, a: SetupActi
87
89
  <Box key="summary" flexDirection="column" rowGap={1} paddingLeft={Math.ceil(GREET_SIZE / CELL_PX) + 2}>
88
90
  {summary.map(([name, value], i) => (
89
91
  <Box key={'sum-' + i} flexDirection="row" columnGap={2} alignItems="flex-start">
90
- <Box width={nameCells} flexShrink={0}><Text color={MUTE}>{name}</Text></Box>
91
- <Text color={INK} wrap="wrap">{value}</Text>
92
+ <Box width={nameCells} flexShrink={0}><Text {...k.mute}>{name}</Text></Box>
93
+ <Text {...k.ink} wrap="wrap">{value}</Text>
92
94
  </Box>
93
95
  ))}
94
96
  </Box>,
@@ -100,7 +102,7 @@ export function renderSetup(el: Els, frame: Frame, view: SetupView, a: SetupActi
100
102
  </Box>,
101
103
  ]
102
104
  : []
103
- const next = done ? pill(el, 'finish', t(L, 'setup.start'), 'primary', a.finish, 'green', 'next') : pill(el, 'next', view.step === 3 ? t(L, 'setup.finish') : t(L, 'setup.next'), 'primary', a.next, 'violet', 'next')
105
+ const next = done ? pill(el, frame, 'finish', t(L, 'setup.start'), 'primary', a.finish, 'green', 'next') : pill(el, frame, 'next', view.step === 3 ? t(L, 'setup.finish') : t(L, 'setup.next'), 'primary', a.next, 'violet', 'next')
104
106
  return capsule(el, frame, {
105
107
  key: 'setup',
106
108
  sea: { tone: done ? 'green' : 'violet', ripples: 'calm', reach: 170, motes: true, wave: 19 * 0.5 + GREET_SIZE + 9, ...(done ? { tick: true, weather: 'sparkles' as const } : {}), at: { x: 1.5 * CELL_PX + GREET_SIZE / 2, y: 19 * 0.5 + GREET_SIZE / 2 } },
@@ -109,21 +111,21 @@ export function renderSetup(el: Els, frame: Frame, view: SetupView, a: SetupActi
109
111
  gap: 1,
110
112
  children: [
111
113
  <Box key="setup-head" flexDirection="row" alignItems="center" columnGap={2} width="100%" minWidth={0}>
112
- {ripple(el, 'setup', face, title, GREET_SIZE)}
114
+ {ripple(el, frame, 'setup', face, title, GREET_SIZE)}
113
115
  <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
114
116
  <Box flexDirection="row" alignItems="center" columnGap={2} flexWrap="wrap">
115
- <Text bold color={done ? TONE.green.text : INK} wrap="wrap">{title}</Text>
117
+ <Text bold {...(done ? { color: k.tone('green') } : k.ink)} wrap="wrap">{title}</Text>
116
118
  {done ? null : pic(el, 'setup-beads', beads.source, view.step + ' / 3', beads.width, 16)}
117
119
  </Box>
118
- <Text color={SUB} wrap="wrap">{line}</Text>
120
+ <Text {...k.sub} wrap="wrap">{line}</Text>
119
121
  </Box>
120
122
  </Box>,
121
123
  ...content,
122
124
  <Box key="actions" flexDirection="row" alignItems="center" columnGap={1} flexWrap="wrap" rowGap={1}>
123
125
  {next}
124
- {!done && view.step === 2 ? pill(el, 'all-settings', t(L, 'settings.openAll'), 'ghost', a.openAllSettings, 'violet', 'open') : null}
125
- {done || view.step > 1 ? pill(el, 'back', t(L, 'setup.back'), 'ghost', a.back, 'violet', 'back') : null}
126
- {done ? null : pill(el, 'skip', t(L, 'setup.skip'), 'ghost', a.skip)}
126
+ {!done && view.step === 2 ? pill(el, frame, 'all-settings', t(L, 'settings.openAll'), 'ghost', a.openAllSettings, 'violet', 'open') : null}
127
+ {done || view.step > 1 ? pill(el, frame, 'back', t(L, 'setup.back'), 'ghost', a.back, 'violet', 'back') : null}
128
+ {done ? null : pill(el, frame, 'skip', t(L, 'setup.skip'), 'ghost', a.skip)}
127
129
  </Box>,
128
130
  ],
129
131
  })
@@ -84,10 +84,13 @@ export function textTone(appearance: Appearance): typeof TEXT {
84
84
  const KNOWN: Record<string, string> = {
85
85
  anthropic: '#d97757',
86
86
  chatgpt: '#10a37f',
87
+ google: '#4285f4',
87
88
  'google-gemini': '#4285f4',
88
89
  deepseek: '#4d6bfe',
89
90
  'opencode-go': '#9333ea',
90
91
  'opencode-zen': '#9333ea',
92
+ // xAI's mark is black on white; silver reads on the band's dark capsule.
93
+ grok: '#a8a29e',
91
94
  }
92
95
  const PALETTE = ['#0891b2', '#db2777', '#7c3aed', '#0d9488', '#c026d3', '#4f46e5', '#0284c7', '#be185d']
93
96
 
@@ -0,0 +1,124 @@
1
+ // A subagent's tool calls as the Agents pane words them: one short line of each call's input, and a
2
+ // run of calls headed in one line the way the Claude app heads it ("명령어 7개 실행함, 파일 1개를
3
+ // 편집함"). Pure, so the tests can call it.
4
+ //
5
+ // The phone app's host words a run the same way (packages/remote/src/tool-details.ts
6
+ // `activitySummary`). The mod cannot import it: Claude Code loads the mod as its own folder
7
+ // (dist/mod in an npm install, scripts/build-npm.mjs), with no packages/remote beside it. So the
8
+ // rules are kept here too: kinds in the order they first appear, a file read or edited counted once,
9
+ // five or more kinds cut to the first three and the rest counted.
10
+
11
+ import { t } from '../i18n.ts'
12
+ import type { Lang } from '../i18n.ts'
13
+
14
+ /** Where the session runs and the user's home, to say paths short: relative to the one, `~` for the other. */
15
+ export type Place = { cwd?: string; home?: string }
16
+
17
+ /** One tool call as the pane holds it (view.ts AgentEntry's tool kind). */
18
+ export type ToolCall = { id: string; tool: string; detail: string; state: 'running' | 'ok' | 'error' }
19
+
20
+ type Activity = 'command' | 'read' | 'edit' | 'search' | 'web' | 'agent' | 'other'
21
+
22
+ export function activityOf(tool: string): Activity {
23
+ if (['Bash', 'BashOutput', 'KillShell'].includes(tool)) return 'command'
24
+ if (tool === 'Read') return 'read'
25
+ if (['Edit', 'MultiEdit', 'Write', 'NotebookEdit'].includes(tool)) return 'edit'
26
+ if (['Grep', 'Glob', 'LS'].includes(tool)) return 'search'
27
+ if (['WebSearch', 'WebFetch'].includes(tool)) return 'web'
28
+ if (['Task', 'Agent'].includes(tool)) return 'agent'
29
+ return 'other'
30
+ }
31
+
32
+ /** A directory without its trailing slash; nothing for one that cannot shorten a path (`/`, `~`). */
33
+ function dir(path: string | undefined): string | undefined {
34
+ const bare = path?.replace(/\/+$/, '')
35
+ return bare && bare.startsWith('/') ? bare : undefined
36
+ }
37
+
38
+ /** A path relative to the session's folder, else under `~`, else as given. */
39
+ export function shortPath(path: string, place: Place): string {
40
+ const cwd = dir(place.cwd)
41
+ const home = dir(place.home)
42
+ if (cwd) {
43
+ if (path === cwd) return '.'
44
+ if (path.startsWith(cwd + '/')) return path.slice(cwd.length + 1)
45
+ }
46
+ if (home) {
47
+ if (path === home) return '~'
48
+ if (path.startsWith(home + '/')) return '~' + path.slice(home.length)
49
+ }
50
+ return path
51
+ }
52
+
53
+ function escape(text: string): string {
54
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
55
+ }
56
+
57
+ /** Every absolute path in a command said short, as shortPath says one. */
58
+ function shortPaths(text: string, place: Place): string {
59
+ let out = text
60
+ const cwd = dir(place.cwd)
61
+ const home = dir(place.home)
62
+ if (cwd) out = out.replace(new RegExp(escape(cwd) + '(/|(?![\\w.-]))', 'g'), (_m, slash: string) => slash ? '' : '.')
63
+ if (home) out = out.replace(new RegExp(escape(home) + '(?=/|(?![\\w.-]))', 'g'), '~')
64
+ return out
65
+ }
66
+
67
+ /** A `cd <dir> &&` (or `;`, or a line of its own) at the head of a command: it says nothing about what it does. */
68
+ const CD = /^\s*cd\s+(?:"[^"]*"|'[^']*'|[^\s;&|]+)\s*(?:&&|;|\n)\s*/
69
+
70
+ /** A command as a line: without the `cd` it opens with, its first line only, its paths short. */
71
+ export function commandLine(command: string, place: Place = {}): string {
72
+ let rest = command
73
+ for (let i = 0; i < 3 && CD.test(rest); i++) rest = rest.replace(CD, '')
74
+ const first = rest.split('\n').find(line => line.trim()) ?? ''
75
+ return shortPaths(first.trim(), place)
76
+ }
77
+
78
+ function text(value: unknown): string {
79
+ return typeof value === 'string' ? value : value === undefined || value === null ? '' : JSON.stringify(value)
80
+ }
81
+
82
+ function firstLine(value: string): string {
83
+ return (value.split('\n').find(line => line.trim()) ?? '').trim()
84
+ }
85
+
86
+ /**
87
+ * A tool call's input in one line: a command's own description when it gave one, else the command;
88
+ * a file's path; a search's pattern (and where). Paths short, multi-line input its first line.
89
+ */
90
+ export function toolDetail(tool: string, input: Readonly<Record<string, unknown>>, place: Place = {}): string {
91
+ if (tool === 'Bash') {
92
+ const description = firstLine(text(input.description))
93
+ if (description) return description
94
+ return commandLine(text(input.command), place)
95
+ }
96
+ if (tool === 'Grep' || tool === 'Glob') {
97
+ const where = text(input.path)
98
+ return [firstLine(text(input.pattern)), where ? shortPath(where, place) : ''].filter(Boolean).join(' · ')
99
+ }
100
+ const path = text(input.file_path ?? input.notebook_path ?? input.path)
101
+ if (path) return shortPath(firstLine(path), place)
102
+ const detail = input.command ?? input.pattern ?? input.query ?? input.url ?? input.description ?? input.prompt ?? ''
103
+ return shortPaths(firstLine(text(detail)), place)
104
+ }
105
+
106
+ /** A run's head in one line, and how many of its calls failed. */
107
+ export function runSummary(run: readonly ToolCall[], lang: Lang): { text: string; failed: number } {
108
+ const counts = new Map<Activity, number>()
109
+ const files = new Set<string>()
110
+ for (const call of run) {
111
+ const kind = activityOf(call.tool)
112
+ const file = (kind === 'read' || kind === 'edit') && call.detail ? kind + ':' + call.detail : ''
113
+ if (!file || !files.has(file)) counts.set(kind, (counts.get(kind) ?? 0) + 1)
114
+ if (file) files.add(file)
115
+ }
116
+ const word = (key: string, count: number) => t(lang, 'agents.act.' + key + (count === 1 ? 'One' : ''), { count })
117
+ const parts = [...counts]
118
+ const shown = parts.length >= 5 ? parts.slice(0, 3) : parts
119
+ const rest = parts.slice(shown.length).reduce((sum, [, n]) => sum + n, 0)
120
+ return {
121
+ text: [...shown.map(([kind, n]) => word(kind, n)), ...(rest ? [word('more', rest)] : [])].join(', '),
122
+ failed: run.filter(call => call.state === 'error').length,
123
+ }
124
+ }