diff --git a/test/tauri-ui.test.js b/test/tauri-ui.test.js
index bafbbde..f1c9b3e 100644
--- a/test/tauri-ui.test.js
+++ b/test/tauri-ui.test.js
@@ -23,6 +23,20 @@ test("renders every Pi RPC extension UI method without replying to fire-and-forg
assert.match(source, /stripAnsi\(extension\.statusText\)/);
assert.match(source, /className="pi-controls"/);
assert.match(source, /aria-label="Model and thinking controls"/);
+ assert.match(source, /const uiScaleKey/);
+ assert.match(source, /function clampUiScale/);
+ assert.match(source, /function updateUiScale/);
+ assert.match(source, /localStorage\.setItem\(uiScaleKey/);
+ assert.match(source, /className="scale-settings"/);
+ assert.match(source, /aria-label="Interface scale"/);
+ assert.match(source, /aria-label="Decrease interface scale"/);
+ assert.match(source, /aria-label="Increase interface scale"/);
+ assert.match(source, /type="range"/);
+ assert.match(source, /style=\{\{ zoom: uiScale \}\}/);
+ assert.doesNotMatch(
+ source,
+ /className="pi-controls"[\s\S]*aria-label="Interface scale"/,
+ );
assert.doesNotMatch(source, //);
assert.doesNotMatch(
source,
@@ -35,7 +49,7 @@ test("renders every Pi RPC extension UI method without replying to fire-and-forg
);
assert.match(source, /useLayoutEffect/);
assert.match(source, /transcript\.scrollTop = transcript\.scrollHeight/);
- assert.match(source, /requestAnimationFrame\(scrollToLatest\)/);
+ assert.match(source, /requestAnimationFrame\(scrollTranscriptToLatest\)/);
assert.match(source, /const loadGeneration = useRef\(0\)/);
assert.match(
source,
@@ -55,6 +69,12 @@ test("renders every Pi RPC extension UI method without replying to fire-and-forg
source,
/const lastEventSequence = useRef>/,
);
+ assert.match(source, /const eventReloadInFlight = useRef\(false\)/);
+ assert.match(source, /const queuedEventReload = useRef/);
+ assert.match(source, /function reloadAgentAfterEvent\(agentId: string\)/);
+ assert.match(source, /void reloadAgentAfterEvent\(selectedId\)/);
+ assert.match(source, /let disposed = false;/);
+ assert.match(source, /if \(disposed\) stop\(\);/);
assert.match(source, /generation !== loadGeneration\.current/);
assert.match(source, /current\.phase !== "working"/);
assert.match(source, /bridgeEvent\.seq <= \(lastEventSequence\.current/);
@@ -181,6 +201,19 @@ test("shows bridge-managed directory status and sessions for the opened director
/state: loadedState\.isStreaming \? "streaming" : "idle"/,
);
assert.match(component, /className="directory-add"/);
+ assert.match(
+ component,
+ /import \{ open \} from "@tauri-apps\/plugin-dialog"/,
+ );
+ assert.match(component, /function browseForFolder/);
+ assert.match(component, /directory: true/);
+ assert.match(component, /multiple: false/);
+ assert.match(component, /Browse…/);
+ assert.match(component, /const \[isAddingDirectory, setIsAddingDirectory\]/);
+ assert.match(component, /aria-busy=\{isAddingDirectory\}/);
+ assert.match(component, /className="directory-add-status" role="status"/);
+ assert.match(component, /pendingDirectoryPath/);
+ assert.match(component, /Added \$\{worktreeLabel\(pendingDirectoryPath\)\}/);
assert.match(component, /Add directory/);
assert.doesNotMatch(component, /className="folder-form"/);
assert.doesNotMatch(component, /Remembered folders/);
@@ -192,6 +225,9 @@ test("shows bridge-managed directory status and sessions for the opened director
assert.match(component, /New session/);
assert.match(component, /state\.isStreaming/);
assert.match(styles, /\.directory-add \{/);
+ assert.match(styles, /\.directory-add-status \{/);
+ assert.match(styles, /\.directory-add-spinner \{/);
+ assert.match(styles, /directory-add-spin/);
assert.match(styles, /\.directory-tab \{/);
assert.match(styles, /\.directory-tab-button \{/);
assert.match(styles, /\.directory-close \{/);
@@ -235,6 +271,28 @@ test("shows an optimistic prompt until the transcript receives it", async () =>
assert.match(styles, /\.pending-message\.sending/);
});
+test("offers a lower-center control when the conversation is above its latest message", async () => {
+ const [component, styles] = await Promise.all([
+ readFile("ui/src/App.tsx", "utf8"),
+ readFile("ui/src/App.css", "utf8"),
+ ]);
+ assert.match(
+ component,
+ /const \[isTranscriptAtBottom, setIsTranscriptAtBottom\]/,
+ );
+ assert.match(component, /function updateTranscriptScrollPosition/);
+ assert.match(component, /function scrollTranscriptToLatest/);
+ assert.match(component, /onScroll=\{updateTranscriptScrollPosition\}/);
+ assert.match(component, /!isTranscriptAtBottom/);
+ assert.match(component, /aria-label="Scroll to latest message"/);
+ assert.match(component, /className="scroll-to-latest"/);
+ assert.match(component, /scrollTranscriptToLatest\(\);/);
+ assert.match(styles, /\.transcript-container \{/);
+ assert.match(styles, /\.scroll-to-latest \{[\s\S]*left: 50%/);
+ assert.match(styles, /\.scroll-to-latest \{[\s\S]*bottom: 14px/);
+ assert.match(styles, /\.scroll-to-latest \{[\s\S]*border-radius: 50%/);
+});
+
test("distinguishes user and assistant messages", async () => {
const styles = await readFile("ui/src/App.css", "utf8");
assert.match(styles, /\.message\.user \{/);
@@ -293,9 +351,36 @@ test("uses a compact workflow layout that keeps the composer and transcript stab
assert.match(styles, /conic-gradient\(/);
assert.match(styles, /transcript-border-orbit/);
assert.match(styles, /prefers-reduced-motion: reduce/);
+ assert.match(styles, /font-size: 18px/);
+ assert.match(styles, /\.directory-tab-button \{[\s\S]*font-size: 13px/);
+ assert.match(styles, /\.message pre \{[\s\S]*font-size: 14px/);
assert.match(
styles,
- /\.workflow-main \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) 210px/,
+ /\.workflow-main \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) 250px/,
);
+ assert.match(
+ styles,
+ /\.pi-controls \{[\s\S]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/,
+ );
+ assert.match(styles, /\.scale-settings \{/);
+ assert.match(
+ styles,
+ /\.scale-adjustment \{[\s\S]*grid-template-columns: auto minmax\(0, 1fr\) auto/,
+ );
+ assert.match(styles, /\.scale-adjustment input\[type="range"\] \{/);
assert.match(styles, /\.pi-controls \{[\s\S]*padding: 4px 6px/);
});
+
+test("configures a larger desktop window and enables the folder dialog", async () => {
+ const [config, capability, rust] = await Promise.all([
+ readFile("ui/src-tauri/tauri.conf.json", "utf8"),
+ readFile("ui/src-tauri/capabilities/default.json", "utf8"),
+ readFile("ui/src-tauri/src/lib.rs", "utf8"),
+ ]);
+ assert.match(config, /"width": 1100/);
+ assert.match(config, /"height": 840/);
+ assert.match(config, /"minWidth": 640/);
+ assert.match(config, /"minHeight": 520/);
+ assert.match(capability, /"dialog:default"/);
+ assert.match(rust, /\.plugin\(tauri_plugin_dialog::init\(\)\)/);
+});
diff --git a/ui/package-lock.json b/ui/package-lock.json
index b73a2cc..17467f0 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -1,14 +1,16 @@
{
- "name": "ui",
+ "name": "pi-status-ui",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "ui",
+ "name": "pi-status-ui",
"version": "0.1.0",
+ "license": "Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2",
+ "@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-opener": "^2",
"react": "^19.1.0",
"react-dom": "^19.1.0"
@@ -1434,6 +1436,15 @@
"node": ">= 10"
}
},
+ "node_modules/@tauri-apps/plugin-dialog": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz",
+ "integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==",
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "@tauri-apps/api": "^2.11.0"
+ }
+ },
"node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
diff --git a/ui/package.json b/ui/package.json
index c732c1f..66f621e 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -17,17 +17,18 @@
"tauri:release": "tauri build --no-bundle"
},
"dependencies": {
- "react": "^19.1.0",
- "react-dom": "^19.1.0",
"@tauri-apps/api": "^2",
- "@tauri-apps/plugin-opener": "^2"
+ "@tauri-apps/plugin-dialog": "^2.7.2",
+ "@tauri-apps/plugin-opener": "^2",
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0"
},
"devDependencies": {
+ "@tauri-apps/cli": "^2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"typescript": "~5.8.3",
- "vite": "^7.0.4",
- "@tauri-apps/cli": "^2"
+ "vite": "^7.0.4"
}
}
diff --git a/ui/src-tauri/Cargo.lock b/ui/src-tauri/Cargo.lock
index 9e86038..9a98307 100644
--- a/ui/src-tauri/Cargo.lock
+++ b/ui/src-tauri/Cargo.lock
@@ -2168,6 +2168,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.1",
"block2",
+ "libc",
"objc2",
"objc2-core-foundation",
]
@@ -2383,6 +2384,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
+ "tauri-plugin-dialog",
"tauri-plugin-single-instance",
"tokio",
]
@@ -2685,6 +2687,30 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "rfd"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
+dependencies = [
+ "block2",
+ "dispatch2",
+ "glib-sys",
+ "gobject-sys",
+ "gtk-sys",
+ "js-sys",
+ "log",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "raw-window-handle",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "windows-sys 0.60.2",
+]
+
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -3363,6 +3389,64 @@ dependencies = [
"tauri-utils",
]
+[[package]]
+name = "tauri-plugin"
+version = "2.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
+dependencies = [
+ "anyhow",
+ "glob",
+ "plist",
+ "schemars 0.8.22",
+ "serde",
+ "serde_json",
+ "tauri-utils",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-plugin-dialog"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940"
+dependencies = [
+ "log",
+ "raw-window-handle",
+ "rfd",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-plugin",
+ "tauri-plugin-fs",
+ "thiserror 2.0.19",
+ "url",
+]
+
+[[package]]
+name = "tauri-plugin-fs"
+version = "2.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
+dependencies = [
+ "anyhow",
+ "dunce",
+ "glob",
+ "log",
+ "objc2-foundation",
+ "percent-encoding",
+ "schemars 0.8.22",
+ "serde",
+ "serde_json",
+ "serde_repr",
+ "tauri",
+ "tauri-plugin",
+ "tauri-utils",
+ "thiserror 2.0.19",
+ "toml 1.1.3+spec-1.1.0",
+ "url",
+]
+
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.3"
diff --git a/ui/src-tauri/Cargo.toml b/ui/src-tauri/Cargo.toml
index 2ecf3f3..c47bf73 100644
--- a/ui/src-tauri/Cargo.toml
+++ b/ui/src-tauri/Cargo.toml
@@ -24,6 +24,7 @@ tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["io-util", "net", "rt", "macros"] }
+tauri-plugin-dialog = "2"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-single-instance = "2"
diff --git a/ui/src-tauri/capabilities/default.json b/ui/src-tauri/capabilities/default.json
index 45461e2..c45ddda 100644
--- a/ui/src-tauri/capabilities/default.json
+++ b/ui/src-tauri/capabilities/default.json
@@ -2,10 +2,13 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
- "windows": ["main"],
+ "windows": [
+ "main"
+ ],
"permissions": [
"core:default",
"core:window:allow-hide",
- "core:window:allow-start-dragging"
+ "core:window:allow-start-dragging",
+ "dialog:default"
]
-}
+}
\ No newline at end of file
diff --git a/ui/src-tauri/src/bridge.rs b/ui/src-tauri/src/bridge.rs
index f9c5dc5..dc1e015 100644
--- a/ui/src-tauri/src/bridge.rs
+++ b/ui/src-tauri/src/bridge.rs
@@ -1,6 +1,7 @@
use serde::Serialize;
use serde_json::{json, Value};
use std::env;
+use std::path::Path;
use tauri::{AppHandle, Emitter};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
@@ -132,22 +133,67 @@ pub async fn new_session(socket_path: &str, agent_id: &str) -> Result Value {
+ let Some(path) = state.pointer("/data/sessionFile").and_then(Value::as_str) else {
+ return json!({ "data": { "messages": [] } });
+ };
+ let path = Path::new(path);
+ if !path.is_absolute()
+ || path.extension().and_then(|extension| extension.to_str()) != Some("jsonl")
+ {
+ return json!({ "data": { "messages": [] } });
+ }
+ let messages = std::fs::read_to_string(path)
+ .ok()
+ .map(|content| {
+ content
+ .lines()
+ .filter_map(|line| serde_json::from_str::(line).ok())
+ .filter(|entry| entry.get("type").and_then(Value::as_str) == Some("message"))
+ .filter_map(|entry| entry.get("message").cloned())
+ .collect::>()
+ })
+ .unwrap_or_default();
+ json!({ "data": { "messages": messages } })
+}
+
+fn compact_commands(value: Value) -> Value {
+ let commands = value
+ .pointer("/data/commands")
+ .and_then(Value::as_array)
+ .map(|commands| {
+ commands
+ .iter()
+ .map(|command| {
+ let mut compact = serde_json::Map::new();
+ for field in ["name", "description"] {
+ if let Some(value) = command.get(field) {
+ compact.insert(field.to_owned(), value.clone());
+ }
+ }
+ Value::Object(compact)
+ })
+ .collect::>()
+ })
+ .unwrap_or_default();
+ json!({ "data": { "commands": commands } })
+}
+
pub async fn load_agent(socket_path: &str, agent_id: &str) -> Result {
// Older bridge daemons can still serve the conversation while they await a restart.
let stats = request(socket_path, "get_session_stats", Some(agent_id), None)
.await
.unwrap_or_else(|_| json!({ "data": {} }));
- let (state, transcript, commands, models) = tokio::try_join!(
+ let (state, commands, models) = tokio::try_join!(
request(socket_path, "get_state", Some(agent_id), None),
- request(socket_path, "get_transcript", Some(agent_id), None),
request(socket_path, "get_commands", Some(agent_id), None),
request(socket_path, "get_available_models", Some(agent_id), None),
)?;
Ok(json!({
"state": state,
"stats": stats,
- "transcript": transcript,
- "commands": commands,
+ "transcript": fallback_transcript(&state),
+ "commands": compact_commands(commands),
"models": models
}))
}
@@ -164,7 +210,9 @@ pub async fn submit_prompt(socket_path: &str, agent_id: &str, message: &str) ->
}
pub async fn abort(socket_path: &str, agent_id: &str) -> Result<(), String> {
- request(socket_path, "abort", Some(agent_id), None).await.map(|_| ())
+ request(socket_path, "abort", Some(agent_id), None)
+ .await
+ .map(|_| ())
}
pub async fn set_model(
@@ -313,6 +361,35 @@ mod tests {
std::fs::remove_file(path).expect("socket cleanup");
}
+ #[test]
+ fn reads_messages_from_the_saved_session_file_when_transcript_rpc_is_unavailable() {
+ let path = format!(
+ "{}/pi-status-ui-history-{}.jsonl",
+ env::temp_dir().display(),
+ std::process::id()
+ );
+ std::fs::write(
+ &path,
+ concat!(
+ "{\"type\":\"session\",\"id\":\"session-1\"}\n",
+ "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"Hello\"}}\n",
+ "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":\"Hi\"}}\n"
+ ),
+ )
+ .expect("session fixture");
+ let transcript = fallback_transcript(&json!({
+ "data": { "sessionFile": path }
+ }));
+ assert_eq!(
+ transcript["data"]["messages"],
+ json!([
+ { "role": "user", "content": "Hello" },
+ { "role": "assistant", "content": "Hi" }
+ ])
+ );
+ std::fs::remove_file(path).expect("session cleanup");
+ }
+
#[tokio::test]
async fn loads_state_history_commands_and_models_as_one_snapshot() {
let path = format!(
@@ -323,7 +400,7 @@ mod tests {
let _ = std::fs::remove_file(&path);
let listener = UnixListener::bind(&path).expect("listener");
let server = tokio::spawn(async move {
- for _ in 0..5 {
+ for _ in 0..4 {
let (stream, _) = listener.accept().await.expect("connection");
tokio::spawn(async move {
let mut line = String::new();
@@ -336,8 +413,11 @@ mod tests {
Some("get_session_stats") => json!({
"data": { "contextUsage": { "tokens": 32000, "contextWindow": 200000 } }
}),
- Some("get_transcript") => json!({ "data": { "messages": [] } }),
- Some("get_commands") => json!({ "data": { "commands": [] } }),
+ Some("get_commands") => json!({ "data": { "commands": [{
+ "name": "resume",
+ "description": "Resume a saved session",
+ "sourceInfo": { "path": "/very/large/extension/metadata" }
+ }] } }),
Some("get_available_models") => json!({ "data": { "models": [] } }),
other => panic!("unexpected operation: {other:?}"),
};
@@ -346,7 +426,11 @@ mod tests {
}))
.expect("response JSON");
response.push(b'\n');
- reader.get_mut().write_all(&response).await.expect("response");
+ reader
+ .get_mut()
+ .write_all(&response)
+ .await
+ .expect("response");
});
}
});
@@ -354,6 +438,10 @@ mod tests {
assert_eq!(snapshot["state"]["data"]["isStreaming"], false);
assert_eq!(snapshot["stats"]["data"]["contextUsage"]["tokens"], 32000);
assert_eq!(snapshot["transcript"]["data"]["messages"], json!([]));
+ assert_eq!(
+ snapshot["commands"]["data"]["commands"],
+ json!([{ "name": "resume", "description": "Resume a saved session" }])
+ );
server.await.expect("server succeeds");
std::fs::remove_file(path).expect("socket cleanup");
}
diff --git a/ui/src-tauri/src/lib.rs b/ui/src-tauri/src/lib.rs
index 25eb0f4..4f3833b 100644
--- a/ui/src-tauri/src/lib.rs
+++ b/ui/src-tauri/src/lib.rs
@@ -123,7 +123,9 @@ fn subscribe_agent(
}
let event_app = app.clone();
let task = tauri::async_runtime::spawn(async move {
- if let Err(error) = bridge::subscribe(socket, agent_id.clone(), cursor, event_app.clone()).await {
+ if let Err(error) =
+ bridge::subscribe(socket, agent_id.clone(), cursor, event_app.clone()).await
+ {
let _ = event_app.emit(
"bridge-error",
serde_json::json!({ "agentId": agent_id, "message": error }),
@@ -141,6 +143,7 @@ fn subscribe_agent(
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let builder = tauri::Builder::default()
+ .plugin(tauri_plugin_dialog::init())
.manage(Subscription(Mutex::new(None)))
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
@@ -189,6 +192,9 @@ mod tests {
assert_eq!(window_action(&toggle, true), WindowAction::Hide);
assert_eq!(window_action(&toggle, false), WindowAction::ShowAndFocus);
- assert_eq!(window_action(&vec!["--show".to_owned()], true), WindowAction::ShowAndFocus);
+ assert_eq!(
+ window_action(&vec!["--show".to_owned()], true),
+ WindowAction::ShowAndFocus
+ );
}
}
diff --git a/ui/src-tauri/tauri.conf.json b/ui/src-tauri/tauri.conf.json
index 7bc8072..5612bfb 100644
--- a/ui/src-tauri/tauri.conf.json
+++ b/ui/src-tauri/tauri.conf.json
@@ -13,10 +13,10 @@
"windows": [
{
"title": "Pi Status UI",
- "width": 900,
- "height": 720,
- "minWidth": 480,
- "minHeight": 420,
+ "width": 1100,
+ "height": 840,
+ "minWidth": 640,
+ "minHeight": 520,
"center": true,
"decorations": false,
"transparent": true,
diff --git a/ui/src/App.css b/ui/src/App.css
index 4781fb7..b6c8388 100644
--- a/ui/src/App.css
+++ b/ui/src/App.css
@@ -6,6 +6,7 @@
:root {
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
+ font-size: 18px;
color: #ebebe8;
background: #101111;
}
@@ -27,8 +28,8 @@
}
body {
margin: 0;
- min-width: 480px;
- min-height: 420px;
+ min-width: 640px;
+ min-height: 520px;
background: transparent;
}
button,
@@ -41,10 +42,10 @@ button {
cursor: pointer;
border: 0;
border-radius: 7px;
- padding: 8px 12px;
+ padding: 9px 14px;
background: #dcecc8;
color: #152012;
- font-size: 12px;
+ font-size: 14px;
font-weight: 750;
}
button:hover {
@@ -66,8 +67,8 @@ textarea:focus-visible {
overflow: hidden;
display: flex;
flex-direction: column;
- gap: 9px;
- padding: 14px;
+ gap: 12px;
+ padding: 18px;
border: 1px solid #3b403a;
border-radius: 14px;
background: rgba(18, 20, 19, 0.97);
@@ -101,23 +102,23 @@ textarea:focus-visible {
.brand {
flex: 0 0 auto;
color: #b8df84;
- font-size: 10px;
+ font-size: 12px;
font-weight: 900;
letter-spacing: 0.16em;
}
h1 {
min-width: 0;
margin: 0;
- font-size: 16px;
+ font-size: 20px;
line-height: 1.15;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-row {
- margin-top: 3px;
+ margin-top: 4px;
color: #898f89;
- font-size: 11px;
+ font-size: 13px;
white-space: nowrap;
overflow: hidden;
}
@@ -139,10 +140,10 @@ h1 {
flex: 0 0 auto;
border: 1px solid #404740;
border-radius: 999px;
- padding: 2px 6px;
+ padding: 3px 8px;
color: #afb3ad;
background: #202320;
- font-size: 10px;
+ font-size: 12px;
font-weight: 800;
}
.state-pill.working {
@@ -171,8 +172,8 @@ h1 {
.directory-tab-list {
display: flex;
align-items: center;
- gap: 6px;
- min-height: 32px;
+ gap: 7px;
+ min-height: 38px;
}
.directory-tabs {
overflow-x: auto;
@@ -184,7 +185,7 @@ h1 {
.agent-caption {
flex: 0 0 auto;
color: #70776f;
- font-size: 10px;
+ font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -197,16 +198,16 @@ h1 {
.directory-tab-button {
display: flex;
align-items: center;
- gap: 6px;
+ gap: 7px;
min-width: 0;
- max-width: 180px;
+ max-width: 220px;
border: 1px solid #303530;
border-right: 0;
border-radius: 7px 0 0 7px;
- padding: 5px 8px;
+ padding: 7px 10px;
background: #1b1e1b;
color: #aeb4ac;
- font-size: 11px;
+ font-size: 13px;
font-weight: 650;
}
.directory-tab-button > span {
@@ -216,7 +217,7 @@ h1 {
}
.directory-tab-button small {
color: #70776f;
- font-size: 10px;
+ font-size: 11px;
font-weight: 600;
}
.directory-tab-button.selected {
@@ -236,13 +237,13 @@ h1 {
border-color: #7da856;
}
.directory-close {
- min-width: 24px;
+ min-width: 30px;
border: 1px solid #303530;
border-radius: 0 7px 7px 0;
- padding: 3px 6px;
+ padding: 4px 7px;
background: #1b1e1b;
color: #aeb4ac;
- font-size: 15px;
+ font-size: 18px;
line-height: 1;
}
.directory-tab:focus-within .directory-tab-button,
@@ -259,7 +260,7 @@ h1 {
}
.directory-add-toggle {
flex: 0 0 auto;
- padding: 5px 8px;
+ padding: 7px 10px;
white-space: nowrap;
}
.directory-add {
@@ -269,12 +270,34 @@ h1 {
gap: 5px;
}
.directory-add input {
- width: 220px;
- padding: 5px 7px;
- font-size: 11px;
+ width: 280px;
+ padding: 7px 9px;
+ font-size: 13px;
}
.directory-add button {
- padding: 5px 8px;
+ padding: 7px 10px;
+}
+.directory-add-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ color: #c4ed8b;
+ font-size: 13px;
+ font-weight: 700;
+ white-space: nowrap;
+}
+.directory-add-spinner {
+ width: 14px;
+ height: 14px;
+ border: 2px solid #52703a;
+ border-top-color: #c4ed8b;
+ border-radius: 50%;
+ animation: directory-add-spin 0.75s linear infinite;
+}
+@keyframes directory-add-spin {
+ to {
+ transform: rotate(360deg);
+ }
}
.notifications {
@@ -311,9 +334,36 @@ h1 {
.workflow-main {
min-height: 0;
display: grid;
- grid-template-columns: minmax(0, 1fr) 210px;
+ grid-template-columns: minmax(0, 1fr) 250px;
gap: 8px;
}
+.transcript-container {
+ position: relative;
+ min-height: 0;
+}
+.transcript-container .transcript {
+ height: 100%;
+}
+.scroll-to-latest {
+ position: absolute;
+ z-index: 1;
+ left: 50%;
+ bottom: 14px;
+ width: 40px;
+ height: 40px;
+ padding: 0;
+ border: 1px solid #6f8b56;
+ border-radius: 50%;
+ background: #263020;
+ color: #dcecc8;
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
+ font-size: 22px;
+ line-height: 1;
+ transform: translateX(-50%);
+}
+.scroll-to-latest:hover {
+ background: #35452b;
+}
.transcript,
.settings {
min-height: 0;
@@ -499,14 +549,14 @@ h1 {
white-space: pre-wrap;
overflow-wrap: anywhere;
font: inherit;
- font-size: 12px;
+ font-size: 14px;
line-height: 1.38;
color: #d7dbd5;
}
.muted {
margin: 0;
color: #858b85;
- font-size: 12px;
+ font-size: 14px;
}
.warning {
color: #ffb4aa;
@@ -519,7 +569,7 @@ h1 {
}
.pi-controls {
display: grid;
- grid-template-columns: minmax(0, 1fr) 150px;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
padding: 4px 6px;
border: 1px solid #303730;
@@ -577,10 +627,10 @@ textarea:focus {
outline-offset: 1px;
}
textarea {
- min-height: 48px;
- max-height: 100px;
+ min-height: 64px;
+ max-height: 140px;
resize: vertical;
- font-size: 12px;
+ font-size: 14px;
line-height: 1.35;
}
.composer textarea:focus {
@@ -739,11 +789,49 @@ textarea {
}
h2 {
margin: 0;
- font-size: 14px;
+ font-size: 17px;
}
.settings-actions {
justify-content: flex-start;
}
+.scale-settings {
+ display: grid;
+ gap: 8px;
+ border-top: 1px solid #303830;
+ padding-top: 10px;
+}
+.scale-settings-heading {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 8px;
+}
+.scale-settings-heading output {
+ color: #b8df84;
+ font-variant-numeric: tabular-nums;
+ font-weight: 800;
+}
+.scale-adjustment {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 8px;
+}
+.scale-adjustment button {
+ min-width: 32px;
+ padding: 5px 8px;
+ font-size: 16px;
+ line-height: 1;
+}
+.scale-adjustment button:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+}
+.scale-adjustment input[type="range"] {
+ min-width: 0;
+ padding: 0;
+ accent-color: #b8df84;
+}
.session-panel {
display: grid;
gap: 8px;
@@ -859,7 +947,7 @@ h2 {
grid-template-columns: minmax(0, 1fr) 170px;
}
.pi-controls {
- grid-template-columns: minmax(0, 1fr) 120px;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
}
.transcript {
padding: 7px 9px;
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index fc7438f..abab11c 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -9,6 +9,7 @@ import {
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
+import { open } from "@tauri-apps/plugin-dialog";
import "./App.css";
type Agent = { id: string; worktreePath: string; state: string };
@@ -131,6 +132,10 @@ const thinkingLevels = [
"max",
];
const selectedPathKey = "pi-status-ui.selected-worktree";
+const uiScaleKey = "pi-status-ui.interface-scale";
+const minimumUiScale = 0.9;
+const maximumUiScale = 1.4;
+const uiScaleStep = 0.05;
const controlCommands: Command[] = [
{
name: "model",
@@ -181,6 +186,10 @@ function formatTokens(value?: number | null) {
return String(value);
}
+function clampUiScale(value: number) {
+ return Math.min(maximumUiScale, Math.max(minimumUiScale, value));
+}
+
function currentTodos(messages: Message[]) {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
@@ -221,14 +230,21 @@ function App() {
const [state, setState] = useState({});
const [stats, setStats] = useState({});
const [messages, setMessages] = useState([]);
+ const [isTranscriptAtBottom, setIsTranscriptAtBottom] = useState(true);
const [pendingSubmissions, setPendingSubmissions] = useState<
PendingSubmission[]
>([]);
const [models, setModels] = useState([]);
const [commands, setCommands] = useState([]);
const [message, setMessage] = useState("");
+ const [uiScale, setUiScale] = useState(() => {
+ const storedScale = Number(localStorage.getItem(uiScaleKey));
+ return Number.isFinite(storedScale) ? clampUiScale(storedScale) : 1;
+ });
const [folderPath, setFolderPath] = useState("");
const [addingDirectory, setAddingDirectory] = useState(false);
+ const [isAddingDirectory, setIsAddingDirectory] = useState(false);
+ const [pendingDirectoryPath, setPendingDirectoryPath] = useState();
const [status, setStatus] = useState("Connecting to Pi Status Bridge…");
const [view, setView] = useState<"conversation" | "settings">("conversation");
const [commandFollowUp, setCommandFollowUp] = useState();
@@ -253,6 +269,8 @@ function App() {
const bridgeRetryTimer = useRef(undefined);
const bridgeRetryAttempt = useRef(0);
const lastEventSequence = useRef>({});
+ const eventReloadInFlight = useRef(false);
+ const queuedEventReload = useRef(undefined);
const [workProgress, setWorkProgress] = useState({
phase: "idle",
detail: "Ready for your next prompt",
@@ -338,6 +356,20 @@ function App() {
}
}
+ function reloadAgentAfterEvent(agentId: string) {
+ if (eventReloadInFlight.current) {
+ queuedEventReload.current = agentId;
+ return;
+ }
+ eventReloadInFlight.current = true;
+ void loadAgent(agentId).finally(() => {
+ eventReloadInFlight.current = false;
+ const queuedAgentId = queuedEventReload.current;
+ queuedEventReload.current = undefined;
+ if (queuedAgentId) reloadAgentAfterEvent(queuedAgentId);
+ });
+ }
+
function scheduleBridgeReconnect(preferredPath?: string) {
if (bridgeRetryTimer.current !== undefined) return;
const delay = Math.min(250 * 2 ** bridgeRetryAttempt.current, 2_000);
@@ -402,20 +434,55 @@ function App() {
};
}, []);
+ useEffect(() => {
+ if (
+ !pendingDirectoryPath ||
+ !directories.some(
+ (directory) => directory.worktreePath === pendingDirectoryPath,
+ )
+ )
+ return;
+ setPendingDirectoryPath(undefined);
+ setIsAddingDirectory(false);
+ setAddingDirectory(false);
+ setStatus(`Added ${worktreeLabel(pendingDirectoryPath)}`);
+ }, [directories, pendingDirectoryPath]);
+
useEffect(() => {
if (selectedId) void loadSessions(selectedId);
else setSessions([]);
}, [selectedId]);
+ function updateTranscriptScrollPosition() {
+ const transcript = transcriptRef.current;
+ if (!transcript) return;
+ const isAtBottom =
+ transcript.scrollHeight -
+ transcript.scrollTop -
+ transcript.clientHeight <=
+ 4;
+ setIsTranscriptAtBottom((current) =>
+ current === isAtBottom ? current : isAtBottom,
+ );
+ }
+
+ function scrollTranscriptToLatest() {
+ const transcript = transcriptRef.current;
+ if (!transcript) return;
+ transcript.scrollTop = transcript.scrollHeight;
+ setIsTranscriptAtBottom(true);
+ }
+
useLayoutEffect(() => {
- const scrollToLatest = () => {
- const transcript = transcriptRef.current;
- if (transcript) transcript.scrollTop = transcript.scrollHeight;
- };
- scrollToLatest();
- const frame = requestAnimationFrame(scrollToLatest);
+ setIsTranscriptAtBottom(true);
+ }, [selectedId]);
+
+ useLayoutEffect(() => {
+ if (!isTranscriptAtBottom) return;
+ scrollTranscriptToLatest();
+ const frame = requestAnimationFrame(scrollTranscriptToLatest);
return () => cancelAnimationFrame(frame);
- }, [messages, pendingSubmissions, selectedId]);
+ }, [isTranscriptAtBottom, messages, pendingSubmissions, selectedId]);
useEffect(() => {
const userMessages = messages.filter((entry) => entry.role === "user");
@@ -579,6 +646,7 @@ function App() {
}
useEffect(() => {
+ let disposed = false;
let unlisten: (() => void) | undefined;
let unlistenError: (() => void) | undefined;
void listen("bridge-event", (event) => {
@@ -608,10 +676,11 @@ function App() {
);
}
updateWorkProgress(bridgeEvent);
- if (selectedId) void loadAgent(selectedId);
+ if (selectedId) void reloadAgentAfterEvent(selectedId);
}
}).then((stop) => {
- unlisten = stop;
+ if (disposed) stop();
+ else unlisten = stop;
});
void listen<{ message?: string }>("bridge-error", (event) => {
setStatus(
@@ -619,9 +688,11 @@ function App() {
);
scheduleBridgeReconnect();
}).then((stop) => {
- unlistenError = stop;
+ if (disposed) stop();
+ else unlistenError = stop;
});
return () => {
+ disposed = true;
unlisten?.();
unlistenError?.();
};
@@ -783,28 +854,54 @@ function App() {
setCommandFollowUp(undefined);
}
+ function updateUiScale(nextScale: number) {
+ const scale = clampUiScale(nextScale);
+ setUiScale(scale);
+ localStorage.setItem(uiScaleKey, String(scale));
+ }
+
async function activateWorktree(worktreePath: string) {
+ setIsAddingDirectory(true);
+ setPendingDirectoryPath(worktreePath);
+ setStatus("Adding directory…");
try {
const result = await invoke<{ agent: Agent }>("select_worktree", {
worktreePath,
});
- localStorage.setItem(selectedPathKey, worktreePath);
+ const addedPath = result.agent.worktreePath;
+ localStorage.setItem(selectedPathKey, addedPath);
+ setPendingDirectoryPath(addedPath);
setFolderPath("");
- setAddingDirectory(false);
setSelectedId(result.agent.id);
- await refreshAgents(worktreePath);
+ await refreshAgents(addedPath);
setView("conversation");
+ } catch (error) {
+ setPendingDirectoryPath(undefined);
+ setIsAddingDirectory(false);
+ setStatus(String(error));
+ }
+ }
+
+ async function browseForFolder() {
+ try {
+ const selectedPath = await open({
+ directory: true,
+ multiple: false,
+ defaultPath: folderPath || undefined,
+ });
+ if (selectedPath) setFolderPath(selectedPath);
} catch (error) {
setStatus(String(error));
}
}
async function addFolder() {
- if (!folderPath.startsWith("/")) {
+ const worktreePath = folderPath.trim();
+ if (!worktreePath.startsWith("/")) {
setStatus("Enter an absolute folder path");
return;
}
- await activateWorktree(folderPath);
+ await activateWorktree(worktreePath);
}
async function submit() {
@@ -940,7 +1037,7 @@ function App() {
const workspaceLabel =
extensionTitle ?? worktreeLabel(selected?.worktreePath) ?? "Pi";
return (
-
+
{
@@ -1069,12 +1166,14 @@ function App() {
{addingDirectory && (
)}
@@ -1120,6 +1235,44 @@ function App() {
{state.messageCount ?? 0} messages ·{" "}
{state.pendingMessageCount ?? 0} queued
+
-
- {messages.length ? (
- messages.map((entry, index) => (
+
+
+ {messages.length ? (
+ messages.map((entry, index) => (
+
+ {entry.role ?? "message"}
+ {messageText(entry)}
+
+ ))
+ ) : (
+ No messages yet.
+ )}
+ {pendingSubmissions.map((pending) => (
- {entry.role ?? "message"}
- {messageText(entry)}
+
+ You{" "}
+
+ {pending.phase === "sending"
+ ? "Sending…"
+ : "Sent · waiting for Pi"}
+
+
+ {pending.text}
- ))
- ) : (
- No messages yet.
- )}
- {pendingSubmissions.map((pending) => (
-
+ {!isTranscriptAtBottom && (
+
- ))}
-
+
↓
+
Scroll to latest message
+
+ )}
+