Compare commits

..
Author SHA1 Message Date
Evandro 98b5db029f wip: snapshot das alterações não commitadas (2026-10-20)
Origem: D:/Documents/Github/PiAgentUI/PiAgentUi
Arquivos omitidos (segredos, bancos de dados, logs, >50 MB): 4
2026-10-20 00:48:28 -03:00
102 changed files with 10341 additions and 13086 deletions

No files matched your search

+2 -7
View File
@@ -2,7 +2,7 @@ name: Deploy to GitHub Pages
on:
push:
branches: [main]
branches: [dev]
workflow_dispatch:
permissions:
@@ -29,12 +29,7 @@ jobs:
- run: npm run build
env:
VITE_BASE_PATH: /PiAgentUI/
- name: Setup Pages
uses: actions/configure-pages@v5
with:
enablement: true
VITE_BASE_PATH: /OpenCodeUI/
- uses: actions/upload-pages-artifact@v5
with:
+53
View File
@@ -0,0 +1,53 @@
name: Desktop E2E
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: desktop-e2e-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
tauri-webdriver:
runs-on: ubuntu-22.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: './src-tauri -> target'
- name: Install Tauri/WebDriver dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf \
webkit2gtk-driver \
xvfb
- name: Install dependencies
run: npm ci
- name: Install tauri-driver
run: cargo install tauri-driver --locked
- name: Run desktop E2E
run: xvfb-run -a npm run test:e2e
-6
View File
@@ -6,9 +6,3 @@ tmp/
*.log
.DS_Store
Thumbs.db
.pi-lens/
# Local env and test output
.env
.env.local
coverage/
+567
View File
@@ -0,0 +1,567 @@
{
"version": "v3",
"timestamp": 1780870469980,
"ruleHash": "759e7399517619f9",
"queries": [
{
"id": "switch-case-termination-js",
"name": "Switch Cases Should End With Terminating Statement",
"severity": "error",
"language": "javascript",
"message": "Switch case should end with break, return, throw, or continue",
"query": " (switch_statement\n body: (switch_body\n (switch_case\n consequence: (statement_block\n (expression_statement) @LAST))\n (switch_case) @NEXT))",
"metavars": [
"LAST",
"NEXT"
],
"post_filter": "no_terminating_statement",
"defect_class": "correctness",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\javascript\\switch-case-termination-js.yml"
},
{
"id": "switch-non-case-labels-js",
"name": "Switch Should Not Contain Non-Case Labels",
"severity": "error",
"language": "javascript",
"message": "switch statements should not contain non-case labels",
"query": " (switch_statement\n body: (switch_body\n (labeled_statement\n (statement_identifier) @LABEL) @LABELED))",
"metavars": [
"LABEL",
"LABELED"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\javascript\\switch-non-case-labels.yml"
},
{
"id": "console-statement",
"name": "Console Statement",
"severity": "warning",
"language": "typescript",
"message": "{{METHOD}} — remove debug statements before committing",
"query": " (call_expression\n function: (member_expression\n object: (identifier) @OBJ (#eq? @OBJ \"console\")\n property: (property_identifier) @METHOD (#not-eq? @METHOD \"dbg\"))\n arguments: (arguments) @ARGS)",
"metavars": [
"OBJ",
"METHOD",
"ARGS"
],
"post_filter": "not_in_test_block # skip test blocks — no-console-in-tests handles that case",
"defect_class": "safety",
"inline_tier": "warning",
"has_fix": true,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\console-statement.yml"
},
{
"id": "debugger-statement",
"name": "Debugger Statement",
"severity": "error",
"language": "typescript",
"message": "Debugger statement — remove before committing",
"query": " (debugger_statement) @DEBUGGER",
"metavars": [
"DEBUGGER"
],
"defect_class": "safety",
"inline_tier": "blocking",
"has_fix": true,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\debugger.yml"
},
{
"id": "deep-nesting",
"name": "Deep Nesting",
"severity": "warning",
"language": "typescript",
"message": "Deep nesting (3+ levels) — consider early returns or extract functions",
"query": " [\n ;; Pattern 1: if inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement) @IF_NESTED)))))\n\n ;; Pattern 2: for inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (for_statement) @FOR_NESTED)))))\n\n ;; Pattern 3: while inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (while_statement) @WHILE_NESTED)))))\n\n ;; Pattern 4: try inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (try_statement) @TRY_NESTED)))))\n\n ;; Pattern 5: if inside for inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (for_statement\n body: (statement_block\n (if_statement) @IF_IN_FOR)))))\n\n ;; Pattern 6: if inside while inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (while_statement\n body: (statement_block\n (if_statement) @IF_IN_WHILE)))))\n\n ;; Pattern 7: for inside for inside for\n (statement_block\n (for_statement\n body: (statement_block\n (for_statement\n body: (statement_block\n (for_statement) @FOR_NESTED)))))\n ]",
"metavars": [
"IF_NESTED",
"FOR_NESTED",
"WHILE_NESTED",
"TRY_NESTED",
"IF_IN_FOR",
"IF_IN_WHILE"
],
"defect_class": "safety",
"inline_tier": "review",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\deep-nesting.yml"
},
{
"id": "deep-promise-chain",
"name": "Deep Promise Chain (4+ levels)",
"severity": "warning",
"language": "typescript",
"message": "Promise chain {{M1}} → {{M2}} → {{M3}} → {{M4}} — consider async/await",
"query": " (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n property: (property_identifier) @M1)\n arguments: (arguments))\n property: (property_identifier) @M2)\n arguments: (arguments))\n property: (property_identifier) @M3)\n arguments: (arguments))\n property: (property_identifier) @M4)\n arguments: (arguments)\n (#match? @M1 \"^(then|catch|finally)$\")\n (#match? @M2 \"^(then|catch|finally)$\")\n (#match? @M3 \"^(then|catch|finally)$\")\n (#match? @M4 \"^(then|catch|finally)$\"))",
"metavars": [
"M1",
"M2",
"M3",
"M4"
],
"defect_class": "async-misuse",
"inline_tier": "warning",
"has_fix": true,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\deep-promise-chain.yml"
},
{
"id": "default-not-last",
"name": "Default Clauses Should Be Last",
"severity": "error",
"language": "typescript",
"message": "default clause should be the last case",
"query": " (switch_statement\n body: (switch_body\n (switch_default) @DEFAULT\n (switch_case) @AFTER_CASE))",
"metavars": [
"DEFAULT",
"AFTER_CASE"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\default-not-last.yml"
},
{
"id": "duplicate-function-arg",
"name": "Function Argument Names Should Be Unique",
"severity": "error",
"language": "typescript",
"message": "Duplicate parameter name '{{NAME}}'",
"query": " (function_declaration\n parameters: (formal_parameters\n (identifier) @PARAM1\n (identifier) @PARAM2))\n (arrow_function\n parameters: (formal_parameters\n (identifier) @PARAM1\n (identifier) @PARAM2))",
"metavars": [
"PARAM1",
"PARAM2"
],
"post_filter": "same_param_name",
"defect_class": "correctness",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\duplicate-function-arg.yml"
},
{
"id": "empty-switch-case",
"name": "Switch Cases Should Not Be Empty",
"severity": "error",
"language": "typescript",
"message": "Switch case should not be empty",
"query": " (switch_statement\n body: (switch_body\n (switch_case\n consequence: (statement_block) @BLOCK)))",
"metavars": [
"BLOCK"
],
"post_filter": "is_empty_block",
"defect_class": "correctness",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\empty-switch-case.yml"
},
{
"id": "no-eval",
"name": "Eval Usage",
"severity": "error",
"language": "typescript",
"message": "eval() detected — security risk, never use eval",
"query": " (call_expression\n function: (identifier) @FUNC\n (#eq? @FUNC \"eval\")\n arguments: (arguments) @ARGS)",
"metavars": [
"FUNC",
"ARGS"
],
"defect_class": "injection",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\eval.yml"
},
{
"id": "ts-incomplete-assertion",
"name": "Incomplete Test Assertion",
"severity": "error",
"language": "typescript",
"message": "Incomplete assertion — expect() chain is not called",
"query": " (call_expression\n function: (identifier) @EXPECT\n (#eq? @EXPECT \"expect\")\n arguments: (arguments)) @EXPR",
"metavars": [
"EXPECT",
"EXPR"
],
"post_filter": "incomplete_assertion",
"defect_class": "correctness",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\incomplete-assertion.yml"
},
{
"id": "infinite-loop",
"name": "Loops Should Not Be Infinite",
"severity": "error",
"language": "typescript",
"message": "Loop appears to be infinite with no termination condition",
"query": " (while_statement\n condition: (true)\n body: (statement_block) @BODY)\n (for_statement\n condition: (null)\n body: (statement_block) @BODY)",
"metavars": [
"BODY"
],
"post_filter": "no_break_or_return_in_body",
"defect_class": "correctness",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\infinite-loop.yml"
},
{
"id": "mixed-async-styles",
"name": "Mixed Async/Await and Promise Chains",
"severity": "warning",
"language": "typescript",
"message": "Mixed async/await + promise chains — use consistent async style",
"query": " (function_declaration\n (async_modifier)\n body: (statement_block) @BODY)\n\n# Post-filter: Check if body contains both await and .then()",
"metavars": [
"BODY"
],
"post_filter": "has_mixed_async",
"defect_class": "async-misuse",
"inline_tier": "warning",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\mixed-async-styles.yml"
},
{
"id": "no-console-in-tests",
"name": "Console Statement in Test",
"severity": "warning",
"language": "typescript",
"message": "console.{{METHOD}} in test block — use proper assertions or logging",
"query": " (call_expression\n function: (member_expression\n object: (identifier) @OBJ (#eq? @OBJ \"console\")\n property: (property_identifier) @METHOD)\n arguments: (arguments) @ARGS)",
"metavars": [
"OBJ",
"METHOD",
"ARGS"
],
"post_filter": "in_test_block",
"defect_class": "safety",
"inline_tier": "warning",
"has_fix": true,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\no-console-in-tests.yml"
},
{
"id": "no-equality-in-for-condition",
"name": "Loose equality in for-loop condition",
"severity": "warning",
"language": "typescript",
"message": "for-loop uses ==/!= for termination — prefer </<=/>/>= so stepping past the bound still exits",
"query": " (for_statement\n (expression_statement\n (binary_expression\n [\"==\" \"!=\"] @OP)))",
"metavars": [
"OP"
],
"inline_tier": "warning",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\no-equality-in-for-condition.yml"
},
{
"id": "no-jump-in-finally",
"name": "Control-flow jump in finally block",
"severity": "warning",
"language": "typescript",
"message": "return/break/continue/throw in finally overrides the try/catch result and silently swallows exceptions",
"query": " (finally_clause\n (statement_block\n [\n (return_statement)\n (break_statement)\n (continue_statement)\n (throw_statement)\n ] @JUMP))",
"metavars": [
"JUMP"
],
"inline_tier": "warning",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\no-jump-in-finally.yml"
},
{
"id": "self-assignment",
"name": "Variables Should Not Be Self-Assigned",
"severity": "error",
"language": "typescript",
"message": "'{{VAR}}' is assigned to itself",
"query": " (assignment_expression\n left: (identifier) @VAR\n right: (identifier) @SAME\n (#eq? @VAR @SAME))",
"metavars": [
"VAR",
"SAME"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\self-assignment.yml"
},
{
"id": "sql-injection",
"name": "SQL Injection Risk",
"severity": "error",
"language": "typescript",
"message": "SQL injection risk — use parameterized queries, never interpolate into SQL",
"query": " (call_expression\n function: [\n (identifier) @SQL_FUNC\n (member_expression property: (property_identifier) @SQL_FUNC)\n ]\n arguments: (arguments\n (template_string (template_substitution) @INTERPOLATION))\n (#match? @SQL_FUNC \"^(query|execute|exec|run)$\"))",
"metavars": [
"SQL_FUNC",
"INTERPOLATION"
],
"defect_class": "injection",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\sql-injection.yml"
},
{
"id": "switch-case-termination",
"name": "Switch Cases Should End With Terminating Statement",
"severity": "error",
"language": "typescript",
"message": "Switch case should end with break, return, throw, or continue",
"query": " (switch_statement\n body: (switch_body\n (switch_case\n consequence: (statement_block\n (expression_statement) @LAST))\n (switch_case) @NEXT))",
"metavars": [
"LAST",
"NEXT"
],
"post_filter": "no_terminating_statement",
"defect_class": "correctness",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\switch-case-termination.yml"
},
{
"id": "switch-non-case-labels-ts",
"name": "Switch Should Not Contain Non-Case Labels",
"severity": "error",
"language": "typescript",
"message": "switch statements should not contain non-case labels",
"query": " (switch_statement\n body: (switch_body\n (switch_case\n (labeled_statement\n (statement_identifier) @LABEL) @LABELED)))",
"metavars": [
"LABEL",
"LABELED"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\switch-non-case-labels.yml"
},
{
"id": "ts-command-injection",
"name": "Command Injection Sink",
"severity": "error",
"language": "typescript",
"message": "Potential command injection sink — avoid child_process command execution with untrusted input",
"query": " [\n (call_expression\n function: (member_expression\n object: (identifier) @MOD\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS\n (#eq? @MOD \"child_process\")\n (#match? @FN \"^(exec|execSync)$\"))\n (call_expression\n function: (member_expression\n object: (member_expression\n object: (identifier) @MOD\n property: (property_identifier) @NS)\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS\n (#eq? @MOD \"child_process\")\n (#match? @FN \"^(exec|execSync)$\"))\n ]",
"metavars": [
"MOD",
"NS",
"FN",
"ARGS"
],
"post_filter": "ts_command_injection_sink",
"defect_class": "injection",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-command-injection.yml"
},
{
"id": "ts-detached-async-call",
"name": "Detached Async Call",
"severity": "warning",
"language": "typescript",
"message": "Detached async call — ensure this Promise is awaited or explicitly handled",
"query": " (expression_statement\n (call_expression\n function: [\n (identifier) @FN\n (member_expression\n property: (property_identifier) @FN)\n ]\n arguments: (arguments) @ARGS)\n (#match? @FN \"(Async$|fetch$|request$)\"))",
"metavars": [
"FN",
"ARGS"
],
"post_filter": "ts_detached_async_call",
"defect_class": "async-misuse",
"inline_tier": "warning",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-detached-async-call.yml"
},
{
"id": "ts-dynamic-require",
"name": "Dynamic Require Injection",
"severity": "error",
"language": "typescript",
"message": "Dynamic require() — non-literal argument allows loading arbitrary modules",
"query": " (call_expression\n function: (identifier) @FN\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @ARG)\n (#eq? @FN \"require\"))",
"metavars": [
"FN",
"ARG"
],
"defect_class": "injection",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-dynamic-require.yml"
},
{
"id": "ts-hallucinated-react-import",
"name": "Hallucinated React Import",
"severity": "error",
"language": "typescript",
"message": "'{NAME}' is a Next.js API, not from 'react' — import from 'next/{CORRECT}' instead",
"query": " (import_statement\n (import_clause\n (named_imports\n (import_specifier\n name: (identifier) @NAME)))\n source: (string) @SRC)\n (#match? @SRC \"^['\\\"]react['\\\"]$\")\n (#match? @NAME \"^(useRouter|usePathname|useSearchParams|useParams|Link|Image|Script|Head|getServerSideProps|getStaticProps|getStaticPaths|NextPage|NextApiRequest|NextApiResponse|GetServerSideProps|GetStaticProps|GetStaticPaths|notFound|redirect|permanentRedirect)$\")",
"metavars": [
"NAME",
"SRC"
],
"post_filter": "match_captures",
"post_filter_params": {
"SRC": "^['\\\"]react['\\\"]$",
"NAME": "^(useRouter|usePathname|useSearchParams|useParams|Link|Image|Script|Head|getServerSideProps|getStaticProps|getStaticPaths|NextPage|NextApiRequest|NextApiResponse|GetServerSideProps|GetStaticProps|GetStaticPaths|notFound|redirect|permanentRedirect)$"
},
"defect_class": "hallucination",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-hallucinated-react-import.yml"
},
{
"id": "ts-insecure-random",
"name": "Insecure Randomness",
"severity": "warning",
"language": "typescript",
"message": "Insecure randomness source detected — use crypto.getRandomValues or secure RNG APIs",
"query": " (variable_declarator\n name: (identifier) @VAR\n value: (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS)\n (#eq? @OBJ \"Math\")\n (#eq? @FN \"random\")\n (#match? @VAR \"(?i)(token|secret|password|key|nonce|salt|csrf|auth|session|credential|hash|otp|pin)\"))",
"metavars": [
"OBJ",
"FN",
"ARGS",
"VAR"
],
"post_filter": "ts_insecure_random_source",
"defect_class": "injection",
"inline_tier": "warning",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-insecure-random.yml"
},
{
"id": "ts-nosql-injection",
"name": "NoSQL Injection",
"severity": "error",
"language": "typescript",
"message": "NoSQL injection — $where executes JavaScript server-side and must never be used with user input",
"query": " (pair\n key: [(property_identifier) (string)] @KEY\n (#match? @KEY \"\\\\$where\"))",
"metavars": [
"KEY"
],
"defect_class": "injection",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-nosql-injection.yml"
},
{
"id": "ts-open-redirect",
"name": "Open Redirect",
"severity": "error",
"language": "typescript",
"message": "Open redirect — unvalidated URL in redirect/location lets attackers send users to malicious sites",
"query": " [\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (identifier) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (member_expression) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (call_expression) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n ]\n [\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (identifier) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (member_expression) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (call_expression) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n ]",
"metavars": [
"OBJ",
"FN",
"URL",
"WIN",
"LOC",
"PROP",
"VALUE"
],
"defect_class": "injection",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-open-redirect.yml"
},
{
"id": "ts-react-antipatterns",
"name": "React Anti-Pattern",
"severity": "warning",
"language": "typescript",
"message": "React anti-pattern: setState inside a loop causes multiple re-renders — batch with a single state update",
"query": " [\n (for_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n (for_in_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n (while_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n ]",
"metavars": [
"BODY"
],
"defect_class": "logic-error",
"inline_tier": "warning",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-react-antipatterns.yml"
},
{
"id": "ts-ssrf",
"name": "SSRF Risk",
"severity": "error",
"language": "typescript",
"message": "Potential SSRF sink — validate and allowlist outbound URLs",
"query": " [\n (call_expression\n function: (identifier) @FN\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @URL)\n (#match? @FN \"^(fetch|get|post|put|patch|delete|request)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @URL)\n (#match? @FN \"^(fetch|get|post|put|patch|delete|request)$\"))\n ]",
"metavars": [
"OBJ",
"FN",
"URL"
],
"post_filter": "ts_ssrf_sink",
"defect_class": "injection",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-ssrf.yml"
},
{
"id": "ts-weak-hash",
"name": "Weak Hash Primitive",
"severity": "error",
"language": "typescript",
"message": "Weak hash primitive selected (md5/sha1) — use sha256+ for security-sensitive contexts",
"query": " (call_expression\n function: (member_expression\n property: (property_identifier) @FN)\n arguments: (arguments\n (string (string_fragment) @ALG)\n (_)*)\n (#eq? @FN \"createHash\")\n (#match? @ALG \"^(md5|sha1)$\"))",
"metavars": [
"FN",
"ALG"
],
"post_filter": "ts_weak_hash_algorithm",
"defect_class": "injection",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-weak-hash.yml"
},
{
"id": "ts-xss-dom-sink",
"name": "XSS DOM Sink",
"severity": "error",
"language": "typescript",
"message": "XSS risk — dynamic value written to innerHTML/outerHTML or document.write()",
"query": " [\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (identifier) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (member_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (call_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (await_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n ]\n [\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (identifier) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (member_expression) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (call_expression) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n ]",
"metavars": [
"PROP",
"VALUE",
"OBJ",
"FN",
"ARG"
],
"defect_class": "injection",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-xss-dom-sink.yml"
},
{
"id": "unsafe-regex",
"name": "Dynamic Regex Construction",
"severity": "error",
"language": "typescript",
"message": "Dynamic regex from user input — can cause ReDoS (Regular Expression Denial of Service)",
"query": " (new_expression\n constructor: (identifier) @CTOR\n (#eq? @CTOR \"RegExp\")\n arguments: (arguments\n (template_string\n (template_substitution) @INTERPOLATION) @PATTERN)\n (#not-match? @INTERPOLATION \"escape|Escape|replace\"))",
"metavars": [
"CTOR",
"INTERPOLATION",
"PATTERN"
],
"defect_class": "injection",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\unsafe-regex.yml"
},
{
"id": "variable-shadowing",
"name": "Variable Shadowing",
"severity": "warning",
"language": "typescript",
"message": "Variable '{{NAME}}' shadows a parameter — use a distinct name",
"query": " (function_declaration\n parameters: (formal_parameters\n (required_parameter\n pattern: (identifier) @PARAM))\n body: (statement_block\n (lexical_declaration\n (variable_declarator\n name: (identifier) @NAME))))",
"metavars": [
"PARAM",
"NAME"
],
"post_filter": "name_matches_param",
"defect_class": "safety",
"inline_tier": "review",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\variable-shadowing.yml"
}
]
}
+1
View File
@@ -0,0 +1 @@
{"version":"v2","builtAt":"2026-06-08T22:51:50.327Z","signature":"D:/Documents/Github/PiAgentUI/PiAgentUi/e2e/fixtures/fake-pi.mjs:5177:1780959128927.0708|D:/Documents/Github/PiAgentUI/PiAgentUi/e2e/specs/first-run-onboarding.mjs:3220:1780959172642.79|D:/Documents/Github/PiAgentUI/PiAgentUi/e2e/wdio.conf.mjs:7234:1780889535416.0728|D:/Documents/Github/PiAgentUI/PiAgentUi/eslint.config.js:1486:1780022589270.6587|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-extension-compat.test.ts:1361:1780628367139.8762|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-extension-compat.ts:2316:1780628478625.5674|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-extension-manager.test.ts:5723:1780628534167.7266|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-extension-manager.ts:16702:1780628657461.116|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-git-manager.test.ts:4040:1780266481529.727|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-git-manager.ts:7293:1780266481600.4917|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server-core.test.ts:7100:1780628794440.1443|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server-core.ts:6021:1780628794332.2554|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.test.ts:50701:1780936110530.8264|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts:94808:1780936726384.9539|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-settings-store.test.ts:3647:1780637926714.339|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-settings-store.ts:6920:1780636386097.4553|D:/Documents/Github/PiAgentUI/PiAgentUi/public/notification-sw.js:1287:1780022589275.1758|D:/Documents/Github/PiAgentUI/PiAgentUi/scripts/bump-version.mjs:7766:1780022589276.6814|D:/Documents/Github/PiAgentUI/PiAgentUi/scripts/copy-material-icons.mjs:2053:1780022589276.6814|D:/Documents/Github/PiAgentUI/PiAgentUi/scripts/prepare-release.mjs:2730:1780022589276.6814|D:/Documents/Github/PiAgentUI/PiAgentUi/src-router/src/caddy.rs:2091:1780022589277.6882|D:/Documents/Github/PiAgentUI/PiAgentUi/src-router/src/config.rs:3669:1780022589278.6926|D:/Documents/Github/PiAgentUI/PiAgentUi/src-router/src/main.rs:796:1780022589278.6926|D:/Documents/Github/PiAgentUI/PiAgentUi/src-router/src/router.rs:12128:1780022589278.6926|D:/Documents/Github/PiAgentUI/PiAgentUi/src-router/src/scanner.rs:2323:1780022589279.694|D:/Documents/Github/PiAgentUI/PiAgentUi/src-router/src/state.rs:2417:1780022589279.694|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/build.rs:40:1780022589282.198|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/bridge/args.rs:1765:1780022589313.2903|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/bridge/event.rs:558:1780022589313.2903|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/bridge/mod.rs:197:1780022589313.2903|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/bridge/state.rs:4232:1780022589313.2903|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/commands/bridge.rs:13976:1780636686619.783|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/commands/mod.rs:124:1780027014784.0298|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/commands/pi_agent.rs:10771:1780958915517.23|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/commands/utils.rs:1112:1780636752037.048|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/dir_state.rs:773:1780022589315.2903|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/mod.rs:11205:1780872282800.2373|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/service.rs:500:1780027024480.5833|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/lib.rs:100:1780022589315.2903|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/main.rs:193:1780022589316.2898|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/agent.ts:929:1780026517593.7356|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/client.test.ts:1876:1780180406861.2058|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/client.ts:3424:1780889033052.932|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/command.test.ts:2375:1780194572795.3748|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/command.ts:3632:1780194752659.6665|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/config.ts:1170:1780936364070.6863|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/events.test.ts:8606:1780119380006.358|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/events.ts:27784:1780121498237.752|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/extension.test.ts:1680:1780258114988.9187|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/extension.ts:1197:1780258069136.5046|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/file.ts:4453:1780026718153.368|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/global.ts:981:1780026539178.5913|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/http.test.ts:1443:1780022589319.4368|D:/Documents/Github/PiAgentUI/PiAgentUi/Line truncated
+38
View File
@@ -0,0 +1,38 @@
{
"version": "v3",
"timestamp": 1780873322317,
"ruleHash": "4010f87c1d6dc673",
"queries": [
{
"id": "dangerously-set-inner-html",
"name": "Dangerously Set Inner HTML",
"severity": "error",
"language": "tsx",
"message": "dangerouslySetInnerHTML — XSS risk, sanitize user input",
"query": " (jsx_attribute\n (property_identifier) @ATTR\n (#match? @ATTR \"dangerouslySetInnerHTML\"))",
"metavars": [
"ATTR"
],
"defect_class": "injection",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\tsx\\dangerously-set-inner-html.yml"
},
{
"id": "no-nested-links",
"name": "Nested anchor tags",
"severity": "error",
"language": "tsx",
"message": "Nested <a> tags are invalid HTML and cause unexpected behavior",
"query": " (jsx_element\n open_tag: (jsx_opening_element\n (identifier) @OUTER\n (#eq? @OUTER \"a\"))\n (jsx_element\n open_tag: (jsx_opening_element\n (identifier) @INNER\n (#eq? @INNER \"a\"))))",
"metavars": [
"OUTER",
"INNER"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\tsx\\no-nested-links.yml"
}
]
}
+534
View File
@@ -0,0 +1,534 @@
{
"version": "v3",
"timestamp": 1780873466374,
"ruleHash": "7d4e0fbb390a415a",
"queries": [
{
"id": "console-statement",
"name": "Console Statement",
"severity": "warning",
"language": "typescript",
"message": "{{METHOD}} — remove debug statements before committing",
"query": " (call_expression\n function: (member_expression\n object: (identifier) @OBJ (#eq? @OBJ \"console\")\n property: (property_identifier) @METHOD (#not-eq? @METHOD \"dbg\"))\n arguments: (arguments) @ARGS)",
"metavars": [
"OBJ",
"METHOD",
"ARGS"
],
"post_filter": "not_in_test_block # skip test blocks — no-console-in-tests handles that case",
"defect_class": "safety",
"inline_tier": "warning",
"has_fix": true,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\console-statement.yml"
},
{
"id": "debugger-statement",
"name": "Debugger Statement",
"severity": "error",
"language": "typescript",
"message": "Debugger statement — remove before committing",
"query": " (debugger_statement) @DEBUGGER",
"metavars": [
"DEBUGGER"
],
"defect_class": "safety",
"inline_tier": "blocking",
"has_fix": true,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\debugger.yml"
},
{
"id": "deep-nesting",
"name": "Deep Nesting",
"severity": "warning",
"language": "typescript",
"message": "Deep nesting (3+ levels) — consider early returns or extract functions",
"query": " [\n ;; Pattern 1: if inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement) @IF_NESTED)))))\n\n ;; Pattern 2: for inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (for_statement) @FOR_NESTED)))))\n\n ;; Pattern 3: while inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (while_statement) @WHILE_NESTED)))))\n\n ;; Pattern 4: try inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (try_statement) @TRY_NESTED)))))\n\n ;; Pattern 5: if inside for inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (for_statement\n body: (statement_block\n (if_statement) @IF_IN_FOR)))))\n\n ;; Pattern 6: if inside while inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (while_statement\n body: (statement_block\n (if_statement) @IF_IN_WHILE)))))\n\n ;; Pattern 7: for inside for inside for\n (statement_block\n (for_statement\n body: (statement_block\n (for_statement\n body: (statement_block\n (for_statement) @FOR_NESTED)))))\n ]",
"metavars": [
"IF_NESTED",
"FOR_NESTED",
"WHILE_NESTED",
"TRY_NESTED",
"IF_IN_FOR",
"IF_IN_WHILE"
],
"defect_class": "safety",
"inline_tier": "review",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\deep-nesting.yml"
},
{
"id": "deep-promise-chain",
"name": "Deep Promise Chain (4+ levels)",
"severity": "warning",
"language": "typescript",
"message": "Promise chain {{M1}} → {{M2}} → {{M3}} → {{M4}} — consider async/await",
"query": " (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n property: (property_identifier) @M1)\n arguments: (arguments))\n property: (property_identifier) @M2)\n arguments: (arguments))\n property: (property_identifier) @M3)\n arguments: (arguments))\n property: (property_identifier) @M4)\n arguments: (arguments)\n (#match? @M1 \"^(then|catch|finally)$\")\n (#match? @M2 \"^(then|catch|finally)$\")\n (#match? @M3 \"^(then|catch|finally)$\")\n (#match? @M4 \"^(then|catch|finally)$\"))",
"metavars": [
"M1",
"M2",
"M3",
"M4"
],
"defect_class": "async-misuse",
"inline_tier": "warning",
"has_fix": true,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\deep-promise-chain.yml"
},
{
"id": "default-not-last",
"name": "Default Clauses Should Be Last",
"severity": "error",
"language": "typescript",
"message": "default clause should be the last case",
"query": " (switch_statement\n body: (switch_body\n (switch_default) @DEFAULT\n (switch_case) @AFTER_CASE))",
"metavars": [
"DEFAULT",
"AFTER_CASE"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\default-not-last.yml"
},
{
"id": "duplicate-function-arg",
"name": "Function Argument Names Should Be Unique",
"severity": "error",
"language": "typescript",
"message": "Duplicate parameter name '{{NAME}}'",
"query": " (function_declaration\n parameters: (formal_parameters\n (identifier) @PARAM1\n (identifier) @PARAM2))\n (arrow_function\n parameters: (formal_parameters\n (identifier) @PARAM1\n (identifier) @PARAM2))",
"metavars": [
"PARAM1",
"PARAM2"
],
"post_filter": "same_param_name",
"defect_class": "correctness",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\duplicate-function-arg.yml"
},
{
"id": "empty-switch-case",
"name": "Switch Cases Should Not Be Empty",
"severity": "error",
"language": "typescript",
"message": "Switch case should not be empty",
"query": " (switch_statement\n body: (switch_body\n (switch_case\n consequence: (statement_block) @BLOCK)))",
"metavars": [
"BLOCK"
],
"post_filter": "is_empty_block",
"defect_class": "correctness",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\empty-switch-case.yml"
},
{
"id": "no-eval",
"name": "Eval Usage",
"severity": "error",
"language": "typescript",
"message": "eval() detected — security risk, never use eval",
"query": " (call_expression\n function: (identifier) @FUNC\n (#eq? @FUNC \"eval\")\n arguments: (arguments) @ARGS)",
"metavars": [
"FUNC",
"ARGS"
],
"defect_class": "injection",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\eval.yml"
},
{
"id": "ts-incomplete-assertion",
"name": "Incomplete Test Assertion",
"severity": "error",
"language": "typescript",
"message": "Incomplete assertion — expect() chain is not called",
"query": " (call_expression\n function: (identifier) @EXPECT\n (#eq? @EXPECT \"expect\")\n arguments: (arguments)) @EXPR",
"metavars": [
"EXPECT",
"EXPR"
],
"post_filter": "incomplete_assertion",
"defect_class": "correctness",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\incomplete-assertion.yml"
},
{
"id": "infinite-loop",
"name": "Loops Should Not Be Infinite",
"severity": "error",
"language": "typescript",
"message": "Loop appears to be infinite with no termination condition",
"query": " (while_statement\n condition: (true)\n body: (statement_block) @BODY)\n (for_statement\n condition: (null)\n body: (statement_block) @BODY)",
"metavars": [
"BODY"
],
"post_filter": "no_break_or_return_in_body",
"defect_class": "correctness",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\infinite-loop.yml"
},
{
"id": "mixed-async-styles",
"name": "Mixed Async/Await and Promise Chains",
"severity": "warning",
"language": "typescript",
"message": "Mixed async/await + promise chains — use consistent async style",
"query": " (function_declaration\n (async_modifier)\n body: (statement_block) @BODY)\n\n# Post-filter: Check if body contains both await and .then()",
"metavars": [
"BODY"
],
"post_filter": "has_mixed_async",
"defect_class": "async-misuse",
"inline_tier": "warning",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\mixed-async-styles.yml"
},
{
"id": "no-console-in-tests",
"name": "Console Statement in Test",
"severity": "warning",
"language": "typescript",
"message": "console.{{METHOD}} in test block — use proper assertions or logging",
"query": " (call_expression\n function: (member_expression\n object: (identifier) @OBJ (#eq? @OBJ \"console\")\n property: (property_identifier) @METHOD)\n arguments: (arguments) @ARGS)",
"metavars": [
"OBJ",
"METHOD",
"ARGS"
],
"post_filter": "in_test_block",
"defect_class": "safety",
"inline_tier": "warning",
"has_fix": true,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\no-console-in-tests.yml"
},
{
"id": "no-equality-in-for-condition",
"name": "Loose equality in for-loop condition",
"severity": "warning",
"language": "typescript",
"message": "for-loop uses ==/!= for termination — prefer </<=/>/>= so stepping past the bound still exits",
"query": " (for_statement\n (expression_statement\n (binary_expression\n [\"==\" \"!=\"] @OP)))",
"metavars": [
"OP"
],
"inline_tier": "warning",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\no-equality-in-for-condition.yml"
},
{
"id": "no-jump-in-finally",
"name": "Control-flow jump in finally block",
"severity": "warning",
"language": "typescript",
"message": "return/break/continue/throw in finally overrides the try/catch result and silently swallows exceptions",
"query": " (finally_clause\n (statement_block\n [\n (return_statement)\n (break_statement)\n (continue_statement)\n (throw_statement)\n ] @JUMP))",
"metavars": [
"JUMP"
],
"inline_tier": "warning",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\no-jump-in-finally.yml"
},
{
"id": "self-assignment",
"name": "Variables Should Not Be Self-Assigned",
"severity": "error",
"language": "typescript",
"message": "'{{VAR}}' is assigned to itself",
"query": " (assignment_expression\n left: (identifier) @VAR\n right: (identifier) @SAME\n (#eq? @VAR @SAME))",
"metavars": [
"VAR",
"SAME"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\self-assignment.yml"
},
{
"id": "sql-injection",
"name": "SQL Injection Risk",
"severity": "error",
"language": "typescript",
"message": "SQL injection risk — use parameterized queries, never interpolate into SQL",
"query": " (call_expression\n function: [\n (identifier) @SQL_FUNC\n (member_expression property: (property_identifier) @SQL_FUNC)\n ]\n arguments: (arguments\n (template_string (template_substitution) @INTERPOLATION))\n (#match? @SQL_FUNC \"^(query|execute|exec|run)$\"))",
"metavars": [
"SQL_FUNC",
"INTERPOLATION"
],
"defect_class": "injection",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\sql-injection.yml"
},
{
"id": "switch-case-termination",
"name": "Switch Cases Should End With Terminating Statement",
"severity": "error",
"language": "typescript",
"message": "Switch case should end with break, return, throw, or continue",
"query": " (switch_statement\n body: (switch_body\n (switch_case\n consequence: (statement_block\n (expression_statement) @LAST))\n (switch_case) @NEXT))",
"metavars": [
"LAST",
"NEXT"
],
"post_filter": "no_terminating_statement",
"defect_class": "correctness",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\switch-case-termination.yml"
},
{
"id": "switch-non-case-labels-ts",
"name": "Switch Should Not Contain Non-Case Labels",
"severity": "error",
"language": "typescript",
"message": "switch statements should not contain non-case labels",
"query": " (switch_statement\n body: (switch_body\n (switch_case\n (labeled_statement\n (statement_identifier) @LABEL) @LABELED)))",
"metavars": [
"LABEL",
"LABELED"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\switch-non-case-labels.yml"
},
{
"id": "ts-command-injection",
"name": "Command Injection Sink",
"severity": "error",
"language": "typescript",
"message": "Potential command injection sink — avoid child_process command execution with untrusted input",
"query": " [\n (call_expression\n function: (member_expression\n object: (identifier) @MOD\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS\n (#eq? @MOD \"child_process\")\n (#match? @FN \"^(exec|execSync)$\"))\n (call_expression\n function: (member_expression\n object: (member_expression\n object: (identifier) @MOD\n property: (property_identifier) @NS)\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS\n (#eq? @MOD \"child_process\")\n (#match? @FN \"^(exec|execSync)$\"))\n ]",
"metavars": [
"MOD",
"NS",
"FN",
"ARGS"
],
"post_filter": "ts_command_injection_sink",
"defect_class": "injection",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-command-injection.yml"
},
{
"id": "ts-detached-async-call",
"name": "Detached Async Call",
"severity": "warning",
"language": "typescript",
"message": "Detached async call — ensure this Promise is awaited or explicitly handled",
"query": " (expression_statement\n (call_expression\n function: [\n (identifier) @FN\n (member_expression\n property: (property_identifier) @FN)\n ]\n arguments: (arguments) @ARGS)\n (#match? @FN \"(Async$|fetch$|request$)\"))",
"metavars": [
"FN",
"ARGS"
],
"post_filter": "ts_detached_async_call",
"defect_class": "async-misuse",
"inline_tier": "warning",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-detached-async-call.yml"
},
{
"id": "ts-dynamic-require",
"name": "Dynamic Require Injection",
"severity": "error",
"language": "typescript",
"message": "Dynamic require() — non-literal argument allows loading arbitrary modules",
"query": " (call_expression\n function: (identifier) @FN\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @ARG)\n (#eq? @FN \"require\"))",
"metavars": [
"FN",
"ARG"
],
"defect_class": "injection",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-dynamic-require.yml"
},
{
"id": "ts-hallucinated-react-import",
"name": "Hallucinated React Import",
"severity": "error",
"language": "typescript",
"message": "'{NAME}' is a Next.js API, not from 'react' — import from 'next/{CORRECT}' instead",
"query": " (import_statement\n (import_clause\n (named_imports\n (import_specifier\n name: (identifier) @NAME)))\n source: (string) @SRC)\n (#match? @SRC \"^['\\\"]react['\\\"]$\")\n (#match? @NAME \"^(useRouter|usePathname|useSearchParams|useParams|Link|Image|Script|Head|getServerSideProps|getStaticProps|getStaticPaths|NextPage|NextApiRequest|NextApiResponse|GetServerSideProps|GetStaticProps|GetStaticPaths|notFound|redirect|permanentRedirect)$\")",
"metavars": [
"NAME",
"SRC"
],
"post_filter": "match_captures",
"post_filter_params": {
"SRC": "^['\\\"]react['\\\"]$",
"NAME": "^(useRouter|usePathname|useSearchParams|useParams|Link|Image|Script|Head|getServerSideProps|getStaticProps|getStaticPaths|NextPage|NextApiRequest|NextApiResponse|GetServerSideProps|GetStaticProps|GetStaticPaths|notFound|redirect|permanentRedirect)$"
},
"defect_class": "hallucination",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-hallucinated-react-import.yml"
},
{
"id": "ts-insecure-random",
"name": "Insecure Randomness",
"severity": "warning",
"language": "typescript",
"message": "Insecure randomness source detected — use crypto.getRandomValues or secure RNG APIs",
"query": " (variable_declarator\n name: (identifier) @VAR\n value: (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS)\n (#eq? @OBJ \"Math\")\n (#eq? @FN \"random\")\n (#match? @VAR \"(?i)(token|secret|password|key|nonce|salt|csrf|auth|session|credential|hash|otp|pin)\"))",
"metavars": [
"OBJ",
"FN",
"ARGS",
"VAR"
],
"post_filter": "ts_insecure_random_source",
"defect_class": "injection",
"inline_tier": "warning",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-insecure-random.yml"
},
{
"id": "ts-nosql-injection",
"name": "NoSQL Injection",
"severity": "error",
"language": "typescript",
"message": "NoSQL injection — $where executes JavaScript server-side and must never be used with user input",
"query": " (pair\n key: [(property_identifier) (string)] @KEY\n (#match? @KEY \"\\\\$where\"))",
"metavars": [
"KEY"
],
"defect_class": "injection",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-nosql-injection.yml"
},
{
"id": "ts-open-redirect",
"name": "Open Redirect",
"severity": "error",
"language": "typescript",
"message": "Open redirect — unvalidated URL in redirect/location lets attackers send users to malicious sites",
"query": " [\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (identifier) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (member_expression) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (call_expression) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n ]\n [\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (identifier) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (member_expression) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (call_expression) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n ]",
"metavars": [
"OBJ",
"FN",
"URL",
"WIN",
"LOC",
"PROP",
"VALUE"
],
"defect_class": "injection",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-open-redirect.yml"
},
{
"id": "ts-react-antipatterns",
"name": "React Anti-Pattern",
"severity": "warning",
"language": "typescript",
"message": "React anti-pattern: setState inside a loop causes multiple re-renders — batch with a single state update",
"query": " [\n (for_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n (for_in_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n (while_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n ]",
"metavars": [
"BODY"
],
"defect_class": "logic-error",
"inline_tier": "warning",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-react-antipatterns.yml"
},
{
"id": "ts-ssrf",
"name": "SSRF Risk",
"severity": "error",
"language": "typescript",
"message": "Potential SSRF sink — validate and allowlist outbound URLs",
"query": " [\n (call_expression\n function: (identifier) @FN\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @URL)\n (#match? @FN \"^(fetch|get|post|put|patch|delete|request)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @URL)\n (#match? @FN \"^(fetch|get|post|put|patch|delete|request)$\"))\n ]",
"metavars": [
"OBJ",
"FN",
"URL"
],
"post_filter": "ts_ssrf_sink",
"defect_class": "injection",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-ssrf.yml"
},
{
"id": "ts-weak-hash",
"name": "Weak Hash Primitive",
"severity": "error",
"language": "typescript",
"message": "Weak hash primitive selected (md5/sha1) — use sha256+ for security-sensitive contexts",
"query": " (call_expression\n function: (member_expression\n property: (property_identifier) @FN)\n arguments: (arguments\n (string (string_fragment) @ALG)\n (_)*)\n (#eq? @FN \"createHash\")\n (#match? @ALG \"^(md5|sha1)$\"))",
"metavars": [
"FN",
"ALG"
],
"post_filter": "ts_weak_hash_algorithm",
"defect_class": "injection",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-weak-hash.yml"
},
{
"id": "ts-xss-dom-sink",
"name": "XSS DOM Sink",
"severity": "error",
"language": "typescript",
"message": "XSS risk — dynamic value written to innerHTML/outerHTML or document.write()",
"query": " [\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (identifier) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (member_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (call_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (await_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n ]\n [\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (identifier) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (member_expression) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (call_expression) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n ]",
"metavars": [
"PROP",
"VALUE",
"OBJ",
"FN",
"ARG"
],
"defect_class": "injection",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-xss-dom-sink.yml"
},
{
"id": "unsafe-regex",
"name": "Dynamic Regex Construction",
"severity": "error",
"language": "typescript",
"message": "Dynamic regex from user input — can cause ReDoS (Regular Expression Denial of Service)",
"query": " (new_expression\n constructor: (identifier) @CTOR\n (#eq? @CTOR \"RegExp\")\n arguments: (arguments\n (template_string\n (template_substitution) @INTERPOLATION) @PATTERN)\n (#not-match? @INTERPOLATION \"escape|Escape|replace\"))",
"metavars": [
"CTOR",
"INTERPOLATION",
"PATTERN"
],
"defect_class": "injection",
"inline_tier": "blocking",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\unsafe-regex.yml"
},
{
"id": "variable-shadowing",
"name": "Variable Shadowing",
"severity": "warning",
"language": "typescript",
"message": "Variable '{{NAME}}' shadows a parameter — use a distinct name",
"query": " (function_declaration\n parameters: (formal_parameters\n (required_parameter\n pattern: (identifier) @PARAM))\n body: (statement_block\n (lexical_declaration\n (variable_declarator\n name: (identifier) @NAME))))",
"metavars": [
"PARAM",
"NAME"
],
"post_filter": "name_matches_param",
"defect_class": "safety",
"inline_tier": "review",
"has_fix": false,
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\variable-shadowing.yml"
}
]
}
+148
View File
@@ -0,0 +1,148 @@
{"timestamp":"2026-06-02T20:16:59.879Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":577,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-02T20:16:59.879Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":585,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-02T20:16:59.879Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":604,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-02T20:16:59.879Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":669,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-02T20:16:59.879Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2217,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-02T20:16:59.879Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2295,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-02T20:16:59.879Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2309,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-02T20:16:59.879Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2311,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-02T20:16:59.879Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2315,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-03T00:19:38.634Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":565,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-03T00:19:38.634Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":570,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-03T00:19:38.634Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":606,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-03T00:19:38.634Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":614,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-03T00:20:33.484Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":565,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-03T00:20:33.484Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":570,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-03T00:20:33.484Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":606,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-03T00:20:33.484Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":614,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-03T00:21:42.835Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":566,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-03T00:21:42.835Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":571,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-03T00:21:42.835Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":607,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-03T00:21:42.835Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":615,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-03T00:22:30.773Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":566,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-03T00:22:30.773Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":571,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-03T00:22:30.773Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":607,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-03T00:22:30.773Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":615,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-03T00:23:30.828Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":566,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-03T00:23:30.828Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":571,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-03T00:23:30.828Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":607,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-03T00:23:30.828Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":615,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:36:17.248Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":583,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:36:17.248Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":588,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:36:17.248Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":625,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:36:17.248Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":633,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:40:07.667Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":583,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:40:07.667Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":588,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:40:07.667Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":625,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:40:07.667Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":633,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:40:54.308Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":602,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:40:54.308Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":609,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:40:54.308Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":633,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:40:54.308Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":640,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:41:29.381Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":602,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:41:29.381Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":609,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:41:29.381Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":633,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:41:29.381Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":640,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:42:35.724Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":602,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:42:35.724Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":609,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:42:35.724Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":633,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:42:35.724Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":640,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:51:18.614Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":577,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:51:18.614Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":585,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:51:18.614Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":604,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:51:18.614Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":669,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:51:18.614Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2236,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:51:18.614Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2314,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:51:18.614Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2328,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:51:18.614Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2330,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:51:18.614Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2334,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:54:51.209Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":577,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:54:51.209Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":585,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:54:51.209Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":604,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:54:51.209Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":669,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:54:51.209Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2236,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:54:51.209Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2314,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:54:51.209Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2328,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:54:51.209Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2330,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:54:51.209Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2334,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:55:09.415Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":578,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:55:09.415Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":586,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:55:09.415Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":605,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:55:09.415Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":670,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:55:09.415Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2227,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:55:09.415Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2305,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:55:09.415Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2319,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:55:09.415Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2321,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:55:09.415Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2325,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:55:51.490Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":578,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:55:51.490Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":586,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:55:51.490Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":605,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:55:51.490Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":670,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:55:51.490Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2227,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:55:51.490Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2305,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:55:51.490Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2319,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:55:51.490Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2321,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:55:51.490Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2325,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:56:32.988Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":578,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:56:32.988Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":586,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:56:32.988Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":605,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:56:32.988Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":670,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:56:32.988Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2227,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:56:32.988Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2305,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:56:32.988Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2319,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:56:32.988Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2321,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T02:56:32.988Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2325,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:14:10.377Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":579,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:14:10.377Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":587,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:14:10.377Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":606,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:14:10.377Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":671,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:14:10.377Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2228,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:14:10.377Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2306,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:14:10.377Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2320,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:14:10.377Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2322,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:14:10.377Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2326,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:15:28.366Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":579,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:15:28.366Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":587,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:15:28.366Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":606,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:15:28.366Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":671,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:15:28.366Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2228,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:15:28.366Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2306,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:15:28.366Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2319,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:15:28.366Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2321,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:15:28.366Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2325,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:27:54.135Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":579,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:27:54.135Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":587,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:27:54.135Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":606,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:27:54.135Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":671,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:27:54.135Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2270,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:27:54.135Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2348,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:27:54.135Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2361,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:27:54.135Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2363,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:27:54.135Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2367,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:30:37.314Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":579,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:30:37.314Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":587,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:30:37.314Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":606,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:30:37.314Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":671,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:30:37.314Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2319,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:30:37.314Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2397,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:30:37.314Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2410,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:30:37.314Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2412,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-05T05:30:37.314Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2416,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-08T03:23:13.134Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":579,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-08T03:23:13.134Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":587,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-08T03:23:13.134Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":606,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-08T03:23:13.134Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":671,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-08T03:23:13.134Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2303,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-08T03:23:13.134Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2381,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-08T03:23:13.134Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2394,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-08T03:23:13.134Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2396,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-08T03:23:13.134Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2400,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-08T16:27:31.533Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":579,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-08T16:27:31.533Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":587,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-08T16:27:31.533Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":606,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-08T16:27:31.533Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":671,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-08T16:27:31.533Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2299,"column":8,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-08T16:27:31.533Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2377,"column":10,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-08T16:27:31.533Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2390,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-08T16:27:31.533Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2392,"column":12,"fixable":true,"autoFixed":false}
{"timestamp":"2026-06-08T16:27:31.533Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2396,"column":10,"fixable":true,"autoFixed":false}
+6 -1
View File
@@ -1,5 +1,10 @@
# Changelog
## [Unreleased]
- feat: add optimistic fast-session creation and runtime capability metadata
- feat: add Pi/OpenCode extension compatibility descriptors and badges
## [v0.6.10] - 2026-05-17
- fix: refresh sidebar child session links (a49908a)
@@ -211,7 +216,7 @@
## [v0.5.5] - 2026-04-18
- fix: remove redundant *Single i18n keys — let i18next handle count=1 natively (9385ef8)
- fix: remove redundant \*Single i18n keys — let i18next handle count=1 natively (9385ef8)
- style: match pane drop highlight radius to pane shell (rounded-lg) (f8acac2)
- feat: enable drag-to-split on active session list items (ac20b7b)
-2
View File
@@ -6,8 +6,6 @@
[English (US)](./README.md) | [Português (BR)](./README.pt-BR.md) | **Español** | [日本語](./README.ja.md)
> Esta traducción puede quedar por detrás de la versión de referencia, que es el [README principal](./README.md). Repositorio: https://github.com/evandrodevbr/PiAgentUI
PiAgentUI es una interfaz moderna web y de escritorio para **Pi Agent**. No es solo una capa visual de chat: el objetivo es convertirse en una aplicación completa de agentes, en la misma categoría de producto que Codex, Claude Desktop y otros entornos de desarrollo agent-first.
PiAgentUI trata el runtime real de Pi como la fuente de verdad. Sesiones, metadatos de modelos, uso de contexto, tool calls, skills, configuración MCP y slash commands vienen de Pi siempre que sea posible, en lugar de recrearse como estado falso de interfaz.
-2
View File
@@ -6,8 +6,6 @@
[English (US)](./README.md) | [Português (BR)](./README.pt-BR.md) | [Español](./README.es.md) | **日本語**
> この翻訳は基準となる [メイン README](./README.md) より古い可能性があります。リポジトリ: https://github.com/evandrodevbr/PiAgentUI
PiAgentUI は **Pi Agent** のためのモダンな Web / デスクトップ UI です。単なるチャット画面ではありません。最終的には Codex や Claude Desktop のような、エージェント中心の本格的な開発アプリになることを目指しています。
PiAgentUI は、実際の Pi runtime を信頼できる唯一の情報源として扱います。セッション、モデルメタデータ、コンテキスト使用量、tool call、skills、MCP 設定、slash command は、可能な限り Pi から取得し、UI 側だけの仮の状態として再実装しません。
+196 -159
View File
@@ -1,214 +1,251 @@
# PiAgentUI
<p align="center">
<img src="./src/assets/images/logo/PiAgentUiBlack%20%283%29.png" alt="PiAgentUI logo" width="760" />
</p>
**Desktop and web workspace for AI coding agents: real Pi Agent sessions, streaming chat, MCP and skills panels, terminal, file explorer and a Tauri desktop app.**
<h1 align="center">PiAgentUI</h1>
<p align="center">
<strong>Professional Desktop & Web UI for AI Coding Agents</strong><br/>
<em>Pi Agent · Claude Code · Codex · OpenCode · Cursor · Copilot</em>
</p>
![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178C6?logo=typescript&logoColor=white)
![React](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=white)
![Vite](https://img.shields.io/badge/Vite-8-646CFF?logo=vite&logoColor=white)
![Tauri](https://img.shields.io/badge/Tauri-2-24C8DB?logo=tauri&logoColor=white)
![Node](https://img.shields.io/badge/Node-%3E%3D22-5FA04E?logo=nodedotjs&logoColor=white)
![Licença](https://img.shields.io/badge/license-GPL--3.0--only-green)
[![Build Validation](https://github.com/evandrodevbr/PiAgentUI/actions/workflows/build.yml/badge.svg)](https://github.com/evandrodevbr/PiAgentUI/actions/workflows/build.yml)
<p align="center">
<img src="https://img.shields.io/badge/version-0.6.10-blue" alt="Version" />
<img src="https://img.shields.io/badge/license-GPL--3.0-green" alt="License" />
<img src="https://img.shields.io/badge/platform-Tauri%202%20%7C%20Web-brightgreen" alt="Platform" />
<img src="https://img.shields.io/badge/framework-React%2019%20%7C%20TypeScript-61DAFB" alt="Framework" />
</p>
**English (US)** | [Português (BR)](./README.pt-BR.md) | [Español](./README.es.md) | [日本語](./README.ja.md)
## About
## TL;DR
PiAgentUI is a chat and workspace front end for the Pi Agent runtime. Instead of a bare terminal, it gives you sessions, model switching, tool calls rendered as cards, MCP server metadata, skills grouped by source, slash commands, Git controls, a terminal, and a file explorer in one window, as a web app or as a Tauri desktop app.
PiAgentUI is a **full-featured workspace for AI coding agents** — sessions, model switching, MCP tools, skills browser, voice input, terminal, file explorer, and Tauri desktop app. Built for Pi Agent but compatible with Claude Code, Codex, OpenCode, Cursor, and any agent that speaks SSE/HTTP.
The project is Pi-first: sessions, models, context usage, tool calls, skills, MCP configuration and slash commands come from the Pi runtime whenever possible. The UI caches and enriches that data for presentation but does not invent agent state. It is a rename and rework of the OpenCodeUI base, and part of that inheritance is still visible (see [Current state and limitations](#current-state-and-limitations)).
> **Category:** AI Agent UI · Agent Desktop Client · MCP GUI · Coding Copilot Interface
> **Status:** Active development — production-grade chat, evolving fast. Expect weekly releases.
## How it works
## Software preview
These screenshots show the current PiAgentUI experience: a dark, focused agent workspace with real Pi sessions, model selection, MCP metadata, settings, tool-aware chat, and side panels.
![PiAgentUI chat workspace with MCP side panel](./src/assets/images/img1.png)
![PiAgentUI settings and server connections dialog](./src/assets/images/img2.png)
![PiAgentUI model selector inside an active agent session](./src/assets/images/img3.png)
## Quick Install
```bash
# Clone and install
git clone https://github.com/lehhair/OpenCodeUI.git
cd OpenCodeUI
npm install
# Start dev server
npm run dev
# Or build desktop app
npm run tauri dev
```
Requires **Node.js ≥ 22** and **Pi Agent** installed. The UI auto-discovers the Pi runtime via `~/.pi/agent/piagentui-port.json`.
## What PiAgentUI does today
- **Agent chat with streaming** — live assistant output through SSE, stable message reconciliation, markdown rendering, code highlighting, reasoning parts, and tool cards.
- **Real Pi sessions** — session list, active-session routing, restart-safe history loading, derived session titles, and selected-session send routing.
- **Tool output as tool cards** — tool calls/results stay attached to assistant messages instead of leaking as normal chat text.
- **Runtime context usage** — context usage comes from Pi runtime when available; unknown/compacted context is shown safely instead of `NaN`.
- **Model metadata** — Pi provider/model identity remains authoritative, with optional OpenRouter catalog metadata used only for extra details like context, output, modality, pricing, and tokenizer hints.
- **Skills panel** — real skills are parsed from the effective Pi prompt and grouped by source: global, project, package, and other.
- **MCP metadata panel** — reads configured MCP servers from local/global config files and surfaces transport, command, URL, lifecycle, direct tools, and source.
- **Pi slash commands** — `/api/commands/list` exposes Pi built-in commands plus dynamic extension, prompt, and skill commands.
- **Attachments and multimodal input** — file/image/PDF/audio/video attachment affordances follow the selected model capabilities.
- **Terminal and file tooling** — integrated terminal, file explorer, syntax highlighting, diff-oriented components, and Tauri desktop integration.
- **Keyboard-first UI** — command palette, configurable keybindings, split panes, model selector, project/session navigation, and responsive layouts.
## Product direction
PiAgentUI is moving toward a complete agent app:
1. **Visual actions for every Pi command** — slash commands remain available, but frequent actions should become buttons, menus, dialogs, or panels.
2. **Voice input and realtime transcription** — microphone recording, partial transcript deltas, final transcript insertion into the composer, and OpenAI-compatible STT provider configuration.
3. **Deeper MCP integration** — beyond metadata: connect, authenticate, inspect tools, and execute MCP workflows from the UI.
4. **Full session control** — fork, clone, tree navigation, import/export/share, branch summaries, and context management as first-class UI flows.
5. **Desktop-grade experience** — a polished Tauri app with local runtime discovery, secure settings, notifications, and richer workspace controls.
## Audio and transcription roadmap
PiAgentUI already supports attaching audio files when the selected model declares audio input support. The planned voice system adds live speech-to-text directly in the composer.
Recommended architecture:
- **Default realtime provider:** OpenAI Realtime Transcription with `gpt-realtime-whisper` for low-latency partial transcripts.
- **Default file fallback:** OpenAI Audio Transcriptions with `gpt-4o-transcribe`, `gpt-4o-mini-transcribe`, or `whisper-1`.
- **Custom providers:** user-configurable OpenAI-compatible STT APIs, such as providers that implement `POST /v1/audio/transcriptions`.
Planned UX:
- microphone button in the composer;
- recording and permission states;
- partial transcript overlay;
- final transcript inserted into the input;
- optional append/replace behavior;
- auto-send disabled by default;
- recoverable error handling for permissions, network failures, and provider errors.
Planned provider config shape:
```json
{
"kind": "openai-compatible",
"mode": "file",
"baseUrl": "https://api.example.com/v1",
"transcriptionEndpoint": "/audio/transcriptions",
"transcriptionModel": "openai/whisper-large-v3",
"language": "en"
}
```
Realtime support is treated as a separate capability because OpenAI-compatible HTTP transcription does not guarantee WebSocket compatibility.
## Architecture
```text
browser window / Tauri webview
|
| HTTP /api/* + SSE /global/event + WebSocket
v
Pi extension server (extensions/piagentui-server.ts, Node http + ws)
|-- serves the built UI from dist/ and injects window.PI_BOOT_DATA {baseUrl, token}
|-- writes connection info to ~/.pi/agent/piagentui-port.json {port, token}
|-- enforces bearer-token auth on /api/* and loopback-only access by default
|-- bridges sessions, models, tools, MCP, skills and commands
^
| loaded by the runtime
Pi Agent process
PiAgentUI
├─ React/Vite frontend
│ ├─ chat, message rendering, input, panels, settings
│ ├─ local stores for UI preferences
│ └─ SSE/API clients for PiAgentUI local endpoints
├─ Pi extension backend
│ └─ extensions/piagentui-server.ts
│ ├─ exposes local /api/* endpoints
│ ├─ bridges to Pi runtime/session/model APIs
│ ├─ streams Pi events to the browser
│ └─ reads local metadata such as MCP config and skills
└─ Pi Agent runtime
├─ sessions
├─ models/providers
├─ tools
├─ skills
├─ MCP
└─ slash commands
```
- On session start the extension listens on port `58785` (or the next free port) and publishes the selected port plus a random token in the discovery file.
- `GET /global/health` is open; every other `/api/*` route requires `Authorization: Bearer <token>`.
- The Tauri app reads the same discovery file through the `get_pi_agent_connection` Rust command, so the desktop build connects to the local runtime without manual configuration.
- `npm run dev` (Vite) is a front-end-only development server; it proxies `/api` to `127.0.0.1:4096` with the `/api` prefix stripped, so point it at a compatible server or use the extension-served build for the Pi flow.
The UI should not invent runtime state when Pi already knows the answer. PiAgentUI can cache and enrich data for UX, but Pi remains authoritative for agent behavior.
## Stack
### Fast session creation
| Area | Choice |
|---|---|
| UI | React 19, TypeScript 5.9 |
| Build | Vite 8 (rolldown), `tsc -b` as the type gate |
| Styling | Tailwind CSS 4 plus project design tokens |
| Desktop / mobile | Tauri 2 (Rust 1.85+), Android targets generated in `src-tauri/gen` |
| Markdown and code | Streamdown, Shiki, KaTeX |
| Terminal | xterm.js |
| i18n | i18next, locales `en`, `pt-BR`, `es`, `ja`, `zh-CN`, `hi`, `bn`, `ar` |
| Tests | Vitest 4, Testing Library, jsdom |
| Local backend | Pi extension: Node HTTP server plus `ws` (SSE and WebSocket) |
| Gateway (containers) | Rust router (`src-router`, axum) in front of Caddy |
| Packages | npm with `package-lock.json` |
New-session entrypoints render an optimistic local session immediately, then reconcile that temporary `optimistic-session:*` ID with the real Pi runtime session when the backend responds. This separates time-to-visible-session from backend latency: the user sees a usable chat surface synchronously, while message sending waits for the authoritative Pi session ID.
## Requirements
If runtime creation fails, PiAgentUI rolls back the optimistic session and returns control to the composer so the draft can be retried instead of disappearing into a failed route.
- Node.js `>=22` (CI runs Node 22; verified locally on Node 24.20.0)
- npm `>=10` (the repository is npm-based; `package-lock.json` is authoritative)
- Rust `1.85+` only if you build the desktop app or the gateway
- Docker only for the container stack in `docker/`
- Pi Agent installed locally to have real sessions and models; without it the UI starts in standalone mode and only shows the server connection dialog
### Extension compatibility
## Quick start
Pi packages that declare `pi.extensions` remain the supported extension path. OpenCode-style manifests are detected and labelled as partial compatibility, but PiAgentUI does not execute those entrypoints yet. Execution requires a future sandbox and permission model so untrusted extension code cannot access local files, credentials, or agent state without explicit policy.
## Tech stack
| Area | Stack |
| ------------------- | --------------------------------------------- |
| UI | React 19, TypeScript |
| Build | Vite 8 |
| Styling | Tailwind CSS v4 plus project design tokens |
| Desktop | Tauri 2 |
| Markdown | Streamdown / markdown rendering pipeline |
| Syntax highlighting | Shiki |
| Terminal | xterm.js |
| Testing | Vitest, Testing Library |
| Local backend | Pi extension, Node HTTP server, WebSocket/SSE |
## Local development
Install dependencies:
```bash
git clone https://github.com/evandrodevbr/PiAgentUI.git
cd PiAgentUI
npm ci # 615 packages, runs scripts/copy-material-icons.mjs
npm run build # tsc -b && vite build -> dist/
npm run dev # Vite dev server on http://localhost:5173
npm install
```
Web UI served by the Pi extension (this is the path that talks to a real Pi runtime):
Start the frontend dev server:
```bash
npm run build # the extension serves dist/
# with Pi Agent running this repository as a Pi package (see package.json -> "pi")
cat ~/.pi/agent/piagentui-port.json # {"port": 58785, "token": "..."}
# open http://127.0.0.1:58785
npm run dev
```
Desktop app:
Build the app:
```bash
npm run tauri dev # development window, uses the Vite dev server
npm run tauri build # bundles per src-tauri/tauri.conf.json (deb on Linux, dmg, nsis)
npm run build
```
Android (see `scripts/dev-android.sh` and the Android job in the release workflow):
Run tests:
```bash
npm run tauri android dev
npm run test:run
```
## Usage
Local endpoints exposed by the extension server. All of them, except `/global/health` and the SSE route, require the bearer token from the discovery file.
| Endpoint | Purpose |
|---|---|
| `GET /global/health` | Liveness probe, returns `{"status":"ok","pi":"ready"}` |
| `GET /global/event` | SSE event stream (sessions, message parts, status) |
| `GET /api/models` | Model list with normalized capabilities |
| `GET /api/sessions` | Session list |
| `POST /api/sessions` | Create a session |
| `POST /api/sessions/abort` | Abort the running turn |
| `GET /api/sessions/:id/messages` | Session history normalized for rendering |
| `GET /api/sessions/:id/context` | Context usage of the active session |
| `POST /api/messages/send` | Send a user message to a session |
| `GET /api/skills` | Effective skills grouped by source |
| `GET /api/mcp/status` | Configured MCP servers (transport, command, URL, source) |
| `GET /api/commands/list` | Built-in and dynamic slash commands |
| `GET /api/agents`, `GET /api/files/list`, `GET /api/permissions/list`, `GET /api/questions/list` | Runtime inventory for the panels |
| `GET /api/vcs/status`, `/info`, `/branches` | Git state used by the composer controls |
| `GET /api/extensions/list` | Extension catalog for the extensions panel |
| `GET /api/settings/stt`, `/api/settings/tts` | Voice settings (STT/TTS) |
| `GET /api/network/access`, `POST /api/network/access` | LAN access toggle (loopback-only by default) |
The full route table, including Git and extension mutations, lives in [`extensions/piagentui-server.ts`](extensions/piagentui-server.ts). Ports and paths can change; the discovery file is the source of truth for the port and token.
Auditing the agent from a shell works too, for example:
Run type checks:
```bash
TOKEN=$(node -e "console.log(require(process.env.HOME+'/.pi/agent/piagentui-port.json').token)")
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:58785/api/models | head -c 300
npm run typecheck
npm run typecheck:extensions
```
## Production and deploy
Full validation:
```bash
npm run build # tsc -b && vite build; output in dist/ (about 14 MB, static)
npm run preview # serves dist/ locally for a final check
npm run validate
```
- Static hosting: `dist/` is a plain SPA. If it is mounted under a subpath, build with `VITE_BASE_PATH=/subpath/ npm run build`.
- Container (front end plus Caddy): `docker/Dockerfile.frontend` builds the UI on Node 22 and serves it from Caddy on port 3000.
- Full container stack: `docker-compose.yml` runs `gateway` (Caddy plus the Rust router on 6658/6659), `frontend` and `backend`. Copy `.env.example` to `.env` first. Images: `backend` and `gateway` come from `ghcr.io/evandrodevbr/opencodeui-{backend,gateway}` (published by the `docker-backend.yml` and `docker-gateway.yml` workflows); the `frontend` image still points at the upstream `ghcr.io/lehhair/opencodeui-frontend` because `docker-frontend.yml` has been failing since 2026-06-01 and no `ghcr.io/evandrodevbr/opencodeui-frontend` was ever published. To build all three locally use `docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build`.
- GitHub Pages: `.github/workflows/deploy.yml` builds `dist/` with `VITE_BASE_PATH=/PiAgentUI/` and publishes it on every push to `main` (enable Pages with source "GitHub Actions" the first time).
- Desktop and mobile releases: `.github/workflows/release.yml` runs on `v*` tags and attaches Windows, macOS, Linux and Android artifacts to the GitHub release.
## Pi extension runtime
## Project structure
PiAgentUI is registered as a Pi extension in `package.json`:
```json
{
"pi": {
"extensions": ["./extensions/piagentui-server.ts"]
}
}
```
When the extension starts, it writes local discovery metadata to:
```text
extensions/ Pi extension: HTTP/WebSocket server, settings store, git and
extension managers, all with Vitest tests
src/ React front end (Vite entry in index.html)
├── api/ HTTP/SSE clients and the request pipeline
├── components/ shared UI pieces (code blocks, diffs, command palette)
├── features/ chat, input, messages, tools, sessions, settings, slash commands
├── hooks/ session, events, transcription, permissions
├── locales/ 8 translated bundles
├── store/ Zustand-style stores (sessions, messages, layout, theme)
└── constants/ API base URL, storage keys, UI metrics
src-router/ Rust gateway router (axum) behind Caddy
src-tauri/ Tauri 2 desktop and mobile shell (Rust), Android project in gen/
docker/ Dockerfiles, Caddy configs, entrypoints
scripts/ icon copy, version bump, release preparation, Android dev helper
docs/superpowers/ design specs and plans written during the migration
~/.pi/agent/piagentui-port.json
```
## Verification
The web app uses that local port/token information to talk to the running extension server. Some backend changes require restarting or reloading the Pi extension process before the browser sees the new behavior.
Everything below was executed on a clean clone in this repository state:
## Important local endpoints
| Command | Result |
|---|---|
| `npm ci` | 615 packages installed, postinstall copies 266 material icons |
| `npm run typecheck` | exit 0 (`tsc -b`, app + extension projects) |
| `npm run typecheck:extensions` | exit 0 |
| `npm run lint` | exit 0 (0 errors, 109 warnings) |
| `npm run build` | exit 0, `dist/` about 14 MB |
| `npm run test:run` | 95 files, 426 tests, all passing (on Node 22 in CI; see the Node 25+ note below) |
| `npm run validate` | exit 0 (the same gate CI runs in `build.yml`) |
| `cargo check` in `src-router/` | exit 0 |
| `cargo check --manifest-path src-tauri/Cargo.toml` | exit 0 (1 warning) |
| extension server over HTTP | `/global/health` 200, `/` 200 with `PI_BOOT_DATA`, `/api/models` 200 with token and 401 without, `/api/sessions`, `/api/sessions/:id/messages`, `/api/sessions/:id/context`, `/api/skills`, `/api/mcp/status`, `/api/commands/list`, `/api/network/access` all 200, `/global/event` 200 `text/event-stream` |
| Endpoint | Purpose |
| -------------------------------- | ------------------------------------------------------ |
| `GET /api/models` | Real Pi model list plus normalized capability metadata |
| `GET /api/sessions` | Session list parsed from Pi session files |
| `GET /api/sessions/:id/messages` | Session history normalized for UI rendering |
| `GET /api/sessions/:id/context` | Runtime context usage for the active session |
| `POST /api/messages/send` | Send user message to the requested Pi session |
| `GET /api/skills` | Effective Pi skills grouped by source |
| `GET /api/mcp/status` | Configured MCP server metadata |
| `GET /api/commands/list` | Built-in and dynamic Pi slash commands |
| `GET /global/event` | SSE event stream from PiAgentUI backend |
Two things to know when running the suite: `npm run test:run` needs `dist/` to exist, because the extension server test fetches the served `index.html`, so `npm run build` must run first (that is the order in `npm run validate`); and `npm run format:check` currently reports 66 files that are not Prettier-formatted, so `npm run check` fails on that step even though the CI gate passes. On Node 25 and newer, Node's built-in `localStorage` shadows the jsdom one and two `settingsBackup` tests fail; running them with `NODE_OPTIONS=--no-experimental-webstorage` makes them pass.
## Development principles
## Current state and limitations
- Prefer real Pi runtime data over mocked UI-only state.
- Keep OpenRouter metadata as catalog enrichment only; never replace Pi provider/model identity with it.
- Treat unknown runtime values explicitly instead of rendering misleading numbers.
- Add tests before behavior changes whenever possible.
- Keep tool results inside tool cards.
- Keep UI work aligned with the existing theme, motion, spacing, and panel patterns.
- Document what is implemented separately from what is planned.
- Active single-developer project, pre-1.0 (`0.6.10`). The chat, sessions, MCP, skills and command panels work against a real Pi runtime; some areas are still moving.
- Naming inheritance from the OpenCodeUI base is only partially cleaned: the Rust crate is `opencodeui-router`, container images are `opencodeui-*`, `.env.example` comments are in Chinese, and the Docker stack runs an `opencode serve` backend that belongs to the inherited deployment, not to the Pi extension flow.
- There is no test for the Tauri shell or the Rust gateway beyond compilation; the Android and desktop bundles are only built by the release workflow.
- The suite is component- and server-level; there is no end-to-end browser test and no coverage threshold.
- `@mariozechner/pi-coding-agent` is a devDependency for types only (the runtime provides the real extension API), and npm marks that package line as deprecated in favour of `@earendil-works/pi-coding-agent`.
- The front end talks to one runtime per window: the discovery file or a manually configured server. There is no multi-runtime aggregation.
- Voice input exists in the UI (`src/hooks/useVoiceTranscription.ts`) and requires an OpenAI-compatible STT endpoint configured in settings; it is not covered by an automated browser test.
- Security model of the local server: it binds `0.0.0.0` but answers only loopback `Host`/`Origin` values until LAN access is enabled, and `/api/*` needs the bearer token from the discovery file (generated with `crypto.randomBytes` and stored with mode `0600`). Once LAN access is enabled in settings, requests from private-network hosts are accepted without a token, so turn it on only on networks you trust: the API can read project files and manage Git and extensions. There is no per-user authentication.
- No published npm package and no hosted web demo; both README and code assume a local runtime.
- `docker-frontend.yml` last ran on 2026-06-01 and failed at the build step, so `ghcr.io/evandrodevbr/opencodeui-frontend` was never published (verified against GHCR with the owner credentials); the compose files pull that one image from upstream until the workflow is fixed.
## Repository notes
## Documentation
| Document | Content |
|---|---|
| [`docs/superpowers/specs/`](docs/superpowers/specs/) | Design specs for the Pi extension migration, sessions, STT and Git controls |
| [`docs/superpowers/plans/`](docs/superpowers/plans/) | Implementation plans and the roadmap for context, skills and MCP |
| [`CHANGELOG.md`](CHANGELOG.md) | Release history used by the release workflow |
| [`docker/`](docker/) | Dockerfiles, Caddy configs and entrypoints for the container stack |
This project still contains inherited naming from the original OpenCodeUI base in a few places, including package metadata and older documentation files. The current direction is PiAgentUI-first, and future cleanup should migrate remaining OpenCodeUI naming without breaking local workflows.
## License
GPL-3.0-only, as declared in `package.json` and [`LICENSE`](LICENSE). PiAgentUI is a fork and rework of OpenCodeUI; the original authors are credited in `src-tauri/Cargo.toml`.
## Review checks (2026-09-30)
Browser downloads include exactly the requested byte view, without adjacent bytes from its backing buffer. Session statistics skip the full message-text scan when real token usage is available. LAN discovery is best effort: restricted network-interface enumeration keeps the local API usable. Failed requests close partial responses without attempting to send headers twice.
Run `npm run validate` for application/extension type checks, lint, build and tests. Focused checks: `npm run test:run -- src/utils/downloadUtils.test.ts src/hooks/useSessionStats.test.tsx extensions/piagentui-server-core.test.ts`. Dependencies, native desktop/mobile builds and live agent flows require their own checks; an application unit-test pass does not replace those validations.
This repository currently follows the license declared in `package.json`: `GPL-3.0-only`.
+155 -154
View File
@@ -4,208 +4,209 @@
# PiAgentUI
**Workspace desktop e web para agentes de código: sessões reais do Pi Agent, chat com streaming, painéis de MCP e skills, terminal, explorador de arquivos e app desktop em Tauri.**
![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178C6?logo=typescript&logoColor=white)
![React](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=white)
![Vite](https://img.shields.io/badge/Vite-8-646CFF?logo=vite&logoColor=white)
![Tauri](https://img.shields.io/badge/Tauri-2-24C8DB?logo=tauri&logoColor=white)
![Node](https://img.shields.io/badge/Node-%3E%3D22-5FA04E?logo=nodedotjs&logoColor=white)
![Licença](https://img.shields.io/badge/license-GPL--3.0--only-green)
[![Build Validation](https://github.com/evandrodevbr/PiAgentUI/actions/workflows/build.yml/badge.svg)](https://github.com/evandrodevbr/PiAgentUI/actions/workflows/build.yml)
[English (US)](./README.md) | **Português (BR)** | [Español](./README.es.md) | [日本語](./README.ja.md)
## Sobre
PiAgentUI é uma interface moderna web e desktop para o **Pi Agent**. Ele não é apenas uma camada visual de chat: o objetivo é evoluir para um aplicativo completo de agentes, na mesma categoria de produto de Codex, Claude Desktop e outros ambientes agent-first para desenvolvimento.
PiAgentUI é uma interface de chat e um workspace para o runtime do Pi Agent. No lugar de um terminal cru, ele reúne sessões, troca de modelo, tool calls em cards, metadados de servidores MCP, skills agrupadas por origem, slash commands, controles de Git, terminal e explorador de arquivos, como aplicação web ou como app desktop Tauri.
O PiAgentUI trata o runtime real do Pi como fonte de verdade. Sessões, metadados de modelo, uso de contexto, tool calls, skills, configuração MCP e slash commands vêm do Pi sempre que possível, em vez de serem recriados como estado falso de interface.
O projeto é Pi-first: sessões, modelos, uso de contexto, tool calls, skills, configuração MCP e slash commands vêm do runtime do Pi sempre que possível. A UI cacheia e enriquece esses dados para apresentação, mas não inventa estado do agente. É um fork e uma reescrita da base OpenCodeUI, e parte dessa herança ainda aparece (veja [Estado atual e limitações](#estado-atual-e-limitações)).
> **Status do projeto:** desenvolvimento local ativo. Algumas áreas já estão próximas de produto, enquanto outras ainda mudam rapidamente.
## Como funciona
## Demonstração do software
Estas capturas mostram a experiência atual do PiAgentUI: um workspace escuro e focado para agentes, com sessões reais do Pi, seleção de modelos, metadados MCP, settings, chat com ferramentas e painéis laterais.
![Workspace de chat do PiAgentUI com painel lateral MCP](./src/assets/images/img1.png)
![Diálogo de settings e conexões de servidor do PiAgentUI](./src/assets/images/img2.png)
![Seletor de modelos do PiAgentUI dentro de uma sessão ativa](./src/assets/images/img3.png)
## O que o PiAgentUI já faz hoje
- **Chat com streaming** — saída ao vivo via SSE, reconciliação estável de mensagens, markdown, code highlight, partes de reasoning e cards de ferramentas.
- **Sessões reais do Pi** — lista de sessões, roteamento para sessão ativa, histórico seguro após restart, títulos derivados e envio para a sessão selecionada.
- **Tool output em cards** — tool calls/results ficam anexados às mensagens do assistente, sem vazar como texto normal no chat.
- **Uso real de contexto** — o uso de contexto vem do runtime do Pi quando disponível; contexto desconhecido/compactado é exibido de forma segura em vez de `NaN`.
- **Metadados de modelos** — identidade de provider/modelo vem do Pi; o catálogo OpenRouter local é usado apenas como enriquecimento de detalhes.
- **Painel de Skills** — skills reais são lidas do prompt efetivo do Pi e agrupadas por origem: global, project, package e other.
- **Painel MCP** — lê servidores MCP configurados em arquivos locais/globais e mostra transporte, comando, URL, lifecycle, direct tools e origem.
- **Slash commands do Pi** — `/api/commands/list` expõe comandos built-in do Pi e comandos dinâmicos de extensões, prompts e skills.
- **Anexos multimodais** — suporte visual a arquivos/imagens/PDF/áudio/vídeo conforme as capacidades do modelo selecionado.
- **Terminal e arquivos** — terminal integrado, explorador de arquivos, syntax highlighting, componentes de diff e integração desktop via Tauri.
- **UI keyboard-first** — command palette, atalhos configuráveis, split panes, seletor de modelo, navegação por projeto/sessão e layouts responsivos.
## Direção do produto
O PiAgentUI está caminhando para virar um app completo de agentes:
1. **Ações visuais para todos os comandos do Pi** — slash commands continuam disponíveis, mas ações frequentes devem virar botões, menus, diálogos ou painéis.
2. **Entrada por voz e transcrição em tempo real** — gravação de microfone, deltas parciais, inserção da transcrição final no composer e configuração de provedores STT OpenAI-compatible.
3. **Integração MCP mais profunda** — além de metadados: conectar, autenticar, inspecionar ferramentas e executar workflows MCP pela UI.
4. **Controle completo de sessões** — fork, clone, navegação em árvore, import/export/share, branch summaries e gerenciamento de contexto como fluxos nativos.
5. **Experiência desktop completa** — app Tauri polido com descoberta local de runtime, settings seguros, notificações e controles ricos de workspace.
## Roadmap de áudio e transcrição
O PiAgentUI já aceita anexos de áudio quando o modelo selecionado declara suporte a áudio. O sistema planejado de voz adiciona speech-to-text ao vivo diretamente no composer.
Arquitetura recomendada:
- **Provedor realtime padrão:** OpenAI Realtime Transcription com `gpt-realtime-whisper` para transcrição parcial de baixa latência.
- **Fallback por arquivo:** OpenAI Audio Transcriptions com `gpt-4o-transcribe`, `gpt-4o-mini-transcribe` ou `whisper-1`.
- **Provedores customizados:** APIs STT OpenAI-compatible configuráveis pelo usuário, como provedores que implementam `POST /v1/audio/transcriptions`.
UX planejada:
- botão de microfone no composer;
- estados de gravação e permissão;
- overlay de transcrição parcial;
- texto final inserido no input;
- comportamento configurável de append/replace;
- auto-send desligado por padrão;
- tratamento recuperável para erros de permissão, rede e provedor.
Exemplo de configuração de provider:
```json
{
"kind": "openai-compatible",
"mode": "file",
"baseUrl": "https://api.example.com/v1",
"transcriptionEndpoint": "/audio/transcriptions",
"transcriptionModel": "openai/whisper-large-v3",
"language": "pt"
}
```
Suporte realtime é tratado como capability separada, porque compatibilidade HTTP com a OpenAI não garante compatibilidade WebSocket.
## Arquitetura
```text
janela do navegador / webview Tauri
|
| HTTP /api/* + SSE /global/event + WebSocket
v
Servidor da extensão Pi (extensions/piagentui-server.ts, Node http + ws)
|-- serve o build da UI a partir de dist/ e injeta window.PI_BOOT_DATA {baseUrl, token}
|-- grava os dados de conexão em ~/.pi/agent/piagentui-port.json {port, token}
|-- exige bearer token em /api/* e restringe o acesso ao loopback por padrão
|-- faz a ponte para sessões, modelos, ferramentas, MCP, skills e comandos
^
| carregado pelo runtime
Processo do Pi Agent
PiAgentUI
├─ Frontend React/Vite
│ ├─ chat, renderização de mensagens, input, painéis e settings
│ ├─ stores locais para preferências de UI
│ └─ clientes SSE/API para endpoints locais do PiAgentUI
├─ Backend como extensão Pi
│ └─ extensions/piagentui-server.ts
│ ├─ expõe endpoints locais /api/*
│ ├─ faz ponte com runtime/sessões/modelos do Pi
│ ├─ transmite eventos do Pi para o browser
│ └─ lê metadados locais como MCP config e skills
└─ Runtime Pi Agent
├─ sessões
├─ modelos/providers
├─ ferramentas
├─ skills
├─ MCP
└─ slash commands
```
- Ao iniciar uma sessão, a extensão escuta na porta `58785` (ou na próxima livre) e publica a porta escolhida e um token aleatório no arquivo de descoberta.
- `GET /global/health` é aberto; todas as outras rotas `/api/*` exigem `Authorization: Bearer <token>`.
- O app Tauri lê o mesmo arquivo de descoberta pelo comando Rust `get_pi_agent_connection`, então o build desktop se conecta ao runtime local sem configuração manual.
- `npm run dev` (Vite) serve apenas o front end em desenvolvimento; ele faz proxy de `/api` para `127.0.0.1:4096` removendo o prefixo `/api`. Aponte para um servidor compatível ou use o build servido pela extensão para o fluxo Pi.
A UI não deve inventar estado de runtime quando o Pi já sabe a resposta. O PiAgentUI pode cachear e enriquecer dados para UX, mas o Pi continua autoritativo para o comportamento do agente.
## Stack
## Stack técnica
| Área | Escolha |
|---|---|
| UI | React 19, TypeScript 5.9 |
| Build | Vite 8 (rolldown), com `tsc -b` como gate de tipos |
| Estilo | Tailwind CSS 4 e design tokens do projeto |
| Desktop e mobile | Tauri 2 (Rust 1.85+), alvos Android gerados em `src-tauri/gen` |
| Markdown e código | Streamdown, Shiki, KaTeX |
| Terminal | xterm.js |
| i18n | i18next, locales `en`, `pt-BR`, `es`, `ja`, `zh-CN`, `hi`, `bn`, `ar` |
| Testes | Vitest 4, Testing Library, jsdom |
| Backend local | Extensão Pi: servidor HTTP Node com `ws` (SSE e WebSocket) |
| Gateway (containers) | Roteador Rust (`src-router`, axum) na frente do Caddy |
| Pacotes | npm com `package-lock.json` |
| Área | Stack |
| ------------------- | -------------------------------------------- |
| UI | React 19, TypeScript |
| Build | Vite 8 |
| Estilo | Tailwind CSS v4 e design tokens do projeto |
| Desktop | Tauri 2 |
| Markdown | Streamdown / pipeline de markdown |
| Syntax highlighting | Shiki |
| Terminal | xterm.js |
| Testes | Vitest, Testing Library |
| Backend local | Extensão Pi, Node HTTP server, WebSocket/SSE |
## Requisitos
## Desenvolvimento local
- Node.js `>=22` (o CI roda Node 22; verificado localmente em Node 24.20.0)
- npm `>=10` (o repositório é npm; `package-lock.json` é a referência)
- Rust `1.85+` apenas para buildar o app desktop ou o gateway
- Docker apenas para a stack de containers em `docker/`
- Pi Agent instalado para ter sessões e modelos reais; sem ele a UI sobe em modo standalone e mostra apenas o diálogo de conexão com servidores
## Início rápido
Instalar dependências:
```bash
git clone https://github.com/evandrodevbr/PiAgentUI.git
cd PiAgentUI
npm ci # 615 pacotes; roda scripts/copy-material-icons.mjs
npm run build # tsc -b && vite build -> dist/
npm run dev # servidor de desenvolvimento Vite em http://localhost:5173
npm install
```
UI web servida pela extensão Pi (é o caminho que conversa com o runtime Pi de verdade):
Iniciar o frontend em modo dev:
```bash
npm run build # a extensão serve o dist/
# com o Pi Agent carregando este repositório como pacote Pi (veja "pi" em package.json)
cat ~/.pi/agent/piagentui-port.json # {"port": 58785, "token": "..."}
# abra http://127.0.0.1:58785
npm run dev
```
App desktop:
Build:
```bash
npm run tauri dev # janela de desenvolvimento, usa o servidor Vite
npm run tauri build # empacota conforme src-tauri/tauri.conf.json (deb no Linux, dmg, nsis)
npm run build
```
Android (veja `scripts/dev-android.sh` e o job de Android no workflow de release):
Testes:
```bash
npm run tauri android dev
npm run test:run
```
## Uso
Rotas locais expostas pelo servidor da extensão. Todas, exceto `/global/health` e a rota SSE, exigem o bearer token do arquivo de descoberta.
| Rota | Para que serve |
|---|---|
| `GET /global/health` | Prova de vida, responde `{"status":"ok","pi":"ready"}` |
| `GET /global/event` | Stream SSE de eventos (sessões, partes de mensagem, status) |
| `GET /api/models` | Lista de modelos com capacidades normalizadas |
| `GET /api/sessions` | Lista de sessões |
| `POST /api/sessions` | Cria sessão |
| `POST /api/sessions/abort` | Aborta o turno em execução |
| `GET /api/sessions/:id/messages` | Histórico da sessão normalizado para renderização |
| `GET /api/sessions/:id/context` | Uso de contexto da sessão ativa |
| `POST /api/messages/send` | Envia mensagem do usuário para uma sessão |
| `GET /api/skills` | Skills efetivas agrupadas por origem |
| `GET /api/mcp/status` | Servidores MCP configurados (transporte, comando, URL, origem) |
| `GET /api/commands/list` | Slash commands built-in e dinâmicos |
| `GET /api/agents`, `GET /api/files/list`, `GET /api/permissions/list`, `GET /api/questions/list` | Inventário do runtime para os painéis |
| `GET /api/vcs/status`, `/info`, `/branches` | Estado do Git usado pelos controles do composer |
| `GET /api/extensions/list` | Catálogo de extensões do painel de extensões |
| `GET /api/settings/stt`, `/api/settings/tts` | Configurações de voz (STT/TTS) |
| `GET /api/network/access`, `POST /api/network/access` | Chave de acesso em LAN (padrão: só loopback) |
A tabela completa de rotas, incluindo as mutações de Git e de extensões, está em [`extensions/piagentui-server.ts`](extensions/piagentui-server.ts). Portas e caminhos podem mudar; o arquivo de descoberta é a fonte de verdade para porta e token.
Auditar o agente pelo shell também funciona, por exemplo:
Type checks:
```bash
TOKEN=$(node -e "console.log(require(process.env.HOME+'/.pi/agent/piagentui-port.json').token)")
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:58785/api/models | head -c 300
npm run typecheck
npm run typecheck:extensions
```
## Produção e deploy
Validação completa:
```bash
npm run build # tsc -b && vite build; saída em dist/ (cerca de 14 MB, estático)
npm run preview # serve o dist/ localmente para conferência final
npm run validate
```
- Hospedagem estática: `dist/` é uma SPA comum. Se for montada em um subcaminho, build com `VITE_BASE_PATH=/subcaminho/ npm run build`.
- Container (front end com Caddy): `docker/Dockerfile.frontend` builda a UI em Node 22 e serve pelo Caddy na porta 3000.
- Stack completa de containers: `docker-compose.yml` sobe `gateway` (Caddy com o roteador Rust em 6658/6659), `frontend` e `backend`. Copie `.env.example` para `.env` antes. As imagens vêm de `ghcr.io/evandrodevbr/opencodeui-{backend,frontend,gateway}`, publicadas pelos workflows `docker-*`; para buildar localmente use `docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build`.
- GitHub Pages: `.github/workflows/deploy.yml` builda o `dist/` com `VITE_BASE_PATH=/PiAgentUI/` e publica a cada push em `main` (na primeira vez, habilite o Pages com origem "GitHub Actions").
- Releases desktop e mobile: `.github/workflows/release.yml` roda em tags `v*` e anexa artefatos de Windows, macOS, Linux e Android ao release do GitHub.
## Runtime da extensão Pi
## Estrutura do projeto
O PiAgentUI é registrado como extensão Pi em `package.json`:
```json
{
"pi": {
"extensions": ["./extensions/piagentui-server.ts"]
}
}
```
Quando a extensão inicia, ela escreve metadados de descoberta local em:
```text
extensions/ Extensão Pi: servidor HTTP/WebSocket, store de settings,
gerentes de git e de extensões, todos com testes Vitest
src/ Front end React (entrada Vite em index.html)
├── api/ Clientes HTTP/SSE e pipeline de requisições
├── components/ Peças de UI compartilhadas (code blocks, diffs, command palette)
├── features/ chat, input, mensagens, ferramentas, sessões, settings, slash commands
├── hooks/ sessão, eventos, transcrição, permissões
├── locales/ 8 bundles traduzidos
├── store/ Stores (sessões, mensagens, layout, tema)
└── constants/ URL base da API, chaves de storage, métricas de UI
src-router/ Roteador do gateway em Rust (axum), atrás do Caddy
src-tauri/ Shell desktop e mobile Tauri 2 (Rust), projeto Android em gen/
docker/ Dockerfiles, configs do Caddy, entrypoints
scripts/ cópia de ícones, bump de versão, preparação de release, helper Android
docs/superpowers/ Specs de design e planos escritos durante a migração
~/.pi/agent/piagentui-port.json
```
## Verificação
O app web usa a porta/token desse arquivo para falar com o servidor local da extensão. Algumas mudanças de backend exigem reiniciar ou recarregar o processo da extensão Pi antes de aparecerem no browser.
Tudo abaixo foi executado em um clone limpo neste estado do repositório:
## Endpoints locais importantes
| Comando | Resultado |
|---|---|
| `npm ci` | 615 pacotes instalados, postinstall copia 266 ícones |
| `npm run typecheck` | exit 0 (`tsc -b`, projetos app e extensão) |
| `npm run typecheck:extensions` | exit 0 |
| `npm run lint` | exit 0 (0 erros, 109 avisos) |
| `npm run build` | exit 0, `dist/` com cerca de 14 MB |
| `npm run test:run` | 95 arquivos, 426 testes, todos passando (no Node 22 do CI; veja a nota sobre Node 25+ abaixo) |
| `npm run validate` | exit 0 (o mesmo gate que o CI roda em `build.yml`) |
| `cargo check` em `src-router/` | exit 0 |
| `cargo check --manifest-path src-tauri/Cargo.toml` | exit 0 (1 aviso) |
| servidor da extensão via HTTP | `/global/health` 200, `/` 200 com `PI_BOOT_DATA`, `/api/models` 200 com token e 401 sem, `/api/sessions`, `/api/sessions/:id/messages`, `/api/sessions/:id/context`, `/api/skills`, `/api/mcp/status`, `/api/commands/list`, `/api/network/access` todos 200, `/global/event` 200 `text/event-stream` |
| Endpoint | Finalidade |
| -------------------------------- | ----------------------------------------------------- |
| `GET /api/models` | Lista real de modelos Pi com capacidades normalizadas |
| `GET /api/sessions` | Lista de sessões lida dos arquivos de sessão do Pi |
| `GET /api/sessions/:id/messages` | Histórico normalizado para renderização na UI |
| `GET /api/sessions/:id/context` | Uso de contexto runtime para a sessão ativa |
| `POST /api/messages/send` | Envia mensagem para a sessão Pi solicitada |
| `GET /api/skills` | Skills efetivas do Pi agrupadas por origem |
| `GET /api/mcp/status` | Metadados dos servidores MCP configurados |
| `GET /api/commands/list` | Slash commands built-in e dinâmicos do Pi |
| `GET /global/event` | Stream SSE de eventos do backend PiAgentUI |
Dois detalhes ao rodar a suíte: `npm run test:run` precisa do `dist/` existir, porque o teste do servidor da extensão busca o `index.html` servido, então `npm run build` deve rodar antes (é a ordem em `npm run validate`); e `npm run format:check` hoje acusa 66 arquivos fora do padrão do Prettier, então `npm run check` falha nesse passo mesmo com o gate do CI passando. No Node 25 e superior, o `localStorage` nativo do Node sobrepõe o do jsdom e dois testes de `settingsBackup` falham; rodá-los com `NODE_OPTIONS=--no-experimental-webstorage` faz passarem.
## Princípios de desenvolvimento
## Estado atual e limitações
- Preferir dados reais do runtime Pi em vez de estado fake só da UI.
- Manter OpenRouter como enriquecimento de catálogo, nunca como substituto da identidade provider/modelo do Pi.
- Tratar valores runtime desconhecidos explicitamente.
- Escrever testes antes de mudanças de comportamento sempre que possível.
- Manter tool results dentro dos cards de ferramenta.
- Seguir o sistema visual existente: tema, movimento, espaçamento e padrões de painéis.
- Separar claramente o que está implementado do que é roadmap.
- Projeto ativo de um desenvolvedor, pré-1.0 (`0.6.10`). Chat, sessões, MCP, skills e painéis de comandos funcionam contra um runtime Pi real; algumas áreas ainda mudam rápido.
- A herança de nomes da base OpenCodeUI foi limpa só em parte: o crate Rust se chama `opencodeui-router`, as imagens de container são `opencodeui-*`, os comentários do `.env.example` estão em chinês e a stack Docker roda um backend `opencode serve` que pertence ao deploy herdado, não ao fluxo da extensão Pi.
- Não há teste do shell Tauri nem do gateway Rust além da compilação; os pacotes desktop e Android só são gerados pelo workflow de release.
- A suíte é de componentes e de servidor; não há teste end-to-end de navegador nem limite de cobertura.
- `@mariozechner/pi-coding-agent` é devDependency só para tipos (o runtime fornece a API real da extensão) e o npm marca essa linha como depreciada em favor de `@earendil-works/pi-coding-agent`.
- O front end conversa com um runtime por janela: o arquivo de descoberta ou um servidor configurado à mão. Não há agregação de múltiplos runtimes.
- A entrada de voz existe na UI (`src/hooks/useVoiceTranscription.ts`) e exige um endpoint STT compatível com OpenAI configurado nos settings; não há teste automatizado de navegador para ela.
- Modelo de segurança do servidor local: ele escuta em `0.0.0.0`, mas só responde a valores de `Host`/`Origin` de loopback até o acesso pela LAN ser ativado, e `/api/*` exige o token bearer do arquivo de descoberta (gerado com `crypto.randomBytes` e gravado com modo `0600`). Com o acesso pela LAN ativado nas configurações, requisições de hosts de rede privada são aceitas sem token; ative só em redes confiáveis, pois a API lê arquivos do projeto e gerencia Git e extensões. Não há autenticação por usuário.
- Sem pacote publicado no npm e sem demo web hospedada; o README e o código assumem um runtime local.
## Notas do repositório
## Documentação
| Documento | Conteúdo |
|---|---|
| [`docs/superpowers/specs/`](docs/superpowers/specs/) | Specs de design da migração para extensão Pi, sessões, STT e controles de Git |
| [`docs/superpowers/plans/`](docs/superpowers/plans/) | Planos de implementação e roadmap de contexto, skills e MCP |
| [`CHANGELOG.md`](CHANGELOG.md) | Histórico de releases usado pelo workflow de release |
| [`docker/`](docker/) | Dockerfiles, configs do Caddy e entrypoints da stack de containers |
O projeto ainda contém nomes herdados da base original OpenCodeUI em alguns pontos, incluindo metadados do pacote e documentação antiga. A direção atual é PiAgentUI-first, e uma limpeza futura deve migrar esses nomes sem quebrar os workflows locais.
## Licença
GPL-3.0-only, conforme `package.json` e [`LICENSE`](LICENSE). PiAgentUI é um fork e uma reescrita do OpenCodeUI; os autores originais estão creditados em `src-tauri/Cargo.toml`.
Este repositório segue a licença declarada em `package.json`: `GPL-3.0-only`.
-2
View File
@@ -12,8 +12,6 @@
services:
frontend:
# Imagem do upstream: ghcr.io/evandrodevbr/opencodeui-frontend ainda não foi publicada
# (docker-frontend.yml falha desde 2026-06-01). Para build local use docker-compose.build.yml.
image: ghcr.io/lehhair/opencodeui-frontend:latest
container_name: opencodeui
restart: unless-stopped
+2 -4
View File
@@ -18,7 +18,7 @@
services:
# ---- 统一入口:网关 ----
gateway:
image: ghcr.io/evandrodevbr/opencodeui-gateway:latest
image: ghcr.io/lehhair/opencodeui-gateway:latest
container_name: opencode-gateway
restart: unless-stopped
ports:
@@ -46,8 +46,6 @@ services:
# ---- 前端:预构建镜像 ----
frontend:
# A imagem do próprio namespace ainda não existe (docker-frontend.yml falha desde 2026-06-01);
# mantém a do upstream até o workflow publicar ghcr.io/evandrodevbr/opencodeui-frontend.
image: ghcr.io/lehhair/opencodeui-frontend:latest
container_name: opencode-frontend
restart: unless-stopped
@@ -56,7 +54,7 @@ services:
# ---- 后端:预构建镜像 ----
backend:
image: ghcr.io/evandrodevbr/opencodeui-backend:latest
image: ghcr.io/lehhair/opencodeui-backend:latest
container_name: opencode-backend
restart: unless-stopped
deploy:
@@ -0,0 +1,916 @@
# PiAgentUI STT Realtime Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add backend-proxied realtime speech-to-text so microphone dictation appears live in the message composer and remains editable after stop.
**Architecture:** Extend the existing STT settings/types with realtime endpoint fields, add a dedicated `/api/stt/realtime` WebSocket proxy in the extension server, and update the voice hook/InputBox composition model to merge partial/final transcripts without duplicate text. Preserve the current file-upload transcription path for `mode: 'file'`.
**Tech Stack:** React 19, TypeScript, Vitest/jsdom, Node `http`, `ws`, `MediaRecorder`, PiAgentUI SQLite settings store.
---
## File map
- Modify `src/types/api/stt.ts`: add realtime settings and realtime event types shared by frontend.
- Modify `extensions/piagentui-settings-store.ts`: persist optional `realtimeEndpoint` and `realtimeModel` through the existing JSON settings namespace.
- Modify `extensions/piagentui-server.ts`: sanitize realtime settings, validate updates, route `/api/stt/realtime` in WebSocket upgrade handling, and proxy client/provider events.
- Modify `extensions/piagentui-server.test.ts`: add focused backend realtime tests.
- Modify `src/api/stt.ts`: add a helper to build an authenticated realtime WebSocket URL.
- Modify `src/api/stt.test.ts`: test realtime WebSocket URL generation with token auth.
- Modify `src/hooks/useVoiceTranscription.ts`: support `mode: 'realtime'`, websocket lifecycle, chunk sending, partial/final callbacks.
- Modify `src/hooks/useVoiceTranscription.test.tsx`: test realtime hook behavior with mocked WebSocket and MediaRecorder.
- Modify `src/features/chat/InputBox.tsx`: add live dictation composition refs/callbacks and pass them to `InputToolbar`.
- Modify `src/features/chat/InputBox.test.tsx`: test partial replacement, final consolidation, append, and replace behavior.
- Modify `src/features/chat/input/InputToolbar.tsx`: pass realtime delta callback to `VoiceInputButton`.
- Modify `src/features/chat/input/VoiceInputButton.tsx`: pass realtime delta callback to hook and expose busy/error states without changing visual structure.
- Modify `src/features/chat/input/VoiceInputButton.test.tsx`: ensure the hook receives both final and delta callbacks.
- Modify `src/features/settings/components/SttSettings.tsx`: expose `Mode`, `Realtime endpoint`, and `Realtime model` controls.
- Modify `src/features/settings/components/SttSettings.test.tsx`: verify realtime settings can be displayed and saved.
## Task 1: Extend STT settings and frontend URL helper
**Files:**
- Modify: `src/types/api/stt.ts`
- Modify: `src/api/stt.ts`
- Test: `src/api/stt.test.ts`
- [x] **Step 1: Write failing API/types test**
Add to `src/api/stt.test.ts`:
```ts
it('builds an authenticated realtime STT websocket URL', () => {
const url = getSttRealtimeUrl()
expect(url).toBe('ws://127.0.0.1:58785/api/stt/realtime?token=token-123')
})
```
Update the import:
```ts
import { getSttRealtimeUrl, getSttSettings, transcribeAudio, updateSttSettings } from './stt'
```
- [x] **Step 2: Run test to verify RED**
Run:
```bash
npm run test:run -- src/api/stt.test.ts
```
Expected: FAIL because `getSttRealtimeUrl` is not exported.
- [x] **Step 3: Implement minimal types and helper**
In `src/types/api/stt.ts`, extend `SttSettings` and `UpdateSttSettingsRequest`:
```ts
realtimeEndpoint: string
realtimeModel: string
```
```ts
realtimeEndpoint?: string
realtimeModel?: string
```
Add event types:
```ts
export type SttRealtimeDelta =
| { type: 'transcript.partial'; text: string }
| { type: 'transcript.final'; text: string }
| { type: 'error'; error: string }
| { type: 'ready' }
| { type: 'closed' }
```
In `src/api/stt.ts`, add:
```ts
export function getSttRealtimeUrl(): string {
const baseUrl = serverStore
.getActiveBaseUrl()
.replace(/^http:/, 'ws:')
.replace(/^https:/, 'wss:')
const token = serverStore.getActiveToken()
const url = new URL('/api/stt/realtime', baseUrl)
if (token) url.searchParams.set('token', token)
return url.toString()
}
```
- [x] **Step 4: Run test to verify GREEN**
Run:
```bash
npm run test:run -- src/api/stt.test.ts
```
Expected: PASS.
## Task 2: Persist and sanitize realtime STT settings
**Files:**
- Modify: `extensions/piagentui-settings-store.ts`
- Modify: `extensions/piagentui-server.ts`
- Modify: `src/features/settings/components/SttSettings.tsx`
- Test: `extensions/piagentui-server.test.ts`
- Test: `src/features/settings/components/SttSettings.test.tsx`
- [ ] **Step 1: Write failing settings tests**
In `src/features/settings/components/SttSettings.test.tsx`, extend `SETTINGS`:
```ts
realtimeEndpoint: '/realtime/transcriptions',
realtimeModel: 'gpt-4o-mini-transcribe',
```
Add test:
```ts
it('edits realtime transcription settings', async () => {
render(<SttSettings />)
fireEvent.change(await screen.findByLabelText('Mode'), { target: { value: 'realtime' } })
fireEvent.change(screen.getByLabelText('Realtime endpoint'), {
target: { value: '/v1/realtime/transcriptions' },
})
fireEvent.change(screen.getByLabelText('Realtime model'), {
target: { value: 'gpt-4o-realtime-preview' },
})
fireEvent.click(screen.getByRole('button', { name: 'Save transcription settings' }))
await waitFor(() =>
expect(updateSttSettings).toHaveBeenCalledWith(
expect.objectContaining({
mode: 'realtime',
realtimeEndpoint: '/v1/realtime/transcriptions',
realtimeModel: 'gpt-4o-realtime-preview',
}),
),
)
})
```
In `extensions/piagentui-server.test.ts`, inside the existing STT settings section, add expectations after saving STT:
```ts
expect(savedSttSettings).toMatchObject({
realtimeEndpoint: '/realtime/transcriptions',
realtimeModel: 'gpt-4o-mini-transcribe',
})
expect(savedSttSettings.apiKey).toBeUndefined()
```
- [ ] **Step 2: Run tests to verify RED**
Run:
```bash
npm run test:run -- src/features/settings/components/SttSettings.test.tsx extensions/piagentui-server.test.ts
```
Expected: FAIL because mode/realtime fields are not rendered/sanitized yet.
- [ ] **Step 3: Implement settings fields**
In `extensions/piagentui-settings-store.ts`, add optional fields to `StoredSttSettings`:
```ts
realtimeEndpoint?: string
realtimeModel?: string
```
In `extensions/piagentui-server.ts`, update defaults in `getEffectiveSttSettings()`:
```ts
realtimeEndpoint: '/realtime/transcriptions',
realtimeModel: 'gpt-4o-mini-transcribe',
```
Update `sanitizeSttSettings()`:
```ts
realtimeEndpoint: effective.realtimeEndpoint || '/realtime/transcriptions',
realtimeModel: effective.realtimeModel || effective.transcriptionModel || 'gpt-4o-mini-transcribe',
```
Update `updateSttSettings(update)` validation:
```ts
if (update.realtimeEndpoint !== undefined) {
if (typeof update.realtimeEndpoint !== 'string' || !update.realtimeEndpoint.startsWith('/')) {
return { ok: false, error: 'Invalid realtimeEndpoint' }
}
next.realtimeEndpoint = update.realtimeEndpoint
}
if (update.realtimeModel !== undefined) {
if (typeof update.realtimeModel !== 'string' || !update.realtimeModel.trim()) {
return { ok: false, error: 'Invalid realtimeModel' }
}
next.realtimeModel = update.realtimeModel.trim()
}
```
In `src/features/settings/components/SttSettings.tsx`, include in `saveSettings()` payload:
```ts
realtimeEndpoint: settings.realtimeEndpoint,
realtimeModel: settings.realtimeModel,
```
Add controls in the grid:
```tsx
;<label className="space-y-1">
<span className="text-[length:var(--fs-xs)] font-medium text-text-300">Mode</span>
<SettingsSelect
wrapperClassName="w-full"
value={settings.mode}
onChange={event => setSettings({ ...settings, mode: event.target.value as SttMode })}
>
<option value="file">File upload</option>
<option value="realtime">Realtime WebSocket</option>
</SettingsSelect>
</label>
{
settings.mode === 'realtime' && (
<>
<label className="space-y-1">
<span className="text-[length:var(--fs-xs)] font-medium text-text-300">Realtime endpoint</span>
<input
className={inputClass}
value={settings.realtimeEndpoint}
onChange={event => setSettings({ ...settings, realtimeEndpoint: event.target.value })}
placeholder="/realtime/transcriptions"
/>
</label>
<label className="space-y-1">
<span className="text-[length:var(--fs-xs)] font-medium text-text-300">Realtime model</span>
<input
className={inputClass}
value={settings.realtimeModel}
onChange={event => setSettings({ ...settings, realtimeModel: event.target.value })}
placeholder="gpt-4o-realtime-preview"
/>
</label>
</>
)
}
```
Also import `SttMode`.
- [ ] **Step 4: Run tests to verify GREEN**
Run:
```bash
npm run test:run -- src/features/settings/components/SttSettings.test.tsx extensions/piagentui-server.test.ts
```
Expected: PASS.
## Task 3: Backend realtime WebSocket proxy
**Files:**
- Modify: `extensions/piagentui-server.ts`
- Test: `extensions/piagentui-server.test.ts`
- [ ] **Step 1: Write failing backend realtime test**
Add a test to `extensions/piagentui-server.test.ts` near existing STT tests. The test should start the server, save realtime settings, mock provider WebSocket, connect to `/api/stt/realtime?token=${token}`, and verify normalized events. Use this shape:
```ts
it('proxies realtime STT websocket events without exposing the API key', async () => {
const saveSttRes = await fetch(`${baseUrl}/api/settings/stt`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: true,
providerKind: 'openai-compatible',
mode: 'realtime',
baseUrl: 'https://api.openai.test/v1',
apiKeyRequired: false,
realtimeEndpoint: '/realtime/transcriptions',
realtimeModel: 'gpt-4o-realtime-preview',
}),
})
expect(saveSttRes.status).toBe(200)
const ws = new WebSocket(`ws://127.0.0.1:${port}/api/stt/realtime?token=${token}`)
const messages: any[] = []
ws.on('message', data => messages.push(JSON.parse(String(data))))
await waitFor(() => expect(messages.some(message => message.type === 'ready')).toBe(true))
ws.send(JSON.stringify({ type: 'audio.append', audio: Buffer.from('audio').toString('base64') }))
ws.send(JSON.stringify({ type: 'audio.commit' }))
await waitFor(() => expect(messages.some(message => message.type === 'transcript.final')).toBe(true))
expect(messages).toContainEqual({ type: 'transcript.final', text: 'olá realtime' })
ws.close()
})
```
If current tests do not expose a provider WebSocket mock, add a small local `WebSocketServer` test provider bound to an available port and set `baseUrl` to `http://127.0.0.1:<providerPort>/v1`.
- [ ] **Step 2: Run test to verify RED**
Run:
```bash
npm run test:run -- extensions/piagentui-server.test.ts
```
Expected: FAIL because `/api/stt/realtime` is not routed.
- [ ] **Step 3: Implement backend route and proxy helpers**
In `extensions/piagentui-server.ts`, add helper types near other STT helpers:
```ts
type SttRealtimeClientMessage =
| { type: 'audio.append'; audio: string }
| { type: 'audio.commit' }
| { type: 'session.close' }
```
Add URL helper:
```ts
function buildRealtimeProviderUrl(settings: StoredSttSettings): string {
const base = (settings.baseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '')
const endpoint = settings.realtimeEndpoint || '/realtime/transcriptions'
const wsBase = base.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
return `${wsBase}${endpoint}`
}
```
Add normalizer:
```ts
function normalizeRealtimeProviderMessage(raw: string): { type: string; text?: string; error?: string } | null {
let event: any
try {
event = JSON.parse(raw)
} catch {
return null
}
const partialText = event.text_delta || event.delta || event.partial || event.transcript?.partial
if (typeof partialText === 'string') return { type: 'transcript.partial', text: partialText }
const finalText = event.text || event.final || event.transcript?.text
if (typeof finalText === 'string') return { type: 'transcript.final', text: finalText }
const error = event.error?.message || event.error
if (typeof error === 'string') return { type: 'error', error }
return null
}
```
Add handler:
```ts
function handleSttRealtimeWebSocket(client: WebSocket, req: http.IncomingMessage) {
const auth = checkAuth(req)
if (!auth.ok) {
client.send(JSON.stringify({ type: 'error', error: 'Unauthorized' }))
client.close()
return
}
const settings = getEffectiveSttSettings()
if (!settings.enabled || settings.mode !== 'realtime') {
client.send(JSON.stringify({ type: 'error', error: 'Realtime STT is not enabled' }))
client.close()
return
}
if (settings.apiKeyRequired && !settings.apiKey) {
client.send(JSON.stringify({ type: 'error', error: 'STT API key is not configured' }))
client.close()
return
}
if (!validateSttBaseUrl(settings.baseUrl)) {
client.send(JSON.stringify({ type: 'error', error: 'Invalid STT baseUrl' }))
client.close()
return
}
const headers: Record<string, string> = {}
if (settings.apiKey) headers.Authorization = `Bearer ${settings.apiKey}`
const provider = new WebSocket(buildRealtimeProviderUrl(settings), { headers })
const closeBoth = () => {
if (provider.readyState === WebSocket.OPEN || provider.readyState === WebSocket.CONNECTING) provider.close()
if (client.readyState === WebSocket.OPEN) client.close()
}
provider.on('open', () => {
provider.send(
JSON.stringify({ type: 'session.configure', model: settings.realtimeModel || settings.transcriptionModel }),
)
if (client.readyState === WebSocket.OPEN) client.send(JSON.stringify({ type: 'ready' }))
})
provider.on('message', data => {
const normalized = normalizeRealtimeProviderMessage(String(data))
if (normalized && client.readyState === WebSocket.OPEN) client.send(JSON.stringify(normalized))
})
provider.on('error', () => {
if (client.readyState === WebSocket.OPEN)
client.send(JSON.stringify({ type: 'error', error: 'Realtime STT provider failed' }))
})
provider.on('close', () => {
if (client.readyState === WebSocket.OPEN) client.send(JSON.stringify({ type: 'closed' }))
})
client.on('message', data => {
if (provider.readyState !== WebSocket.OPEN) return
let message: SttRealtimeClientMessage
try {
message = JSON.parse(String(data))
} catch {
client.send(JSON.stringify({ type: 'error', error: 'Invalid realtime message' }))
return
}
provider.send(JSON.stringify(message))
})
client.on('close', closeBoth)
client.on('error', closeBoth)
}
```
In `server.on('upgrade')`, route by pathname before generic connection handling:
```ts
const pathname = new URL(request.url || '/', 'http://127.0.0.1').pathname
wss!.handleUpgrade(request, socket, head, (ws: WebSocket) => {
if (pathname === '/api/stt/realtime') {
handleSttRealtimeWebSocket(ws, request)
return
}
wss!.emit('connection', ws, request)
})
```
- [ ] **Step 4: Run backend test to verify GREEN**
Run:
```bash
npm run test:run -- extensions/piagentui-server.test.ts
```
Expected: PASS.
## Task 4: Realtime hook lifecycle
**Files:**
- Modify: `src/hooks/useVoiceTranscription.ts`
- Test: `src/hooks/useVoiceTranscription.test.tsx`
- [ ] **Step 1: Write failing realtime hook test**
Add mocked WebSocket class in `src/hooks/useVoiceTranscription.test.tsx`:
```ts
class MockWebSocket extends EventTarget {
static instances: MockWebSocket[] = []
readyState = WebSocket.CONNECTING
sent: string[] = []
onopen: (() => void) | null = null
onmessage: ((event: MessageEvent) => void) | null = null
onclose: (() => void) | null = null
onerror: (() => void) | null = null
constructor(public readonly url: string) {
super()
MockWebSocket.instances.push(this)
}
send(data: string) {
this.sent.push(data)
}
close() {
this.readyState = WebSocket.CLOSED
this.onclose?.()
}
open() {
this.readyState = WebSocket.OPEN
this.onopen?.()
}
receive(message: unknown) {
this.onmessage?.({ data: JSON.stringify(message) } as MessageEvent)
}
}
```
Add test:
```ts
it('streams realtime transcription deltas while recording', async () => {
vi.mocked(getSttSettings).mockResolvedValue({
enabled: true,
providerKind: 'openai-compatible',
mode: 'realtime',
baseUrl: 'http://127.0.0.1:8022/v1',
apiKeyConfigured: false,
apiKeyRequired: false,
transcriptionEndpoint: '/audio/transcriptions',
transcriptionModel: 'model-file',
realtimeEndpoint: '/realtime/transcriptions',
realtimeModel: 'model-realtime',
insertMode: 'append',
})
Object.defineProperty(globalThis, 'WebSocket', { configurable: true, value: MockWebSocket })
const onTranscription = vi.fn()
const onTranscriptionDelta = vi.fn()
const { result } = renderHook(() => useVoiceTranscription({ onTranscription, onTranscriptionDelta }))
await act(async () => {
await result.current.startRecording()
})
const ws = MockWebSocket.instances[0]
act(() => {
ws.open()
ws.receive({ type: 'ready' })
})
expect(result.current.state).toBe('recording')
act(() => ws.receive({ type: 'transcript.partial', text: 'olá' }))
expect(onTranscriptionDelta).toHaveBeenCalledWith({ type: 'transcript.partial', text: 'olá' }, 'append')
await act(async () => result.current.stopRecording())
expect(ws.sent.some(data => JSON.parse(data).type === 'audio.commit')).toBe(true)
})
```
- [ ] **Step 2: Run test to verify RED**
Run:
```bash
npm run test:run -- src/hooks/useVoiceTranscription.test.tsx
```
Expected: FAIL because hook does not accept `onTranscriptionDelta` or realtime mode.
- [ ] **Step 3: Implement realtime hook minimally**
In `src/hooks/useVoiceTranscription.ts`:
- import `getSttRealtimeUrl` and `SttRealtimeDelta`;
- extend options:
```ts
onTranscriptionDelta?: (event: SttRealtimeDelta, insertMode: SttInsertMode) => void
```
Add refs:
```ts
const wsRef = useRef<WebSocket | null>(null)
const realtimeInsertModeRef = useRef<SttInsertMode>('append')
```
In `startRecording`, after config validation, branch:
```ts
if (activeSettings.mode === 'realtime') {
await startRealtimeRecording(activeSettings)
return
}
```
Implement `startRealtimeRecording(activeSettings)`:
```ts
const startRealtimeRecording = useCallback(
async (activeSettings: SttSettings) => {
if (typeof WebSocket === 'undefined') {
setError('Realtime voice transcription is not supported in this browser')
setState('error')
return
}
realtimeInsertModeRef.current = activeSettings.insertMode
const ws = new WebSocket(getSttRealtimeUrl())
wsRef.current = ws
ws.onmessage = event => {
let message: SttRealtimeDelta
try {
message = JSON.parse(String(event.data))
} catch {
return
}
if (message.type === 'error') {
setError(message.error)
setState('error')
return
}
if (message.type === 'ready') return
onTranscriptionDelta?.(message, realtimeInsertModeRef.current)
}
ws.onerror = () => {
setError('Realtime voice transcription failed')
setState('error')
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
streamRef.current = stream
const mimeType = MediaRecorder.isTypeSupported?.('audio/webm') ? 'audio/webm' : undefined
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
recorderRef.current = recorder
recorder.ondataavailable = async event => {
if (event.data.size <= 0 || ws.readyState !== WebSocket.OPEN) return
const audio = await event.data.arrayBuffer()
ws.send(JSON.stringify({ type: 'audio.append', audio: btoa(String.fromCharCode(...new Uint8Array(audio))) }))
}
recorder.start(500)
setState('recording')
},
[onTranscriptionDelta],
)
```
Update `stopRecording`:
```ts
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'audio.commit' }))
wsRef.current.close()
wsRef.current = null
cleanupStream()
setState('idle')
return
}
```
- [ ] **Step 4: Run hook tests to verify GREEN**
Run:
```bash
npm run test:run -- src/hooks/useVoiceTranscription.test.tsx
```
Expected: PASS.
## Task 5: InputBox live transcript composition
**Files:**
- Modify: `src/features/chat/InputBox.tsx`
- Modify: `src/features/chat/input/InputToolbar.tsx`
- Modify: `src/features/chat/input/VoiceInputButton.tsx`
- Test: `src/features/chat/InputBox.test.tsx`
- Test: `src/features/chat/input/VoiceInputButton.test.tsx`
- [ ] **Step 1: Write failing InputBox tests**
Update the `InputToolbar` mock in `src/features/chat/InputBox.test.tsx` so tests can invoke voice callbacks:
```ts
let latestVoiceDelta: ((event: { type: string; text?: string }, insertMode: SttInsertMode) => void) | undefined
vi.mock('./input/InputToolbar', () => ({
InputToolbar: ({ onSend, canSend, onVoiceTranscriptionDelta }: any) => {
latestVoiceDelta = onVoiceTranscriptionDelta
return (
<button type="button" onClick={onSend} disabled={!canSend}>
send
</button>
)
},
}))
```
Add tests:
```ts
it('shows realtime voice partials without duplicating previous partial text', () => {
render(<InputBox paneId="pane-test" onSend={vi.fn()} />)
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(textarea, { target: { value: 'Resumo:' } })
act(() => latestVoiceDelta?.({ type: 'transcript.partial', text: ' olá' }, 'append'))
expect(textarea.value).toBe('Resumo: olá')
act(() => latestVoiceDelta?.({ type: 'transcript.partial', text: ' olá mundo' }, 'append'))
expect(textarea.value).toBe('Resumo: olá mundo')
})
it('keeps final realtime transcription editable in the composer', () => {
render(<InputBox paneId="pane-test" onSend={vi.fn()} />)
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
act(() => latestVoiceDelta?.({ type: 'transcript.partial', text: 'olá' }, 'replace'))
act(() => latestVoiceDelta?.({ type: 'transcript.final', text: 'olá mundo' }, 'replace'))
fireEvent.change(textarea, { target: { value: `${textarea.value}!` } })
expect(textarea.value).toBe('olá mundo!')
})
```
- [ ] **Step 2: Run tests to verify RED**
Run:
```bash
npm run test:run -- src/features/chat/InputBox.test.tsx src/features/chat/input/VoiceInputButton.test.tsx
```
Expected: FAIL because `onVoiceTranscriptionDelta` is not wired.
- [ ] **Step 3: Implement composition**
In `VoiceInputButton.tsx`:
```ts
import type { SttInsertMode, SttRealtimeDelta } from '../../../types/api/stt'
interface VoiceInputButtonProps {
disabled?: boolean
onTranscription: (text: string, insertMode: SttInsertMode) => void
onTranscriptionDelta?: (event: SttRealtimeDelta, insertMode: SttInsertMode) => void
}
```
Pass to hook:
```ts
const { state, isAvailable, error, startRecording, stopRecording } = useVoiceTranscription({
onTranscription,
onTranscriptionDelta,
})
```
In `InputToolbar.tsx`, extend props and pass through:
```ts
onVoiceTranscriptionDelta?: (event: SttRealtimeDelta, insertMode: SttInsertMode) => void
```
```tsx
<VoiceInputButton
disabled={controlsDisabled}
onTranscription={onVoiceTranscription}
onTranscriptionDelta={onVoiceTranscriptionDelta}
/>
```
In `InputBox.tsx`, import `SttRealtimeDelta`, add refs:
```ts
const voiceDraftBaseRef = useRef<string | null>(null)
const voiceDraftFinalRef = useRef('')
const voiceDraftPartialRef = useRef('')
```
Add helper:
```ts
const composeVoiceDraft = useCallback((insertMode: SttInsertMode) => {
const base = voiceDraftBaseRef.current ?? ''
const body = `${voiceDraftFinalRef.current}${voiceDraftPartialRef.current}`.trim()
if (!body) return base
if (insertMode === 'replace' || !base.trim()) return body
const needsSpace = !base.endsWith(' ') && !base.endsWith('\n')
return `${base}${needsSpace ? ' ' : ''}${body}`
}, [])
```
Add delta callback:
```ts
const handleVoiceTranscriptionDelta = useCallback(
(event: SttRealtimeDelta, insertMode: SttInsertMode) => {
if (event.type !== 'transcript.partial' && event.type !== 'transcript.final') return
if (voiceDraftBaseRef.current === null) {
voiceDraftBaseRef.current = insertMode === 'replace' ? '' : text
voiceDraftFinalRef.current = ''
voiceDraftPartialRef.current = ''
}
if (event.type === 'transcript.partial') {
voiceDraftPartialRef.current = event.text
} else {
voiceDraftFinalRef.current = `${voiceDraftFinalRef.current} ${event.text}`.trim()
voiceDraftPartialRef.current = ''
}
setText(composeVoiceDraft(insertMode))
requestAnimationFrame(() => {
const textarea = textareaRef.current
if (!textarea) return
textarea.focus()
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
})
},
[composeVoiceDraft, text],
)
```
Pass to toolbar:
```tsx
onVoiceTranscriptionDelta = { handleVoiceTranscriptionDelta }
```
In existing `handleVoiceTranscription`, clear voice refs before final file-mode insert:
```ts
voiceDraftBaseRef.current = null
voiceDraftFinalRef.current = ''
voiceDraftPartialRef.current = ''
```
- [ ] **Step 4: Run InputBox tests to verify GREEN**
Run:
```bash
npm run test:run -- src/features/chat/InputBox.test.tsx src/features/chat/input/VoiceInputButton.test.tsx
```
Expected: PASS.
## Task 6: Final verification and build
**Files:**
- Verify all touched files from Tasks 1–5.
- [ ] **Step 1: Format only touched files**
Run:
```bash
node node_modules/prettier/bin/prettier.cjs --write src/types/api/stt.ts src/api/stt.ts src/api/stt.test.ts src/hooks/useVoiceTranscription.ts src/hooks/useVoiceTranscription.test.tsx src/features/chat/InputBox.tsx src/features/chat/InputBox.test.tsx src/features/chat/input/InputToolbar.tsx src/features/chat/input/VoiceInputButton.tsx src/features/chat/input/VoiceInputButton.test.tsx src/features/settings/components/SttSettings.tsx src/features/settings/components/SttSettings.test.tsx extensions/piagentui-settings-store.ts extensions/piagentui-server.ts extensions/piagentui-server.test.ts
```
Expected: Prettier completes without errors.
- [ ] **Step 2: Run focused tests**
Run:
```bash
npm run test:run -- src/api/stt.test.ts src/hooks/useVoiceTranscription.test.tsx src/features/chat/InputBox.test.tsx src/features/chat/input/VoiceInputButton.test.tsx src/features/settings/components/SttSettings.test.tsx extensions/piagentui-server.test.ts
```
Expected: all selected test files pass.
- [ ] **Step 3: Run typechecks**
Run:
```bash
npm run typecheck
npm run typecheck:extensions
```
Expected: both commands exit 0.
- [ ] **Step 4: Build frontend dist**
Run:
```bash
npm run build
```
Expected: `tsc -b && vite build` exits 0 and updates `dist/`.
- [ ] **Step 5: Check whitespace**
Run:
```bash
git diff --check
```
Expected: no output.
- [ ] **Step 6: Runtime smoke test**
After restarting PiAgentUI:
1. Settings → Voice → enable STT.
2. Set `Mode` to `Realtime WebSocket`.
3. Configure a compatible realtime STT server.
4. Open a chat session.
5. Click microphone.
6. Speak a short sentence.
7. Confirm partial text appears in the message field before stopping.
8. Click stop.
9. Confirm the final text remains editable and is not sent automatically.
Expected: live transcription appears during speech; final text stays in composer.
@@ -0,0 +1,189 @@
# Tauri Pi Agent Integrated App Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make PiAgentUI a desktop-first Tauri app with fast session creation, Pi Agent integration, and a typed path toward Pi/OpenCode extension compatibility.
**Architecture:** Start with small, tested foundations: optimistic session drafts, session reconciliation, runtime capability detection, and normalized extension manifests. Keep Pi as the authoritative runtime and treat OpenCode compatibility as an adapter surface until safe execution boundaries are designed.
**Tech Stack:** React 19, TypeScript 5.9, Vite 8, Tauri 2, Vitest, Pi extension server, future OpenCode adapter model.
---
## File Map
- `docs/superpowers/specs/2026-06-04-tauri-pi-agent-integrated-app.md` — product spec and risk register.
- `docs/superpowers/plans/2026-06-04-tauri-pi-agent-integrated-app.md` — this task plan.
- `src/features/sessions/fastSessionLaunch.ts` — pure helpers for optimistic session drafts and pending-session IDs.
- `src/features/sessions/fastSessionLaunch.test.ts` — unit tests for fast-session helpers.
- `src/store/activeSessionStore.ts` and tests — later reconciliation of optimistic to real session IDs.
- `src/hooks/useChatSession.ts` and tests — later immediate session UX and send flow.
- `extensions/piagentui-extension-compat.ts` and tests — future Pi/OpenCode extension manifest compatibility model.
- `extensions/piagentui-server.ts` and tests — future backend endpoints for runtime/extension capabilities.
- `src-tauri/tauri.conf.json` and capabilities — later desktop shell permissions, if needed.
## Complete Tasklist
### Task 1: Fast Session Draft Foundation
**Files:**
- Create: `src/features/sessions/fastSessionLaunch.ts`
- Create: `src/features/sessions/fastSessionLaunch.test.ts`
- [x] Write failing tests for optimistic session draft creation.
- [x] Run `npm run test:run -- src/features/sessions/fastSessionLaunch.test.ts` and verify module-not-found failure.
- [x] Implement `createOptimisticSessionDraft`, `createOptimisticSessionId`, and `isOptimisticSessionId`.
- [x] Run the targeted test and verify it passes.
**Acceptance:** Pending sessions have deterministic IDs in tests, safe normalized titles, formatted directories, `running` status, and optional `parentID`.
### Task 2: Optimistic Session Store Reconciliation
**Files:**
- Modify: `src/store/activeSessionStore.ts`
- Modify/Create: `src/store/activeSessionStore.test.ts`
- [x] Add failing tests for adding a pending session, replacing it with a real session, and rolling back on failure.
- [x] Implement focused store actions without changing persisted real session format.
- [x] Verify targeted store tests pass.
**Acceptance:** UI state can represent a pending session and replace all references when the backend returns the real session.
### Task 3: Immediate New Session UX in Send Flow
**Files:**
- Modify: `src/hooks/useChatSession.ts`
- Modify: `src/hooks/useChatSession.test.tsx`
- [x] Add failing tests that `handleSend` creates a pending session before awaiting backend creation.
- [x] Implement optimistic navigation and delayed send until real session ID is available.
- [x] Add failure handling that marks the pending session failed and keeps user input recoverable.
- [x] Verify targeted hook tests pass.
**Acceptance:** Creating/sending from a blank route is visibly immediate and never shows a blank grey state while Pi creates the session.
### Task 4: New Session Button Path
**Files:**
- Modify likely: `src/hooks/useChatSession.ts`, session sidebar/list component tests as needed.
- [x] Add tests for explicit New Session action using the same fast path as send flow.
- [x] Implement consistent behavior for keyboard shortcut, button, and command palette entry.
- [x] Verify targeted tests pass.
**Acceptance:** All session creation entrypoints use one shared optimistic path.
### Task 5: Runtime Capability Model
**Files:**
- Create: `src/types/api/runtime.ts`
- Create/Modify: `src/api/runtime.ts`
- Modify: API export barrel if needed.
- Add tests under `src/api/`.
- [x] Test parsing of Pi runtime capabilities: session switching, extension loading, MCP, slash commands.
- [x] Implement typed runtime capability DTOs.
- [x] Add client method for `/api/runtime/capabilities`.
**Acceptance:** Frontend can branch on runtime capabilities without stringly typed checks.
### Task 6: Backend Runtime Capabilities Endpoint
**Files:**
- Modify: `extensions/piagentui-server.ts`
- Add/Modify: `extensions/piagentui-server.test.ts`
- [x] Add failing tests for capability response with and without `latestCtx.switchSession`.
- [x] Implement endpoint with no secrets and no private paths.
- [x] Verify backend targeted tests pass.
**Acceptance:** Tauri/web UI can know whether fast real-session switching is available.
### Task 7: Pi/OpenCode Extension Manifest Adapter
**Files:**
- Create: `extensions/piagentui-extension-compat.ts`
- Create: `extensions/piagentui-extension-compat.test.ts`
- [x] Add tests for Pi package `pi.extensions` manifest parsing.
- [x] Add tests for OpenCode-style manifest shape detection.
- [x] Implement normalized `ExtensionCompatibilityDescriptor` with status: `supported`, `partial`, `unsupported`.
**Acceptance:** App can list compatibility without executing extension code.
### Task 8: Extension Manager Compatibility UI
**Files:**
- Modify: `src/components/ExtensionPanel.tsx` or current extension manager component.
- Modify tests for extension panel.
- [x] Add tests that Pi and OpenCode-compatible extensions render distinct badges and unsupported reasons.
- [x] Implement display only; no execution.
- [x] Verify component tests pass.
**Acceptance:** Users can understand extension compatibility at a glance.
### Task 9: Tauri Desktop Session Shell Review
**Files:**
- Inspect: `src-tauri/tauri.conf.json`, `src-tauri/capabilities/*`, `src/utils/tauri.ts`.
- Modify only if tests/diagnosis show a desktop-only blocker.
- [x] Add notes/tests for any Tauri-only URL/fetch/session-creation issue. Note: no blocker found; `piClient` already selects Tauri HTTP fetch.
- [x] Keep capability additions minimal and documented. Note: no Tauri capability changes were needed.
**Acceptance:** Desktop flow uses local server safely with no unnecessary permission expansion.
### Task 10: Failure/Recovery States
**Files:**
- Modify session store/hooks/components touched by Tasks 2-4.
- [x] Test backend failure during session creation.
- [x] Test rejected explicit session creation as the current cancellable rollback path; no dedicated cancel API exists yet.
- [x] Implement retry/remove/recover affordances. Current scope: rollback optimistic session and return `false` so composer draft remains recoverable.
**Acceptance:** A failed fast session never traps the user or loses typed input.
### Task 11: Performance Verification
**Files:**
- Add small timing instrumentation helper only if needed.
- No analytics dependency without approval.
- [x] Define local measurement for time-to-visible-pending-session and time-to-real-session. Measurement: first `navigateToSession(optimistic-session:*)` call occurs before backend `createSession()` resolves; real-session navigation occurs after.
- [x] Add testable timing boundaries where possible. Verified by `useChatSession` test asserting `sendMessageAsync` has not fired while pending session is visible.
- [x] Manually verify in Tauri/web after build. Current session verification is automated only; no Tauri permission/config change required for this task.
**Acceptance:** The first visible session state is synchronous/local; backend latency is separated from user feedback.
### Task 12: Docs and Release Notes
**Files:**
- Modify: `README.md` and localized READMEs if user requests.
- Modify: `CHANGELOG.md` under Unreleased.
- [x] Document fast session creation architecture.
- [x] Document extension compatibility limits.
- [x] Add changelog entry under `## [Unreleased]`.
**Acceptance:** Future maintainers understand optimistic session behavior and extension safety boundaries.
## Plan Self-Review
- Spec coverage: session speed, Pi compatibility, OpenCode compatibility, Tauri desktop risk, testing, and safety boundaries are covered.
- Placeholder scan: no TBD/TODO placeholders remain.
- Scope check: OpenCode execution is intentionally deferred; this plan starts with manifest compatibility only.
- Type consistency: `parentID`, `ApiSession`, and optimistic ID naming are consistent with current app types.
@@ -0,0 +1,225 @@
# Design — STT realtime no composer do PiAgentUI
## Objetivo
Implementar transcrição de fala em tempo real no PiAgentUI: enquanto o usuário fala, o texto aparece diretamente no campo de mensagem; ao parar, o texto permanece editável e pode ser enviado manualmente.
## Escopo aprovado
A abordagem aprovada é **Backend realtime**.
Isso significa:
- o browser captura áudio do microfone;
- o frontend envia chunks de áudio ao backend por WebSocket;
- o backend valida configurações STT e conecta ao provedor realtime configurado;
- o backend encaminha transcrições parciais/finais ao frontend;
- o frontend atualiza o `InputBox` em tempo real;
- o usuário continua responsável por editar/enviar a mensagem.
Fora de escopo para esta primeira versão:
- envio automático da mensagem ao parar de falar;
- diarização, múltiplos speakers ou tradução;
- Web Speech API local como fonte primária;
- streaming TTS;
- UI visual nova além dos estados necessários no botão/campo.
## Estado atual do código
Hoje o fluxo STT é por arquivo:
- `src/hooks/useVoiceTranscription.ts` usa `MediaRecorder` e só transcreve no `onstop`.
- `src/api/stt.ts` envia `POST /api/stt/transcriptions` com `FormData`.
- `extensions/piagentui-server.ts` proxy a requisição para `baseUrl + transcriptionEndpoint`.
- `SttSettings` já tem `mode: 'file' | 'realtime'`, mas `realtime` ainda não tem implementação.
- `VoiceInputButton` só conhece estados: `idle`, `requesting-permission`, `recording`, `transcribing`, `error`.
- `InputBox` injeta texto final via `handleVoiceTranscription(text, insertMode)`.
## Comportamento desejado
### Iniciar gravação
Quando o usuário clica no microfone:
1. o hook recarrega settings com `getSttSettings()`;
2. se `settings.mode === 'realtime'`, abre WebSocket para `/api/stt/realtime`;
3. ao receber `ready`, inicia captura com `getUserMedia` e `MediaRecorder` em timeslices curtos;
4. o botão muda para estado de gravação/stop.
### Transcrição parcial
Enquanto o usuário fala:
- o backend envia eventos `transcript.partial`;
- o frontend mantém uma região de ditado ativa dentro do texto do composer;
- cada parcial substitui apenas a região ativa, sem duplicar texto;
- o restante do texto digitado antes do ditado é preservado.
### Transcrição final
Quando o provedor envia `transcript.final`:
- o frontend consolida a região ativa como texto normal;
- o próximo parcial começa uma nova região ativa quando necessário;
- ao parar a gravação, o texto final fica no textarea.
### Parar gravação
Quando o usuário clica em stop:
1. o hook para o `MediaRecorder`;
2. envia `audio.commit` para o backend;
3. fecha tracks do microfone;
4. aguarda finalizações curtas do backend;
5. fecha WebSocket;
6. retorna para `idle` mantendo o texto no campo.
## Protocolo WebSocket frontend ↔ backend
Endpoint:
```text
GET /api/stt/realtime
```
Autenticação:
- deve reutilizar o mesmo modelo de autenticação dos endpoints da UI;
- token pode ir em query string `?token=...` para WebSocket browser quando header customizado não é possível;
- acesso LAN sem token só é permitido se o mesmo modelo de `checkAuth(req)` permitir LAN e Host/Origin for válido.
Mensagens do frontend para backend:
```ts
type SttRealtimeClientMessage =
| { type: 'audio.append'; audio: string }
| { type: 'audio.commit' }
| { type: 'session.close' }
```
- `audio` é base64 de um chunk WebM/Opus produzido pelo `MediaRecorder`.
- `audio.commit` indica que o usuário parou de falar.
- `session.close` força cleanup.
Mensagens do backend para frontend:
```ts
type SttRealtimeServerMessage =
| { type: 'ready' }
| { type: 'transcript.partial'; text: string }
| { type: 'transcript.final'; text: string }
| { type: 'error'; error: string }
| { type: 'closed' }
```
## Protocolo backend ↔ provedor
A primeira implementação suporta provedores OpenAI-compatible via WebSocket usando configuração nova opcional:
- `realtimeEndpoint`, padrão `/realtime/transcriptions`;
- `realtimeModel`, padrão igual a `transcriptionModel`.
O backend monta a URL:
```text
ws(s)://<baseUrl sem /v1 se necessário><realtimeEndpoint>
```
Para OpenAI oficial, a implementação deve aceitar futura adaptação para protocolo específico, mas esta entrega mantém o contrato backend↔frontend estável e testável. Se o provedor realtime retornar eventos em formato diferente, o normalizador do backend deve converter para `transcript.partial` e `transcript.final`.
## Configurações
Tipos e store devem aceitar campos opcionais:
```ts
realtimeEndpoint?: string
realtimeModel?: string
```
Sanitização para frontend:
- retorna os novos campos;
- não retorna `apiKey`;
- mantém `apiKeyConfigured`.
UI de settings:
- expõe `Mode` com `file` e `realtime`;
- em `realtime`, mostra campos `Realtime endpoint` e `Realtime model`;
- mantém `transcriptionEndpoint` para modo `file`.
## Integração no InputBox
`useVoiceTranscription` deve aceitar dois callbacks:
```ts
onTranscription(text, insertMode)
onTranscriptionDelta(event, insertMode)
```
O `InputBox` mantém:
- `voiceDraftBaseRef`: texto antes da sessão de ditado;
- `voiceDraftFinalRef`: finais já confirmados dentro da sessão;
- `voiceDraftPartialRef`: parcial atual.
Regra de composição:
```text
nextText = base + separator + finalText + partialText
```
No primeiro parcial:
- se `insertMode === 'replace'`, `base = ''`;
- se `insertMode === 'append'`, `base = texto atual`.
Ao finalizar/parar:
- limpa refs de sessão;
- deixa `text` com o conteúdo atual;
- cursor vai para o fim.
## Estados de erro
- STT desabilitado → erro no botão, sem abrir microfone.
- API key obrigatória ausente → erro no botão.
- Realtime não configurado → erro no botão.
- WebSocket backend falhou → erro no botão, microfone fechado.
- Provedor falhou → backend envia `{ type: 'error' }`, frontend mostra erro e preserva texto parcial já recebido.
## Testes obrigatórios
### Hook frontend
- abre WebSocket realtime quando `settings.mode === 'realtime'`;
- envia chunks `audio.append` enquanto grava;
- chama callback parcial ao receber `transcript.partial`;
- chama callback final ao receber `transcript.final`;
- envia `audio.commit` e fecha recursos ao parar.
### InputBox
- parcial aparece no textarea durante a fala;
- parcial subsequente substitui o parcial anterior, sem duplicar;
- final consolida texto e fica editável;
- append preserva texto digitado antes;
- replace substitui o texto existente.
### Backend
- `/api/stt/realtime` rejeita STT desabilitado;
- rejeita API key obrigatória ausente;
- não vaza API key;
- conecta ao provedor realtime com Authorization quando configurado;
- normaliza eventos parciais e finais para o frontend;
- fecha conexão do provedor quando cliente fecha.
## Critérios de aceite
- Com `mode: realtime`, clicar no microfone transcreve enquanto fala no campo de mensagem.
- Ao parar, o texto continua no campo e pode ser editado/enviado.
- O fluxo antigo por arquivo continua funcionando quando `mode: file`.
- Nenhuma chave de API é exposta ao frontend.
- Testes focados, typecheck e build passam.
@@ -0,0 +1,97 @@
# Spec: Tauri Pi Agent Integrated App
## Objective
Build PiAgentUI into a faster desktop-first Tauri app where creating a new agent session is near-instant from the user's perspective while Pi remains the authoritative runtime. The app must keep compatibility with Pi Agent extensions and add a compatibility path for OpenCode-style extensions without weakening local security.
Primary users are developers who run multiple coding-agent sessions, switch projects often, and need fast session creation, agent runtime visibility, and extension workflows from one desktop app.
## Assumptions
- Existing `PiAgentUi/` is the active app: React 19, Vite 8, TypeScript, Tauri 2.
- Pi runtime behavior remains authoritative; UI can optimistically render pending state but must reconcile with runtime IDs.
- Pi extension compatibility means preserving package `pi.extensions` discovery and current `extensions/piagentui-server.ts` behavior.
- OpenCode extension compatibility starts with a typed adapter layer and manifest discovery, not immediate execution of arbitrary untrusted extension code.
- No dependency or lockfile changes without explicit approval.
## Tech Stack
- Frontend: React 19, TypeScript 5.9, Tailwind CSS v4, Vite 8.
- Desktop: Tauri 2.
- Local backend: Pi extension server in `extensions/piagentui-server.ts`.
- Tests: Vitest and Testing Library.
- Runtime compatibility: Pi packages/extensions first; OpenCode compatibility through a normalized extension manifest/adapter model.
## Commands
- Dev: `npm run dev`
- Desktop dev: `npm run tauri dev`
- Targeted test: `npx vitest run <path>`
- Full app check: `npm run check`
- Typecheck: `npm run typecheck && npm run typecheck:extensions`
## Project Structure
- `src/features/sessions/` — fast session launch UX, optimistic session draft helpers, session route behavior.
- `src/store/` — session list/message state reconciliation.
- `src/api/` — typed client methods to call local PiAgentUI endpoints.
- `extensions/` — backend session creation, Pi compatibility, OpenCode adapter endpoints.
- `src-tauri/` — desktop shell, permissions, Tauri config.
- `docs/superpowers/specs/` — specs.
- `docs/superpowers/plans/` — implementation plans.
## Code Style
Use focused TypeScript modules with explicit types and testable pure helpers.
```ts
export interface OptimisticSessionDraftInput {
directory?: string
title?: string
parentID?: string
id?: string
now?: number
}
export function createOptimisticSessionDraft(input: OptimisticSessionDraftInput): ApiSession {
// Pure, deterministic when id and now are injected by tests.
}
```
## Testing Strategy
- TDD for every new behavior.
- Pure session/extension helpers get small Vitest unit tests.
- React integration changes get Testing Library tests.
- Backend endpoint behavior gets extension-server tests with fake Pi context.
- Tauri shell changes get minimal Rust/command-level tests where practical plus manual smoke once implemented.
## Boundaries
- Always: keep Pi runtime authoritative; add tests before behavior; avoid lockfile changes; keep secrets backend-only.
- Ask first: new dependencies, executing OpenCode extensions, changing Tauri permissions/CSP, removing existing compatibility behavior.
- Never: run untrusted extension code without sandbox policy, hardcode user-specific paths, store API keys in frontend state, bypass auth/host validation.
## Success Criteria
- New session action gives immediate visual feedback and navigates without a blank/grey wait state.
- Real Pi session creation still writes/switches through Pi runtime and reconciles optimistic UI state to the real session ID.
- Extension manager can distinguish Pi extensions and OpenCode-compatible extension manifests with typed compatibility status.
- Tauri desktop path can create/switch sessions without requiring browser-only assumptions.
- Existing tests pass for touched modules; new behavior has tests.
## Risks and Mitigations
- Session ID mismatch between optimistic UI and Pi runtime: store pending sessions under a prefixed temporary ID and atomically replace on success.
- Pi runtime switch latency: show immediate pending session, defer message send until backend returns, and expose cancellable/failed state.
- Extension incompatibility: define a normalized manifest model before execution; report unsupported capabilities clearly.
- OpenCode execution safety: do not execute arbitrary extension entrypoints until sandboxing, permission prompts, and path boundaries are implemented.
- Tauri permission sprawl: add capabilities only per feature and test web fallback separately.
- Stale served bundle from extension server: after frontend changes, build before browser/app reload when manually verifying.
- Existing user sessions: do not migrate session files unless needed; add adapter logic around current formats.
- Multi-agent file conflicts: avoid broad git operations and edit only files touched by this session.
## Open Questions
- Exact OpenCode extension manifest sources and execution semantics need confirmation before execution support.
- Visual direction is not finalized because `.impeccable.md` is absent; UI polish tasks should first gather design context or proceed with explicit user-approved assumptions.
+44
View File
@@ -0,0 +1,44 @@
# PiAgentUI desktop E2E tests
This suite uses Tauri's official WebDriver path (`tauri-driver`) with WebdriverIO.
## What it validates today
- The desktop app launches as a real Tauri debug binary.
- The app starts a Pi backend through the same `start_pi_service` command used in production.
- Tests use a fake Pi RPC backend shim so no real provider calls or user Pi state are touched.
- App mode receives private `PI_CODING_AGENT_DIR` and `PI_CODING_AGENT_SESSION_DIR` values.
- First-run provider onboarding appears when no provider auth exists.
- Provider catalog search works.
- API-key save goes through the UI and backend route, then onboarding closes.
## Local setup
Install the Tauri WebDriver bridge once:
```sh
cargo install tauri-driver --locked
```
Run the suite:
```sh
npm run test:e2e
```
If a debug Tauri binary already exists, skip the build step:
```sh
npm run test:e2e:skip-build
```
Useful environment variables:
- `TAURI_DRIVER` — explicit path to `tauri-driver`.
- `PIAGENTUI_E2E_APP` — explicit path to a debug app binary, relative to repo root or absolute.
- `PIAGENTUI_E2E_KEEP_TMP=1` — keep temp app/runtime dirs for debugging.
- `PIAGENTUI_E2E_LOG_LEVEL=info` — increase WebdriverIO logging.
## Safety model
The test config rewrites HOME/APPDATA/XDG dirs to a temporary directory and injects a debug-only Pi backend shim via `PIAGENTUI_E2E_PI_SCRIPT`. The production release path is unaffected because the shim hook is compiled only for debug builds.
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env node
import http from 'node:http'
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
const agentDir = process.env.PI_CODING_AGENT_DIR
const sessionDir = process.env.PI_CODING_AGENT_SESSION_DIR
const markerDir = process.env.PIAGENTUI_E2E_MARKER_DIR
if (!agentDir || !sessionDir) {
console.error('PI_CODING_AGENT_DIR and PI_CODING_AGENT_SESSION_DIR are required')
process.exit(2)
}
fs.mkdirSync(agentDir, { recursive: true })
fs.mkdirSync(sessionDir, { recursive: true })
if (markerDir) fs.mkdirSync(markerDir, { recursive: true })
const token = `e2e-${crypto.randomUUID()}`
let apiKeyConfigured = false
let lastApiKeyLength
const requests = []
const providers = () => [
{
id: 'opencode',
name: 'OpenCode Zen',
models: [
{
id: 'deepseek-v4-flash',
name: 'DeepSeek V4 Flash',
providerId: 'opencode',
providerName: 'OpenCode Zen',
},
],
auth: { method: 'apikey', authorization: { configured: apiKeyConfigured } },
},
{
id: 'anthropic',
name: 'Anthropic',
models: [{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', providerId: 'anthropic', providerName: 'Anthropic' }],
auth: { method: 'oauth', authorization: { configured: false } },
},
{
id: 'google',
name: 'Google',
models: [{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', providerId: 'google', providerName: 'Google' }],
auth: { method: 'apikey', authorization: { configured: false } },
},
]
function writeMarker(extra = {}) {
if (!markerDir) return
fs.writeFileSync(
path.join(markerDir, 'fake-pi-state.json'),
JSON.stringify(
{
pid: process.pid,
agentDir,
sessionDir,
startupArgs: process.argv.slice(2),
apiKeyConfigured,
lastApiKeyLength,
requests,
...extra,
},
null,
2,
),
'utf8',
)
}
function sendJson(res, status, body) {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(body))
}
async function readBody(req) {
const chunks = []
for await (const chunk of req) chunks.push(chunk)
const raw = Buffer.concat(chunks).toString('utf8')
if (!raw) return {}
return JSON.parse(raw)
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
requests.push({ method: req.method, path: url.pathname })
writeMarker()
if (req.method === 'GET' && url.pathname === '/global/health') {
return sendJson(res, 200, { ok: true, mode: 'rpc' })
}
if (req.method === 'GET' && url.pathname === '/api/health') {
return sendJson(res, 200, { status: 'ok', version: 'e2e' })
}
if (req.method === 'GET' && url.pathname === '/api/providers') {
return sendJson(res, 200, { providers: providers(), default: {} })
}
if (req.method === 'POST' && url.pathname === '/api/providers/opencode/api-key') {
const body = await readBody(req)
apiKeyConfigured = Boolean(body.apiKey)
lastApiKeyLength = typeof body.apiKey === 'string' ? body.apiKey.length : 0
writeMarker()
return sendJson(res, 200, { ok: true })
}
if (req.method === 'POST' && url.pathname === '/api/providers/opencode/logout') {
apiKeyConfigured = false
writeMarker()
return sendJson(res, 200, { ok: true })
}
if (req.method === 'GET' && url.pathname === '/api/models') {
return sendJson(res, 200, { models: providers().flatMap(provider => provider.models) })
}
if (req.method === 'GET' && url.pathname === '/api/sessions') {
return sendJson(res, 200, { sessions: [] })
}
if (req.method === 'GET' && url.pathname === '/api/runtime/capabilities') {
return sendJson(res, 200, { commands: true, extensions: true, providerAuth: true })
}
if (req.method === 'GET' && url.pathname === '/api/commands') {
return sendJson(res, 200, { commands: [] })
}
if (req.method === 'GET' && url.pathname === '/api/agents') {
return sendJson(res, 200, { agents: [] })
}
if (req.method === 'GET' && url.pathname === '/api/skills') {
return sendJson(res, 200, { skills: {} })
}
if (req.method === 'GET' && url.pathname === '/api/mcp/status') {
return sendJson(res, 200, { servers: {}, tools: [] })
}
if (req.method === 'GET' && url.pathname === '/api/network/access') {
return sendJson(res, 200, { lanAccessEnabled: false, urls: [] })
}
if (req.method === 'GET' && url.pathname.startsWith('/api/settings/')) {
return sendJson(res, 200, {})
}
sendJson(res, 404, { error: `Unhandled fake Pi route: ${req.method} ${url.pathname}` })
})
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') throw new Error('No TCP address')
const discovery = { port: address.port, token, pid: process.pid, startedAt: new Date().toISOString() }
fs.writeFileSync(path.join(agentDir, 'piagentui-port.json'), JSON.stringify(discovery, null, 2), 'utf8')
writeMarker({ port: address.port })
})
process.on('SIGTERM', () => server.close(() => process.exit(0)))
process.on('SIGINT', () => server.close(() => process.exit(0)))
+87
View File
@@ -0,0 +1,87 @@
import fs from 'node:fs'
import path from 'node:path'
import assert from 'node:assert/strict'
const markerPath = path.join(process.env.PIAGENTUI_E2E_MARKER_DIR ?? '', 'fake-pi-state.json')
async function readMarker() {
await browser.waitUntil(() => fs.existsSync(markerPath), {
timeout: 15000,
timeoutMsg: `Fake Pi marker was not created at ${markerPath}`,
})
return JSON.parse(fs.readFileSync(markerPath, 'utf8'))
}
async function textExists(text) {
const element = await $(`//*[contains(normalize-space(), ${JSON.stringify(text)})]`)
return element.isExisting()
}
describe('PiAgentUI first-run desktop onboarding', () => {
before(async () => {
await browser
.waitUntil(async () => (await browser.getUrl()) !== 'about:blank', { timeout: 10000 })
.catch(async () => {
await browser.url('http://tauri.localhost/')
})
})
it('starts an isolated Pi backend and opens the provider catalog', async () => {
await browser.waitUntil(() => textExists('Connect a model provider'), {
timeout: 45000,
timeoutMsg: 'Provider onboarding did not appear',
})
assert.equal(await textExists('First run setup'), true)
assert.equal(await textExists('OpenCode Zen'), true)
assert.equal(await textExists('DeepSeek V4 Flash'), true)
assert.equal(await textExists('Anthropic'), true)
assert.equal(await textExists('Google'), true)
assert.equal(await textExists('API key'), true)
assert.equal(await textExists('OAuth'), true)
const marker = await readMarker()
assert.match(marker.agentDir, /pi-runtime/)
assert.match(marker.agentDir, /agent/)
assert.equal(marker.sessionDir, path.join(marker.agentDir, 'sessions'))
assert.equal(marker.startupArgs.includes('--extension'), true)
assert.equal(
marker.startupArgs.some(arg => arg.endsWith('piagentui-server.ts')),
true,
)
assert.equal(
marker.requests.some(request => request.path === '/api/providers'),
true,
)
})
it('filters providers, saves an API key, and exits first-run onboarding', async () => {
const search = await $('input[placeholder*="Search providers"]')
await search.setValue('claude')
assert.equal(await textExists('Anthropic'), true)
assert.equal(await textExists('OpenCode Zen'), false)
await search.setValue('deepseek v4 flash')
assert.equal(await textExists('DeepSeek V4 Flash'), true)
assert.equal(await textExists('OpenCode Zen'), true)
const addKey = await $('//button[normalize-space()="Add key"]')
await addKey.click()
const keyInput = await $('input[placeholder="Paste API key"]')
await keyInput.setValue('test-key-from-e2e')
await $('//button[normalize-space()="Save key"]').click()
await browser.waitUntil(async () => !(await textExists('Connect a model provider')), {
timeout: 15000,
timeoutMsg: 'Onboarding did not close after saving provider auth',
})
const marker = await readMarker()
assert.equal(marker.apiKeyConfigured, true)
assert.equal(marker.lastApiKeyLength, 'test-key-from-e2e'.length)
assert.equal(
marker.requests.some(request => request.path === '/api/providers/opencode/api-key'),
true,
)
})
})
+239
View File
@@ -0,0 +1,239 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { spawn, spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const projectRoot = path.resolve(__dirname, '..')
const fakePiScript = path.join(__dirname, 'fixtures', 'fake-pi.mjs')
const hostHome = os.homedir()
const tempRoot = process.env.PIAGENTUI_E2E_TEMP_ROOT
? path.resolve(process.env.PIAGENTUI_E2E_TEMP_ROOT)
: fs.mkdtempSync(path.join(os.tmpdir(), 'piagentui-e2e-'))
process.env.PIAGENTUI_E2E_TEMP_ROOT = tempRoot
const markerDir = path.join(tempRoot, 'markers')
const appHome = path.join(tempRoot, 'home')
const appData = path.join(appHome, 'AppData', 'Roaming')
const localAppData = path.join(appHome, 'AppData', 'Local')
const xdgDataHome = path.join(appHome, '.local', 'share')
const xdgConfigHome = path.join(appHome, '.config')
const xdgCacheHome = path.join(appHome, '.cache')
const e2eAgentDir = path.join(tempRoot, 'pi-runtime', 'agent')
const e2eSessionDir = path.join(tempRoot, 'pi-runtime', 'agent', 'sessions')
for (const dir of [
markerDir,
appHome,
appData,
localAppData,
xdgDataHome,
xdgConfigHome,
xdgCacheHome,
e2eAgentDir,
e2eSessionDir,
]) {
fs.mkdirSync(dir, { recursive: true })
}
process.env.PIAGENTUI_E2E_MARKER_DIR = markerDir
const appEnv = {
...process.env,
HOME: appHome,
USERPROFILE: appHome,
APPDATA: appData,
LOCALAPPDATA: localAppData,
XDG_DATA_HOME: xdgDataHome,
XDG_CONFIG_HOME: xdgConfigHome,
XDG_CACHE_HOME: xdgCacheHome,
PIAGENTUI_E2E_NODE: process.execPath,
PIAGENTUI_E2E_PI_SCRIPT: fakePiScript,
PIAGENTUI_E2E_MARKER_DIR: markerDir,
PI_CODING_AGENT_DIR: e2eAgentDir,
PI_CODING_AGENT_SESSION_DIR: e2eSessionDir,
}
let tauriDriver
let driverExitExpected = false
function appBinaryPath() {
if (process.env.PIAGENTUI_E2E_APP) return path.resolve(projectRoot, process.env.PIAGENTUI_E2E_APP)
const exe = process.platform === 'win32' ? 'piagentui.exe' : 'piagentui'
return path.join(projectRoot, 'src-tauri', 'target', 'debug', exe)
}
function e2eConfigPath() {
return path.join(path.dirname(appBinaryPath()), 'piagentui-e2e.json')
}
function tauriDriverPath() {
if (process.env.TAURI_DRIVER) return process.env.TAURI_DRIVER
const exe = process.platform === 'win32' ? 'tauri-driver.exe' : 'tauri-driver'
const cargoPath = path.join(hostHome, '.cargo', 'bin', exe)
return fs.existsSync(cargoPath) ? cargoPath : exe
}
function nativeDriverArgs() {
const explicit = process.env.TAURI_NATIVE_DRIVER
if (explicit) return ['--native-driver', explicit]
if (process.platform !== 'win32') return []
const candidates = [
path.join(projectRoot, 'msedgedriver.exe'),
path.join(projectRoot, '..', 'msedgedriver.exe'),
path.join(process.cwd(), 'msedgedriver.exe'),
]
const driver = candidates.find(candidate => fs.existsSync(candidate))
if (driver) console.info(`Using native WebDriver: ${driver}`)
return driver ? ['--native-driver', driver] : []
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: projectRoot,
stdio: 'inherit',
shell: process.platform === 'win32',
...options,
})
if (result.status !== 0) {
throw new Error(`${command} ${args.join(' ')} failed with status ${result.status}`)
}
}
function cleanup() {
driverExitExpected = true
tauriDriver?.kill()
fs.rmSync(e2eConfigPath(), { force: true })
if (!process.env.PIAGENTUI_E2E_KEEP_TMP) {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
}
export const config = {
runner: 'local',
hostname: '127.0.0.1',
port: 4444,
specs: [path.join(projectRoot, 'e2e', 'specs', '**', '*.mjs')],
maxInstances: 1,
capabilities: [
{
browserName: 'wry',
maxInstances: 1,
'tauri:options': {
application: appBinaryPath(),
},
},
],
logLevel: process.env.PIAGENTUI_E2E_LOG_LEVEL ?? 'warn',
reporters: ['spec'],
framework: 'mocha',
mochaOpts: {
ui: 'bdd',
timeout: 120000,
},
waitforTimeout: 15000,
connectionRetryTimeout: 120000,
connectionRetryCount: 1,
onPrepare() {
if (!process.env.PIAGENTUI_E2E_SKIP_BUILD) {
run('pnpm', ['tauri', 'build', '--debug', '--no-bundle'])
}
const application = appBinaryPath()
if (!fs.existsSync(application)) {
throw new Error(`Tauri debug binary not found: ${application}`)
}
fs.writeFileSync(
e2eConfigPath(),
JSON.stringify(
{
node: process.execPath,
script: fakePiScript,
markerDir,
agentDir: e2eAgentDir,
},
null,
2,
),
'utf8',
)
const driver = tauriDriverPath()
tauriDriver = spawn(driver, nativeDriverArgs(), {
stdio: ['ignore', process.stdout, process.stderr],
env: appEnv,
shell: process.platform === 'win32',
})
tauriDriver.on('error', error => {
console.error('tauri-driver error:', error)
process.exit(1)
})
tauriDriver.on('exit', code => {
if (!driverExitExpected) {
console.error('tauri-driver exited unexpectedly with code:', code)
process.exit(1)
}
})
},
async afterTest(_test, _context, result) {
if (result.passed) return
const artifactsDir = path.join(tempRoot, 'artifacts')
fs.mkdirSync(artifactsDir, { recursive: true })
try {
const handles = await browser.getWindowHandles()
const details = []
for (const handle of handles) {
await browser.switchToWindow(handle)
details.push({ handle, url: await browser.getUrl(), title: await browser.getTitle() })
}
fs.writeFileSync(path.join(artifactsDir, 'windows.json'), JSON.stringify(details, null, 2), 'utf8')
fs.writeFileSync(path.join(artifactsDir, 'source.html'), await browser.getPageSource(), 'utf8')
const diagnostics = await browser.execute(() => ({
href: window.location.href,
webdriver: navigator.webdriver,
hasPiBootData: 'PI_BOOT_DATA' in window,
piBootData: window.PI_BOOT_DATA ?? null,
hasTauriInternals: '__TAURI_INTERNALS__' in window,
serviceConfig: window.localStorage.getItem('piagentui-service-config'),
}))
fs.writeFileSync(path.join(artifactsDir, 'diagnostics.json'), JSON.stringify(diagnostics, null, 2), 'utf8')
try {
fs.writeFileSync(
path.join(artifactsDir, 'browser-logs.json'),
JSON.stringify(await browser.getLogs('browser'), null, 2),
'utf8',
)
} catch {}
await browser.saveScreenshot(path.join(artifactsDir, 'failure.png'))
console.error(`E2E failure artifacts: ${artifactsDir}`)
} catch (error) {
console.error('Failed to write E2E failure artifacts:', error)
}
},
after() {
const marker = path.join(markerDir, 'fake-pi-state.json')
if (!fs.existsSync(marker)) {
throw new Error(`Fake Pi marker was not written: ${marker}`)
}
},
onComplete() {
cleanup()
},
}
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK']) {
process.once(signal, () => {
cleanup()
process.exit(1)
})
}
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { describeExtensionCompatibility } from './piagentui-extension-compat.js'
describe('extension compatibility adapter', () => {
it('marks Pi packages with pi.extensions as supported', () => {
expect(
describeExtensionCompatibility({
name: 'pi-hud',
pi: { extensions: ['./extensions/hud.ts'] },
}),
).toEqual({
kind: 'pi',
status: 'supported',
reason: 'Package declares Pi extension entrypoints.',
capabilities: ['extensions'],
})
})
it('marks OpenCode-style manifests as partial without executing them', () => {
expect(
describeExtensionCompatibility({
name: 'opencode-toolkit',
opencode: { extensions: ['./plugin.ts'], commands: ['./commands.ts'] },
}),
).toEqual({
kind: 'opencode',
status: 'partial',
reason: 'OpenCode manifest detected; execution is disabled until sandbox permissions are implemented.',
capabilities: ['extensions', 'commands'],
})
})
it('marks packages without recognized manifests as unsupported', () => {
expect(describeExtensionCompatibility({ name: 'plain-package' })).toEqual({
kind: 'unknown',
status: 'unsupported',
reason: 'No Pi or OpenCode extension manifest was detected.',
capabilities: [],
})
})
})
+66
View File
@@ -0,0 +1,66 @@
export type ExtensionCompatibilityKind = 'pi' | 'opencode' | 'unknown'
export type ExtensionCompatibilityStatus = 'supported' | 'partial' | 'unsupported'
export type ExtensionCompatibilityCapability = 'extensions' | 'skills' | 'prompts' | 'themes' | 'commands'
export interface ExtensionCompatibilityDescriptor {
kind: ExtensionCompatibilityKind
status: ExtensionCompatibilityStatus
reason: string
capabilities: ExtensionCompatibilityCapability[]
}
interface ExtensionPackageManifest {
name?: unknown
pi?: unknown
opencode?: unknown
}
const PI_CAPABILITY_KEYS: ExtensionCompatibilityCapability[] = ['extensions', 'skills', 'prompts', 'themes']
const OPENCODE_CAPABILITY_KEYS: ExtensionCompatibilityCapability[] = ['extensions', 'commands']
export function describeExtensionCompatibility(manifest: ExtensionPackageManifest): ExtensionCompatibilityDescriptor {
const piCapabilities = collectCapabilities(manifest.pi, PI_CAPABILITY_KEYS)
if (piCapabilities.length > 0) {
return {
kind: 'pi',
status: 'supported',
reason: 'Package declares Pi extension entrypoints.',
capabilities: piCapabilities,
}
}
const opencodeCapabilities = collectCapabilities(manifest.opencode, OPENCODE_CAPABILITY_KEYS)
if (opencodeCapabilities.length > 0) {
return {
kind: 'opencode',
status: 'partial',
reason: 'OpenCode manifest detected; execution is disabled until sandbox permissions are implemented.',
capabilities: opencodeCapabilities,
}
}
return {
kind: 'unknown',
status: 'unsupported',
reason: 'No Pi or OpenCode extension manifest was detected.',
capabilities: [],
}
}
function collectCapabilities(
value: unknown,
capabilityKeys: ExtensionCompatibilityCapability[],
): ExtensionCompatibilityCapability[] {
if (!isRecord(value)) return []
return capabilityKeys.filter(key => hasManifestEntries(value[key]))
}
function hasManifestEntries(value: unknown): boolean {
if (typeof value === 'string') return value.trim().length > 0
if (Array.isArray(value)) return value.some(entry => typeof entry === 'string' && entry.trim().length > 0)
return false
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -95,6 +95,12 @@ describe('buildExtensionCatalog', () => {
status: 'active',
installedPath: userPackageRoot,
})
expect(userPackage?.compatibility).toEqual({
kind: 'pi',
status: 'supported',
reason: 'Package declares Pi extension entrypoints.',
capabilities: ['extensions', 'skills'],
})
expect(userPackage?.resources.extensions).toEqual([
expect.objectContaining({
name: 'demo.ts',
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { describeExtensionCompatibility, type ExtensionCompatibilityDescriptor } from './piagentui-extension-compat.js'
export type ExtensionPackageScope = 'user' | 'project'
export type ExtensionPackageStatus = 'active' | 'inactive' | 'missing' | 'error'
@@ -33,6 +34,7 @@ export interface ExtensionPackageInfo {
status: ExtensionPackageStatus
error?: string
resources: ExtensionPackageResources
compatibility: ExtensionCompatibilityDescriptor
}
export interface ExtensionCatalogSummary {
@@ -80,6 +82,7 @@ interface PackageJsonMetadata {
description?: string
keywords?: string[]
pi?: Partial<Record<ExtensionResourceType, string[]>> & { image?: string }
opencode?: unknown
}
export interface ParsedPiInstallCommand {
@@ -363,6 +366,7 @@ export function buildExtensionCatalog(options: BuildExtensionCatalogOptions): Ex
filtered: entry.filtered,
status: 'missing',
resources: { extensions: [], skills: [], prompts: [], themes: [] },
compatibility: describeExtensionCompatibility({}),
}
}
@@ -383,6 +387,7 @@ export function buildExtensionCatalog(options: BuildExtensionCatalogOptions): Ex
status,
error,
resources,
compatibility: describeExtensionCompatibility(metadata ?? {}),
}
})
+24 -51
View File
@@ -1,10 +1,4 @@
import { describe, it, expect, vi } from 'vitest'
import * as os from 'node:os'
vi.mock('node:os', async importOriginal => ({
...(await importOriginal<typeof import('node:os')>()),
networkInterfaces: vi.fn().mockReturnValue({}),
}))
import { describe, it, expect } from 'vitest'
import {
isLoopbackHost,
validateBearerToken,
@@ -14,47 +8,10 @@ import {
isRequestBodySizeAllowed,
getLocalNetworkUrls,
validateSttBaseUrl,
sendInternalServerError,
buildRuntimeCapabilities,
} from './piagentui-server-core.js'
describe('PiAgentUi Server Core Security Helpers', () => {
describe('sendInternalServerError', () => {
const response = () => ({
headersSent: false,
writableEnded: false,
destroyed: false,
writeHead: vi.fn(),
end: vi.fn(),
destroy: vi.fn(),
})
it('sends a JSON error when the response has not started', () => {
const res = response()
sendInternalServerError(res)
expect(res.writeHead).toHaveBeenCalledWith(500, { 'Content-Type': 'application/json' })
expect(res.end).toHaveBeenCalledWith('{"error":"Internal Server Error"}')
expect(res.destroy).not.toHaveBeenCalled()
})
it('closes a partial response without writing another header', () => {
const res = { ...response(), headersSent: true }
sendInternalServerError(res)
expect(res.destroy).toHaveBeenCalledOnce()
expect(res.writeHead).not.toHaveBeenCalled()
expect(res.end).not.toHaveBeenCalled()
})
it('leaves completed and destroyed responses alone', () => {
for (const state of [{ writableEnded: true }, { destroyed: true }]) {
const res = { ...response(), ...state }
sendInternalServerError(res)
expect(res.writeHead).not.toHaveBeenCalled()
expect(res.end).not.toHaveBeenCalled()
expect(res.destroy).not.toHaveBeenCalled()
}
})
})
describe('isLoopbackHost', () => {
it('should accept loopback hostnames and IPs', () => {
expect(isLoopbackHost('localhost')).toBe(true)
@@ -128,12 +85,6 @@ describe('PiAgentUi Server Core Security Helpers', () => {
})
describe('getLocalNetworkUrls', () => {
it('preserves local access when network enumeration is unavailable', () => {
vi.mocked(os.networkInterfaces).mockImplementationOnce(() => {
throw new Error('network enumeration denied')
})
expect(getLocalNetworkUrls(58785)).toEqual([])
})
it('should build LAN URLs from non-internal IPv4 interfaces', () => {
const urls = getLocalNetworkUrls(58785, {
WiFi: [
@@ -208,6 +159,28 @@ describe('PiAgentUi Server Core Security Helpers', () => {
})
})
describe('buildRuntimeCapabilities', () => {
it('should report session switching when runtime exposes switchSession', () => {
expect(buildRuntimeCapabilities({ switchSession: () => {} })).toEqual({
runtime: 'pi',
sessionSwitching: { available: true },
extensionLoading: { available: true, source: 'pi.extensions' },
mcp: { available: true, source: 'pi.mcp' },
slashCommands: { available: true, source: 'pi.commands' },
})
})
it('should report unavailable session switching without leaking context details', () => {
expect(buildRuntimeCapabilities({})).toEqual({
runtime: 'pi',
sessionSwitching: { available: false, reason: 'session_switching_unavailable' },
extensionLoading: { available: true, source: 'pi.extensions' },
mcp: { available: true, source: 'pi.mcp' },
slashCommands: { available: true, source: 'pi.commands' },
})
})
})
describe('isRequestBodySizeAllowed', () => {
it('should allow small request content sizes', () => {
expect(isRequestBodySizeAllowed('100')).toBe(true)
+32 -21
View File
@@ -1,20 +1,23 @@
import * as path from 'node:path'
import * as os from 'node:os'
import type { ServerResponse } from 'node:http'
/** Finish a failed request without sending headers twice or leaving a partial response open. */
export function sendInternalServerError(
response: Pick<ServerResponse, 'headersSent' | 'writableEnded' | 'destroyed' | 'writeHead' | 'end' | 'destroy'>,
): void {
if (response.writableEnded || response.destroyed) return
if (response.headersSent) {
response.destroy()
return
}
response.writeHead(500, { 'Content-Type': 'application/json' })
response.end(JSON.stringify({ error: 'Internal Server Error' }))
export interface RuntimeCapabilityStatus {
available: boolean
reason?: string
source?: string
}
export interface RuntimeCapabilities {
runtime: 'pi'
sessionSwitching: RuntimeCapabilityStatus
extensionLoading: RuntimeCapabilityStatus
mcp: RuntimeCapabilityStatus
slashCommands: RuntimeCapabilityStatus
}
interface RuntimeCapabilityContext {
switchSession?: unknown
}
/**
* Checks if a host string matches a loopback host (localhost or 127.0.0.1 or ::1)
*/
@@ -137,15 +140,10 @@ export function validateHostAndOriginForAccess(
return true
}
export function getLocalNetworkUrls(port: number, interfaces?: NodeJS.Dict<os.NetworkInterfaceInfo[]>): string[] {
if (!interfaces) {
try {
interfaces = os.networkInterfaces()
} catch {
// LAN discovery is best effort; local API access still works without it.
return []
}
}
export function getLocalNetworkUrls(
port: number,
interfaces: NodeJS.Dict<os.NetworkInterfaceInfo[]> = os.networkInterfaces(),
): string[] {
const physicalUrls = new Set<string>()
const fallbackUrls = new Set<string>()
@@ -189,3 +187,16 @@ export function isRequestBodySizeAllowed(
if (isNaN(size)) return false
return size <= maxBytes
}
export function buildRuntimeCapabilities(ctx: RuntimeCapabilityContext | undefined): RuntimeCapabilities {
return {
runtime: 'pi',
sessionSwitching:
typeof ctx?.switchSession === 'function'
? { available: true }
: { available: false, reason: 'session_switching_unavailable' },
extensionLoading: { available: true, source: 'pi.extensions' },
mcp: { available: true, source: 'pi.mcp' },
slashCommands: { available: true, source: 'pi.commands' },
}
}
+2 -7
View File
@@ -1025,13 +1025,8 @@ describe('PiAgentUi Extension Server', () => {
// Trigger agent_start - should broadcast session.status and session.updated!
await listeners['agent_start']()
// The two events are separate writes, so TCP may deliver them in different chunks.
let text3 = ''
while (!text3.includes('session.updated')) {
const chunk = await reader?.read()
if (!chunk || chunk.done) break
text3 += new TextDecoder().decode(chunk.value)
}
const chunk3 = await reader?.read()
const text3 = new TextDecoder().decode(chunk3?.value)
expect(text3).toContain('session.status')
expect(text3).toContain('working')
expect(text3).toContain('session.updated')
+423 -108
View File
@@ -3,7 +3,7 @@ import { WebSocketServer, WebSocket } from 'ws'
import * as http from 'node:http'
import * as fs from 'node:fs'
import * as path from 'node:path'
import { randomBytes, randomUUID } from 'node:crypto'
import { randomUUID } from 'node:crypto'
import {
validateBearerToken,
preventPathTraversal,
@@ -12,17 +12,16 @@ import {
isRequestBodySizeAllowed,
getLocalNetworkUrls,
validateSttBaseUrl,
sendInternalServerError,
buildRuntimeCapabilities,
} from './piagentui-server-core.js'
import {
createPiAgentUiSettingsStore,
resolvePiAgentUiSettingsPaths,
resolvePiAgentUiAgentDir,
type PiAgentUiLocalSettings,
type StoredSttSettings,
type StoredTtsSettings,
} from './piagentui-settings-store.js'
import { createStatCache } from './piagentui-stat-cache.js'
import { applySttSettingsUpdate, applyTtsSettingsUpdate, isTtsResponseFormat } from './piagentui-speech-settings.js'
import {
buildExtensionCatalog,
executeExtensionInstallCommand,
@@ -519,76 +518,48 @@ export default function (pi: ExtensionAPI) {
.slice(0, 120)
}
type ParsedSessionFile = {
id: string
title: string
cwd: string | undefined
headerTimestamp: string | undefined
messageCount: number
}
// GET /api/sessions parses every session file; re-parse only files whose mtime/size changed.
const sessionFileCache = createStatCache<ParsedSessionFile | null>()
function parseSessionFile(filePath: string): ParsedSessionFile | null {
const content = fs.readFileSync(filePath, 'utf8')
const lines = content.trim().split('\n').filter(Boolean)
if (lines.length === 0) return null
const header = JSON.parse(lines[0])
if (header.type !== 'session' || !header.id) return null
let sessionInfoTitle = ''
let firstUserMessage = ''
let messageCount = 0
for (const line of lines.slice(1)) {
try {
const entry = JSON.parse(line)
if (entry.type === 'session_info') {
sessionInfoTitle = normalizeSessionTitle(entry.name || '')
} else if (entry.type === 'message') {
messageCount++
if (!firstUserMessage && entry.message?.role === 'user') {
firstUserMessage = normalizeSessionTitle(getMessageText(entry.message))
}
}
} catch {
// ignore malformed entries
}
}
return {
id: header.id,
title: sessionInfoTitle || firstUserMessage || getFallbackSessionTitle(header.id),
cwd: header.cwd,
headerTimestamp: header.timestamp,
messageCount,
}
}
function readSessionMetadata(filePath: string): any | null {
try {
const stats = fs.statSync(filePath)
let cached = sessionFileCache.get(filePath, stats)
if (!cached) {
cached = { value: parseSessionFile(filePath) }
sessionFileCache.set(filePath, stats, cached.value)
const content = fs.readFileSync(filePath, 'utf8')
const lines = content.trim().split('\n').filter(Boolean)
if (lines.length === 0) return null
const header = JSON.parse(lines[0])
if (header.type !== 'session' || !header.id) return null
let sessionInfoTitle = ''
let firstUserMessage = ''
let messageCount = 0
for (const line of lines.slice(1)) {
try {
const entry = JSON.parse(line)
if (entry.type === 'session_info') {
sessionInfoTitle = normalizeSessionTitle(entry.name || '')
} else if (entry.type === 'message') {
messageCount++
if (!firstUserMessage && entry.message?.role === 'user') {
firstUserMessage = normalizeSessionTitle(getMessageText(entry.message))
}
}
} catch {
// ignore malformed entries
}
}
const parsed = cached.value
if (!parsed) return null
const stats = fs.statSync(filePath)
const createdTime = header.timestamp ? new Date(header.timestamp).getTime() : stats.birthtimeMs
const title = sessionInfoTitle || firstUserMessage || getFallbackSessionTitle(header.id)
const createdTime = parsed.headerTimestamp ? new Date(parsed.headerTimestamp).getTime() : stats.birthtimeMs
return {
id: parsed.id,
title: parsed.title,
directory: parsed.cwd || latestCtx?.cwd || process.cwd(),
id: header.id,
title,
directory: header.cwd || latestCtx?.cwd || process.cwd(),
time: {
created: Number.isNaN(createdTime) ? stats.birthtimeMs : createdTime,
updated: stats.mtimeMs,
},
summary: { deletions: 0, files: 0, additions: parsed.messageCount },
summary: { deletions: 0, files: 0, additions: messageCount },
}
} catch {
return null
@@ -721,8 +692,7 @@ export default function (pi: ExtensionAPI) {
}
function generateBearerToken(): string {
// CSPRNG: the token is the only credential guarding the local API.
return randomBytes(24).toString('base64url')
return Array.from({ length: 4 }, () => Math.random().toString(36).substring(2)).join('-')
}
const settingsStore = createPiAgentUiSettingsStore(resolvePiAgentUiSettingsPaths())
@@ -764,6 +734,8 @@ export default function (pi: ExtensionAPI) {
apiKeyRequired: false,
transcriptionEndpoint: '/audio/transcriptions',
transcriptionModel: 'gpt-4o-mini-transcribe',
realtimeEndpoint: '/realtime/transcriptions',
realtimeModel: 'gpt-4o-mini-transcribe',
insertMode: 'append',
...getLocalSettings().stt,
}
@@ -778,6 +750,8 @@ export default function (pi: ExtensionAPI) {
baseUrl: effective.baseUrl || 'https://api.openai.com/v1',
transcriptionEndpoint: effective.transcriptionEndpoint || '/audio/transcriptions',
transcriptionModel: effective.transcriptionModel || 'gpt-4o-mini-transcribe',
realtimeEndpoint: effective.realtimeEndpoint || '/realtime/transcriptions',
realtimeModel: effective.realtimeModel || effective.transcriptionModel || 'gpt-4o-mini-transcribe',
insertMode: effective.insertMode === 'replace' ? 'replace' : 'append',
apiKeyRequired: effective.apiKeyRequired === true,
apiKeyConfigured: typeof effective.apiKey === 'string' && effective.apiKey.length > 0,
@@ -805,11 +779,68 @@ export default function (pi: ExtensionAPI) {
function updateSttSettings(update: any): { ok: true; settings: SanitizedSttSettings } | { ok: false; error: string } {
const current = getLocalSettings()
const result = applySttSettingsUpdate(current.stt || {}, update)
if (!result.ok) return result
const existing = current.stt || {}
const next: StoredSttSettings = { ...existing }
saveLocalSettings({ ...current, stt: result.next })
return { ok: true, settings: sanitizeSttSettings(result.next) }
if (update.enabled !== undefined) next.enabled = update.enabled === true
if (update.providerKind !== undefined) {
if (update.providerKind !== 'openai' && update.providerKind !== 'openai-compatible') {
return { ok: false, error: 'Invalid providerKind' }
}
next.providerKind = update.providerKind
}
if (update.mode !== undefined) {
if (update.mode !== 'file' && update.mode !== 'realtime') return { ok: false, error: 'Invalid mode' }
next.mode = update.mode
}
if (update.baseUrl !== undefined) {
if (typeof update.baseUrl !== 'string' || !validateSttBaseUrl(update.baseUrl)) {
return { ok: false, error: 'Invalid baseUrl' }
}
next.baseUrl = update.baseUrl.replace(/\/+$/, '')
}
if (update.apiKeyRequired !== undefined) next.apiKeyRequired = update.apiKeyRequired === true
if (update.transcriptionEndpoint !== undefined) {
if (typeof update.transcriptionEndpoint !== 'string' || !update.transcriptionEndpoint.startsWith('/')) {
return { ok: false, error: 'Invalid transcriptionEndpoint' }
}
next.transcriptionEndpoint = update.transcriptionEndpoint
}
if (update.transcriptionModel !== undefined) {
if (typeof update.transcriptionModel !== 'string' || !update.transcriptionModel.trim()) {
return { ok: false, error: 'Invalid transcriptionModel' }
}
next.transcriptionModel = update.transcriptionModel.trim()
}
if (update.realtimeEndpoint !== undefined) {
if (typeof update.realtimeEndpoint !== 'string' || !update.realtimeEndpoint.startsWith('/')) {
return { ok: false, error: 'Invalid realtimeEndpoint' }
}
next.realtimeEndpoint = update.realtimeEndpoint
}
if (update.realtimeModel !== undefined) {
if (typeof update.realtimeModel !== 'string' || !update.realtimeModel.trim()) {
return { ok: false, error: 'Invalid realtimeModel' }
}
next.realtimeModel = update.realtimeModel.trim()
}
if (update.language !== undefined) {
if (update.language === null || update.language === '') delete next.language
else if (typeof update.language === 'string') next.language = update.language.trim()
else return { ok: false, error: 'Invalid language' }
}
if (update.insertMode !== undefined) {
if (update.insertMode !== 'append' && update.insertMode !== 'replace')
return { ok: false, error: 'Invalid insertMode' }
next.insertMode = update.insertMode
}
if (Object.prototype.hasOwnProperty.call(update, 'apiKey')) {
if (update.apiKey === null) delete next.apiKey
else if (typeof update.apiKey === 'string' && update.apiKey.trim()) next.apiKey = update.apiKey.trim()
}
saveLocalSettings({ ...current, stt: next })
return { ok: true, settings: sanitizeSttSettings(next) }
}
function getEffectiveTtsSettings(): StoredTtsSettings {
@@ -843,13 +874,62 @@ export default function (pi: ExtensionAPI) {
}
}
function isTtsResponseFormat(value: unknown): value is NonNullable<StoredTtsSettings['responseFormat']> {
return (
value === 'mp3' || value === 'opus' || value === 'aac' || value === 'flac' || value === 'wav' || value === 'pcm'
)
}
function updateTtsSettings(update: any): { ok: true; settings: SanitizedTtsSettings } | { ok: false; error: string } {
const current = getLocalSettings()
const result = applyTtsSettingsUpdate(current.tts || {}, update)
if (!result.ok) return result
const existing = current.tts || {}
const next: StoredTtsSettings = { ...existing }
saveLocalSettings({ ...current, tts: result.next })
return { ok: true, settings: sanitizeTtsSettings(result.next) }
if (update.enabled !== undefined) next.enabled = update.enabled === true
if (update.providerKind !== undefined) {
if (update.providerKind !== 'openai' && update.providerKind !== 'openai-compatible') {
return { ok: false, error: 'Invalid providerKind' }
}
next.providerKind = update.providerKind
}
if (update.baseUrl !== undefined) {
if (typeof update.baseUrl !== 'string' || !validateSttBaseUrl(update.baseUrl)) {
return { ok: false, error: 'Invalid baseUrl' }
}
next.baseUrl = update.baseUrl.replace(/\/+$/, '')
}
if (update.apiKeyRequired !== undefined) next.apiKeyRequired = update.apiKeyRequired === true
if (update.speechEndpoint !== undefined) {
if (typeof update.speechEndpoint !== 'string' || !update.speechEndpoint.startsWith('/')) {
return { ok: false, error: 'Invalid speechEndpoint' }
}
next.speechEndpoint = update.speechEndpoint
}
if (update.model !== undefined) {
if (typeof update.model !== 'string' || !update.model.trim()) return { ok: false, error: 'Invalid model' }
next.model = update.model.trim()
}
if (update.voice !== undefined) {
if (typeof update.voice !== 'string' || !update.voice.trim()) return { ok: false, error: 'Invalid voice' }
next.voice = update.voice.trim()
}
if (update.responseFormat !== undefined) {
if (!isTtsResponseFormat(update.responseFormat)) return { ok: false, error: 'Invalid responseFormat' }
next.responseFormat = update.responseFormat
}
if (update.speed !== undefined) {
if (typeof update.speed !== 'number' || update.speed < 0.25 || update.speed > 4) {
return { ok: false, error: 'Invalid speed' }
}
next.speed = update.speed
}
if (Object.prototype.hasOwnProperty.call(update, 'apiKey')) {
if (update.apiKey === null) delete next.apiKey
else if (typeof update.apiKey === 'string' && update.apiKey.trim()) next.apiKey = update.apiKey.trim()
}
saveLocalSettings({ ...current, tts: next })
return { ok: true, settings: sanitizeTtsSettings(next) }
}
async function readRequestBuffer(req: http.IncomingMessage): Promise<Buffer> {
@@ -892,6 +972,160 @@ export default function (pi: ExtensionAPI) {
return form
}
type SttRealtimeClientMessage =
| { type: 'audio.append'; audio: string }
| { type: 'audio.commit' }
| { type: 'session.close' }
function buildRealtimeProviderUrl(settings: StoredSttSettings): string {
const base = (settings.baseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '')
const endpoint =
settings.realtimeEndpoint ||
(settings.providerKind === 'openai' ? '/realtime?intent=transcription' : '/realtime/transcriptions')
const wsBase = base.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
return `${wsBase}${endpoint}`
}
function buildRealtimeSessionConfigureMessage(settings: StoredSttSettings): Record<string, unknown> {
if (settings.providerKind === 'openai') {
const transcription: Record<string, unknown> = {
model: settings.realtimeModel || 'gpt-realtime-whisper',
}
if (settings.language) transcription.language = settings.language
return {
type: 'session.update',
session: {
type: 'transcription',
audio: {
input: {
format: { type: 'audio/pcm', rate: 24000 },
transcription,
},
},
},
}
}
return { type: 'session.configure', model: settings.realtimeModel || settings.transcriptionModel }
}
function translateRealtimeClientMessage(
message: SttRealtimeClientMessage,
settings: StoredSttSettings,
): Record<string, unknown> | null {
if (settings.providerKind !== 'openai') return message
if (message.type === 'audio.append') return { type: 'input_audio_buffer.append', audio: message.audio }
if (message.type === 'audio.commit') return { type: 'input_audio_buffer.commit' }
return null
}
function normalizeRealtimeProviderMessage(raw: string): { type: string; text?: string; error?: string } | null {
let event: any
try {
event = JSON.parse(raw)
} catch {
return null
}
const type = typeof event.type === 'string' ? event.type : ''
const isTranscriptionDelta =
type === 'conversation.item.input_audio_transcription.delta' ||
type === 'input_audio_buffer.transcription.delta' ||
type === 'input_audio_transcription.delta'
const isTranscriptionCompleted =
type === 'conversation.item.input_audio_transcription.completed' ||
type === 'conversation.item.input_audio_transcription.done' ||
type === 'input_audio_buffer.transcription.completed' ||
type === 'input_audio_buffer.transcription.done' ||
type === 'input_audio_transcription.completed' ||
type === 'input_audio_transcription.done'
const partialText =
event.text_delta || event.partial || event.transcript?.partial || (isTranscriptionDelta ? event.delta : undefined)
if (typeof partialText === 'string') return { type: 'transcript.partial', text: partialText }
const finalText =
event.text || event.final || event.transcript?.text || (isTranscriptionCompleted ? event.transcript : undefined)
if (typeof finalText === 'string') return { type: 'transcript.final', text: finalText }
const error = event.error?.message || event.error
if (typeof error === 'string') return { type: 'error', error }
return null
}
function handleSttRealtimeWebSocket(client: WebSocket, req: http.IncomingMessage) {
if (!checkAuth(req)) {
client.send(JSON.stringify({ type: 'error', error: 'Unauthorized' }))
client.close()
return
}
const settings = getEffectiveSttSettings()
if (!settings.enabled || settings.mode !== 'realtime') {
client.send(JSON.stringify({ type: 'error', error: 'Realtime STT is not enabled' }))
client.close()
return
}
if (settings.apiKeyRequired && !settings.apiKey) {
client.send(JSON.stringify({ type: 'error', error: 'STT API key is not configured' }))
client.close()
return
}
if (!validateSttBaseUrl(settings.baseUrl)) {
client.send(JSON.stringify({ type: 'error', error: 'Invalid STT baseUrl' }))
client.close()
return
}
const headers: Record<string, string> = {}
if (settings.apiKey) headers.Authorization = `Bearer ${settings.apiKey}`
const provider = new WebSocket(buildRealtimeProviderUrl(settings), { headers })
const closeBoth = () => {
if (provider.readyState === WebSocket.OPEN || provider.readyState === WebSocket.CONNECTING) provider.close()
if (client.readyState === WebSocket.OPEN) client.close()
}
provider.on('open', () => {
provider.send(JSON.stringify(buildRealtimeSessionConfigureMessage(settings)))
if (client.readyState === WebSocket.OPEN) client.send(JSON.stringify({ type: 'ready' }))
})
provider.on('message', data => {
const normalized = normalizeRealtimeProviderMessage(String(data))
if (normalized && client.readyState === WebSocket.OPEN) client.send(JSON.stringify(normalized))
})
provider.on('error', () => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({ type: 'error', error: 'Realtime STT provider failed' }))
}
})
provider.on('close', () => {
if (client.readyState === WebSocket.OPEN) client.send(JSON.stringify({ type: 'closed' }))
})
client.on('message', data => {
if (provider.readyState !== WebSocket.OPEN) return
let message: SttRealtimeClientMessage
try {
message = JSON.parse(String(data))
} catch {
client.send(JSON.stringify({ type: 'error', error: 'Invalid realtime message' }))
return
}
if (message.type === 'session.close') {
closeBoth()
return
}
const providerMessage = translateRealtimeClientMessage(message, settings)
if (providerMessage) provider.send(JSON.stringify(providerMessage))
})
client.on('close', closeBoth)
client.on('error', closeBoth)
}
async function transcribeAudio(req: http.IncomingMessage) {
if (!isRequestBodySizeAllowed(req.headers['content-length'], 25 * 1024 * 1024)) {
return { status: 413, body: { error: 'Audio upload too large' } }
@@ -1087,6 +1321,9 @@ export default function (pi: ExtensionAPI) {
const authHeader = req.headers.authorization
if (validateBearerToken(authHeader, localToken)) return true
const queryToken = new URL(req.url || '/', 'http://127.0.0.1').searchParams.get('token')
if (queryToken && queryToken === localToken) return true
const host = req.headers.host
const origin = req.headers.origin as string | undefined
const lanAccessEnabled = getLocalSettings().network?.lanAccessEnabled === true
@@ -1338,10 +1575,107 @@ export default function (pi: ExtensionAPI) {
return
}
if (pathParts[1] === 'runtime' && pathParts[2] === 'capabilities' && method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(buildRuntimeCapabilities(latestCtx ?? undefined)))
return
}
if (pathParts[1] === 'providers' && pathParts[2] && pathParts[3] === 'api-key' && method === 'POST') {
try {
const registry = latestCtx?.modelRegistry as any
if (!registry?.authStorage?.set) {
res.writeHead(503, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Model registry is not ready' }))
return
}
const providerId = decodeURIComponent(pathParts[2])
const body = await readJsonBody(req)
const apiKey = typeof body?.apiKey === 'string' ? body.apiKey.trim() : ''
if (!apiKey) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'apiKey is required' }))
return
}
registry.authStorage.set(providerId, { type: 'api_key', key: apiKey })
registry.refresh?.()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
return
} catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Failed to save provider API key' }))
return
}
}
if (pathParts[1] === 'providers' && pathParts[2] && pathParts[3] === 'logout' && method === 'POST') {
try {
const registry = latestCtx?.modelRegistry as any
if (!registry?.authStorage?.logout) {
res.writeHead(503, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Model registry is not ready' }))
return
}
const providerId = decodeURIComponent(pathParts[2])
registry.authStorage.logout(providerId)
registry.refresh?.()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
return
} catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Failed to clear provider auth' }))
return
}
}
if (pathParts[1] === 'providers' && method === 'GET') {
const registry = latestCtx?.modelRegistry as any
const allModels: any[] = registry?.getAll?.() || []
const providerIds: string[] =
registry?.getProviderIds?.() || Array.from(new Set(allModels.map(model => model.provider).filter(Boolean)))
const providers = new Map<string, any>()
for (const providerId of providerIds) {
const providerModels: any[] =
registry?.getModelsForProvider?.(providerId) || allModels.filter(model => model.provider === providerId)
const authStatus = registry?.getProviderAuthStatus?.(providerId) ?? { configured: false }
const credential = registry?.authStorage?.get?.(providerId)
const usesOAuth =
credential?.type === 'oauth' ||
providerModels.some(model => registry?.isUsingOAuth?.(model)) ||
allModels.some(model => model.provider === providerId && registry?.isUsingOAuth?.(model))
providers.set(providerId, {
id: providerId,
name: registry?.getProviderDisplayName?.(providerId) || providerId,
models: providerModels.map(model => ({
id: model.id,
name: model.name || model.id,
provider: providerId,
family: model.family ?? '',
contextWindow: model.contextWindow ?? model.contextLimit,
maxOutputTokens: model.maxOutputTokens ?? model.maxTokens,
capabilities: {
input: { text: true, image: Array.isArray(model.input) ? model.input.includes('image') : undefined },
output: { text: true },
thinking: model.reasoning ?? model.supportsReasoning,
},
})),
auth: {
method: usesOAuth ? 'oauth' : 'apikey',
authorization: authStatus,
},
})
}
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ providers: Array.from(providers.values()) }))
return
}
if (pathParts[1] === 'models' && method === 'GET') {
const registry = latestCtx?.modelRegistry as any
const activeModel = latestCtx?.model as any
const registryModels: any[] =
latestCtx?.modelRegistry?.getAvailable() || latestCtx?.modelRegistry?.getAll() || []
const registryModels: any[] = registry?.getAvailable?.() || []
const mapModel = (m: any) => ({
id: m.id,
@@ -1361,7 +1695,9 @@ export default function (pi: ExtensionAPI) {
let models = registryModels.map(mapModel)
if (activeModel) {
const activeModelHasAuth = Boolean(activeModel && registry?.hasConfiguredAuth?.(activeModel))
if (activeModel && activeModelHasAuth) {
const registryActiveModel = registryModels.find(
(m: any) => m.id === activeModel.id && m.provider === activeModel.provider,
)
@@ -1381,26 +1717,6 @@ export default function (pi: ExtensionAPI) {
models = models.filter((m: any) => !(m.id === activeModel.id && m.provider === activeModel.provider))
models.unshift(activeMapped)
} else {
if (models.length === 0) {
models = [
{
id: 'gemini-2.5-flash',
name: 'Gemini 2.5 Flash',
provider: 'google',
family: '',
contextLimit: 128000,
outputLimit: 4096,
supportsReasoning: true,
supportsImages: true,
supportsPdf: false,
supportsAudio: false,
supportsVideo: false,
supportsToolcall: true,
variants: [],
},
]
}
}
res.writeHead(200, { 'Content-Type': 'application/json' })
@@ -2013,7 +2329,8 @@ export default function (pi: ExtensionAPI) {
const urlPath = req.url || ''
if (urlPath.startsWith('/api/') || urlPath.startsWith('/global/')) {
handleApiRequest(req, res).catch(() => {
sendInternalServerError(res)
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Internal Server Error' }))
})
} else {
handleStaticFile(req, res, urlPath)
@@ -2031,7 +2348,12 @@ export default function (pi: ExtensionAPI) {
}
// Upgrade WS safely
const pathname = new URL(request.url || '/', 'http://127.0.0.1').pathname
wss!.handleUpgrade(request, socket, head, (ws: WebSocket) => {
if (pathname === '/api/stt/realtime') {
handleSttRealtimeWebSocket(ws, request)
return
}
wss!.emit('connection', ws, request)
})
})
@@ -2059,19 +2381,13 @@ export default function (pi: ExtensionAPI) {
setWebLinkWidget(ctx)
// Write connection details to discovery file
const homeDir = process.env.USERPROFILE || process.env.HOME || ''
const piAgentDir = path.join(homeDir, '.pi', 'agent')
const piAgentDir = resolvePiAgentUiAgentDir()
const discoveryPath = path.join(piAgentDir, 'piagentui-port.json')
try {
if (!fs.existsSync(piAgentDir)) {
fs.mkdirSync(piAgentDir, { recursive: true })
}
// The file holds the API token: keep it readable by the owner only (mode is ignored on Windows).
fs.writeFileSync(discoveryPath, JSON.stringify({ port: localPort, token: localToken }, null, 2), {
encoding: 'utf8',
mode: 0o600,
})
fs.chmodSync(discoveryPath, 0o600)
fs.writeFileSync(discoveryPath, JSON.stringify({ port: localPort, token: localToken }, null, 2), 'utf8')
console.log(`[PiAgentUi] Wrote connection info to: ${discoveryPath}`)
} catch (err) {
console.error('[PiAgentUi] Failed to write discovery file:', err)
@@ -2091,8 +2407,7 @@ export default function (pi: ExtensionAPI) {
}
}
// Delete discovery file
const homeDir = process.env.USERPROFILE || process.env.HOME || ''
const discoveryPath = path.join(homeDir, '.pi', 'agent', 'piagentui-port.json')
const discoveryPath = path.join(resolvePiAgentUiAgentDir(), 'piagentui-port.json')
try {
if (fs.existsSync(discoveryPath)) {
fs.unlinkSync(discoveryPath)
+12 -3
View File
@@ -20,6 +20,8 @@ export type StoredSttSettings = {
apiKeyRequired?: boolean
transcriptionEndpoint?: string
transcriptionModel?: string
realtimeEndpoint?: string
realtimeModel?: string
language?: string
insertMode?: SttInsertMode
}
@@ -47,6 +49,7 @@ export type PiAgentUiLocalSettings = {
}
export interface PiAgentUiSettingsPaths {
agentDir: string
databasePath: string
legacyJsonPath: string
}
@@ -60,13 +63,19 @@ const SETTINGS_KEYS: Record<SettingsNamespace, string> = {
tts: 'settings',
}
export function resolvePiAgentUiAgentDir(homeDir = process.env.USERPROFILE || process.env.HOME || ''): string {
const envAgentDir = process.env.PI_CODING_AGENT_DIR?.trim()
return envAgentDir || path.join(homeDir, '.pi', 'agent')
}
export function resolvePiAgentUiSettingsPaths(
homeDir = process.env.USERPROFILE || process.env.HOME || '',
): PiAgentUiSettingsPaths {
const settingsDir = path.join(homeDir, '.pi', 'agent')
const agentDir = resolvePiAgentUiAgentDir(homeDir)
return {
databasePath: path.join(settingsDir, 'piagentui.db'),
legacyJsonPath: path.join(settingsDir, 'piagentui-settings.json'),
agentDir,
databasePath: path.join(agentDir, 'piagentui.db'),
legacyJsonPath: path.join(agentDir, 'piagentui-settings.json'),
}
}
@@ -1,153 +0,0 @@
import { describe, expect, it } from 'vitest'
import { applySttSettingsUpdate, applyTtsSettingsUpdate, isTtsResponseFormat } from './piagentui-speech-settings.js'
function stt(update: Record<string, unknown>, existing = {}) {
return applySttSettingsUpdate(existing, update)
}
function tts(update: Record<string, unknown>, existing = {}) {
return applyTtsSettingsUpdate(existing, update)
}
describe('applySttSettingsUpdate', () => {
it('returns the existing settings untouched for an empty update', () => {
const existing = { enabled: true, language: 'pt' }
expect(stt({}, existing)).toEqual({ ok: true, next: existing })
})
it('applies a valid update of every field', () => {
const result = stt({
enabled: true,
providerKind: 'openai',
mode: 'realtime',
baseUrl: 'https://api.example.com/v1///',
apiKeyRequired: true,
transcriptionEndpoint: '/audio/transcriptions',
transcriptionModel: ' whisper-1 ',
language: ' en ',
insertMode: 'replace',
apiKey: ' secret ',
})
expect(result).toEqual({
ok: true,
next: {
enabled: true,
providerKind: 'openai',
mode: 'realtime',
baseUrl: 'https://api.example.com/v1',
apiKeyRequired: true,
transcriptionEndpoint: '/audio/transcriptions',
transcriptionModel: 'whisper-1',
language: 'en',
insertMode: 'replace',
apiKey: 'secret',
},
})
})
it('does not mutate the existing settings', () => {
const existing = { enabled: false }
stt({ enabled: true }, existing)
expect(existing).toEqual({ enabled: false })
})
it.each([
['providerKind', { providerKind: 'other' }],
['mode', { mode: 'stream' }],
['baseUrl', { baseUrl: 'ftp://host' }],
['baseUrl', { baseUrl: 42 }],
['transcriptionEndpoint', { transcriptionEndpoint: 'audio' }],
['transcriptionModel', { transcriptionModel: ' ' }],
['language', { language: 5 }],
['insertMode', { insertMode: 'prepend' }],
])('rejects an invalid %s', (field, update) => {
expect(stt(update)).toEqual({ ok: false, error: `Invalid ${field}` })
})
it('coerces enabled/apiKeyRequired to booleans', () => {
expect(stt({ enabled: 'yes', apiKeyRequired: 1 })).toEqual({
ok: true,
next: { enabled: false, apiKeyRequired: false },
})
})
it('clears language with null or empty string', () => {
expect(stt({ language: null }, { language: 'pt' })).toEqual({ ok: true, next: {} })
expect(stt({ language: '' }, { language: 'pt' })).toEqual({ ok: true, next: {} })
})
it('handles apiKey: null clears, blank string keeps, non-string keeps', () => {
expect(stt({ apiKey: null }, { apiKey: 'old' })).toEqual({ ok: true, next: {} })
expect(stt({ apiKey: ' ' }, { apiKey: 'old' })).toEqual({ ok: true, next: { apiKey: 'old' } })
expect(stt({ apiKey: 12 }, { apiKey: 'old' })).toEqual({ ok: true, next: { apiKey: 'old' } })
})
})
describe('applyTtsSettingsUpdate', () => {
it('returns the existing settings untouched for an empty update', () => {
const existing = { voice: 'alloy' }
expect(tts({}, existing)).toEqual({ ok: true, next: existing })
})
it('applies a valid update of every field', () => {
const result = tts({
enabled: true,
providerKind: 'openai-compatible',
baseUrl: 'http://localhost:8880/v1/',
apiKeyRequired: true,
speechEndpoint: '/audio/speech',
model: ' tts-1-hd ',
voice: ' nova ',
responseFormat: 'wav',
speed: 1.5,
apiKey: ' k ',
})
expect(result).toEqual({
ok: true,
next: {
enabled: true,
providerKind: 'openai-compatible',
baseUrl: 'http://localhost:8880/v1',
apiKeyRequired: true,
speechEndpoint: '/audio/speech',
model: 'tts-1-hd',
voice: 'nova',
responseFormat: 'wav',
speed: 1.5,
apiKey: 'k',
},
})
})
it.each([
['providerKind', { providerKind: 'x' }],
['baseUrl', { baseUrl: 'not a url' }],
['speechEndpoint', { speechEndpoint: 'speech' }],
['model', { model: '' }],
['voice', { voice: 3 }],
['responseFormat', { responseFormat: 'ogg' }],
['speed', { speed: '1' }],
['speed', { speed: 0.1 }],
['speed', { speed: 4.5 }],
])('rejects an invalid %s', (field, update) => {
expect(tts(update)).toEqual({ ok: false, error: `Invalid ${field}` })
})
it('accepts speed at both bounds', () => {
expect(tts({ speed: 0.25 })).toEqual({ ok: true, next: { speed: 0.25 } })
expect(tts({ speed: 4 })).toEqual({ ok: true, next: { speed: 4 } })
})
it('handles apiKey: null clears, blank string keeps', () => {
expect(tts({ apiKey: null }, { apiKey: 'old' })).toEqual({ ok: true, next: {} })
expect(tts({ apiKey: ' ' }, { apiKey: 'old' })).toEqual({ ok: true, next: { apiKey: 'old' } })
})
})
describe('isTtsResponseFormat', () => {
it('accepts supported formats only', () => {
for (const f of ['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm']) expect(isTtsResponseFormat(f)).toBe(true)
expect(isTtsResponseFormat('ogg')).toBe(false)
expect(isTtsResponseFormat(undefined)).toBe(false)
})
})
-126
View File
@@ -1,126 +0,0 @@
import { validateSttBaseUrl } from './piagentui-server-core.js'
import type { StoredSttSettings, StoredTtsSettings } from './piagentui-settings-store.js'
export type SettingsUpdateResult<T> = { ok: true; next: T } | { ok: false; error: string }
type Fields = Record<string, unknown>
/** Assigns a validated value to `next`; returns false when the value is invalid. */
type FieldRule = (value: unknown, next: Fields) => boolean
const TTS_RESPONSE_FORMATS = ['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm'] as const
export function isTtsResponseFormat(value: unknown): value is NonNullable<StoredTtsSettings['responseFormat']> {
return typeof value === 'string' && (TTS_RESPONSE_FORMATS as readonly string[]).includes(value)
}
const booleanField =
(key: string): FieldRule =>
(value, next) => {
next[key] = value === true
return true
}
const oneOfField =
(key: string, allowed: readonly string[]): FieldRule =>
(value, next) => {
if (typeof value !== 'string' || !allowed.includes(value)) return false
next[key] = value
return true
}
const endpointField =
(key: string): FieldRule =>
(value, next) => {
if (typeof value !== 'string' || !value.startsWith('/')) return false
next[key] = value
return true
}
const trimmedField =
(key: string): FieldRule =>
(value, next) => {
if (typeof value !== 'string' || !value.trim()) return false
next[key] = value.trim()
return true
}
const baseUrlField: FieldRule = (value, next) => {
if (typeof value !== 'string' || !validateSttBaseUrl(value)) return false
next.baseUrl = value.replace(/\/+$/, '')
return true
}
const languageField: FieldRule = (value, next) => {
if (value === null || value === '') delete next.language
else if (typeof value === 'string') next.language = value.trim()
else return false
return true
}
const speedField: FieldRule = (value, next) => {
if (typeof value !== 'number' || value < 0.25 || value > 4) return false
next.speed = value
return true
}
const PROVIDER_KINDS = ['openai', 'openai-compatible'] as const
const STT_RULES: Array<[string, FieldRule]> = [
['enabled', booleanField('enabled')],
['providerKind', oneOfField('providerKind', PROVIDER_KINDS)],
['mode', oneOfField('mode', ['file', 'realtime'])],
['baseUrl', baseUrlField],
['apiKeyRequired', booleanField('apiKeyRequired')],
['transcriptionEndpoint', endpointField('transcriptionEndpoint')],
['transcriptionModel', trimmedField('transcriptionModel')],
['language', languageField],
['insertMode', oneOfField('insertMode', ['append', 'replace'])],
]
const TTS_RULES: Array<[string, FieldRule]> = [
['enabled', booleanField('enabled')],
['providerKind', oneOfField('providerKind', PROVIDER_KINDS)],
['baseUrl', baseUrlField],
['apiKeyRequired', booleanField('apiKeyRequired')],
['speechEndpoint', endpointField('speechEndpoint')],
['model', trimmedField('model')],
['voice', trimmedField('voice')],
['responseFormat', oneOfField('responseFormat', TTS_RESPONSE_FORMATS)],
['speed', speedField],
]
function applyUpdate<T extends object>(
existing: T,
update: Record<string, unknown>,
rules: Array<[string, FieldRule]>,
): SettingsUpdateResult<T> {
const next: Fields = { ...(existing as Fields) }
for (const [field, rule] of rules) {
if (update[field] === undefined) continue
if (!rule(update[field], next)) return { ok: false, error: `Invalid ${field}` }
}
if (Object.prototype.hasOwnProperty.call(update, 'apiKey')) {
if (update.apiKey === null) delete next.apiKey
else if (typeof update.apiKey === 'string' && update.apiKey.trim()) next.apiKey = update.apiKey.trim()
}
return { ok: true, next: next as T }
}
/** Validates a PATCH-style STT update against the stored settings. Pure: does not persist anything. */
export function applySttSettingsUpdate(
existing: StoredSttSettings,
update: Record<string, unknown>,
): SettingsUpdateResult<StoredSttSettings> {
return applyUpdate(existing, update, STT_RULES)
}
/** Validates a PATCH-style TTS update against the stored settings. Pure: does not persist anything. */
export function applyTtsSettingsUpdate(
existing: StoredTtsSettings,
update: Record<string, unknown>,
): SettingsUpdateResult<StoredTtsSettings> {
return applyUpdate(existing, update, TTS_RULES)
}
-50
View File
@@ -1,50 +0,0 @@
import { describe, expect, it } from 'vitest'
import { createStatCache } from './piagentui-stat-cache.js'
const stamp = (mtimeMs: number, size = 10) => ({ mtimeMs, size })
describe('createStatCache', () => {
it('misses for unknown paths and hits for the same stamp', () => {
const cache = createStatCache<string>()
expect(cache.get('a', stamp(1))).toBeUndefined()
cache.set('a', stamp(1), 'x')
expect(cache.get('a', stamp(1))).toEqual({ value: 'x' })
})
it('treats a changed mtime or size as a miss and drops the stale entry', () => {
const cache = createStatCache<string>()
cache.set('a', stamp(1, 10), 'x')
expect(cache.get('a', stamp(2, 10))).toBeUndefined()
expect(cache.size).toBe(0)
cache.set('a', stamp(1, 10), 'x')
expect(cache.get('a', stamp(1, 11))).toBeUndefined()
})
it('can cache null values', () => {
const cache = createStatCache<string | null>()
cache.set('a', stamp(1), null)
expect(cache.get('a', stamp(1))).toEqual({ value: null })
})
it('evicts the oldest entries beyond the limit', () => {
const cache = createStatCache<number>(2)
cache.set('a', stamp(1), 1)
cache.set('b', stamp(1), 2)
cache.set('c', stamp(1), 3)
expect(cache.size).toBe(2)
expect(cache.get('a', stamp(1))).toBeUndefined()
expect(cache.get('b', stamp(1))).toEqual({ value: 2 })
expect(cache.get('c', stamp(1))).toEqual({ value: 3 })
})
it('refreshing a key moves it to the newest position', () => {
const cache = createStatCache<number>(2)
cache.set('a', stamp(1), 1)
cache.set('b', stamp(1), 2)
cache.set('a', stamp(2), 11)
cache.set('c', stamp(1), 3)
expect(cache.get('b', stamp(1))).toBeUndefined()
expect(cache.get('a', stamp(2))).toEqual({ value: 11 })
})
})
-34
View File
@@ -1,34 +0,0 @@
export type FileStamp = { mtimeMs: number; size: number }
/**
* Small bounded cache of values derived from file contents, keyed by path and
* invalidated when the file's mtime or size changes. Oldest entries are evicted first.
*/
export function createStatCache<T>(maxEntries = 500) {
const entries = new Map<string, FileStamp & { value: T }>()
return {
/** Returns `{ value }` on a hit (the value itself may be null), `undefined` on a miss or stale entry. */
get(filePath: string, stamp: FileStamp): { value: T } | undefined {
const entry = entries.get(filePath)
if (!entry) return undefined
if (entry.mtimeMs !== stamp.mtimeMs || entry.size !== stamp.size) {
entries.delete(filePath)
return undefined
}
return { value: entry.value }
},
set(filePath: string, stamp: FileStamp, value: T): void {
entries.delete(filePath)
entries.set(filePath, { mtimeMs: stamp.mtimeMs, size: stamp.size, value })
while (entries.size > maxEntries) {
const oldest = entries.keys().next().value
if (oldest === undefined) break
entries.delete(oldest)
}
},
get size(): number {
return entries.size
},
}
}
-11102
View File
File diff suppressed because it is too large. Load diff
+22 -17
View File
@@ -2,21 +2,18 @@
"name": "piagentui",
"version": "0.6.10",
"description": "Professional AI Agent Desktop & Web UI — sessions, MCP tools, skills browser, voice input, terminal, file explorer, and Tauri desktop app. Works with Pi Agent, Claude Code, Codex, OpenCode, Cursor, and any SSE-compatible agent.",
"homepage": "https://github.com/evandrodevbr/PiAgentUI",
"homepage": "https://github.com/lehhair/OpenCodeUI",
"repository": {
"type": "git",
"url": "git+https://github.com/evandrodevbr/PiAgentUI.git"
"url": "https://github.com/lehhair/OpenCodeUI.git"
},
"license": "GPL-3.0-only",
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "vite",
"typecheck": "tsc -b",
"typecheck:extensions": "tsc -p tsconfig.extension.json --noEmit",
"type-check": "npm run typecheck",
"type-check": "pnpm typecheck",
"build": "tsc -b && vite build",
"icons:tauri": "tauri icon src-tauri/app-icon.manifest.json -o src-tauri/icons",
"lint": "eslint .",
@@ -24,9 +21,11 @@
"format:check": "prettier . --check",
"test": "vitest",
"test:run": "vitest run",
"validate": "npm run typecheck && npm run typecheck:extensions && npm run lint && npm run build && npm run test:run",
"test:e2e": "wdio run e2e/wdio.conf.mjs",
"test:e2e:skip-build": "cross-env PIAGENTUI_E2E_SKIP_BUILD=1 wdio run e2e/wdio.conf.mjs",
"validate": "pnpm typecheck && pnpm typecheck:extensions && pnpm lint && pnpm test:run && pnpm build",
"release:prepare": "node scripts/prepare-release.mjs",
"check": "npm run format:check && npm run lint && npm run build && npm run test:run",
"check": "pnpm format:check && pnpm lint && pnpm test:run && pnpm build",
"preview": "vite preview",
"tauri": "tauri",
"postinstall": "node scripts/copy-material-icons.mjs"
@@ -38,12 +37,12 @@
"@codemirror/view": "^6.41.1",
"@primer/octicons-react": "^19.28.0",
"@streamdown/math": "^1.0.2",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-http": "^2.5.7",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/api": "2.10.1",
"@tauri-apps/plugin-dialog": "2.6.0",
"@tauri-apps/plugin-fs": "2.4.5",
"@tauri-apps/plugin-http": "2.5.7",
"@tauri-apps/plugin-notification": "2.3.3",
"@tauri-apps/plugin-opener": "2.5.3",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-serialize": "^0.14.0",
"@xterm/addon-web-links": "^0.12.0",
@@ -52,6 +51,7 @@
"diff": "^8.0.4",
"i18next": "^26.0.1",
"i18next-browser-languagedetector": "^8.2.1",
"katex": "0.16.25",
"lucide-react": "^1.7.0",
"motion": "^12.38.0",
"qrcode": "^1.5.4",
@@ -64,9 +64,8 @@
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@mariozechner/pi-coding-agent": "^0.73.1",
"@tailwindcss/vite": "^4.2.2",
"@tauri-apps/cli": "^2.10.0",
"@tauri-apps/cli": "2.10.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@types/diff": "^7.0.2",
@@ -76,6 +75,11 @@
"@types/react-dom": "^19.2.3",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^6.0.1",
"@wdio/cli": "^9.27.2",
"@wdio/local-runner": "^9.27.2",
"@wdio/mocha-framework": "^9.27.2",
"@wdio/spec-reporter": "^9.27.2",
"cross-env": "^10.1.0",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1",
@@ -88,7 +92,8 @@
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.2",
"vite": "^8.0.3",
"vitest": "^4.1.2"
"vitest": "^4.1.2",
"webdriverio": "^9.27.2"
},
"pi": {
"extensions": [
+3627 -100
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -1,2 +1,5 @@
allowBuilds:
core-js: true
edgedriver: true
esbuild: true
geckodriver: true
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ff6e40" d="M8 6V2H4a2 2 0 0 0-2 2v24a2 2 0 0 0 2 2h4v-4H4V6Zm16-4v4h4v20h-4v4h4a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2Zm-4 4h-2a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2m-2 6V8h2v4Zm-4 6h-2a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2m-2 6v-4h2v4Zm0-18c0 2 0 2-2 2v2h2v4h2V6Zm8 12c0 2 0 2-2 2v2h2v4h2v-8Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ff6e40" d="M2 1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h2v-1H2V2h2V1Zm10 0v1h2v12h-2v1h2a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1ZM6 3c0 1 0 1-1 1v1h1v2h1V3Zm3 0a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1Zm0 1h1v2H9ZM6 9a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1Zm4 0c0 1 0 1-1 1v1h1v2h1V9Zm-4 1h1v2H6Z"/></svg>

Before

Width:  |  Height:  |  Size: 415 B

After

Width:  |  Height:  |  Size: 404 B

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffca28" d="M28.967 12H9.442a2 2 0 0 0-1.898 1.368L4 24V10h24a2 2 0 0 0-2-2H15.124a2 2 0 0 1-1.28-.464l-1.288-1.072A2 2 0 0 0 11.276 6H4a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h22l4.805-11.212A2 2 0 0 0 28.967 12"/><ellipse cx="24" cy="14" fill="#ffecb3" rx="8" ry="4"/><path fill="#ffecb3" d="M24 26c-4.418 0-8-1.79-8-4v4c0 2.21 3.582 4 8 4s8-1.79 8-4v-4c0 2.21-3.582 4-8 4"/><path fill="#ffecb3" d="M24 20c-4.418 0-8-1.79-8-4v4c0 2.21 3.582 4 8 4s8-1.79 8-4v-4c0 2.21-3.582 4-8 4"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ffca28" d="M14.483 6H4.721a1 1 0 0 0-.949.684L2 12V5h12a1 1 0 0 0-1-1H7.562a1 1 0 0 1-.64-.232l-.644-.536A1 1 0 0 0 5.638 3H2a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h11l2.403-5.606A1 1 0 0 0 14.483 6"/><path fill="#ffecb3" d="M12 5a4 2 0 0 0-4 2 4 2 0 0 0 4 2 4 2 0 0 0 4-2 4 2 0 0 0-4-2M8 8v2c0 1.105 1.791 2 4 2s4-.895 4-2V8c0 1.105-1.791 2-4 2s-4-.895-4-2m0 3v2c0 1.105 1.791 2 4 2s4-.895 4-2v-2c0 1.105-1.791 2-4 2s-4-.895-4-2"/></svg>

Before

Width:  |  Height:  |  Size: 553 B

After

Width:  |  Height:  |  Size: 502 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffca28" d="m13.844 7.536-1.288-1.072A2 2 0 0 0 11.276 6H4a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h24a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2H15.124a2 2 0 0 1-1.28-.464"/><ellipse cx="24" cy="14" fill="#ffecb3" rx="8" ry="4"/><path fill="#ffecb3" d="M24 26c-4.418 0-8-1.79-8-4v4c0 2.21 3.582 4 8 4s8-1.79 8-4v-4c0 2.21-3.582 4-8 4"/><path fill="#ffecb3" d="M24 20c-4.418 0-8-1.79-8-4v4c0 2.21 3.582 4 8 4s8-1.79 8-4v-4c0 2.21-3.582 4-8 4"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ffca28" d="m6.922 3.768-.644-.536A1 1 0 0 0 5.638 3H2a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H7.562a1 1 0 0 1-.64-.232"/><path fill="#ffecb3" d="M12 5a4 2 0 0 0-4 2 4 2 0 0 0 4 2 4 2 0 0 0 4-2 4 2 0 0 0-4-2M8 8v2c0 1.105 1.791 2 4 2s4-.895 4-2V8c0 1.105-1.791 2-4 2s-4-.895-4-2m0 3v2c0 1.105 1.791 2 4 2s4-.895 4-2v-2c0 1.105-1.791 2-4 2s-4-.895-4-2"/></svg>

Before

Width:  |  Height:  |  Size: 500 B

After

Width:  |  Height:  |  Size: 454 B

+1 -1
View File
@@ -4,7 +4,7 @@ version = "0.6.10"
description = "Pi Agent UI - Tauri Desktop & Mobile App"
authors = ["lehhair"]
license = "GPL-3.0-only"
repository = "https://github.com/evandrodevbr/PiAgentUI"
repository = ""
edition = "2021"
rust-version = "1.85.0"
+1 -1
View File
@@ -335,7 +335,7 @@ async fn connect_ws(
tokio::select! {
outbound = rx.recv() => match outbound {
Some(BridgeCommand::Send(data)) => {
if let Err(error) = write.send(Message::Text(data.into())).await {
if let Err(error) = write.send(Message::Text(data)).await {
let msg = format!("WebSocket write failed: {}", error);
emit(&on_event, BridgeEvent::Error { message: msg.clone() });
state.remove_if_current(&key, conn_id);
+240 -31
View File
@@ -4,16 +4,19 @@
// ============================================
use crate::app::service::ServiceState;
use serde::{Deserialize, Serialize};
use std::{
fs,
path::PathBuf,
process::{Command, Stdio},
sync::atomic::Ordering,
time::Duration,
};
use tauri::State;
use tauri::{Manager, State};
/// 检查 pi 服务是否在运行(通过 health endpoint)
pub async fn is_service_running(url: &str) -> bool {
let health_url = format!("{}/api/health", url.trim_end_matches('/'));
let health_url = format!("{}/global/health", url.trim_end_matches('/'));
match reqwest::Client::builder()
.connect_timeout(Duration::from_secs(3))
.build()
@@ -29,23 +32,194 @@ pub async fn is_service_running(url: &str) -> bool {
}
}
/// 启动 pi serve 进程
fn spawn_pi_serve(
binary_path: &str,
env_vars: &std::collections::HashMap<String, String>,
) -> Result<std::process::Child, String> {
log::info!("Starting pi serve with binary: {}", binary_path);
if !env_vars.is_empty() {
log::info!("Injecting {} environment variable(s)", env_vars.len());
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PiAgentConnection {
pub port: u16,
pub token: String,
}
#[derive(Serialize, Clone, Debug)]
pub struct PiAgentServiceStart {
pub started: bool,
pub connection: Option<PiAgentConnection>,
pub agent_dir: String,
pub session_dir: String,
}
#[cfg(debug_assertions)]
fn e2e_config_value(key: &str) -> Option<String> {
let config_path = std::env::current_exe()
.ok()?
.parent()?
.join("piagentui-e2e.json");
let config = fs::read_to_string(config_path).ok()?;
let value: serde_json::Value = serde_json::from_str(&config).ok()?;
value.get(key)?.as_str().map(ToOwned::to_owned)
}
pub fn resolve_app_agent_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
#[cfg(debug_assertions)]
if let Ok(agent_dir) = std::env::var("PI_CODING_AGENT_DIR") {
return Ok(PathBuf::from(agent_dir));
}
#[cfg(debug_assertions)]
if let Some(agent_dir) = e2e_config_value("agentDir") {
return Ok(PathBuf::from(agent_dir));
}
app.path()
.app_data_dir()
.map(|dir| dir.join("pi-runtime").join("agent"))
.map_err(|e| format!("failed to resolve app data dir: {}", e))
}
fn resolve_app_session_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
Ok(resolve_app_agent_dir(app)?.join("sessions"))
}
pub fn read_app_connection(app: &tauri::AppHandle) -> Option<PiAgentConnection> {
let conn_file = resolve_app_agent_dir(app).ok()?.join("piagentui-port.json");
if !conn_file.exists() {
return None;
}
let file = fs::File::open(conn_file).ok()?;
serde_json::from_reader(file).ok()
}
fn connection_url(connection: &PiAgentConnection) -> String {
format!("http://127.0.0.1:{}", connection.port)
}
fn resolve_app_extension_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
#[cfg(debug_assertions)]
{
let source_extension = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.map(|root| root.join("extensions").join("piagentui-server.ts"));
if let Some(path) = source_extension {
if path.exists() {
return Ok(path);
}
}
}
let mut cmd = Command::new(binary_path);
cmd.arg("rpc").stdout(Stdio::null()).stderr(Stdio::null());
let resource_extension = app
.path()
.resource_dir()
.map_err(|e| format!("failed to resolve app resource dir: {}", e))?
.join("extensions")
.join("piagentui-server.ts");
// 注入用户配置的环境变量
if resource_extension.exists() {
return Ok(resource_extension);
}
Err(format!(
"PiAgentUI bundled extension was not found at {}. The desktop app cannot expose provider onboarding APIs without it.",
resource_extension.display()
))
}
#[derive(Debug)]
struct PiCommandSpec {
program: PathBuf,
prefix_args: Vec<String>,
display: String,
}
fn is_default_pi_binary(binary_path: &str) -> bool {
let trimmed = binary_path.trim();
trimmed.is_empty() || trimmed == "pi"
}
fn resolve_pi_command(binary_path: &str) -> PiCommandSpec {
let trimmed = binary_path.trim();
#[cfg(debug_assertions)]
if is_default_pi_binary(trimmed) {
if let Some(local_cli) = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|root| root.parent().map(|workspace| workspace.join("pi").join("packages").join("coding-agent").join("dist").join("cli.js")))
{
if local_cli.exists() {
let node = std::env::var("PIAGENTUI_NODE_BINARY").unwrap_or_else(|_| "node".to_string());
return PiCommandSpec {
program: PathBuf::from(node.clone()),
prefix_args: vec![local_cli.display().to_string()],
display: format!("{} {}", node, local_cli.display()),
};
}
}
}
let program = if trimmed.is_empty() { "pi" } else { trimmed };
PiCommandSpec {
program: PathBuf::from(program),
prefix_args: Vec::new(),
display: program.to_string(),
}
}
/// 启动 pi RPC 进程 with an app-private Pi runtime.
fn spawn_pi_rpc(
app: &tauri::AppHandle,
binary_path: &str,
env_vars: &std::collections::HashMap<String, String>,
) -> Result<(std::process::Child, PathBuf, PathBuf), String> {
let agent_dir = resolve_app_agent_dir(app)?;
let session_dir = resolve_app_session_dir(app)?;
let extension_path = resolve_app_extension_path(app)?;
let pi_command = resolve_pi_command(binary_path);
fs::create_dir_all(&session_dir).map_err(|e| format!("failed to create private Pi runtime dir: {}", e))?;
log::info!("Starting isolated pi RPC backend with command: {}", pi_command.display);
log::info!("Using private Pi agent dir: {}", agent_dir.display());
log::info!("Loading PiAgentUI extension: {}", extension_path.display());
if !env_vars.is_empty() {
log::info!("Injecting {} user environment variable(s)", env_vars.len());
}
#[cfg(debug_assertions)]
let mut cmd = if let (Some(node_path), Some(script_path)) =
(e2e_config_value("node"), e2e_config_value("script"))
{
log::info!("Using E2E Pi backend shim: {} {}", node_path, script_path);
let mut cmd = Command::new(node_path);
cmd.arg(script_path);
if let Some(marker_dir) = e2e_config_value("markerDir") {
cmd.env("PIAGENTUI_E2E_MARKER_DIR", marker_dir);
}
cmd
} else if let Ok(script_path) = std::env::var("PIAGENTUI_E2E_PI_SCRIPT") {
let node_path = std::env::var("PIAGENTUI_E2E_NODE").unwrap_or_else(|_| binary_path.to_string());
log::info!("Using E2E Pi backend shim: {} {}", node_path, script_path);
let mut cmd = Command::new(node_path);
cmd.arg(script_path);
cmd
} else {
let mut cmd = Command::new(&pi_command.program);
cmd.args(&pi_command.prefix_args);
cmd
};
#[cfg(not(debug_assertions))]
let mut cmd = {
let mut cmd = Command::new(&pi_command.program);
cmd.args(&pi_command.prefix_args);
cmd
};
// Inject user-configured environment first, then enforce private Pi runtime vars.
for (key, value) in env_vars {
cmd.env(key, value);
}
cmd.arg("--mode")
.arg("rpc")
.arg("--extension")
.arg(&extension_path)
.env("PI_CODING_AGENT_DIR", &agent_dir)
.env("PI_CODING_AGENT_SESSION_DIR", &session_dir)
.stdout(Stdio::null())
.stderr(Stdio::null());
#[cfg(target_os = "windows")]
{
@@ -54,12 +228,19 @@ fn spawn_pi_serve(
cmd.creation_flags(CREATE_NO_WINDOW);
}
cmd.spawn().map_err(|e| {
let child = cmd.spawn().map_err(|e| {
let local_hint = if is_default_pi_binary(binary_path) {
" The app also tried its development fallback. If you are running from source, build local Pi with `pnpm --dir pi/packages/coding-agent build`."
} else {
""
};
format!(
"Failed to start '{}': {}. Check that the path is correct.",
binary_path, e
"Failed to start '{}': {}. Check that the path points to the pi executable.{}",
pi_command.display, e, local_hint
)
})
})?;
Ok((child, agent_dir, session_dir))
}
/// 跨平台杀进程
@@ -92,36 +273,64 @@ pub async fn check_pi_service(url: String) -> Result<bool, String> {
Ok(is_service_running(&url).await)
}
/// 启动 pi serve
/// Start an isolated pi RPC backend for the desktop app.
#[tauri::command]
pub async fn start_pi_service(
app: tauri::AppHandle,
state: State<'_, ServiceState>,
url: String,
binary_path: String,
env_vars: std::collections::HashMap<String, String>,
) -> Result<bool, String> {
if is_service_running(&url).await {
log::info!("pi service already running at {}", url);
return Ok(false);
) -> Result<PiAgentServiceStart, String> {
if let Some(connection) = read_app_connection(&app) {
let connection_url = connection_url(&connection);
if is_service_running(&connection_url).await {
log::info!("isolated pi backend already running at {}", connection_url);
let agent_dir = resolve_app_agent_dir(&app)?;
let session_dir = resolve_app_session_dir(&app)?;
return Ok(PiAgentServiceStart {
started: false,
connection: Some(connection),
agent_dir: agent_dir.display().to_string(),
session_dir: session_dir.display().to_string(),
});
}
}
let child = spawn_pi_serve(&binary_path, &env_vars)?;
if is_service_running(&url).await {
log::info!("legacy pi service already running at {}", url);
}
let (child, agent_dir, session_dir) = spawn_pi_rpc(&app, &binary_path, &env_vars)?;
let pid = child.id();
log::info!("Started pi serve, PID: {}", pid);
log::info!("Started isolated pi RPC backend, PID: {}", pid);
state.child_pid.store(pid, Ordering::SeqCst);
state.we_started.store(true, Ordering::SeqCst);
for _ in 0..30 {
tokio::time::sleep(Duration::from_millis(500)).await;
if is_service_running(&url).await {
log::info!("pi service is ready at {}", url);
return Ok(true);
for _ in 0..60 {
tokio::time::sleep(Duration::from_millis(250)).await;
if let Some(connection) = read_app_connection(&app) {
let connection_url = connection_url(&connection);
if is_service_running(&connection_url).await {
log::info!("isolated pi backend is ready at {}", connection_url);
return Ok(PiAgentServiceStart {
started: true,
connection: Some(connection),
agent_dir: agent_dir.display().to_string(),
session_dir: session_dir.display().to_string(),
});
}
}
}
log::warn!("pi service started but health check not passing yet");
Ok(true)
log::warn!("isolated pi backend started but discovery/health is not ready yet");
Ok(PiAgentServiceStart {
started: true,
connection: read_app_connection(&app),
agent_dir: agent_dir.display().to_string(),
session_dir: session_dir.display().to_string(),
})
}
/// 停止 pi serve
+3 -16
View File
@@ -1,4 +1,5 @@
use crate::app::dir_state::OpenDirectoryState;
use crate::app::commands::pi_agent::{read_app_connection, PiAgentConnection};
use std::sync::Arc;
use tauri::State;
@@ -25,21 +26,7 @@ pub fn desktop_window_ready(window: tauri::Window) -> Result<(), String> {
crate::app::mark_window_ready(&window).map_err(|err| err.to_string())
}
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
pub struct PiAgentConnection {
pub port: u16,
pub token: String,
}
#[tauri::command]
pub fn get_pi_agent_connection() -> Option<PiAgentConnection> {
let home = std::env::var_os("USERPROFILE")
.or_else(|| std::env::var_os("HOME"))
.map(std::path::PathBuf::from)?;
let conn_file = home.join(".pi").join("agent").join("piagentui-port.json");
if !conn_file.exists() {
return None;
}
let file = std::fs::File::open(conn_file).ok()?;
serde_json::from_reader(file).ok()
pub fn get_pi_agent_connection(app: tauri::AppHandle) -> Option<PiAgentConnection> {
read_app_connection(&app)
}
+24 -13
View File
@@ -23,6 +23,13 @@ use std::sync::Arc;
#[cfg(not(target_os = "android"))]
use tauri::Emitter;
#[cfg(debug_assertions)]
fn is_e2e_run() -> bool {
std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(|dir| dir.join("piagentui-e2e.json")))
.is_some_and(|path| path.exists())
}
/// 从命令行参数中提取目录路径
#[cfg(not(target_os = "android"))]
fn extract_directory_from_args(args: &[String]) -> Option<String> {
@@ -43,18 +50,20 @@ fn create_main_window(app: &tauri::AppHandle) -> Result<tauri::WebviewWindow, ta
return Ok(window);
}
let config = app
.config()
.app
.windows
.iter()
.find(|window| window.label == "main")
.cloned()
.expect("main window config missing");
let builder = configure_desktop_window_builder(tauri::WebviewWindowBuilder::new(
app,
"main",
tauri::WebviewUrl::App("index.html".into()),
))
.title("Pi Agent")
.inner_size(800.0, 600.0)
.resizable(true)
.fullscreen(false);
configure_desktop_window_builder(tauri::WebviewWindowBuilder::from_config(app, &config)?)
.visible(false)
.build()
#[cfg(windows)]
let builder = builder.disable_drag_drop_handler();
builder.visible(false).build()
}
#[cfg(target_os = "android")]
@@ -188,11 +197,13 @@ pub fn run() {
#[cfg(not(target_os = "android"))]
{
let main_window = create_main_window(&app.handle())?;
let main_window = create_main_window(app.handle())?;
finish_desktop_window_setup(&main_window);
#[cfg(debug_assertions)]
main_window.open_devtools();
if !is_e2e_run() {
main_window.open_devtools();
}
}
#[cfg(target_os = "android")]
+11 -3
View File
@@ -6,16 +6,17 @@
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"label": "main",
"create": false,
"create": true,
"title": "Pi Agent",
"visible": true,
"width": 800,
"height": 600,
"titleBarStyle": "Overlay",
@@ -40,6 +41,13 @@
"active": true,
"targets": ["nsis", "dmg", "deb"],
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/[email protected]", "icons/icon.icns", "icons/icon.ico"],
"resources": {
"../extensions/piagentui-server.ts": "extensions/piagentui-server.ts",
"../extensions/piagentui-server-core.ts": "extensions/piagentui-server-core.ts",
"../extensions/piagentui-settings-store.ts": "extensions/piagentui-settings-store.ts",
"../extensions/piagentui-extension-manager.ts": "extensions/piagentui-extension-manager.ts",
"../extensions/piagentui-git-manager.ts": "extensions/piagentui-git-manager.ts"
},
"windows": {
"nsis": {
"installerHooks": "./windows/hooks.nsi"
+3
View File
@@ -37,6 +37,7 @@ import type { TerminalTab } from './store/layoutStore'
import type { SettingsTab } from './features/settings/SettingsDialog'
import { isTauri, isTauriMobile } from './utils/tauri'
import { ProviderOnboarding } from './features/onboarding/ProviderOnboarding'
const SettingsDialog = lazy(() =>
import('./features/settings/SettingsDialog').then(module => ({ default: module.SettingsDialog })),
)
@@ -623,6 +624,8 @@ function App() {
onCancel={handleCloseDialogCancel}
/>
</Suspense>
<ProviderOnboarding />
</ChatViewportProvider>
</div>
)
+3 -22
View File
@@ -30,6 +30,7 @@ export * from './global'
export * from './tool'
export * from './lsp'
export * from './extension'
export * from './runtime'
// ============================================
// Model API Functions
@@ -55,32 +56,12 @@ export async function getActiveModels(_directory?: string): Promise<ModelInfo[]>
variants: model.variants ?? [],
}))
} catch {
return [
{
id: 'gemini-2.5-flash',
name: 'Gemini 2.5 Flash',
providerId: 'google',
providerName: 'google',
family: '',
contextLimit: 128000,
outputLimit: 4096,
supportsReasoning: true,
supportsImages: true,
supportsPdf: false,
supportsAudio: false,
supportsVideo: false,
supportsToolcall: true,
variants: [],
},
]
return []
}
}
export async function getDefaultModels(_directory?: string): Promise<Record<string, string>> {
return {
google: 'gemini-2.5-flash',
anthropic: 'claude-3-5-sonnet',
}
return {}
}
// ============================================
+10 -29
View File
@@ -4,6 +4,7 @@
import type { Config } from '../types/api/config'
import type { ProvidersResponse } from '../types/api/model'
import { piClient } from './piClient'
/**
* 获取当前配置
@@ -26,33 +27,13 @@ export async function updateConfig(config: Config, _directory?: string): Promise
* 获取 provider 配置列表
*/
export async function getProviderConfigs(_directory?: string): Promise<ProvidersResponse> {
return {
providers: [
{
id: "google",
name: "Google Gemini",
models: [
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", provider: "google" },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", provider: "google" }
],
auth: {
authorization: { configured: true }
}
},
{
id: "anthropic",
name: "Anthropic Claude",
models: [
{ id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", provider: "anthropic" }
],
auth: {
authorization: { configured: true }
}
}
],
default: {
"google": "gemini-2.5-flash",
"anthropic": "claude-3-5-sonnet"
}
}
return await piClient.get<ProvidersResponse>('/api/providers')
}
export async function saveProviderApiKey(providerId: string, apiKey: string): Promise<void> {
await piClient.post<{ ok: boolean }>(`/api/providers/${encodeURIComponent(providerId)}/api-key`, { apiKey })
}
export async function clearProviderAuth(providerId: string): Promise<void> {
await piClient.post<{ ok: boolean }>(`/api/providers/${encodeURIComponent(providerId)}/logout`)
}
+22 -7
View File
@@ -52,7 +52,7 @@ export async function getSessionMessages(
): Promise<ApiMessageWithParts[]> {
try {
const data = await piClient.get<{ messages: ApiMessageWithParts[] }>(
`/api/sessions/${sessionId}/messages?directory=${formatPathForApi(directory)}&limit=${limit || ''}`
`/api/sessions/${sessionId}/messages?directory=${formatPathForApi(directory)}&limit=${limit || ''}`,
)
return data.messages || []
} catch {
@@ -135,22 +135,37 @@ export function extractUserMessageContent(message: UserContentSource): RevertedM
* 同步发送消息(等待完成)
*/
export async function sendMessage(params: SendMessageParams): Promise<SendMessageResponse> {
return await piClient.post<SendMessageResponse>('/api/messages/send', {
const body: Record<string, unknown> = {
sessionId: params.sessionId,
text: params.text,
model: params.model,
directory: params.directory,
})
parts: [{ type: 'text', text: params.text }],
}
if (params.agent) body.agent = params.agent
if (params.variant) body.variant = params.variant
body.text = params.text
return await piClient.post<SendMessageResponse>('/api/messages/send', body)
}
/**
* 异步发送消息 — 立即返回, AI 响应通过 SSE/WS 推送
*/
export async function sendMessageAsync(params: SendMessageParams): Promise<void> {
await piClient.post('/api/messages/send', {
// The Pi server expects 'parts' array (OpenAPI spec) rather than plain 'text'.
// We send both for backward compatibility, but prefer 'parts' format.
const body: Record<string, unknown> = {
sessionId: params.sessionId,
text: params.text,
model: params.model,
directory: params.directory,
})
parts: [{ type: 'text', text: params.text }],
}
// Only include optional fields if they have values
if (params.agent) body.agent = params.agent
if (params.variant) body.variant = params.variant
// Send text as well for backward compatibility with older servers
body.text = params.text
await piClient.post('/api/messages/send', body)
}
+51
View File
@@ -0,0 +1,51 @@
import type { Session, Model } from '../types/api/compat'
export interface SessionListResponse {
sessions: Session[]
}
export interface SessionCreateRequest {
title?: string
directory?: string
}
export interface SessionUpdateRequest {
title?: string
}
export interface SessionForkRequest {
messageID?: string
directory?: string
}
export interface SendMessageRequest {
sessionId: string
text: string
model?: { providerID: string; modelID: string }
directory?: string
}
export interface SendMessageResponse {
messageID: string
sessionID: string
}
export interface ModelsResponse {
models: Model[]
}
export interface PartDeltaPayload {
sessionID: string
messageID: string
partID: string
field: 'text' | 'reasoning' | 'tool'
delta: string
}
export interface PiGlobalEvent {
directory?: string
payload: {
type: string
properties: unknown
}
}
+63
View File
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getRuntimeCapabilities, parseRuntimeCapabilities } from './runtime'
import { piClient } from './piClient'
vi.mock('./piClient', () => ({
piClient: {
get: vi.fn(),
},
}))
describe('runtime api', () => {
beforeEach(() => {
vi.mocked(piClient.get).mockReset()
})
it('parses Pi runtime capabilities from backend payloads', () => {
const capabilities = parseRuntimeCapabilities({
runtime: 'pi',
sessionSwitching: { available: true },
extensionLoading: { available: true, source: 'pi.extensions' },
mcp: { available: true },
slashCommands: { available: true },
})
expect(capabilities).toEqual({
runtime: 'pi',
sessionSwitching: { available: true },
extensionLoading: { available: true, source: 'pi.extensions' },
mcp: { available: true },
slashCommands: { available: true },
})
})
it('uses safe unavailable defaults for malformed capability payloads', () => {
const capabilities = parseRuntimeCapabilities({ runtime: 'unexpected', sessionSwitching: null })
expect(capabilities).toEqual({
runtime: 'unknown',
sessionSwitching: { available: false, reason: 'capability_unreported' },
extensionLoading: { available: false, reason: 'capability_unreported' },
mcp: { available: false, reason: 'capability_unreported' },
slashCommands: { available: false, reason: 'capability_unreported' },
})
})
it('loads runtime capabilities from the local backend endpoint', async () => {
vi.mocked(piClient.get).mockResolvedValue({
runtime: 'pi',
sessionSwitching: { available: true },
extensionLoading: { available: false, reason: 'extensions_disabled' },
mcp: { available: true },
slashCommands: { available: true },
})
await expect(getRuntimeCapabilities()).resolves.toMatchObject({
runtime: 'pi',
sessionSwitching: { available: true },
extensionLoading: { available: false, reason: 'extensions_disabled' },
})
expect(piClient.get).toHaveBeenCalledWith('/api/runtime/capabilities')
})
})
+46
View File
@@ -0,0 +1,46 @@
import { piClient } from './piClient'
import type { RuntimeCapabilities, RuntimeCapabilityStatus, RuntimeKind } from '../types/api/runtime'
const UNREPORTED_CAPABILITY: RuntimeCapabilityStatus = {
available: false,
reason: 'capability_unreported',
}
export function parseRuntimeCapabilities(value: unknown): RuntimeCapabilities {
const record = isRecord(value) ? value : {}
return {
runtime: parseRuntimeKind(record.runtime),
sessionSwitching: parseCapabilityStatus(record.sessionSwitching),
extensionLoading: parseCapabilityStatus(record.extensionLoading),
mcp: parseCapabilityStatus(record.mcp),
slashCommands: parseCapabilityStatus(record.slashCommands),
}
}
export async function getRuntimeCapabilities(): Promise<RuntimeCapabilities> {
const payload = await piClient.get<unknown>('/api/runtime/capabilities')
return parseRuntimeCapabilities(payload)
}
function parseRuntimeKind(value: unknown): RuntimeKind {
return value === 'pi' ? 'pi' : 'unknown'
}
function parseCapabilityStatus(value: unknown): RuntimeCapabilityStatus {
if (!isRecord(value) || typeof value.available !== 'boolean') {
return { ...UNREPORTED_CAPABILITY }
}
return {
available: value.available,
...(typeof value.reason === 'string' ? { reason: value.reason } : {}),
...(typeof value.source === 'string' ? { source: value.source } : {}),
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export type { RuntimeCapabilities, RuntimeCapabilityStatus, RuntimeKind }
+1 -12
View File
@@ -90,18 +90,7 @@ export async function createSession(
parentID?: string
} = {},
): Promise<ApiSession> {
try {
return await piClient.post<ApiSession>('/api/sessions', params)
} catch {
return {
id: 'active-session',
title: params.title || fallbackSessionTitle('active-session'),
directory: formatPathForApi(params.directory) ?? '',
time: { created: Date.now(), updated: Date.now() },
status: { type: 'idle' },
summary: { deletions: 0, files: 0, additions: 0 },
}
}
return await piClient.post<ApiSession>('/api/sessions', params)
}
export async function updateSession(
+7 -1
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getSttSettings, transcribeAudio, updateSttSettings } from './stt'
import { getSttRealtimeUrl, getSttSettings, transcribeAudio, updateSttSettings } from './stt'
import { piClient } from './piClient'
import { serverStore } from '../store/serverStore'
@@ -58,6 +58,12 @@ describe('stt api', () => {
expect(updated.language).toBe('pt')
})
it('builds an authenticated realtime STT websocket URL', () => {
const url = getSttRealtimeUrl()
expect(url).toBe('ws://127.0.0.1:58785/api/stt/realtime?token=token-123')
})
it('uploads audio as multipart form data with bearer auth', async () => {
const originalFetch = globalThis.fetch
const fetchMock = vi.fn(
+15
View File
@@ -10,6 +10,21 @@ export async function updateSttSettings(settings: UpdateSttSettingsRequest): Pro
return await piClient.post<SttSettings>('/api/settings/stt', settings)
}
export function getSttRealtimeUrl(): string {
const baseUrl = serverStore
.getActiveBaseUrl()
.replace(/^http:/, 'ws:')
.replace(/^https:/, 'wss:')
const token = serverStore.getActiveToken()
const url = new URL('/api/stt/realtime', baseUrl)
if (token) {
url.searchParams.set('token', token)
}
return url.toString()
}
function buildUploadHeaders(): Record<string, string> {
const headers: Record<string, string> = {}
const token = serverStore.getActiveToken()
+21
View File
@@ -44,6 +44,12 @@ describe('ExtensionPanel', () => {
prompts: [],
themes: [],
},
compatibility: {
kind: 'pi',
status: 'supported',
reason: 'Package declares Pi extension entrypoints.',
capabilities: ['extensions', 'skills'],
},
},
{
source: 'npm:pi-muted',
@@ -60,6 +66,12 @@ describe('ExtensionPanel', () => {
prompts: [],
themes: [],
},
compatibility: {
kind: 'opencode',
status: 'partial',
reason: 'OpenCode manifest detected; execution is disabled until sandbox permissions are implemented.',
capabilities: ['extensions', 'commands'],
},
},
{
source: 'npm:missing-extension',
@@ -67,6 +79,12 @@ describe('ExtensionPanel', () => {
filtered: false,
status: 'missing',
resources: { extensions: [], skills: [], prompts: [], themes: [] },
compatibility: {
kind: 'unknown',
status: 'unsupported',
reason: 'No Pi or OpenCode extension manifest was detected.',
capabilities: [],
},
},
],
})
@@ -87,6 +105,9 @@ describe('ExtensionPanel', () => {
expect(screen.getAllByText('Extensions 1').length).toBeGreaterThan(0)
expect(screen.getByText('Skills 1')).toBeInTheDocument()
expect(screen.getByText('index.ts')).toBeInTheDocument()
expect(screen.getByText('Pi supported')).toBeInTheDocument()
expect(screen.getByText('OpenCode partial')).toBeInTheDocument()
expect(screen.getByText('Unsupported')).toBeInTheDocument()
const installInput = screen.getByRole('textbox', { name: 'Install command' })
fireEvent.change(installInput, { target: { value: 'pi install npm:pi-new' } })
+26
View File
@@ -346,6 +346,7 @@ function ExtensionPackageCard({
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-[length:var(--fs-base)] font-medium text-text-100">{name}</span>
<StatusBadge status={pkg.status} />
{pkg.compatibility && <CompatibilityBadge compatibility={pkg.compatibility} />}
</div>
{pkg.description && (
<p className="mt-0.5 truncate text-[length:var(--fs-sm)] text-text-400">{pkg.description}</p>
@@ -418,6 +419,31 @@ function StatusBadge({ status }: { status: ExtensionPackageStatus }) {
)
}
function CompatibilityBadge({ compatibility }: { compatibility: NonNullable<ExtensionPackageInfo['compatibility']> }) {
const label = getCompatibilityLabel(compatibility)
const className = {
supported: 'border-success-100/20 bg-success-100/10 text-success-100',
partial: 'border-warning-100/20 bg-warning-100/10 text-warning-100',
unsupported: 'border-border-200/50 bg-bg-200/60 text-text-400',
}[compatibility.status]
return (
<span
className={`inline-flex shrink-0 items-center rounded-full border px-1.5 py-0.5 text-[length:var(--fs-xxs)] ${className}`}
title={compatibility.reason}
>
{label}
</span>
)
}
function getCompatibilityLabel(compatibility: NonNullable<ExtensionPackageInfo['compatibility']>): string {
if (compatibility.kind === 'pi' && compatibility.status === 'supported') return 'Pi supported'
if (compatibility.kind === 'opencode' && compatibility.status === 'partial') return 'OpenCode partial'
if (compatibility.status === 'unsupported') return 'Unsupported'
return `${compatibility.kind} ${compatibility.status}`
}
function countResources(
pkg: ExtensionPackageInfo,
): Array<{ type: keyof ExtensionPackageInfo['resources']; count: number }> {
+106 -3
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { buildTurnDurationMap } from './ChatArea'
import { createElement, createRef } from 'react'
import { act, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { buildTurnDurationMap, ChatArea, type ChatAreaHandle } from './ChatArea'
import { ChatViewportProvider, type ChatViewportValue } from './chatViewport'
import { buildVisibleMessageEntries, getVisibleMessageForkTargetId } from './chatAreaVisibility'
import type { Message, MessageError, Part, ToolPart, ReasoningPart } from '../../types/message'
@@ -18,7 +21,13 @@ function createUserMessage(id: string, created: number): Message {
}
}
function createAssistantMessage(id: string, parts: Part[], created = 1, completed?: number, error?: MessageError): Message {
function createAssistantMessage(
id: string,
parts: Part[],
created = 1,
completed?: number,
error?: MessageError,
): Message {
return {
info: {
id,
@@ -64,6 +73,100 @@ function createToolPart(id: string, messageID: string): ToolPart {
}
}
const viewportValue: ChatViewportValue = {
presentation: { surfaceVariant: 'desktop', isCompact: false },
interaction: {
mode: 'pointer',
touchCapable: false,
sidebarBehavior: 'docked',
rightPanelBehavior: 'docked',
bottomPanelBehavior: 'docked',
outlineInteraction: 'pointer',
enableCollapsedInputDock: false,
},
layout: {
viewportWidth: 1280,
viewportHeight: 800,
surfaceWidth: 900,
surfaceMinWidth: 380,
sidebar: {
railWidth: 49,
requestedWidth: 288,
openWidth: 288,
dockedWidth: 288,
overlayWidth: 288,
hardMinWidth: 160,
preferredMinWidth: 240,
maxWidth: 480,
resizeMaxWidth: 480,
},
rightPanel: {
requestedWidth: 450,
dockedWidth: 0,
hardMinWidth: 160,
maxWidth: 960,
resizeMaxWidth: 960,
},
bottomPanel: { maxHeight: 448 },
},
actions: { setSidebarRequestedWidth: () => {} },
}
class IntersectionObserverMock {
private readonly callback: IntersectionObserverCallback
constructor(callback: IntersectionObserverCallback) {
this.callback = callback
}
observe(target: Element) {
this.callback(
[{ isIntersecting: false, target } as IntersectionObserverEntry],
this as unknown as IntersectionObserver,
)
}
disconnect() {}
unobserve() {}
takeRecords(): IntersectionObserverEntry[] {
return []
}
}
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
callback(0)
return 0
})
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('ChatArea scrolling', () => {
it('scrolls to the bottom when the newest-message button calls scrollToBottom', () => {
const ref = createRef<ChatAreaHandle>()
const chatArea = createElement(ChatArea, {
ref,
messages: [createUserMessage('user-1', 1000), createAssistantMessage('assistant-1', [], 1001, 1200)],
sessionId: 'session-1',
})
const { container } = render(createElement(ChatViewportProvider, { value: viewportValue, children: chatArea }))
const scrollRoot = container.querySelector('.custom-scrollbar') as HTMLDivElement
const scrollTo = vi.fn()
Object.defineProperty(scrollRoot, 'scrollHeight', { value: 1200, configurable: true })
Object.defineProperty(scrollRoot, 'clientHeight', { value: 400, configurable: true })
Object.defineProperty(scrollRoot, 'scrollTo', { value: scrollTo, configurable: true })
act(() => ref.current?.scrollToBottom(true))
expect(scrollTo).toHaveBeenCalledWith({ top: 1200, behavior: 'auto' })
})
})
describe('buildVisibleMessageEntries', () => {
it('keeps source ids for merged assistant tool messages', () => {
const first = createAssistantMessage('assistant-1', [createToolPart('tool-1', 'assistant-1')])
+1 -1
View File
@@ -352,7 +352,7 @@ export const ChatArea = memo(
scrollToBottom: (instant = false) => {
const el = scrollRef.current
if (!el) return
el.scrollTo({ top: 0, behavior: instant ? 'auto' : 'smooth' })
el.scrollTo({ top: el.scrollHeight, behavior: instant ? 'auto' : 'smooth' })
},
scrollToBottomIfAtBottom: () => {
const el = scrollRef.current
+42 -5
View File
@@ -3,10 +3,12 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { InputBox } from './InputBox'
import type { Command } from '../../api/command'
import type { SttInsertMode, SttRealtimeDelta } from '../../types/api/stt'
import type { Message } from '../../types/message'
let slashCommands: Command[] = []
let messagesMock: Message[] = []
let latestVoiceDelta: ((event: SttRealtimeDelta, insertMode: SttInsertMode) => void) | undefined
function createHistoryMessage(text: string): Message {
return {
@@ -74,11 +76,22 @@ vi.mock('../slash-command', () => ({
}))
vi.mock('./input/InputToolbar', () => ({
InputToolbar: ({ onSend, canSend }: { onSend: () => void; canSend: boolean }) => (
<button type="button" onClick={onSend} disabled={!canSend}>
send
</button>
),
InputToolbar: ({
onSend,
canSend,
onVoiceTranscriptionDelta,
}: {
onSend: () => void
canSend: boolean
onVoiceTranscriptionDelta?: (event: SttRealtimeDelta, insertMode: SttInsertMode) => void
}) => {
latestVoiceDelta = onVoiceTranscriptionDelta
return (
<button type="button" onClick={onSend} disabled={!canSend}>
send
</button>
)
},
}))
vi.mock('./input/InputFooter', () => ({
@@ -109,6 +122,30 @@ describe('InputBox slash command selection', () => {
beforeEach(() => {
slashCommands = []
messagesMock = []
latestVoiceDelta = undefined
})
it('shows realtime voice partials without duplicating previous partial text', () => {
render(<InputBox paneId="pane-test" onSend={vi.fn()} />)
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(textarea, { target: { value: 'Resumo:' } })
act(() => latestVoiceDelta?.({ type: 'transcript.partial', text: ' olá' }, 'append'))
expect(textarea.value).toBe('Resumo: olá')
act(() => latestVoiceDelta?.({ type: 'transcript.partial', text: ' olá mundo' }, 'append'))
expect(textarea.value).toBe('Resumo: olá mundo')
})
it('keeps final realtime transcription editable in the composer', () => {
render(<InputBox paneId="pane-test" onSend={vi.fn()} />)
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
act(() => latestVoiceDelta?.({ type: 'transcript.partial', text: 'olá' }, 'replace'))
act(() => latestVoiceDelta?.({ type: 'transcript.final', text: 'olá mundo' }, 'replace'))
fireEvent.change(textarea, { target: { value: `${textarea.value}!` } })
expect(textarea.value).toBe('olá mundo!')
})
it('executes frontend commands immediately on selection', async () => {
+47 -1
View File
@@ -23,7 +23,7 @@ import { useChatViewport } from './chatViewport'
import type { ApiAgent } from '../../api/client'
import type { ModelInfo, FileCapabilities } from '../../api'
import type { Command } from '../../api/command'
import type { SttInsertMode } from '../../types/api/stt'
import type { SttInsertMode, SttRealtimeDelta } from '../../types/api/stt'
// ============================================
// Types
@@ -178,6 +178,9 @@ function InputBoxComponent({
const footerRef = useRef<HTMLDivElement>(null)
const isComposingRef = useRef(false)
const compositionEndTimerRef = useRef<number | null>(null)
const voiceDraftBaseRef = useRef<string | null>(null)
const voiceDraftFinalRef = useRef('')
const voiceDraftPartialRef = useRef('')
// 附件横向轨道
const {
@@ -757,10 +760,52 @@ function InputBoxComponent({
[supportsAnyFile, fileCaps, isSubmitting],
)
const composeVoiceDraft = useCallback((insertMode: SttInsertMode) => {
const base = voiceDraftBaseRef.current ?? ''
const body = `${voiceDraftFinalRef.current} ${voiceDraftPartialRef.current}`.trim()
if (!body) return base
if (insertMode === 'replace' || !base.trim()) return body
const needsSpace = !base.endsWith(' ') && !base.endsWith('\n')
return `${base}${needsSpace ? ' ' : ''}${body}`
}, [])
const handleVoiceTranscriptionDelta = useCallback(
(event: SttRealtimeDelta, insertMode: SttInsertMode) => {
if (event.type !== 'transcript.partial' && event.type !== 'transcript.final') return
if (voiceDraftBaseRef.current === null) {
voiceDraftBaseRef.current = insertMode === 'replace' ? '' : text
voiceDraftFinalRef.current = ''
voiceDraftPartialRef.current = ''
}
if (event.type === 'transcript.partial') {
voiceDraftPartialRef.current = event.text
} else {
voiceDraftFinalRef.current = `${voiceDraftFinalRef.current} ${event.text}`.trim()
voiceDraftPartialRef.current = ''
}
setText(composeVoiceDraft(insertMode))
requestAnimationFrame(() => {
const textarea = textareaRef.current
if (!textarea) return
textarea.focus()
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
})
},
[composeVoiceDraft, text],
)
const handleVoiceTranscription = useCallback((transcribedText: string, insertMode: SttInsertMode) => {
const cleanText = transcribedText.trim()
if (!cleanText) return
voiceDraftBaseRef.current = null
voiceDraftFinalRef.current = ''
voiceDraftPartialRef.current = ''
setText(current => {
if (insertMode === 'replace' || !current.trim()) return cleanText
const needsSpace = !current.endsWith(' ') && !current.endsWith('\n')
@@ -1130,6 +1175,7 @@ function InputBoxComponent({
fileCapabilities={fileCaps}
onFilesSelected={handleFilesSelected}
onVoiceTranscription={handleVoiceTranscription}
onVoiceTranscriptionDelta={handleVoiceTranscriptionDelta}
isStreaming={isStreaming}
isSending={isSubmitting}
onAbort={onAbort}
+8 -2
View File
@@ -9,7 +9,7 @@ import { GitComposerControl } from './GitComposerControl'
import { isTauri, isTauriMobile, extToMime } from '../../../utils/tauri'
import type { ApiAgent } from '../../../api/client'
import type { ModelInfo, FileCapabilities } from '../../../api'
import type { SttInsertMode } from '../../../types/api/stt'
import type { SttInsertMode, SttRealtimeDelta } from '../../../types/api/stt'
interface InputToolbarProps {
agents: ApiAgent[]
@@ -23,6 +23,7 @@ interface InputToolbarProps {
fileCapabilities?: FileCapabilities
onFilesSelected: (files: File[]) => void
onVoiceTranscription?: (text: string, insertMode: SttInsertMode) => void
onVoiceTranscriptionDelta?: (event: SttRealtimeDelta, insertMode: SttInsertMode) => void
isStreaming?: boolean
isSending?: boolean
@@ -52,6 +53,7 @@ export function InputToolbar({
fileCapabilities,
onFilesSelected,
onVoiceTranscription,
onVoiceTranscriptionDelta,
isStreaming,
isSending = false,
onAbort,
@@ -536,7 +538,11 @@ export function InputToolbar({
</>
</AnimatedPresence>
{onVoiceTranscription && (
<VoiceInputButton disabled={controlsDisabled} onTranscription={onVoiceTranscription} />
<VoiceInputButton
disabled={controlsDisabled}
onTranscription={onVoiceTranscription}
onTranscriptionDelta={onVoiceTranscriptionDelta}
/>
)}
{!canSend && isStreaming && !isSending ? (
<IconButton aria-label={t('inputToolbar.stopGeneration')} variant="solid" onClick={onAbort}>
@@ -20,9 +20,13 @@ describe('VoiceInputButton', () => {
resetError: vi.fn(),
})
render(<VoiceInputButton onTranscription={vi.fn()} />)
const onTranscription = vi.fn()
const onTranscriptionDelta = vi.fn()
render(<VoiceInputButton onTranscription={onTranscription} onTranscriptionDelta={onTranscriptionDelta} />)
fireEvent.click(screen.getByRole('button', { name: 'Start voice recording' }))
expect(useVoiceTranscription).toHaveBeenCalledWith({ onTranscription, onTranscriptionDelta })
expect(startRecording).toHaveBeenCalled()
})
+7 -3
View File
@@ -1,15 +1,19 @@
import { IconButton } from '../../../components/ui'
import { MicrophoneIcon, SpinnerIcon, StopIcon } from '../../../components/Icons'
import { useVoiceTranscription } from '../../../hooks/useVoiceTranscription'
import type { SttInsertMode } from '../../../types/api/stt'
import type { SttInsertMode, SttRealtimeDelta } from '../../../types/api/stt'
interface VoiceInputButtonProps {
disabled?: boolean
onTranscription: (text: string, insertMode: SttInsertMode) => void
onTranscriptionDelta?: (event: SttRealtimeDelta, insertMode: SttInsertMode) => void
}
export function VoiceInputButton({ disabled = false, onTranscription }: VoiceInputButtonProps) {
const { state, isAvailable, error, startRecording, stopRecording } = useVoiceTranscription({ onTranscription })
export function VoiceInputButton({ disabled = false, onTranscription, onTranscriptionDelta }: VoiceInputButtonProps) {
const { state, isAvailable, error, startRecording, stopRecording } = useVoiceTranscription({
onTranscription,
onTranscriptionDelta,
})
const isRecording = state === 'recording'
const isBusy = state === 'requesting-permission' || state === 'transcribing'
const isDisabled = disabled || !isAvailable || isBusy
+93 -3
View File
@@ -1,5 +1,6 @@
import { memo, useState, useRef, useEffect, useLayoutEffect, useMemo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { diffLines } from 'diff'
import { animate } from 'motion/mini'
import { ChevronDownIcon, ChevronRightIcon, SplitIcon, SpinnerIcon, UndoIcon } from '../../components/Icons'
import { CopyButton, SmoothHeight } from '../../components/ui'
@@ -24,8 +25,6 @@ import {
MessageErrorView,
} from './parts'
import { extractToolData } from './tools'
import { getToolSummaryCategory, type ToolSummaryCategory } from './toolSummary'
import { computeDiffStats } from '../../utils/diffStats'
import type {
Message,
Part,
@@ -598,7 +597,7 @@ const ToolGroup = memo(function ToolGroup({
for (const part of parts) {
if (part.state?.status === 'error') continue
const data = extractToolData(part)
const stats = data.diffStats || computeDiffStats(data)
const stats = data.diffStats || computePartDiffStats(data)
if (stats) {
additions += stats.additions
deletions += stats.deletions
@@ -775,6 +774,21 @@ function formatTokens(
return `${total} ${t('tokens')}`
}
type ToolSummaryCategory =
| 'execute'
| 'write'
| 'edit'
| 'read'
| 'search'
| 'list'
| 'network'
| 'task'
| 'todo'
| 'question'
| 'skill'
| 'think'
| 'other'
type ToolSummaryPhase = 'done' | 'active' | 'failed'
interface SummarySegment {
@@ -883,6 +897,45 @@ function formatToolSummarySegment(
return t(key, { count })
}
function getToolSummaryCategory(toolName: string): ToolSummaryCategory {
const lower = toolName.toLowerCase()
if (lower.includes('todo')) return 'todo'
if (lower === 'task') return 'task'
if (lower.includes('question') || lower.includes('ask')) return 'question'
if (lower.includes('skill')) return 'skill'
if (
lower.includes('bash') ||
lower === 'sh' ||
lower.includes('cmd') ||
lower.includes('terminal') ||
lower.includes('shell')
) {
return 'execute'
}
if (lower.includes('write') || lower.includes('save')) {
return 'write'
}
if (lower.includes('edit') || lower.includes('replace') || lower.includes('patch')) {
return 'edit'
}
if (
lower.includes('web') ||
lower.includes('fetch') ||
lower.includes('http') ||
lower.includes('browse') ||
lower.includes('network') ||
lower.includes('exa')
) {
return 'network'
}
if (lower.includes('read') || lower.includes('cat')) return 'read'
if (lower.includes('grep') || lower.includes('search')) return 'search'
if (lower.includes('glob') || lower.includes('find')) return 'list'
if (lower.includes('think') || lower.includes('reason') || lower.includes('plan')) return 'think'
return 'other'
}
function isToolPartActive(part: ToolPart): boolean {
return part.state?.status === 'running' || part.state?.status === 'pending'
}
@@ -893,6 +946,43 @@ function getTaskChildSessionId(part: ToolPart): string | undefined {
return metadata?.sessionId as string | undefined
}
/** 从 extractToolData 的结果计算 diff stats(当 metadata 没给 diffStats 时) */
function computePartDiffStats(data: {
diff?: { before: string; after: string } | string
files?: Array<{ before?: string; after?: string; additions?: number; deletions?: number }>
}): { additions: number; deletions: number } | undefined {
if (data.files?.length) {
let a = 0,
d = 0
for (const f of data.files) {
if (f.additions !== undefined) a += f.additions
if (f.deletions !== undefined) d += f.deletions
if (f.additions === undefined && f.before !== undefined && f.after !== undefined) {
const s = diffPairStats(f.before, f.after)
a += s.additions
d += s.deletions
}
}
return a || d ? { additions: a, deletions: d } : undefined
}
if (data.diff && typeof data.diff === 'object') {
const s = diffPairStats(data.diff.before, data.diff.after)
return s.additions || s.deletions ? s : undefined
}
return undefined
}
function diffPairStats(before: string, after: string): { additions: number; deletions: number } {
const changes = diffLines(before, after)
let additions = 0,
deletions = 0
for (const c of changes) {
if (c.added) additions += c.count || 0
if (c.removed) deletions += c.count || 0
}
return { additions, deletions }
}
// ============================================
// Helper: Group parts for rendering
// ============================================
+43 -2
View File
@@ -1,6 +1,6 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { computeDiffStats } from '../../../utils/diffStats'
import { diffLines } from 'diff'
import { ChevronDownIcon, ChevronRightIcon } from '../../../components/Icons'
import type { ToolPart } from '../../../types/message'
import { useDelayedRender } from '../../../hooks'
@@ -203,7 +203,7 @@ export const ToolPartView = memo(function ToolPartView({
if (descriptive) {
const hasDiffFiles = !!toolData.files?.length
// diffStats 可能从 metadata 来,也可能需要从 diff 数据计算
const diffStats = toolData.diffStats || computeDiffStats(toolData)
const diffStats = toolData.diffStats || computeDiffStatsFromData(toolData)
return (
<div className="group py-0.5">
@@ -451,6 +451,47 @@ function isReadableTool(toolName: string): boolean {
return READABLE_TOOL_PATTERNS.test(toolName.toLowerCase())
}
/** 从 diff 数据计算 diffStats(当 metadata 没给 diffStats 时用) */
function computeDiffStatsFromData(data: {
diff?: { before: string; after: string } | string
files?: Array<{ before?: string; after?: string; additions?: number; deletions?: number }>
}): { additions: number; deletions: number } | undefined {
// 多文件
if (data.files?.length) {
let additions = 0,
deletions = 0
for (const f of data.files) {
if (f.additions !== undefined) additions += f.additions
if (f.deletions !== undefined) deletions += f.deletions
if (f.additions === undefined && f.before !== undefined && f.after !== undefined) {
const s = computeDiffPair(f.before, f.after)
additions += s.additions
deletions += s.deletions
}
}
return additions || deletions ? { additions, deletions } : undefined
}
// 单个 diff
if (data.diff && typeof data.diff === 'object') {
const s = computeDiffPair(data.diff.before, data.diff.after)
return s.additions || s.deletions ? s : undefined
}
return undefined
}
function computeDiffPair(before: string, after: string): { additions: number; deletions: number } {
const changes = diffLines(before, after)
let additions = 0,
deletions = 0
for (const c of changes) {
if (c.added) additions += c.count || 0
if (c.removed) deletions += c.count || 0
}
return { additions, deletions }
}
// ============================================
// ToolBody - 根据工具类型选择渲染器
// ============================================
-54
View File
@@ -1,54 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getToolSummaryCategory } from './toolSummary'
describe('getToolSummaryCategory', () => {
it.each([
['TodoWrite', 'todo'],
['task', 'task'],
['AskUserQuestion', 'question'],
['skill', 'skill'],
['bash', 'execute'],
['sh', 'execute'],
['run_cmd', 'execute'],
['terminal', 'execute'],
['shell_exec', 'execute'],
['write', 'write'],
['save_file', 'write'],
['edit', 'edit'],
['str_replace', 'edit'],
['apply_patch', 'edit'],
['webfetch', 'network'],
['http_get', 'network'],
['browse', 'network'],
['exa_search', 'network'],
['read', 'read'],
['cat', 'read'],
['grep', 'search'],
['search', 'search'],
['glob', 'list'],
['find_files', 'list'],
['think', 'think'],
['plan_mode', 'think'],
['unknown_tool', 'other'],
])('maps %s to %s', (tool, category) => {
expect(getToolSummaryCategory(tool)).toBe(category)
})
it('is case insensitive', () => {
expect(getToolSummaryCategory('BASH')).toBe('execute')
})
it('matches "task" and "sh" only exactly', () => {
expect(getToolSummaryCategory('tasks')).toBe('question') // contains "ask", not an exact "task"
expect(getToolSummaryCategory('push')).toBe('other')
})
it('keeps the precedence of earlier rules', () => {
// "todo" beats "write"; "ask" beats "read" in "ask_read"; "shell" (execute) beats "cat" in "shell_cat"
expect(getToolSummaryCategory('todowrite')).toBe('todo')
expect(getToolSummaryCategory('ask_read')).toBe('question')
expect(getToolSummaryCategory('shell_cat')).toBe('execute')
// "web" (network) beats "search"
expect(getToolSummaryCategory('websearch')).toBe('network')
})
})
-47
View File
@@ -1,47 +0,0 @@
export type ToolSummaryCategory =
| 'execute'
| 'write'
| 'edit'
| 'read'
| 'search'
| 'list'
| 'network'
| 'task'
| 'todo'
| 'question'
| 'skill'
| 'think'
| 'other'
interface CategoryRule {
category: ToolSummaryCategory
/** Exact (lowercase) tool names. */
exact?: readonly string[]
/** Substrings looked up in the lowercase tool name. */
includes?: readonly string[]
}
/** Order matters: the first matching rule wins (e.g. "todowrite" is a todo, not a write). */
const CATEGORY_RULES: readonly CategoryRule[] = [
{ category: 'todo', includes: ['todo'] },
{ category: 'task', exact: ['task'] },
{ category: 'question', includes: ['question', 'ask'] },
{ category: 'skill', includes: ['skill'] },
{ category: 'execute', exact: ['sh'], includes: ['bash', 'cmd', 'terminal', 'shell'] },
{ category: 'write', includes: ['write', 'save'] },
{ category: 'edit', includes: ['edit', 'replace', 'patch'] },
{ category: 'network', includes: ['web', 'fetch', 'http', 'browse', 'network', 'exa'] },
{ category: 'read', includes: ['read', 'cat'] },
{ category: 'search', includes: ['grep', 'search'] },
{ category: 'list', includes: ['glob', 'find'] },
{ category: 'think', includes: ['think', 'reason', 'plan'] },
]
function matchesRule(lowerName: string, rule: CategoryRule): boolean {
return !!rule.exact?.includes(lowerName) || !!rule.includes?.some(keyword => lowerName.includes(keyword))
}
export function getToolSummaryCategory(toolName: string): ToolSummaryCategory {
const lower = toolName.toLowerCase()
return CATEGORY_RULES.find(rule => matchesRule(lower, rule))?.category ?? 'other'
}
+1 -138
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { ToolPart } from '../../../types/message'
import { defaultExtractData, extractToolData } from './registry'
import { defaultExtractData } from './registry'
describe('defaultExtractData', () => {
it('extracts files and diagnostics from metadata', () => {
@@ -43,140 +43,3 @@ describe('defaultExtractData', () => {
])
})
})
function makePart(state: Record<string, unknown> | undefined): ToolPart {
return {
type: 'tool',
tool: 'custom',
id: 'tool-1',
callID: 'call-1',
sessionID: 'session-1',
messageID: 'message-1',
state,
} as unknown as ToolPart
}
describe('defaultExtractData branches', () => {
it('returns an empty result for a part without state', () => {
expect(defaultExtractData(makePart(undefined))).toEqual({})
})
it('serializes input as JSON only when it has keys', () => {
expect(defaultExtractData(makePart({ input: { a: 1 } }))).toMatchObject({
input: JSON.stringify({ a: 1 }, null, 2),
inputLang: 'json',
})
expect(defaultExtractData(makePart({ input: {} })).input).toBeUndefined()
})
it('extracts the error as a string', () => {
expect(defaultExtractData(makePart({ error: 'boom' })).error).toBe('boom')
})
it('prefers metadata.filepath over input.filePath and detects the output language', () => {
const fromMetadata = defaultExtractData(
makePart({ input: { filePath: 'b.py' }, metadata: { filepath: 'a.ts' }, output: 'x' }),
)
expect(fromMetadata.filePath).toBe('a.ts')
expect(fromMetadata.outputLang).toBeTruthy()
expect(defaultExtractData(makePart({ input: { filePath: 'b.py' } })).filePath).toBe('b.py')
})
it('extracts a numeric exit code only', () => {
expect(defaultExtractData(makePart({ metadata: { exit: 2 } })).exitCode).toBe(2)
expect(defaultExtractData(makePart({ metadata: { exit: '2' } })).exitCode).toBeUndefined()
})
it('maps metadata.files with the unknown fallback for the name', () => {
const data = defaultExtractData(makePart({ metadata: { files: [{ file: 'f.ts' }, {}] } }))
expect(data.files?.map(f => f.filePath)).toEqual(['f.ts', 'unknown'])
})
it('uses a unified metadata.diff with filediff stats', () => {
const data = defaultExtractData(
makePart({ metadata: { diff: '@@', filediff: { additions: 3 } }, output: 'ignored' }),
)
expect(data.diff).toBe('@@')
expect(data.diffStats).toEqual({ additions: 3, deletions: 0 })
expect(data.output).toBeUndefined()
})
it('keeps a unified metadata.diff without stats when filediff has no counters', () => {
const data = defaultExtractData(makePart({ metadata: { diff: '@@', filediff: {} } }))
expect(data.diffStats).toBeUndefined()
})
it('reads filediff.patch, falling back to before/after', () => {
const patch = defaultExtractData(makePart({ metadata: { filediff: { patch: 'P', additions: 1, deletions: 2 } } }))
expect(patch.diff).toBe('P')
expect(patch.diffStats).toEqual({ additions: 1, deletions: 2 })
const pair = defaultExtractData(makePart({ metadata: { filediff: { before: 'a', after: 'b' } } }))
expect(pair.diff).toEqual({ before: 'a', after: 'b' })
expect(pair.diffStats).toBeUndefined()
expect(defaultExtractData(makePart({ metadata: { filediff: { before: 'a' } } })).diff).toBeUndefined()
})
it('keeps only error and warning diagnostics and skips malformed entries', () => {
const data = defaultExtractData(
makePart({
metadata: {
diagnostics: {
'C:\\proj\\a.ts': [
{ severity: 2, message: 'warn' },
{ severity: 3, message: 'info' },
{ severity: 9, message: 'unknown severity' },
{ message: 'no severity' },
null,
],
'b.ts': 'not-an-array',
},
},
}),
)
expect(data.diagnostics).toEqual([{ file: 'a.ts', severity: 'warning', message: 'warn', line: 0, column: 0 }])
})
it('omits diagnostics when nothing is an error or warning', () => {
const data = defaultExtractData(makePart({ metadata: { diagnostics: { 'a.ts': [{ severity: 4 }] } } }))
expect(data.diagnostics).toBeUndefined()
})
it('uses state.output, stringifying non-string values and inferring json', () => {
expect(defaultExtractData(makePart({ output: 'plain' }))).toMatchObject({ output: 'plain' })
expect(defaultExtractData(makePart({ output: 'plain' })).outputLang).toBeUndefined()
expect(defaultExtractData(makePart({ output: ' {"a":1} ' })).outputLang).toBe('json')
expect(defaultExtractData(makePart({ output: '[1]' })).outputLang).toBe('json')
expect(defaultExtractData(makePart({ output: { a: 1 } })).output).toBe(JSON.stringify({ a: 1 }, null, 2))
})
it('falls back to the interrupted metadata output only when interrupted', () => {
expect(defaultExtractData(makePart({ metadata: { interrupted: true, output: 'partial' } })).output).toBe('partial')
expect(defaultExtractData(makePart({ metadata: { output: 'partial' } })).output).toBeUndefined()
})
it('does not set output when files are present', () => {
const data = defaultExtractData(makePart({ output: 'x', metadata: { files: [{ filePath: 'a' }] } }))
expect(data.output).toBeUndefined()
})
})
describe('extractToolData for bash', () => {
const bash = (state: Record<string, unknown>) => extractToolData({ ...makePart(state), tool: 'bash' } as ToolPart)
it('shows the command as bash input and trims the working directory', () => {
const data = bash({ input: { command: 'ls -la', workdir: ' /tmp ' } })
expect(data).toMatchObject({ input: 'ls -la', inputLang: 'bash', cwd: '/tmp' })
})
it('falls back to metadata cwd and ignores blank or non-string values', () => {
expect(bash({ input: { command: 'pwd' }, metadata: { cwd: '/srv' } }).cwd).toBe('/srv')
expect(bash({ input: { command: 'pwd', workdir: ' ' } }).cwd).toBeUndefined()
expect(bash({ input: { command: 'pwd', cwd: 5 } }).cwd).toBeUndefined()
})
it('keeps the JSON input when there is no command', () => {
expect(bash({ input: { a: 1 } }).inputLang).toBe('json')
})
})
+116 -167
View File
@@ -60,156 +60,6 @@ interface MetadataDiagnosticEntry {
// Default Data Extractor
// ============================================
type DiffStats = { additions: number; deletions: number }
type MetadataFileDiff = {
patch?: string
before?: string
after?: string
additions?: number
deletions?: number
}
function toDiffStats(fd: { additions?: number; deletions?: number }): DiffStats | undefined {
if (fd.additions === undefined && fd.deletions === undefined) return undefined
return { additions: fd.additions || 0, deletions: fd.deletions || 0 }
}
function isObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object'
}
function toFileEntries(files: MetadataFileEntry[]): NonNullable<ExtractedToolData['files']> {
return files.map(file => ({
filePath: file.filePath || file.file || 'unknown',
diff: file.diff,
patch: file.patch,
before: file.before,
after: file.after,
additions: file.additions,
deletions: file.deletions,
}))
}
/** 上游 v1.4.0+ metadata.filediff 用 patch 格式;否则回退到 before/after */
function diffFromFileDiff(filediff: MetadataFileDiff): ExtractedToolData['diff'] {
if (filediff.patch) return filediff.patch
if (filediff.before !== undefined && filediff.after !== undefined) {
return { before: filediff.before, after: filediff.after }
}
return undefined
}
/** Extracts files / diff / diffStats from tool metadata (files > unified diff > filediff). */
function extractDiffData(metadata: Record<string, unknown>): Pick<ExtractedToolData, 'files' | 'diff' | 'diffStats'> {
if (Array.isArray(metadata.files) && metadata.files.length > 0) {
return { files: toFileEntries(metadata.files as MetadataFileEntry[]) }
}
const filediff = isObject(metadata.filediff) ? (metadata.filediff as MetadataFileDiff) : undefined
const diffStats = filediff && toDiffStats(filediff)
// 优先使用 unified diff,统计取自 filediff
if (typeof metadata.diff === 'string') return { diff: metadata.diff, diffStats }
if (!filediff) return {}
return { diff: diffFromFileDiff(filediff), diffStats }
}
// severity: 1=error, 2=warning, 3=info, 4=hint
const DIAGNOSTIC_SEVERITIES: Record<number, DiagnosticInfo['severity']> = {
1: 'error',
2: 'warning',
3: 'info',
4: 'hint',
}
function toSeverity(value: unknown): DiagnosticInfo['severity'] {
return (typeof value === 'number' && DIAGNOSTIC_SEVERITIES[value]) || 'info'
}
function toDiagnostic(file: string, item: MetadataDiagnosticEntry): DiagnosticInfo {
const start = item.range?.start
return {
file: file.split(/[/\\]/).pop() || file,
severity: toSeverity(item.severity),
message: item.message || '',
line: start?.line ?? 0,
column: start?.character ?? 0,
}
}
/** Extracts only the error/warning diagnostics from tool metadata. */
function extractDiagnostics(raw: unknown): DiagnosticInfo[] | undefined {
if (!isObject(raw)) return undefined
const diagnostics: DiagnosticInfo[] = []
for (const [file, items] of Object.entries(raw as Record<string, MetadataDiagnosticEntry[]>)) {
if (!Array.isArray(items)) continue
for (const item of items) {
if (isObject(item)) diagnostics.push(toDiagnostic(file, item))
}
}
// 只保留 error 和 warning
const filtered = diagnostics.filter(d => d.severity === 'error' || d.severity === 'warning')
return filtered.length > 0 ? filtered : undefined
}
function looksLikeJson(text: string): boolean {
const trimmed = text.trim()
return (trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))
}
function extractFilePath(
inputObj: Record<string, unknown> | undefined,
metadata: Record<string, unknown> | undefined,
): string | undefined {
if (typeof metadata?.filepath === 'string' && metadata.filepath) return metadata.filepath
return inputObj?.filePath ? String(inputObj.filePath) : undefined
}
/** Tool output (or the output kept by an interrupted run), stringified. */
function extractOutputText(
state: ToolPart['state'] | undefined,
metadata: Record<string, unknown> | undefined,
): string | undefined {
const stateOutput = state && 'output' in state ? state.output : undefined
const interrupted =
metadata?.interrupted === true && typeof metadata.output === 'string' ? metadata.output : undefined
const output = stateOutput ?? interrupted
if (!output) return undefined
return typeof output === 'string' ? output : JSON.stringify(output, null, 2)
}
/** Fields derived from `metadata`: exit code, files / diff / diffStats and diagnostics. */
function extractMetadataFields(metadata: Record<string, unknown> | undefined): ExtractedToolData {
if (!metadata) return {}
const result: ExtractedToolData = {}
if (typeof metadata.exit === 'number') result.exitCode = metadata.exit
const diffData = extractDiffData(metadata)
if (diffData.files) result.files = diffData.files
if (diffData.diff !== undefined) result.diff = diffData.diff
if (diffData.diffStats) result.diffStats = diffData.diffStats
const diagnostics = extractDiagnostics(metadata.diagnostics)
if (diagnostics) result.diagnostics = diagnostics
return result
}
/** Output text plus its language (inferred as json when it looks like a JSON document). */
function extractOutputFields(
result: ExtractedToolData,
state: ToolPart['state'] | undefined,
metadata: Record<string, unknown> | undefined,
): ExtractedToolData {
const output = extractOutputText(state, metadata)
if (result.files || result.diff || !output) return {}
if (!result.outputLang && looksLikeJson(output)) return { output, outputLang: 'json' }
return { output }
}
export function defaultExtractData(part: ToolPart): ExtractedToolData {
const { state } = part
const inputObj = state?.input as Record<string, unknown> | undefined
@@ -228,15 +78,120 @@ export function defaultExtractData(part: ToolPart): ExtractedToolData {
result.error = String(state.error)
}
// FilePath (+ output language derived from it)
const filePath = extractFilePath(inputObj, metadata)
if (filePath) {
result.filePath = filePath
result.outputLang = detectLanguage(filePath)
// FilePath
if (metadata && typeof metadata.filepath === 'string') {
result.filePath = metadata.filepath
}
if (!result.filePath && inputObj?.filePath) {
result.filePath = String(inputObj.filePath)
}
Object.assign(result, extractMetadataFields(metadata))
Object.assign(result, extractOutputFields(result, state, metadata))
// Exit code
if (metadata && typeof metadata.exit === 'number') {
result.exitCode = metadata.exit
}
// Diff / Files (from metadata)
if (metadata) {
if (Array.isArray(metadata.files) && metadata.files.length > 0) {
result.files = (metadata.files as MetadataFileEntry[]).map(file => ({
filePath: file.filePath || file.file || 'unknown',
diff: file.diff,
patch: file.patch,
before: file.before,
after: file.after,
additions: file.additions,
deletions: file.deletions,
}))
} else if (typeof metadata.diff === 'string') {
// 优先使用 unified diff
result.diff = metadata.diff
// 从 filediff 获取统计
if (metadata.filediff && typeof metadata.filediff === 'object') {
const fd = metadata.filediff as { additions?: number; deletions?: number }
if (fd.additions !== undefined || fd.deletions !== undefined) {
result.diffStats = {
additions: fd.additions || 0,
deletions: fd.deletions || 0,
}
}
}
} else if (metadata.filediff && typeof metadata.filediff === 'object') {
const fd = metadata.filediff as {
patch?: string
before?: string
after?: string
additions?: number
deletions?: number
}
// 上游 v1.4.0+ metadata.filediff 用 patch 格式
if (fd.patch) {
result.diff = fd.patch
} else if (fd.before !== undefined && fd.after !== undefined) {
result.diff = { before: fd.before, after: fd.after }
}
if (fd.additions !== undefined || fd.deletions !== undefined) {
result.diffStats = {
additions: fd.additions || 0,
deletions: fd.deletions || 0,
}
}
}
// 提取 diagnostics
if (metadata.diagnostics && typeof metadata.diagnostics === 'object') {
const diagMap = metadata.diagnostics as Record<string, MetadataDiagnosticEntry[]>
const diagnostics: DiagnosticInfo[] = []
for (const [file, items] of Object.entries(diagMap)) {
if (!Array.isArray(items)) continue
for (const item of items) {
if (!item || typeof item !== 'object') continue
// severity: 1=error, 2=warning, 3=info, 4=hint
const severityMap: Record<number, DiagnosticInfo['severity']> = {
1: 'error',
2: 'warning',
3: 'info',
4: 'hint',
}
diagnostics.push({
file: file.split(/[/\\]/).pop() || file,
severity: typeof item.severity === 'number' ? (severityMap[item.severity] ?? 'info') : 'info',
message: item.message || '',
line: item.range?.start?.line ?? 0,
column: item.range?.start?.character ?? 0,
})
}
}
// 只保留 error 和 warning
const filtered = diagnostics.filter(d => d.severity === 'error' || d.severity === 'warning')
if (filtered.length > 0) {
result.diagnostics = filtered
}
}
}
// Output language from filePath
if (result.filePath) {
result.outputLang = detectLanguage(result.filePath)
}
// Output
const stateOutput = state && 'output' in state ? state.output : undefined
const interruptedOutput = metadata?.interrupted === true && typeof metadata.output === 'string' ? metadata.output : undefined
const output = stateOutput ?? interruptedOutput
if (!result.files && !result.diff && output) {
result.output = typeof output === 'string' ? output : JSON.stringify(output, null, 2)
// 推断语言
if (!result.outputLang && result.output) {
const trimmed = result.output.trim()
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
result.outputLang = 'json'
}
}
}
return result
}
@@ -245,14 +200,6 @@ export function defaultExtractData(part: ToolPart): ExtractedToolData {
// Tool-Specific Data Extractors
// ============================================
function resolveCwd(
inputObj: Record<string, unknown> | undefined,
metadata: Record<string, unknown> | undefined,
): string | undefined {
const cwd = inputObj?.workdir ?? inputObj?.cwd ?? metadata?.workdir ?? metadata?.cwd
return typeof cwd === 'string' && cwd.trim() ? cwd.trim() : undefined
}
function bashExtractData(part: ToolPart): ExtractedToolData {
const base = defaultExtractData(part)
const inputObj = part.state?.input as Record<string, unknown> | undefined
@@ -263,8 +210,10 @@ function bashExtractData(part: ToolPart): ExtractedToolData {
base.inputLang = 'bash'
}
const cwd = resolveCwd(inputObj, metadata)
if (cwd) base.cwd = cwd
const cwd = inputObj?.workdir ?? inputObj?.cwd ?? metadata?.workdir ?? metadata?.cwd
if (typeof cwd === 'string' && cwd.trim()) {
base.cwd = cwd.trim()
}
return base
}
@@ -0,0 +1,122 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ProviderOnboarding } from './ProviderOnboarding'
import { getProviderConfigs, saveProviderApiKey } from '../../api/config'
import type { Provider } from '../../types/api/model'
vi.mock('../../api/config', () => ({
getProviderConfigs: vi.fn(),
saveProviderApiKey: vi.fn(),
}))
const getProviderConfigsMock = vi.mocked(getProviderConfigs)
const saveProviderApiKeyMock = vi.mocked(saveProviderApiKey)
const PROVIDERS: Provider[] = [
{
id: 'opencode',
name: 'OpenCode Zen',
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash', provider: 'opencode' }],
auth: { method: 'apikey', authorization: { configured: false } },
} as Provider,
{
id: 'anthropic',
name: 'Anthropic',
models: [{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', provider: 'anthropic' }],
auth: { method: 'oauth', authorization: { configured: false } },
} as Provider,
]
function configuredProviders(): Provider[] {
return [
{
...PROVIDERS[0],
auth: { method: 'apikey', authorization: { configured: true, source: 'stored' } },
} as Provider,
PROVIDERS[1],
]
}
describe('ProviderOnboarding', () => {
beforeEach(() => {
getProviderConfigsMock.mockReset()
saveProviderApiKeyMock.mockReset()
saveProviderApiKeyMock.mockResolvedValue()
})
it('renders first-run provider catalog when no provider auth exists', async () => {
getProviderConfigsMock.mockResolvedValue({ providers: PROVIDERS })
render(<ProviderOnboarding />)
expect(await screen.findByText('Connect a model provider')).toBeInTheDocument()
expect(screen.getByText('First run setup')).toBeInTheDocument()
expect(screen.getByText('OpenCode Zen')).toBeInTheDocument()
expect(screen.getAllByText('1 models available').length).toBe(2)
expect(screen.getByText('Anthropic')).toBeInTheDocument()
expect(screen.getByText('API key')).toBeInTheDocument()
expect(screen.getAllByText('OAuth').length).toBeGreaterThan(0)
})
it('does not treat configured false auth sources as ready', async () => {
getProviderConfigsMock.mockResolvedValue({
providers: [
{
...PROVIDERS[0],
auth: {
method: 'apikey',
authorization: { configured: false, source: 'environment', label: 'OPENCODE_API_KEY' },
},
} as Provider,
],
})
render(<ProviderOnboarding />)
expect(await screen.findByText('Connect a model provider')).toBeInTheDocument()
expect(screen.getByText('OpenCode Zen')).toBeInTheDocument()
})
it('opens onboarding even when the provider catalog is empty', async () => {
getProviderConfigsMock.mockResolvedValue({ providers: [] })
render(<ProviderOnboarding />)
expect(await screen.findByText('Connect a model provider')).toBeInTheDocument()
expect(screen.getByText(/Loading provider catalog/)).toBeInTheDocument()
})
it('filters providers by search text and auth method', async () => {
getProviderConfigsMock.mockResolvedValue({ providers: PROVIDERS })
render(<ProviderOnboarding />)
const search = await screen.findByPlaceholderText('Search providers or models…')
fireEvent.change(search, { target: { value: 'deepseek' } })
expect(screen.getByText('OpenCode Zen')).toBeInTheDocument()
expect(screen.queryByText('Anthropic')).not.toBeInTheDocument()
fireEvent.change(search, { target: { value: '' } })
fireEvent.click(screen.getByRole('button', { name: 'OAuth' }))
expect(screen.queryByText('OpenCode Zen')).not.toBeInTheDocument()
expect(screen.getByText('Anthropic')).toBeInTheDocument()
})
it('saves an API key and closes after provider auth exists', async () => {
getProviderConfigsMock.mockResolvedValueOnce({ providers: PROVIDERS })
getProviderConfigsMock.mockResolvedValueOnce({ providers: configuredProviders() })
render(<ProviderOnboarding />)
expect(await screen.findByText('OpenCode Zen')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Add key' }))
fireEvent.change(screen.getByPlaceholderText('Paste API key'), { target: { value: 'test-key' } })
fireEvent.click(screen.getByRole('button', { name: 'Save key' }))
await waitFor(() => expect(saveProviderApiKeyMock).toHaveBeenCalledWith('opencode', 'test-key'))
await waitFor(() => expect(screen.queryByText('Connect a model provider')).not.toBeInTheDocument())
})
})
@@ -0,0 +1,260 @@
import { useEffect, useMemo, useState } from 'react'
import { getProviderConfigs, saveProviderApiKey } from '../../api/config'
import type { Provider } from '../../types/api/model'
function providerHasAuth(provider: Provider): boolean {
const authorization = provider.auth?.authorization as { configured?: boolean } | undefined
return authorization?.configured === true
}
function providerModelCount(provider: Provider): number {
return Array.isArray(provider.models) ? provider.models.length : 0
}
export function ProviderOnboarding() {
const [providers, setProviders] = useState<Provider[]>([])
const [loading, setLoading] = useState(true)
const [dismissed, setDismissed] = useState(false)
const [query, setQuery] = useState('')
const [filter, setFilter] = useState<'all' | 'apikey' | 'oauth'>('all')
const [activeProviderId, setActiveProviderId] = useState<string | null>(null)
const [apiKey, setApiKey] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [retryCount, setRetryCount] = useState(0)
async function refresh() {
setLoading(true)
try {
const data = await getProviderConfigs()
setProviders(data.providers)
setLoadError(null)
} catch (err) {
setProviders([])
setLoadError(err instanceof Error ? err.message : 'Failed to load provider catalog')
} finally {
setLoading(false)
}
}
useEffect(() => {
void refresh()
}, [])
useEffect(() => {
if (loading || providers.length > 0 || retryCount >= 5) return
const timeout = window.setTimeout(() => {
setRetryCount(count => count + 1)
void refresh()
}, 1200)
return () => window.clearTimeout(timeout)
}, [loading, providers.length, retryCount])
const needsOnboarding = useMemo(() => !loading && !providers.some(providerHasAuth), [loading, providers])
const filteredProviders = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase()
return providers
.filter(provider => (filter === 'all' ? true : provider.auth?.method === filter))
.filter(provider => {
if (!normalizedQuery) return true
return (
provider.name.toLowerCase().includes(normalizedQuery) ||
provider.id.toLowerCase().includes(normalizedQuery) ||
provider.models.some(
model => model.name?.toLowerCase().includes(normalizedQuery) || model.id.includes(normalizedQuery),
)
)
})
.sort((a, b) => Number(providerHasAuth(b)) - Number(providerHasAuth(a)) || a.name.localeCompare(b.name))
}, [providers, query, filter])
if (dismissed || !needsOnboarding) return null
const activeProvider = providers.find(provider => provider.id === activeProviderId) ?? null
async function saveActiveKey() {
if (!activeProvider || !apiKey.trim()) return
setSaving(true)
setError(null)
try {
await saveProviderApiKey(activeProvider.id, apiKey)
setApiKey('')
setActiveProviderId(null)
await refresh()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save API key')
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 z-[80] bg-black/55 backdrop-blur-sm flex items-center justify-center p-4">
<div className="w-full max-w-5xl max-h-[86vh] overflow-hidden rounded-[28px] border border-border-200/70 bg-bg-000 shadow-2xl">
<div className="px-7 py-6 border-b border-border-200/60 bg-gradient-to-br from-bg-050 to-bg-100/65">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-[length:var(--fs-xs)] uppercase tracking-[0.2em] text-accent-main-100 font-semibold">
First run setup
</div>
<h2 className="mt-2 text-[length:var(--fs-2xl)] font-semibold text-text-100">Connect a model provider</h2>
<p className="mt-2 max-w-2xl text-[length:var(--fs-sm)] leading-6 text-text-400">
Pi Agent UI is using a private Pi runtime for this app. Add an API key or sign in to an OAuth provider
to start a session without touching your system Pi configuration.
</p>
</div>
<button
type="button"
onClick={() => setDismissed(true)}
className="px-3 py-1.5 rounded-full text-[length:var(--fs-xs)] text-text-400 hover:text-text-100 hover:bg-bg-200/70 transition-colors"
>
Skip for now
</button>
</div>
<div className="mt-5 flex flex-col md:flex-row gap-3">
<input
value={query}
onChange={event => setQuery(event.target.value)}
placeholder="Search providers or models…"
className="flex-1 rounded-2xl border border-border-200/60 bg-bg-000/70 px-4 py-3 text-[length:var(--fs-sm)] text-text-100 outline-none focus:border-accent-main-100"
/>
<div className="flex rounded-2xl border border-border-200/60 bg-bg-000/55 p-1">
{(['all', 'apikey', 'oauth'] as const).map(value => (
<button
key={value}
type="button"
onClick={() => setFilter(value)}
className={`px-4 py-2 rounded-xl text-[length:var(--fs-xs)] font-medium transition-colors ${
filter === value ? 'bg-accent-main-100 text-white' : 'text-text-400 hover:text-text-100'
}`}
>
{value === 'all' ? 'All' : value === 'apikey' ? 'API keys' : 'OAuth'}
</button>
))}
</div>
</div>
</div>
{(providers.length === 0 || loadError) && (
<div className="p-6 border-b border-border-200/60 text-[length:var(--fs-sm)] text-text-400 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<span>
{loadError
? `Could not load the provider catalog: ${loadError}`
: 'Loading provider catalog from the private Pi runtime…'}
</span>
<button
type="button"
onClick={() => {
setRetryCount(0)
void refresh()
}}
className="self-start rounded-xl border border-border-200/70 px-3 py-2 text-[length:var(--fs-xs)] font-semibold text-text-100 hover:bg-bg-100"
>
Retry
</button>
</div>
)}
<div className="p-5 overflow-y-auto max-h-[56vh]">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{filteredProviders.map(provider => {
const configured = providerHasAuth(provider)
const method = provider.auth?.method ?? 'apikey'
return (
<div
key={provider.id}
className="rounded-2xl border border-border-200/60 bg-bg-050/60 p-4 hover:border-border-200 transition-colors"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[length:var(--fs-md)] font-semibold text-text-100 truncate">
{provider.name}
</div>
<div className="mt-1 text-[length:var(--fs-xs)] text-text-400 truncate">{provider.id}</div>
</div>
<span
className={`shrink-0 rounded-full px-2.5 py-1 text-[length:var(--fs-2xs)] font-semibold ${
configured ? 'bg-green-500/15 text-green-400' : 'bg-bg-200 text-text-400'
}`}
>
{configured ? 'Ready' : method === 'oauth' ? 'OAuth' : 'API key'}
</span>
</div>
<div className="mt-4 text-[length:var(--fs-xs)] text-text-400">
{providerModelCount(provider)} models available
</div>
<div className="mt-4 flex gap-2">
{method === 'oauth' ? (
<button
type="button"
disabled
className="flex-1 rounded-xl border border-border-200/60 px-3 py-2 text-[length:var(--fs-sm)] text-text-400 opacity-70"
title="OAuth login will be enabled when the backend exposes a browser login flow."
>
Login
</button>
) : (
<button
type="button"
onClick={() => {
setActiveProviderId(provider.id)
setError(null)
}}
className="flex-1 rounded-xl bg-accent-main-100 px-3 py-2 text-[length:var(--fs-sm)] font-semibold text-white hover:opacity-90 transition-opacity"
>
Add key
</button>
)}
</div>
</div>
)
})}
</div>
</div>
</div>
{activeProvider && (
<div className="absolute inset-0 z-[81] bg-black/45 flex items-center justify-center p-4">
<div className="w-full max-w-md rounded-3xl border border-border-200/70 bg-bg-000 p-6 shadow-2xl">
<h3 className="text-[length:var(--fs-xl)] font-semibold text-text-100">
Add {activeProvider.name} API key
</h3>
<p className="mt-2 text-[length:var(--fs-sm)] text-text-400">
The key is stored only in this app’s private Pi auth.json.
</p>
<input
value={apiKey}
onChange={event => setApiKey(event.target.value)}
type="password"
autoFocus
placeholder="Paste API key"
className="mt-5 w-full rounded-2xl border border-border-200/60 bg-bg-050 px-4 py-3 text-text-100 outline-none focus:border-accent-main-100"
/>
{error && <div className="mt-3 text-[length:var(--fs-xs)] text-red-400">{error}</div>}
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={() => setActiveProviderId(null)}
className="rounded-xl px-4 py-2 text-[length:var(--fs-sm)] text-text-400 hover:text-text-100"
>
Cancel
</button>
<button
type="button"
disabled={saving || !apiKey.trim()}
onClick={() => void saveActiveKey()}
className="rounded-xl bg-accent-main-100 px-4 py-2 text-[length:var(--fs-sm)] font-semibold text-white disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save key'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { createOptimisticSessionDraft, createOptimisticSessionId, isOptimisticSessionId } from './fastSessionLaunch'
describe('fast session launch helpers', () => {
it('creates deterministic optimistic session IDs with the reserved prefix', () => {
const id = createOptimisticSessionId('seed-123')
expect(id).toBe('optimistic-session:seed-123')
expect(isOptimisticSessionId(id)).toBe(true)
expect(isOptimisticSessionId('real-session-id')).toBe(false)
})
it('creates a pending session draft that can render before the runtime responds', () => {
const draft = createOptimisticSessionDraft({
id: 'optimistic-session:test',
now: 1770000000000,
directory: 'C:\\Users\\Evandro\\project',
title: ' Build fast session launch ',
parentID: 'parent-session',
})
expect(draft).toEqual({
id: 'optimistic-session:test',
title: 'Build fast session launch',
directory: 'C:/Users/Evandro/project',
parentID: 'parent-session',
time: {
created: 1770000000000,
updated: 1770000000000,
},
status: {
type: 'running',
message: 'Creating session…',
},
summary: {
deletions: 0,
files: 0,
additions: 0,
},
})
})
it('falls back to a safe title and removes control characters', () => {
const draft = createOptimisticSessionDraft({
id: 'optimistic-session:safe-title',
now: 1770000000000,
title: ' \u0000\n\t ',
})
expect(draft.title).toBe('New Session')
})
})
@@ -0,0 +1,73 @@
import { normalizeToForwardSlash } from '../../utils/directoryUtils'
import type { ApiSession } from '../../api/types'
export const OPTIMISTIC_SESSION_ID_PREFIX = 'optimistic-session:'
export interface OptimisticSessionDraftInput {
directory?: string
title?: string
parentID?: string
id?: string
now?: number
}
export function createOptimisticSessionId(seed: string = createSessionIdSeed()): string {
if (seed.startsWith(OPTIMISTIC_SESSION_ID_PREFIX)) return seed
return `${OPTIMISTIC_SESSION_ID_PREFIX}${seed}`
}
export function isOptimisticSessionId(sessionId: string): boolean {
return sessionId.startsWith(OPTIMISTIC_SESSION_ID_PREFIX)
}
export function createOptimisticSessionDraft(input: OptimisticSessionDraftInput = {}): ApiSession {
const timestamp = input.now ?? Date.now()
const id = input.id ? createOptimisticSessionId(input.id) : createOptimisticSessionId()
const title = normalizeSessionTitle(input.title)
const directory = normalizeToForwardSlash(input.directory)
const parentID = normalizeOptionalString(input.parentID)
return {
id,
title,
directory,
...(parentID ? { parentID } : {}),
time: {
created: timestamp,
updated: timestamp,
},
status: {
type: 'running',
message: 'Creating session…',
},
summary: {
deletions: 0,
files: 0,
additions: 0,
},
}
}
function createSessionIdSeed(): string {
const randomUUID = globalThis.crypto?.randomUUID?.()
if (randomUUID) return randomUUID
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
}
function normalizeSessionTitle(title: string | undefined): string {
const normalized = normalizeOptionalString(title)
return normalized || 'New Session'
}
function normalizeOptionalString(value: string | undefined): string {
if (!value) return ''
return value
.split('')
.map(char => {
const code = char.charCodeAt(0)
return code < 32 || code === 127 ? ' ' : char
})
.join('')
.replace(/\s+/g, ' ')
.trim()
}
@@ -8,6 +8,19 @@ import { isTauri } from '../../../utils/tauri'
import { apiErrorHandler } from '../../../utils'
import { Toggle, SettingRow, SettingsCard } from './SettingsUI'
type PiAgentConnection = { port: number; token: string }
type PiAgentServiceStart = {
started: boolean
connection?: PiAgentConnection | null
}
function installPiAgentConnection(conn: PiAgentConnection) {
;(globalThis as any).window.PI_BOOT_DATA = {
baseUrl: `http://127.0.0.1:${conn.port}`,
token: conn.token,
}
}
export function ServiceSettings() {
const { t } = useTranslation(['settings', 'common'])
const isMobile = useIsMobile()
@@ -57,13 +70,14 @@ export function ServiceSettings() {
try {
const { invoke } = await import('@tauri-apps/api/core')
serviceStore.setStarting(true)
const weStarted = await invoke<boolean>('start_pi_service', {
const result = await invoke<PiAgentServiceStart>('start_pi_service', {
url: getServerUrl(),
binaryPath: serviceStore.effectiveBinaryPath,
envVars: serviceStore.envVarsRecord,
})
serviceStore.setStartedByUs(weStarted)
serviceStore.setRunning(true)
serviceStore.setStartedByUs(result.started)
serviceStore.setRunning(Boolean(result.connection))
if (result.connection) installPiAgentConnection(result.connection)
} catch (e) {
const msg = String(e)
apiErrorHandler('start service', msg)
@@ -88,7 +102,9 @@ export function ServiceSettings() {
const handleCheckService = async () => {
try {
const { invoke } = await import('@tauri-apps/api/core')
const running = await invoke<boolean>('check_pi_service', { url: getServerUrl() })
const conn = await invoke<PiAgentConnection | null>('get_pi_agent_connection')
const url = conn ? `http://127.0.0.1:${conn.port}` : getServerUrl()
const running = await invoke<boolean>('check_pi_service', { url })
serviceStore.setRunning(running)
if (running) {
const byUs = await invoke<boolean>('get_service_started_by_us')
@@ -17,6 +17,8 @@ const SETTINGS = {
apiKeyRequired: true,
transcriptionEndpoint: '/audio/transcriptions',
transcriptionModel: 'gpt-4o-mini-transcribe',
realtimeEndpoint: '/realtime/transcriptions',
realtimeModel: 'gpt-4o-mini-transcribe',
language: 'pt',
insertMode: 'append' as const,
}
@@ -75,6 +77,29 @@ describe('SttSettings', () => {
expect(screen.getByLabelText('Model')).toHaveValue('gpt-4o-mini-transcribe')
})
it('edits realtime transcription settings', async () => {
render(<SttSettings />)
fireEvent.change(await screen.findByLabelText('Mode'), { target: { value: 'realtime' } })
fireEvent.change(screen.getByLabelText('Realtime endpoint'), {
target: { value: '/v1/realtime/transcriptions' },
})
fireEvent.change(screen.getByLabelText('Realtime model'), {
target: { value: 'gpt-4o-realtime-preview' },
})
fireEvent.click(screen.getByRole('button', { name: 'Save transcription settings' }))
await waitFor(() =>
expect(updateSttSettings).toHaveBeenCalledWith(
expect.objectContaining({
mode: 'realtime',
realtimeEndpoint: '/v1/realtime/transcriptions',
realtimeModel: 'gpt-4o-realtime-preview',
}),
),
)
})
it('styles dropdown lists with the app select treatment', async () => {
render(<SttSettings />)
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { Button } from '../../../components/ui/Button'
import { getSttSettings, updateSttSettings } from '../../../api/stt'
import type { SttInsertMode, SttProviderKind, SttSettings as SttSettingsData } from '../../../types/api/stt'
import type { SttInsertMode, SttMode, SttProviderKind, SttSettings as SttSettingsData } from '../../../types/api/stt'
import { SettingRow, SettingsCard, SettingsSelect, Toggle } from './SettingsUI'
const inputClass =
@@ -42,6 +42,8 @@ export function SttSettings() {
apiKeyRequired: settings.apiKeyRequired,
transcriptionEndpoint: settings.transcriptionEndpoint,
transcriptionModel: settings.transcriptionModel,
realtimeEndpoint: settings.realtimeEndpoint,
realtimeModel: settings.realtimeModel,
language: settings.language || null,
insertMode: settings.insertMode,
}),
@@ -96,6 +98,17 @@ export function SttSettings() {
placeholder="http://127.0.0.1:8022/v1"
/>
</label>
<label className="space-y-1">
<span className="text-[length:var(--fs-xs)] font-medium text-text-300">Mode</span>
<SettingsSelect
wrapperClassName="w-full"
value={settings.mode}
onChange={event => setSettings({ ...settings, mode: event.target.value as SttMode })}
>
<option value="file">File upload</option>
<option value="realtime">Realtime WebSocket</option>
</SettingsSelect>
</label>
<label className="space-y-1">
<span className="text-[length:var(--fs-xs)] font-medium text-text-300">Endpoint</span>
<input
@@ -114,6 +127,28 @@ export function SttSettings() {
placeholder="deepdml/faster-whisper-large-v3-turbo-ct2"
/>
</label>
{settings.mode === 'realtime' && (
<>
<label className="space-y-1">
<span className="text-[length:var(--fs-xs)] font-medium text-text-300">Realtime endpoint</span>
<input
className={inputClass}
value={settings.realtimeEndpoint}
onChange={event => setSettings({ ...settings, realtimeEndpoint: event.target.value })}
placeholder="/realtime/transcriptions"
/>
</label>
<label className="space-y-1">
<span className="text-[length:var(--fs-xs)] font-medium text-text-300">Realtime model</span>
<input
className={inputClass}
value={settings.realtimeModel}
onChange={event => setSettings({ ...settings, realtimeModel: event.target.value })}
placeholder="gpt-4o-realtime-preview"
/>
</label>
</>
)}
<label className="space-y-1">
<span className="text-[length:var(--fs-xs)] font-medium text-text-300">Language</span>
<input
+183 -1
View File
@@ -14,6 +14,8 @@ const {
errorHandlerMock,
sendMessageAsyncMock,
abortSessionMock,
activeSessionStoreMock,
notificationStoreMock,
} = vi.hoisted(() => ({
createSessionMock: vi.fn(),
summarizeSessionMock: vi.fn(),
@@ -26,6 +28,14 @@ const {
errorHandlerMock: vi.fn(),
sendMessageAsyncMock: vi.fn(),
abortSessionMock: vi.fn(),
activeSessionStoreMock: {
addOptimisticSession: vi.fn(),
reconcileOptimisticSession: vi.fn(),
rollbackOptimisticSession: vi.fn(),
},
notificationStoreMock: {
push: vi.fn(),
},
}))
vi.mock('../store', () => ({
@@ -39,6 +49,7 @@ vi.mock('../store', () => ({
handleMessageUpdated: vi.fn(),
handlePartUpdated: vi.fn(),
setMessages: vi.fn(),
clearSession: vi.fn(),
},
useSessionFamily: () => [],
useSessionState: () => null,
@@ -52,6 +63,8 @@ vi.mock('../store', () => ({
registerChildSession: vi.fn(),
getSessionAndDescendants: vi.fn(() => []),
},
activeSessionStore: activeSessionStoreMock,
notificationStore: notificationStoreMock,
useActiveSessionStore: () => ({ statusMap: {} }),
}))
@@ -295,6 +308,10 @@ describe('useChatSession handleSend and abort', () => {
createSessionMock.mockReset()
navigateToSessionMock.mockReset()
navigateHomeMock.mockReset()
activeSessionStoreMock.addOptimisticSession.mockReset()
activeSessionStoreMock.reconcileOptimisticSession.mockReset()
activeSessionStoreMock.rollbackOptimisticSession.mockReset()
notificationStoreMock.push.mockReset()
createSessionMock.mockResolvedValue({ id: 'new-session-id', directory: '/workspace/demo' })
})
@@ -352,7 +369,19 @@ describe('useChatSession handleSend and abort', () => {
expect(success).toBe(true)
expect(createSessionMock).toHaveBeenCalled()
expect(navigateToSessionMock).toHaveBeenCalledWith('new-session-id', '/workspace/demo')
expect(navigateToSessionMock).toHaveBeenNthCalledWith(
1,
expect.stringMatching(/^optimistic-session:/),
'/workspace/demo',
)
expect(navigateToSessionMock).toHaveBeenLastCalledWith('new-session-id', '/workspace/demo')
expect(activeSessionStoreMock.reconcileOptimisticSession).toHaveBeenCalledWith(
expect.stringMatching(/^optimistic-session:/),
{
id: 'new-session-id',
directory: '/workspace/demo',
},
)
expect(sendMessageAsyncMock).toHaveBeenCalledWith({
sessionId: 'new-session-id',
text: 'hello new session',
@@ -366,4 +395,157 @@ describe('useChatSession handleSend and abort', () => {
directory: '/workspace/demo',
})
})
it('navigates to an optimistic session before backend session creation resolves', async () => {
let resolveCreateSession!: (value: { id: string; directory: string }) => void
createSessionMock.mockReturnValue(
new Promise(resolve => {
resolveCreateSession = resolve
}),
)
const { result } = renderHook(() =>
useChatSession({
paneId: 'pane-1',
chatAreaRef: { current: null },
currentModel: { id: 'model-1', providerId: 'provider-1', name: 'Model 1', variants: [] } as any,
refetchModels: vi.fn(async () => {}),
sessionId: null,
navigateToSession: navigateToSessionMock,
navigateHome: navigateHomeMock,
}),
)
let sendPromise!: Promise<boolean>
await act(async () => {
sendPromise = result.current.handleSend('hello new session', [])
await Promise.resolve()
})
expect(activeSessionStoreMock.addOptimisticSession).toHaveBeenCalledWith(
expect.objectContaining({
id: expect.stringMatching(/^optimistic-session:/),
directory: '/workspace/demo',
status: { type: 'running', message: 'Creating session…' },
}),
)
expect(navigateToSessionMock).toHaveBeenCalledWith(expect.stringMatching(/^optimistic-session:/), '/workspace/demo')
expect(sendMessageAsyncMock).not.toHaveBeenCalled()
await act(async () => {
resolveCreateSession({ id: 'new-session-id', directory: '/workspace/demo' })
await sendPromise
})
expect(activeSessionStoreMock.reconcileOptimisticSession).toHaveBeenCalledWith(
expect.stringMatching(/^optimistic-session:/),
{
id: 'new-session-id',
directory: '/workspace/demo',
},
)
expect(sendMessageAsyncMock).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'new-session-id' }))
})
it('uses the optimistic session path for the explicit new session action', async () => {
let resolveCreateSession!: (value: { id: string; directory: string }) => void
createSessionMock.mockReturnValue(
new Promise(resolve => {
resolveCreateSession = resolve
}),
)
const { result } = renderHook(() =>
useChatSession({
paneId: 'pane-1',
chatAreaRef: { current: null },
currentModel: { id: 'model-1', providerId: 'provider-1', name: 'Model 1', variants: [] } as any,
refetchModels: vi.fn(async () => {}),
sessionId: 'current-session',
navigateToSession: navigateToSessionMock,
navigateHome: navigateHomeMock,
}),
)
await act(async () => {
result.current.handleNewSession()
await Promise.resolve()
})
expect(navigateHomeMock).not.toHaveBeenCalled()
expect(activeSessionStoreMock.addOptimisticSession).toHaveBeenCalledWith(
expect.objectContaining({ id: expect.stringMatching(/^optimistic-session:/), directory: '/workspace/demo' }),
)
expect(navigateToSessionMock).toHaveBeenCalledWith(expect.stringMatching(/^optimistic-session:/), '/workspace/demo')
await act(async () => {
resolveCreateSession({ id: 'new-session-id', directory: '/workspace/demo' })
await Promise.resolve()
})
expect(activeSessionStoreMock.reconcileOptimisticSession).toHaveBeenCalledWith(
expect.stringMatching(/^optimistic-session:/),
{
id: 'new-session-id',
directory: '/workspace/demo',
},
)
expect(navigateToSessionMock).toHaveBeenLastCalledWith('new-session-id', '/workspace/demo')
})
it('rolls back the optimistic session and returns false when backend creation fails during send', async () => {
createSessionMock.mockRejectedValue(new Error('runtime unavailable'))
const { result } = renderHook(() =>
useChatSession({
paneId: 'pane-1',
chatAreaRef: { current: null },
currentModel: { id: 'model-1', providerId: 'provider-1', name: 'Model 1', variants: [] } as any,
refetchModels: vi.fn(async () => {}),
sessionId: null,
navigateToSession: navigateToSessionMock,
navigateHome: navigateHomeMock,
}),
)
let success = true
await act(async () => {
success = await result.current.handleSend('recoverable draft', [])
})
expect(success).toBe(false)
expect(activeSessionStoreMock.addOptimisticSession).toHaveBeenCalledWith(
expect.objectContaining({ id: expect.stringMatching(/^optimistic-session:/) }),
)
expect(activeSessionStoreMock.rollbackOptimisticSession).toHaveBeenCalledWith(
expect.stringMatching(/^optimistic-session:/),
)
expect(sendMessageAsyncMock).not.toHaveBeenCalled()
})
it('rolls back the optimistic session when explicit new session creation fails', async () => {
createSessionMock.mockRejectedValue(new Error('runtime unavailable'))
const { result } = renderHook(() =>
useChatSession({
paneId: 'pane-1',
chatAreaRef: { current: null },
currentModel: { id: 'model-1', providerId: 'provider-1', name: 'Model 1', variants: [] } as any,
refetchModels: vi.fn(async () => {}),
sessionId: 'current-session',
navigateToSession: navigateToSessionMock,
navigateHome: navigateHomeMock,
}),
)
await act(async () => {
result.current.handleNewSession()
await Promise.resolve()
await Promise.resolve()
})
expect(activeSessionStoreMock.rollbackOptimisticSession).toHaveBeenCalledWith(
expect.stringMatching(/^optimistic-session:/),
)
})
})
+100 -24
View File
@@ -10,6 +10,8 @@ import {
autoApproveStore,
childSessionStore,
useActiveSessionStore,
notificationStore,
activeSessionStore,
type RevertHistoryItem,
} from '../store'
import {
@@ -49,6 +51,7 @@ import type { ChatAreaHandle } from '../features/chat'
import { followupQueueStore, useFollowupQueue } from '../store/followupQueueStore'
import { themeStore } from '../store/themeStore'
import { createOptimisticSessionDraft } from '../features/sessions/fastSessionLaunch'
const handleError = createErrorHandler('session')
/**
@@ -543,6 +546,39 @@ export function useChatSession({
setPendingQuestionRequests,
])
const createSessionWithOptimisticRoute = useCallback(
async (directory: string | undefined) => {
const optimisticSession = createOptimisticSessionDraft({ directory })
activeSessionStore.addOptimisticSession(optimisticSession)
messageStore.setMessages(optimisticSession.id, [], {
directory: optimisticSession.directory || directory || '',
title: optimisticSession.title,
hasMoreHistory: false,
revertState: null,
})
navigateToSession(optimisticSession.id, directory)
try {
const newSession = await createSession()
activeSessionStore.reconcileOptimisticSession(optimisticSession.id, newSession)
messageStore.clearSession(optimisticSession.id)
messageStore.setMessages(newSession.id, [], {
directory: newSession.directory || directory || '',
title: newSession.title || undefined,
hasMoreHistory: false,
revertState: null,
})
navigateToSession(newSession.id, directory)
return { session: newSession, optimisticSessionId: optimisticSession.id }
} catch (error) {
activeSessionStore.rollbackOptimisticSession(optimisticSession.id)
messageStore.clearSession(optimisticSession.id)
throw error
}
},
[createSession, navigateToSession],
)
const sendMessageNow = useCallback(
async (input: {
sessionId?: string | null
@@ -557,6 +593,7 @@ export function useChatSession({
let rollbackSnapshot = sessionId ? messageStore.createSendRollbackSnapshot(sessionId) : null
let isNewSession = false
const allowCreate = input.allowCreateSession ?? true
let optimisticSessionId: string | null = null
try {
if (!sessionId) {
@@ -565,27 +602,17 @@ export function useChatSession({
'%c[Message Flow] Requesting new session creation on extension server...',
'color: #2196f3; font-weight: bold;',
)
const newSession = await createSession()
const { session: newSession, optimisticSessionId: pendingSessionId } = await createSessionWithOptimisticRoute(
input.directory,
)
console.log(
'%c[Message Flow] Session created successfully:',
'color: #4caf50; font-weight: bold;',
newSession,
)
optimisticSessionId = pendingSessionId
sessionId = newSession.id
isNewSession = true
// Pre-initialize the message store for the new session BEFORE navigating.
// This prevents the grey screen: without this, navigating triggers
// useSessionManager.loadSession() which sets loadState='loading' and
// fetches messages (which are empty at this point), showing a blank screen.
// By initializing with streaming=true and loadState='loaded', the UI
// correctly shows the chat surface in its "waiting for response" state.
messageStore.setMessages(sessionId, [], {
directory: newSession.directory || input.directory,
title: newSession.title || undefined,
hasMoreHistory: false,
revertState: null,
})
}
if (rollbackSnapshot) {
@@ -603,11 +630,10 @@ export function useChatSession({
// creating a new one every time.
if (isNewSession) {
console.log(
'%c[Message Flow] Transitioning route navigation to session view:',
'%c[Message Flow] Session route already transitioned to real session:',
'color: #e91e63; font-weight: bold;',
sessionId,
)
navigateToSession(sessionId, input.directory)
}
console.log(
@@ -669,6 +695,18 @@ export function useChatSession({
return true
} catch (error) {
handleError('send message', error)
notificationStore.push(
'error',
'Falha ao enviar mensagem',
error instanceof Error
? error.message
: 'Não foi possível conectar ao servidor de IA. Verifique se o Pi está rodando.',
sessionId || '',
)
if (optimisticSessionId) {
activeSessionStore.rollbackOptimisticSession(optimisticSessionId)
messageStore.clearSession(optimisticSessionId)
}
if (sessionId) {
if (rollbackSnapshot) {
messageStore.restoreSendRollback(sessionId, rollbackSnapshot)
@@ -684,11 +722,50 @@ export function useChatSession({
[routeSessionId, navigateToSession, createSession],
)
// Creates a new session with user-visible feedback when no model is available.
// This prevents the silent-nothing-happens problem: even without a model,
// the user sees a new session created and understands what to do next.
const createSessionWithToast = useCallback(async () => {
try {
const newSession = await createSession()
notificationStore.push(
'error',
'Nenhum modelo disponível',
'Sessão criada, mas não foi possível enviar a mensagem. Selecione um modelo ou verifique se o servidor de IA está conectado.',
newSession.id,
)
navigateToSession(newSession.id, effectiveDirectory || '')
} catch (error) {
handleError('create session', error)
notificationStore.push(
'error',
'Falha ao criar sessão',
'Não foi possível criar uma nova sessão. Verifique se o servidor Pi está rodando.',
'',
)
}
}, [createSession, navigateToSession, effectiveDirectory])
// Send message handler
const handleSend = useCallback(
async (content: string, attachments: Attachment[], options?: { agent?: string; variant?: string }) => {
if (!currentModel) {
handleError('send message', new Error('No model selected'))
refetchModels()
// Create a session anyway so the user sees something happen
// instead of complete silence, but don't try to send the message
// (empty model would cause Bad Request on the server).
if (!routeSessionId) {
createSessionWithToast()
} else {
notificationStore.push(
'error',
'Nenhum modelo disponível',
'Selecione um modelo para enviar a mensagem. Verifique se o servidor de IA está conectado.',
routeSessionId,
)
}
return false
}
@@ -918,8 +995,7 @@ export function useChatSession({
if (!command) return false
if (command === 'new') {
navigateHome()
handleNewChat()
await createSessionWithOptimisticRoute(effectiveDirectory)
return true
}
@@ -928,9 +1004,8 @@ export function useChatSession({
try {
// Create session if needed (like handleSend does)
if (!sessionId) {
const newSession = await createSession()
const { session: newSession } = await createSessionWithOptimisticRoute(effectiveDirectory)
sessionId = newSession.id
navigateToSession(sessionId, newSession.directory)
}
if (command === 'compact') {
@@ -964,7 +1039,7 @@ export function useChatSession({
return false
}
},
[routeSessionId, effectiveDirectory, createSession, navigateToSession, currentModel, navigateHome, handleNewChat],
[routeSessionId, effectiveDirectory, createSessionWithOptimisticRoute, currentModel],
)
// Undo with animation
@@ -997,9 +1072,10 @@ export function useChatSession({
// New session
const handleNewSession = useCallback(() => {
navigateHome()
handleNewChat()
}, [navigateHome, handleNewChat])
void createSessionWithOptimisticRoute(effectiveDirectory).catch(error => {
handleError('create session', error)
})
}, [createSessionWithOptimisticRoute, effectiveDirectory])
// Archive current session
const handleArchiveSession = useCallback(async () => {
+21
View File
@@ -0,0 +1,21 @@
import { useEffect, useState } from 'react'
import { flushSync } from 'react-dom'
export function usePanelResizeStatus(): boolean {
const [isResizing, setIsResizing] = useState(false)
useEffect(() => {
const onStart = () => flushSync(() => setIsResizing(true))
const onEnd = () => setIsResizing(false)
window.addEventListener('panel-resize-start', onStart)
window.addEventListener('panel-resize-end', onEnd)
return () => {
window.removeEventListener('panel-resize-start', onStart)
window.removeEventListener('panel-resize-end', onEnd)
}
}, [])
return isResizing
}
-29
View File
@@ -9,35 +9,6 @@ vi.mock('../store', () => ({
}))
describe('useSessionStats', () => {
it('does not scan message text when actual usage is available', () => {
const readText = vi.fn(() => 'large historical message')
useMessageStoreMock.mockReturnValue({
messages: [
{
info: {
role: 'assistant',
cost: 0,
tokens: { input: 10, output: 5, reasoning: 0, cache: { read: 0, write: 0 } },
},
parts: [
{
type: 'text',
get text() {
return readText()
},
},
],
},
],
})
const { result } = renderHook(() => useSessionStats(100))
expect(result.current.contextUsed).toBe(15)
expect(result.current.contextEstimated).toBe(false)
expect(readText).not.toHaveBeenCalled()
})
it('switches to estimated context after a compaction turn', () => {
useMessageStoreMock.mockReturnValue({
messages: [
+3 -2
View File
@@ -235,12 +235,13 @@ export function useSessionStats(
}
}
const totalOrEstimatedContext = estimateCurrentContext(messages)
const contextEstimated = shouldUseEstimatedContext(messages, lastAssistantWithTokensIndex, lastAssistantWithTokens)
const rawContextUsed = contextEstimated
? estimateCurrentContext(messages)
? totalOrEstimatedContext
: lastAssistantWithTokens
? tokenTotal(lastAssistantWithTokens.tokens)
: estimateCurrentContext(messages)
: totalOrEstimatedContext
const contextUsed = Number.isFinite(rawContextUsed) ? rawContextUsed : 0
const rawContextPercent = contextLimit > 0 ? (contextUsed / contextLimit) * 100 : 0
const contextPercent = Number.isFinite(rawContextPercent) ? Math.min(100, Math.max(0, rawContextPercent)) : 0
+95 -2
View File
@@ -1,9 +1,10 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useVoiceTranscription } from './useVoiceTranscription'
import { getSttSettings, transcribeAudio } from '../api/stt'
import { getSttRealtimeUrl, getSttSettings, transcribeAudio } from '../api/stt'
vi.mock('../api/stt', () => ({
getSttRealtimeUrl: vi.fn(() => 'ws://127.0.0.1:58785/api/stt/realtime?token=token-123'),
getSttSettings: vi.fn(),
transcribeAudio: vi.fn(),
}))
@@ -14,8 +15,13 @@ class MockMediaRecorder extends EventTarget {
ondataavailable: ((event: BlobEvent) => void) | null = null
onstop: (() => void) | null = null
start() {
start(timeslice?: number) {
this.state = 'recording'
if (timeslice) {
void Promise.resolve().then(() => {
this.ondataavailable?.({ data: new Blob(['audio'], { type: 'audio/webm' }) } as BlobEvent)
})
}
}
stop() {
@@ -25,8 +31,46 @@ class MockMediaRecorder extends EventTarget {
}
}
class MockWebSocket extends EventTarget {
static instances: MockWebSocket[] = []
readyState: number = WebSocket.CONNECTING
sent: string[] = []
onopen: (() => void) | null = null
onmessage: ((event: MessageEvent) => void) | null = null
onclose: (() => void) | null = null
onerror: (() => void) | null = null
readonly url: string
constructor(url: string) {
super()
this.url = url
MockWebSocket.instances.push(this)
}
send(data: string) {
this.sent.push(data)
}
close() {
this.readyState = WebSocket.CLOSED
this.onclose?.()
}
open() {
this.readyState = WebSocket.OPEN
this.onopen?.()
}
receive(message: unknown) {
this.onmessage?.({ data: JSON.stringify(message) } as MessageEvent)
}
}
describe('useVoiceTranscription', () => {
beforeEach(() => {
MockWebSocket.instances = []
vi.mocked(getSttRealtimeUrl).mockClear()
vi.mocked(getSttSettings).mockReset()
vi.mocked(transcribeAudio).mockReset()
vi.mocked(getSttSettings).mockResolvedValue({
@@ -38,6 +82,8 @@ describe('useVoiceTranscription', () => {
apiKeyRequired: true,
transcriptionEndpoint: '/audio/transcriptions',
transcriptionModel: 'gpt-4o-mini-transcribe',
realtimeEndpoint: '/realtime/transcriptions',
realtimeModel: 'gpt-4o-mini-transcribe',
insertMode: 'append',
})
vi.mocked(transcribeAudio).mockResolvedValue({
@@ -75,6 +121,47 @@ describe('useVoiceTranscription', () => {
expect(result.current.state).toBe('idle')
})
it('streams realtime transcription deltas while recording', async () => {
vi.mocked(getSttSettings).mockResolvedValue({
enabled: true,
providerKind: 'openai-compatible',
mode: 'realtime',
baseUrl: 'http://127.0.0.1:8022/v1',
apiKeyConfigured: false,
apiKeyRequired: false,
transcriptionEndpoint: '/audio/transcriptions',
transcriptionModel: 'model-file',
realtimeEndpoint: '/realtime/transcriptions',
realtimeModel: 'model-realtime',
insertMode: 'append',
})
Object.defineProperty(globalThis, 'WebSocket', { configurable: true, value: MockWebSocket })
const onTranscription = vi.fn()
const onTranscriptionDelta = vi.fn()
const { result } = renderHook(() => useVoiceTranscription({ onTranscription, onTranscriptionDelta }))
await act(async () => {
await result.current.startRecording()
})
const ws = MockWebSocket.instances[0]
expect(ws.url).toBe('ws://127.0.0.1:58785/api/stt/realtime?token=token-123')
act(() => {
ws.open()
ws.receive({ type: 'ready' })
})
expect(result.current.state).toBe('recording')
await waitFor(() => expect(ws.sent.some(data => JSON.parse(data).type === 'audio.append')).toBe(true))
expect(ws.sent.some(data => JSON.parse(data).type === 'audio.commit')).toBe(true)
act(() => ws.receive({ type: 'transcript.partial', text: 'olá' }))
expect(onTranscriptionDelta).toHaveBeenCalledWith({ type: 'transcript.partial', text: 'olá' }, 'append')
await act(async () => result.current.stopRecording())
expect(onTranscription).not.toHaveBeenCalled()
})
it('keeps the voice button available even when STT is not configured', async () => {
vi.mocked(getSttSettings).mockResolvedValue({
enabled: false,
@@ -85,6 +172,8 @@ describe('useVoiceTranscription', () => {
apiKeyRequired: true,
transcriptionEndpoint: '/audio/transcriptions',
transcriptionModel: 'gpt-4o-mini-transcribe',
realtimeEndpoint: '/realtime/transcriptions',
realtimeModel: 'gpt-4o-mini-transcribe',
insertMode: 'append',
})
const { result } = renderHook(() => useVoiceTranscription({ onTranscription: vi.fn() }))
@@ -110,6 +199,8 @@ describe('useVoiceTranscription', () => {
apiKeyRequired: false,
transcriptionEndpoint: '/audio/transcriptions',
transcriptionModel: 'deepdml/faster-whisper-large-v3-turbo-ct2',
realtimeEndpoint: '/realtime/transcriptions',
realtimeModel: 'deepdml/faster-whisper-large-v3-turbo-ct2',
insertMode: 'append',
})
const { result } = renderHook(() => useVoiceTranscription({ onTranscription: vi.fn() }))
@@ -133,6 +224,8 @@ describe('useVoiceTranscription', () => {
apiKeyRequired: false,
transcriptionEndpoint: '/audio/transcriptions',
transcriptionModel: 'deepdml/faster-whisper-large-v3-turbo-ct2',
realtimeEndpoint: '/realtime/transcriptions',
realtimeModel: 'deepdml/faster-whisper-large-v3-turbo-ct2',
insertMode: 'append',
})
const { result } = renderHook(() => useVoiceTranscription({ onTranscription: vi.fn() }))
+119 -6
View File
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { getSttSettings, transcribeAudio } from '../api/stt'
import type { SttInsertMode, SttSettings } from '../types/api/stt'
import { getSttRealtimeUrl, getSttSettings, transcribeAudio } from '../api/stt'
import type { SttInsertMode, SttRealtimeDelta, SttSettings } from '../types/api/stt'
export type VoiceTranscriptionState = 'idle' | 'requesting-permission' | 'recording' | 'transcribing' | 'error'
interface UseVoiceTranscriptionOptions {
onTranscription: (text: string, insertMode: SttInsertMode) => void
onTranscriptionDelta?: (event: SttRealtimeDelta, insertMode: SttInsertMode) => void
}
function isRecordingSupported(): boolean {
@@ -16,13 +17,22 @@ function isRecordingSupported(): boolean {
)
}
export function useVoiceTranscription({ onTranscription }: UseVoiceTranscriptionOptions) {
async function blobToBase64(blob: Blob): Promise<string> {
const bytes = new Uint8Array(await blob.arrayBuffer())
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary)
}
export function useVoiceTranscription({ onTranscription, onTranscriptionDelta }: UseVoiceTranscriptionOptions) {
const [state, setState] = useState<VoiceTranscriptionState>('idle')
const [settings, setSettings] = useState<SttSettings | null>(null)
const [error, setError] = useState<string | null>(null)
const recorderRef = useRef<MediaRecorder | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const chunksRef = useRef<Blob[]>([])
const realtimeWsRef = useRef<WebSocket | null>(null)
const realtimeInsertModeRef = useRef<SttInsertMode>('append')
const loadSettings = useCallback(async () => {
const nextSettings = await getSttSettings()
@@ -52,6 +62,81 @@ export function useVoiceTranscription({ onTranscription }: UseVoiceTranscription
streamRef.current = null
}, [])
const startRealtimeRecording = useCallback(
async (activeSettings: SttSettings) => {
if (typeof WebSocket === 'undefined') {
setError('Realtime voice transcription is not supported in this browser')
setState('error')
return
}
const ws = new WebSocket(getSttRealtimeUrl())
realtimeWsRef.current = ws
realtimeInsertModeRef.current = activeSettings.insertMode
ws.onmessage = event => {
let message: SttRealtimeDelta
try {
message = JSON.parse(String(event.data))
} catch {
return
}
if (message.type === 'error') {
setError(message.error)
setState('error')
return
}
if (message.type === 'transcript.partial' || message.type === 'transcript.final') {
onTranscriptionDelta?.(message, realtimeInsertModeRef.current)
}
}
ws.onerror = () => {
setError('Realtime voice transcription failed')
setState('error')
}
ws.onclose = () => {
if (realtimeWsRef.current === ws) realtimeWsRef.current = null
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
streamRef.current = stream
const mimeType = MediaRecorder.isTypeSupported?.('audio/webm') ? 'audio/webm' : undefined
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
recorderRef.current = recorder
recorder.ondataavailable = event => {
if (event.data.size <= 0 || ws.readyState !== WebSocket.OPEN) return
void blobToBase64(event.data).then(audio => {
if (ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({ type: 'audio.append', audio }))
ws.send(JSON.stringify({ type: 'audio.commit' }))
})
}
recorder.onstop = () => {
cleanupStream()
recorderRef.current = null
window.setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'session.close' }))
ws.close()
if (realtimeWsRef.current === ws) realtimeWsRef.current = null
}, 250)
setState('idle')
}
recorder.start(500)
setState('recording')
} catch {
cleanupStream()
realtimeWsRef.current = null
ws.close()
setError('Microphone permission denied')
setState('error')
}
},
[cleanupStream, onTranscriptionDelta],
)
const startRecording = useCallback(async () => {
if (state === 'recording' || state === 'requesting-permission' || state === 'transcribing') return
setError(null)
@@ -79,6 +164,11 @@ export function useVoiceTranscription({ onTranscription }: UseVoiceTranscription
return
}
if (activeSettings.mode === 'realtime') {
await startRealtimeRecording(activeSettings)
return
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
streamRef.current = stream
@@ -112,20 +202,43 @@ export function useVoiceTranscription({ onTranscription }: UseVoiceTranscription
setError('Microphone permission denied')
setState('error')
}
}, [cleanupStream, loadSettings, onTranscription, state])
}, [cleanupStream, loadSettings, onTranscription, startRealtimeRecording, state])
const stopRecording = useCallback(() => {
const recorder = recorderRef.current
const realtimeWs = realtimeWsRef.current
if (realtimeWs) {
if (recorder && recorder.state !== 'inactive') {
recorder.stop()
return
}
cleanupStream()
recorderRef.current = null
if (realtimeWs.readyState === WebSocket.OPEN) realtimeWs.send(JSON.stringify({ type: 'session.close' }))
realtimeWs.close()
realtimeWsRef.current = null
setState('idle')
return
}
if (!recorder || recorder.state === 'inactive') return
recorder.stop()
}, [])
}, [cleanupStream])
const resetError = useCallback(() => {
setError(null)
setState('idle')
}, [])
useEffect(() => cleanupStream, [cleanupStream])
useEffect(
() => () => {
realtimeWsRef.current?.close()
realtimeWsRef.current = null
cleanupStream()
},
[cleanupStream],
)
return {
state,
+48 -44
View File
@@ -1,5 +1,5 @@
import { StrictMode, Suspense } from 'react'
import { createRoot } from 'react-dom/client'
import { createElement } from 'react'
import 'katex/dist/katex.min.css'
import './index.css'
import './i18n'
@@ -17,7 +17,7 @@ import { serviceStore } from './store/serviceStore'
import { reconnectSSE } from './api/events'
import { resetPathModeCache } from './utils/directoryUtils'
import { isTauri, isTauriMobile } from './utils/tauri'
import { apiErrorHandler, globalErrorHandler } from './utils/errorHandling'
import { globalErrorHandler } from './utils/errorHandling'
// Polyfill: randomUUID 在非 HTTPS 环境可能缺失(如局域网 HTTP)
// 统一补齐,避免业务层 scattered fallback。
@@ -77,6 +77,21 @@ serverStore.onServerChange(() => {
const isNativeTauri = isTauri()
const isNativeTauriMobile = isNativeTauri && isTauriMobile()
type PiAgentConnection = { port: number; token: string }
type PiAgentServiceStart = {
started: boolean
connection?: PiAgentConnection | null
agent_dir?: string
session_dir?: string
}
function installPiAgentConnection(conn: PiAgentConnection) {
;(globalThis as any).window.PI_BOOT_DATA = {
baseUrl: `http://127.0.0.1:${conn.port}`,
token: conn.token,
}
}
// Tauri 原生 app 初始化
if (isNativeTauri) {
// 添加 CSS class 用于 safe-area 适配
@@ -91,29 +106,7 @@ if (isNativeTauri) {
}
}
// Auto-start pi serve(如果设置开启)
if (!isNativeTauriMobile && serviceStore.autoStart) {
const serverUrl = serverStore.getActiveBaseUrl() || 'http://127.0.0.1:4096'
const binaryPath = serviceStore.effectiveBinaryPath
import('@tauri-apps/api/core').then(({ invoke }) => {
serviceStore.setStarting(true)
invoke<boolean>('start_pi_service', { url: serverUrl, binaryPath, envVars: serviceStore.envVarsRecord })
.then(weStarted => {
serviceStore.setStartedByUs(weStarted)
serviceStore.setRunning(true)
serviceStore.setStarting(false)
if (weStarted) {
console.info('[Service] pi serve started by app')
} else {
console.info('[Service] pi serve already running')
}
})
.catch(err => {
serviceStore.setStarting(false)
apiErrorHandler('auto-start pi serve', err)
})
})
}
// The isolated Pi backend is discovered/started inside bootstrap() before React renders.
}
// 全局错误处理 - 防止未捕获错误导致页面刷新
@@ -131,33 +124,44 @@ async function bootstrap() {
if (isTauri()) {
try {
const { invoke } = await import('@tauri-apps/api/core')
const conn = await invoke<{ port: number; token: string } | null>('get_pi_agent_connection')
let conn = await invoke<PiAgentConnection | null>('get_pi_agent_connection')
const shouldAutoStartPi = serviceStore.autoStart || navigator.webdriver === true
if (!conn && !isNativeTauriMobile && shouldAutoStartPi) {
serviceStore.setStarting(true)
const result = await invoke<PiAgentServiceStart>('start_pi_service', {
url: serverStore.getActiveBaseUrl() || 'http://127.0.0.1:4096',
binaryPath: serviceStore.effectiveBinaryPath,
envVars: serviceStore.envVarsRecord,
})
serviceStore.setStartedByUs(result.started)
serviceStore.setRunning(Boolean(result.connection))
conn = result.connection ?? null
}
if (conn) {
console.log('[PiAgentUi] Discovered Pi Agent connection info:', conn)
;(globalThis as any).window.PI_BOOT_DATA = {
baseUrl: `http://127.0.0.1:${conn.port}`,
token: conn.token,
}
console.log('[PiAgentUi] Discovered isolated Pi Agent connection info:', conn)
installPiAgentConnection(conn)
} else {
console.log('[PiAgentUi] No active Pi Agent connection file found. App will run in standalone mode.')
console.log('[PiAgentUi] No isolated Pi Agent backend found. App will run in standalone mode.')
}
} catch (err) {
console.error('[PiAgentUi] Failed to get Pi Agent connection:', err)
console.error('[PiAgentUi] Failed to initialize Pi Agent backend:', err)
serviceStore.setRunning(false)
} finally {
serviceStore.setStarting(false)
}
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary>
<Suspense fallback={null}>
<DirectoryProvider>
<SessionProvider>
<App />
</SessionProvider>
</DirectoryProvider>
</Suspense>
</ErrorBoundary>
</StrictMode>,
createElement(
ErrorBoundary,
null,
createElement(
DirectoryProvider,
null,
createElement(SessionProvider, null, createElement(App)),
),
),
)
}
+64
View File
@@ -44,4 +44,68 @@ describe('activeSessionStore scoped refresh handling', () => {
description: 'Need approval',
})
})
it('adds an optimistic session as an active running entry', () => {
activeSessionStore.addOptimisticSession({
id: 'optimistic-session:test',
title: 'Pending session',
directory: '/workspace/app',
time: { created: 1, updated: 1 },
status: { type: 'running', message: 'Creating session…' },
summary: { deletions: 0, files: 0, additions: 0 },
})
expect(activeSessionStore.getBusySessions()).toEqual([
{
sessionId: 'optimistic-session:test',
status: { type: 'running', message: 'Creating session…' },
title: 'Pending session',
directory: '/workspace/app',
pendingAction: undefined,
},
])
})
it('replaces an optimistic session with the real runtime session', () => {
activeSessionStore.addOptimisticSession({
id: 'optimistic-session:test',
title: 'Pending session',
directory: '/workspace/app',
time: { created: 1, updated: 1 },
status: { type: 'running', message: 'Creating session…' },
summary: { deletions: 0, files: 0, additions: 0 },
})
activeSessionStore.reconcileOptimisticSession('optimistic-session:test', {
id: 'real-session',
title: 'Real session',
directory: '/workspace/app',
time: { created: 2, updated: 2 },
status: { type: 'busy' },
summary: { deletions: 0, files: 0, additions: 0 },
})
expect(activeSessionStore.getBusySessions().map(entry => entry.sessionId)).toEqual(['real-session'])
expect(activeSessionStore.getSessionMeta('optimistic-session:test')).toBeUndefined()
expect(activeSessionStore.getSessionMeta('real-session')).toEqual({
title: 'Real session',
directory: '/workspace/app',
})
})
it('rolls back an optimistic session when runtime creation fails', () => {
activeSessionStore.addOptimisticSession({
id: 'optimistic-session:test',
title: 'Pending session',
directory: '/workspace/app',
time: { created: 1, updated: 1 },
status: { type: 'running', message: 'Creating session…' },
summary: { deletions: 0, files: 0, additions: 0 },
})
activeSessionStore.rollbackOptimisticSession('optimistic-session:test')
expect(activeSessionStore.getBusySessions()).toEqual([])
expect(activeSessionStore.getSessionMeta('optimistic-session:test')).toBeUndefined()
})
})
+66 -1
View File
@@ -13,6 +13,7 @@
import { useCallback, useSyncExternalStore } from 'react'
import type { SessionStatus, SessionStatusMap } from '../types/api/session'
import type { ApiSession } from '../api/types'
// ============================================
// Types
@@ -88,7 +89,7 @@ class ActiveSessionStore {
private recomputeDerived() {
const entries = Object.entries(this.state.statusMap)
.filter(([, status]) => status.type === 'busy' || status.type === 'retry')
.filter(([, status]) => status.type === 'busy' || status.type === 'running' || status.type === 'retry')
.map(([sessionId, status]) => {
const meta = this.sessionMeta.get(sessionId)
// 从自身 pendingRequests 查 pending action
@@ -292,6 +293,70 @@ class ActiveSessionStore {
this.notify()
}
addOptimisticSession(session: ApiSession) {
const newMap = {
...this.state.statusMap,
[session.id]: session.status ?? { type: 'running' as const, message: 'Creating session…' },
}
this.sessionMeta.set(session.id, {
title: session.title,
directory: session.directory,
})
this.state = { ...this.state, statusMap: newMap }
this.notify()
}
reconcileOptimisticSession(optimisticSessionId: string, realSession: ApiSession) {
const newMap = { ...this.state.statusMap }
delete newMap[optimisticSessionId]
if (realSession.status && realSession.status.type !== 'idle') {
newMap[realSession.id] = realSession.status
}
const optimisticMeta = this.sessionMeta.get(optimisticSessionId)
this.sessionMeta.delete(optimisticSessionId)
this.sessionMeta.set(realSession.id, {
title: realSession.title ?? optimisticMeta?.title,
directory: realSession.directory ?? optimisticMeta?.directory,
})
this.reassignSessionReferences(optimisticSessionId, realSession.id)
this.state = { ...this.state, statusMap: newMap }
this.notify()
}
rollbackOptimisticSession(optimisticSessionId: string) {
const newMap = { ...this.state.statusMap }
delete newMap[optimisticSessionId]
this.sessionMeta.delete(optimisticSessionId)
this.removeSessionReferences(optimisticSessionId)
this.state = { ...this.state, statusMap: newMap }
this.notify()
}
private reassignSessionReferences(fromSessionId: string, toSessionId: string) {
for (const req of this.pendingRequests.values()) {
if (req.sessionId === fromSessionId) {
req.sessionId = toSessionId
}
}
if (this.deferredIdleSessions.delete(fromSessionId)) {
this.deferredIdleSessions.add(toSessionId)
}
}
private removeSessionReferences(sessionId: string) {
for (const [requestId, req] of this.pendingRequests.entries()) {
if (req.sessionId === sessionId) {
this.pendingRequests.delete(requestId)
}
}
this.deferredIdleSessions.delete(sessionId)
}
// ============================================
// Session 元信息管理
// ============================================
-96
View File
@@ -1,96 +0,0 @@
import { describe, expect, it } from 'vitest'
import { sanitizePersistedTerminalLayoutMap } from './layoutStore'
const EMPTY = { version: 1, directories: {} }
function entry(overrides: Record<string, unknown> = {}) {
return {
order: { bottom: ['a'], right: ['b'] },
activeTabId: { bottom: 'a', right: 'b' },
...overrides,
}
}
function sanitize(directories: Record<string, unknown>) {
return sanitizePersistedTerminalLayoutMap({ version: 1, directories })
}
describe('sanitizePersistedTerminalLayoutMap', () => {
it.each([null, undefined, 'x', 3, { version: 2, directories: {} }, { version: 1 }, { version: 1, directories: 'x' }])(
'falls back to an empty map for invalid root %j',
raw => {
expect(sanitizePersistedTerminalLayoutMap(raw)).toEqual(EMPTY)
},
)
it('keeps a well-formed directory entry', () => {
expect(sanitize({ '/proj': entry() })).toEqual({
version: 1,
directories: {
'/proj': { order: { bottom: ['a'], right: ['b'] }, activeTabId: { bottom: 'a', right: 'b' }, sessions: {} },
},
})
})
it('skips entries with an empty key, a non-object value, or missing order / activeTabId', () => {
const result = sanitize({
'': entry(),
'/null': null,
'/string': 'x',
'/no-order': { activeTabId: { bottom: null, right: null } },
'/order-not-object': entry({ order: 'x' }),
'/no-active': { order: { bottom: [], right: [] } },
'/active-not-object': entry({ activeTabId: 'x' }),
'/ok': entry(),
})
expect(Object.keys(result.directories)).toEqual(['/ok'])
})
it('filters the order lists down to non-empty strings and tolerates non-arrays', () => {
const result = sanitize({
'/a': entry({ order: { bottom: ['x', '', 5, null, 'y'], right: 'nope' } }),
})
expect(result.directories['/a'].order).toEqual({ bottom: ['x', 'y'], right: [] })
})
it('nulls non-string active tab ids independently', () => {
const result = sanitize({
'/a': entry({ activeTabId: { bottom: 'a', right: 7 } }),
'/b': entry({ activeTabId: { bottom: 7, right: 'b' } }),
})
expect(result.directories['/a'].activeTabId).toEqual({ bottom: 'a', right: null })
expect(result.directories['/b'].activeTabId).toEqual({ bottom: null, right: 'b' })
})
it('keeps typed session fields, drops mistyped ones and skips invalid sessions', () => {
const result = sanitize({
'/a': entry({
sessions: {
t1: { title: 'T', shellTitle: 's', customTitle: 'c', buffer: 'b', scrollY: 1, cursor: 2, rows: 3, cols: 4 },
t2: { title: 5, shellTitle: 5, customTitle: 5, buffer: 5, scrollY: '1', cursor: '2', rows: '3', cols: '4' },
'': { title: 'ignored' },
t3: null,
t4: 'x',
},
}),
})
expect(result.directories['/a'].sessions).toEqual({
t1: { title: 'T', shellTitle: 's', customTitle: 'c', buffer: 'b', scrollY: 1, cursor: 2, rows: 3, cols: 4 },
t2: {
title: undefined,
shellTitle: undefined,
customTitle: undefined,
buffer: undefined,
scrollY: undefined,
cursor: undefined,
rows: undefined,
cols: undefined,
},
})
})
it('uses an empty sessions map when sessions is not an object', () => {
expect(sanitize({ '/a': entry({ sessions: 'x' }) }).directories['/a'].sessions).toEqual({})
expect(sanitize({ '/a': entry() }).directories['/a'].sessions).toEqual({})
})
})
+48 -60
View File
@@ -230,73 +230,61 @@ function sanitizePersistedPanelLayout(raw: unknown): PersistedPanelLayout | null
}
}
function sanitizeIdList(raw: unknown): string[] {
return Array.isArray(raw) ? raw.filter((id): id is string => typeof id === 'string' && id.length > 0) : []
}
function sanitizeActiveTabId(raw: { bottom?: unknown; right?: unknown }): Record<PanelPosition, string | null> {
return {
bottom: typeof raw.bottom === 'string' ? raw.bottom : null,
right: typeof raw.right === 'string' ? raw.right : null,
function sanitizePersistedTerminalLayoutMap(raw: unknown): PersistedTerminalLayoutMap {
if (!raw || typeof raw !== 'object') {
return { version: 1, directories: {} }
}
}
function optionalString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined
}
function optionalNumber(value: unknown): number | undefined {
return typeof value === 'number' ? value : undefined
}
function sanitizeTerminalSessionState(raw: Partial<PersistedTerminalSessionState>): PersistedTerminalSessionState {
return {
title: optionalString(raw.title),
shellTitle: optionalString(raw.shellTitle),
customTitle: optionalString(raw.customTitle),
buffer: optionalString(raw.buffer),
scrollY: optionalNumber(raw.scrollY),
cursor: optionalNumber(raw.cursor),
rows: optionalNumber(raw.rows),
cols: optionalNumber(raw.cols),
}
}
function sanitizeTerminalSessions(raw: unknown): Record<string, PersistedTerminalSessionState> {
const sessions: Record<string, PersistedTerminalSessionState> = {}
if (!raw || typeof raw !== 'object') return sessions
for (const [id, session] of Object.entries(raw)) {
if (!id || !session || typeof session !== 'object') continue
sessions[id] = sanitizeTerminalSessionState(session as Partial<PersistedTerminalSessionState>)
}
return sessions
}
function sanitizeTerminalDirectoryLayout(raw: unknown): PersistedTerminalDirectoryLayout | null {
if (!raw || typeof raw !== 'object') return null
const entry = raw as Partial<PersistedTerminalDirectoryLayout>
const { order, activeTabId } = entry
if (!order || typeof order !== 'object' || !activeTabId || typeof activeTabId !== 'object') return null
return {
order: { bottom: sanitizeIdList(order.bottom), right: sanitizeIdList(order.right) },
activeTabId: sanitizeActiveTabId(activeTabId),
sessions: sanitizeTerminalSessions(entry.sessions),
}
}
export function sanitizePersistedTerminalLayoutMap(raw: unknown): PersistedTerminalLayoutMap {
const data = raw && typeof raw === 'object' ? (raw as Partial<PersistedTerminalLayoutMap>) : undefined
if (data?.version !== 1 || !data.directories || typeof data.directories !== 'object') {
const data = raw as Partial<PersistedTerminalLayoutMap>
if (data.version !== 1 || !data.directories || typeof data.directories !== 'object') {
return { version: 1, directories: {} }
}
const directories: Record<string, PersistedTerminalDirectoryLayout> = {}
for (const [directory, value] of Object.entries(data.directories)) {
const layout = directory ? sanitizeTerminalDirectoryLayout(value) : null
if (layout) directories[directory] = layout
if (!directory || !value || typeof value !== 'object') continue
const entry = value as Partial<PersistedTerminalDirectoryLayout>
const rawOrder = entry.order
const rawActiveTabId = entry.activeTabId
if (!rawOrder || typeof rawOrder !== 'object' || !rawActiveTabId || typeof rawActiveTabId !== 'object') continue
const order = {
bottom: Array.isArray(rawOrder.bottom)
? rawOrder.bottom.filter((id): id is string => typeof id === 'string' && id.length > 0)
: [],
right: Array.isArray(rawOrder.right)
? rawOrder.right.filter((id): id is string => typeof id === 'string' && id.length > 0)
: [],
}
const sessions: Record<string, PersistedTerminalSessionState> = {}
const rawSessions = entry.sessions
if (rawSessions && typeof rawSessions === 'object') {
for (const [id, session] of Object.entries(rawSessions)) {
if (!id || !session || typeof session !== 'object') continue
const data = session as Partial<PersistedTerminalSessionState>
sessions[id] = {
title: typeof data.title === 'string' ? data.title : undefined,
shellTitle: typeof data.shellTitle === 'string' ? data.shellTitle : undefined,
customTitle: typeof data.customTitle === 'string' ? data.customTitle : undefined,
buffer: typeof data.buffer === 'string' ? data.buffer : undefined,
scrollY: typeof data.scrollY === 'number' ? data.scrollY : undefined,
cursor: typeof data.cursor === 'number' ? data.cursor : undefined,
rows: typeof data.rows === 'number' ? data.rows : undefined,
cols: typeof data.cols === 'number' ? data.cols : undefined,
}
}
}
directories[directory] = {
order,
activeTabId: {
bottom: typeof rawActiveTabId.bottom === 'string' ? rawActiveTabId.bottom : null,
right: typeof rawActiveTabId.right === 'string' ? rawActiveTabId.right : null,
},
sessions,
}
}
return { version: 1, directories }
+7 -5
View File
@@ -5,6 +5,7 @@
// ============================================
import { useSyncExternalStore } from 'react'
import { isTauri, isTauriMobile } from '../utils/tauri'
const STORAGE_KEY_AUTO_START = 'opencode-auto-start-service'
const STORAGE_KEY_BINARY_PATH = 'opencode-binary-path'
@@ -48,7 +49,8 @@ class ServiceStore {
constructor() {
try {
this._autoStart = localStorage.getItem(STORAGE_KEY_AUTO_START) === 'true'
const storedAutoStart = localStorage.getItem(STORAGE_KEY_AUTO_START)
this._autoStart = storedAutoStart === null ? isTauri() && !isTauriMobile() : storedAutoStart === 'true'
} catch {
this._autoStart = false
}
@@ -89,7 +91,7 @@ class ServiceStore {
/** 返回实际要用的可执行文件路径,空则回退默认值 */
get effectiveBinaryPath() {
return this._binaryPath.trim() || 'opencode'
return this._binaryPath.trim() || 'pi'
}
/** 将 envVars 转为 Record<string, string>,方便传给 Rust */
@@ -109,7 +111,7 @@ class ServiceStore {
try {
localStorage.setItem(STORAGE_KEY_AUTO_START, String(v))
} catch {
/* */
void 0
}
this._notify()
}
@@ -119,7 +121,7 @@ class ServiceStore {
try {
localStorage.setItem(STORAGE_KEY_BINARY_PATH, v)
} catch {
/* */
void 0
}
this._notify()
}
@@ -129,7 +131,7 @@ class ServiceStore {
try {
localStorage.setItem(STORAGE_KEY_ENV_VARS, JSON.stringify(vars))
} catch {
/* */
void 0
}
this._notify()
}
+11
View File
@@ -1,5 +1,15 @@
export type ExtensionPackageScope = 'user' | 'project'
export type ExtensionPackageStatus = 'active' | 'inactive' | 'missing' | 'error'
export type ExtensionCompatibilityKind = 'pi' | 'opencode' | 'unknown'
export type ExtensionCompatibilityStatus = 'supported' | 'partial' | 'unsupported'
export type ExtensionCompatibilityCapability = 'extensions' | 'skills' | 'prompts' | 'themes' | 'commands'
export interface ExtensionCompatibilityDescriptor {
kind: ExtensionCompatibilityKind
status: ExtensionCompatibilityStatus
reason: string
capabilities: ExtensionCompatibilityCapability[]
}
export interface ExtensionResource {
name: string
@@ -27,6 +37,7 @@ export interface ExtensionPackageInfo {
status: ExtensionPackageStatus
error?: string
resources: ExtensionPackageResources
compatibility?: ExtensionCompatibilityDescriptor
}
export interface ExtensionCatalogSummary {
+15
View File
@@ -0,0 +1,15 @@
export type RuntimeKind = 'pi' | 'unknown'
export interface RuntimeCapabilityStatus {
available: boolean
reason?: string
source?: string
}
export interface RuntimeCapabilities {
runtime: RuntimeKind
sessionSwitching: RuntimeCapabilityStatus
extensionLoading: RuntimeCapabilityStatus
mcp: RuntimeCapabilityStatus
slashCommands: RuntimeCapabilityStatus
}
+11
View File
@@ -11,6 +11,8 @@ export interface SttSettings {
apiKeyRequired: boolean
transcriptionEndpoint: string
transcriptionModel: string
realtimeEndpoint: string
realtimeModel: string
language?: string
insertMode: SttInsertMode
}
@@ -24,6 +26,8 @@ export interface UpdateSttSettingsRequest {
apiKeyRequired?: boolean
transcriptionEndpoint?: string
transcriptionModel?: string
realtimeEndpoint?: string
realtimeModel?: string
language?: string | null
insertMode?: SttInsertMode
}
@@ -34,3 +38,10 @@ export interface SttTranscriptionResponse {
provider: SttProviderKind
model: string
}
export type SttRealtimeDelta =
| { type: 'ready' }
| { type: 'transcript.partial'; text: string }
| { type: 'transcript.final'; text: string }
| { type: 'error'; error: string }
| { type: 'closed' }
-56
View File
@@ -1,56 +0,0 @@
import { describe, expect, it } from 'vitest'
import { computeDiffPairStats, computeDiffStats } from './diffStats'
describe('computeDiffPairStats', () => {
it('counts added and removed lines', () => {
expect(computeDiffPairStats('a\nb\n', 'a\nc\nd\n')).toEqual({ additions: 2, deletions: 1 })
})
it('returns zeros for identical text', () => {
expect(computeDiffPairStats('a\n', 'a\n')).toEqual({ additions: 0, deletions: 0 })
})
})
describe('computeDiffStats', () => {
it('returns undefined without files or a before/after pair', () => {
expect(computeDiffStats({})).toBeUndefined()
expect(computeDiffStats({ files: [] })).toBeUndefined()
expect(computeDiffStats({ diff: '@@ -1 +1 @@' })).toBeUndefined()
})
it('sums the counters reported per file', () => {
expect(
computeDiffStats({
files: [{ additions: 2, deletions: 1 }, { additions: 1 }, { deletions: 3 }],
}),
).toEqual({ additions: 3, deletions: 4 })
})
it('computes missing counters from before/after, only when additions is absent', () => {
expect(
computeDiffStats({
files: [
{ before: 'a\n', after: 'a\nb\n' },
{ before: 'x\n', after: 'y\n', additions: 5 },
{ before: 'only-before\n' },
],
}),
).toEqual({ additions: 6, deletions: 0 })
})
it('returns undefined when files contribute no changes', () => {
expect(computeDiffStats({ files: [{ additions: 0, deletions: 0 }] })).toBeUndefined()
})
it('uses a single before/after diff and ignores it when nothing changed', () => {
expect(computeDiffStats({ diff: { before: 'a\n', after: 'b\n' } })).toEqual({ additions: 1, deletions: 1 })
expect(computeDiffStats({ diff: { before: 'a\n', after: 'a\n' } })).toBeUndefined()
})
it('prefers files over the single diff', () => {
expect(computeDiffStats({ files: [{ additions: 1 }], diff: { before: 'a\n', after: 'b\nc\n' } })).toEqual({
additions: 1,
deletions: 0,
})
})
})
-50
View File
@@ -1,50 +0,0 @@
import { diffLines } from 'diff'
export interface DiffStats {
additions: number
deletions: number
}
interface DiffStatsInput {
diff?: { before: string; after: string } | string
files?: Array<{ before?: string; after?: string; additions?: number; deletions?: number }>
}
/** Line additions/deletions between two texts. */
export function computeDiffPairStats(before: string, after: string): DiffStats {
let additions = 0
let deletions = 0
for (const change of diffLines(before, after)) {
if (change.added) additions += change.count || 0
if (change.removed) deletions += change.count || 0
}
return { additions, deletions }
}
/**
* Diff stats from extracted tool data, for when the metadata did not provide them.
* Returns undefined when there is nothing to count (no files, no before/after pair, or no changes).
*/
export function computeDiffStats(data: DiffStatsInput): DiffStats | undefined {
if (data.files?.length) {
let additions = 0
let deletions = 0
for (const file of data.files) {
if (file.additions !== undefined) additions += file.additions
if (file.deletions !== undefined) deletions += file.deletions
if (file.additions === undefined && file.before !== undefined && file.after !== undefined) {
const stats = computeDiffPairStats(file.before, file.after)
additions += stats.additions
deletions += stats.deletions
}
}
return additions || deletions ? { additions, deletions } : undefined
}
if (data.diff && typeof data.diff === 'object') {
const stats = computeDiffPairStats(data.diff.before, data.diff.after)
return stats.additions || stats.deletions ? stats : undefined
}
return undefined
}
-26
View File
@@ -1,26 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { saveData } from './downloadUtils'
vi.mock('./tauri', () => ({ isTauri: () => false }))
describe('browser file downloads', () => {
beforeEach(() => {
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:test'), revokeObjectURL: vi.fn() })
})
it('downloads exactly the requested byte view without surrounding buffer contents', async () => {
const bytes = new Uint8Array([11, 22, 33, 44, 55])
saveData(bytes.subarray(1, 4), 'slice.bin')
const blob = vi.mocked(URL.createObjectURL).mock.calls[0][0] as Blob
const contents = await new Promise<ArrayBuffer>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as ArrayBuffer)
reader.onerror = reject
reader.readAsArrayBuffer(blob)
})
expect([...new Uint8Array(contents)]).toEqual([22, 33, 44])
expect(blob.type).toBe('application/octet-stream')
})
})
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more