@crab-dev/wake 0.1.18 → 0.1.20

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.20
4
+
5
+ - 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.
6
+ - Added source-located compiler diagnostics with numbered code frames across Rust and npm build, bundle, watch, and development terminals.
7
+ - Added binding-aware Crab CSS semantic values, scoped automatic property and value completions, and deterministic suggestion ranking in the VS Code language service.
8
+ - Made Wake Docs accent colors optional and confined accent-derived component tokens to Demo previews without rewriting global workbench semantics.
9
+ - Preserved side effects inside `void` expressions during minification so editor and application commands cannot be deleted.
10
+
11
+ ## 0.1.19
12
+
13
+ - Fixed preserve-module CommonJS output so every generated file defines the default and namespace interop helpers it uses.
14
+ - Made repeated library builds reliable on Windows by keeping output directories stable, skipping unchanged files, and rolling back failed per-file commits with actionable diagnostics.
15
+
3
16
  ## 0.1.18
4
17
 
5
18
  - Added native component-library ESM, CommonJS, declaration, and extracted CSS builds through `wake library build` and `buildLibrary()` without changing application builds.
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)
@@ -240,7 +306,7 @@ export function applyDashboardEvent(state, event) {
240
306
  )
241
307
  } else if (event.type === 'diagnostic') {
242
308
  state.status = 'error'
243
- pushActivity(state, 'error', event.message)
309
+ pushActivity(state, 'error', formatDiagnostic(createUi(false), event.diagnostic).join('\n'))
244
310
  } else if (event.type === 'closed') {
245
311
  state.status = 'stopped'
246
312
  pushActivity(state, 'info', 'Wake stopped')
@@ -286,14 +352,19 @@ function elapsedStamp(durationMs) {
286
352
  }
287
353
 
288
354
  function charLength(text) {
289
- return [...stripAnsi(String(text))].length
355
+ return stringWidth(stripAnsi(String(text)))
290
356
  }
291
357
 
292
358
  function truncate(text, width) {
293
359
  const chars = [...String(text)]
294
- if (chars.length <= width) return String(text)
360
+ if (charLength(text) <= width) return String(text)
295
361
  if (width <= 1) return chars.slice(0, width).join('')
296
- return `${chars.slice(0, width - 1).join('')}…`
362
+ let value = ''
363
+ for (const character of chars) {
364
+ if (charLength(value + character) > width - 1) break
365
+ value += character
366
+ }
367
+ return `${value}…`
297
368
  }
298
369
 
299
370
  function pad(text, width) {
@@ -330,15 +401,22 @@ function metricsText(state) {
330
401
  }
331
402
 
332
403
  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) => {
404
+ const rows = state.activity.flatMap((item) => {
336
405
  const symbol = { info: '·', success: '✓', warning: '↻', error: '✗' }[item.level]
337
- return `${elapsedStamp(item.elapsedMs)} ${symbol} ${String(item.message).replaceAll('\n', ' ')}`
406
+ return String(item.message).split('\n').map((line, index) => index === 0
407
+ ? `${elapsedStamp(item.elapsedMs)} ${symbol} ${line}`
408
+ : ` ${line}`)
338
409
  })
410
+ const end = Math.max(0, rows.length - state.scrollFromBottom)
411
+ const start = Math.max(0, end - Math.max(1, available))
412
+ return rows.slice(start, end)
339
413
  }
340
414
 
341
- function plainFrame(state, width, height) {
415
+ function activityRowCount(state) {
416
+ return state.activity.reduce((count, item) => count + String(item.message).split('\n').length, 0)
417
+ }
418
+
419
+ function plainFrame(state, width, height, editor = new InputEditor(), notice) {
342
420
  width = Math.max(10, width || 80)
343
421
  height = Math.max(6, height || 24)
344
422
  const runtime = humanRuntime(Date.now() - state.startedAt)
@@ -348,13 +426,13 @@ function plainFrame(state, width, height) {
348
426
  let activityHeight
349
427
 
350
428
  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
- ]
429
+ const latest = String(state.activity.at(-1)?.message || 'Starting Wake…').split('\n')
430
+ fixed = [topBorder(state, width), boxLine(header, width)]
431
+ const diagnosticRows = latest.length > 1
432
+ for (const line of latest.slice(0, Math.max(1, height - (diagnosticRows ? 5 : 6)))) {
433
+ fixed.push(boxLine(line, width))
434
+ }
435
+ if (!diagnosticRows) fixed.push(boxLine('Resize for details · type help for commands', width))
358
436
  activityHeight = 0
359
437
  } else if (width < 80 || height < 20) {
360
438
  fixed = [
@@ -381,10 +459,18 @@ function plainFrame(state, width, height) {
381
459
  }
382
460
 
383
461
  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))
462
+ while (fixed.length < height - 3) fixed.push(boxLine('', width))
463
+ fixed.splice(Math.max(0, height - 3))
464
+ const visible = editor.visible(Math.max(0, width - 6))
465
+ fixed.push(boxLine(
466
+ notice
467
+ ? `${notice.error ? '✗' : '✓'} ${notice.message}`
468
+ : 'Enter command · PgUp/PgDn logs · drag to copy · Ctrl-C quit',
469
+ width,
470
+ ))
471
+ fixed.push(boxLine(`› ${visible.text}`, width))
386
472
  fixed.push(bottomBorder(width))
387
- return fixed.slice(0, height)
473
+ return { lines: fixed.slice(0, height), cursor: visible.cursor }
388
474
  }
389
475
 
390
476
  function colorizeLine(line, ui) {
@@ -398,16 +484,67 @@ function colorizeLine(line, ui) {
398
484
  return value
399
485
  }
400
486
 
487
+ function renderDashboardFrame(
488
+ state,
489
+ width = 80,
490
+ height = 24,
491
+ ui = createUi(false),
492
+ editor = new InputEditor(),
493
+ selection,
494
+ notice,
495
+ ) {
496
+ const frame = plainFrame(state, width, height, editor, notice)
497
+ const rows = frame.lines.map((line) => lineToCells(line, width))
498
+ const rendered = rows.map((cells, y) => {
499
+ let selected = false
500
+ let line = ''
501
+ for (let x = 0; x < cells.length; x += 1) {
502
+ const nextSelected = selectionContains(selection, x, y)
503
+ if (nextSelected !== selected) {
504
+ line += nextSelected ? '\x1b[7m' : RESET
505
+ selected = nextSelected
506
+ }
507
+ line += cells[x]
508
+ }
509
+ if (selected) line += RESET
510
+ return colorizeLine(line, ui)
511
+ })
512
+ return {
513
+ text: rendered.join('\n'),
514
+ rows,
515
+ inputY: Math.max(0, rows.length - 2),
516
+ inputX: 4,
517
+ cursorX: Math.min(width - 2, 4 + frame.cursor),
518
+ }
519
+ }
520
+
401
521
  export function renderDashboard(state, width = 80, height = 24, ui = createUi(false)) {
402
- return plainFrame(state, width, height).map((line) => colorizeLine(line, ui)).join('\n')
522
+ return renderDashboardFrame(state, width, height, ui).text
403
523
  }
404
524
 
405
525
  export function createDashboardSession(
406
526
  state,
407
- { input = process.stdin, output = process.stderr, ui = createUi() } = {},
527
+ {
528
+ input = process.stdin,
529
+ output = process.stderr,
530
+ ui = createUi(),
531
+ clipboardAdapter = clipboard,
532
+ openUrl = open,
533
+ env = process.env,
534
+ } = {},
408
535
  ) {
409
536
  let closed = false
410
537
  let previousRaw = false
538
+ const editor = new InputEditor()
539
+ const decoder = new TerminalInputDecoder()
540
+ let rows = []
541
+ let inputY = 0
542
+ let inputX = 0
543
+ let selection
544
+ let dragStart
545
+ let lastSelection = ''
546
+ let notice
547
+ let eventQueue = Promise.resolve()
411
548
  let resolveExit
412
549
  const exit = new Promise((resolve) => { resolveExit = resolve })
413
550
 
@@ -415,26 +552,122 @@ export function createDashboardSession(
415
552
  if (closed) return
416
553
  const width = output.columns || 80
417
554
  const height = output.rows || 24
418
- output.write(`\x1b[H${renderDashboard(state, width, height, ui)}\x1b[J`)
555
+ if (notice && Date.now() - notice.startedAt >= 1500) notice = undefined
556
+ const frame = renderDashboardFrame(state, width, height, ui, editor, selection, notice)
557
+ rows = frame.rows
558
+ inputY = frame.inputY
559
+ inputX = frame.inputX
560
+ output.write(`\x1b[H${frame.text}\x1b[J\x1b[${inputY + 1};${frame.cursorX + 1}H\x1b[?25h`)
419
561
  }
420
562
  const requestExit = (reason) => {
421
563
  if (!closed) resolveExit(reason)
422
564
  }
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') {
565
+ const setNotice = (message, error = false) => {
566
+ notice = { message, error, startedAt: Date.now() }
567
+ draw()
568
+ }
569
+ const osc52 = (value) => {
570
+ const sequence = `\x1b]52;c;${Buffer.from(value).toString('base64')}\x07`
571
+ output.write(env.TMUX ? `\x1bPtmux;${sequence.replaceAll('\x1b', '\x1b\x1b')}\x1b\\` : sequence)
572
+ }
573
+ const copyText = async (value) => {
574
+ if (env.SSH_CONNECTION || env.SSH_TTY) {
575
+ osc52(value)
576
+ setNotice('Copied to clipboard')
577
+ return
578
+ }
579
+ try {
580
+ await clipboardAdapter.write(value)
581
+ setNotice('Copied to clipboard')
582
+ } catch {
583
+ try {
584
+ osc52(value)
585
+ setNotice('Copied to clipboard')
586
+ } catch {
587
+ setNotice('Failed to copy; terminal clipboard is unavailable', true)
588
+ }
589
+ }
590
+ }
591
+ const pasteClipboard = async () => {
592
+ try {
593
+ editor.insertPaste(await clipboardAdapter.read())
594
+ selection = undefined
595
+ draw()
596
+ } catch {
597
+ setNotice('Clipboard paste is unavailable; use terminal paste', true)
598
+ }
599
+ }
600
+ const submit = async () => {
601
+ const result = editor.submit()
602
+ if (result.error) pushActivity(state, 'warning', result.error)
603
+ else if (result.command === 'help') pushActivity(state, 'info', 'Commands: help · clear · open · quit (a leading / is optional)')
604
+ else if (result.command === 'clear') {
428
605
  state.activity.length = 0
429
606
  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
607
+ setNotice('Activity cleared')
608
+ } else if (result.command === 'open') {
609
+ if (!/^https?:\/\//u.test(state.endpoint)) pushActivity(state, 'warning', 'No development-server URL is available to open')
610
+ else {
611
+ try {
612
+ await openUrl(state.endpoint)
613
+ setNotice('Opened development server')
614
+ } catch (error) {
615
+ pushActivity(state, 'warning', `Failed to open ${state.endpoint}: ${error.message || error}`)
616
+ }
617
+ }
618
+ } else if (result.command === 'quit') requestExit('q')
436
619
  draw()
437
620
  }
621
+ const handleEvent = async (event) => {
622
+ if (event.type === 'paste') editor.insertPaste(event.value)
623
+ else if (event.type === 'text') {
624
+ editor.insert(event.value)
625
+ selection = undefined
626
+ } else if (event.type === 'mouse') {
627
+ const position = { x: event.x, y: event.y }
628
+ if (event.kind === 'down' && event.button === 'left') {
629
+ dragStart = position
630
+ selection = { start: position, end: position }
631
+ } else if (event.kind === 'drag' && dragStart) selection = { start: dragStart, end: position }
632
+ else if (event.kind === 'up' && event.button === 'left' && dragStart) {
633
+ selection = { start: dragStart, end: position }
634
+ dragStart = undefined
635
+ const value = extractSelection(rows, selection)
636
+ if (value) {
637
+ lastSelection = value
638
+ await copyText(value)
639
+ } else {
640
+ if (position.y === inputY) editor.setCursorFromCell(Math.max(0, position.x - inputX))
641
+ selection = undefined
642
+ }
643
+ } else if (event.kind === 'down' && event.button === 'right' && position.y === inputY) await pasteClipboard()
644
+ else if (event.kind === 'scroll-up') state.scrollFromBottom = Math.max(0, Math.min(activityRowCount(state) - 1, state.scrollFromBottom + 3))
645
+ else if (event.kind === 'scroll-down') state.scrollFromBottom = Math.max(0, state.scrollFromBottom - 3)
646
+ } else if (event.type === 'key') {
647
+ if (event.key === 'ctrl-c') requestExit('SIGINT')
648
+ else if (event.key === 'ctrl-y') lastSelection ? await copyText(lastSelection) : setNotice('No selected text to copy', true)
649
+ else if (event.key === 'ctrl-v') await pasteClipboard()
650
+ else if (event.key === 'enter') await submit()
651
+ else if (event.key === 'escape') { editor.clear(); selection = undefined }
652
+ else if (event.key === 'left') editor.moveLeft()
653
+ else if (event.key === 'right') editor.moveRight()
654
+ else if (event.key === 'home') editor.moveHome()
655
+ else if (event.key === 'end') editor.moveEnd()
656
+ else if (event.key === 'ctrl-end') state.scrollFromBottom = 0
657
+ else if (event.key === 'backspace') editor.backspace()
658
+ else if (event.key === 'delete') editor.delete()
659
+ else if (event.key === 'up') editor.historyPrevious()
660
+ else if (event.key === 'down') editor.historyNext()
661
+ else if (event.key === 'pageup') state.scrollFromBottom = Math.max(0, Math.min(activityRowCount(state) - 1, state.scrollFromBottom + 10))
662
+ else if (event.key === 'pagedown') state.scrollFromBottom = Math.max(0, state.scrollFromBottom - 10)
663
+ }
664
+ draw()
665
+ }
666
+ const onData = (chunk) => {
667
+ for (const event of decoder.push(chunk)) {
668
+ eventQueue = eventQueue.then(() => handleEvent(event))
669
+ }
670
+ }
438
671
  const onResize = () => draw()
439
672
 
440
673
  previousRaw = input.isRaw === true
@@ -442,7 +675,7 @@ export function createDashboardSession(
442
675
  input.resume?.()
443
676
  input.on('data', onData)
444
677
  output.on?.('resize', onResize)
445
- output.write('\x1b[?1049h\x1b[?25l\x1b[2J')
678
+ output.write('\x1b[?1049h\x1b[?1002h\x1b[?1006h\x1b[?2004h\x1b[?25l\x1b[2J')
446
679
  const timer = setInterval(draw, 100)
447
680
  timer.unref?.()
448
681
  draw()
@@ -459,7 +692,7 @@ export function createDashboardSession(
459
692
  output.off?.('resize', onResize)
460
693
  if (typeof input.setRawMode === 'function') input.setRawMode(previousRaw)
461
694
  if (!previousRaw) input.pause?.()
462
- output.write('\x1b[?25h\x1b[?1049l')
695
+ output.write('\x1b[?2004l\x1b[?1006l\x1b[?1002l\x1b[?25h\x1b[?1049l')
463
696
  },
464
697
  }
465
698
  }
package/bin/wake.mjs CHANGED
@@ -184,7 +184,7 @@ 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
188
  dashboard?.draw()
189
189
  }
190
190
  const onClosed = () => {
package/index.cjs CHANGED
@@ -124,11 +124,7 @@ class DevServer extends EventEmitter {
124
124
  } else if (event.type === 'rebuilt') {
125
125
  this.emit('rebuilt', event)
126
126
  } else if (event.type === 'diagnostic') {
127
- this.emit('diagnostic', {
128
- severity: 'error',
129
- code: 'WAKE_BUILD',
130
- message: event.message,
131
- })
127
+ this.emit('diagnostic', event.diagnostic)
132
128
  } else if (event.type === 'closed' && !this.#closed) {
133
129
  this.#closed = true
134
130
  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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crab-dev/wake",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
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.20",
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.18",
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.18",
76
- "@crab-dev/wake-darwin-x64": "0.1.18",
77
- "@crab-dev/wake-linux-arm64-gnu": "0.1.18",
78
- "@crab-dev/wake-linux-x64-gnu": "0.1.18",
79
- "@crab-dev/wake-win32-x64-msvc": "0.1.18"
78
+ "@crab-dev/wake-darwin-arm64": "0.1.20",
79
+ "@crab-dev/wake-darwin-x64": "0.1.20",
80
+ "@crab-dev/wake-linux-arm64-gnu": "0.1.20",
81
+ "@crab-dev/wake-linux-x64-gnu": "0.1.20",
82
+ "@crab-dev/wake-win32-x64-msvc": "0.1.20"
80
83
  },
81
84
  "publishConfig": {
82
85
  "access": "public",