@crab-dev/wake 0.1.19 → 0.1.21

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.21
4
+
5
+ - Added aggregate Wake Docs workspaces with isolated production bundles, deterministic manifests,
6
+ transactional output commits, one-port lazy/eager development mounts, scoped HMR events, and
7
+ embedded or standalone workbench presentation.
8
+ - Fixed MDX JSX structure generation by lifting block JSX and splitting mixed paragraphs at AST
9
+ boundaries, preventing invalid paragraph/block nesting without changing inline JSX or component
10
+ Markdown children.
11
+ - Fixed constrained, defaulted, and comma-disambiguated generic arrow functions in TSX so Wake
12
+ parses them as TypeScript instead of emitting cascading JSX diagnostics.
13
+ - Added a TypeScript 7 compatibility gate covering strict type checking, TS/TSX erasure, module
14
+ extensions, value transforms, and production/development JSX runtime execution.
15
+ - Fixed top-level TypeScript overload signatures so only their implementation is emitted, and
16
+ removed false runtime dependencies from imports and exports containing only inline `type`
17
+ specifiers.
18
+
19
+ ## 0.1.20
20
+
21
+ - Added a modern interactive terminal console with editable commands, history, Unicode-aware selection, clipboard copy and paste, and safe development-server opening across Rust and npm CLIs.
22
+ - Added source-located compiler diagnostics with numbered code frames across Rust and npm build, bundle, watch, and development terminals.
23
+ - Added binding-aware Crab CSS semantic values, scoped automatic property and value completions, and deterministic suggestion ranking in the VS Code language service.
24
+ - Made Wake Docs accent colors optional and confined accent-derived component tokens to Demo previews without rewriting global workbench semantics.
25
+ - Preserved side effects inside `void` expressions during minification so editor and application commands cannot be deleted.
26
+
3
27
  ## 0.1.19
4
28
 
5
29
  - Fixed preserve-module CommonJS output so every generated file defines the default and namespace interop helpers it uses.
package/README.md CHANGED
@@ -3,6 +3,15 @@
3
3
  Wake is a Rust-native web builder exposed as both a Node.js library and the
4
4
  `wake` command.
5
5
 
6
+ Interactive `dev` and `docs dev` sessions use the full-screen TUI when stdin
7
+ and stderr are terminals. The command line accepts `help`, `clear`, `open`,
8
+ and `quit` (with optional `/` prefixes), keeps in-memory history, supports
9
+ paste, and copies mouse-selected screen text to the clipboard. Use
10
+ `--ui plain` for stable non-interactive logs; Ctrl-C always interrupts.
11
+ Compiler failures include `path:line:column`, a numbered source line, and a caret when the diagnostic
12
+ has a valid source span. The same structured location is available from Node build errors and server
13
+ `diagnostic` events.
14
+
6
15
  ```sh
7
16
  npm install --save-dev @crab-dev/wake
8
17
  npx wake build
@@ -0,0 +1,247 @@
1
+ import { StringDecoder } from 'node:string_decoder'
2
+ import stringWidth from 'string-width'
3
+
4
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
5
+ const MAX_INPUT = 4096
6
+ const MAX_HISTORY = 50
7
+
8
+ export function graphemes(value) {
9
+ return [...segmenter.segment(String(value))].map((part) => part.segment)
10
+ }
11
+
12
+ export function parseConsoleCommand(value) {
13
+ const original = String(value).trim()
14
+ if (!original) throw new Error('Enter a command. Type help to list available commands.')
15
+ const normalized = (original.startsWith('/') ? original.slice(1) : original).toLowerCase()
16
+ if (normalized === 'help') return 'help'
17
+ if (normalized === 'clear') return 'clear'
18
+ if (normalized === 'open') return 'open'
19
+ if (normalized === 'quit' || normalized === 'q') return 'quit'
20
+ throw new Error(`Unknown command: ${original}. Type help for available commands.`)
21
+ }
22
+
23
+ export class InputEditor {
24
+ constructor() {
25
+ this.value = ''
26
+ this.cursor = 0
27
+ this.history = []
28
+ this.historyIndex = undefined
29
+ this.draft = ''
30
+ }
31
+
32
+ clear() {
33
+ this.value = ''
34
+ this.cursor = 0
35
+ this.historyIndex = undefined
36
+ this.draft = ''
37
+ }
38
+
39
+ insert(value) {
40
+ const current = graphemes(this.value)
41
+ const addition = graphemes(value).slice(0, Math.max(0, MAX_INPUT - current.length))
42
+ current.splice(this.cursor, 0, ...addition)
43
+ this.value = current.join('')
44
+ this.cursor += addition.length
45
+ this.historyIndex = undefined
46
+ }
47
+
48
+ insertPaste(value) {
49
+ this.insert(String(value).replace(/[\r\n\t\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, ' '))
50
+ }
51
+
52
+ moveLeft() { this.cursor = Math.max(0, this.cursor - 1) }
53
+ moveRight() { this.cursor = Math.min(graphemes(this.value).length, this.cursor + 1) }
54
+ moveHome() { this.cursor = 0 }
55
+ moveEnd() { this.cursor = graphemes(this.value).length }
56
+
57
+ backspace() {
58
+ if (this.cursor === 0) return
59
+ const values = graphemes(this.value)
60
+ values.splice(this.cursor - 1, 1)
61
+ this.value = values.join('')
62
+ this.cursor -= 1
63
+ }
64
+
65
+ delete() {
66
+ const values = graphemes(this.value)
67
+ if (this.cursor >= values.length) return
68
+ values.splice(this.cursor, 1)
69
+ this.value = values.join('')
70
+ }
71
+
72
+ historyPrevious() {
73
+ if (this.history.length === 0) return
74
+ if (this.historyIndex === undefined) {
75
+ this.draft = this.value
76
+ this.historyIndex = this.history.length - 1
77
+ } else {
78
+ this.historyIndex = Math.max(0, this.historyIndex - 1)
79
+ }
80
+ this.value = this.history[this.historyIndex]
81
+ this.moveEnd()
82
+ }
83
+
84
+ historyNext() {
85
+ if (this.historyIndex === undefined) return
86
+ if (this.historyIndex + 1 < this.history.length) {
87
+ this.historyIndex += 1
88
+ this.value = this.history[this.historyIndex]
89
+ } else {
90
+ this.historyIndex = undefined
91
+ this.value = this.draft
92
+ this.draft = ''
93
+ }
94
+ this.moveEnd()
95
+ }
96
+
97
+ submit() {
98
+ const submitted = this.value.trim()
99
+ let result
100
+ try {
101
+ result = { command: parseConsoleCommand(submitted) }
102
+ } catch (error) {
103
+ result = { error: error.message }
104
+ }
105
+ if (submitted && this.history.at(-1) !== submitted) {
106
+ this.history.push(submitted)
107
+ if (this.history.length > MAX_HISTORY) this.history.shift()
108
+ }
109
+ this.clear()
110
+ return result
111
+ }
112
+
113
+ cursorCell() {
114
+ return graphemes(this.value).slice(0, this.cursor).reduce((sum, value) => sum + stringWidth(value), 0)
115
+ }
116
+
117
+ setCursorFromCell(cell) {
118
+ let width = 0
119
+ let cursor = 0
120
+ for (const value of graphemes(this.value)) {
121
+ const next = width + stringWidth(value)
122
+ if (cell < next) break
123
+ width = next
124
+ cursor += 1
125
+ }
126
+ this.cursor = cursor
127
+ }
128
+
129
+ visible(width) {
130
+ if (width <= 0) return { text: '', cursor: 0 }
131
+ const cursor = this.cursorCell()
132
+ const start = cursor >= width ? cursor + 1 - width : 0
133
+ let position = 0
134
+ let text = ''
135
+ for (const value of graphemes(this.value)) {
136
+ const next = position + stringWidth(value)
137
+ if (next > start && position < start + width) text += value
138
+ position = next
139
+ if (position >= start + width) break
140
+ }
141
+ return { text, cursor: Math.min(width, Math.max(0, cursor - start)) }
142
+ }
143
+ }
144
+
145
+ export function lineToCells(value, width) {
146
+ const cells = []
147
+ for (const valuePart of graphemes(value)) {
148
+ const cellWidth = Math.max(0, stringWidth(valuePart))
149
+ if (cells.length + cellWidth > width) break
150
+ cells.push(valuePart)
151
+ for (let index = 1; index < cellWidth; index += 1) cells.push('')
152
+ }
153
+ while (cells.length < width) cells.push(' ')
154
+ return cells.slice(0, width)
155
+ }
156
+
157
+ function orderedSelection(selection) {
158
+ const forward = selection.start.y < selection.end.y
159
+ || (selection.start.y === selection.end.y && selection.start.x <= selection.end.x)
160
+ return forward ? [selection.start, selection.end] : [selection.end, selection.start]
161
+ }
162
+
163
+ export function selectionContains(selection, x, y) {
164
+ if (!selection) return false
165
+ const [start, end] = orderedSelection(selection)
166
+ if (y < start.y || y > end.y) return false
167
+ return (y !== start.y || x >= start.x) && (y !== end.y || x <= end.x)
168
+ }
169
+
170
+ export function extractSelection(rows, selection) {
171
+ if (!selection || (selection.start.x === selection.end.x && selection.start.y === selection.end.y)) return ''
172
+ const [start, end] = orderedSelection(selection)
173
+ const lines = []
174
+ for (let y = start.y; y <= Math.min(end.y, rows.length - 1); y += 1) {
175
+ const row = rows[y] || []
176
+ const from = y === start.y ? start.x : 0
177
+ const to = y === end.y ? Math.min(end.x, row.length - 1) : row.length - 1
178
+ lines.push(row.slice(from, to + 1).join('').replace(/ +$/u, ''))
179
+ }
180
+ return lines.join('\n').replace(/\n+$/u, '')
181
+ }
182
+
183
+ const KEY_SEQUENCES = new Map([
184
+ ['\x1b[A', 'up'], ['\x1b[B', 'down'], ['\x1b[C', 'right'], ['\x1b[D', 'left'],
185
+ ['\x1bOA', 'up'], ['\x1bOB', 'down'], ['\x1bOC', 'right'], ['\x1bOD', 'left'],
186
+ ['\x1b[H', 'home'], ['\x1b[F', 'end'], ['\x1b[1~', 'home'], ['\x1b[4~', 'end'],
187
+ ['\x1bOH', 'home'], ['\x1bOF', 'end'],
188
+ ['\x1b[3~', 'delete'], ['\x1b[5~', 'pageup'], ['\x1b[6~', 'pagedown'],
189
+ ['\x1b[1;5F', 'ctrl-end'],
190
+ ])
191
+
192
+ export class TerminalInputDecoder {
193
+ constructor() {
194
+ this.decoder = new StringDecoder('utf8')
195
+ this.buffer = ''
196
+ this.paste = false
197
+ }
198
+
199
+ push(chunk) {
200
+ this.buffer += this.decoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
201
+ const events = []
202
+ while (this.buffer) {
203
+ if (this.paste) {
204
+ const end = this.buffer.indexOf('\x1b[201~')
205
+ if (end === -1) break
206
+ events.push({ type: 'paste', value: this.buffer.slice(0, end) })
207
+ this.buffer = this.buffer.slice(end + 6)
208
+ this.paste = false
209
+ continue
210
+ }
211
+ if (this.buffer.startsWith('\x1b[200~')) {
212
+ this.buffer = this.buffer.slice(6)
213
+ this.paste = true
214
+ continue
215
+ }
216
+ const mouse = /^\x1b\[<(\d+);(\d+);(\d+)([mM])/.exec(this.buffer)
217
+ if (mouse) {
218
+ const code = Number(mouse[1])
219
+ const suffix = mouse[4]
220
+ let kind = 'down'
221
+ let button = ['left', 'middle', 'right'][code & 3] || 'left'
222
+ if ((code & 64) !== 0) kind = (code & 1) === 0 ? 'scroll-up' : 'scroll-down'
223
+ else if ((code & 32) !== 0) kind = 'drag'
224
+ else if (suffix === 'm') kind = 'up'
225
+ events.push({ type: 'mouse', kind, button, x: Number(mouse[2]) - 1, y: Number(mouse[3]) - 1 })
226
+ this.buffer = this.buffer.slice(mouse[0].length)
227
+ continue
228
+ }
229
+ const matched = [...KEY_SEQUENCES].find(([sequence]) => this.buffer.startsWith(sequence))
230
+ if (matched) {
231
+ events.push({ type: 'key', key: matched[1] })
232
+ this.buffer = this.buffer.slice(matched[0].length)
233
+ continue
234
+ }
235
+ if (this.buffer.startsWith('\x1b') && [...KEY_SEQUENCES.keys(), '\x1b[200~', '\x1b[<'].some((value) => value.startsWith(this.buffer))) break
236
+ const first = this.buffer.codePointAt(0)
237
+ const value = String.fromCodePoint(first)
238
+ this.buffer = this.buffer.slice(value.length)
239
+ const control = {
240
+ '\u0003': 'ctrl-c', '\u0019': 'ctrl-y', '\u0016': 'ctrl-v', '\r': 'enter', '\n': 'enter',
241
+ '\u007f': 'backspace', '\u0008': 'backspace', '\u001b': 'escape',
242
+ }[value]
243
+ events.push(control ? { type: 'key', key: control } : { type: 'text', value })
244
+ }
245
+ return events
246
+ }
247
+ }
package/bin/terminal.mjs CHANGED
@@ -1,3 +1,15 @@
1
+ import clipboard from 'clipboardy'
2
+ import open from 'open'
3
+ import stringWidth from 'string-width'
4
+
5
+ import {
6
+ InputEditor,
7
+ TerminalInputDecoder,
8
+ extractSelection,
9
+ lineToCells,
10
+ selectionContains,
11
+ } from './console.mjs'
12
+
1
13
  const RESET = '\x1b[0m'
2
14
  const BOLD = '\x1b[1m'
3
15
  const DIM = '\x1b[2m'
@@ -81,7 +93,7 @@ export function formatBuildResult(ui, result, label = 'Built', extra = '') {
81
93
  ]
82
94
  if (result.outputDir) lines.push(` ${ui.dim('Output')} ${ui.accent(result.outputDir)}`)
83
95
  for (const diagnostic of result.diagnostics || []) {
84
- lines.push(` ${ui.warn(String(diagnostic.severity || 'warning').toUpperCase())} ${diagnostic.message}`)
96
+ lines.push(...formatDiagnostic(ui, diagnostic).map((line) => ` ${line}`))
85
97
  }
86
98
  lines.push('')
87
99
  return lines
@@ -119,16 +131,69 @@ export function formatError(ui, error) {
119
131
  ]
120
132
  if (error?.path) lines.push(` ${ui.dim('Path')} ${ui.accent(error.path)}`)
121
133
  for (const diagnostic of error?.diagnostics || []) {
122
- const diagnosticCode = diagnostic.code ? `[${diagnostic.code}] ` : ''
123
- lines.push(
124
- ` ${ui.warn(String(diagnostic.severity || 'error').toUpperCase())} ${diagnosticCode}${diagnostic.message}`,
125
- )
126
- for (const note of diagnostic.notes || []) lines.push(` ${ui.dim('·')} ${note}`)
134
+ lines.push(...formatDiagnostic(ui, diagnostic).map((line) => ` ${line}`))
127
135
  }
128
136
  lines.push('')
129
137
  return lines
130
138
  }
131
139
 
140
+ export function formatDiagnostic(ui, diagnostic) {
141
+ const severity = String(diagnostic?.severity || 'error').toUpperCase()
142
+ const heading = diagnostic?.code
143
+ ? `${severity} [${diagnostic.code}]: ${diagnostic.message}`
144
+ : `${severity}: ${diagnostic?.message || ''}`
145
+ const lines = [
146
+ diagnostic?.severity === 'warning'
147
+ ? ui.warn(heading)
148
+ : diagnostic?.severity === 'error'
149
+ ? ui.error(heading)
150
+ : ui.accent(heading),
151
+ ]
152
+ const location = diagnostic?.location
153
+ if (diagnostic?.path && location) {
154
+ lines.push(` ${ui.dim('-->')} ${ui.accent(diagnostic.path)}:${location.line}:${location.column}`)
155
+ } else if (diagnostic?.path) {
156
+ lines.push(` ${ui.dim('-->')} ${ui.accent(diagnostic.path)}`)
157
+ }
158
+ if (location) {
159
+ const lineText = expandTabs(String(location.lineText || ''))
160
+ const gutter = String(location.line).length
161
+ const start = displayColumn(String(location.lineText || ''), location.column)
162
+ const end = location.endLine === location.line
163
+ ? displayColumn(String(location.lineText || ''), location.endColumn)
164
+ : stringWidth(lineText)
165
+ const width = Math.max(1, end - start)
166
+ const label = location.label ? ` ${location.label}` : ''
167
+ lines.push(`${' '.repeat(gutter)} ${ui.dim('|')}`)
168
+ lines.push(`${String(location.line).padStart(gutter)} ${ui.dim('|')} ${lineText}`)
169
+ lines.push(`${' '.repeat(gutter)} ${ui.dim('|')} ${ui.error(`${' '.repeat(start)}${'^'.repeat(width)}${label}`)}`)
170
+ }
171
+ for (const note of diagnostic?.notes || []) lines.push(` ${ui.dim('=')} note: ${note}`)
172
+ return lines
173
+ }
174
+
175
+ function displayColumn(line, oneBasedColumn) {
176
+ const prefix = [...line].slice(0, Math.max(0, Number(oneBasedColumn || 1) - 1)).join('')
177
+ return stringWidth(expandTabs(prefix))
178
+ }
179
+
180
+ function expandTabs(line) {
181
+ const tabStop = 4
182
+ let width = 0
183
+ let value = ''
184
+ for (const character of line) {
185
+ if (character === '\t') {
186
+ const spaces = tabStop - (width % tabStop)
187
+ value += ' '.repeat(spaces)
188
+ width += spaces
189
+ } else {
190
+ value += character
191
+ width += stringWidth(character)
192
+ }
193
+ }
194
+ return value
195
+ }
196
+
132
197
  export function formatFinalSummary(ui, state, reason, label = 'Server stopped') {
133
198
  const lines = [
134
199
  '',
@@ -161,8 +226,9 @@ export function observeServer(server, ui, output = console) {
161
226
  )
162
227
  }
163
228
  const onDiagnostic = (diagnostic) => {
164
- const code = diagnostic.code ? `[${diagnostic.code}] ` : ''
165
- output.error(` ${ui.error('✗')} ${ui.bold('Build failed')} ${code}${diagnostic.message}`)
229
+ for (const [index, line] of formatDiagnostic(ui, diagnostic).entries()) {
230
+ output.error(index === 0 ? ` ${ui.error('✗')} ${ui.bold(line)}` : ` ${line}`)
231
+ }
166
232
  }
167
233
 
168
234
  server.on('rebuildStart', onRebuildStart)
@@ -201,6 +267,7 @@ export function createDashboardState({
201
267
  rebuilds: 0,
202
268
  startedAt: Date.now(),
203
269
  activity: [],
270
+ workspaceState: undefined,
204
271
  scrollFromBottom: 0,
205
272
  }
206
273
  pushActivity(state, 'info', 'Starting Wake…')
@@ -240,7 +307,18 @@ export function applyDashboardEvent(state, event) {
240
307
  )
241
308
  } else if (event.type === 'diagnostic') {
242
309
  state.status = 'error'
243
- pushActivity(state, 'error', event.message)
310
+ pushActivity(state, 'error', formatDiagnostic(createUi(false), event.diagnostic).join('\n'))
311
+ } else if (event.type === 'workspaceState') {
312
+ state.workspaceState = {
313
+ total: event.total,
314
+ loaded: event.loaded,
315
+ failed: event.failed,
316
+ current: event.current,
317
+ failedNames: event.failedNames || [],
318
+ }
319
+ if (event.current) {
320
+ pushActivity(state, 'info', `Loading workspace ${event.current}…`)
321
+ }
244
322
  } else if (event.type === 'closed') {
245
323
  state.status = 'stopped'
246
324
  pushActivity(state, 'info', 'Wake stopped')
@@ -286,14 +364,19 @@ function elapsedStamp(durationMs) {
286
364
  }
287
365
 
288
366
  function charLength(text) {
289
- return [...stripAnsi(String(text))].length
367
+ return stringWidth(stripAnsi(String(text)))
290
368
  }
291
369
 
292
370
  function truncate(text, width) {
293
371
  const chars = [...String(text)]
294
- if (chars.length <= width) return String(text)
372
+ if (charLength(text) <= width) return String(text)
295
373
  if (width <= 1) return chars.slice(0, width).join('')
296
- return `${chars.slice(0, width - 1).join('')}…`
374
+ let value = ''
375
+ for (const character of chars) {
376
+ if (charLength(value + character) > width - 1) break
377
+ value += character
378
+ }
379
+ return `${value}…`
297
380
  }
298
381
 
299
382
  function pad(text, width) {
@@ -330,15 +413,30 @@ function metricsText(state) {
330
413
  }
331
414
 
332
415
  function activityRows(state, available) {
333
- const end = Math.max(0, state.activity.length - state.scrollFromBottom)
334
- const start = Math.max(0, end - Math.max(1, available))
335
- return state.activity.slice(start, end).map((item) => {
416
+ const rows = state.activity.flatMap((item) => {
336
417
  const symbol = { info: '·', success: '✓', warning: '↻', error: '✗' }[item.level]
337
- return `${elapsedStamp(item.elapsedMs)} ${symbol} ${String(item.message).replaceAll('\n', ' ')}`
418
+ return String(item.message).split('\n').map((line, index) => index === 0
419
+ ? `${elapsedStamp(item.elapsedMs)} ${symbol} ${line}`
420
+ : ` ${line}`)
338
421
  })
422
+ const end = Math.max(0, rows.length - state.scrollFromBottom)
423
+ const start = Math.max(0, end - Math.max(1, available))
424
+ return rows.slice(start, end)
425
+ }
426
+
427
+ function workspaceText(state) {
428
+ const workspaces = state.workspaceState
429
+ if (!workspaces) return undefined
430
+ const current = workspaces.current ? ` · loading ${workspaces.current}` : ''
431
+ const failed = workspaces.failed ? ` · ${workspaces.failed} failed` : ''
432
+ return `WORKSPACES ${workspaces.loaded}/${workspaces.total} loaded${failed}${current}`
339
433
  }
340
434
 
341
- function plainFrame(state, width, height) {
435
+ function activityRowCount(state) {
436
+ return state.activity.reduce((count, item) => count + String(item.message).split('\n').length, 0)
437
+ }
438
+
439
+ function plainFrame(state, width, height, editor = new InputEditor(), notice) {
342
440
  width = Math.max(10, width || 80)
343
441
  height = Math.max(6, height || 24)
344
442
  const runtime = humanRuntime(Date.now() - state.startedAt)
@@ -348,13 +446,13 @@ function plainFrame(state, width, height) {
348
446
  let activityHeight
349
447
 
350
448
  if (width < 60 || height < 14) {
351
- fixed = [
352
- topBorder(state, width),
353
- boxLine(header, width),
354
- boxLine(state.endpoint || state.watchLabel, width),
355
- boxLine(state.activity.at(-1)?.message || 'Starting Wake…', width),
356
- boxLine('Resize for details · q/Ctrl-C quit', width),
357
- ]
449
+ const latest = String(state.activity.at(-1)?.message || 'Starting Wake…').split('\n')
450
+ fixed = [topBorder(state, width), boxLine(header, width)]
451
+ const diagnosticRows = latest.length > 1
452
+ for (const line of latest.slice(0, Math.max(1, height - (diagnosticRows ? 5 : 6)))) {
453
+ fixed.push(boxLine(line, width))
454
+ }
455
+ if (!diagnosticRows) fixed.push(boxLine('Resize for details · type help for commands', width))
358
456
  activityHeight = 0
359
457
  } else if (width < 80 || height < 20) {
360
458
  fixed = [
@@ -364,6 +462,7 @@ function plainFrame(state, width, height) {
364
462
  boxLine(metricsText(state), width),
365
463
  separator(width, 'ACTIVITY'),
366
464
  ]
465
+ if (workspaceText(state)) fixed.splice(-1, 0, boxLine(workspaceText(state), width))
367
466
  activityHeight = Math.max(1, height - fixed.length - 2)
368
467
  } else {
369
468
  fixed = [
@@ -377,14 +476,23 @@ function plainFrame(state, width, height) {
377
476
  boxLine(metricsText(state), width),
378
477
  separator(width, 'ACTIVITY'),
379
478
  ]
479
+ if (workspaceText(state)) fixed.splice(-1, 0, boxLine(workspaceText(state), width))
380
480
  activityHeight = Math.max(1, height - fixed.length - 2)
381
481
  }
382
482
 
383
483
  for (const row of activityRows(state, activityHeight)) fixed.push(boxLine(row, width))
384
- while (activityHeight > 0 && fixed.length < height - 2) fixed.push(boxLine('', width))
385
- if (height >= 14) fixed.push(boxLine('↑↓/PgUp/PgDn scroll · End follow · c clear · q/Ctrl-C quit', width))
484
+ while (fixed.length < height - 3) fixed.push(boxLine('', width))
485
+ fixed.splice(Math.max(0, height - 3))
486
+ const visible = editor.visible(Math.max(0, width - 6))
487
+ fixed.push(boxLine(
488
+ notice
489
+ ? `${notice.error ? '✗' : '✓'} ${notice.message}`
490
+ : 'Enter command · PgUp/PgDn logs · drag to copy · Ctrl-C quit',
491
+ width,
492
+ ))
493
+ fixed.push(boxLine(`› ${visible.text}`, width))
386
494
  fixed.push(bottomBorder(width))
387
- return fixed.slice(0, height)
495
+ return { lines: fixed.slice(0, height), cursor: visible.cursor }
388
496
  }
389
497
 
390
498
  function colorizeLine(line, ui) {
@@ -398,16 +506,67 @@ function colorizeLine(line, ui) {
398
506
  return value
399
507
  }
400
508
 
509
+ function renderDashboardFrame(
510
+ state,
511
+ width = 80,
512
+ height = 24,
513
+ ui = createUi(false),
514
+ editor = new InputEditor(),
515
+ selection,
516
+ notice,
517
+ ) {
518
+ const frame = plainFrame(state, width, height, editor, notice)
519
+ const rows = frame.lines.map((line) => lineToCells(line, width))
520
+ const rendered = rows.map((cells, y) => {
521
+ let selected = false
522
+ let line = ''
523
+ for (let x = 0; x < cells.length; x += 1) {
524
+ const nextSelected = selectionContains(selection, x, y)
525
+ if (nextSelected !== selected) {
526
+ line += nextSelected ? '\x1b[7m' : RESET
527
+ selected = nextSelected
528
+ }
529
+ line += cells[x]
530
+ }
531
+ if (selected) line += RESET
532
+ return colorizeLine(line, ui)
533
+ })
534
+ return {
535
+ text: rendered.join('\n'),
536
+ rows,
537
+ inputY: Math.max(0, rows.length - 2),
538
+ inputX: 4,
539
+ cursorX: Math.min(width - 2, 4 + frame.cursor),
540
+ }
541
+ }
542
+
401
543
  export function renderDashboard(state, width = 80, height = 24, ui = createUi(false)) {
402
- return plainFrame(state, width, height).map((line) => colorizeLine(line, ui)).join('\n')
544
+ return renderDashboardFrame(state, width, height, ui).text
403
545
  }
404
546
 
405
547
  export function createDashboardSession(
406
548
  state,
407
- { input = process.stdin, output = process.stderr, ui = createUi() } = {},
549
+ {
550
+ input = process.stdin,
551
+ output = process.stderr,
552
+ ui = createUi(),
553
+ clipboardAdapter = clipboard,
554
+ openUrl = open,
555
+ env = process.env,
556
+ } = {},
408
557
  ) {
409
558
  let closed = false
410
559
  let previousRaw = false
560
+ const editor = new InputEditor()
561
+ const decoder = new TerminalInputDecoder()
562
+ let rows = []
563
+ let inputY = 0
564
+ let inputX = 0
565
+ let selection
566
+ let dragStart
567
+ let lastSelection = ''
568
+ let notice
569
+ let eventQueue = Promise.resolve()
411
570
  let resolveExit
412
571
  const exit = new Promise((resolve) => { resolveExit = resolve })
413
572
 
@@ -415,26 +574,122 @@ export function createDashboardSession(
415
574
  if (closed) return
416
575
  const width = output.columns || 80
417
576
  const height = output.rows || 24
418
- output.write(`\x1b[H${renderDashboard(state, width, height, ui)}\x1b[J`)
577
+ if (notice && Date.now() - notice.startedAt >= 1500) notice = undefined
578
+ const frame = renderDashboardFrame(state, width, height, ui, editor, selection, notice)
579
+ rows = frame.rows
580
+ inputY = frame.inputY
581
+ inputX = frame.inputX
582
+ output.write(`\x1b[H${frame.text}\x1b[J\x1b[${inputY + 1};${frame.cursorX + 1}H\x1b[?25h`)
419
583
  }
420
584
  const requestExit = (reason) => {
421
585
  if (!closed) resolveExit(reason)
422
586
  }
423
- const onData = (chunk) => {
424
- const key = chunk.toString('utf8')
425
- if (key === '\u0003') requestExit('SIGINT')
426
- else if (key === 'q' || key === 'Q') requestExit('q')
427
- else if (key === 'c' || key === 'C') {
587
+ const setNotice = (message, error = false) => {
588
+ notice = { message, error, startedAt: Date.now() }
589
+ draw()
590
+ }
591
+ const osc52 = (value) => {
592
+ const sequence = `\x1b]52;c;${Buffer.from(value).toString('base64')}\x07`
593
+ output.write(env.TMUX ? `\x1bPtmux;${sequence.replaceAll('\x1b', '\x1b\x1b')}\x1b\\` : sequence)
594
+ }
595
+ const copyText = async (value) => {
596
+ if (env.SSH_CONNECTION || env.SSH_TTY) {
597
+ osc52(value)
598
+ setNotice('Copied to clipboard')
599
+ return
600
+ }
601
+ try {
602
+ await clipboardAdapter.write(value)
603
+ setNotice('Copied to clipboard')
604
+ } catch {
605
+ try {
606
+ osc52(value)
607
+ setNotice('Copied to clipboard')
608
+ } catch {
609
+ setNotice('Failed to copy; terminal clipboard is unavailable', true)
610
+ }
611
+ }
612
+ }
613
+ const pasteClipboard = async () => {
614
+ try {
615
+ editor.insertPaste(await clipboardAdapter.read())
616
+ selection = undefined
617
+ draw()
618
+ } catch {
619
+ setNotice('Clipboard paste is unavailable; use terminal paste', true)
620
+ }
621
+ }
622
+ const submit = async () => {
623
+ const result = editor.submit()
624
+ if (result.error) pushActivity(state, 'warning', result.error)
625
+ else if (result.command === 'help') pushActivity(state, 'info', 'Commands: help · clear · open · quit (a leading / is optional)')
626
+ else if (result.command === 'clear') {
428
627
  state.activity.length = 0
429
628
  state.scrollFromBottom = 0
430
- draw()
431
- } else if (key === '\x1b[A') state.scrollFromBottom = Math.max(0, Math.min(state.activity.length - 1, state.scrollFromBottom + 1))
432
- else if (key === '\x1b[B') state.scrollFromBottom = Math.max(0, state.scrollFromBottom - 1)
433
- else if (key === '\x1b[5~') state.scrollFromBottom = Math.max(0, Math.min(state.activity.length - 1, state.scrollFromBottom + 10))
434
- else if (key === '\x1b[6~') state.scrollFromBottom = Math.max(0, state.scrollFromBottom - 10)
435
- else if (key === '\x1b[F' || key === '\x1b[4~') state.scrollFromBottom = 0
629
+ setNotice('Activity cleared')
630
+ } else if (result.command === 'open') {
631
+ if (!/^https?:\/\//u.test(state.endpoint)) pushActivity(state, 'warning', 'No development-server URL is available to open')
632
+ else {
633
+ try {
634
+ await openUrl(state.endpoint)
635
+ setNotice('Opened development server')
636
+ } catch (error) {
637
+ pushActivity(state, 'warning', `Failed to open ${state.endpoint}: ${error.message || error}`)
638
+ }
639
+ }
640
+ } else if (result.command === 'quit') requestExit('q')
641
+ draw()
642
+ }
643
+ const handleEvent = async (event) => {
644
+ if (event.type === 'paste') editor.insertPaste(event.value)
645
+ else if (event.type === 'text') {
646
+ editor.insert(event.value)
647
+ selection = undefined
648
+ } else if (event.type === 'mouse') {
649
+ const position = { x: event.x, y: event.y }
650
+ if (event.kind === 'down' && event.button === 'left') {
651
+ dragStart = position
652
+ selection = { start: position, end: position }
653
+ } else if (event.kind === 'drag' && dragStart) selection = { start: dragStart, end: position }
654
+ else if (event.kind === 'up' && event.button === 'left' && dragStart) {
655
+ selection = { start: dragStart, end: position }
656
+ dragStart = undefined
657
+ const value = extractSelection(rows, selection)
658
+ if (value) {
659
+ lastSelection = value
660
+ await copyText(value)
661
+ } else {
662
+ if (position.y === inputY) editor.setCursorFromCell(Math.max(0, position.x - inputX))
663
+ selection = undefined
664
+ }
665
+ } else if (event.kind === 'down' && event.button === 'right' && position.y === inputY) await pasteClipboard()
666
+ else if (event.kind === 'scroll-up') state.scrollFromBottom = Math.max(0, Math.min(activityRowCount(state) - 1, state.scrollFromBottom + 3))
667
+ else if (event.kind === 'scroll-down') state.scrollFromBottom = Math.max(0, state.scrollFromBottom - 3)
668
+ } else if (event.type === 'key') {
669
+ if (event.key === 'ctrl-c') requestExit('SIGINT')
670
+ else if (event.key === 'ctrl-y') lastSelection ? await copyText(lastSelection) : setNotice('No selected text to copy', true)
671
+ else if (event.key === 'ctrl-v') await pasteClipboard()
672
+ else if (event.key === 'enter') await submit()
673
+ else if (event.key === 'escape') { editor.clear(); selection = undefined }
674
+ else if (event.key === 'left') editor.moveLeft()
675
+ else if (event.key === 'right') editor.moveRight()
676
+ else if (event.key === 'home') editor.moveHome()
677
+ else if (event.key === 'end') editor.moveEnd()
678
+ else if (event.key === 'ctrl-end') state.scrollFromBottom = 0
679
+ else if (event.key === 'backspace') editor.backspace()
680
+ else if (event.key === 'delete') editor.delete()
681
+ else if (event.key === 'up') editor.historyPrevious()
682
+ else if (event.key === 'down') editor.historyNext()
683
+ else if (event.key === 'pageup') state.scrollFromBottom = Math.max(0, Math.min(activityRowCount(state) - 1, state.scrollFromBottom + 10))
684
+ else if (event.key === 'pagedown') state.scrollFromBottom = Math.max(0, state.scrollFromBottom - 10)
685
+ }
436
686
  draw()
437
687
  }
688
+ const onData = (chunk) => {
689
+ for (const event of decoder.push(chunk)) {
690
+ eventQueue = eventQueue.then(() => handleEvent(event))
691
+ }
692
+ }
438
693
  const onResize = () => draw()
439
694
 
440
695
  previousRaw = input.isRaw === true
@@ -442,7 +697,7 @@ export function createDashboardSession(
442
697
  input.resume?.()
443
698
  input.on('data', onData)
444
699
  output.on?.('resize', onResize)
445
- output.write('\x1b[?1049h\x1b[?25l\x1b[2J')
700
+ output.write('\x1b[?1049h\x1b[?1002h\x1b[?1006h\x1b[?2004h\x1b[?25l\x1b[2J')
446
701
  const timer = setInterval(draw, 100)
447
702
  timer.unref?.()
448
703
  draw()
@@ -459,7 +714,7 @@ export function createDashboardSession(
459
714
  output.off?.('resize', onResize)
460
715
  if (typeof input.setRawMode === 'function') input.setRawMode(previousRaw)
461
716
  if (!previousRaw) input.pause?.()
462
- output.write('\x1b[?25h\x1b[?1049l')
717
+ output.write('\x1b[?2004l\x1b[?1006l\x1b[?1002l\x1b[?25h\x1b[?1049l')
463
718
  },
464
719
  }
465
720
  }
package/bin/wake.mjs CHANGED
@@ -184,7 +184,11 @@ async function runServer(factory, options, command, root, ui, uiMode) {
184
184
  dashboard?.draw()
185
185
  }
186
186
  const onDiagnostic = (diagnostic) => {
187
- applyDashboardEvent(state, { type: 'diagnostic', message: diagnostic.message })
187
+ applyDashboardEvent(state, { type: 'diagnostic', diagnostic })
188
+ dashboard?.draw()
189
+ }
190
+ const onWorkspaceState = (event) => {
191
+ applyDashboardEvent(state, event)
188
192
  dashboard?.draw()
189
193
  }
190
194
  const onClosed = () => {
@@ -194,6 +198,7 @@ async function runServer(factory, options, command, root, ui, uiMode) {
194
198
  server.on('rebuildStart', onRebuildStart)
195
199
  server.on('rebuilt', onRebuilt)
196
200
  server.on('diagnostic', onDiagnostic)
201
+ server.on('workspaceState', onWorkspaceState)
197
202
  server.on('closed', onClosed)
198
203
 
199
204
  if (!useTui) stopObserving = observeServer(server, ui)
@@ -235,6 +240,7 @@ async function runServer(factory, options, command, root, ui, uiMode) {
235
240
  server.off('rebuildStart', onRebuildStart)
236
241
  server.off('rebuilt', onRebuilt)
237
242
  server.off('diagnostic', onDiagnostic)
243
+ server.off('workspaceState', onWorkspaceState)
238
244
  server.off('closed', onClosed)
239
245
  }
240
246
  } catch (error) {
package/index.cjs CHANGED
@@ -123,12 +123,10 @@ class DevServer extends EventEmitter {
123
123
  this.emit('rebuildStart', event)
124
124
  } else if (event.type === 'rebuilt') {
125
125
  this.emit('rebuilt', event)
126
+ } else if (event.type === 'workspaceState') {
127
+ this.emit('workspaceState', event)
126
128
  } else if (event.type === 'diagnostic') {
127
- this.emit('diagnostic', {
128
- severity: 'error',
129
- code: 'WAKE_BUILD',
130
- message: event.message,
131
- })
129
+ this.emit('diagnostic', event.diagnostic)
132
130
  } else if (event.type === 'closed' && !this.#closed) {
133
131
  this.#closed = true
134
132
  clearInterval(this.#eventTimer)
package/index.d.ts CHANGED
@@ -21,6 +21,20 @@ export type WakeErrorCode =
21
21
  | 'WAKE_LIBRARY_TYPE'
22
22
  | 'WAKE_LIBRARY_OUTPUT'
23
23
 
24
+ export interface DiagnosticLocation {
25
+ /** One-based source line. */
26
+ line: number
27
+ /** One-based Unicode-scalar column. */
28
+ column: number
29
+ /** One-based line containing the exclusive end position. */
30
+ endLine: number
31
+ /** One-based Unicode-scalar column of the exclusive end position. */
32
+ endColumn: number
33
+ /** Exact source line without its line terminator. */
34
+ lineText: string
35
+ label?: string
36
+ }
37
+
24
38
  export interface Diagnostic {
25
39
  severity: 'error' | 'warning' | 'note' | 'help'
26
40
  code?: string
@@ -28,6 +42,7 @@ export interface Diagnostic {
28
42
  path?: string
29
43
  start?: number
30
44
  end?: number
45
+ location?: DiagnosticLocation
31
46
  notes?: string[]
32
47
  }
33
48
 
@@ -168,6 +183,15 @@ export interface DocsDemo {
168
183
  warnings: string[]
169
184
  }
170
185
 
186
+ export interface DocsWorkspaceBuildInfo {
187
+ name: string
188
+ root: string
189
+ basePath: string
190
+ mode: 'components'
191
+ presentation: 'embedded' | 'standalone'
192
+ demos: number
193
+ }
194
+
171
195
  export interface DocsBuildOptions extends ProjectOptions {
172
196
  outdir?: string
173
197
  basePath?: string
@@ -178,6 +202,7 @@ export interface DocsBuildResult extends BuildResult {
178
202
  routes: DocsRoute[]
179
203
  mode: DocsMode
180
204
  demos: DocsDemo[]
205
+ workspaces: DocsWorkspaceBuildInfo[]
181
206
  }
182
207
 
183
208
  export interface DevServerOptions extends ProjectOptions {
@@ -193,6 +218,8 @@ export interface DocsDevServerOptions extends DevServerOptions {
193
218
  export interface DevServerRebuildStartEvent {
194
219
  type: 'rebuildStart'
195
220
  changedPaths: string[]
221
+ workspace?: string
222
+ basePath?: string
196
223
  }
197
224
 
198
225
  export interface DevServerRebuiltEvent {
@@ -204,6 +231,17 @@ export interface DevServerRebuiltEvent {
204
231
  chunks: number
205
232
  assets: number
206
233
  durationMs: number
234
+ workspace?: string
235
+ basePath?: string
236
+ }
237
+
238
+ export interface DevServerWorkspaceStateEvent {
239
+ type: 'workspaceState'
240
+ total: number
241
+ loaded: number
242
+ failed: number
243
+ current?: string
244
+ failedNames: string[]
207
245
  }
208
246
 
209
247
  export class BuildContext {
@@ -222,6 +260,7 @@ export class DevServer extends EventEmitter {
222
260
  [Symbol.asyncDispose](): Promise<void>
223
261
  on(event: 'rebuildStart', listener: (event: DevServerRebuildStartEvent) => void): this
224
262
  on(event: 'rebuilt', listener: (event: DevServerRebuiltEvent) => void): this
263
+ on(event: 'workspaceState', listener: (event: DevServerWorkspaceStateEvent) => void): this
225
264
  on(event: 'diagnostic', listener: (diagnostic: Diagnostic) => void): this
226
265
  on(event: 'closed', listener: () => void): this
227
266
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crab-dev/wake",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "Wake native web build tools for Node.js",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "repository": {
@@ -50,6 +50,7 @@
50
50
  "node": ">=22.14 <27"
51
51
  },
52
52
  "dependencies": {
53
+ "@crab-dev/css": "0.1.21",
53
54
  "@crab-dev/rc-alert": "^0.0.2",
54
55
  "@crab-dev/rc-button": "^0.0.2",
55
56
  "@crab-dev/rc-dialog": "^0.0.2",
@@ -64,19 +65,21 @@
64
65
  "@crab-dev/rc-text-edit": "^0.0.1",
65
66
  "@crab-dev/rc-tooltip": "^0.0.2",
66
67
  "@crab-dev/rc-tree": "^0.1.2",
67
- "@crab-dev/css": "0.1.19",
68
- "lucide-react": "^1.23.0"
68
+ "clipboardy": "5.3.2",
69
+ "lucide-react": "^1.23.0",
70
+ "open": "11.0.0",
71
+ "string-width": "8.2.2"
69
72
  },
70
73
  "peerDependencies": {
71
74
  "react": "^19.2.8",
72
75
  "react-dom": "^19.2.8"
73
76
  },
74
77
  "optionalDependencies": {
75
- "@crab-dev/wake-darwin-arm64": "0.1.19",
76
- "@crab-dev/wake-darwin-x64": "0.1.19",
77
- "@crab-dev/wake-linux-arm64-gnu": "0.1.19",
78
- "@crab-dev/wake-linux-x64-gnu": "0.1.19",
79
- "@crab-dev/wake-win32-x64-msvc": "0.1.19"
78
+ "@crab-dev/wake-darwin-arm64": "0.1.21",
79
+ "@crab-dev/wake-darwin-x64": "0.1.21",
80
+ "@crab-dev/wake-linux-arm64-gnu": "0.1.21",
81
+ "@crab-dev/wake-linux-x64-gnu": "0.1.21",
82
+ "@crab-dev/wake-win32-x64-msvc": "0.1.21"
80
83
  },
81
84
  "publishConfig": {
82
85
  "access": "public",