@nan0web/ui 1.0.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.
- package/.datasets/README.dataset.jsonl +12 -0
- package/.editorconfig +20 -0
- package/CONTRIBUTING.md +42 -0
- package/LICENSE +15 -0
- package/README.md +238 -0
- package/docs/uk/README.md +240 -0
- package/package.json +64 -0
- package/playground/User.js +52 -0
- package/playground/currency.exchange.js +48 -0
- package/playground/i18n/index.js +21 -0
- package/playground/i18n/uk.js +53 -0
- package/playground/language.form.js +25 -0
- package/playground/main.js +72 -0
- package/playground/registration.form.js +58 -0
- package/playground/topup.telephone.js +62 -0
- package/src/App/Command/Options.js +78 -0
- package/src/App/Command/index.js +9 -0
- package/src/App/Core/CoreApp.js +129 -0
- package/src/App/Core/UI.js +116 -0
- package/src/App/Core/Widget.js +67 -0
- package/src/App/Core/index.js +11 -0
- package/src/App/Scenario.js +45 -0
- package/src/App/User/Command/Message.js +44 -0
- package/src/App/User/Command/Options.js +48 -0
- package/src/App/User/Command/index.js +11 -0
- package/src/App/User/UserApp.js +73 -0
- package/src/App/User/UserApp.test.js +56 -0
- package/src/App/User/UserUI.js +20 -0
- package/src/App/User/UserUI.test.js +51 -0
- package/src/App/User/index.js +15 -0
- package/src/App/index.js +22 -0
- package/src/Component/Process/Input.js +70 -0
- package/src/Component/Process/Process.js +26 -0
- package/src/Component/Process/index.js +5 -0
- package/src/Component/Welcome/Input.js +50 -0
- package/src/Component/Welcome/Welcome.js +26 -0
- package/src/Component/Welcome/index.js +5 -0
- package/src/Component/index.js +9 -0
- package/src/Frame/Frame.js +591 -0
- package/src/Frame/Frame.test.js +429 -0
- package/src/Frame/Props.js +102 -0
- package/src/Locale.js +119 -0
- package/src/Model/User/User.js +56 -0
- package/src/Model/index.js +7 -0
- package/src/README.md.js +371 -0
- package/src/StdIn.js +111 -0
- package/src/StdOut.js +99 -0
- package/src/View/RenderOptions.js +48 -0
- package/src/View/View.js +289 -0
- package/src/View/View.test.js +77 -0
- package/src/core/Form/Form.js +289 -0
- package/src/core/Form/Form.test.js +116 -0
- package/src/core/Form/Input.js +116 -0
- package/src/core/Form/Input.test.js +58 -0
- package/src/core/Form/Message.js +86 -0
- package/src/core/Form/Message.test.js +54 -0
- package/src/core/Form/index.js +11 -0
- package/src/core/InputAdapter.js +41 -0
- package/src/core/InputAdapter.test.js +35 -0
- package/src/core/Message/InputMessage.js +119 -0
- package/src/core/Message/InputMessage.test.js +45 -0
- package/src/core/Message/Message.js +77 -0
- package/src/core/Message/Message.test.js +58 -0
- package/src/core/Message/OutputMessage.js +143 -0
- package/src/core/Message/OutputMessage.test.js +61 -0
- package/src/core/Message/index.js +7 -0
- package/src/core/OutputAdapter.js +50 -0
- package/src/core/OutputAdapter.test.js +35 -0
- package/src/core/Stream.js +71 -0
- package/src/core/Stream.test.js +78 -0
- package/src/core/StreamEntry.js +59 -0
- package/src/core/index.js +13 -0
- package/src/functions.js +38 -0
- package/src/index.js +34 -0
- package/src/index.test.js +14 -0
- package/src/models/SimpleUser.js +18 -0
- package/stories/App/AppView.js +15 -0
- package/stories/App/AppView.test.js +22 -0
- package/stories/App/RenderOptions.js +14 -0
- package/stories/nodejs/interface.test.js +27 -0
- package/system.md +187 -0
- package/system1.md +137 -0
- package/task.md +181 -0
- package/tsconfig.json +23 -0
- package/types/App/Command/Options.d.ts +46 -0
- package/types/App/Command/index.d.ts +8 -0
- package/types/App/Core/CoreApp.d.ts +70 -0
- package/types/App/Core/UI.d.ts +49 -0
- package/types/App/Core/Widget.d.ts +40 -0
- package/types/App/Core/index.d.ts +10 -0
- package/types/App/Scenario.d.ts +26 -0
- package/types/App/User/Command/Message.d.ts +30 -0
- package/types/App/User/Command/Options.d.ts +27 -0
- package/types/App/User/Command/index.d.ts +8 -0
- package/types/App/User/UserApp.d.ts +31 -0
- package/types/App/User/UserUI.d.ts +18 -0
- package/types/App/User/index.d.ts +12 -0
- package/types/App/index.d.ts +14 -0
- package/types/Component/Process/Input.d.ts +48 -0
- package/types/Component/Process/Process.d.ts +13 -0
- package/types/Component/Process/index.d.ts +4 -0
- package/types/Component/Welcome/Input.d.ts +34 -0
- package/types/Component/Welcome/Welcome.d.ts +13 -0
- package/types/Component/Welcome/index.d.ts +4 -0
- package/types/Component/index.d.ts +8 -0
- package/types/Frame/Frame.d.ts +186 -0
- package/types/Frame/Props.d.ts +77 -0
- package/types/Locale.d.ts +55 -0
- package/types/Model/User/User.d.ts +36 -0
- package/types/Model/index.d.ts +6 -0
- package/types/StdIn.d.ts +62 -0
- package/types/StdOut.d.ts +52 -0
- package/types/View/RenderOptions.d.ts +29 -0
- package/types/View/View.d.ts +115 -0
- package/types/core/Form/Form.d.ts +123 -0
- package/types/core/Form/Input.d.ts +69 -0
- package/types/core/Form/Message.d.ts +28 -0
- package/types/core/Form/index.d.ts +5 -0
- package/types/core/InputAdapter.d.ts +28 -0
- package/types/core/Message/InputMessage.d.ts +71 -0
- package/types/core/Message/Message.d.ts +50 -0
- package/types/core/Message/OutputMessage.d.ts +53 -0
- package/types/core/Message/index.d.ts +5 -0
- package/types/core/OutputAdapter.d.ts +33 -0
- package/types/core/Stream.d.ts +27 -0
- package/types/core/StreamEntry.d.ts +45 -0
- package/types/core/index.d.ts +9 -0
- package/types/functions.d.ts +3 -0
- package/types/index.d.ts +20 -0
- package/types/models/SimpleUser.d.ts +21 -0
- package/vitest.config.js +26 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Message as BaseMessage } from "@nan0web/co"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Base UI message class.
|
|
5
|
+
*
|
|
6
|
+
* @class UIMessage
|
|
7
|
+
* @extends BaseMessage
|
|
8
|
+
*/
|
|
9
|
+
class UIMessage extends BaseMessage {
|
|
10
|
+
static TYPES = {
|
|
11
|
+
TEXT: 'text',
|
|
12
|
+
FORM: 'form',
|
|
13
|
+
PROGRESS: 'progress',
|
|
14
|
+
ERROR: 'error',
|
|
15
|
+
INFO: 'info',
|
|
16
|
+
SUCCESS: 'success',
|
|
17
|
+
WARNING: 'warning',
|
|
18
|
+
COMMAND: 'command',
|
|
19
|
+
NAVIGATION: 'navigation'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @type {string} */
|
|
23
|
+
type = ""
|
|
24
|
+
/** @type {string} */
|
|
25
|
+
id = ""
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Creates a UIMessage.
|
|
29
|
+
*
|
|
30
|
+
* @param {Object} [input={}] - Message properties.
|
|
31
|
+
*/
|
|
32
|
+
constructor(input = {}) {
|
|
33
|
+
super(input)
|
|
34
|
+
|
|
35
|
+
const {
|
|
36
|
+
type = this.type,
|
|
37
|
+
id = this.id,
|
|
38
|
+
} = input
|
|
39
|
+
this.id = id || `ui-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
|
40
|
+
this.type = String(type)
|
|
41
|
+
|
|
42
|
+
if (!('body' in input) && 'content' in input) {
|
|
43
|
+
this.body = Array.isArray(input.content) ? input.content : [input.content]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Creates a UIMessage instance from plain data.
|
|
49
|
+
*
|
|
50
|
+
* @param {Object} data - Message data.
|
|
51
|
+
* @returns {UIMessage}
|
|
52
|
+
*/
|
|
53
|
+
static from(data) {
|
|
54
|
+
if (data instanceof UIMessage) return data
|
|
55
|
+
return new this(data)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Checks if the message type is valid.
|
|
60
|
+
*
|
|
61
|
+
* @returns {boolean}
|
|
62
|
+
*/
|
|
63
|
+
isValidType() {
|
|
64
|
+
return Object.values(UIMessage.TYPES).includes(this.type)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Checks whether the message contains any body content.
|
|
69
|
+
*
|
|
70
|
+
* @returns {boolean}
|
|
71
|
+
*/
|
|
72
|
+
isEmpty() {
|
|
73
|
+
return !this.body || this.body.length === 0
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export default UIMessage
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it } from "node:test"
|
|
2
|
+
import { strict as assert } from "node:assert"
|
|
3
|
+
import UIMessage from "./Message.js"
|
|
4
|
+
|
|
5
|
+
describe("UIMessage", () => {
|
|
6
|
+
it("should create instance with default values", () => {
|
|
7
|
+
const msg = new UIMessage()
|
|
8
|
+
assert.ok(msg instanceof UIMessage)
|
|
9
|
+
assert.equal(typeof msg.type, "string")
|
|
10
|
+
assert.equal(typeof msg.id, "string")
|
|
11
|
+
assert.ok(msg.time instanceof Date)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("should create instance with custom values", () => {
|
|
15
|
+
const props = {
|
|
16
|
+
type: "test",
|
|
17
|
+
id: "custom-id",
|
|
18
|
+
body: ["test content"]
|
|
19
|
+
}
|
|
20
|
+
const msg = new UIMessage(props)
|
|
21
|
+
assert.equal(msg.type, "test")
|
|
22
|
+
assert.equal(msg.id, "custom-id")
|
|
23
|
+
assert.deepEqual(msg.body, ["test content"])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("should generate unique id when not provided", () => {
|
|
27
|
+
const msg1 = new UIMessage()
|
|
28
|
+
const msg2 = new UIMessage()
|
|
29
|
+
assert.notEqual(msg1.id, msg2.id)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("should use content as body when body not provided", () => {
|
|
33
|
+
const msg = new UIMessage({ content: ["hello"] })
|
|
34
|
+
assert.deepEqual(msg.body, ["hello"])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("should create from data using from() method", () => {
|
|
38
|
+
const data = { type: "info", body: ["test"] }
|
|
39
|
+
const msg = UIMessage.from(data)
|
|
40
|
+
assert.ok(msg instanceof UIMessage)
|
|
41
|
+
assert.equal(msg.type, "info")
|
|
42
|
+
assert.deepEqual(msg.body, ["test"])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("should validate type correctly", () => {
|
|
46
|
+
const validMsg = new UIMessage({ type: UIMessage.TYPES.TEXT })
|
|
47
|
+
const invalidMsg = new UIMessage({ type: "invalid-type" })
|
|
48
|
+
assert.ok(validMsg.isValidType())
|
|
49
|
+
assert.ok(!invalidMsg.isValidType())
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("should check if message is empty", () => {
|
|
53
|
+
const emptyMsg = new UIMessage()
|
|
54
|
+
const nonEmptyMsg = new UIMessage({ body: ["content"] })
|
|
55
|
+
assert.ok(emptyMsg.isEmpty())
|
|
56
|
+
assert.ok(!nonEmptyMsg.isEmpty())
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import UIMessage from "./Message.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* OutputMessage – message sent from the system to the UI.
|
|
5
|
+
*
|
|
6
|
+
* @class OutputMessage
|
|
7
|
+
* @extends UIMessage
|
|
8
|
+
*/
|
|
9
|
+
export default class OutputMessage extends UIMessage {
|
|
10
|
+
static PRIORITY = {
|
|
11
|
+
LOW: 0,
|
|
12
|
+
NORMAL: 1,
|
|
13
|
+
HIGH: 2,
|
|
14
|
+
CRITICAL: 3
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** @type {string[]} */
|
|
18
|
+
body
|
|
19
|
+
/** @type {Object} */
|
|
20
|
+
meta = {}
|
|
21
|
+
/** @type {Error|null} */
|
|
22
|
+
error = null
|
|
23
|
+
/** @type {number} */
|
|
24
|
+
priority = OutputMessage.PRIORITY.NORMAL
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Creates an OutputMessage.
|
|
28
|
+
*
|
|
29
|
+
* @param {Object} [input={}] - Message properties.
|
|
30
|
+
*/
|
|
31
|
+
constructor(input = {}) {
|
|
32
|
+
super(input)
|
|
33
|
+
|
|
34
|
+
const {
|
|
35
|
+
content = [],
|
|
36
|
+
body = [],
|
|
37
|
+
meta = {},
|
|
38
|
+
error = null,
|
|
39
|
+
priority = OutputMessage.PRIORITY.NORMAL
|
|
40
|
+
} = input
|
|
41
|
+
|
|
42
|
+
const contentSource = 'body' in input ? body :
|
|
43
|
+
'content' in input ? content : []
|
|
44
|
+
|
|
45
|
+
this.body = Array.isArray(contentSource) ?
|
|
46
|
+
contentSource :
|
|
47
|
+
(contentSource ? [String(contentSource)] : [])
|
|
48
|
+
|
|
49
|
+
this.meta = meta
|
|
50
|
+
this.error = error instanceof Error ? error :
|
|
51
|
+
error ? new Error(String(error)) : null
|
|
52
|
+
this.priority = Number(priority)
|
|
53
|
+
|
|
54
|
+
if (!this.type && this.error) {
|
|
55
|
+
this.type = UIMessage.TYPES.ERROR
|
|
56
|
+
} else if (!this.type) {
|
|
57
|
+
this.type = UIMessage.TYPES.INFO
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @returns {string[]} */
|
|
62
|
+
get content() {
|
|
63
|
+
return this.body
|
|
64
|
+
}
|
|
65
|
+
/** @param {string[]|string} value */
|
|
66
|
+
set content(value) {
|
|
67
|
+
this.body = Array.isArray(value) ? value : [String(value)]
|
|
68
|
+
}
|
|
69
|
+
/** @returns {number} */
|
|
70
|
+
get size() {
|
|
71
|
+
return this.content.length
|
|
72
|
+
}
|
|
73
|
+
/** @returns {boolean} */
|
|
74
|
+
get isError() {
|
|
75
|
+
return this.error !== null || this.type === UIMessage.TYPES.ERROR
|
|
76
|
+
}
|
|
77
|
+
/** @returns {boolean} */
|
|
78
|
+
get isInfo() {
|
|
79
|
+
return this.type === UIMessage.TYPES.INFO || this.type === UIMessage.TYPES.SUCCESS
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Combines multiple messages into a new one.
|
|
84
|
+
*
|
|
85
|
+
* @param {...OutputMessage} messages - Messages to combine.
|
|
86
|
+
* @returns {OutputMessage}
|
|
87
|
+
*/
|
|
88
|
+
combine(...messages) {
|
|
89
|
+
const combinedContent = [...this.content]
|
|
90
|
+
let combinedMeta = { ...this.meta }
|
|
91
|
+
let combinedError = this.error
|
|
92
|
+
let combinedPriority = this.priority
|
|
93
|
+
|
|
94
|
+
messages.forEach(msg => {
|
|
95
|
+
if (msg instanceof OutputMessage) {
|
|
96
|
+
combinedContent.push(...msg.content)
|
|
97
|
+
combinedMeta = { ...combinedMeta, ...msg.meta }
|
|
98
|
+
if (msg.error) combinedError = msg.error
|
|
99
|
+
combinedPriority = Math.max(combinedPriority, msg.priority)
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
return new OutputMessage({
|
|
104
|
+
content: combinedContent,
|
|
105
|
+
meta: combinedMeta,
|
|
106
|
+
error: combinedError,
|
|
107
|
+
priority: combinedPriority,
|
|
108
|
+
type: this.type
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Serialises the message to a plain JSON object.
|
|
114
|
+
*
|
|
115
|
+
* @returns {Object}
|
|
116
|
+
*/
|
|
117
|
+
toJSON() {
|
|
118
|
+
return {
|
|
119
|
+
body: this.body,
|
|
120
|
+
content: this.content,
|
|
121
|
+
meta: this.meta,
|
|
122
|
+
type: this.type,
|
|
123
|
+
id: this.id,
|
|
124
|
+
time: this.time.toISOString(),
|
|
125
|
+
error: this.error ? {
|
|
126
|
+
message: this.error.message,
|
|
127
|
+
stack: this.error.stack
|
|
128
|
+
} : null,
|
|
129
|
+
priority: this.priority
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Creates an OutputMessage from plain input.
|
|
135
|
+
*
|
|
136
|
+
* @param {Object} input - Message data.
|
|
137
|
+
* @returns {OutputMessage}
|
|
138
|
+
*/
|
|
139
|
+
static from(input) {
|
|
140
|
+
if (input instanceof OutputMessage) return input
|
|
141
|
+
return new OutputMessage(input)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it } from "node:test"
|
|
2
|
+
import { strict as assert } from "node:assert"
|
|
3
|
+
import OutputMessage from "./OutputMessage.js"
|
|
4
|
+
|
|
5
|
+
describe("OutputMessage", () => {
|
|
6
|
+
it("should create instance with default values", () => {
|
|
7
|
+
const msg = new OutputMessage()
|
|
8
|
+
assert.ok(msg instanceof OutputMessage)
|
|
9
|
+
assert.deepEqual(msg.body, [])
|
|
10
|
+
assert.deepEqual(msg.meta, {})
|
|
11
|
+
assert.equal(msg.error, null)
|
|
12
|
+
assert.equal(msg.priority, OutputMessage.PRIORITY.NORMAL)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("should create instance with custom values", () => {
|
|
16
|
+
const props = {
|
|
17
|
+
content: ["test content"],
|
|
18
|
+
meta: { key: "value" },
|
|
19
|
+
error: new Error("test error"),
|
|
20
|
+
priority: OutputMessage.PRIORITY.HIGH
|
|
21
|
+
}
|
|
22
|
+
const msg = new OutputMessage(props)
|
|
23
|
+
assert.deepEqual(msg.content, ["test content"])
|
|
24
|
+
assert.deepEqual(msg.meta, { key: "value" })
|
|
25
|
+
assert.ok(msg.error instanceof Error)
|
|
26
|
+
assert.equal(msg.priority, OutputMessage.PRIORITY.HIGH)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("should set type based on error presence", () => {
|
|
30
|
+
const errorMsg = new OutputMessage({ error: "error occurred" })
|
|
31
|
+
const infoMsg = new OutputMessage({ content: ["info"] })
|
|
32
|
+
assert.equal(errorMsg.type, "error")
|
|
33
|
+
assert.equal(infoMsg.type, "info")
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("should handle string content", () => {
|
|
37
|
+
const msg = new OutputMessage({ content: "single string" })
|
|
38
|
+
assert.deepEqual(msg.content, ["single string"])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("should combine messages", () => {
|
|
42
|
+
const msg1 = new OutputMessage({ content: ["part1"], priority: 1 })
|
|
43
|
+
const msg2 = new OutputMessage({ content: ["part2"], priority: 2 })
|
|
44
|
+
const combined = msg1.combine(msg2)
|
|
45
|
+
assert.deepEqual(combined.content, ["part1", "part2"])
|
|
46
|
+
assert.equal(combined.priority, 2)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("should convert to JSON", () => {
|
|
50
|
+
const msg = new OutputMessage({
|
|
51
|
+
content: ["test"],
|
|
52
|
+
meta: { test: true },
|
|
53
|
+
error: new Error("test error")
|
|
54
|
+
})
|
|
55
|
+
const json = msg.toJSON()
|
|
56
|
+
assert.ok(json.body)
|
|
57
|
+
assert.ok(json.meta)
|
|
58
|
+
assert.ok(json.error)
|
|
59
|
+
assert.ok(json.time)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import Event from '@nan0web/event/oop'
|
|
2
|
+
import OutputMessage from './Message/OutputMessage.js'
|
|
3
|
+
import FormMessage from './Form/Message.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Abstract output adapter for UI implementations.
|
|
7
|
+
*
|
|
8
|
+
* @class OutputAdapter
|
|
9
|
+
* @extends Event
|
|
10
|
+
*/
|
|
11
|
+
class OutputAdapter extends Event {
|
|
12
|
+
/**
|
|
13
|
+
* Renders a message to the user.
|
|
14
|
+
*
|
|
15
|
+
* @param {OutputMessage|FormMessage} message - Message to render.
|
|
16
|
+
* @throws {Error} If not overridden by a subclass.
|
|
17
|
+
*/
|
|
18
|
+
render(message) {
|
|
19
|
+
throw new Error("render() must be implemented by subclass")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Shows progress of a long‑running operation.
|
|
24
|
+
*
|
|
25
|
+
* @param {number} progress - Progress value in range 0‑1.
|
|
26
|
+
* @param {Object} [metadata={}] - Additional metadata.
|
|
27
|
+
* @returns {void}
|
|
28
|
+
*/
|
|
29
|
+
progress(progress, metadata = {}) {
|
|
30
|
+
this.render(OutputMessage.from({
|
|
31
|
+
content: [],
|
|
32
|
+
metadata: {
|
|
33
|
+
...metadata,
|
|
34
|
+
progress,
|
|
35
|
+
elementType: "progress"
|
|
36
|
+
}
|
|
37
|
+
}))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Stops the output stream. Default implementation does nothing.
|
|
42
|
+
*
|
|
43
|
+
* @returns {void}
|
|
44
|
+
*/
|
|
45
|
+
stop() {
|
|
46
|
+
// Default implementation – does nothing
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default OutputAdapter
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, it } from "node:test"
|
|
2
|
+
import { strict as assert } from "node:assert"
|
|
3
|
+
import OutputAdapter from "./OutputAdapter.js"
|
|
4
|
+
|
|
5
|
+
describe("OutputAdapter", () => {
|
|
6
|
+
it("should create instance", () => {
|
|
7
|
+
const adapter = new OutputAdapter()
|
|
8
|
+
assert.ok(adapter instanceof OutputAdapter)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it("should throw error on render method", () => {
|
|
12
|
+
const adapter = new OutputAdapter()
|
|
13
|
+
assert.throws(() => adapter.render(), {
|
|
14
|
+
message: "render() must be implemented by subclass"
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("should call render on progress", () => {
|
|
19
|
+
const adapter = new OutputAdapter()
|
|
20
|
+
let rendered = false
|
|
21
|
+
|
|
22
|
+
// Override render for test
|
|
23
|
+
adapter.render = () => {
|
|
24
|
+
rendered = true
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
adapter.progress(0.5)
|
|
28
|
+
assert.ok(rendered)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("should stop without error", () => {
|
|
32
|
+
const adapter = new OutputAdapter()
|
|
33
|
+
assert.doesNotThrow(() => adapter.stop())
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import StreamEntry from "./StreamEntry.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Agnostic UI stream for processing progress.
|
|
5
|
+
*
|
|
6
|
+
* @class UIStream
|
|
7
|
+
*/
|
|
8
|
+
export default class UIStream {
|
|
9
|
+
/**
|
|
10
|
+
* Creates an async generator that runs the supplied processor function.
|
|
11
|
+
*
|
|
12
|
+
* @param {AbortSignal} signal - Abort signal.
|
|
13
|
+
* @param {() => Promise<StreamEntry>} processorFn - Async function that returns a result.
|
|
14
|
+
* @returns {() => AsyncGenerator<StreamEntry>} Async generator function.
|
|
15
|
+
*/
|
|
16
|
+
static createProcessor(signal, processorFn) {
|
|
17
|
+
return async function* () {
|
|
18
|
+
try {
|
|
19
|
+
const result = await processorFn()
|
|
20
|
+
yield result
|
|
21
|
+
} catch (/** @type {any} */ error) {
|
|
22
|
+
if (error.name === 'AbortError') {
|
|
23
|
+
yield StreamEntry.from({ done: true, cancelled: true })
|
|
24
|
+
} else {
|
|
25
|
+
yield StreamEntry.from({ error: error.message })
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Runs a generator with progress callbacks and abort handling.
|
|
33
|
+
*
|
|
34
|
+
* @param {AbortSignal} signal - Abort signal.
|
|
35
|
+
* @param {Function} generator - Function returning an async iterator.
|
|
36
|
+
* @param {Function} [onProgress] - Called with (progress, item).
|
|
37
|
+
* @param {Function} [onError] - Called with (errorMessage, item).
|
|
38
|
+
* @param {Function} [onComplete] - Called with (item) when done.
|
|
39
|
+
* @returns {Promise<void>}
|
|
40
|
+
*/
|
|
41
|
+
static async process(signal, generator, onProgress, onError, onComplete) {
|
|
42
|
+
const iter = generator()
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
for await (const item of iter) {
|
|
46
|
+
if (signal.aborted) {
|
|
47
|
+
throw new DOMException('Aborted', 'AbortError')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (item.progress !== undefined) {
|
|
51
|
+
onProgress?.(item.progress, item)
|
|
52
|
+
} else if (item.error) {
|
|
53
|
+
onError?.(item.error, item)
|
|
54
|
+
} else if (item.done) {
|
|
55
|
+
onComplete?.(item)
|
|
56
|
+
break
|
|
57
|
+
} else {
|
|
58
|
+
// Intermediate results
|
|
59
|
+
onProgress?.(null, item)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch (/** @type {any} */ error) {
|
|
63
|
+
if (error.name === 'AbortError') {
|
|
64
|
+
onComplete?.({ cancelled: true })
|
|
65
|
+
} else {
|
|
66
|
+
onError?.(error.message)
|
|
67
|
+
}
|
|
68
|
+
throw error
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it } from "node:test"
|
|
2
|
+
import { strict as assert } from "node:assert"
|
|
3
|
+
import UIStream from "./Stream.js"
|
|
4
|
+
import StreamEntry from "./StreamEntry.js"
|
|
5
|
+
|
|
6
|
+
describe("UIStream", () => {
|
|
7
|
+
it("should create processor generator", async () => {
|
|
8
|
+
const controller = new AbortController()
|
|
9
|
+
const processorFn = () => Promise.resolve("test result")
|
|
10
|
+
const generator = UIStream.createProcessor(controller.signal, processorFn)
|
|
11
|
+
|
|
12
|
+
assert.equal(typeof generator, "function")
|
|
13
|
+
|
|
14
|
+
for await (const item of generator()) {
|
|
15
|
+
assert.equal(item, "test result")
|
|
16
|
+
break
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("should handle aborted signal", async () => {
|
|
21
|
+
const controller = new AbortController()
|
|
22
|
+
controller.abort()
|
|
23
|
+
|
|
24
|
+
const processorFn = () => Promise.resolve(StreamEntry.from({ value: "Hello", done: true }))
|
|
25
|
+
const generator = UIStream.createProcessor(controller.signal, processorFn)
|
|
26
|
+
|
|
27
|
+
for await (const item of generator()) {
|
|
28
|
+
assert.deepEqual(item, StreamEntry.from({ done: true, value: "Hello" }))
|
|
29
|
+
break
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("should process stream with callbacks", async () => {
|
|
34
|
+
const controller = new AbortController()
|
|
35
|
+
let progressCalled = false
|
|
36
|
+
let completeCalled = false
|
|
37
|
+
|
|
38
|
+
const generatorFn = async function* () {
|
|
39
|
+
yield StreamEntry.from({ progress: 0.5 })
|
|
40
|
+
yield StreamEntry.from({ done: true })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const onProgress = () => { progressCalled = true }
|
|
44
|
+
const onComplete = () => { completeCalled = true }
|
|
45
|
+
|
|
46
|
+
await UIStream.process(
|
|
47
|
+
controller.signal,
|
|
48
|
+
generatorFn,
|
|
49
|
+
onProgress,
|
|
50
|
+
null,
|
|
51
|
+
onComplete
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
assert.ok(progressCalled)
|
|
55
|
+
assert.ok(completeCalled)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("should handle errors in stream processing", async () => {
|
|
59
|
+
const controller = new AbortController()
|
|
60
|
+
let errorCalled = false
|
|
61
|
+
|
|
62
|
+
const generatorFn = async function* () {
|
|
63
|
+
yield { error: "test error" }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const onError = () => { errorCalled = true }
|
|
67
|
+
|
|
68
|
+
await UIStream.process(
|
|
69
|
+
controller.signal,
|
|
70
|
+
generatorFn,
|
|
71
|
+
null,
|
|
72
|
+
onError,
|
|
73
|
+
null
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
assert.ok(errorCalled)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Represents an entry in a stream with value, completion status, cancellation status, and error message.
|
|
3
|
+
*/
|
|
4
|
+
export default class StreamEntry {
|
|
5
|
+
/**
|
|
6
|
+
* The value of the stream entry.
|
|
7
|
+
* @type {any}
|
|
8
|
+
*/
|
|
9
|
+
value = undefined
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Indicates if the stream entry is done (completed).
|
|
13
|
+
* @type {boolean}
|
|
14
|
+
*/
|
|
15
|
+
done = false
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Indicates if the stream entry has been cancelled.
|
|
19
|
+
* @type {boolean}
|
|
20
|
+
*/
|
|
21
|
+
cancelled = false
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Error message associated with the stream entry.
|
|
25
|
+
* @type {string}
|
|
26
|
+
*/
|
|
27
|
+
error = ""
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Creates a new StreamEntry instance.
|
|
31
|
+
* @param {Object} [input={}] - Input object to initialize the stream entry.
|
|
32
|
+
* @param {any} [input.value] - The value for the stream entry.
|
|
33
|
+
* @param {boolean} [input.done] - Whether the stream entry is completed.
|
|
34
|
+
* @param {boolean} [input.cancelled] - Whether the stream entry is cancelled.
|
|
35
|
+
* @param {string} [input.error] - Error message for the stream entry.
|
|
36
|
+
*/
|
|
37
|
+
constructor(input = {}) {
|
|
38
|
+
const {
|
|
39
|
+
value = this.value,
|
|
40
|
+
done = this.done,
|
|
41
|
+
cancelled = this.cancelled,
|
|
42
|
+
error = this.error,
|
|
43
|
+
} = input
|
|
44
|
+
this.value = value
|
|
45
|
+
this.done = Boolean(done)
|
|
46
|
+
this.cancelled = Boolean(cancelled)
|
|
47
|
+
this.error = String(error)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Creates a StreamEntry instance from the given input.
|
|
52
|
+
* @param {any} input - The input to create a StreamEntry from.
|
|
53
|
+
* @returns {StreamEntry} A new or existing StreamEntry instance.
|
|
54
|
+
*/
|
|
55
|
+
static from(input) {
|
|
56
|
+
if (input instanceof StreamEntry) return input
|
|
57
|
+
return new StreamEntry(input)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// export default App
|
|
2
|
+
|
|
3
|
+
export { default as InputAdapter } from "./InputAdapter.js"
|
|
4
|
+
export { default as OutputAdapter } from "./OutputAdapter.js"
|
|
5
|
+
export { default as UIStream } from "./Stream.js"
|
|
6
|
+
|
|
7
|
+
export { default as UIMessage } from "./Message/Message.js"
|
|
8
|
+
export { default as InputMessage } from "./Message/InputMessage.js"
|
|
9
|
+
export { default as OutputMessage } from "./Message/OutputMessage.js"
|
|
10
|
+
|
|
11
|
+
export { default as FormMessage } from "./Form/Message.js"
|
|
12
|
+
export { default as FormInput } from "./Form/Input.js"
|
|
13
|
+
export { default as UIForm } from "./Form/Form.js"
|
package/src/functions.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { empty } from "@nan0web/types"
|
|
2
|
+
|
|
3
|
+
export const spaces = (options = {}) => {
|
|
4
|
+
const { cols = [], padding = 1, aligns = [] } = options
|
|
5
|
+
return (row) => (
|
|
6
|
+
row.map((str, i) => {
|
|
7
|
+
const pad = " ".repeat(cols[i] - str.length + padding)
|
|
8
|
+
return aligns[i] === "r" ? pad + str : str + pad
|
|
9
|
+
})
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const weight = (arr) => {
|
|
14
|
+
return (Fn = v => v) => {
|
|
15
|
+
const cols = []
|
|
16
|
+
arr.forEach(m => {
|
|
17
|
+
Fn(m).forEach((str, i) => {
|
|
18
|
+
if (undefined === cols[i]) cols[i] = 0
|
|
19
|
+
cols[i] = Math.max(str.length, cols[i])
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
return cols
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
export const table = (options = {}) => {
|
|
28
|
+
const {
|
|
29
|
+
Fn = v => v,
|
|
30
|
+
cols = [],
|
|
31
|
+
padding = 1,
|
|
32
|
+
aligns = []
|
|
33
|
+
} = options
|
|
34
|
+
return (arr) => {
|
|
35
|
+
const options = { cols: empty(cols) ? weight(arr)(Fn) : cols, padding, aligns }
|
|
36
|
+
return arr.map(row => spaces(options)(row))
|
|
37
|
+
}
|
|
38
|
+
}
|