merge: dashboard console tui refactor

This commit is contained in:
Keisuke Hirata 2026-06-20 18:33:22 +09:00
commit 23ec2bbd7e
No known key found for this signature in database
12 changed files with 4545 additions and 4423 deletions

View File

@ -13,7 +13,7 @@ Main highlights:
- Multi-agent orchestration with scoped coder/reviewer Pods.
- Profile, Manifest, and prompt-based runtime configuration.
- Local Tickets and workflow files for auditable project coordination.
- TUI and CLI entry points, including a multi-Pod dashboard.
- TUI and CLI entry points, including the `yoi panel` workspace Dashboard and single-Pod Console.
Yoi is actively dogfooded in this repository. Public APIs, configuration formats, and workflows may still change.
@ -38,7 +38,7 @@ nix build .#yoi
```sh
yoi --help
yoi
yoi --multi
yoi panel
yoi --pod <name>
yoi pod --help
```
@ -46,7 +46,7 @@ yoi pod --help
Typical flow:
1. Configure providers, models, profiles, prompts, and scopes.
2. Start or attach to a named Pod from the CLI/TUI.
2. Start or attach to a named Pod in the Console, or inspect workspace activity in the Dashboard.
3. Use explicit tools and scoped delegation for multi-agent work.
4. Record project work through Tickets, workflow files, and git history.

View File

@ -2,7 +2,7 @@
## Role
`tui` implements terminal UI clients for interacting with one or more Pods.
`tui` implements terminal UI clients for the single-Pod Console and workspace Dashboard surfaces.
## Boundaries
@ -10,8 +10,8 @@ Owns:
- terminal rendering and input handling
- local composer state and UI affordances
- single-Pod attach/restore screens
- multi-Pod dashboard presentation
- single-Pod Console attach/restore/chat screens
- workspace Dashboard presentation and role-action UI
Does not own:

View File

@ -1,3 +1,4 @@
use std::error::Error;
use std::fmt;
use std::future::Future;
use std::io;
@ -30,9 +31,15 @@ use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
use crate::composer_keys::{ComposerEditAction, composer_edit_action};
use crate::picker::PickerOutcome;
use crate::spawn::{SpawnOutcome, SpawnReady};
use crate::{multi_pod, picker, spawn, ui};
use crate::{picker, spawn, ui};
type FullscreenTerminal = Terminal<CrosstermBackend<io::Stdout>>;
pub(crate) type ConsoleTerminal = Terminal<CrosstermBackend<io::Stdout>>;
/// Narrow request bridge used when the workspace Dashboard opens a Pod Console.
pub(crate) struct DashboardConsoleOpenRequest {
pub(crate) pod_name: String,
pub(crate) socket_override: Option<PathBuf>,
}
/// Enable SGR coordinates plus normal mouse tracking. This captures clicks,
/// releases, and wheel events without drag-capture modes (`?1002h`/`?1003h`)
@ -58,13 +65,13 @@ impl Command for EnableSinglePodMouseCapture {
}
}
/// Enable Panel mouse input without drag tracking. The Panel only needs button
/// presses/releases and wheel events; enabling `?1002h` can make terminal drag
/// selection look captured and is intentionally avoided for Panel startup.
/// Enable Dashboard mouse input without drag tracking. The Dashboard only needs
/// button presses/releases and wheel events; enabling `?1002h` can make terminal
/// drag selection look captured and is intentionally avoided before startup.
#[derive(Debug, Clone, Copy)]
struct EnablePanelMouseCapture;
struct EnableDashboardMouseCapture;
impl Command for EnablePanelMouseCapture {
impl Command for EnableDashboardMouseCapture {
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
// 1006: SGR extended coordinates used by crossterm's parser
// 1000: normal mouse tracking (button presses/releases and wheel)
@ -165,7 +172,7 @@ pub(crate) async fn run_pod_name(
}
async fn run_connected_pod(
terminal: &mut FullscreenTerminal,
terminal: &mut ConsoleTerminal,
pod_name: String,
client: PodClient,
runtime_command: PodRuntimeCommand,
@ -176,12 +183,12 @@ async fn run_connected_pod(
run_loop(terminal, &mut app, client, runtime_command).await
}
async fn run_pod_name_nested(
terminal: &mut FullscreenTerminal,
request: multi_pod::OpenPodRequest,
pub(crate) async fn open_from_dashboard(
terminal: &mut ConsoleTerminal,
request: DashboardConsoleOpenRequest,
runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
let multi_pod::OpenPodRequest {
let DashboardConsoleOpenRequest {
pod_name,
socket_override,
} = request;
@ -196,7 +203,7 @@ async fn run_pod_name_nested(
}
async fn spawn_pod_name_from_fullscreen(
terminal: &mut FullscreenTerminal,
terminal: &mut ConsoleTerminal,
pod_name: &str,
runtime_command: PodRuntimeCommand,
) -> Result<SpawnReady, Box<dyn std::error::Error>> {
@ -233,7 +240,7 @@ impl std::fmt::Display for NestedOpenCancelled {
impl std::error::Error for NestedOpenCancelled {}
async fn run_ready_pod(
terminal: &mut FullscreenTerminal,
terminal: &mut ConsoleTerminal,
ready: SpawnReady,
runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
@ -281,36 +288,7 @@ pub(crate) async fn run_resume(
run_pod_name(pod_name, socket_override, runtime_command).await
}
pub(crate) async fn run_panel(
runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
let mut app = multi_pod::load_app(runtime_command.clone()).await?;
let mut terminal = enter_panel_fullscreen()?;
loop {
match multi_pod::run(&mut terminal, &mut app).await? {
multi_pod::MultiPodOutcome::Quit => {
let _ = leave_fullscreen(&mut terminal);
return Ok(());
}
multi_pod::MultiPodOutcome::Open(request) => {
let pod_name = request.pod_name.clone();
match run_pod_name_nested(&mut terminal, request, runtime_command.clone()).await {
Ok(()) => app.finish_open(&pod_name, Ok(())),
Err(error) if is_recoverable_multi_open_error(error.as_ref()) => {
app.finish_open(&pod_name, Err(error.as_ref()));
}
Err(error) => {
let _ = leave_fullscreen(&mut terminal);
return Err(error);
}
}
}
}
}
}
fn is_recoverable_multi_open_error(error: &(dyn std::error::Error + 'static)) -> bool {
pub(crate) fn is_recoverable_dashboard_open_error(error: &(dyn Error + 'static)) -> bool {
error.is::<spawn::SpawnError>() || error.is::<NestedOpenCancelled>()
}
@ -353,7 +331,7 @@ pub(crate) async fn run_spawn(
result
}
fn enter_fullscreen() -> Result<FullscreenTerminal, Box<dyn std::error::Error>> {
fn enter_fullscreen() -> Result<ConsoleTerminal, Box<dyn std::error::Error>> {
let mut stdout = io::stdout();
// Enable button-event tracking so the transcript can own drag selection;
// avoid all-motion capture because hover-motion reports are unnecessary.
@ -362,17 +340,17 @@ fn enter_fullscreen() -> Result<FullscreenTerminal, Box<dyn std::error::Error>>
Ok(Terminal::new(backend)?)
}
fn enter_panel_fullscreen() -> Result<FullscreenTerminal, Box<dyn std::error::Error>> {
pub(crate) fn enter_dashboard_fullscreen() -> Result<ConsoleTerminal, Box<dyn std::error::Error>> {
let mut stdout = io::stdout();
// Panel needs clicks and wheel input only; do not capture drag motion before
// Dashboard needs clicks and wheel input only; do not capture drag motion before
// the first visible frame.
execute!(stdout, EnterAlternateScreen, EnablePanelMouseCapture)?;
execute!(stdout, EnterAlternateScreen, EnableDashboardMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
Ok(Terminal::new(backend)?)
}
fn enter_fullscreen_existing(
terminal: &mut FullscreenTerminal,
terminal: &mut ConsoleTerminal,
) -> Result<(), Box<dyn std::error::Error>> {
// Re-enable the same least-intrusive wheel mouse mode after returning from
// nested inline screens.
@ -384,7 +362,7 @@ fn enter_fullscreen_existing(
Ok(())
}
fn leave_fullscreen(terminal: &mut FullscreenTerminal) -> io::Result<()> {
fn leave_fullscreen(terminal: &mut ConsoleTerminal) -> io::Result<()> {
execute!(
terminal.backend_mut(),
DisableMouseCapture,
@ -392,8 +370,12 @@ fn leave_fullscreen(terminal: &mut FullscreenTerminal) -> io::Result<()> {
)
}
pub(crate) fn leave_dashboard_fullscreen(terminal: &mut ConsoleTerminal) -> io::Result<()> {
leave_fullscreen(terminal)
}
async fn run(
terminal: &mut FullscreenTerminal,
terminal: &mut ConsoleTerminal,
pod_name: String,
socket_path: &std::path::Path,
runtime_command: PodRuntimeCommand,
@ -480,7 +462,7 @@ fn read_terminal_events(stop: Arc<AtomicBool>, tx: mpsc::UnboundedSender<Termina
#[cfg(feature = "e2e-test")]
async fn run_e2e_rewind_fixture(
terminal: &mut FullscreenTerminal,
terminal: &mut ConsoleTerminal,
pod_name: String,
) -> Result<(), Box<dyn std::error::Error>> {
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -4,18 +4,18 @@ mod cache;
mod command;
mod composer_history;
mod composer_keys;
mod console;
mod dashboard;
#[cfg(feature = "e2e-test")]
mod e2e_observer;
mod input;
pub mod keys;
mod markdown;
mod multi_pod;
mod picker;
mod pod_list;
mod role_session_registry;
mod scroll;
pub mod setup_model;
mod single_pod;
mod spawn;
mod task;
mod text_selection;
@ -64,7 +64,7 @@ pub enum LaunchMode {
id: SegmentId,
pod_name: Option<String>,
},
/// `yoi panel`: open the workspace panel from the current workspace.
/// `yoi panel`: open the workspace Dashboard from the current workspace.
Panel,
}
@ -95,17 +95,17 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
let result = match mode {
LaunchMode::Spawn { pod_name, profile } => {
single_pod::run_spawn(None, pod_name, profile, runtime_command).await
console::run_spawn(None, pod_name, profile, runtime_command).await
}
LaunchMode::PodName {
pod_name,
socket_override,
} => single_pod::run_pod_name(pod_name, socket_override, runtime_command).await,
LaunchMode::Resume => single_pod::run_resume(runtime_command).await,
} => console::run_pod_name(pod_name, socket_override, runtime_command).await,
LaunchMode::Resume => console::run_resume(runtime_command).await,
LaunchMode::ResumeWithSession { id, pod_name } => {
single_pod::run_spawn(Some(id), pod_name, None, runtime_command).await
console::run_spawn(Some(id), pod_name, None, runtime_command).await
}
LaunchMode::Panel => single_pod::run_panel(runtime_command).await,
LaunchMode::Panel => dashboard::launch(runtime_command).await,
};
// Always restore the terminal first so any pending eprintln below

View File

@ -671,7 +671,11 @@ coder = "profiles/coder.lua"
.unwrap();
let (choices, default_index) = profile_choices_for_cwd(&project);
assert_eq!(default_index, 1);
let default_choice = choices
.iter()
.position(|choice| choice.selector.as_deref() == Some("project:coder"))
.expect("project default choice is present");
assert_eq!(default_index, default_choice);
let selected = &choices[default_index];
assert_eq!(selected.selector.as_deref(), Some("project:coder"));
assert_eq!(selected.label, "project:coder (default)");
@ -701,9 +705,19 @@ description = "Project coder"
choices[0].label,
"builtin:default — Bundled default Yoi coding profile"
);
assert_eq!(default_index, 1);
assert_eq!(choices[1].selector.as_deref(), Some("project:coder"));
assert_eq!(choices[1].label, "project:coder (default) — Project coder");
let project_index = choices
.iter()
.position(|choice| choice.selector.as_deref() == Some("project:coder"))
.expect("project default choice is present");
assert_eq!(default_index, project_index);
assert_eq!(
choices[project_index].selector.as_deref(),
Some("project:coder")
);
assert_eq!(
choices[project_index].label,
"project:coder (default) — Project coder"
);
}
#[test]

View File

@ -623,7 +623,7 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
fn print_help() {
println!(
"yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi ticket <COMMAND> [OPTIONS]\n yoi plugin new rust-component-tool <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace <PATH> Runtime workspace root (defaults to cwd)\n --pod <NAME> Attach/restore/create a Pod by name\n --socket <PATH> Attach to a specific Pod socket with --pod\n --session <UUID> Resume a specific session segment\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
"yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi ticket <COMMAND> [OPTIONS]\n yoi plugin new rust-component-tool <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, --resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n -r, --resume Open the Pod Console picker and resume/attach a Pod\n --workspace <PATH> Runtime workspace root (defaults to cwd)\n --pod <NAME> Open the Pod Console by name (attach/restore/create)\n --socket <PATH> Attach a Pod Console to a specific socket with --pod\n --session <UUID> Resume a specific session segment in the Pod Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
);
}
@ -973,6 +973,18 @@ mod tests {
}
}
#[test]
fn parse_dashboard_word_remains_a_pod_console_name_not_an_alias() {
let config = parse_args_from(["dashboard"]).unwrap();
match config {
Mode::Tui {
mode: LaunchMode::PodName { pod_name, .. },
..
} => assert_eq!(pod_name, "dashboard"),
other => panic!("expected PodName TUI mode, got {other:?}"),
}
}
#[test]
fn parse_multi_flag_is_not_a_launch_alias() {
let err = parse_args_from(["--multi"]).unwrap_err();

View File

@ -1125,7 +1125,8 @@ mod tests {
assert!(config.contains("# [ticket]\n# language = \"Japanese\""));
for role in TicketRole::ALL {
assert!(config.contains(&format!(
"[roles.{role}]\nprofile = \"builtin:default\"\nworkflow = \"{}\"",
"[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"",
role.default_profile(),
role.default_workflow()
)));
}

View File

@ -22,7 +22,7 @@ A Ticket may represent a feature, bug, cleanup, design decision, investigation,
Use the highest-level interface that matches the work:
- Use `yoi panel` for the Ticket/Intake/Orchestrator workspace UI and role-launch actions.
- Use `yoi panel` for the Ticket/Intake/Orchestrator workspace Dashboard and role-launch actions.
- Use `yoi objective ...` for lightweight medium-term Objective records and their non-blocking canonical Ticket links.
- Inside Pods, use typed Ticket tools to create, inspect, comment, review, and close Tickets.
- For multi-step work, follow the Ticket Intake, Orchestrator Routing, planning/requirements-sync, and Multi-agent workflows.
@ -268,9 +268,9 @@ Before closing, verify concrete evidence:
Close with a resolution that summarizes what changed, key commits, validation, review state, and remaining follow-ups.
## Workspace panel Ticket role actions
## Workspace Dashboard Ticket role actions
`yoi panel` is the active Ticket/Intake/Orchestrator UI. It owns fixed Ticket role-launch actions and uses the shared client Ticket role launcher. The single-Pod TUI no longer supports `:ticket ...` commands; typing them in command mode is treated like any other unknown command.
`yoi panel` is the active Ticket/Intake/Orchestrator Dashboard. It owns fixed Ticket role-launch actions and uses the shared client Ticket role launcher. The single-Pod Console no longer supports `:ticket ...` commands; typing them in command mode is treated like any other unknown command.
Role actions map to the same fixed roles configured in `.yoi/ticket.config.toml`:
@ -279,24 +279,24 @@ Role actions map to the same fixed roles configured in `.yoi/ticket.config.toml`
- implement launches the coder role for an implementation assignment.
- review launches the reviewer role for review.
All actions are explicit and user-triggered. They are not a scheduler, queue, spawned-Pod panel, or automatic maintainer loop.
All actions are explicit and user-triggered. They are not a scheduler, queue, spawned-Pod Dashboard, or automatic maintainer loop.
### Panel execution path
### Dashboard execution path
The role-launch path is:
```text
User triggers a Ticket action in yoi panel
-> panel builds a TicketRoleLaunchContext
-> Dashboard builds a TicketRoleLaunchContext
-> client Ticket role launcher reads .yoi/ticket.config.toml
-> launcher selects the role Profile and workflow
-> launcher spawns the role Pod
-> launcher sends Method::Run with WorkflowInvoke + Text segments
-> launcher waits for run-acceptance evidence
-> panel reports success/failure
-> Dashboard reports success/failure
```
The launched Pod receives dynamic Ticket/action context as its first committed run input. The panel does not inject hidden context, does not write Ticket files directly, and does not construct prompt/workflow segments by hand.
The launched Pod receives dynamic Ticket/action context as its first committed run input. The Dashboard does not inject hidden context, does not write Ticket files directly, and does not construct prompt/workflow segments by hand.
The first run input contains:
@ -308,9 +308,9 @@ The first run input contains:
The selected Profile supplies durable system/role behavior. `ticket.config.toml` does not override system instruction.
### Panel setup
### Dashboard setup
Because top-level role launches cannot inherit a parent Profile, configure concrete role profiles before using panel role actions:
Because top-level role launches cannot inherit a parent Profile, configure concrete role profiles before using Dashboard role actions:
```toml
# .yoi/ticket.config.toml
@ -336,9 +336,9 @@ profile = "project:reviewer"
workflow = "multi-agent-workflow"
```
If a role still uses `profile = "inherit"`, the panel fails closed with a diagnostic explaining that a concrete profile is required.
If a role still uses `profile = "inherit"`, the Dashboard fails closed with a diagnostic explaining that a concrete profile is required.
### Panel troubleshooting
### Dashboard troubleshooting
- `profile = "inherit"`: configure a concrete role Profile in `.yoi/ticket.config.toml`.
- malformed `.yoi/ticket.config.toml`: fix the config and retry.

View File

@ -1,5 +1,5 @@
<system-reminder>
Workspace panel observed that this Orchestrator Pod is idle while queued Ticket work is present.
Workspace Dashboard observed that this Orchestrator Pod is idle while queued Ticket work is present.
This is bounded attention only, not scheduler authority. Do not drain the queue automatically. Before implementation side effects, verify the Ticket state and record the normal `queued -> inprogress` acceptance through Ticket tools.