Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad807fb0df | ||
|
|
5ee759d0fd | ||
|
|
eda5d04710 | ||
|
|
329aa8a360 | ||
|
|
9945290bdd | ||
|
|
4242cdc490 | ||
|
|
6601056972 | ||
|
|
1c424f26e7 | ||
|
|
6e6546fe32 | ||
|
|
e329f1cc6f | ||
|
|
29ac9b3caf | ||
|
|
71b21e1974 | ||
|
|
96c6cfc6c5 | ||
|
|
aa742eed79 | ||
|
|
1caaf5f4dd | ||
|
|
bdc46a4fdd | ||
|
|
4043c2cbe0 | ||
|
|
b0a826cfd4 | ||
|
|
18d4c5978b | ||
|
|
2d64740007 | ||
|
|
5f162a6668 | ||
|
|
5fb3df84ed | ||
|
|
41fd586ca0 | ||
|
|
1787147e82 | ||
|
|
0d333e8470 | ||
|
|
816e60d028 | ||
|
|
b99548a900 | ||
|
|
4ff004790d | ||
|
|
c378503e26 | ||
|
|
8a5c626892 | ||
|
|
85e275a362 | ||
|
|
9c1597a450 | ||
|
|
d4369aabc3 | ||
|
|
7b30748bf5 | ||
|
|
a5221792ba | ||
|
|
79342277b8 |
@@ -0,0 +1,151 @@
|
||||
---
|
||||
name: d2-diagram
|
||||
description: >
|
||||
Generate technical diagrams using d2 (text-to-diagram CLI). Use when the
|
||||
user says "create a diagram", "draw architecture", "make a flowchart",
|
||||
"diagram this", "render d2", "d2", "data flow diagram", "entity relationship",
|
||||
"state machine", "sequence diagram", "UI flow", or invokes /d2-diagram.
|
||||
Produces .d2 source files and renders them to PNG. Also use when asked
|
||||
to update, re-render, or batch render existing diagrams.
|
||||
---
|
||||
|
||||
# d2 Diagram Generation
|
||||
|
||||
Generate technical diagrams from text using d2 (v0.7.1). Pure CLI, no
|
||||
external dependencies beyond the d2 binary.
|
||||
|
||||
**Binary:** `/home/linuxbrew/.linuxbrew/bin/d2`
|
||||
|
||||
## Project Defaults
|
||||
|
||||
| Setting | Value | Override |
|
||||
|---------|-------|----------|
|
||||
| Theme | 200 (Dark Mauve) | `--theme N` |
|
||||
| Layout | dagre | `--layout elk` |
|
||||
| Padding | 100px | — |
|
||||
| Format | PNG | `--svg` |
|
||||
|
||||
## Output Convention
|
||||
|
||||
```
|
||||
docs/diagrams/
|
||||
architecture/ # System architecture, IPC, component layout
|
||||
data-flow/ # Sequence diagrams, data pipelines
|
||||
entity/ # ER diagrams, ECS component schemas
|
||||
state/ # State machines, behavior trees
|
||||
ui/ # UI navigation flow, screen transitions
|
||||
```
|
||||
|
||||
Both `.d2` source and `.png` output are tracked in git.
|
||||
|
||||
## Single Diagram Workflow
|
||||
|
||||
1. **Determine category** — architecture, data-flow, entity, state, or ui
|
||||
2. **Read template** — `references/diagram-templates.md` for the matching category
|
||||
3. **Read syntax** — `references/d2-syntax-guide.md` if unfamiliar with d2 syntax
|
||||
4. **Write .d2 source** — to `docs/diagrams/{category}/{name}.d2`
|
||||
5. **Validate** — `.claude/skills/d2-diagram/scripts/d2-render.sh validate {file}`
|
||||
6. **Render** — `.claude/skills/d2-diagram/scripts/d2-render.sh {file}`
|
||||
7. **Read SVG** — verify the output, present to user
|
||||
|
||||
### Script Usage
|
||||
|
||||
```bash
|
||||
# Render with project defaults
|
||||
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/architecture/ipc-bridge.d2
|
||||
|
||||
# Validate syntax only
|
||||
.claude/skills/d2-diagram/scripts/d2-render.sh validate docs/diagrams/architecture/ipc-bridge.d2
|
||||
|
||||
# Auto-format source
|
||||
.claude/skills/d2-diagram/scripts/d2-render.sh fmt docs/diagrams/architecture/ipc-bridge.d2
|
||||
|
||||
# Sketch mode (hand-drawn look for drafts)
|
||||
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/ui/flow.d2 --sketch
|
||||
|
||||
# Light theme (for printable docs)
|
||||
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/entity/schema.d2 --theme 0
|
||||
|
||||
# SVG output (if specifically needed)
|
||||
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/architecture/overview.d2 --svg
|
||||
```
|
||||
|
||||
## Batch Render
|
||||
|
||||
Re-render all diagrams after theme or style changes:
|
||||
|
||||
```bash
|
||||
# All diagrams
|
||||
.claude/skills/d2-diagram/scripts/d2-batch.sh
|
||||
|
||||
# One category
|
||||
.claude/skills/d2-diagram/scripts/d2-batch.sh docs/diagrams/architecture/
|
||||
|
||||
# Preview what would render
|
||||
.claude/skills/d2-diagram/scripts/d2-batch.sh --dry-run
|
||||
|
||||
# Force re-render everything
|
||||
.claude/skills/d2-diagram/scripts/d2-batch.sh --force
|
||||
```
|
||||
|
||||
Batch skips files whose PNG is newer than the `.d2` source unless `--force`.
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Variables for consistent styling
|
||||
|
||||
```d2
|
||||
vars: {
|
||||
color-bg: "#2a3040"
|
||||
color-stroke: "#333340"
|
||||
color-text: "#c8d0e0"
|
||||
color-accent: "#c8d8f0"
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-board (layers)
|
||||
|
||||
```d2
|
||||
# Base diagram here
|
||||
|
||||
layers: {
|
||||
detailed: {
|
||||
# More detailed view
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sequence diagrams
|
||||
|
||||
```d2
|
||||
shape: sequence_diagram
|
||||
client: Godot Client
|
||||
server: Rust Server
|
||||
|
||||
client -> server: TickRequest(delta)
|
||||
server -> client: WorldState(entities)
|
||||
```
|
||||
|
||||
### Imports
|
||||
|
||||
Split shared definitions into a separate file and import:
|
||||
|
||||
```d2
|
||||
...@shared-defs.d2
|
||||
```
|
||||
|
||||
## Agent Guidance
|
||||
|
||||
- **Qatux** — Architecture decision records, system overview diagrams, data
|
||||
schemas. Prefer architecture and entity templates.
|
||||
- **Tyre** — IPC bridge, ECS system flow, chunk loading pipeline, perception
|
||||
system data flow. Prefer architecture and data-flow templates.
|
||||
- **Araminta** — UI navigation flow, screen transitions, component hierarchy.
|
||||
Prefer UI flow template.
|
||||
|
||||
## References
|
||||
|
||||
- `references/d2-syntax-guide.md` — Language quick reference (shapes, edges,
|
||||
containers, styling, variables). Read when unfamiliar with d2 syntax.
|
||||
- `references/diagram-templates.md` — Five category templates with complete
|
||||
d2 source examples. Read when starting a new diagram.
|
||||
@@ -0,0 +1,212 @@
|
||||
# D2 Language Quick Reference
|
||||
|
||||
## Nodes
|
||||
|
||||
```d2
|
||||
server # Implicit label from key
|
||||
server: Simulation Server # Explicit label
|
||||
server: Simulation Server { # With properties
|
||||
shape: hexagon
|
||||
style.fill: "#2d3436"
|
||||
}
|
||||
```
|
||||
|
||||
## Edges
|
||||
|
||||
```d2
|
||||
a -> b # Directed
|
||||
a <- b # Reverse directed
|
||||
a <-> b # Bidirectional
|
||||
a -- b # Undirected
|
||||
a -> b: "label" # Labeled edge
|
||||
a -> b -> c # Chained
|
||||
```
|
||||
|
||||
## Containers (nesting)
|
||||
|
||||
```d2
|
||||
infrastructure: {
|
||||
server: Simulation Server
|
||||
database: State Store {
|
||||
shape: cylinder
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Shapes
|
||||
|
||||
| Shape | Use for |
|
||||
|-------|---------|
|
||||
| `rectangle` | Default. Components, modules, generic. |
|
||||
| `hexagon` | Systems, services, major components. |
|
||||
| `cylinder` | Databases, storage, persistent state. |
|
||||
| `diamond` | Decisions, conditions, branch points. |
|
||||
| `oval` / `circle` | Start/end states, events. |
|
||||
| `cloud` | External systems, networks. |
|
||||
| `person` | Actors, users, NPCs. |
|
||||
| `queue` | Message queues, buffers. |
|
||||
| `page` | Documents, files. |
|
||||
| `package` | Packages, modules, crates. |
|
||||
| `sql_table` | Database tables, ECS component schemas. |
|
||||
| `class` | Class diagrams, ECS system definitions. |
|
||||
| `code` | Code blocks (set `language` property). |
|
||||
| `markdown` | Rich text blocks. |
|
||||
|
||||
## SQL Tables
|
||||
|
||||
```d2
|
||||
entity: {
|
||||
shape: sql_table
|
||||
id: u64 {constraint: primary_key}
|
||||
name: String
|
||||
position: Vec2
|
||||
faction_id: u64 {constraint: foreign_key}
|
||||
}
|
||||
```
|
||||
|
||||
## Class Diagrams
|
||||
|
||||
```d2
|
||||
perception_system: {
|
||||
shape: class
|
||||
+run(world: &mut World)
|
||||
-calculate_los(entity: Entity): HashSet<Entity>
|
||||
#update_knowledge(entity: Entity, seen: HashSet<Entity>)
|
||||
}
|
||||
```
|
||||
|
||||
## Sequence Diagrams
|
||||
|
||||
```d2
|
||||
shape: sequence_diagram
|
||||
client: Godot Client
|
||||
server: Rust Server
|
||||
|
||||
client -> server: TickRequest(delta)
|
||||
server -> server: run ECS systems
|
||||
server -> client: WorldState(entities)
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
```d2
|
||||
node: Label {
|
||||
style: {
|
||||
fill: "#2d3436"
|
||||
stroke: "#333340"
|
||||
stroke-width: 2
|
||||
stroke-dash: 5 # Dashed line
|
||||
opacity: 0.8
|
||||
font-size: 14
|
||||
font-color: "#c8d0e0"
|
||||
bold: true
|
||||
italic: false
|
||||
border-radius: 4
|
||||
shadow: true
|
||||
3d: true # Rectangles only
|
||||
multiple: true # Stacked appearance
|
||||
double-border: true # Rectangles/ovals only
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Edge styling
|
||||
|
||||
```d2
|
||||
a -> b: {
|
||||
style: {
|
||||
stroke: "#c8d8f0"
|
||||
stroke-width: 2
|
||||
stroke-dash: 5
|
||||
opacity: 0.8
|
||||
animated: true # Animated flow
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Variables
|
||||
|
||||
```d2
|
||||
vars: {
|
||||
color-bg: "#1a1e24"
|
||||
color-stroke: "#333340"
|
||||
color-text: "#c8d0e0"
|
||||
color-accent: "#c8d8f0"
|
||||
}
|
||||
|
||||
node: {
|
||||
style.fill: ${color-bg}
|
||||
style.stroke: ${color-stroke}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
```
|
||||
|
||||
## Direction
|
||||
|
||||
```d2
|
||||
direction: right # left-to-right (default for dagre)
|
||||
direction: down # top-to-bottom
|
||||
direction: up
|
||||
direction: left
|
||||
```
|
||||
|
||||
## Imports
|
||||
|
||||
```d2
|
||||
...@shared-defs.d2 # Spread import (inline all definitions)
|
||||
```
|
||||
|
||||
## Icons
|
||||
|
||||
```d2
|
||||
node: Label {
|
||||
icon: https://icons.terrastruct.com/essentials/time.svg
|
||||
}
|
||||
```
|
||||
|
||||
## Layers (multi-board)
|
||||
|
||||
```d2
|
||||
# Base diagram content here
|
||||
|
||||
layers: {
|
||||
detailed: {
|
||||
# More detailed view
|
||||
}
|
||||
simplified: {
|
||||
# Simplified overview
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Scenarios (animated transitions)
|
||||
|
||||
```d2
|
||||
# Base state
|
||||
|
||||
scenarios: {
|
||||
alert: {
|
||||
# Changes from base for alert state
|
||||
}
|
||||
combat: {
|
||||
# Changes from base for combat state
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Comments
|
||||
|
||||
```d2
|
||||
# This is a comment
|
||||
node: Label # Inline comment
|
||||
```
|
||||
|
||||
## Project Colors (from visual-grammar-v01.md)
|
||||
|
||||
| Constant | Hex | Usage |
|
||||
|----------|-----|-------|
|
||||
| Zone 1 floor | `#1a1e24` | Dark backgrounds |
|
||||
| Zone 1 wall | `#2a3040` | Component fill |
|
||||
| Outline standard | `#333340` | Borders, strokes |
|
||||
| Insert chrome | `#c8d0e0` | Text, labels |
|
||||
| Zone 1 fixture | `#c8d8f0` | Accents, highlights |
|
||||
@@ -0,0 +1,251 @@
|
||||
# Diagram Templates
|
||||
|
||||
Copy, adapt, and render. Each template uses project colors from visual-grammar-v01.md.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture Diagram
|
||||
|
||||
System components, relationships, communication channels.
|
||||
|
||||
**When to use:** IPC bridge, perception pipeline, chunk loading, ECS system layout, client-server architecture.
|
||||
|
||||
**Agents:** Tyre (system architecture), Qatux (architecture decision records).
|
||||
|
||||
```d2
|
||||
vars: {
|
||||
color-bg: "#2a3040"
|
||||
color-stroke: "#333340"
|
||||
color-text: "#c8d0e0"
|
||||
color-accent: "#c8d8f0"
|
||||
}
|
||||
|
||||
direction: right
|
||||
|
||||
client: Godot Client {
|
||||
shape: hexagon
|
||||
style.fill: ${color-bg}
|
||||
style.font-color: ${color-text}
|
||||
|
||||
rendering: Rendering {
|
||||
style.fill: ${color-bg}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
ui: UI Layer {
|
||||
style.fill: ${color-bg}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
bridge: IPC Bridge {
|
||||
style.fill: ${color-bg}
|
||||
style.font-color: ${color-text}
|
||||
style.stroke: ${color-accent}
|
||||
}
|
||||
}
|
||||
|
||||
server: Rust Server {
|
||||
shape: hexagon
|
||||
style.fill: ${color-bg}
|
||||
style.font-color: ${color-text}
|
||||
|
||||
ecs: bevy_ecs {
|
||||
style.fill: ${color-bg}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
perception: Perception {
|
||||
style.fill: ${color-bg}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
bridge: IPC Bridge {
|
||||
style.fill: ${color-bg}
|
||||
style.font-color: ${color-text}
|
||||
style.stroke: ${color-accent}
|
||||
}
|
||||
}
|
||||
|
||||
client.bridge -> server.bridge: "stdin/stdout" {
|
||||
style.stroke: ${color-accent}
|
||||
style.stroke-dash: 5
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Entity Relationship
|
||||
|
||||
Data schemas, ECS components, knowledge graph structure.
|
||||
|
||||
**When to use:** Database tables, component definitions, entity relationships, knowledge store schema.
|
||||
|
||||
**Agents:** Tyre (ECS component design), Qatux (schema documentation).
|
||||
|
||||
```d2
|
||||
entity: Entity {
|
||||
shape: sql_table
|
||||
id: u64 {constraint: primary_key}
|
||||
name: String
|
||||
faction_id: u64 {constraint: foreign_key}
|
||||
}
|
||||
|
||||
position: Position {
|
||||
shape: sql_table
|
||||
entity_id: u64 {constraint: foreign_key}
|
||||
x: f32
|
||||
y: f32
|
||||
chunk_id: u32
|
||||
}
|
||||
|
||||
knowledge: KnowledgeEntry {
|
||||
shape: sql_table
|
||||
observer_id: u64 {constraint: foreign_key}
|
||||
subject_id: u64 {constraint: foreign_key}
|
||||
fact_type: FactType
|
||||
confidence: f32
|
||||
last_seen_tick: u64
|
||||
}
|
||||
|
||||
entity.id -> position.entity_id
|
||||
entity.id -> knowledge.observer_id
|
||||
entity.id -> knowledge.subject_id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Sequence / Data Flow
|
||||
|
||||
Ordered interactions between systems over time.
|
||||
|
||||
**When to use:** IPC message flow, tick processing, perception update cycle, dialogue system exchanges.
|
||||
|
||||
**Agents:** Tyre (system interaction design), Qatux (protocol documentation).
|
||||
|
||||
```d2
|
||||
shape: sequence_diagram
|
||||
|
||||
client: Godot Client
|
||||
bridge: IPC Bridge
|
||||
server: Rust Server
|
||||
ecs: ECS Systems
|
||||
|
||||
client -> bridge: TickRequest(delta, input)
|
||||
bridge -> server: deserialize + dispatch
|
||||
server -> ecs: run_systems(delta)
|
||||
ecs -> ecs: perception, AI, physics
|
||||
ecs -> server: collect WorldState
|
||||
server -> bridge: serialize WorldState
|
||||
bridge -> client: WorldState(entities, events)
|
||||
client -> client: update rendering
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. State Machine
|
||||
|
||||
Entity states, transitions, conditions.
|
||||
|
||||
**When to use:** NPC behavior states, game mode transitions, dialogue state, investigation phases.
|
||||
|
||||
**Agents:** Tyre (behavior system design), Qatux (state documentation).
|
||||
|
||||
```d2
|
||||
vars: {
|
||||
color-state: "#2a3040"
|
||||
color-text: "#c8d0e0"
|
||||
color-edge: "#c8d8f0"
|
||||
color-decision: "#333340"
|
||||
}
|
||||
|
||||
idle: Idle {
|
||||
style.fill: ${color-state}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
|
||||
alert: Alert {
|
||||
style.fill: ${color-state}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
|
||||
investigate: Investigate {
|
||||
style.fill: ${color-state}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
|
||||
combat: Combat {
|
||||
style.fill: ${color-state}
|
||||
style.font-color: ${color-text}
|
||||
style.stroke: "#f0b840"
|
||||
}
|
||||
|
||||
flee: Flee {
|
||||
style.fill: ${color-state}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
|
||||
idle -> alert: "stimulus detected" { style.stroke: ${color-edge} }
|
||||
alert -> investigate: "stimulus confirmed" { style.stroke: ${color-edge} }
|
||||
alert -> idle: "timeout / stimulus lost" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
|
||||
investigate -> combat: "threat confirmed" { style.stroke: "#f0b840" }
|
||||
investigate -> idle: "nothing found" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
|
||||
combat -> flee: "health < threshold" { style.stroke: "#f0b840" }
|
||||
combat -> idle: "threat eliminated" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
|
||||
flee -> idle: "safe distance reached" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. UI Flow
|
||||
|
||||
Screen navigation, component hierarchy, interaction paths.
|
||||
|
||||
**When to use:** HUD layout relationships, menu navigation, dialogue flow, insert mode transitions.
|
||||
|
||||
**Agents:** Araminta (UI/visual design), Tyre (interface architecture), Qatux (UI documentation).
|
||||
|
||||
```d2
|
||||
vars: {
|
||||
color-screen: "#1a1e24"
|
||||
color-panel: "#2a3040"
|
||||
color-text: "#c8d0e0"
|
||||
color-nav: "#c8d8f0"
|
||||
}
|
||||
|
||||
gameplay: Gameplay {
|
||||
style.fill: ${color-screen}
|
||||
style.font-color: ${color-text}
|
||||
|
||||
hud: HUD {
|
||||
style.fill: ${color-panel}
|
||||
style.font-color: ${color-text}
|
||||
|
||||
minimap: Minimap
|
||||
monologue: Monologue Panel
|
||||
insert_display: Insert Display
|
||||
}
|
||||
|
||||
world: World View {
|
||||
style.fill: ${color-panel}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
}
|
||||
|
||||
pause: Pause Menu {
|
||||
style.fill: ${color-screen}
|
||||
style.font-color: ${color-text}
|
||||
|
||||
inventory: Inventory
|
||||
journal: Journal
|
||||
settings: Settings
|
||||
}
|
||||
|
||||
dialogue: Dialogue Mode {
|
||||
style.fill: ${color-screen}
|
||||
style.font-color: ${color-text}
|
||||
|
||||
speaker: Speaker Panel
|
||||
responses: Response List
|
||||
}
|
||||
|
||||
gameplay -> pause: "ESC" { style.stroke: ${color-nav} }
|
||||
pause -> gameplay: "ESC / Resume" { style.stroke: ${color-nav}; style.stroke-dash: 5 }
|
||||
gameplay -> dialogue: "interact with NPC" { style.stroke: ${color-nav} }
|
||||
dialogue -> gameplay: "end conversation" { style.stroke: ${color-nav}; style.stroke-dash: 5 }
|
||||
```
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR" && git rev-parse --show-toplevel)"
|
||||
RENDER="$SCRIPT_DIR/d2-render.sh"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [directory] [options]
|
||||
|
||||
Batch render all .d2 files in a directory. Skips files whose PNG is
|
||||
newer than the source unless --force is used.
|
||||
|
||||
Options:
|
||||
--dry-run List files that would be rendered
|
||||
--force Re-render even if SVG is up to date
|
||||
--theme N Override theme for all files
|
||||
|
||||
Examples:
|
||||
$(basename "$0") # All in docs/diagrams/
|
||||
$(basename "$0") docs/diagrams/architecture/ # One category
|
||||
$(basename "$0") --dry-run # Preview
|
||||
$(basename "$0") --force # Re-render everything
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
DIR="$REPO_ROOT/docs/diagrams"
|
||||
DRY_RUN=false
|
||||
FORCE=false
|
||||
EXTRA_ARGS=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
--force) FORCE=true; shift ;;
|
||||
--theme) EXTRA_ARGS+=(--theme "$2"); shift 2 ;;
|
||||
--help|-h) usage ;;
|
||||
*)
|
||||
if [[ -d "$1" ]] || [[ -d "$REPO_ROOT/$1" ]]; then
|
||||
DIR="$1"
|
||||
[[ "$DIR" != /* ]] && DIR="$REPO_ROOT/$DIR"
|
||||
else
|
||||
echo "Unknown option or directory: $1" >&2; exit 1
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ ! -d "$DIR" ]] && { echo "ERROR: Directory not found: $DIR" >&2; exit 1; }
|
||||
|
||||
RENDERED=0
|
||||
SKIPPED=0
|
||||
FAILED=0
|
||||
|
||||
while IFS= read -r -d '' d2_file; do
|
||||
png_file="${d2_file%.d2}.png"
|
||||
|
||||
# Skip if PNG is newer than source (unless --force)
|
||||
if [[ "$FORCE" != true ]] && [[ -f "$png_file" ]] && [[ "$png_file" -nt "$d2_file" ]]; then
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
rel_path="${d2_file#"$REPO_ROOT/"}"
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
echo "Would render: $rel_path"
|
||||
RENDERED=$((RENDERED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if "$RENDER" "$d2_file" "${EXTRA_ARGS[@]+"${EXTRA_ARGS[@]}"}"; then
|
||||
RENDERED=$((RENDERED + 1))
|
||||
else
|
||||
echo "FAILED: $rel_path" >&2
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
done < <(find "$DIR" -name '*.d2' -print0 | sort -z)
|
||||
|
||||
echo ""
|
||||
echo "Batch complete: $RENDERED rendered, $SKIPPED skipped (up to date), $FAILED failed"
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
D2="/home/linuxbrew/.linuxbrew/bin/d2"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR" && git rev-parse --show-toplevel)"
|
||||
|
||||
DEFAULT_THEME=200
|
||||
DEFAULT_LAYOUT="dagre"
|
||||
DEFAULT_PAD=100
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [validate|fmt] <file.d2> [options]
|
||||
|
||||
Render a .d2 file to PNG with project defaults (theme $DEFAULT_THEME, $DEFAULT_LAYOUT layout).
|
||||
|
||||
Commands:
|
||||
validate <file> Check syntax without rendering
|
||||
fmt <file> Auto-format in place
|
||||
|
||||
Options:
|
||||
--theme N Override theme (default: $DEFAULT_THEME)
|
||||
--layout NAME Override layout engine (default: $DEFAULT_LAYOUT)
|
||||
--sketch Enable hand-drawn sketch mode
|
||||
--output PATH Override output path (default: input with .png extension)
|
||||
--svg Render to SVG instead of PNG
|
||||
|
||||
Examples:
|
||||
$(basename "$0") docs/diagrams/architecture/ipc-bridge.d2
|
||||
$(basename "$0") validate docs/diagrams/architecture/ipc-bridge.d2
|
||||
$(basename "$0") docs/diagrams/architecture/ipc-bridge.d2 --sketch --theme 0
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ $# -lt 1 ]] && usage
|
||||
|
||||
# Parse subcommand
|
||||
SUBCMD=""
|
||||
case "$1" in
|
||||
validate|fmt)
|
||||
SUBCMD="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
|
||||
[[ $# -lt 1 ]] && usage
|
||||
|
||||
INPUT="$1"
|
||||
shift
|
||||
|
||||
# Resolve to absolute path
|
||||
[[ "$INPUT" != /* ]] && INPUT="$REPO_ROOT/$INPUT"
|
||||
|
||||
[[ ! -f "$INPUT" ]] && { echo "ERROR: File not found: $INPUT" >&2; exit 1; }
|
||||
|
||||
# Handle subcommands
|
||||
if [[ -n "$SUBCMD" ]]; then
|
||||
"$D2" "$SUBCMD" "$INPUT"
|
||||
echo "OK: $SUBCMD $INPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Parse render options
|
||||
THEME="$DEFAULT_THEME"
|
||||
LAYOUT="$DEFAULT_LAYOUT"
|
||||
SKETCH=""
|
||||
OUTPUT=""
|
||||
FORMAT="png"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--theme) THEME="$2"; shift 2 ;;
|
||||
--layout) LAYOUT="$2"; shift 2 ;;
|
||||
--sketch) SKETCH="-s"; shift ;;
|
||||
--output) OUTPUT="$2"; shift 2 ;;
|
||||
--svg) FORMAT="svg"; shift ;;
|
||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Derive output path
|
||||
if [[ -z "$OUTPUT" ]]; then
|
||||
OUTPUT="${INPUT%.d2}.$FORMAT"
|
||||
fi
|
||||
|
||||
# Render
|
||||
"$D2" -t "$THEME" -l "$LAYOUT" --pad "$DEFAULT_PAD" $SKETCH "$INPUT" "$OUTPUT"
|
||||
|
||||
SIZE=$(stat --printf="%s" "$OUTPUT" 2>/dev/null || stat -f%z "$OUTPUT" 2>/dev/null)
|
||||
echo "Rendered: $OUTPUT ($(( SIZE / 1024 ))KB)"
|
||||
@@ -0,0 +1,197 @@
|
||||
---
|
||||
name: frame0-wireframe
|
||||
description: >
|
||||
Create and export UI wireframes using Frame0 (local desktop wireframing
|
||||
app with HTTP API). Use when the user says "create wireframe", "wireframe
|
||||
this", "mock up the UI", "draw a screen", "UI layout", "wireframe the HUD",
|
||||
"Frame0", "frame0", "export wireframe", or invokes /frame0-wireframe.
|
||||
Wireframes are authored as local JSON files (source of truth) and synced
|
||||
to Frame0 for rendering and export. Requires Frame0 to be running locally.
|
||||
---
|
||||
|
||||
# Frame0 Wireframe Generation
|
||||
|
||||
Create UI wireframes as JSON files, sync them to Frame0 for rendering, and
|
||||
export as PNG. Local JSON is the source of truth — Frame0 is the renderer.
|
||||
|
||||
**Frame0 is a renderer, not a workspace.** Treat it as disposable output.
|
||||
Push freely, delete test pages, keep it clean. Never pull from Frame0 unless
|
||||
the user explicitly says they have made edits in Frame0 and want to import
|
||||
them. The pull workflow exists for that case only — do not use it proactively.
|
||||
|
||||
**Prerequisite:** Frame0 desktop app must be running. If not available,
|
||||
stop and inform the user. Point to `references/setup-guide.md`.
|
||||
|
||||
## Health Check
|
||||
|
||||
Always check first:
|
||||
|
||||
```bash
|
||||
.claude/skills/frame0-wireframe/scripts/frame0-cmd.sh health
|
||||
```
|
||||
|
||||
## Core Workflow
|
||||
|
||||
1. **Health check** — verify Frame0 is running
|
||||
2. **Write wireframe JSON** — to `docs/design/wireframes/{category}/{name}.json`
|
||||
3. **Push to Frame0** — `frame0-sync.py push <file.json>`
|
||||
4. **Export PNG** — `frame0-sync.py export <file.json> <output.png>`
|
||||
5. **Clean up** — delete test/scratch pages from Frame0 when done
|
||||
|
||||
### Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `scripts/frame0-sync.py` | **Primary.** Push/pull/export wireframes between JSON and Frame0 |
|
||||
| `scripts/frame0-cmd.sh` | Low-level API wrapper for ad-hoc commands |
|
||||
|
||||
## Wireframe JSON Format
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Dialogue Box",
|
||||
"shapes": {
|
||||
"panel": {
|
||||
"type": "Rectangle",
|
||||
"left": 170, "top": 500, "width": 800, "height": 260,
|
||||
"fillColor": "#1a1e24",
|
||||
"strokeColor": "#333340",
|
||||
"corners": [8, 8, 8, 8]
|
||||
},
|
||||
"speaker": {
|
||||
"type": "Text",
|
||||
"parent": "panel",
|
||||
"left": 190, "top": 520,
|
||||
"text": "LERA KONSTANTIN",
|
||||
"fontColor": "#c8d0e0",
|
||||
"fontSize": 16
|
||||
},
|
||||
"btn-ask": {
|
||||
"type": "Rectangle",
|
||||
"parent": "panel",
|
||||
"left": 190, "top": 670, "width": 370, "height": 30,
|
||||
"fillColor": "#2a3040",
|
||||
"strokeColor": "#c8d8f0",
|
||||
"corners": [4, 4, 4, 4]
|
||||
}
|
||||
},
|
||||
"connectors": {
|
||||
"flow-1": {
|
||||
"tailId": "panel",
|
||||
"headId": "btn-ask",
|
||||
"strokeColor": "#c8d8f0"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Key rules
|
||||
|
||||
- **Shape IDs are stable local IDs** you control (e.g. `"panel"`, `"btn-ask"`)
|
||||
- **`parent`** references another local shape ID for nesting
|
||||
- **`type`** uses create-API names: `Rectangle`, `Ellipse`, `Text`, `Line`
|
||||
- **Colors** can be hex (`#2a3040`) or Frame0 theme tokens (`$slate6`)
|
||||
- After a pull, Frame0 returns its native vocabulary (`Box` for Rectangle,
|
||||
theme tokens for colors). The sync script handles the mapping transparently.
|
||||
- The `.idmap.json` mapping file (gitignored) tracks local ID ↔ Frame0 ID
|
||||
|
||||
### Sync commands
|
||||
|
||||
```bash
|
||||
SYNC=".claude/skills/frame0-wireframe/scripts/frame0-sync.py"
|
||||
|
||||
# Push local JSON to Frame0 (clears page, recreates all shapes)
|
||||
$SYNC push docs/design/wireframes/dialogue/dialogue-box.json
|
||||
|
||||
# Pull Frame0 page back to local JSON (preserves local IDs via mapping)
|
||||
$SYNC pull "Dialogue Box" docs/design/wireframes/dialogue/dialogue-box.json
|
||||
|
||||
# Push + export as PNG in one step
|
||||
$SYNC export docs/design/wireframes/dialogue/dialogue-box.json \
|
||||
docs/design/wireframes/dialogue/dialogue-box.png
|
||||
```
|
||||
|
||||
### Batch export
|
||||
|
||||
Use this when exporting multiple wireframes. It runs as a single Bash call,
|
||||
avoiding repeated permission prompts.
|
||||
|
||||
```bash
|
||||
BATCH=".claude/skills/frame0-wireframe/scripts/frame0-export-batch.sh"
|
||||
|
||||
# Dry run first — shows full manifest, no Frame0 calls
|
||||
$BATCH --dry-run
|
||||
|
||||
# Export everything (skips PNGs already newer than their JSON)
|
||||
$BATCH
|
||||
|
||||
# Export one category only
|
||||
$BATCH --category dialogue
|
||||
|
||||
# Force re-export of everything
|
||||
$BATCH --force
|
||||
```
|
||||
|
||||
**Always dry-run first, then get approval before running the live export.**
|
||||
|
||||
## Low-Level Commands
|
||||
|
||||
For ad-hoc operations or exec calls not covered by sync:
|
||||
|
||||
```bash
|
||||
CMD=".claude/skills/frame0-wireframe/scripts/frame0-cmd.sh"
|
||||
$CMD health
|
||||
$CMD list-pages
|
||||
$CMD current-page
|
||||
$CMD get-page <page-id>
|
||||
$CMD create-shape Rectangle '{"name":"btn","left":100,"top":100,"width":120,"height":36}'
|
||||
$CMD create-connector <tail-id> <head-id>
|
||||
$CMD move <shape-id> <dx> <dy>
|
||||
$CMD export --format image/png
|
||||
$CMD exec "view:fit-to-screen" '{}'
|
||||
```
|
||||
|
||||
If you find yourself using `exec` for the same command repeatedly, flag it as
|
||||
a candidate for a proper wrapper in `frame0-cmd.sh`.
|
||||
|
||||
## Project Styling Defaults
|
||||
|
||||
Colors from `docs/design/visual-grammar-v01.md`:
|
||||
|
||||
| Role | Hex | Frame0 token |
|
||||
|------|-----|-------------|
|
||||
| Background | `#1a1e24` | `$sage3` |
|
||||
| Stroke | `#333340` | `$slate6` |
|
||||
| Fill | `#2a3040` | `$slate5` |
|
||||
| Text | `#c8d0e0` | `$mint12` |
|
||||
| Accent | `#c8d8f0` | `$blue12` |
|
||||
|
||||
Use hex when authoring new wireframes. Frame0 maps them to theme tokens on push.
|
||||
|
||||
## Output Convention
|
||||
|
||||
```
|
||||
docs/design/wireframes/
|
||||
hud/ # HUD layout wireframes
|
||||
menus/ # Menu screen wireframes
|
||||
dialogue/ # Dialogue box wireframes
|
||||
insert/ # Neural insert wireframes
|
||||
```
|
||||
|
||||
Both `.json` source and `.png` exports are tracked in git.
|
||||
`.idmap.json` mapping files are gitignored.
|
||||
|
||||
## Agent Guidance
|
||||
|
||||
- **Araminta** — Primary user. Full wireframe creation, layout iteration,
|
||||
visual consistency. Uses all component library patterns.
|
||||
- **Tyre** — Interface architecture wireframes. System boundary diagrams.
|
||||
- **Qatux** — Export wireframes for UI decision records and documentation.
|
||||
|
||||
## References
|
||||
|
||||
- `references/component-library.md` — Pre-built JSON wireframe templates
|
||||
(HUD, dialogue, menus, modals, lists, inventory). Copy and adapt.
|
||||
- `references/api-reference.md` — Frame0 HTTP API command reference and
|
||||
type mappings. Read for low-level control.
|
||||
- `references/setup-guide.md` — Frame0 installation and startup for Fedora.
|
||||
@@ -0,0 +1,241 @@
|
||||
# Frame0 HTTP API Reference
|
||||
|
||||
Frame0 exposes a local HTTP API when the desktop app is running.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
POST http://localhost:{port}/execute_command
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Default port: **58320** (override via `FRAME0_PORT` env var or `--port` flag).
|
||||
|
||||
## Request / Response
|
||||
|
||||
```json
|
||||
{"command": "namespace:action", "args": { ... }}
|
||||
```
|
||||
|
||||
```json
|
||||
{"success": true, "data": { ... }}
|
||||
{"success": false, "error": "description"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Type Mapping
|
||||
|
||||
Frame0 uses different type names for create vs get:
|
||||
|
||||
| Create API (`type`) | Get API (internal) | Description |
|
||||
|--------------------|--------------------|-------------|
|
||||
| `Rectangle` | `Box` | Rectangle with optional corners |
|
||||
| `Ellipse` | `Ellipse` | Circle/ellipse |
|
||||
| `Text` | `Text` | Text label |
|
||||
| `Line` | `Line` | Line/polyline |
|
||||
| `Frame` | `Frame` | Container from library |
|
||||
| `Freehand` | `Freehand` | Freehand drawing |
|
||||
| `Highlighter` | `Highlighter` | Highlighter stroke |
|
||||
|
||||
The sync script handles this mapping transparently.
|
||||
|
||||
## Color Tokens
|
||||
|
||||
Frame0 maps hex colors to theme tokens on creation (`convertColors: true`):
|
||||
|
||||
| Hex | Token | Role |
|
||||
|-----|-------|------|
|
||||
| `#1a1e24` | `$sage3` | Background |
|
||||
| `#2a3040` | `$slate5` | Fill |
|
||||
| `#333340` | `$slate6` | Stroke |
|
||||
| `#c8d0e0` | `$mint12` | Text |
|
||||
| `#c8d8f0` | `$blue12` | Accent |
|
||||
|
||||
Both hex and token strings work in the API. Tokens are preserved on round-trip.
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
### shape:create-shape
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "shape:create-shape",
|
||||
"args": {
|
||||
"type": "Rectangle",
|
||||
"shapeProps": {
|
||||
"name": "my-button",
|
||||
"left": 100, "top": 200, "width": 120, "height": 36,
|
||||
"fillColor": "#2a3040",
|
||||
"strokeColor": "#c8d8f0",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"parentId": "optional-parent-shape-id",
|
||||
"convertColors": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Returns: shape ID (string).
|
||||
|
||||
### shape:get-shape
|
||||
|
||||
```json
|
||||
{"command": "shape:get-shape", "args": {"shapeId": "id"}}
|
||||
```
|
||||
|
||||
### shape:update-shape
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "shape:update-shape",
|
||||
"args": {
|
||||
"shapeId": "id",
|
||||
"shapeProps": {"fillColor": "#1a1e24", "text": "Updated"},
|
||||
"convertColors": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### shape:move
|
||||
|
||||
```json
|
||||
{"command": "shape:move", "args": {"shapeId": "id", "dx": 50, "dy": -20}}
|
||||
```
|
||||
|
||||
### shape:create-connector
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "shape:create-connector",
|
||||
"args": {
|
||||
"tailId": "source-id",
|
||||
"headId": "target-id",
|
||||
"shapeProps": {"strokeColor": "#c8d8f0"},
|
||||
"convertColors": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### shape:create-icon
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "shape:create-icon",
|
||||
"args": {
|
||||
"iconName": "search",
|
||||
"shapeProps": {"left": 100, "top": 100, "width": 24, "height": 24}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### shape:get-available-icons
|
||||
|
||||
```json
|
||||
{"command": "shape:get-available-icons", "args": {}}
|
||||
```
|
||||
|
||||
### shape:group / shape:ungroup
|
||||
|
||||
```json
|
||||
{"command": "shape:group", "args": {"shapeIdArray": ["id1", "id2"]}}
|
||||
{"command": "shape:ungroup", "args": {"shapeIdArray": ["group-id"]}}
|
||||
```
|
||||
|
||||
### edit:delete / edit:duplicate
|
||||
|
||||
```json
|
||||
{"command": "edit:delete", "args": {"shapeIdArray": ["id1", "id2"]}}
|
||||
{"command": "edit:duplicate", "args": {"shapeIdArray": ["id"], "dx": 20, "dy": 0}}
|
||||
```
|
||||
|
||||
### page:add
|
||||
|
||||
```json
|
||||
{"command": "page:add", "args": {"pageProps": {"name": "Page Name"}}}
|
||||
```
|
||||
|
||||
Returns: `{id, type, name}`.
|
||||
|
||||
### page:get
|
||||
|
||||
```json
|
||||
{"command": "page:get", "args": {"pageId": "id", "exportShapes": true}}
|
||||
```
|
||||
|
||||
### page:get-current-page
|
||||
|
||||
```json
|
||||
{"command": "page:get-current-page", "args": {}}
|
||||
```
|
||||
|
||||
Returns: page ID string.
|
||||
|
||||
### page:set-current-page
|
||||
|
||||
```json
|
||||
{"command": "page:set-current-page", "args": {"pageId": "id"}}
|
||||
```
|
||||
|
||||
### doc:get (list all pages)
|
||||
|
||||
```json
|
||||
{"command": "doc:get", "args": {"exportPages": true, "exportShapes": false}}
|
||||
```
|
||||
|
||||
### page:delete
|
||||
|
||||
```json
|
||||
{"command": "page:delete", "args": {"pageId": "id"}}
|
||||
```
|
||||
|
||||
### file:export-image
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "file:export-image",
|
||||
"args": {
|
||||
"pageId": "optional-page-id",
|
||||
"format": "image/png",
|
||||
"fillBackground": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Formats: `image/png`, `image/jpeg`, `image/webp`, `image/svg+xml`.
|
||||
Returns: base64-encoded image data.
|
||||
|
||||
### view:fit-to-screen
|
||||
|
||||
```json
|
||||
{"command": "view:fit-to-screen", "args": {}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shape Properties
|
||||
|
||||
| Property | Type | Notes |
|
||||
|----------|------|-------|
|
||||
| `name` | string | Shape identifier/label |
|
||||
| `left` | number | X position (origin: top-left) |
|
||||
| `top` | number | Y position |
|
||||
| `width` | number | Width in pixels |
|
||||
| `height` | number | Height in pixels |
|
||||
| `fillColor` | string | Hex or `$token` |
|
||||
| `strokeColor` | string | Hex or `$token` |
|
||||
| `strokeWidth` | number | Border width |
|
||||
| `fontColor` | string | Text color (hex or `$token`) |
|
||||
| `fontSize` | number | Font size in pixels |
|
||||
| `fontFamily` | string | Font name (Frame0 default: `Loranthus`) |
|
||||
| `text` | string | Text content |
|
||||
| `wordWrap` | boolean | Enable word wrapping |
|
||||
| `corners` | number[4] | Border radius [TL, TR, BR, BL] |
|
||||
| `roughness` | number | Sketch roughness (Frame0 default: 1) |
|
||||
| `constraints` | array | Auto-sizing constraints |
|
||||
| `horzAlign` | string | Horizontal text alignment |
|
||||
| `vertAlign` | string | Vertical text alignment |
|
||||
| `fillStyle` | string | Fill style (`none` for transparent) |
|
||||
| `path` | array | Coordinate pairs for lines |
|
||||
@@ -0,0 +1,467 @@
|
||||
# Component Library
|
||||
|
||||
Pre-built wireframe JSON templates for The Settled Reach UI. Copy the JSON,
|
||||
adapt positions/sizes, save to `docs/design/wireframes/{category}/`, and push.
|
||||
|
||||
**Viewport:** 1140x780 (Godot project settings)
|
||||
**Grid unit:** 8px
|
||||
**Min touch target:** 36px height
|
||||
**Font sizes:** 12 (label), 14 (body), 16 (subtitle), 18 (heading), 24 (title)
|
||||
|
||||
---
|
||||
|
||||
## 1. HUD Layout
|
||||
|
||||
Main gameplay overlay. Minimap top-right, monologue bottom-center,
|
||||
insert display bottom-left, action hints bottom-right.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "HUD Layout",
|
||||
"shapes": {
|
||||
"minimap": {
|
||||
"type": "Rectangle",
|
||||
"left": 880, "top": 20, "width": 240, "height": 240,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"minimap-label": {
|
||||
"type": "Text",
|
||||
"parent": "minimap",
|
||||
"left": 890, "top": 30,
|
||||
"text": "Minimap",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14
|
||||
},
|
||||
"monologue": {
|
||||
"type": "Rectangle",
|
||||
"left": 300, "top": 680, "width": 520, "height": 80,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"monologue-text": {
|
||||
"type": "Text",
|
||||
"parent": "monologue",
|
||||
"left": 310, "top": 700, "width": 500,
|
||||
"text": "Internal monologue text appears here...",
|
||||
"fontColor": "#c8d0e0", "fontSize": 13, "wordWrap": true
|
||||
},
|
||||
"insert": {
|
||||
"type": "Rectangle",
|
||||
"left": 20, "top": 600, "width": 260, "height": 160,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"insert-label": {
|
||||
"type": "Text",
|
||||
"parent": "insert",
|
||||
"left": 30, "top": 620,
|
||||
"text": "Neural Insert Data",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14
|
||||
},
|
||||
"hints": {
|
||||
"type": "Rectangle",
|
||||
"left": 880, "top": 700, "width": 240, "height": 60,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"hints-label": {
|
||||
"type": "Text",
|
||||
"parent": "hints",
|
||||
"left": 890, "top": 720,
|
||||
"text": "[E] Interact [TAB] Insert",
|
||||
"fontColor": "#c8d0e0", "fontSize": 12
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Dialogue Box
|
||||
|
||||
Speaker panel with response options. Anchored bottom-center during dialogue mode.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Dialogue Box",
|
||||
"shapes": {
|
||||
"panel": {
|
||||
"type": "Rectangle",
|
||||
"left": 170, "top": 500, "width": 800, "height": 260,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||
"corners": [8, 8, 8, 8]
|
||||
},
|
||||
"speaker": {
|
||||
"type": "Text",
|
||||
"parent": "panel",
|
||||
"left": 190, "top": 520,
|
||||
"text": "LERA KONSTANTIN",
|
||||
"fontColor": "#c8d0e0", "fontSize": 16
|
||||
},
|
||||
"text-area": {
|
||||
"type": "Rectangle",
|
||||
"parent": "panel",
|
||||
"left": 190, "top": 550, "width": 760, "height": 100,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"dialogue-text": {
|
||||
"type": "Text",
|
||||
"parent": "text-area",
|
||||
"left": 200, "top": 560, "width": 740,
|
||||
"text": "You look like you could use a drink. First time on the station?",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14, "wordWrap": true
|
||||
},
|
||||
"btn-option1": {
|
||||
"type": "Rectangle",
|
||||
"parent": "panel",
|
||||
"left": 190, "top": 670, "width": 370, "height": 30,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-option1-label": {
|
||||
"type": "Text",
|
||||
"parent": "btn-option1",
|
||||
"left": 200, "top": 674,
|
||||
"text": "[1] Ask about the station",
|
||||
"fontColor": "#c8d8f0", "fontSize": 12
|
||||
},
|
||||
"btn-option2": {
|
||||
"type": "Rectangle",
|
||||
"parent": "panel",
|
||||
"left": 190, "top": 710, "width": 370, "height": 30,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-option2-label": {
|
||||
"type": "Text",
|
||||
"parent": "btn-option2",
|
||||
"left": 200, "top": 714,
|
||||
"text": "[2] Ask about recent events",
|
||||
"fontColor": "#c8d8f0", "fontSize": 12
|
||||
},
|
||||
"btn-leave": {
|
||||
"type": "Rectangle",
|
||||
"parent": "panel",
|
||||
"left": 580, "top": 670, "width": 180, "height": 30,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-leave-label": {
|
||||
"type": "Text",
|
||||
"parent": "btn-leave",
|
||||
"left": 590, "top": 674,
|
||||
"text": "[3] Leave",
|
||||
"fontColor": "#c8d8f0", "fontSize": 12
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Menu Screen
|
||||
|
||||
Full-screen menu with sidebar navigation and content area.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Pause Menu",
|
||||
"shapes": {
|
||||
"bg": {
|
||||
"type": "Rectangle",
|
||||
"left": 0, "top": 0, "width": 1140, "height": 780,
|
||||
"fillColor": "#1a1e24"
|
||||
},
|
||||
"nav": {
|
||||
"type": "Rectangle",
|
||||
"parent": "bg",
|
||||
"left": 20, "top": 20, "width": 200, "height": 740,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-inventory": {
|
||||
"type": "Rectangle", "parent": "nav",
|
||||
"left": 30, "top": 40, "width": 180, "height": 36,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-inventory-label": {
|
||||
"type": "Text", "parent": "btn-inventory",
|
||||
"left": 40, "top": 48, "text": "Inventory",
|
||||
"fontColor": "#c8d8f0", "fontSize": 14
|
||||
},
|
||||
"btn-journal": {
|
||||
"type": "Rectangle", "parent": "nav",
|
||||
"left": 30, "top": 86, "width": 180, "height": 36,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-journal-label": {
|
||||
"type": "Text", "parent": "btn-journal",
|
||||
"left": 40, "top": 94, "text": "Journal",
|
||||
"fontColor": "#c8d8f0", "fontSize": 14
|
||||
},
|
||||
"btn-map": {
|
||||
"type": "Rectangle", "parent": "nav",
|
||||
"left": 30, "top": 132, "width": 180, "height": 36,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-map-label": {
|
||||
"type": "Text", "parent": "btn-map",
|
||||
"left": 40, "top": 140, "text": "Map",
|
||||
"fontColor": "#c8d8f0", "fontSize": 14
|
||||
},
|
||||
"btn-settings": {
|
||||
"type": "Rectangle", "parent": "nav",
|
||||
"left": 30, "top": 178, "width": 180, "height": 36,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-settings-label": {
|
||||
"type": "Text", "parent": "btn-settings",
|
||||
"left": 40, "top": 186, "text": "Settings",
|
||||
"fontColor": "#c8d8f0", "fontSize": 14
|
||||
},
|
||||
"btn-resume": {
|
||||
"type": "Rectangle", "parent": "nav",
|
||||
"left": 30, "top": 720, "width": 180, "height": 36,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-resume-label": {
|
||||
"type": "Text", "parent": "btn-resume",
|
||||
"left": 40, "top": 728, "text": "Resume",
|
||||
"fontColor": "#c8d8f0", "fontSize": 14
|
||||
},
|
||||
"content": {
|
||||
"type": "Rectangle",
|
||||
"parent": "bg",
|
||||
"left": 240, "top": 20, "width": 880, "height": 740,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"content-label": {
|
||||
"type": "Text", "parent": "content",
|
||||
"left": 260, "top": 40,
|
||||
"text": "Content area",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Modal Dialog
|
||||
|
||||
Centered overlay for confirmations, alerts, choices.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Modal Dialog",
|
||||
"shapes": {
|
||||
"overlay": {
|
||||
"type": "Rectangle",
|
||||
"left": 0, "top": 0, "width": 1140, "height": 780,
|
||||
"fillColor": "#0a0c10"
|
||||
},
|
||||
"modal": {
|
||||
"type": "Rectangle",
|
||||
"parent": "overlay",
|
||||
"left": 320, "top": 240, "width": 500, "height": 300,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||
"corners": [8, 8, 8, 8]
|
||||
},
|
||||
"title": {
|
||||
"type": "Text", "parent": "modal",
|
||||
"left": 340, "top": 260,
|
||||
"text": "Confirm Action",
|
||||
"fontColor": "#c8d0e0", "fontSize": 18
|
||||
},
|
||||
"divider": {
|
||||
"type": "Line", "parent": "modal",
|
||||
"left": 340, "top": 290, "width": 460, "height": 0,
|
||||
"strokeColor": "#333340"
|
||||
},
|
||||
"body-1": {
|
||||
"type": "Text", "parent": "modal",
|
||||
"left": 340, "top": 310,
|
||||
"text": "Are you sure you want to proceed?",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14
|
||||
},
|
||||
"body-2": {
|
||||
"type": "Text", "parent": "modal",
|
||||
"left": 340, "top": 340,
|
||||
"text": "This action cannot be undone.",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14
|
||||
},
|
||||
"btn-cancel": {
|
||||
"type": "Rectangle", "parent": "modal",
|
||||
"left": 480, "top": 480, "width": 120, "height": 36,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-cancel-label": {
|
||||
"type": "Text", "parent": "btn-cancel",
|
||||
"left": 510, "top": 488,
|
||||
"text": "Cancel",
|
||||
"fontColor": "#c8d8f0", "fontSize": 14
|
||||
},
|
||||
"btn-confirm": {
|
||||
"type": "Rectangle", "parent": "modal",
|
||||
"left": 620, "top": 480, "width": 120, "height": 36,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-confirm-label": {
|
||||
"type": "Text", "parent": "btn-confirm",
|
||||
"left": 645, "top": 488,
|
||||
"text": "Confirm",
|
||||
"fontColor": "#c8d8f0", "fontSize": 14
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. List View
|
||||
|
||||
Scrollable list with item selection and detail panel.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "List View",
|
||||
"shapes": {
|
||||
"list-panel": {
|
||||
"type": "Rectangle",
|
||||
"left": 20, "top": 20, "width": 400, "height": 740,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"item-1": {
|
||||
"type": "Rectangle", "parent": "list-panel",
|
||||
"left": 30, "top": 30, "width": 380, "height": 40,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"item-1-label": {
|
||||
"type": "Text", "parent": "item-1",
|
||||
"left": 40, "top": 38, "text": "Item Alpha",
|
||||
"fontColor": "#c8d8f0", "fontSize": 14
|
||||
},
|
||||
"item-2": {
|
||||
"type": "Rectangle", "parent": "list-panel",
|
||||
"left": 30, "top": 80, "width": 380, "height": 40,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"item-2-label": {
|
||||
"type": "Text", "parent": "item-2",
|
||||
"left": 40, "top": 88, "text": "Item Beta",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14
|
||||
},
|
||||
"item-3": {
|
||||
"type": "Rectangle", "parent": "list-panel",
|
||||
"left": 30, "top": 130, "width": 380, "height": 40,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"item-3-label": {
|
||||
"type": "Text", "parent": "item-3",
|
||||
"left": 40, "top": 138, "text": "Item Gamma",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14
|
||||
},
|
||||
"detail-panel": {
|
||||
"type": "Rectangle",
|
||||
"left": 440, "top": 20, "width": 680, "height": 740,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"detail-title": {
|
||||
"type": "Text", "parent": "detail-panel",
|
||||
"left": 460, "top": 40,
|
||||
"text": "Item Alpha",
|
||||
"fontColor": "#c8d0e0", "fontSize": 18
|
||||
},
|
||||
"detail-body": {
|
||||
"type": "Text", "parent": "detail-panel",
|
||||
"left": 460, "top": 80, "width": 640,
|
||||
"text": "Description and properties appear here.",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14, "wordWrap": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Inventory Grid
|
||||
|
||||
Grid of cells for item management.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Inventory Grid",
|
||||
"shapes": {
|
||||
"panel": {
|
||||
"type": "Rectangle",
|
||||
"left": 240, "top": 100, "width": 660, "height": 580,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8]
|
||||
},
|
||||
"title": {
|
||||
"type": "Text", "parent": "panel",
|
||||
"left": 260, "top": 120,
|
||||
"text": "INVENTORY",
|
||||
"fontColor": "#c8d0e0", "fontSize": 18
|
||||
},
|
||||
"cell-1-1": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 260, "top": 160, "width": 64, "height": 64,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"cell-1-2": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 332, "top": 160, "width": 64, "height": 64,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"cell-1-3": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 404, "top": 160, "width": 64, "height": 64,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"cell-1-4": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 476, "top": 160, "width": 64, "height": 64,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"cell-2-1": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 260, "top": 232, "width": 64, "height": 64,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"cell-2-2": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 332, "top": 232, "width": 64, "height": 64,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"cell-2-3": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 404, "top": 232, "width": 64, "height": 64,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"cell-2-4": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 476, "top": 232, "width": 64, "height": 64,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"detail": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 580, "top": 160, "width": 300, "height": 400,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"detail-title": {
|
||||
"type": "Text", "parent": "detail",
|
||||
"left": 600, "top": 180,
|
||||
"text": "Selected Item Name",
|
||||
"fontColor": "#c8d0e0", "fontSize": 16
|
||||
},
|
||||
"detail-body": {
|
||||
"type": "Text", "parent": "detail",
|
||||
"left": 600, "top": 210, "width": 260,
|
||||
"text": "Item description and stats",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14, "wordWrap": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,53 @@
|
||||
# Frame0 Setup Guide
|
||||
|
||||
## Installation (Fedora)
|
||||
|
||||
Download from https://frame0.app/download and install the RPM:
|
||||
|
||||
```bash
|
||||
sudo dnf install ./frame0-*.x86_64.rpm
|
||||
```
|
||||
|
||||
Requires: Fedora 40 or later (x86_64).
|
||||
|
||||
## Starting Frame0
|
||||
|
||||
Launch the desktop application:
|
||||
|
||||
```bash
|
||||
frame0 &
|
||||
```
|
||||
|
||||
Frame0 exposes an HTTP API at `localhost:58320` when running.
|
||||
|
||||
## Verify API Access
|
||||
|
||||
```bash
|
||||
.claude/skills/frame0-wireframe/scripts/frame0-cmd.sh health
|
||||
```
|
||||
|
||||
Expected output: `Frame0 is running on port 58320`
|
||||
|
||||
## Port Configuration
|
||||
|
||||
Default port: **58320**
|
||||
|
||||
To use a different port, set the environment variable:
|
||||
|
||||
```bash
|
||||
export FRAME0_PORT=58321
|
||||
```
|
||||
|
||||
Or pass `--port` to any script:
|
||||
|
||||
```bash
|
||||
.claude/skills/frame0-wireframe/scripts/frame0-cmd.sh --port 58321 health
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| "Connection refused" | Frame0 not running | Start the desktop app |
|
||||
| "Port in use" | Another instance running | Close duplicate or use different port |
|
||||
| Script hangs | API unresponsive | Restart Frame0 |
|
||||
@@ -0,0 +1,253 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
PORT="${FRAME0_PORT:-58320}"
|
||||
ENDPOINT="http://localhost:${PORT}/execute_command"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") <command> [args...] [--port N]
|
||||
|
||||
Low-level Frame0 HTTP API wrapper. Replaces the MCP server with direct
|
||||
curl calls. Requires Frame0 desktop app to be running.
|
||||
|
||||
Commands:
|
||||
health Check if Frame0 is running
|
||||
exec <namespace:action> <json> Execute a raw API command
|
||||
create-shape <type> <json-props> Create a shape (Rectangle, Ellipse, Text, Line)
|
||||
get-shape <id> Get shape details
|
||||
update-shape <id> <json-props> Update shape properties
|
||||
delete <id> [id...] Delete shapes by ID
|
||||
move <id> <dx> <dy> Move a shape by pixel offset
|
||||
duplicate <id> Duplicate a shape
|
||||
group <id> [id...] Group shapes
|
||||
ungroup <group-id> Ungroup a group
|
||||
create-connector <tail-id> <head-id> [json-props] Connect two shapes
|
||||
create-icon <name> <json-props> Create an icon shape
|
||||
add-page <name> Add a new page (becomes current)
|
||||
get-page [page-id] Get current or specific page data
|
||||
list-pages [--shapes] List all pages (--shapes for shape data)
|
||||
current-page Get current page ID
|
||||
set-page <page-id> Set current page
|
||||
export [page-id] [--format mime] Export page as image (default: image/png)
|
||||
fit Fit view to screen
|
||||
|
||||
Options:
|
||||
--port N Frame0 API port (default: $PORT, env: FRAME0_PORT)
|
||||
|
||||
Examples:
|
||||
$(basename "$0") health
|
||||
$(basename "$0") add-page "HUD Layout"
|
||||
$(basename "$0") create-shape Rectangle '{"name":"btn","left":100,"top":100,"width":120,"height":36}'
|
||||
$(basename "$0") list-pages
|
||||
$(basename "$0") export --format image/png
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Parse --port from anywhere in args
|
||||
ARGS=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--port) PORT="$2"; ENDPOINT="http://localhost:${PORT}/execute_command"; shift 2 ;;
|
||||
*) ARGS+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
set -- "${ARGS[@]+"${ARGS[@]}"}"
|
||||
|
||||
[[ $# -lt 1 ]] && usage
|
||||
|
||||
# Execute a Frame0 API command, return data or error
|
||||
frame0_exec() {
|
||||
local command="$1"
|
||||
local args
|
||||
args="${2:-"{}"}"
|
||||
|
||||
local response
|
||||
response=$(curl -s -w "\n%{http_code}" -X POST "$ENDPOINT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"command\": \"$command\", \"args\": $args}" 2>&1) || {
|
||||
echo "ERROR: Cannot connect to Frame0 at localhost:$PORT" >&2
|
||||
echo "Is Frame0 running? See: .claude/skills/frame0-wireframe/references/setup-guide.md" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
local http_code body
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
body=$(echo "$response" | sed '$d')
|
||||
|
||||
if [[ "$http_code" != 2* ]]; then
|
||||
echo "ERROR: HTTP $http_code from Frame0" >&2
|
||||
echo "$body" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Parse success/error from response
|
||||
python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
r = json.loads(sys.stdin.read())
|
||||
if r.get('success'):
|
||||
d = r.get('data')
|
||||
if d is not None:
|
||||
print(json.dumps(d, indent=2))
|
||||
else:
|
||||
print('ERROR: ' + str(r.get('error', 'Unknown error')), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f'ERROR: Invalid JSON response: {e}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
" <<< "$body"
|
||||
}
|
||||
|
||||
# Build JSON array from remaining args
|
||||
ids_to_json_array() {
|
||||
local arr="["
|
||||
local first=true
|
||||
for id in "$@"; do
|
||||
[[ "$first" == true ]] && first=false || arr+=","
|
||||
arr+="\"$id\""
|
||||
done
|
||||
arr+="]"
|
||||
echo "$arr"
|
||||
}
|
||||
|
||||
CMD="${1:-}"
|
||||
shift || true
|
||||
|
||||
case "$CMD" in
|
||||
health)
|
||||
if curl -s -o /dev/null -w "%{http_code}" "http://localhost:${PORT}/" 2>/dev/null | grep -q "^[23]"; then
|
||||
echo "Frame0 is running on port $PORT"
|
||||
else
|
||||
echo "Frame0 is NOT running on port $PORT" >&2
|
||||
echo "Start Frame0 desktop app, then retry." >&2
|
||||
echo "See: .claude/skills/frame0-wireframe/references/setup-guide.md" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
|
||||
exec)
|
||||
[[ $# -lt 2 ]] && { echo "Usage: exec <command> <json-args>" >&2; exit 1; }
|
||||
frame0_exec "$1" "$2"
|
||||
;;
|
||||
|
||||
create-shape)
|
||||
[[ $# -lt 2 ]] && { echo "Usage: create-shape <Type> <json-props>" >&2; exit 1; }
|
||||
local_type="$1"
|
||||
local_props="$2"
|
||||
local_parent="${3:-}"
|
||||
local_parent_arg=""
|
||||
[[ -n "$local_parent" ]] && local_parent_arg=", \"parentId\": \"$local_parent\""
|
||||
frame0_exec "shape:create-shape" "{\"type\": \"$local_type\", \"shapeProps\": $local_props$local_parent_arg, \"convertColors\": true}"
|
||||
;;
|
||||
|
||||
get-shape)
|
||||
[[ $# -lt 1 ]] && { echo "Usage: get-shape <id>" >&2; exit 1; }
|
||||
frame0_exec "shape:get-shape" "{\"shapeId\": \"$1\"}"
|
||||
;;
|
||||
|
||||
update-shape)
|
||||
[[ $# -lt 2 ]] && { echo "Usage: update-shape <id> <json-props>" >&2; exit 1; }
|
||||
frame0_exec "shape:update-shape" "{\"shapeId\": \"$1\", \"shapeProps\": $2, \"convertColors\": true}"
|
||||
;;
|
||||
|
||||
delete)
|
||||
[[ $# -lt 1 ]] && { echo "Usage: delete <id> [id...]" >&2; exit 1; }
|
||||
local_arr=$(ids_to_json_array "$@")
|
||||
frame0_exec "edit:delete" "{\"shapeIdArray\": $local_arr}"
|
||||
;;
|
||||
|
||||
move)
|
||||
[[ $# -lt 3 ]] && { echo "Usage: move <id> <dx> <dy>" >&2; exit 1; }
|
||||
frame0_exec "shape:move" "{\"shapeId\": \"$1\", \"dx\": $2, \"dy\": $3}"
|
||||
;;
|
||||
|
||||
duplicate)
|
||||
[[ $# -lt 1 ]] && { echo "Usage: duplicate <id> [dx] [dy]" >&2; exit 1; }
|
||||
local_dx="${2:-0}"
|
||||
local_dy="${3:-0}"
|
||||
frame0_exec "edit:duplicate" "{\"shapeIdArray\": [\"$1\"], \"dx\": $local_dx, \"dy\": $local_dy}"
|
||||
;;
|
||||
|
||||
group)
|
||||
[[ $# -lt 2 ]] && { echo "Usage: group <id> <id> [id...]" >&2; exit 1; }
|
||||
local_arr=$(ids_to_json_array "$@")
|
||||
frame0_exec "shape:group" "{\"shapeIdArray\": $local_arr}"
|
||||
;;
|
||||
|
||||
ungroup)
|
||||
[[ $# -lt 1 ]] && { echo "Usage: ungroup <group-id>" >&2; exit 1; }
|
||||
frame0_exec "shape:ungroup" "{\"shapeIdArray\": [\"$1\"]}"
|
||||
;;
|
||||
|
||||
create-connector)
|
||||
[[ $# -lt 2 ]] && { echo "Usage: create-connector <tail-id> <head-id> [json-props]" >&2; exit 1; }
|
||||
local_props="${3:-{}}"
|
||||
frame0_exec "shape:create-connector" "{\"tailId\": \"$1\", \"headId\": \"$2\", \"shapeProps\": $local_props, \"convertColors\": true}"
|
||||
;;
|
||||
|
||||
create-icon)
|
||||
[[ $# -lt 2 ]] && { echo "Usage: create-icon <name> <json-props>" >&2; exit 1; }
|
||||
frame0_exec "shape:create-icon" "{\"iconName\": \"$1\", \"shapeProps\": $2, \"convertColors\": true}"
|
||||
;;
|
||||
|
||||
add-page)
|
||||
[[ $# -lt 1 ]] && { echo "Usage: add-page <name>" >&2; exit 1; }
|
||||
frame0_exec "page:add" "{\"pageProps\": {\"name\": \"$1\"}}"
|
||||
;;
|
||||
|
||||
get-page)
|
||||
if [[ $# -ge 1 ]]; then
|
||||
frame0_exec "page:get" "{\"pageId\": \"$1\", \"exportShapes\": true}"
|
||||
else
|
||||
local_id
|
||||
local_id=$(frame0_exec "page:get-current-page")
|
||||
# Strip quotes from returned ID
|
||||
local_id=$(echo "$local_id" | tr -d '"')
|
||||
frame0_exec "page:get" "{\"pageId\": \"$local_id\", \"exportShapes\": true}"
|
||||
fi
|
||||
;;
|
||||
|
||||
list-pages)
|
||||
local_shapes="false"
|
||||
[[ "${1:-}" == "--shapes" ]] && local_shapes="true"
|
||||
frame0_exec "doc:get" "{\"exportPages\": true, \"exportShapes\": $local_shapes}"
|
||||
;;
|
||||
|
||||
current-page)
|
||||
frame0_exec "page:get-current-page"
|
||||
;;
|
||||
|
||||
set-page)
|
||||
[[ $# -lt 1 ]] && { echo "Usage: set-page <page-id>" >&2; exit 1; }
|
||||
frame0_exec "page:set-current-page" "{\"pageId\": \"$1\"}"
|
||||
;;
|
||||
|
||||
export)
|
||||
local_page_id=""
|
||||
local_format="image/png"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--format) local_format="$2"; shift 2 ;;
|
||||
*) local_page_id="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
local_page_arg=""
|
||||
[[ -n "$local_page_id" ]] && local_page_arg="\"pageId\": \"$local_page_id\", "
|
||||
frame0_exec "file:export-image" "{${local_page_arg}\"format\": \"$local_format\", \"fillBackground\": true}"
|
||||
;;
|
||||
|
||||
fit)
|
||||
frame0_exec "view:fit-to-screen"
|
||||
;;
|
||||
|
||||
--help|-h|help)
|
||||
usage
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Unknown command: $CMD" >&2
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env bash
|
||||
# Batch export wireframes from JSON to PNG via Frame0.
|
||||
#
|
||||
# Finds all .json wireframe files under docs/design/wireframes/ and exports
|
||||
# each to a matching .png. Skips files whose PNG is already newer than the
|
||||
# JSON, unless --force is passed.
|
||||
#
|
||||
# Usage:
|
||||
# frame0-export-batch.sh [--dry-run] [--force] [--category CAT] [--root DIR]
|
||||
#
|
||||
# Options:
|
||||
# --dry-run Print manifest only, don't touch Frame0.
|
||||
# --force Re-export even if PNG already exists and is up to date.
|
||||
# --category CAT Limit to one subdirectory (e.g. --category dialogue)
|
||||
# --root DIR Wireframes root dir (default: docs/design/wireframes)
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 All exports succeeded (or nothing to do)
|
||||
# 1 One or more exports failed
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||
SYNC="$SCRIPT_DIR/frame0-sync.py"
|
||||
DEFAULT_ROOT="$REPO_ROOT/docs/design/wireframes"
|
||||
|
||||
DRY_RUN=false
|
||||
FORCE=false
|
||||
CATEGORY=""
|
||||
WF_ROOT="$DEFAULT_ROOT"
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
--force) FORCE=true; shift ;;
|
||||
--category) CATEGORY="$2"; shift 2 ;;
|
||||
--root) WF_ROOT="$2"; shift 2 ;;
|
||||
-h|--help)
|
||||
sed -n '/^# /p' "$0" | sed 's/^# //'
|
||||
exit 0
|
||||
;;
|
||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -d "$WF_ROOT" ]]; then
|
||||
echo "ERROR: Wireframes directory not found: $WF_ROOT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Collect JSON files, optionally filtered by category subdirectory
|
||||
mapfile -t JSON_FILES < <(
|
||||
if [[ -n "$CATEGORY" ]]; then
|
||||
find "$WF_ROOT/$CATEGORY" -name "*.json" ! -name ".*" | sort
|
||||
else
|
||||
find "$WF_ROOT" -name "*.json" ! -name ".*" | sort
|
||||
fi
|
||||
)
|
||||
|
||||
if [[ ${#JSON_FILES[@]} -eq 0 ]]; then
|
||||
echo "No wireframe JSON files found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Classify files into to-export and to-skip
|
||||
TO_EXPORT=()
|
||||
TO_SKIP=()
|
||||
|
||||
for json in "${JSON_FILES[@]}"; do
|
||||
png="${json%.json}.png"
|
||||
if $FORCE || [[ ! -f "$png" ]] || [[ "$json" -nt "$png" ]]; then
|
||||
TO_EXPORT+=("$json")
|
||||
else
|
||||
TO_SKIP+=("$json")
|
||||
fi
|
||||
done
|
||||
|
||||
# Print manifest
|
||||
if [[ ${#TO_EXPORT[@]} -gt 0 ]]; then
|
||||
echo ""
|
||||
echo "Will export (${#TO_EXPORT[@]} files):"
|
||||
for json in "${TO_EXPORT[@]}"; do
|
||||
png="${json%.json}.png"
|
||||
rel="${json#$REPO_ROOT/}"
|
||||
if [[ ! -f "$png" ]]; then
|
||||
status="new"
|
||||
else
|
||||
status="updated"
|
||||
fi
|
||||
printf " [%-7s] %s\n" "$status" "$rel"
|
||||
done
|
||||
else
|
||||
echo ""
|
||||
echo "Nothing to export (all PNGs up to date)."
|
||||
fi
|
||||
|
||||
if [[ ${#TO_SKIP[@]} -gt 0 ]]; then
|
||||
echo ""
|
||||
echo "Will skip (${#TO_SKIP[@]} files already up to date):"
|
||||
for json in "${TO_SKIP[@]}"; do
|
||||
rel="${json#$REPO_ROOT/}"
|
||||
printf " [skip ] %s\n" "$rel"
|
||||
done
|
||||
fi
|
||||
|
||||
if $DRY_RUN; then
|
||||
echo ""
|
||||
echo "Dry run — no exports performed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ${#TO_EXPORT[@]} -eq 0 ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
PASSED=0
|
||||
FAILED=0
|
||||
FAILED_FILES=()
|
||||
|
||||
TOTAL=${#TO_EXPORT[@]}
|
||||
IDX=0
|
||||
|
||||
for json in "${TO_EXPORT[@]}"; do
|
||||
IDX=$((IDX + 1))
|
||||
png="${json%.json}.png"
|
||||
rel="${json#$REPO_ROOT/}"
|
||||
|
||||
printf "[%d/%d] %s ... " "$IDX" "$TOTAL" "$rel"
|
||||
|
||||
output=$(python3 "$SYNC" export "$json" "$png" 2>/tmp/frame0-batch-err.txt)
|
||||
rc=$?
|
||||
if [[ $rc -eq 0 ]]; then
|
||||
size=$(echo "$output" | tail -1 | grep -oP '\(\K[^)]+' || true)
|
||||
echo "ok $size"
|
||||
PASSED=$((PASSED + 1))
|
||||
else
|
||||
echo "FAILED"
|
||||
cat /tmp/frame0-batch-err.txt >&2
|
||||
FAILED=$((FAILED + 1))
|
||||
FAILED_FILES+=("$rel")
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "$PASSED exported, $FAILED failed."
|
||||
|
||||
if [[ $FAILED -gt 0 ]]; then
|
||||
echo ""
|
||||
echo "Failed:" >&2
|
||||
for f in "${FAILED_FILES[@]}"; do
|
||||
echo " $f" >&2
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,414 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Frame0 sync: push local JSON wireframes to Frame0, pull pages back.
|
||||
|
||||
Local JSON is source of truth. Frame0 is a renderer.
|
||||
A mapping file tracks local_id <-> frame0_id across push/pull cycles.
|
||||
|
||||
Usage:
|
||||
frame0-sync.py push <wireframe.json> [--port PORT]
|
||||
frame0-sync.py pull <page-id|page-name> <output.json> [--port PORT]
|
||||
frame0-sync.py export <wireframe.json> <output.png> [--port PORT] [--format MIME]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
DEFAULT_PORT = 58320
|
||||
|
||||
|
||||
def api(port, command, args=None):
|
||||
"""Execute a Frame0 API command. Returns the data field on success."""
|
||||
url = f"http://localhost:{port}/execute_command"
|
||||
payload = json.dumps({"command": command, "args": args or {}}).encode()
|
||||
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
result = json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode()
|
||||
print(f"ERROR: {command}: HTTP {e.code}: {body[:500]}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except urllib.error.URLError as e:
|
||||
print(f"ERROR: Cannot connect to Frame0 on port {port}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not result.get("success"):
|
||||
print(f"ERROR: {command}: {result.get('error', 'unknown')}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
return result.get("data")
|
||||
|
||||
|
||||
# -- Mapping file management --------------------------------------------------
|
||||
|
||||
def mapping_path(wireframe_path):
|
||||
"""Derive the idmap path from the wireframe JSON path."""
|
||||
d = os.path.dirname(wireframe_path)
|
||||
base = os.path.splitext(os.path.basename(wireframe_path))[0]
|
||||
return os.path.join(d, f".{base}.idmap.json")
|
||||
|
||||
|
||||
def load_mapping(wireframe_path):
|
||||
p = mapping_path(wireframe_path)
|
||||
if os.path.exists(p):
|
||||
with open(p) as f:
|
||||
return json.load(f)
|
||||
return {"page_id": None, "shapes": {}, "connectors": {}}
|
||||
|
||||
|
||||
def save_mapping(wireframe_path, mapping):
|
||||
p = mapping_path(wireframe_path)
|
||||
os.makedirs(os.path.dirname(p) or ".", exist_ok=True)
|
||||
with open(p, "w") as f:
|
||||
json.dump(mapping, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
# -- Push: local JSON -> Frame0 -----------------------------------------------
|
||||
|
||||
# Frame0 returns different type names from get vs what create accepts.
|
||||
# Map internal types back to create-API types.
|
||||
TYPE_TO_CREATE = {
|
||||
"Box": "Rectangle",
|
||||
"box": "Rectangle",
|
||||
}
|
||||
|
||||
|
||||
def topo_sort_shapes(shapes):
|
||||
"""Sort shape IDs so parents come before children."""
|
||||
order = []
|
||||
visited = set()
|
||||
|
||||
def visit(sid):
|
||||
if sid in visited:
|
||||
return
|
||||
visited.add(sid)
|
||||
shape = shapes[sid]
|
||||
parent = shape.get("parent")
|
||||
if parent and parent in shapes:
|
||||
visit(parent)
|
||||
order.append(sid)
|
||||
|
||||
for sid in shapes:
|
||||
visit(sid)
|
||||
return order
|
||||
|
||||
|
||||
def find_or_create_page(port, name, mapping):
|
||||
"""Find existing page by mapping or name, or create a new one."""
|
||||
# Try mapped page_id first
|
||||
if mapping.get("page_id"):
|
||||
try:
|
||||
page = api(port, "page:get", {"pageId": mapping["page_id"]})
|
||||
if page:
|
||||
return mapping["page_id"]
|
||||
except SystemExit:
|
||||
pass # Page no longer exists, fall through
|
||||
|
||||
# Search by name in existing pages
|
||||
doc = api(port, "doc:get", {"exportPages": True, "exportShapes": False})
|
||||
if doc and "children" in doc:
|
||||
for page in doc["children"]:
|
||||
if page.get("name") == name:
|
||||
return page["id"]
|
||||
|
||||
# Create new page
|
||||
page = api(port, "page:add", {"pageProps": {"name": name}})
|
||||
return page["id"]
|
||||
|
||||
|
||||
def clear_page(port, page_id):
|
||||
"""Delete all shapes on a page."""
|
||||
page = api(port, "page:get", {"pageId": page_id, "exportShapes": True})
|
||||
if not page or "children" not in page:
|
||||
return
|
||||
shape_ids = [s["id"] for s in page["children"]]
|
||||
if shape_ids:
|
||||
api(port, "edit:delete", {"shapeIdArray": shape_ids})
|
||||
|
||||
|
||||
def push(wireframe_path, port):
|
||||
"""Push local wireframe JSON to Frame0."""
|
||||
with open(wireframe_path) as f:
|
||||
wireframe = json.load(f)
|
||||
|
||||
name = wireframe.get("name", os.path.splitext(os.path.basename(wireframe_path))[0])
|
||||
shapes = wireframe.get("shapes", {})
|
||||
connectors = wireframe.get("connectors", {})
|
||||
|
||||
mapping = load_mapping(wireframe_path)
|
||||
|
||||
# Find or create page
|
||||
page_id = find_or_create_page(port, name, mapping)
|
||||
mapping["page_id"] = page_id
|
||||
|
||||
# Switch to page and clear it
|
||||
api(port, "page:set-current-page", {"pageId": page_id})
|
||||
clear_page(port, page_id)
|
||||
|
||||
# Reset ID mappings (shapes are recreated)
|
||||
mapping["shapes"] = {}
|
||||
mapping["connectors"] = {}
|
||||
|
||||
# Create shapes in dependency order
|
||||
order = topo_sort_shapes(shapes)
|
||||
for local_id in order:
|
||||
shape = shapes[local_id]
|
||||
raw_type = shape.get("type", "Rectangle")
|
||||
shape_type = TYPE_TO_CREATE.get(raw_type, raw_type)
|
||||
parent_local = shape.get("parent")
|
||||
|
||||
# Build shapeProps from everything except meta fields
|
||||
meta_keys = {"type", "parent"}
|
||||
props = {k: v for k, v in shape.items() if k not in meta_keys}
|
||||
|
||||
# Set name to local_id if not explicitly set
|
||||
if "name" not in props:
|
||||
props["name"] = local_id
|
||||
|
||||
create_args = {
|
||||
"type": shape_type,
|
||||
"shapeProps": props,
|
||||
"convertColors": True,
|
||||
}
|
||||
|
||||
# Resolve parent ID
|
||||
if parent_local and parent_local in mapping["shapes"]:
|
||||
create_args["parentId"] = mapping["shapes"][parent_local]
|
||||
|
||||
f0_id = api(port, "shape:create-shape", create_args)
|
||||
mapping["shapes"][local_id] = f0_id
|
||||
|
||||
# Create connectors
|
||||
for local_id, conn in connectors.items():
|
||||
tail_local = conn.get("tailId")
|
||||
head_local = conn.get("headId")
|
||||
|
||||
if tail_local not in mapping["shapes"] or head_local not in mapping["shapes"]:
|
||||
print(f"WARNING: connector '{local_id}' references unknown shape, skipping", file=sys.stderr)
|
||||
continue
|
||||
|
||||
meta_keys = {"tailId", "headId"}
|
||||
props = {k: v for k, v in conn.items() if k not in meta_keys}
|
||||
if "name" not in props:
|
||||
props["name"] = local_id
|
||||
|
||||
f0_id = api(port, "shape:create-connector", {
|
||||
"tailId": mapping["shapes"][tail_local],
|
||||
"headId": mapping["shapes"][head_local],
|
||||
"shapeProps": props,
|
||||
"convertColors": True,
|
||||
})
|
||||
mapping["connectors"][local_id] = f0_id
|
||||
|
||||
# Fit to screen
|
||||
api(port, "view:fit-to-screen")
|
||||
|
||||
save_mapping(wireframe_path, mapping)
|
||||
total = len(mapping["shapes"]) + len(mapping["connectors"])
|
||||
print(f"Pushed '{name}' to Frame0: {len(mapping['shapes'])} shapes, {len(mapping['connectors'])} connectors")
|
||||
|
||||
|
||||
# -- Pull: Frame0 -> local JSON -----------------------------------------------
|
||||
|
||||
def pull(page_ref, output_path, port):
|
||||
"""Pull a Frame0 page into local wireframe JSON."""
|
||||
# Resolve page_ref: could be an ID or a name
|
||||
page_id = None
|
||||
doc = api(port, "doc:get", {"exportPages": True, "exportShapes": False})
|
||||
if doc and "children" in doc:
|
||||
for page in doc["children"]:
|
||||
if page["id"] == page_ref or page.get("name") == page_ref:
|
||||
page_id = page["id"]
|
||||
page_name = page.get("name", page_ref)
|
||||
break
|
||||
|
||||
if not page_id:
|
||||
print(f"ERROR: Page not found: {page_ref}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Load existing mapping for reverse lookup
|
||||
mapping = load_mapping(output_path)
|
||||
reverse_map = {v: k for k, v in mapping.get("shapes", {}).items()}
|
||||
reverse_conn = {v: k for k, v in mapping.get("connectors", {}).items()}
|
||||
|
||||
# Get full page with shapes
|
||||
page = api(port, "page:get", {"pageId": page_id, "exportShapes": True})
|
||||
|
||||
shapes = {}
|
||||
connectors = {}
|
||||
new_mapping = {"page_id": page_id, "shapes": {}, "connectors": {}}
|
||||
auto_id_counter = [0]
|
||||
|
||||
def auto_id(f0_shape):
|
||||
"""Generate a stable local ID from shape name or auto-number."""
|
||||
# Prefer existing mapping
|
||||
f0_id = f0_shape["id"]
|
||||
if f0_id in reverse_map:
|
||||
return reverse_map[f0_id]
|
||||
# Use sanitized name
|
||||
name = f0_shape.get("name", "")
|
||||
if name:
|
||||
sanitized = name.lower().replace(" ", "-").replace("_", "-")
|
||||
if sanitized not in shapes:
|
||||
return sanitized
|
||||
# Fallback: auto-number
|
||||
auto_id_counter[0] += 1
|
||||
return f"s{auto_id_counter[0]:03d}"
|
||||
|
||||
def process_shape(f0_shape, parent_local_id=None):
|
||||
f0_id = f0_shape["id"]
|
||||
local_id = auto_id(f0_shape)
|
||||
new_mapping["shapes"][local_id] = f0_id
|
||||
|
||||
# Extract shape properties — only strip structural keys that our
|
||||
# ID mapping replaces. Everything else passes through as-is so the
|
||||
# local JSON speaks Frame0's native vocabulary.
|
||||
shape_type = f0_shape.get("type", "Box")
|
||||
skip_keys = {"id", "type", "children", "pageId", "parentId"}
|
||||
props = {k: v for k, v in f0_shape.items() if k not in skip_keys and v is not None}
|
||||
|
||||
entry = {"type": shape_type}
|
||||
if parent_local_id:
|
||||
entry["parent"] = parent_local_id
|
||||
entry.update(props)
|
||||
|
||||
# Remove name if it matches local_id (redundant)
|
||||
if entry.get("name") == local_id:
|
||||
del entry["name"]
|
||||
|
||||
shapes[local_id] = entry
|
||||
|
||||
# Process children recursively
|
||||
for child in f0_shape.get("children", []):
|
||||
child_type = child.get("type", "")
|
||||
if child_type == "Connector":
|
||||
process_connector(child)
|
||||
else:
|
||||
process_shape(child, local_id)
|
||||
|
||||
def process_connector(f0_conn):
|
||||
f0_id = f0_conn["id"]
|
||||
local_id = reverse_conn.get(f0_id)
|
||||
if not local_id:
|
||||
auto_id_counter[0] += 1
|
||||
local_id = f"c{auto_id_counter[0]:03d}"
|
||||
|
||||
new_mapping["connectors"][local_id] = f0_id
|
||||
|
||||
tail_f0 = f0_conn.get("tail", {}).get("id")
|
||||
head_f0 = f0_conn.get("head", {}).get("id")
|
||||
|
||||
entry = {}
|
||||
if tail_f0:
|
||||
# Will be resolved after all shapes are processed
|
||||
entry["_tailF0"] = tail_f0
|
||||
if head_f0:
|
||||
entry["_headF0"] = head_f0
|
||||
|
||||
skip_keys = {"id", "type", "children", "pageId", "tail", "head"}
|
||||
props = {k: v for k, v in f0_conn.items() if k not in skip_keys and v is not None}
|
||||
entry.update(props)
|
||||
|
||||
connectors[local_id] = entry
|
||||
|
||||
# Process all top-level shapes
|
||||
for child in page.get("children", []):
|
||||
child_type = child.get("type", "")
|
||||
if child_type == "Connector":
|
||||
process_connector(child)
|
||||
else:
|
||||
process_shape(child)
|
||||
|
||||
# Resolve connector references to local IDs
|
||||
f0_to_local = {v: k for k, v in new_mapping["shapes"].items()}
|
||||
for conn in connectors.values():
|
||||
tail_f0 = conn.pop("_tailF0", None)
|
||||
head_f0 = conn.pop("_headF0", None)
|
||||
if tail_f0 and tail_f0 in f0_to_local:
|
||||
conn["tailId"] = f0_to_local[tail_f0]
|
||||
if head_f0 and head_f0 in f0_to_local:
|
||||
conn["headId"] = f0_to_local[head_f0]
|
||||
|
||||
wireframe = {"name": page_name}
|
||||
if shapes:
|
||||
wireframe["shapes"] = shapes
|
||||
if connectors:
|
||||
wireframe["connectors"] = connectors
|
||||
|
||||
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
||||
with open(output_path, "w") as f:
|
||||
json.dump(wireframe, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
save_mapping(output_path, new_mapping)
|
||||
print(f"Pulled '{page_name}' -> {output_path}: {len(shapes)} shapes, {len(connectors)} connectors")
|
||||
|
||||
|
||||
# -- Export: push then export as image -----------------------------------------
|
||||
|
||||
def export_image(wireframe_path, output_path, port, fmt="image/png"):
|
||||
"""Push wireframe to Frame0 and export the page as an image."""
|
||||
import base64
|
||||
|
||||
# Push first to ensure Frame0 is up to date
|
||||
push(wireframe_path, port)
|
||||
|
||||
mapping = load_mapping(wireframe_path)
|
||||
page_id = mapping.get("page_id")
|
||||
if not page_id:
|
||||
print("ERROR: No page_id in mapping after push", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
image_b64 = api(port, "file:export-image", {
|
||||
"pageId": page_id,
|
||||
"format": fmt,
|
||||
"fillBackground": True,
|
||||
})
|
||||
|
||||
image_bytes = base64.b64decode(image_b64)
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
print(f"Exported: {output_path} ({len(image_bytes) // 1024}KB)")
|
||||
|
||||
|
||||
# -- CLI -----------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Sync wireframe JSON with Frame0")
|
||||
parser.add_argument("--port", type=int, default=int(os.environ.get("FRAME0_PORT", DEFAULT_PORT)))
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
p_push = sub.add_parser("push", help="Push local JSON to Frame0")
|
||||
p_push.add_argument("wireframe", help="Path to wireframe .json file")
|
||||
|
||||
p_pull = sub.add_parser("pull", help="Pull Frame0 page to local JSON")
|
||||
p_pull.add_argument("page", help="Page ID or page name")
|
||||
p_pull.add_argument("output", help="Output .json path")
|
||||
|
||||
p_export = sub.add_parser("export", help="Push and export as image")
|
||||
p_export.add_argument("wireframe", help="Path to wireframe .json file")
|
||||
p_export.add_argument("output", help="Output image path (e.g. wireframe.png)")
|
||||
p_export.add_argument("--format", default="image/png",
|
||||
help="Export MIME type (default: image/png)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "push":
|
||||
push(args.wireframe, args.port)
|
||||
elif args.command == "pull":
|
||||
pull(args.page, args.output, args.port)
|
||||
elif args.command == "export":
|
||||
export_image(args.wireframe, args.output, args.port, args.format)
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,209 +0,0 @@
|
||||
---
|
||||
name: theme-ui
|
||||
description: >-
|
||||
Token selection guide for clide UI development. Use when building or
|
||||
modifying widgets, panels, pane chrome, status indicators, icons, or
|
||||
any visual surface. Ensures correct background, border, text, and hover
|
||||
tokens are applied per surface type. Triggers on: new widget code,
|
||||
theme-related changes, "which token", "what color", color/background
|
||||
questions, visual inconsistency fixes, new panel/pane/view development,
|
||||
adding or looking up Phosphor icons, icon codepoints.
|
||||
---
|
||||
|
||||
# Theme-UI — token selection for clide surfaces
|
||||
|
||||
All colors come from `SurfaceTokens` via `ClideTheme.of(context).surface`.
|
||||
Never hardcode colors. Never use Material/Cupertino color constants.
|
||||
|
||||
## Token selection by surface
|
||||
|
||||
Pick tokens based on **where** the widget lives, not what it does.
|
||||
|
||||
### Chrome (hat bar, status bar, sidebar, context panel, spines, drag handles)
|
||||
|
||||
```
|
||||
background → chromeBackground
|
||||
text → chromeForeground
|
||||
border → chromeBorder (1px)
|
||||
active text → globalForeground
|
||||
```
|
||||
|
||||
### Side panels (sidebar, context panel)
|
||||
|
||||
```
|
||||
background → chromeBackground (both sides — they're chrome frame)
|
||||
text → sidebarForeground
|
||||
hover → sidebarItemHover
|
||||
selected → sidebarItemSelected
|
||||
section head → sidebarSectionHeader (muted, used for "START", "FILES", etc.)
|
||||
```
|
||||
|
||||
Padding: 2px on outer edges, 0px on divider edge.
|
||||
|
||||
### Center column (workspace, Claude pane, editor)
|
||||
|
||||
```
|
||||
background → panelBackground
|
||||
text → globalForeground
|
||||
```
|
||||
|
||||
No padding — content fills edge to edge.
|
||||
|
||||
### Pane headers (`ClidePaneChrome`)
|
||||
|
||||
```
|
||||
background → panelHeader
|
||||
text (title) → panelHeaderForeground
|
||||
text (sub) → globalTextMuted
|
||||
```
|
||||
|
||||
### List items (decisions, tickets, file rows, backlinks)
|
||||
|
||||
```
|
||||
background → (none / transparent)
|
||||
hover bg → listItemHoverBackground
|
||||
selected bg → listItemSelectedBackground
|
||||
text → listItemForeground / sidebarForeground (in sidebar)
|
||||
selected txt → listItemSelectedForeground
|
||||
```
|
||||
|
||||
In sidebar context, use `sidebarItemHover` not `listItemHoverBackground`.
|
||||
|
||||
### Buttons
|
||||
|
||||
```
|
||||
normal → buttonBackground / buttonForeground / buttonBorder
|
||||
hover → buttonHoverBackground
|
||||
active → buttonActiveBackground
|
||||
primary → buttonActiveBackground bg + globalBackground text
|
||||
subtle → listItemBackground / listItemHoverBackground (no border)
|
||||
```
|
||||
|
||||
### Dividers and separators
|
||||
|
||||
```
|
||||
line → dividerColor (always, everywhere)
|
||||
drag handle → 8px hit area, 1px visible line, panel bg fill
|
||||
hover line → panelActiveBorder
|
||||
```
|
||||
|
||||
### Status indicators
|
||||
|
||||
```
|
||||
success/ok → statusSuccess (green: done, added, connected)
|
||||
warning → statusWarning (amber: question, modified, missing)
|
||||
error → statusError (red: deleted, rejected, cancelled)
|
||||
info → statusInfo (blue: in_progress, modified)
|
||||
```
|
||||
|
||||
Map semantic states, not visual styles:
|
||||
- `done` / `added` / `ok` → `statusSuccess`
|
||||
- `in_progress` / `modified` → `statusInfo`
|
||||
- `question` / `warning` → `statusWarning`
|
||||
- `cancelled` / `deleted` / `error` → `statusError`
|
||||
|
||||
### Overlays (dialogs, palette, tooltips)
|
||||
|
||||
```
|
||||
dialog bg → modalSurfaceBackground
|
||||
dialog border→ modalSurfaceBorder
|
||||
backdrop → modalOverlayBackground
|
||||
tooltip → tooltipBackground / tooltipForeground / tooltipBorder
|
||||
dropdown → dropdownBackground / dropdownForeground / dropdownBorder
|
||||
```
|
||||
|
||||
## Typography
|
||||
|
||||
Three constants — never hardcode sizes or families.
|
||||
|
||||
```
|
||||
family UI → inherited from DefaultTextStyle (JosefinSans Light 300)
|
||||
family mono → clideMonoFamily (JetBrainsMono)
|
||||
body size → clideFontBody (15)
|
||||
caption size → clideFontCaption (14) — status bar, section headers, git info
|
||||
mono size → clideFontMono (14) — terminal, code, paths, IDs
|
||||
```
|
||||
|
||||
Use `ClideText` for themed text. Set `muted: true` for secondary text
|
||||
(resolves to `globalTextMuted`). Set `fontFamily: clideMonoFamily` for
|
||||
code/paths/IDs. Don't set fontFamily for UI text — it inherits.
|
||||
|
||||
## Token identity rule
|
||||
|
||||
Every visual surface gets its own named token. Never borrow a token from
|
||||
another surface just because they happen to resolve to the same color.
|
||||
|
||||
**Wrong:** `sidebarBackground` for the hat bar (the hat isn't a sidebar).
|
||||
**Right:** Create `chromeBackground` that resolves to the same palette key.
|
||||
|
||||
When two surfaces share a color:
|
||||
1. **If they're the same conceptual surface** (sidebar + context panel are
|
||||
both "side panels") → one shared token set is fine.
|
||||
2. **If they're different surfaces that happen to match** (hat bar + sidebar
|
||||
+ status bar are all "chrome frame") → create a shared primitive in the
|
||||
palette/semantic layer (e.g. `bgChrome`) and give each surface its own
|
||||
token that maps to that primitive. This lets themes diverge them later.
|
||||
|
||||
The palette layer has these depth primitives:
|
||||
- `bg` (`#20202C`) — outermost root, behind everything
|
||||
- `bgSunken` (`#1A1A24`) — chrome frame: sidebar, hat, statusbar
|
||||
- `surface` (`#242838`) — elevated: pane headers, active tabs
|
||||
- `surfaceHi` (`#2C3046`) — interactive: hover states, selections
|
||||
|
||||
Chrome tokens (`chromeBackground`/`chromeForeground`/`chromeBorder`) are
|
||||
the shared root for all frame surfaces. They resolve to `bgSunken` /
|
||||
`textDim` / `border` in the palette. Themes can override them to diverge
|
||||
hat from sidebar from status bar if desired.
|
||||
|
||||
## Extension-owned domain colors
|
||||
|
||||
Extensions that need domain-specific color coding (ticket types, decision
|
||||
types, priority levels) should NOT add tokens to `SurfaceTokens`. Instead:
|
||||
|
||||
1. Create a color map class in the extension (e.g. `TicketTypeColors`)
|
||||
2. Ship dark and light presets, auto-selected via `ClideTheme.of(context).dark`
|
||||
3. Store user overrides under `ext.<id>.colors` in settings
|
||||
4. Reference: `lib/builtin/tickets/src/ticket_colors.dart`
|
||||
|
||||
This keeps the core token surface lean and lets each extension own its
|
||||
palette. The pattern scales to any extension needing domain colors.
|
||||
|
||||
## Icons — Phosphor Icons
|
||||
|
||||
The app bundles Phosphor Icons (v2.0.8, MIT) as TTF fonts at
|
||||
`assets/fonts/phosphor/` (regular, bold, fill weights).
|
||||
|
||||
**Codepoint reference:** `assets/fonts/phosphor/codepoints.csv` —
|
||||
full mapping of all 1512 icon codepoints to kebab-case and PascalCase
|
||||
names. Read this file to look up any icon by name or codepoint.
|
||||
|
||||
**Adding an icon:** find the codepoint in `codepoints.csv`, then add
|
||||
a `static const` entry to `PhosphorIcons` in
|
||||
`lib/widgets/src/icons/phosphor.dart`:
|
||||
|
||||
```dart
|
||||
static const arrowClockwise = PhosphorIconPainter(0xe036);
|
||||
```
|
||||
|
||||
Only add icons we actually use — don't bulk-import the full set.
|
||||
|
||||
**Using an icon:** `ClideIcon(PhosphorIcons.arrowClockwise, size: 13)`
|
||||
or as a `TabContribution` icon field: `icon: PhosphorIcons.lightbulb`.
|
||||
|
||||
**Bold weight:** pass `family: 'Phosphor-Bold'` to `PhosphorIconPainter`.
|
||||
Fill weight: `family: 'Phosphor-Fill'`.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Borrowing another surface's token (`sidebarBackground` for hat bar)
|
||||
- `globalBackground` for panel fill → use `panelBackground`
|
||||
- `listItemHoverBackground` in sidebar → use `sidebarItemHover`
|
||||
- Hardcoded `Color(0xFF...)` → use a token
|
||||
- `fontSize: 14` → use `clideFontCaption` or `clideFontMono`
|
||||
- `fontFamily: 'JetBrainsMono'` → use `clideMonoFamily`
|
||||
|
||||
## Reference
|
||||
|
||||
Full token list: `lib/kernel/src/theme/tokens.dart`
|
||||
Resolver fallbacks: `lib/kernel/src/theme/resolver.dart`
|
||||
Theme YAML example: `lib/kernel/src/theme/themes/clide.yaml`
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: ui-design
|
||||
description: >-
|
||||
Visual design guide for clide UI development — covers theme tokens,
|
||||
surface-specific token selection, control geometry/spacing/alignment,
|
||||
and Phosphor icons. Use when building or modifying widgets, panels,
|
||||
pane chrome, status indicators, tabs, list items, dialogs, or any
|
||||
visual surface. Triggers on: new widget code, theme-related changes,
|
||||
"which token", "what color", color/background questions, visual
|
||||
inconsistency fixes, "alignment off", "spacing", "padding", control
|
||||
geometry questions, new panel/pane/view development, adding or
|
||||
looking up Phosphor icons, icon codepoints.
|
||||
---
|
||||
|
||||
# UI design — clide visual surface guide
|
||||
|
||||
This skill bundles four concerns that all surface in widget work:
|
||||
|
||||
| Concern | Reference | When to read |
|
||||
|---------|-----------|--------------|
|
||||
| Token system, palette, typography | [`references/theme.md`](references/theme.md) | Designing or extending the theme pipeline; deciding whether to add a new token |
|
||||
| Token selection per surface | [`references/surface.md`](references/surface.md) | Building a new widget or modifying an existing one — "which token does this need" |
|
||||
| Spacing, alignment, control layout | [`references/geometry.md`](references/geometry.md) | Building tab strips, list items, buttons, anything where icons sit next to text or padded edges |
|
||||
| Phosphor icon usage and codepoints | [`references/icons.md`](references/icons.md) | Adding or referencing an icon |
|
||||
|
||||
Read the reference that matches the question. They cross-reference each
|
||||
other where relevant; you don't need to read all four.
|
||||
|
||||
## Universal rules
|
||||
|
||||
These apply across every reference and every surface:
|
||||
|
||||
- All colors come from `SurfaceTokens` via `ClideTheme.of(context).surface`.
|
||||
Never hardcode `Color(0xFF...)`.
|
||||
- Never use `Material*` or `Cupertino*` widgets or color constants — clide
|
||||
is `WidgetsApp` only (D-7).
|
||||
- Use `ClideText` for themed text; never bare `Text` in production widgets.
|
||||
- Typography: `clideFontMono` for code/paths/IDs, `clideFontCaption` for
|
||||
status/section headers, body inherits from `DefaultTextStyle`.
|
||||
|
||||
## Anti-patterns (cross-cutting)
|
||||
|
||||
- Borrowing another surface's token (`sidebarBackground` for hat bar) — give
|
||||
each surface its own token even if they share a palette key. See `theme.md`.
|
||||
- Hardcoded hex colors → use a token. See `surface.md` for which one.
|
||||
- `fontSize: 14` literal → use `clideFontCaption` or `clideFontMono`.
|
||||
- `fontFamily: 'JetBrainsMono'` literal → use `clideMonoFamily`.
|
||||
- Stacking edge padding on a padded parent + a padded child action → see
|
||||
`geometry.md` "no double edge padding".
|
||||
- Eyeballing pixel margins instead of working back from the constraint —
|
||||
the math matters; see `geometry.md` "uniform inner spacing".
|
||||
@@ -0,0 +1,181 @@
|
||||
# Geometry — spacing, alignment, control layout
|
||||
|
||||
Principles for placing icons, buttons, and text inside controls.
|
||||
Apply when building tab strips, list items, buttons with affordances,
|
||||
or anything where actions sit next to content.
|
||||
|
||||
> Constants live in `lib/widgets/src/spacing.dart` — pull from there
|
||||
> instead of inlining literals:
|
||||
>
|
||||
> | Concept | Constant |
|
||||
> |-------------------------|-------------------------|
|
||||
> | Hairline (2px) | `clideInsetHairline` |
|
||||
> | Tight inset (4px) | `clideInsetTight` |
|
||||
> | Uniform icon margin (6) | `clideInsetIcon` |
|
||||
> | Standard inset (8px) | `clideInsetStandard` |
|
||||
> | Text-content inset (12) | `clideInsetText` |
|
||||
> | Tight / standard gap | `clideGapTight` / `clideGapStandard` |
|
||||
> | Section / major gap | `clideGapSection` / `clideGapMajor` |
|
||||
> | Micro icon (10) | `clideIconMicro` |
|
||||
> | Standard icon (14) | `clideIconStandard` |
|
||||
> | Hit-target (16) | `clideIconHitTarget` |
|
||||
> | Control height (28) | `clideControlHeight` |
|
||||
|
||||
## Uniform inner spacing rule
|
||||
|
||||
Icons inside control surfaces should have **equal margin on every
|
||||
constrained side**. The "constrained sides" are top, bottom, and the
|
||||
side opposite to where content flows in.
|
||||
|
||||
The remaining side — where the text or other content sits — gets a
|
||||
larger, content-appropriate breathing room.
|
||||
|
||||
Example: tab close button (16×16 inside a 28-tall tab):
|
||||
|
||||
```
|
||||
top : 6 ┐
|
||||
bottom : 6 ├─ uniform: (28 − 16) / 2 = 6
|
||||
right : 6 ┘
|
||||
left : 8 ── content gap (separates from title text)
|
||||
```
|
||||
|
||||
The visual effect: the close button looks like a deliberate
|
||||
affordance with a calm, consistent border, not a glyph stuffed into
|
||||
the corner.
|
||||
|
||||
## No double-edge padding
|
||||
|
||||
When a fixed-size action (icon button, close ×) sits at the edge of
|
||||
a padded parent, the parent's padding on that edge should **not stack**
|
||||
with the action's own internal margin. Pick one place to hold the
|
||||
breathing room.
|
||||
|
||||
Wrong:
|
||||
|
||||
```dart
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12), // tab pad: 12 right
|
||||
child: Row(children: [
|
||||
Expanded(child: title),
|
||||
SizedBox(width: 8), // gap: 8
|
||||
Container(width: 28, alignment: Center, // close: 6 internal margin
|
||||
child: Icon(close, size: 16)),
|
||||
]),
|
||||
)
|
||||
// Visible margin from icon right to outer right = 12 + 6 = 18px → too much
|
||||
```
|
||||
|
||||
Right:
|
||||
|
||||
```dart
|
||||
Container(
|
||||
padding: EdgeInsets.only(left: 12, right: 6), // pad matches icon margin
|
||||
child: Row(children: [
|
||||
Expanded(child: title),
|
||||
SizedBox(width: 8),
|
||||
Container(width: 16, height: 16, alignment: Center, // hit target = icon size
|
||||
child: Icon(close, size: 10)),
|
||||
]),
|
||||
)
|
||||
// Visible margin = 6 (parent right pad) ≈ 6 (top/bottom auto) → uniform
|
||||
```
|
||||
|
||||
## Two-column control pattern
|
||||
|
||||
For tab-shaped or row-shaped controls with a primary content area and
|
||||
a secondary action:
|
||||
|
||||
```dart
|
||||
Row(children: [
|
||||
Expanded(child: <content>), // takes remainder
|
||||
if (action != null) ...[
|
||||
SizedBox(width: 8), // standard gap
|
||||
<fixed-size action>, // shrinks to content
|
||||
],
|
||||
])
|
||||
```
|
||||
|
||||
- **Left column**: `Expanded`, holds the primary content (title,
|
||||
label, description). Aligned to the start of its space by default.
|
||||
- **Right column**: fixed natural width, holds the action (close,
|
||||
status, indicator). Sized to the icon, not to artificial padding.
|
||||
|
||||
The parent container's padding sits flush against both columns (see
|
||||
"no double-edge padding").
|
||||
|
||||
## Match perceived mass, not measured pixels
|
||||
|
||||
Glyphs vary in visual weight. A bold `+` looks heavier than a thin
|
||||
`×` at the same point size. When eyeballing alignment, trust the
|
||||
optical center over the geometric center.
|
||||
|
||||
In practice: if two icons measure to the same margin but one *looks*
|
||||
crowded, give the heavier glyph slightly more breathing room and
|
||||
trim the lighter one. For clide, this came up with the `×` close
|
||||
glyph vs the `+` add glyph — both at 14pt, but `+` reads as denser
|
||||
and is left in its 28-wide button without further padding, while
|
||||
`×` sits in a 16×16 hit area with 6px symmetric margin.
|
||||
|
||||
## Strip / row should fill the parent
|
||||
|
||||
Tab strips, status bars, and divider rows should span the full
|
||||
parent width, not size to their content. Without this, the strip
|
||||
looks like it floats inside the pane.
|
||||
|
||||
```dart
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch, // <-- this
|
||||
children: [
|
||||
_TabStrip(...),
|
||||
Expanded(child: _body(...)),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
Without `stretch`, Column gives loose width constraints and a
|
||||
`Container(height: tabHeight)` child sizes to its child's natural
|
||||
width — the strip ends mid-pane.
|
||||
|
||||
## Anchor strips with a divider
|
||||
|
||||
Add a 1px bottom border (`dividerColor`) to tab strips and any
|
||||
header strip that sits above content. Without it, the strip looks
|
||||
disconnected from the body and the perceived alignment slips.
|
||||
|
||||
```dart
|
||||
Container(
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.tabBarBackground,
|
||||
border: Border(bottom: BorderSide(color: tokens.dividerColor)),
|
||||
),
|
||||
child: ...,
|
||||
)
|
||||
```
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Centering a glyph inside a "hover background" that's larger than
|
||||
the natural icon size, then surrounding the whole thing with a
|
||||
padded parent — the icon ends up far inside the visible edge.
|
||||
- Hardcoded `padding: EdgeInsets.symmetric(horizontal: 12)` on every
|
||||
control regardless of whether the right edge has an action — see
|
||||
"no double-edge padding".
|
||||
- Tab strip inside `Column` without `crossAxisAlignment.stretch` —
|
||||
the strip ends mid-pane.
|
||||
- `mainAxisSize.min` on the tab strip's outer Row when you actually
|
||||
want it to fill parent width — only use `min` for pill-shaped
|
||||
controls that should hug their content.
|
||||
- Eyeballing alignment without working back from a target margin in
|
||||
pixels. The math matters; see "uniform inner spacing".
|
||||
|
||||
## Testing alignment
|
||||
|
||||
When iterating on a control's spacing:
|
||||
|
||||
1. State the target margin (e.g. "6px around the close icon, all
|
||||
sides except left").
|
||||
2. Map every contributing source: parent padding, gap SizedBoxes,
|
||||
container alignment offsets, icon-to-container size differences.
|
||||
3. Sum them. Adjust until they hit the target.
|
||||
4. Verify visually — perceived mass may justify a 1–2px tweak.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Icons — Phosphor + clide-owned painters
|
||||
|
||||
## Phosphor Icons
|
||||
|
||||
The app bundles Phosphor Icons (v2.0.8, MIT) as TTF fonts at
|
||||
`assets/fonts/phosphor/` (regular, bold, fill weights).
|
||||
|
||||
**Codepoint reference:** `assets/fonts/phosphor/codepoints.csv` — full
|
||||
mapping of all 1512 icon codepoints to kebab-case and PascalCase
|
||||
names. Read this file to look up any icon by name or codepoint.
|
||||
|
||||
### Adding an icon
|
||||
|
||||
Find the codepoint in `codepoints.csv`, then add a `static const`
|
||||
entry to `PhosphorIcons` in `lib/widgets/src/icons/phosphor.dart`:
|
||||
|
||||
```dart
|
||||
static const arrowClockwise = PhosphorIconPainter(0xe036);
|
||||
```
|
||||
|
||||
Only add icons we actually use — don't bulk-import the full set.
|
||||
|
||||
### Using an icon
|
||||
|
||||
```dart
|
||||
ClideIcon(PhosphorIcons.arrowClockwise, size: 13)
|
||||
```
|
||||
|
||||
Or as a `TabContribution` icon field: `icon: PhosphorIcons.lightbulb`.
|
||||
|
||||
**Bold weight:** pass `family: 'Phosphor-Bold'` to `PhosphorIconPainter`.
|
||||
**Fill weight:** `family: 'Phosphor-Fill'`.
|
||||
|
||||
## clide-owned painters
|
||||
|
||||
Some shapes are simple enough to paint directly without an icon
|
||||
font. Hand-rolled `ClideIconPainter` subclasses live under
|
||||
`lib/widgets/src/icons/`:
|
||||
|
||||
- `CheckIcon`, `ChevronIcon`, `CloseIcon` (`x.dart`)
|
||||
- `DotIcon`, `FolderIcon`, `GearIcon`
|
||||
- `GitBranchIcon`, `PlugIcon`, `SearchIcon`
|
||||
- `TerminalIcon`, `WarningIcon`
|
||||
|
||||
Use these for tiny, theme-aware glyphs (close ×, dropdown chevrons,
|
||||
status dots) where pulling in the Phosphor font weight would be
|
||||
overkill or where the visual needs to match the theme's stroke
|
||||
weight conventions.
|
||||
|
||||
Pattern for a new painter:
|
||||
|
||||
```dart
|
||||
class FoobarIcon extends ClideIconPainter {
|
||||
const FoobarIcon();
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Color color) {
|
||||
final p = Paint()
|
||||
..color = color
|
||||
..strokeWidth = 0.10
|
||||
..strokeCap = StrokeCap.round;
|
||||
// Coordinates are 0..1 (the painter is given a unit square).
|
||||
canvas.drawLine(const Offset(0.2, 0.2), const Offset(0.8, 0.8), p);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Sizing
|
||||
|
||||
Icon sizes used in clide (subject to consolidation under
|
||||
`ClideSpacing` — see T-86):
|
||||
|
||||
- `10` — micro: close × inside a tab
|
||||
- `13` — caption-row icons (sidebar, status bar)
|
||||
- `14` — standard inline icons (icon rail)
|
||||
- `16` — small icon hit-target outer container
|
||||
- `18`–`20` — emphatic / standalone icons
|
||||
|
||||
Pass `size:` to `ClideIcon`; the painter receives a unit-square
|
||||
canvas regardless. Color defaults to `globalForeground`; pass
|
||||
explicit `color:` for muted/active variants.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Importing all of Phosphor — only declare codepoints we use.
|
||||
- Hand-painting a glyph that already exists in Phosphor at the right
|
||||
weight — use the font.
|
||||
- Hardcoded `Color` on icons — pass through the surface tokens
|
||||
(`globalForeground`, `globalTextMuted`, `panelActiveBorder`, etc.).
|
||||
@@ -0,0 +1,120 @@
|
||||
# Surface — token selection per surface type
|
||||
|
||||
Pick tokens based on **where** the widget lives, not what it does.
|
||||
|
||||
## Chrome (hat bar, status bar, sidebar, context panel, spines, drag handles)
|
||||
|
||||
```
|
||||
background → chromeBackground
|
||||
text → chromeForeground
|
||||
border → chromeBorder (1px)
|
||||
active text → globalForeground
|
||||
```
|
||||
|
||||
## Side panels (sidebar, context panel)
|
||||
|
||||
```
|
||||
background → chromeBackground (both sides — they're chrome frame)
|
||||
text → sidebarForeground
|
||||
hover → sidebarItemHover
|
||||
selected → sidebarItemSelected
|
||||
section head → sidebarSectionHeader (muted, used for "START", "FILES", etc.)
|
||||
```
|
||||
|
||||
Padding: 2px on outer edges, 0px on divider edge.
|
||||
|
||||
## Center column (workspace, Claude pane, editor)
|
||||
|
||||
```
|
||||
background → panelBackground
|
||||
text → globalForeground
|
||||
```
|
||||
|
||||
No padding — content fills edge to edge.
|
||||
|
||||
## Pane headers (`ClidePaneChrome`)
|
||||
|
||||
```
|
||||
background → panelHeader
|
||||
text (title) → panelHeaderForeground
|
||||
text (sub) → globalTextMuted
|
||||
```
|
||||
|
||||
## Tabs (`MultitabPane`, `ClideTabBar`)
|
||||
|
||||
```
|
||||
strip bg → tabBarBackground
|
||||
strip border → bottom: dividerColor (anchors strip to body)
|
||||
active fg → tabActiveForeground
|
||||
inactive fg → tabInactiveForeground
|
||||
active bg → panelHeader (elevated chrome)
|
||||
inactive bg → tabBarBackground (blends with strip)
|
||||
active border→ panelActiveBorder (top accent, 1.5px)
|
||||
side border → panelBorder
|
||||
```
|
||||
|
||||
For control geometry inside tabs (close button placement, padding,
|
||||
two-column title+action layout) see [`geometry.md`](geometry.md).
|
||||
|
||||
## List items (decisions, tickets, file rows, backlinks)
|
||||
|
||||
```
|
||||
background → (none / transparent)
|
||||
hover bg → listItemHoverBackground
|
||||
selected bg → listItemSelectedBackground
|
||||
text → listItemForeground / sidebarForeground (in sidebar)
|
||||
selected txt → listItemSelectedForeground
|
||||
```
|
||||
|
||||
In sidebar context, use `sidebarItemHover` not `listItemHoverBackground`.
|
||||
|
||||
## Buttons
|
||||
|
||||
```
|
||||
normal → buttonBackground / buttonForeground / buttonBorder
|
||||
hover → buttonHoverBackground
|
||||
active → buttonActiveBackground
|
||||
primary → buttonActiveBackground bg + globalBackground text
|
||||
subtle → listItemBackground / listItemHoverBackground (no border)
|
||||
```
|
||||
|
||||
## Dividers and separators
|
||||
|
||||
```
|
||||
line → dividerColor (always, everywhere)
|
||||
drag handle → 8px hit area, 1px visible line, panel bg fill
|
||||
hover line → panelActiveBorder
|
||||
```
|
||||
|
||||
## Status indicators
|
||||
|
||||
```
|
||||
success/ok → statusSuccess (green: done, added, connected)
|
||||
warning → statusWarning (amber: question, modified, missing)
|
||||
error → statusError (red: deleted, rejected, cancelled)
|
||||
info → statusInfo (blue: in_progress, modified)
|
||||
```
|
||||
|
||||
Map semantic states, not visual styles:
|
||||
|
||||
- `done` / `added` / `ok` → `statusSuccess`
|
||||
- `in_progress` / `modified` → `statusInfo`
|
||||
- `question` / `warning` → `statusWarning`
|
||||
- `cancelled` / `deleted` / `error` → `statusError`
|
||||
|
||||
## Overlays (dialogs, palette, tooltips)
|
||||
|
||||
```
|
||||
dialog bg → modalSurfaceBackground
|
||||
dialog border→ modalSurfaceBorder
|
||||
backdrop → modalOverlayBackground
|
||||
tooltip → tooltipBackground / tooltipForeground / tooltipBorder
|
||||
dropdown → dropdownBackground / dropdownForeground / dropdownBorder
|
||||
```
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- `globalBackground` for panel fill → use `panelBackground`
|
||||
- `listItemHoverBackground` in sidebar → use `sidebarItemHover`
|
||||
- Tab active bg = `panelBackground` → use `panelHeader` (elevated chrome)
|
||||
- Tab active border = `globalFocus` → use `panelActiveBorder`
|
||||
@@ -0,0 +1,70 @@
|
||||
# Theme — token system, palette, typography
|
||||
|
||||
## Token identity rule
|
||||
|
||||
Every visual surface gets its own named token. Never borrow a token from
|
||||
another surface just because they happen to resolve to the same color.
|
||||
|
||||
**Wrong:** `sidebarBackground` for the hat bar (the hat isn't a sidebar).
|
||||
**Right:** Create `chromeBackground` that resolves to the same palette key.
|
||||
|
||||
When two surfaces share a color:
|
||||
|
||||
1. **Same conceptual surface** (sidebar + context panel are both "side
|
||||
panels") → one shared token set is fine.
|
||||
2. **Different surfaces that happen to match** (hat bar + sidebar +
|
||||
status bar are all "chrome frame") → create a shared primitive in the
|
||||
palette/semantic layer (e.g. `bgChrome`) and give each surface its
|
||||
own token that maps to that primitive. This lets themes diverge them
|
||||
later without breaking widgets.
|
||||
|
||||
## Palette depth primitives
|
||||
|
||||
The palette layer has these depth primitives (defined in each theme YAML):
|
||||
|
||||
- `bg` (`#20202C`) — outermost root, behind everything
|
||||
- `bgSunken` (`#1A1A24`) — chrome frame: sidebar, hat, statusbar
|
||||
- `surface` (`#242838`) — elevated: pane headers, active tabs
|
||||
- `surfaceHi` (`#2C3046`) — interactive: hover states, selections
|
||||
|
||||
Chrome tokens (`chromeBackground` / `chromeForeground` / `chromeBorder`)
|
||||
are the shared root for all frame surfaces. They resolve to `bgSunken` /
|
||||
`textDim` / `border` in the palette. Themes can override them to diverge
|
||||
hat from sidebar from status bar if desired.
|
||||
|
||||
## Typography
|
||||
|
||||
Three constants — never hardcode sizes or families:
|
||||
|
||||
```
|
||||
family UI → inherited from DefaultTextStyle (JosefinSans Light 300)
|
||||
family mono → clideMonoFamily (JetBrainsMono)
|
||||
body size → clideFontBody (15)
|
||||
caption size → clideFontCaption (14) — status bar, section headers, git info
|
||||
mono size → clideFontMono (14) — terminal, code, paths, IDs
|
||||
```
|
||||
|
||||
Use `ClideText` for themed text. Set `muted: true` for secondary text
|
||||
(resolves to `globalTextMuted`). Set `fontFamily: clideMonoFamily` for
|
||||
code/paths/IDs. Don't set fontFamily for UI text — it inherits.
|
||||
|
||||
## Extension-owned domain colors
|
||||
|
||||
Extensions that need domain-specific color coding (ticket types, decision
|
||||
types, priority levels) should NOT add tokens to `SurfaceTokens`. Instead:
|
||||
|
||||
1. Create a color map class in the extension (e.g. `TicketTypeColors`).
|
||||
2. Ship dark and light presets, auto-selected via
|
||||
`ClideTheme.of(context).dark`.
|
||||
3. Store user overrides under `ext.<id>.colors` in settings.
|
||||
4. Reference: `lib/builtin/tickets/src/ticket_colors.dart`.
|
||||
|
||||
This keeps the core token surface lean and lets each extension own its
|
||||
palette. The pattern scales to any extension needing domain colors.
|
||||
|
||||
## Where to look in the codebase
|
||||
|
||||
- Full token list: `lib/kernel/src/theme/tokens.dart`
|
||||
- Resolver fallbacks: `lib/kernel/src/theme/resolver.dart`
|
||||
- Theme YAML example: `lib/kernel/src/theme/themes/clide.yaml`
|
||||
- Decision: D-43 (handoff), D-44 (four bundled themes), D-45 (syntax tokens)
|
||||
@@ -0,0 +1,240 @@
|
||||
---
|
||||
name: whats-next
|
||||
description: >
|
||||
Surface the best batch of tickets to pick up next from pql. Walks the
|
||||
initiative/epic tree, filters to unblocked work, refines context via
|
||||
parallel agents (or `pql ticket refine` for empty descriptions), and
|
||||
optionally activates the batch on a fresh branch. Use when the user
|
||||
says "what's next", "next batch", "pick up work", or invokes
|
||||
/whats-next. NOT triggered by "what should we work on" in a design
|
||||
context — that's a discussion, not a batch selection.
|
||||
user-invocable: true
|
||||
allowed-tools: Bash, Read, Grep, Glob, Agent, AskUserQuestion
|
||||
---
|
||||
|
||||
# What's Next
|
||||
|
||||
Dependency-driven batch selection against pql. Three steps:
|
||||
batch selection → refinement review → batch activation.
|
||||
|
||||
Pql is the single source of truth for tickets and decisions in this repo
|
||||
(see [pql skill](../pql/SKILL.md) and [`decisions/README.md`](../../../decisions/README.md)). Always run from the repo root.
|
||||
|
||||
## Step 0: Sync state
|
||||
|
||||
Decisions on disk may be ahead of pql.db. Always sync before reading:
|
||||
|
||||
```bash
|
||||
pql decisions sync
|
||||
```
|
||||
|
||||
If `pql` is missing, stop and tell the user — don't fall back to grep.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Batch Selection
|
||||
|
||||
### 1a. Find active top-level work
|
||||
|
||||
Pql has no `milestone` concept; **initiatives** (and large **epics**) play
|
||||
that role. List in-flight top-level work:
|
||||
|
||||
```bash
|
||||
pql ticket list --status in_progress --pretty
|
||||
pql ticket list --status ready --pretty
|
||||
```
|
||||
|
||||
If nothing is `in_progress` or `ready` at the initiative/epic level,
|
||||
fall back to `pql plan status --pretty` for a dashboard read and ask the
|
||||
user which area to advance.
|
||||
|
||||
### 1b. Build the work landscape
|
||||
|
||||
For each candidate epic or initiative, expand its children:
|
||||
|
||||
```bash
|
||||
pql ticket show <id> --with-children --pretty
|
||||
```
|
||||
|
||||
Collect every leaf ticket (story/task/bug) underneath. Deduplicate.
|
||||
|
||||
### 1c. Filter to unblocked tickets
|
||||
|
||||
For each leaf with status `ready` or `backlog`, check blockers:
|
||||
|
||||
```bash
|
||||
pql ticket show <id> --with-blockers --pretty
|
||||
```
|
||||
|
||||
A ticket is **unblocked** if every blocker is `done` or `cancelled`.
|
||||
Drop the rest.
|
||||
|
||||
### 1d. Rank and group
|
||||
|
||||
Rank unblocked tickets by:
|
||||
|
||||
1. **Priority** (critical > high > medium > low) — read from ticket fields.
|
||||
2. **Epic proximity to done** — for each epic parent, compute
|
||||
`done_children / total_children`. Higher ratio ranks higher: finishing
|
||||
an epic unlocks downstream work and tightens the board.
|
||||
3. **Fan-out** — tickets that unblock the most other tickets rank higher.
|
||||
Approximate by scanning `pql ticket list --status backlog --pretty`
|
||||
and counting how many list this ticket in their blockers (use
|
||||
`--with-blockers` per candidate, or read `--jsonl` once and reduce in
|
||||
memory).
|
||||
|
||||
Group into **epic-sized batches**: tickets sharing a `parent_id`, or a
|
||||
logical cluster if no shared parent. If nothing groups naturally, batch
|
||||
by area (the directory the work touches, e.g. `lib/src/pty/`).
|
||||
|
||||
### 1e. Show the board
|
||||
|
||||
```bash
|
||||
pql ticket board --pretty
|
||||
```
|
||||
|
||||
This is the "what's currently in flight" view — the user wants to see
|
||||
WIP before committing to more.
|
||||
|
||||
### 1f. Present the recommended batch
|
||||
|
||||
Show the user:
|
||||
|
||||
- The recommended batch — IDs, titles, priorities, parent epic.
|
||||
- **Why this batch** — which epic it advances, what it unblocks downstream.
|
||||
- Current board state (WIP count vs. ready/backlog).
|
||||
- One or two alternative batches worth considering.
|
||||
|
||||
Wait for user confirmation before Step 2.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Ticket Refinement Review
|
||||
|
||||
Two cases — handle the cheap one first.
|
||||
|
||||
### 2a. Tickets with empty descriptions → use pql
|
||||
|
||||
If any ticket in the batch has no description, hand off to pql's
|
||||
built-in refinement flow:
|
||||
|
||||
```bash
|
||||
pql ticket refine list --pretty
|
||||
pql ticket refine next --pretty # full context for the next one
|
||||
pql ticket refine write T-NN '{"description":"..."}'
|
||||
```
|
||||
|
||||
Walk these with the user (AskUserQuestion per ticket if appropriate)
|
||||
before moving on.
|
||||
|
||||
### 2b. Tickets with descriptions → spawn refinement agents
|
||||
|
||||
For each ticket that already has a description but may still be
|
||||
under-specified, spawn one agent in parallel. Use `general-purpose`
|
||||
subagent_type (custom subagent_types lose SendMessage):
|
||||
|
||||
```
|
||||
Agent({
|
||||
subagent_type: "general-purpose",
|
||||
model: "sonnet",
|
||||
description: "Refine T-NN context",
|
||||
prompt: "You are the Refinement Manager for ticket T-NN.
|
||||
|
||||
Ticket: <title>
|
||||
Description: <body>
|
||||
Decision ref: <D-NN or Q-NN, if set>
|
||||
|
||||
Your job:
|
||||
1. Run `pql decisions show <decision_ref> --with-refs --pretty` and
|
||||
read the linked D/Q-record in decisions/<domain>.md.
|
||||
2. Grep decisions/questions-*.md for related Q-records.
|
||||
3. Verify referenced files, classes, and APIs actually exist in the
|
||||
current tree (Read/Grep). Flag dangling references.
|
||||
4. Cross-check against CLAUDE.md guardrails (single process, CLI-first,
|
||||
own the rendering stack, etc.) — flag tickets that conflict.
|
||||
|
||||
Assess: does an implementer have enough context to proceed without
|
||||
guessing? Report exactly one of:
|
||||
- READY: <one-paragraph summary of what the implementer needs to know>
|
||||
- GAPS: <list of specific ambiguities, each with 2–3 options>"
|
||||
})
|
||||
```
|
||||
|
||||
Run all agents in parallel (single message, multiple Agent tool calls).
|
||||
|
||||
### 2c. Resolve gaps
|
||||
|
||||
For each ticket that came back GAPS, surface ambiguities to the user
|
||||
via AskUserQuestion. After the user resolves, append the resolution to
|
||||
the ticket via pql:
|
||||
|
||||
```bash
|
||||
pql ticket refine write T-NN '{"description":"<existing body>\n\n---\nRefinement: <resolution>"}'
|
||||
```
|
||||
|
||||
If a gap really requires a new D-record (architectural choice, not just
|
||||
detail), flag it. Ask whether to write the D-record now (`pql decisions
|
||||
claim D <domain> "title"` then author the markdown) or defer with a note
|
||||
on the ticket.
|
||||
|
||||
### 2d. Present refined batch summary
|
||||
|
||||
Per ticket:
|
||||
|
||||
- READY summary, or the resolution the user just gave.
|
||||
- Linked D/Q-records.
|
||||
- Remaining blockers (should be none — re-check if Step 1 was a while ago).
|
||||
|
||||
Ask: "Batch ready. Activate?"
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Batch Activation
|
||||
|
||||
### 3a. Mark tickets in_progress
|
||||
|
||||
Batch transition (comma-separated IDs):
|
||||
|
||||
```bash
|
||||
pql ticket status T-1,T-2,T-3 in_progress
|
||||
```
|
||||
|
||||
### 3b. Branch? Default no.
|
||||
|
||||
Solo-dev flow on this repo — work lands directly on `main` (see recent
|
||||
`git log`). Don't create a topic branch unless the user explicitly asks.
|
||||
If they do, plain `git checkout -b` is fine; there is no `gh` CLI.
|
||||
|
||||
### 3c. Spawn implementation agents (optional)
|
||||
|
||||
If the user wants agents driving the work, spawn `general-purpose`
|
||||
subagents (`model: sonnet`) per ticket. Each prompt should include:
|
||||
|
||||
- Ticket details + the refinement summary from Step 2.
|
||||
- The full content of any linked D-record (Read it and inline it — don't
|
||||
just cite the ID; the agent has no project memory of it).
|
||||
- Repo guardrails the work touches (from CLAUDE.md — quote the relevant
|
||||
bullets, don't link).
|
||||
- A RULES block: write files only, no git commits, no destructive ops,
|
||||
message back when blocked or done.
|
||||
|
||||
### 3d. Report
|
||||
|
||||
End with a tight summary:
|
||||
|
||||
- Branch.
|
||||
- Tickets now `in_progress`.
|
||||
- Agents spawned (if any).
|
||||
- Next step: implement, then commit per the [git-commit skill](../git-commit/SKILL.md).
|
||||
|
||||
---
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Don't skip Step 0 — stale `pql.db` makes the rest of the skill lie.
|
||||
- Don't activate a batch the user hasn't confirmed.
|
||||
- Don't spawn refinement agents for tickets that have no description — use
|
||||
`pql ticket refine` instead; it's cheaper and writes back through the
|
||||
proper channel.
|
||||
- Don't reach for `gh` — this system doesn't have it. Plain `git` only.
|
||||
- Don't `cd` into subdirectories — run everything from the repo root.
|
||||
@@ -69,3 +69,24 @@ jobs:
|
||||
- run: (cd app && flutter pub get)
|
||||
- run: (cd tools/ui && npm install && npx playwright install --with-deps chromium)
|
||||
- run: ci/test_e2e.sh
|
||||
|
||||
docs:
|
||||
name: dart doc (lib API)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with: { channel: stable, cache: true }
|
||||
- run: dart pub get
|
||||
- name: dart doc --validate-links (fail on warning)
|
||||
run: |
|
||||
set -o pipefail
|
||||
dart doc --validate-links 2>&1 | tee dartdoc.log
|
||||
if grep -q "^ warning:" dartdoc.log; then
|
||||
echo "::error::dartdoc emitted warnings — see log above"
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dart-api-docs
|
||||
path: doc/api/
|
||||
|
||||
@@ -33,6 +33,9 @@ tools/ui/.serve.pid
|
||||
# -- dugite-native (bundled git, downloaded at build time) ---------------
|
||||
/native/dugite/
|
||||
|
||||
# -- dart doc output (generated by `dart doc`, uploaded as CI artefact) -
|
||||
/doc/
|
||||
|
||||
# -- Test, coverage, profile output ------------------------------------
|
||||
*.test
|
||||
*.out
|
||||
@@ -95,3 +98,9 @@ legacy/**/.coverage
|
||||
legacy/**/.coverage.*
|
||||
.clide/settings.yaml
|
||||
.claude/skills/pql/
|
||||
|
||||
# frame0-wireframe local ID ↔ Frame0 ID mapping; per-machine state.
|
||||
*.idmap.json
|
||||
.pql/*
|
||||
!.pql/pql-plan.json
|
||||
!.pql/hooks/
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
# --- pql plan import ---
|
||||
# Auto-installed by pql init. Imports planning state when the
|
||||
# snapshot file changes on pull/merge. Safe no-op if pql is absent.
|
||||
if command -v pql >/dev/null 2>&1; then
|
||||
changed=$(git diff-tree -r --name-only ORIG_HEAD HEAD -- .pql/pql-plan.json 2>/dev/null)
|
||||
if [ -n "$changed" ]; then
|
||||
pql plan import 2>/dev/null
|
||||
fi
|
||||
fi
|
||||
# --- end pql ---
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
# --- pql plan export ---
|
||||
# Auto-installed by pql init. Exports planning state so it's
|
||||
# committed alongside code changes. Safe no-op if pql is absent.
|
||||
if command -v pql >/dev/null 2>&1; then
|
||||
pql plan export 2>/dev/null
|
||||
if ! git diff --quiet -- .pql/pql-plan.json 2>/dev/null; then
|
||||
git add .pql/pql-plan.json
|
||||
fi
|
||||
fi
|
||||
# --- end pql ---
|
||||
@@ -16,6 +16,105 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Staged `dart doc` CI job — generates and uploads an HTML API
|
||||
reference for the public `lib/` surface. The step wraps
|
||||
`dart doc --validate-links` and grep-fails the build on any warning,
|
||||
so broken doc refs and dangling links can't accumulate. Inert with
|
||||
the rest of the workflow until Gitea Actions activates.
|
||||
- Mouse wheel scrolling in Claude pane — converts scroll events to
|
||||
PgUp/PgDown so Claude Code (and other TUI apps) scroll their
|
||||
history naturally.
|
||||
- Welcome screen Tips card — six common keybindings shown below the
|
||||
START / RECENT row when the viewport is tall enough.
|
||||
- `MultitabPane` widget + `MultitabController` for panes that host
|
||||
N runtime tab instances of the same kind. Generic over a payload
|
||||
type, supports pinned/non-closeable tabs (primary), drag-reorder,
|
||||
close × on hover, and an optional `+` add button. Used by the
|
||||
Claude pane to render primary + secondaries.
|
||||
- `MultitabPane.keepAlive` mode — when set, all entry bodies stay
|
||||
mounted via IndexedStack so switching tabs preserves their state
|
||||
(PTY connections, scroll position, etc.).
|
||||
|
||||
### Changed
|
||||
|
||||
- Claude pane uses `MultitabPane` for primary + secondaries — drops
|
||||
~100 lines of bespoke tab-strip code, gains drag-to-reorder.
|
||||
- UI spacing constants live in `lib/widgets/src/spacing.dart` —
|
||||
`clideInset*` for paddings, `clideGap*` for sibling distances,
|
||||
`clideIcon*` / `clideControlHeight` for control sizes. Inline
|
||||
pixel literals replaced where they were repeated.
|
||||
|
||||
### Changed
|
||||
|
||||
- Tagline reads "IDE for Claude Code CLI" everywhere (welcome
|
||||
subtitle, README, CLAUDE.md, pubspec, web manifest, CLI banner).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Closing a secondary Claude pane tab now kills its tmux session
|
||||
on the clide socket, honouring D-41's "closing a secondary kills
|
||||
that tmux session" lifecycle. Previously `pane.close` only killed
|
||||
the ptyc-spawned tmux client and the server-side session leaked.
|
||||
- Cold-start reap: every clide launch kills any leftover secondary
|
||||
tmux sessions for the current repo before spawning new ones, so
|
||||
D-41's "secondary numbering resets between runs" holds even after
|
||||
an abrupt previous exit (kill -9, crash, force-quit).
|
||||
- `claude.kill-all-sessions` command now actually kills the
|
||||
server-side tmux sessions for the repo, not just the panes.
|
||||
- Terminal cell grid no longer drifts on bold text — bold rendering
|
||||
is suppressed at the painter level since synthetic bold (with no
|
||||
Bold.ttf registered) shifts glyph advance widths.
|
||||
- PTY surfaces errno on `forkpty`, `write`, and `ioctl` failures
|
||||
instead of swallowing them. `execve` failures in the spawned
|
||||
child now write a diagnostic line to the slave PTY before
|
||||
`_exit`, so the parent's reader sees the cause instead of an
|
||||
immediate EOF that looked indistinguishable from clean exit.
|
||||
PTY `write` loops on short writes; both `NativePty.write` and
|
||||
`PtySession.write` now throw `PtyException` on hard errors.
|
||||
- PTY teardown order fixed — kill the child first so the master
|
||||
fd returns EOF, await the reader isolate exit, then close the
|
||||
fd. Previously closing the fd while the isolate still polled it
|
||||
could briefly target a reused fd. Reader isolate spawn errors
|
||||
in both `NativePty` and `PtySession` are now surfaced via the
|
||||
output stream instead of silently dropped. `_recvFdAsync` no
|
||||
longer leaks the `ReceivePort` when `Isolate.spawn` throws, and
|
||||
`PtySession.spawn` closes the master fd if any post-receive
|
||||
step fails.
|
||||
- IPC server hardening: per-request 60s timeout (configurable),
|
||||
broadcast/response write failures logged instead of swallowed,
|
||||
client dropped on response-write failure, and the stale-socket
|
||||
retry now probes for a live daemon before unlinking the socket
|
||||
(refusing to start if one answers).
|
||||
- `pane.spawn` and `editor.open` now map POSIX errno values to
|
||||
actionable IPC error kinds. ENOENT → `not_found`, EACCES/EPERM
|
||||
→ `user_error` with a permissions hint, EISDIR/ENOTDIR/EEXIST
|
||||
→ distinct user-error/conflict, EMFILE/ENFILE → `tool_error`
|
||||
with a "fd limit hit" hint. Previously every spawn/open failure
|
||||
was an indistinguishable `tool_error`.
|
||||
|
||||
### Security
|
||||
|
||||
- `files.read` and `files.ls` now reject paths that resolve outside
|
||||
the workspace root. Previously a relative path containing `..`
|
||||
could read arbitrary files via path traversal.
|
||||
|
||||
### Changed
|
||||
|
||||
- Inline terminal emulator based on xterm.dart v4.0.0 — replaces the
|
||||
pub.dev dependency with owned code under `lib/src/terminal/`. Drops
|
||||
three transitive dependencies (xterm, quiver, zmodem).
|
||||
- Bundle clide-specific tmux.conf for Claude pane sessions: no status
|
||||
bar, 50k scrollback, mouse on, zero escape delay, isolated socket.
|
||||
- Claude pane spawns `claude` directly inside tmux with
|
||||
`CLAUDE_CODE_NO_FLICKER=1` to enable Claude's fullscreen TUI mode
|
||||
(input box pinned at the bottom).
|
||||
- PTY read buffer increased from 4KB to 64KB.
|
||||
- Terminal view 2px padding on all sides.
|
||||
- Remove bold JetBrains Mono font registration to prevent glyph width
|
||||
mismatch in terminal rendering.
|
||||
|
||||
## [2.0.0] — 2026-05-03
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## What clide is
|
||||
|
||||
A Flutter desktop IDE for Claude Code. Single Flutter package at the repo root, plus small native supporter tools where Dart can't reach.
|
||||
An IDE for Claude Code CLI. Single Flutter package at the repo root, plus small native supporter tools where Dart can't reach.
|
||||
|
||||
- **`lib/`** — all Dart code. Subsystem handlers (`lib/src/daemon/`, `lib/src/pty/`, `lib/src/ipc/`, `lib/src/git/`, `lib/src/pql/`), kernel services (`lib/kernel/`), UI widgets (`lib/widgets/`), built-in extensions (`lib/builtin/`), and the extension framework (`lib/extension/`). The Flutter app hosts the IPC server in-process (D-56).
|
||||
- **[`pql`](https://github.com/postmeridiem/pql)** — external supporter tool. Clide wraps it for every query surface; never re-implements it.
|
||||
|
||||
@@ -167,11 +167,11 @@ ifeq ($(FLUTTER_OS),linux)
|
||||
done
|
||||
@mkdir -p $(HOME)/.local/share/applications
|
||||
@sed 's|Exec=clide|Exec=$(INSTALL_PREFIX)/clide/clide|' linux/clide.desktop \
|
||||
> $(HOME)/.local/share/applications/clide.desktop
|
||||
> $(HOME)/.local/share/applications/net.schweitz.clide.desktop
|
||||
@gtk-update-icon-cache -f -t $(HOME)/.local/share/icons/hicolor 2>/dev/null || true
|
||||
@update-desktop-database $(HOME)/.local/share/applications 2>/dev/null || true
|
||||
@echo "installed: $(INSTALL_DIR)/clide -> $(INSTALL_PREFIX)/clide/clide"
|
||||
@echo "desktop: ~/.local/share/applications/clide.desktop"
|
||||
@echo "desktop: ~/.local/share/applications/net.schweitz.clide.desktop"
|
||||
@echo "version: $(VERSION)"
|
||||
else ifeq ($(FLUTTER_OS),macos)
|
||||
@mkdir -p $(HOME)/Applications
|
||||
@@ -189,6 +189,7 @@ uninstall: ## Remove installed clide.
|
||||
ifeq ($(FLUTTER_OS),linux)
|
||||
rm -f $(INSTALL_DIR)/clide
|
||||
rm -rf $(INSTALL_PREFIX)/clide
|
||||
rm -f $(HOME)/.local/share/applications/net.schweitz.clide.desktop
|
||||
rm -f $(HOME)/.local/share/applications/clide.desktop
|
||||
@for size in $(ICON_SIZES); do \
|
||||
rm -f $(HOME)/.local/share/icons/hicolor/$${size}x$${size}/apps/clide.png; \
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# clide
|
||||
|
||||
A Flutter desktop IDE for Claude Code. Native rendering, terminal-first interaction, pql-powered queries, canvas and graph surfaces. Linux and macOS.
|
||||
An IDE for Claude Code CLI. Native rendering, terminal-first interaction, pql-powered queries, canvas and graph surfaces. Linux and macOS.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -39,10 +39,10 @@ make push-check # pre-push gate: decisions + core + fast tests
|
||||
|
||||
## Status
|
||||
|
||||
Pre-v2.0 (`2.0.0-dev`). Interaction model and panel system landed. The Python Textual v1.2.0 predecessor is archived under [`legacy/`](legacy/).
|
||||
Pre-v2.0 (`2.0.0-dev`). Interaction model and panel system landed. The Python Textual v1.2.0 predecessor is archived under [`legacy/`](https://github.com/postmeridiem/clide/tree/main/legacy).
|
||||
|
||||
Design doc: [`docs/initial-plan.md`](docs/initial-plan.md). Architectural decisions: [`decisions/`](decisions/).
|
||||
Design doc: [`docs/initial-plan.md`](https://github.com/postmeridiem/clide/blob/main/docs/initial-plan.md). Architectural decisions: [`decisions/`](https://github.com/postmeridiem/clide/tree/main/decisions).
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [`LICENSE`](LICENSE).
|
||||
MIT. See [`LICENSE`](https://github.com/postmeridiem/clide/blob/main/LICENSE).
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# clide tmux.conf — loaded via tmux -f for every Claude pane session.
|
||||
# Tuned for embedding inside xterm.dart; no status bar, large
|
||||
# scrollback, mouse-scroll passthrough, zero escape delay.
|
||||
|
||||
# No status bar — clide renders its own pane chrome.
|
||||
set -g status off
|
||||
|
||||
# 50k lines of scrollback (tmux default is 2000).
|
||||
set -g history-limit 50000
|
||||
|
||||
# Zero escape delay — xterm.dart delivers escape sequences
|
||||
# atomically, so the 500ms default just adds latency.
|
||||
set -sg escape-time 0
|
||||
|
||||
# Mouse on — scroll wheel events reach tmux's copy-mode so the
|
||||
# user can scroll back through Claude output.
|
||||
set -g mouse on
|
||||
|
||||
# 256color + true-color passthrough.
|
||||
set -g default-terminal "xterm-256color"
|
||||
set -ga terminal-overrides ",xterm-256color:Tc"
|
||||
|
||||
# Don't ring the bell visually or audibly — clide owns notifications.
|
||||
set -g visual-bell off
|
||||
set -g bell-action none
|
||||
|
||||
# Keep the session alive when the shell exits — clide manages
|
||||
# lifecycle via pane.close, not tmux session destruction.
|
||||
set -g remain-on-exit off
|
||||
|
||||
# Allow alt-screen passthrough for full-screen programs.
|
||||
set -g alternate-screen on
|
||||
|
||||
# Focus events let the terminal's focus tracking work through tmux.
|
||||
set -g focus-events on
|
||||
@@ -53,7 +53,7 @@ dependencies:
|
||||
purpose: >-
|
||||
Monospace face for terminal panes, diff views, code editors, and
|
||||
any other monospace surface.
|
||||
weights_bundled: [Regular, Italic, Bold, BoldItalic]
|
||||
weights_bundled: [Regular, Italic]
|
||||
|
||||
- name: Josefin Sans
|
||||
kind: font
|
||||
@@ -87,17 +87,18 @@ dependencies:
|
||||
YAML parser for theme files and extension manifests. Justified
|
||||
exception to prefer-zero-deps; Dart-team maintained.
|
||||
|
||||
- name: xterm
|
||||
kind: dart-package
|
||||
- name: terminal (based on xterm.dart)
|
||||
kind: inlined-source
|
||||
version: "4.0.0"
|
||||
homepage: https://pub.dev/packages/xterm
|
||||
homepage: https://github.com/TerminalStudio/xterm.dart
|
||||
license: MIT
|
||||
license_file: lib/src/terminal/LICENSE
|
||||
purpose: >-
|
||||
Flutter-native terminal emulator (ANSI / xterm / truecolor
|
||||
parser + renderer). Powers every pane that renders a PTY —
|
||||
general terminal, Claude, diff views running shell commands.
|
||||
Writing a vt100 / ANSI parser is weeks of work for no fidelity
|
||||
gain.
|
||||
Terminal emulator (ANSI / xterm / truecolor parser + renderer).
|
||||
Inlined from xterm.dart v4.0.0 by xuty (MIT) and modified:
|
||||
Scrollable removed, quiver dependency replaced, zmodem/debugger
|
||||
stripped, scroll forwarding rewritten. Original copyright and
|
||||
MIT license preserved in lib/src/terminal/LICENSE.
|
||||
|
||||
- name: ffi
|
||||
kind: dart-package
|
||||
|
||||
@@ -89,7 +89,7 @@ Future<void> main(List<String> argv) async {
|
||||
|
||||
void _printHelp(IOSink sink) {
|
||||
sink.writeln('''
|
||||
clide $clideVersion — Flutter desktop IDE for Claude Code.
|
||||
clide $clideVersion — IDE for Claude Code CLI.
|
||||
|
||||
Usage:
|
||||
clide --daemon Run the long-running daemon process.
|
||||
|
||||
@@ -8,7 +8,7 @@ A11y + i18n are Tier-0 contracts, not Tier-6 polish.
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** Every widget primitive wraps its interaction surface in a `Semantics` node at the point of creation. A11y coverage is a test-time gate (`ci/test_a11y.sh`), not a post-hoc polish pass. `ensureSemantics()` fires at app boot; Flutter's semantics tree is always populated.
|
||||
- **Rationale:** Retrofitting a11y onto a grown UI is what every project that skips this promises to do later and then doesn't. Making it a Tier-0 contract costs one `Semantics` line per primitive and a semantic-coverage test; postponing costs a rewrite.
|
||||
- **Cost:** Widget authors maintain correct labels; tests reject new primitives without semantics. Enforced by `app/test/a11y/` coverage tests.
|
||||
- **Cost:** Widget authors maintain correct labels; tests reject new primitives without semantics. Enforced by `test/a11y/` coverage tests.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-21: i18n is a Tier-0 contract (fframe pattern + locale-fallback chain)
|
||||
|
||||
@@ -13,7 +13,7 @@ Core, rendering, IPC, kernel, panel manager.
|
||||
|
||||
### D-8: Feature-first folder layout
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** Under `app/lib/`, organise by feature (`kernel/`, `extension/`, `widgets/`, `builtin/<name>/`) rather than by layer (`models/`, `views/`, `controllers/`). Private implementation lives under each feature's `src/`; the feature's public surface is a barrel file at the feature root (e.g. `app/lib/kernel/kernel.dart`).
|
||||
- **Decision:** Under `lib/`, organise by feature (`kernel/`, `extension/`, `widgets/`, `builtin/<name>/`) rather than by layer (`models/`, `views/`, `controllers/`). Private implementation lives under each feature's `src/`; the feature's public surface is a barrel file at the feature root (e.g. `lib/kernel/kernel.dart`).
|
||||
- **Rationale:** Features grow and get deleted as units; layer-first layouts fragment a feature across three directories and make deletions risky. Matches extensions-as-features (every extension already has its own folder).
|
||||
- **Cost:** Imports cross features only via the barrel — enforce by review, no automated check yet.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
@@ -89,7 +89,7 @@ Core, rendering, IPC, kernel, panel manager.
|
||||
|
||||
### D-4: Ignore file strategy
|
||||
- **Date:** 2026-04-20 (was ADR 0004; ported from the claudian lineage)
|
||||
- **Decision:** One mechanism everywhere: the `ignore_files:` list in `.pql/config.yaml`. Ordered list of gitignore-shaped files; later entries win on per-pattern conflicts. pql defaults to `ignore_files: [.gitignore]`. Per [D-3](#d-3-pql-as-supporter-tool), clide writes the list on load — `[.gitignore, .clideignore]` if `.clideignore` exists, else `[.gitignore]`. `.clideignore` carries **only** the clide-specific deviations from `.gitignore` (supports `!pattern` negations); never duplicate gitignore's contents. Walker magic: none except `.git/` — every other tool-owned dir (`.pql/`, `.clide/`) is added to `.gitignore` at install time; exclusion flows through the normal `ignore_files:` chain.
|
||||
- **Decision:** One mechanism everywhere: the `ignore_files:` list in `.pql/config.yaml`. Ordered list of gitignore-shaped files; later entries win on per-pattern conflicts. pql defaults to `ignore_files: [.gitignore]`. Per [D-3](#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates), clide writes the list on load — `[.gitignore, .clideignore]` if `.clideignore` exists, else `[.gitignore]`. `.clideignore` carries **only** the clide-specific deviations from `.gitignore` (supports `!pattern` negations); never duplicate gitignore's contents. Walker magic: none except `.git/` — every other tool-owned dir (`.pql/`, `.clide/`) is added to `.gitignore` at install time; exclusion flows through the normal `ignore_files:` chain.
|
||||
- **Context:** Every file-enumerating surface in clide (pql query panels, canvas drivers, graph view, file watchers, pane lists, file tree) needs to skip the obvious junk — `vendor/`, `node_modules/`, `dist/`, build artifacts — or results drown in noise. Clide's working assumption is that the git repo *is* the workspace — no separate "vault" concept.
|
||||
- **Rationale:** Users get one config knob, in a file they might already know (pql users) or never need to touch (clide-only users). `.clideignore` is short by design — it's deltas, not a full list. Sidecar consumers read the same key and apply identical precedence, so Claude and the user always see the same filtered surface.
|
||||
- **Cost:** Removing clide from a repo leaves pql working with vanilla defaults (clide's last-written `ignore_files:` stays until pql or the user rewrites it; worth reconsidering during uninstall design).
|
||||
@@ -235,4 +235,12 @@ Core, rendering, IPC, kernel, panel manager.
|
||||
- **Cross-reference:** [D-47](#d-47-interaction-model-claude-is-home-layout) (center hat always visible), [D-51](#d-51-panel-collapse-12px-spine-with-badge) (spine-cap behavior).
|
||||
- **Raised by:** 2026-04-23 interaction model refinement.
|
||||
|
||||
### D-64: No telemetry — architectural commitment
|
||||
- **Date:** 2026-05-03
|
||||
- **Decision:** clide does not phone home. No analytics SDKs (Firebase, Sentry, Mixpanel, hand-rolled). No crash reporters that upload automatically — crashes produce local logs the user can read and optionally attach to a manual bug report. No auto-update checks without user action. No license validation calls. No feature flags fetched from a server. No A/B testing, experiments, remote config, or "anonymous usage statistics." This is not a "default off" setting; it is an architectural commitment. Proposals to add telemetry under any framing — opt-in, anonymized, debug-only, "just errors" — are out of scope for this project, full stop.
|
||||
- **Rationale:** clide is a space to think, not a surface for data collection. Users installing clide are choosing a tool that does not watch them. That promise is worth more than any data we could collect. The architectural commitment is the feature.
|
||||
- **Cost:** No usage data for product decisions; no automated crash triage. Accepted — user trust is the product decision.
|
||||
- **Cross-reference:** [D-60](tooling.md#d-60-no-network-on-default-launch-path), `POLICY.md`.
|
||||
- **Raised by:** 2026-05-03 policy-to-decision migration (T-28).
|
||||
|
||||
---
|
||||
|
||||
@@ -13,7 +13,7 @@ Extension contract, Lua runtime, grain, contribution points.
|
||||
|
||||
### D-16: Built-ins in Dart, third-party in sandboxed Lua
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** Bundled extensions (every `app/lib/builtin/<name>`) are Dart — they link into the app binary. Third-party extensions (Tier 6) run in sandboxed Lua via the `ptyc`-peer Lua runtime (see [D-19](#d-19-lua-runtime-as-ptyc-peer-supporter-tool)). The contribution contract is language-agnostic — same contribution shapes, same manifest schema.
|
||||
- **Decision:** Bundled extensions (every `lib/builtin/<name>`) are Dart — they link into the app binary. Third-party extensions (Tier 6) run in sandboxed Lua via the `ptyc`-peer Lua runtime (see [D-19](#d-19-lua-runtime-as-ptyc-peer-supporter-tool)). The contribution contract is language-agnostic — same contribution shapes, same manifest schema.
|
||||
- **Rationale:** Dart built-ins get full SDK power (custom painters, isolates, FFI); third-party Lua gets a narrow capability API, no arbitrary syscalls, no deps on pub.dev. VS Code's Node-runs-with-full-power model is a supply-chain nightmare we're explicitly rejecting.
|
||||
- **Cost:** Two implementation paths for the same contract; we pay in API design to keep them equivalent at the seams.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
@@ -41,7 +41,7 @@ Extension contract, Lua runtime, grain, contribution points.
|
||||
|
||||
### D-46: Core frame builtins vs shipped extensions boundary
|
||||
- **Date:** 2026-04-22
|
||||
- **Decision:** The `app/lib/builtin/` directory is reserved for core frame infrastructure — components the shell cannot function without. Everything that renders *content* (editor surfaces, tool panels, integrations) is a shipped extension: still Dart, still bundled in the binary, but architecturally an extension that registers through the contribution contract and could in principle be disabled by the user.
|
||||
- **Decision:** The `lib/builtin/` directory is reserved for core frame infrastructure — components the shell cannot function without. Everything that renders *content* (editor surfaces, tool panels, integrations) is a shipped extension: still Dart, still bundled in the binary, but architecturally an extension that registers through the contribution contract and could in principle be disabled by the user.
|
||||
|
||||
**Core frame builtins** (cannot be disabled; the frame breaks without them):
|
||||
`default-layout`, `welcome`, `ipc-status`, `theme-picker`, `terminal`, `files`, `grammars-core`, `settings-ui`, `extensions-ui`, `keybindings-ui`.
|
||||
@@ -53,7 +53,7 @@ Extension contract, Lua runtime, grain, contribution points.
|
||||
`editor`, `claude`, `claude-control`, `markdown`, `diff`, `git-ui`, `pql`, `canvas`, `graph`, `decisions`, `tickets`, `todos`, `problems`.
|
||||
|
||||
- **Rationale:** The previous session bled several content extensions (jira, todos, decisions, tickets, canvas, graph) into `builtin/` as stubs, treating "shipped with the app" as "part of the frame." This conflates two concerns: the frame's structural integrity and the bundled feature set. A user who disables the canvas extension should get a working IDE with no canvas panel; a user who disables the layout extension gets a broken window. The boundary is: can the frame render and function without it? If yes, it's a shipped extension, not a frame builtin.
|
||||
- **Cost:** Shipped extensions need a separate registration path (e.g. `app/lib/extensions/` or equivalent) distinct from `app/lib/builtin/`. The extension contract must support "bundled Dart extension" as a first-class category alongside "builtin" and "third-party Lua." Migration is incremental — move one at a time, each behind a working build.
|
||||
- **Cost:** Shipped extensions need a separate registration path (e.g. `lib/extensions/` or equivalent) distinct from `lib/builtin/`. The extension contract must support "bundled Dart extension" as a first-class category alongside "builtin" and "third-party Lua." Migration is incremental — move one at a time, each behind a working build.
|
||||
- **Supersedes:** Removes `builtin.jira` (already deleted; should never have been a builtin — Jira integration is a third-party extension, not a shipped one).
|
||||
- **Raised by:** 2026-04-22 session review.
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ Q&D record system itself, kanban, commit conventions, changelog.
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** Planning subcommands (`decisions`, `ticket`, `plan`) land in pql's repo long-term. Clide consumes them via shell-out, matching [D-3](architecture.md)'s wrap-don't-duplicate rule for pql. Clide does not grow Dart subcommands for planning.
|
||||
- **Rationale:** A terminal user or a user in VS Code / JetBrains still needs Q&D access. Binding planning tooling to clide-the-Flutter-app would cut them off from their own work — see [R-9](rejected.md#r-9-port-planning-tooling-into-clide). pql is already the CLI, already universal, already wrapped by clide.
|
||||
- **Cost:** Planning features don't ship until pql catches up. Mitigated by [D-40](#d-40-python-stopgap-under-toolsscriptsplan). Gated by [Q-21](questions-process.md#q-21-pql-absorbs-planning-vs-keeps-separate).
|
||||
- **Cost:** Planning features don't ship until pql catches up. Mitigated by [D-40](#d-40-superseded-python-stopgap-under-toolsscriptsplan). Gated by [Q-21](questions-process.md#q-21-pql-absorbs-planning-vs-keeps-separate).
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-40: [SUPERSEDED] Python stopgap under `tools/scripts/plan`
|
||||
|
||||
@@ -16,7 +16,7 @@ Tooling-domain questions currently live here too. Split into
|
||||
### Q-16: `tree-sitter-dart` grammar maintenance
|
||||
- **Status:** Open
|
||||
- **Question:** `UserNobody14/tree-sitter-dart` is archived. `nielsenko/tree-sitter-dart` is the maintained fork. Do we pin `nielsenko/`, mirror it in-repo, or lean on the Dart analyzer's own semantic output and skip tree-sitter for Dart?
|
||||
- **Context:** If tree-sitter is the Tier-2 answer ([Q-15](#q-15-editor-tab-full-lsp-vs-tree-sitter-only)), grammar sourcing matters.
|
||||
- **Context:** If tree-sitter is the Tier-2 answer ([Q-15](#q-15-editor-tab-full-lsp-vs-tree-sitter-only-highlight)), grammar sourcing matters.
|
||||
- **Source:** 2026-04-21 planning.
|
||||
|
||||
### Q-17: Icon set growth
|
||||
|
||||
@@ -41,7 +41,7 @@ Test pyramid, drivers, client-side constraint.
|
||||
|
||||
### D-28: Test organisation — mirror `lib/` in `test/`
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** Every test file lives at the same relative path as its subject. `app/lib/kernel/src/i18n/catalog_loader.dart` pairs with `app/test/kernel/i18n/catalog_loader_test.dart`. No separate `unit/` vs `widget/` directories; test type is detected by what the test imports.
|
||||
- **Decision:** Every test file lives at the same relative path as its subject. `lib/kernel/src/i18n/catalog_loader.dart` pairs with `test/kernel/i18n/catalog_loader_test.dart`. No separate `unit/` vs `widget/` directories; test type is detected by what the test imports.
|
||||
- **Rationale:** Matching paths makes "jump to test" predictable in any editor. Type-by-imports matches how `flutter test` already works.
|
||||
- **Cost:** Large feature folders mirror into large test folders. Acceptable.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
@@ -20,7 +20,7 @@ Toolchain, supply chain, CI, ignore strategy.
|
||||
|
||||
### D-42: Dependencies documented in `licenses.yaml`
|
||||
- **Date:** 2026-04-22
|
||||
- **Decision:** `app/assets/licenses.yaml` has three sections: `self:` (clide's MIT license, rendered first in the About screen so the user knows what they're running), `dependencies:` (third-party artefacts that **ship in the binary** — fonts, runtime Dart packages, native supporter tools, bundled data), and `dev_dependencies:` (build-time-only tooling — test runners, mocks, lints, golden harness — tracked for audit but **not rendered** in the About screen because they don't reach the user). Each entry has name, kind, version, homepage, license identifier, and a one-line purpose; runtime entries also carry a `license_file:` pointer to the bundled license text so the About screen can display it verbatim. Adding any dependency is a two-step commit: add the artefact **and** the corresponding `licenses.yaml` entry in the same changeset, under the correct section.
|
||||
- **Decision:** `assets/licenses.yaml` has three sections: `self:` (clide's MIT license, rendered first in the About screen so the user knows what they're running), `dependencies:` (third-party artefacts that **ship in the binary** — fonts, runtime Dart packages, native supporter tools, bundled data), and `dev_dependencies:` (build-time-only tooling — test runners, mocks, lints, golden harness — tracked for audit but **not rendered** in the About screen because they don't reach the user). Each entry has name, kind, version, homepage, license identifier, and a one-line purpose; runtime entries also carry a `license_file:` pointer to the bundled license text so the About screen can display it verbatim. Adding any dependency is a two-step commit: add the artefact **and** the corresponding `licenses.yaml` entry in the same changeset, under the correct section.
|
||||
- **Rationale:** Complements [D-31](#d-31-prefer-zero-deps-exact-pin). Prefer-zero-deps is a *budget*; `licenses.yaml` is the *visible consequence*. An extra row in the About screen is a review-time signal that the shipped-binary surface grew. Splitting dev deps out keeps the user-facing list small and honest — a test framework is not something the user needs to see in About — while still documenting every supply-chain input for audit completeness. The runtime entries discharge the redistribution obligations bundled licenses impose (OFL, MIT, BSD all require preserving the license text alongside the binary) without ad-hoc NOTICE files.
|
||||
- **Cost:** One extra edit per dep. Zero tolerance for drift — an un-listed dep is a contributor-visible bug. Until the About screen lands at Tier 6, `licenses.yaml` is accurate but not rendered; the discipline applies from now regardless so Tier 6 inherits a clean list.
|
||||
- **Raised by:** 2026-04-22 planning (user-directed best practice).
|
||||
@@ -47,8 +47,48 @@ Toolchain, supply chain, CI, ignore strategy.
|
||||
- **Decision:** Ship a self-contained Git binary from [dugite-native](https://github.com/desktop/dugite-native) (the same distribution GitHub Desktop bundles). Downloaded at build time via `make dugite-fetch`, stored under `native/dugite/`, gitignored. The `Toolchain` class resolves to the bundled binary first, falling back to system git on PATH.
|
||||
- **Rationale:** The macOS app sandbox blocks execution of Homebrew-installed git (symlinks resolve to Cellar paths that SBPL cannot match without freezing rendering). `/usr/bin/git` is an xcrun shim that refuses to run inside a sandbox. Bundling dugite-native makes clide self-contained — no dependency on Homebrew, Xcode CLT, or system git. The approach is proven: GitHub Desktop, Tower, and other git GUI apps all bundle their own git for the same reason.
|
||||
- **Alternatives rejected:** (R) libgit2 via FFI — missing porcelain commands (pull/push/rebase), no hooks, would require rewriting GitClient. (R) Build git from source — dugite-native already does this with better infra. (R) SBPL exceptions for Homebrew — `(subpath "/opt/homebrew")` for process-exec freezes Flutter rendering on macOS 26.
|
||||
- **Cost:** ~57 MB download (~199 MB unpacked, stripped at build time). Must track dugite-native releases for security updates. GPL-2.0 (git binary) applies to the bundled artefact, not to clide's MIT code.
|
||||
- **Cost:** ~57 MB download (~199 MB unpacked, stripped at build time). Must track dugite-native releases for security updates (tracked in T-88). GPL-2.0 (git binary) applies to the bundled artefact, not to clide's MIT code.
|
||||
- **Cross-reference:** [D-31](#d-31-prefer-zero-deps-exact-pin), [D-42](#d-42-dependencies-documented-in-licensesyaml).
|
||||
- **Raised by:** 2026-04-25 macOS sandbox investigation.
|
||||
|
||||
### D-60: No network on default launch path
|
||||
- **Date:** 2026-04-26
|
||||
- **Decision:** clide does not perform network I/O during app startup, library initialization, or first use of any API unless the user has explicitly taken an action whose stated purpose is to cause a network fetch. Opening the app, opening a file, or typing in a buffer are not such actions. Libraries that download native binaries on first import (the `wasm_run` pattern), auto-installing language servers/grammars, CDN-fetched assets, startup telemetry, and unsolicited update checks are all prohibited. Signed, pinned fetches are permitted only when: the URL is hardcoded in the repo, the artifact is verified against a committed hash or signature, the fetch is cached, failure produces a clear error, and the primary function works without the fetch succeeding. If all five cannot be satisfied, vendor the artifact or require explicit user action.
|
||||
- **Rationale:** clide's security model claims that app behavior on a user's machine is fully determined by the signed release artifact and the repository state at build time. The moment something is fetched from the network that wasn't audited at build time, the entire sandboxing and trust story collapses. See `POLICY.md` §"The core rule."
|
||||
- **Cost:** Some features require vendoring artifacts that other apps would download at first launch. Accepted — the trust boundary is worth the extra build complexity.
|
||||
- **Cross-reference:** [D-31](#d-31-prefer-zero-deps-exact-pin), [D-63](#d-63-vendored-binary-rebuild-process), `POLICY.md`.
|
||||
- **Raised by:** 2026-04-26 policy-to-decision migration (T-28).
|
||||
|
||||
### D-61: Dependency vetting checklist
|
||||
- **Date:** 2026-04-26
|
||||
- **Decision:** Before adding any dependency (direct or transitive), verify: (1) **Network behavior** — no network I/O during import, init, or first call; no postinstall scripts that download binaries; check transitive deps with `flutter pub deps`. (2) **Binary provenance** — native binaries must be built from source in the same repo, not fetched from release artifacts. (3) **Maintainership** — single-maintainer packages need explicit sign-off and a documented fallback; packages with no activity in 12+ months require a controlled fork or inlining. (4) **Surface area** — prefer packages that do one thing; a dep adding 15 transitive deps for a 100-line problem should be inlined. (5) **Version pinning** — exact-pinned per D-31, lockfile committed, CVE-checked, source-reviewed, justified in place. (6) **License** — compatible per D-65.
|
||||
- **Rationale:** D-31 states the budget; this record codifies the gate each dependency must pass. The checklist exists so agents and human contributors apply the same standard without re-deriving it each time.
|
||||
- **Cost:** Longer evaluation cycle for new dependencies. Intentional — the cost of a bad dep is higher.
|
||||
- **Cross-reference:** [D-31](#d-31-prefer-zero-deps-exact-pin), [D-60](#d-60-no-network-on-default-launch-path), [D-65](#d-65-license-compatibility-matrix), `POLICY.md`.
|
||||
- **Raised by:** 2026-04-26 policy-to-decision migration (T-28).
|
||||
|
||||
### D-62: Dependency removal process
|
||||
- **Date:** 2026-04-26
|
||||
- **Decision:** A dependency is not removed until all five steps are completed in a single PR: (1) Grep the entire repository for references to the package, its exports, and contributed type names — zero hits outside git history. (2) Regenerate the lockfile. (3) Update `assets/licenses.yaml` to drop the package and any orphaned transitive deps. (4) Remove any vendored artifacts (binaries, prebuilt assets, generated bindings) and delete their `BUILD.md` records. (5) Check for architectural assumptions the dep was carrying — if it justified a data flow, build step, or platform strategy, the replacement must pick up those responsibilities or the relevant D-record must be updated.
|
||||
- **Rationale:** "I deleted the line from pubspec.yaml" is the start of a removal, not the end. Partial removals leave orphaned lockfile entries (installed on fresh clones), stale license entries, or orphaned vendored binaries that look legitimate.
|
||||
- **Cost:** Removal PRs are larger than the one-line diff suggests. Accepted.
|
||||
- **Cross-reference:** [D-31](#d-31-prefer-zero-deps-exact-pin), [D-42](#d-42-dependencies-documented-in-licensesyaml), `POLICY.md`.
|
||||
- **Raised by:** 2026-04-26 policy-to-decision migration (T-28).
|
||||
|
||||
### D-63: Vendored binary rebuild process
|
||||
- **Date:** 2026-04-26
|
||||
- **Decision:** Every vendored native binary has a `BUILD.md` next to it recording: (1) exact upstream source (git URL + commit SHA, not a version tag), (2) full build command with all compile flags, (3) toolchain version (compiler, linker, target triple), (4) expected output size and SHA-256 hash, (5) any patches applied (stored as `.patch` files in the same directory). Rebuilds happen in CI, not on contributor machines. The rebuild PR updates `BUILD.md`, the binaries, and hashes atomically. No binary is committed without a reproducibility record. Security patches to vendored deps are tracked with the same urgency as source-level vulnerabilities. Dropping a platform requires a policy decision; adding one requires adding it to the CI matrix and rebuilding all vendored binaries first.
|
||||
- **Rationale:** Vendored binaries are inside the trust boundary — the signed release contains exactly these bytes. Without reproducibility records, a committed binary is unverifiable and therefore untrustworthy.
|
||||
- **Cost:** Rebuilds require CI infrastructure and cross-compilation. Currently partially manual (T-25 tracks full CI automation).
|
||||
- **Cross-reference:** [D-60](#d-60-no-network-on-default-launch-path), [D-42](#d-42-dependencies-documented-in-licensesyaml), T-25, `POLICY.md`.
|
||||
- **Raised by:** 2026-04-26 policy-to-decision migration (T-28).
|
||||
|
||||
### D-65: License compatibility matrix
|
||||
- **Date:** 2026-04-26
|
||||
- **Decision:** clide is MIT-licensed. Every dependency, vendored binary, bundled font, and asset must be compatible and attributed. **Compatible (permissive):** MIT, Apache-2.0, BSD-2/3, ISC, Zlib, Unlicense, CC0. **Compatible with care (copyleft):** MPL-2.0 for libraries; LGPL only for dynamically-linked vendored binaries where users can replace the library. **Not compatible:** GPL for linked code (GPL vendored binaries like git are fine — they ship as separate executables), AGPL, SSPL, "commercial use prohibited," unreviewed custom licenses. Apache-2.0 deps preserve their NOTICE file verbatim. Apache-2.0-with-LLVM-exception requires the exception text specifically. Fonts and icon sets are attributed even if the license doesn't strictly require it. An incompatible or unclear license is disqualifying regardless of technical merit.
|
||||
- **Rationale:** The compatibility rules existed in POLICY.md but were not captured as a D-record, making them invisible to the decision-reference system. This record makes them queryable and cross-referenceable.
|
||||
- **Cost:** License evaluation adds time to the vetting checklist. Intentional.
|
||||
- **Cross-reference:** [D-31](#d-31-prefer-zero-deps-exact-pin), [D-42](#d-42-dependencies-documented-in-licensesyaml), [D-61](#d-61-dependency-vetting-checklist), `POLICY.md`.
|
||||
- **Raised by:** 2026-04-26 policy-to-decision migration (T-28).
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# PTY + IPC error-handling audit
|
||||
|
||||
Date: 2026-05-05
|
||||
Ticket: T-18
|
||||
Decision ref: D-5
|
||||
|
||||
Punch list of error-handling issues in `lib/src/pty/`, `lib/src/ipc/`,
|
||||
and `lib/src/daemon/`. Severity-ranked. Each item references the
|
||||
follow-up ticket where the fix lands.
|
||||
|
||||
## Critical — silent failures, leaks, races
|
||||
|
||||
1. **`lib/src/pty/native_pty.dart:155-158`** — `forkpty()` failure
|
||||
throws `StateError('forkpty() failed')` with no errno. Caller
|
||||
can't distinguish ENOMEM/EAGAIN/ENOENT-of-/dev/ptmx. Capture
|
||||
errno before `_freeAll` (which may trample it) and surface via
|
||||
`PtyException`. → T-75
|
||||
|
||||
2. **`lib/src/pty/native_pty.dart:160-165`** — Child process: `chdir`
|
||||
and `execve` returns are ignored. If `execve` returns (i.e.
|
||||
fails), we fall through to `_exit(1)` with no diagnostic. Write
|
||||
a one-line error envelope to fd 1 before exiting so the parent's
|
||||
reader sees "exec failed: ENOENT" instead of immediate EOF. → T-75
|
||||
|
||||
3. **`lib/src/pty/native_pty.dart:244-251`** — `write()` ignores
|
||||
`_nativeWrite` return. Short writes silently drop bytes; -1/EPIPE
|
||||
reported as successful "wrote -1". Loop until full length is
|
||||
written or surface errno on negative returns. → T-75
|
||||
|
||||
4. **`lib/src/pty/native_pty.dart:259-262`** — `resize()` ignores
|
||||
`_ioctl` and `_nativeKill` return values. EBADF on a half-closed
|
||||
fd silently no-ops. Set `_dead = true` on EBADF. → T-75
|
||||
|
||||
5. **`lib/src/pty/native_pty.dart:198-210`** — Race: `_spawnReader`
|
||||
is `async` but `NativePty.start` returns immediately. `close()`
|
||||
racing with isolate spawn can leave the isolate orphaned. Make
|
||||
`start` await reader spawn or track the spawn-future. → T-76
|
||||
|
||||
6. **`lib/src/pty/native_pty.dart:280-290`** — `close()` sets
|
||||
`_dead = true` *before* `_nativeClose(_fd)`, but the reader
|
||||
isolate continues polling on `_fd`. If a new fd reuses that
|
||||
number, the reader's `poll` may briefly target the wrong file.
|
||||
Send shutdown signal via SendPort or self-pipe before closing. → T-76
|
||||
|
||||
7. **`lib/src/pty/session.dart:135-153`** — Resource leak: if
|
||||
`_recvFdAsync`, `setWinsize`, `proc.stdout.first.timeout`, or
|
||||
`_extractPid` throws, the spawned ptyc Process and (in some
|
||||
cases) the received `masterFd` leak. Only line 151 closes
|
||||
`masterFd`. Wrap post-spawn block in try/catch that kills `proc`,
|
||||
closes `masterFd`, and rethrows. → T-76
|
||||
|
||||
8. **`lib/src/pty/session.dart:240`** — `_recvFdAsync`: if
|
||||
`Isolate.spawn` itself throws, `port` is leaked. Wrap in
|
||||
try/catch. → T-76
|
||||
|
||||
9. **`lib/src/pty/session.dart:165-176`** — `write()` returns raw
|
||||
`libc.write` result without checking < 0 / errno or looping for
|
||||
short writes. Same as #3. → T-75
|
||||
|
||||
10. **`lib/src/pty/session.dart:271-275`** — `Isolate.spawn(...).then(...)`
|
||||
is fire-and-forget. If spawn fails, the error is silently
|
||||
swallowed and `_readerIsolate` remains null forever. Add
|
||||
`.catchError` or await it. → T-76
|
||||
|
||||
11. **`lib/src/ipc/server.dart:30-39`** — `broadcast()` `try/catch (_)`
|
||||
swallows write errors with no logging. At least log the kind. → T-77
|
||||
|
||||
12. **`lib/src/ipc/server.dart:107`** — `client.writeln(resp.encode())`
|
||||
is not awaited and not guarded. If client disconnected mid-dispatch,
|
||||
this throws asynchronously with no `onError` handler. Wrap in
|
||||
try/catch and remove the client from `_clients`. → T-77
|
||||
|
||||
13. **`lib/src/ipc/server.dart:83-108`** — `_handleLine` runs
|
||||
`await dispatch(msg)` with no per-request timeout. A misbehaving
|
||||
handler blocks the connection's read pipeline indefinitely. → T-77
|
||||
|
||||
14. **`lib/src/ipc/server.dart:46-50`** — Stale-socket retry deletes
|
||||
the socket file unconditionally on `SocketException`. If two
|
||||
daemon instances race to start, the second rips the first's live
|
||||
socket out from under it. Try `connect()` first; refuse if a
|
||||
live daemon answers. → T-77
|
||||
|
||||
## High — degraded UX / debugging
|
||||
|
||||
15. **`lib/src/daemon/pane_commands.dart:87-96`** — `_spawn`
|
||||
catch-all flattens every failure into `tool_error: pane.spawn
|
||||
failed: <toString>`. "binary not found", "permission denied",
|
||||
"out of pty fds" all look the same. Map `PtyException.errno`
|
||||
(ENOENT/EACCES/EMFILE) to distinct hints/codes. → T-79
|
||||
|
||||
16. **`lib/src/daemon/editor_commands.dart:67-76`** — Same pattern;
|
||||
`editor.open` catch-all loses FileSystemException distinctions
|
||||
(ENOENT vs EACCES vs EISDIR). → T-79
|
||||
|
||||
17. **`lib/src/daemon/files_commands.dart:78`** — `file.readAsStringSync()`
|
||||
is unguarded; UTF-8 errors, permission errors, races with deletion
|
||||
turn into a 500-style dispatch error instead of a clean
|
||||
`IpcResponse.err`. Wrap in try/catch. → T-81
|
||||
|
||||
18. **`lib/src/daemon/files_commands.dart:74`** — Path is concatenated
|
||||
with `/` and never validated. `path: "../../../etc/passwd"`
|
||||
traverses out of `files.root`. Resolve and verify the resulting
|
||||
path stays under `root.absolute.path`. → T-78 (security)
|
||||
|
||||
19. **`lib/src/pty/session.dart:201-234`** — `close()` distinguishes
|
||||
EOF/EBADF/EIO only in comments. The 500ms timeout is silent
|
||||
(`onTimeout: () {}`). Log the timeout so we know when SIGKILL
|
||||
was actually needed. → T-81
|
||||
|
||||
20. **`lib/src/pty/session.dart:390-394`** — Reader isolate treats
|
||||
any negative read return that isn't EINTR as EOF — including
|
||||
transient EAGAIN or recoverable EIO. Inspect errno and log
|
||||
non-EBADF/EIO/0 cases. → T-81
|
||||
|
||||
21. **`lib/src/pty/ffi/scm_rights.dart:115-116`** — Returned cmsg-data
|
||||
fd is read without sanity-checking against `msgControllen`. A
|
||||
malformed peer that sends only a partial cmsg could let us read
|
||||
garbage as an fd. Verify `dataOffset + 4 <= msgControllen`
|
||||
before deref. → T-81
|
||||
|
||||
22. **`lib/src/ipc/server.dart:41-56`** — `start()` logs to
|
||||
`stderr.writeln` but the rest of the daemon uses no logger. In
|
||||
the Flutter-host process stderr is often consumed by the engine.
|
||||
Standardize on a logger. → T-80
|
||||
|
||||
## Medium — cleanliness
|
||||
|
||||
23. **`lib/src/pty/session.dart:390`, `native_pty.dart:262, 285`** —
|
||||
Magic errno/signal numbers (`4=EINTR`, `9=SIGKILL`, `28=SIGWINCH`,
|
||||
`_kSighup=1`). Pull into named constants. → T-80
|
||||
|
||||
24. **`lib/src/pty/ffi/libc.dart:232-245`** — `errno` getter does a
|
||||
`lookupFunction` on every access (catching ArgumentError every
|
||||
call on macOS). Cache the resolved function pointer. → T-80
|
||||
|
||||
25. **`lib/src/daemon/git_commands.dart:283`** — `_gitError` always
|
||||
reports `tool_error`. A `git push` rejection or merge conflict is
|
||||
user-actionable, not a tool failure; could map to
|
||||
`IpcExitCode.conflict` when stderr matches known patterns. → T-81
|
||||
|
||||
26. **`lib/src/ipc/server.dart:97`** — Dispatch error shows
|
||||
`dispatch failed: $e` (full exception toString). Trim and add
|
||||
the request `cmd` for log correlation. → T-80
|
||||
|
||||
27. **`lib/src/daemon/pane_commands.dart:136`** — `registry.write(id, bytes)`
|
||||
return value `n` is shown to caller, but if `n == -1` (write failed)
|
||||
we still respond `ok`. Distinguish. → T-81
|
||||
|
||||
28. **`lib/src/ipc/envelope.dart:88-94`** — `IpcResponse.fromJson`
|
||||
throws `TypeError` if `ok=false` but `error` is missing. No
|
||||
graceful degradation for a malformed peer response. → T-81
|
||||
|
||||
29. **`lib/src/pty/native_pty.dart:111-119`** — PATH resolution
|
||||
silently uses the first existing match without checking `X_OK`.
|
||||
A non-executable file shadows a valid binary further along PATH. → T-81
|
||||
@@ -1,4 +1,25 @@
|
||||
# clide · design handoff
|
||||
# clide · design handoff (superseded reference)
|
||||
|
||||
> **Status (2026-05-06):** Reference-only. The implementation has
|
||||
> moved past these mockups. The canonical wireframe set now lives at
|
||||
> [`docs/wireframes/`](../wireframes/), generated from the actual
|
||||
> implementation via the `frame0-wireframe` skill.
|
||||
>
|
||||
> Update wireframes there, not here.
|
||||
>
|
||||
> **Why kept:** the design tokens under `tokens/` and `themes/` still
|
||||
> feed the runtime themes (per [D-43](../../decisions/architecture.md#d-43-design-handoff-adopt-token-palettes-reject-material-wrapper)
|
||||
> / [D-44](../../decisions/architecture.md#d-44-four-bundled-themes-clide-midnight-paper-terminal)).
|
||||
> The HTMLs and PNGs are kept for historical context.
|
||||
>
|
||||
> **What changed since:** welcome screen has logo-with-wordmark and a
|
||||
> Tips card spanning both columns; status line with theme switcher
|
||||
> lives at the bottom right; Claude pane runs in fullscreen mode
|
||||
> (`CLAUDE_CODE_NO_FLICKER=1`) so the input box is pinned by Claude
|
||||
> Code itself; tmux uses an isolated `-L clide` socket with bundled
|
||||
> config; sidebar layout follows D-47's "Claude is home" model.
|
||||
|
||||
---
|
||||
|
||||
Bundle for importing into the clide repo and driving further work with Claude Code.
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# Multitab pane — design
|
||||
|
||||
Ticket: T-83
|
||||
Drives: T-24 (secondary Claude pane UI wiring)
|
||||
Date: 2026-05-06
|
||||
|
||||
## Problem
|
||||
|
||||
Some panes need to host multiple, dynamically-spawned views of the
|
||||
same kind. The first concrete case is the Claude pane: per
|
||||
[D-41](../../decisions/architecture.md#d-41-claude-panes-one-primary-per-repo-tmux-backed),
|
||||
each repo has exactly one **primary** Claude pane plus zero or more
|
||||
**secondary** panes spawned at runtime. The user needs a way to:
|
||||
|
||||
- See which Claude sessions are open
|
||||
- Switch between them
|
||||
- Spawn a new secondary
|
||||
- Close a secondary (primary has no close affordance)
|
||||
|
||||
The kernel's existing `TabContribution` system addresses a different
|
||||
need — it lets extensions statically declare which widget shows up in
|
||||
which **panel slot** (sidebar, workspace, context). It does not
|
||||
support dynamic tab instances *within* a single contribution.
|
||||
|
||||
This design fills that gap with a reusable widget, so future panes
|
||||
that need the same shape (potentially the editor — see D-48 — or
|
||||
diff/preview surfaces) can adopt it without reinventing tab strips.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Replacing `TabContribution`. Slot-host tabs are static-by-design;
|
||||
this widget is for inside-a-tab dynamism.
|
||||
- Window-level tab management (browser-style "tear off into a window").
|
||||
- Editor multi-buffer tabs. [D-48](../../decisions/architecture.md#d-48-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first)
|
||||
rejected those; revisiting is a separate decision.
|
||||
|
||||
## API sketch
|
||||
|
||||
```dart
|
||||
class MultitabPane<T> extends StatefulWidget {
|
||||
const MultitabPane({
|
||||
required this.controller,
|
||||
required this.tabBuilder,
|
||||
required this.bodyBuilder,
|
||||
this.onCloseRequested,
|
||||
this.onAddRequested,
|
||||
this.allowReorder = true,
|
||||
});
|
||||
|
||||
final MultitabController<T> controller;
|
||||
final Widget Function(BuildContext, MultitabEntry<T>) tabBuilder;
|
||||
final Widget Function(BuildContext, MultitabEntry<T>) bodyBuilder;
|
||||
final void Function(MultitabEntry<T> entry)? onCloseRequested;
|
||||
final void Function()? onAddRequested;
|
||||
final bool allowReorder;
|
||||
}
|
||||
|
||||
class MultitabEntry<T> {
|
||||
final String id; // stable identity (e.g. "claude.primary")
|
||||
final String title; // display label
|
||||
final bool closeable; // primary tabs set this false
|
||||
final bool reorderable; // primary often pinned to position 0
|
||||
final T payload; // domain object the bodyBuilder renders
|
||||
}
|
||||
|
||||
class MultitabController<T> extends ChangeNotifier {
|
||||
List<MultitabEntry<T>> get entries;
|
||||
MultitabEntry<T>? get active;
|
||||
|
||||
void add(MultitabEntry<T> entry, {bool activate = true});
|
||||
void remove(String id);
|
||||
void activate(String id);
|
||||
void reorder(String id, int newIndex);
|
||||
}
|
||||
```
|
||||
|
||||
The widget is a thin shell:
|
||||
- Renders the tab strip via `ClideTabBar` (or a reorderable variant)
|
||||
- Calls `bodyBuilder(active)` for the visible content
|
||||
- Routes user gestures to controller methods or callbacks
|
||||
- Emits `onCloseRequested` / `onAddRequested` so the host decides
|
||||
the actual lifecycle (e.g. Claude pane spawns a new tmux session,
|
||||
doesn't just append a UI tab)
|
||||
|
||||
The host owns the controller and the payload type. The widget never
|
||||
touches PTY, IPC, or Claude session naming.
|
||||
|
||||
## Rendering
|
||||
|
||||
The tab strip lives at the top of the pane chrome. Layout:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ [primary] [secondary 1] [secondary 2] [+] │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ active tab body │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Active tab: filled background, bright text
|
||||
- Inactive: muted background, muted text
|
||||
- Close glyph (×) appears on hover for `closeable` tabs
|
||||
- `+` button at the end if `onAddRequested` is set
|
||||
- Drag-to-reorder respects `reorderable`; non-reorderable tabs
|
||||
(primary) are pinned to position 0 and other tabs cannot be
|
||||
dropped before them
|
||||
|
||||
## Interaction
|
||||
|
||||
- **Click a tab** → activate
|
||||
- **Click ×** → call `onCloseRequested(entry)`; host decides whether
|
||||
to confirm, kill the underlying session, etc.
|
||||
- **Drag-and-drop** → call `controller.reorder(id, newIndex)` after
|
||||
the gesture completes; controller enforces pinned positions
|
||||
- **Click +** → call `onAddRequested()`; host creates the new entry
|
||||
and adds it via `controller.add(...)`
|
||||
- **Keyboard**: `⌘1`–`⌘9` jump to tab N; `⌘W` close active (skipped
|
||||
for non-closeable); `⌘⇧[` / `⌘⇧]` cycle prev/next
|
||||
|
||||
## Persistence
|
||||
|
||||
Out of scope for the widget. Hosts that want to persist tab order or
|
||||
which tabs were open across sessions read/write through their own
|
||||
settings layer and seed the controller on init.
|
||||
|
||||
## Claude pane integration (T-24)
|
||||
|
||||
```
|
||||
ClaudePane (host)
|
||||
└── MultitabPane<ClaudeSessionRef>(
|
||||
controller: claudeTabsController,
|
||||
tabBuilder: (ctx, e) => Text(e.title),
|
||||
bodyBuilder: (ctx, e) => ClaudePaneBody(session: e.payload),
|
||||
onAddRequested: () => kernel.claude.spawnSecondary(),
|
||||
onCloseRequested: (e) => kernel.claude.closeSecondary(e.payload),
|
||||
)
|
||||
```
|
||||
|
||||
`ClaudeSessionRef` carries the tmux session name + isPrimary. The
|
||||
controller is seeded with `[primary]` on boot; secondaries get
|
||||
appended as the user clicks `+`. Closing a secondary triggers
|
||||
`pane.close` IPC and removes the entry; closing the primary is not
|
||||
exposed (`closeable: false`).
|
||||
|
||||
## What ships in this ticket
|
||||
|
||||
T-83 delivers:
|
||||
1. `MultitabPane` widget + `MultitabController` + `MultitabEntry`
|
||||
under `lib/widgets/src/`
|
||||
2. Unit tests for controller invariants (pinned positions, active
|
||||
selection survives close, reorder bounds)
|
||||
3. Widget tests for the strip (selection, close hover, add button,
|
||||
drag-reorder)
|
||||
4. This design doc
|
||||
|
||||
T-24 picks up after and wires the Claude pane to it.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Q: Where does keyboard handling live?** Host or widget?
|
||||
Recommendation: widget owns `⌘W` / `⌘1`–`⌘9` / cycle; host wires
|
||||
them via the existing kernel commands surface. Avoids each host
|
||||
reinventing the same shortcuts.
|
||||
|
||||
**Nesting caveat:** the widget composes (a Claude tab can host
|
||||
its own `MultitabPane<EditorBuffer>` etc.). Shortcut handling
|
||||
must be scoped to the focus subtree, not registered globally —
|
||||
otherwise the outermost pane consumes `⌘W` even when the user
|
||||
is typing in a nested tab. Implementation: wrap shortcuts in a
|
||||
`Shortcuts` / `Actions` widget inside the pane's `Focus` scope
|
||||
so the innermost focused pane wins via Flutter's normal
|
||||
shortcut-resolution chain.
|
||||
|
||||
- **Q: Tab overflow** when many secondaries open? Recommendation:
|
||||
start with horizontal scroll; revisit if it becomes a problem.
|
||||
|
||||
- **Q: Tab-strip visual style** — match `ClideTabBar` exactly, or
|
||||
introduce a denser variant for inside-pane use? Recommendation:
|
||||
reuse `ClideTabBar` initially; spin off a `ClideTabBar.dense`
|
||||
variant only if visual hierarchy issues emerge.
|
||||
@@ -0,0 +1,166 @@
|
||||
title: MultitabPane — architecture {
|
||||
near: top-center
|
||||
shape: text
|
||||
style.font-size: 24
|
||||
style.bold: true
|
||||
}
|
||||
|
||||
direction: down
|
||||
|
||||
host: ClaudePane (host) {
|
||||
shape: rectangle
|
||||
style.fill: "#13161c"
|
||||
style.stroke: "#7c5cff"
|
||||
style.font-color: "#e8ecf2"
|
||||
|
||||
state: ChangeNotifier — owns lifecycle {
|
||||
shape: rectangle
|
||||
style.fill: "#0e1014"
|
||||
style.stroke: "#262a32"
|
||||
style.font-color: "#a0a8b8"
|
||||
}
|
||||
}
|
||||
|
||||
widget: MultitabPane<T> (widget) {
|
||||
shape: rectangle
|
||||
style.fill: "#13161c"
|
||||
style.stroke: "#262a32"
|
||||
style.font-color: "#e8ecf2"
|
||||
|
||||
shell: builds tabstrip + body shell {
|
||||
shape: rectangle
|
||||
style.fill: "#0e1014"
|
||||
style.stroke: "#262a32"
|
||||
style.font-color: "#a0a8b8"
|
||||
}
|
||||
}
|
||||
|
||||
controller: MultitabController<T> {
|
||||
shape: rectangle
|
||||
style.fill: "#13161c"
|
||||
style.stroke: "#262a32"
|
||||
style.font-color: "#e8ecf2"
|
||||
|
||||
api: |md
|
||||
add(entry)
|
||||
remove(id)
|
||||
activate(id)
|
||||
reorder(id, idx)
|
||||
| {
|
||||
style.font-color: "#a0a8b8"
|
||||
}
|
||||
}
|
||||
|
||||
entries: List<MultitabEntry<T>> {
|
||||
shape: rectangle
|
||||
style.fill: "#0e1014"
|
||||
style.stroke: "#262a32"
|
||||
style.font-color: "#a0a8b8"
|
||||
|
||||
primary: primary {
|
||||
shape: rectangle
|
||||
style.fill: "#1a1f28"
|
||||
style.stroke: "#7c5cff"
|
||||
style.font-color: "#e8ecf2"
|
||||
closeable\: false: { shape: text; style.font-color: "#7a8294"; style.font-size: 10 }
|
||||
reorderable\: false: { shape: text; style.font-color: "#7a8294"; style.font-size: 10 }
|
||||
}
|
||||
sec1: secondary 1 {
|
||||
shape: rectangle
|
||||
style.fill: "#13161c"
|
||||
style.stroke: "#262a32"
|
||||
style.font-color: "#a0a8b8"
|
||||
}
|
||||
sec2: secondary 2 {
|
||||
shape: rectangle
|
||||
style.fill: "#13161c"
|
||||
style.stroke: "#262a32"
|
||||
style.font-color: "#a0a8b8"
|
||||
}
|
||||
}
|
||||
|
||||
ipc: kernel.claude / IPC {
|
||||
shape: rectangle
|
||||
style.fill: "#13161c"
|
||||
style.stroke: "#5a8c5a"
|
||||
style.font-color: "#e8ecf2"
|
||||
|
||||
spawn: spawnSecondary() {
|
||||
shape: rectangle
|
||||
style.fill: "#0e1014"
|
||||
style.stroke: "#262a32"
|
||||
style.font-color: "#a0a8b8"
|
||||
}
|
||||
close: closeSecondary(ref) {
|
||||
shape: rectangle
|
||||
style.fill: "#0e1014"
|
||||
style.stroke: "#262a32"
|
||||
style.font-color: "#a0a8b8"
|
||||
}
|
||||
}
|
||||
|
||||
host -> controller: owns {
|
||||
style.stroke: "#7a8294"
|
||||
style.font-color: "#7a8294"
|
||||
}
|
||||
host -> widget: builds with {
|
||||
style.stroke: "#7a8294"
|
||||
style.font-color: "#7a8294"
|
||||
}
|
||||
controller -> entries: holds {
|
||||
style.stroke: "#7a8294"
|
||||
style.font-color: "#7a8294"
|
||||
}
|
||||
widget -> controller: subscribes (Listenable) {
|
||||
style.stroke: "#7c5cff"
|
||||
style.font-color: "#a0a8b8"
|
||||
}
|
||||
|
||||
widget -> host: onAddRequested() {
|
||||
style.stroke: "#d97757"
|
||||
style.font-color: "#d97757"
|
||||
}
|
||||
widget -> host: onCloseRequested(entry) {
|
||||
style.stroke: "#d97757"
|
||||
style.font-color: "#d97757"
|
||||
}
|
||||
|
||||
host -> ipc: routes user intent {
|
||||
style.stroke: "#5a8c5a"
|
||||
style.font-color: "#5a8c5a"
|
||||
}
|
||||
ipc.spawn -> entries.sec2: appends new entry {
|
||||
style.stroke: "#5a8c5a"
|
||||
style.font-color: "#5a8c5a"
|
||||
}
|
||||
ipc.close -> entries.sec1: removes entry {
|
||||
style.stroke: "#d97757"
|
||||
style.font-color: "#d97757"
|
||||
}
|
||||
|
||||
note: |md
|
||||
### Boundary
|
||||
|
||||
**Widget** is generic. It knows
|
||||
about `MultitabEntry<T>` and routes
|
||||
user gestures back to the host. It
|
||||
never touches PTY, IPC, or session
|
||||
naming.
|
||||
|
||||
**Host** owns the controller and
|
||||
decides what `add` / `close` mean
|
||||
in the domain — for Claude that's
|
||||
spawning/killing tmux sessions
|
||||
via IPC.
|
||||
|
||||
This boundary is what makes the
|
||||
widget reusable: any pane that
|
||||
needs N runtime instances can
|
||||
drop it in with their own host
|
||||
and payload type.
|
||||
| {
|
||||
shape: rectangle
|
||||
style.fill: "#13161c"
|
||||
style.stroke: "#262a32"
|
||||
style.font-color: "#a0a8b8"
|
||||
}
|
||||
|
After Width: | Height: | Size: 1009 KiB |
@@ -0,0 +1,53 @@
|
||||
# clide wireframes
|
||||
|
||||
Canonical layout reference, generated from the actual implementation
|
||||
via the `frame0-wireframe` skill. Each `.json` is the source of
|
||||
truth; the `.png` is rendered from it.
|
||||
|
||||
These supersede the hi-fi mockups under
|
||||
[`../claude-design/`](../claude-design/), which are kept for
|
||||
historical context and design tokens.
|
||||
|
||||
## Set
|
||||
|
||||
### Welcome
|
||||
- [`welcome/welcome-screen.json`](welcome/welcome-screen.json) /
|
||||
[.png](welcome/welcome-screen.png)
|
||||
— first-run landing: logo + wordmark, START / RECENT columns,
|
||||
Tips card, status line.
|
||||
|
||||
### Main view
|
||||
- [`main/main-view.json`](main/main-view.json) /
|
||||
[.png](main/main-view.png)
|
||||
— three-column default: tickets sidebar, Claude pane, empty
|
||||
context panel.
|
||||
- [`main/editor-above-claude.json`](main/editor-above-claude.json) /
|
||||
[.png](main/editor-above-claude.png)
|
||||
— D-49 editor mode: editor above Claude in the middle column,
|
||||
divider between, prompt Y stays fixed.
|
||||
- [`main/focus-mode.json`](main/focus-mode.json) /
|
||||
[.png](main/focus-mode.png)
|
||||
— D-52 focus mode: full-window Claude pane, sidebars hidden,
|
||||
Esc-to-exit hint in the title bar.
|
||||
- [`main/sidebar-collapsed.json`](main/sidebar-collapsed.json) /
|
||||
[.png](main/sidebar-collapsed.png)
|
||||
— D-51 12px spine: sidebar collapsed to a vertical strip with
|
||||
rotated label and activity badge.
|
||||
- [`main/ticket-detail.json`](main/ticket-detail.json) /
|
||||
[.png](main/ticket-detail.png)
|
||||
— context panel showing a selected ticket with metadata and
|
||||
description.
|
||||
|
||||
## Updating
|
||||
|
||||
1. Edit the `.json` (source of truth).
|
||||
2. Re-export with the `frame0-wireframe` skill:
|
||||
```
|
||||
.claude/skills/frame0-wireframe/scripts/frame0-sync.py \
|
||||
export docs/wireframes/<dir>/<name>.json \
|
||||
docs/wireframes/<dir>/<name>.png
|
||||
```
|
||||
3. Commit both files.
|
||||
|
||||
Frame0 must be running locally for export. Don't pull from Frame0 —
|
||||
the JSON is authoritative.
|
||||
@@ -0,0 +1,209 @@
|
||||
{
|
||||
"name": "Claude Pane — multitab",
|
||||
"shapes": {
|
||||
"canvas": {
|
||||
"type": "Rectangle",
|
||||
"left": 40, "top": 40, "width": 1100, "height": 720,
|
||||
"fillColor": "#0e1014",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
|
||||
"pane-header": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 40, "top": 40, "width": 1100, "height": 36,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"pane-title": {
|
||||
"type": "Text",
|
||||
"parent": "pane-header",
|
||||
"left": 56, "top": 50,
|
||||
"text": "claude — secondary 2",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 12
|
||||
},
|
||||
"pane-subtitle": {
|
||||
"type": "Text",
|
||||
"parent": "pane-header",
|
||||
"left": 56, "top": 64,
|
||||
"text": "tmux · clide-claude-var-mnt-data-projects-clide-2",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 10
|
||||
},
|
||||
|
||||
"tabstrip": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 40, "top": 76, "width": 1100, "height": 32,
|
||||
"fillColor": "#0e1014",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
|
||||
"tab-primary": {
|
||||
"type": "Rectangle",
|
||||
"parent": "tabstrip",
|
||||
"left": 56, "top": 80, "width": 132, "height": 28,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028",
|
||||
"corners": [4, 4, 0, 0]
|
||||
},
|
||||
"tab-primary-pin": {
|
||||
"type": "Text",
|
||||
"parent": "tab-primary",
|
||||
"left": 64, "top": 86,
|
||||
"text": "📌",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 10
|
||||
},
|
||||
"tab-primary-text": {
|
||||
"type": "Text",
|
||||
"parent": "tab-primary",
|
||||
"left": 84, "top": 86,
|
||||
"text": "primary",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"tab-sec-1": {
|
||||
"type": "Rectangle",
|
||||
"parent": "tabstrip",
|
||||
"left": 192, "top": 80, "width": 132, "height": 28,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028",
|
||||
"corners": [4, 4, 0, 0]
|
||||
},
|
||||
"tab-sec-1-text": {
|
||||
"type": "Text",
|
||||
"parent": "tab-sec-1",
|
||||
"left": 204, "top": 86,
|
||||
"text": "secondary 1",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
"tab-sec-1-close": {
|
||||
"type": "Text",
|
||||
"parent": "tab-sec-1",
|
||||
"left": 304, "top": 86,
|
||||
"text": "×",
|
||||
"fontColor": "#5a6478",
|
||||
"fontSize": 14
|
||||
},
|
||||
|
||||
"tab-sec-2": {
|
||||
"type": "Rectangle",
|
||||
"parent": "tabstrip",
|
||||
"left": 328, "top": 80, "width": 132, "height": 28,
|
||||
"fillColor": "#1a1f28",
|
||||
"strokeColor": "#7c5cff",
|
||||
"corners": [4, 4, 0, 0]
|
||||
},
|
||||
"tab-sec-2-text": {
|
||||
"type": "Text",
|
||||
"parent": "tab-sec-2",
|
||||
"left": 340, "top": 86,
|
||||
"text": "secondary 2",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 12
|
||||
},
|
||||
"tab-sec-2-close": {
|
||||
"type": "Text",
|
||||
"parent": "tab-sec-2",
|
||||
"left": 440, "top": 86,
|
||||
"text": "×",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 14
|
||||
},
|
||||
|
||||
"tab-add": {
|
||||
"type": "Rectangle",
|
||||
"parent": "tabstrip",
|
||||
"left": 464, "top": 80, "width": 28, "height": 28,
|
||||
"fillColor": "#0e1014",
|
||||
"strokeColor": "#1c2028",
|
||||
"corners": [4, 4, 0, 0]
|
||||
},
|
||||
"tab-add-glyph": {
|
||||
"type": "Text",
|
||||
"parent": "tab-add",
|
||||
"left": 474, "top": 86,
|
||||
"text": "+",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 14
|
||||
},
|
||||
|
||||
"active-divider": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 40, "top": 108, "width": 1100, "height": 2,
|
||||
"fillColor": "#7c5cff",
|
||||
"strokeColor": "#7c5cff"
|
||||
},
|
||||
|
||||
"body": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 40, "top": 110, "width": 1100, "height": 650,
|
||||
"fillColor": "#0e1014",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"body-banner-name": {
|
||||
"type": "Text",
|
||||
"parent": "body",
|
||||
"left": 56, "top": 132,
|
||||
"text": "Claude Code v2.1.128",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 12
|
||||
},
|
||||
"body-banner-meta": {
|
||||
"type": "Text",
|
||||
"parent": "body",
|
||||
"left": 56, "top": 148,
|
||||
"text": "Opus 4.7 (1M context) · fresh secondary session",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
|
||||
"body-msg-prompt": {
|
||||
"type": "Text",
|
||||
"parent": "body",
|
||||
"left": 56, "top": 200,
|
||||
"text": "› dig into the failing test in test/pty/session_test.dart",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
"body-msg-resp": {
|
||||
"type": "Text",
|
||||
"parent": "body",
|
||||
"left": 56, "top": 226,
|
||||
"text": "● Looking at the write-keystrokes test. The shell process\n starts but the echo doesn't appear in the output stream.\n Let me trace the write path…",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"prompt-divider": {
|
||||
"type": "Rectangle",
|
||||
"parent": "body",
|
||||
"left": 40, "top": 700, "width": 1100, "height": 1,
|
||||
"fillColor": "#1c2028",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "Text",
|
||||
"parent": "body",
|
||||
"left": 56, "top": 712,
|
||||
"text": "› Try \"run the test in this pane\"",
|
||||
"fontColor": "#5a6478",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"anno": {
|
||||
"type": "Text",
|
||||
"parent": "canvas",
|
||||
"left": 720, "top": 80,
|
||||
"text": "active tab gets accent border + bottom rule",
|
||||
"fontColor": "#5a6478",
|
||||
"fontSize": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 61 KiB |
@@ -0,0 +1,231 @@
|
||||
{
|
||||
"name": "Main View — editor above Claude",
|
||||
"shapes": {
|
||||
"canvas": {
|
||||
"type": "Rectangle",
|
||||
"left": 40, "top": 40, "width": 1400, "height": 860,
|
||||
"fillColor": "#0e1014",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"title-bar": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 40, "top": 40, "width": 1400, "height": 36,
|
||||
"fillColor": "#1c2028",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"title-bar-text": {
|
||||
"type": "Text",
|
||||
"parent": "title-bar",
|
||||
"left": 720, "top": 50,
|
||||
"text": "clide › clide ⌄ · src/welcome/welcome_view.dart ●",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"sidebar": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 40, "top": 76, "width": 280, "height": 786,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"sidebar-section": {
|
||||
"type": "Text",
|
||||
"parent": "sidebar",
|
||||
"left": 56, "top": 96,
|
||||
"text": "▾ FILES",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"files-tree": {
|
||||
"type": "Text",
|
||||
"parent": "sidebar",
|
||||
"left": 56, "top": 124,
|
||||
"text": "▸ assets\n▸ bin\n▾ lib\n ▾ builtin\n ▾ welcome\n ▾ src\n welcome_view.dart\n extension.dart\n ▸ kernel\n ▸ widgets",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
"sidebar-rail": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 40, "top": 822, "width": 280, "height": 40,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
|
||||
"editor-pane": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 320, "top": 76, "width": 740, "height": 320,
|
||||
"fillColor": "#0e1014",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"editor-header": {
|
||||
"type": "Rectangle",
|
||||
"parent": "editor-pane",
|
||||
"left": 320, "top": 76, "width": 740, "height": 36,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"editor-title": {
|
||||
"type": "Text",
|
||||
"parent": "editor-header",
|
||||
"left": 336, "top": 86,
|
||||
"text": "✎ welcome_view.dart ●",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 12
|
||||
},
|
||||
"editor-demote": {
|
||||
"type": "Text",
|
||||
"parent": "editor-header",
|
||||
"left": 1024, "top": 86,
|
||||
"text": "👁 ⌘W",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"editor-line-1": {
|
||||
"type": "Text",
|
||||
"parent": "editor-pane",
|
||||
"left": 336, "top": 130,
|
||||
"text": " 1 class _TipsCard extends StatelessWidget {",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 11
|
||||
},
|
||||
"editor-line-2": {
|
||||
"type": "Text",
|
||||
"parent": "editor-pane",
|
||||
"left": 336, "top": 148,
|
||||
"text": " 2 const _TipsCard({required this.tokens});",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 11
|
||||
},
|
||||
"editor-line-3": {
|
||||
"type": "Text",
|
||||
"parent": "editor-pane",
|
||||
"left": 336, "top": 166,
|
||||
"text": " 3 final SurfaceTokens tokens;",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 11
|
||||
},
|
||||
"editor-line-4": {
|
||||
"type": "Text",
|
||||
"parent": "editor-pane",
|
||||
"left": 336, "top": 184,
|
||||
"text": " 4",
|
||||
"fontColor": "#5a6478",
|
||||
"fontSize": 11
|
||||
},
|
||||
"editor-line-5": {
|
||||
"type": "Text",
|
||||
"parent": "editor-pane",
|
||||
"left": 336, "top": 202,
|
||||
"text": " 5 static const _tips = <(String, String)>[",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 11
|
||||
},
|
||||
"editor-line-6": {
|
||||
"type": "Text",
|
||||
"parent": "editor-pane",
|
||||
"left": 336, "top": 220,
|
||||
"text": " 6 ('Quick open', '⌘P'),",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 11
|
||||
},
|
||||
|
||||
"divider": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 320, "top": 396, "width": 740, "height": 4,
|
||||
"fillColor": "#262a32",
|
||||
"strokeColor": "#262a32"
|
||||
},
|
||||
|
||||
"claude-pane": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 320, "top": 400, "width": 740, "height": 462,
|
||||
"fillColor": "#0e1014",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"claude-header": {
|
||||
"type": "Rectangle",
|
||||
"parent": "claude-pane",
|
||||
"left": 320, "top": 400, "width": 740, "height": 36,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"claude-title": {
|
||||
"type": "Text",
|
||||
"parent": "claude-header",
|
||||
"left": 336, "top": 410,
|
||||
"text": "claude — primary",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 12
|
||||
},
|
||||
"claude-conv": {
|
||||
"type": "Text",
|
||||
"parent": "claude-pane",
|
||||
"left": 336, "top": 460,
|
||||
"text": "› refactor _TipsCard into its own file\n\n● Moved _TipsCard to lib/builtin/welcome/src/tips_card.dart.\n Re-exported from welcome_view.dart for backwards compat.\n Updated test/builtin/welcome/widget_test.dart to import\n the new path.",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 12
|
||||
},
|
||||
"claude-prompt-divider": {
|
||||
"type": "Rectangle",
|
||||
"parent": "claude-pane",
|
||||
"left": 320, "top": 800, "width": 740, "height": 1,
|
||||
"fillColor": "#1c2028",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"claude-prompt": {
|
||||
"type": "Text",
|
||||
"parent": "claude-pane",
|
||||
"left": 336, "top": 808,
|
||||
"text": "› Try \"run the tests\"",
|
||||
"fontColor": "#5a6478",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"context-panel": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 1060, "top": 76, "width": 380, "height": 786,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"context-section": {
|
||||
"type": "Text",
|
||||
"parent": "context-panel",
|
||||
"left": 1080, "top": 96,
|
||||
"text": "▾ PREVIEW",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"context-doc": {
|
||||
"type": "Text",
|
||||
"parent": "context-panel",
|
||||
"left": 1080, "top": 134,
|
||||
"text": "Welcome screen\n\nThe first surface a user sees when no project is\nopen, or after closing one.\n\nLayout: centered max-850px column with logo +\nwordmark, START / RECENT row, and (when the\nviewport is tall enough) a TIPS card spanning\nthe full width.",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 11
|
||||
},
|
||||
|
||||
"status-branch": {
|
||||
"type": "Text",
|
||||
"parent": "canvas",
|
||||
"left": 56, "top": 880,
|
||||
"text": "⑂ main ↑5 · 3 modified",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"status-app": {
|
||||
"type": "Text",
|
||||
"parent": "canvas",
|
||||
"left": 700, "top": 880,
|
||||
"text": "● application ok",
|
||||
"fontColor": "#5a8c5a",
|
||||
"fontSize": 11
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 125 KiB |
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"name": "Main View — focus mode",
|
||||
"shapes": {
|
||||
"canvas": {
|
||||
"type": "Rectangle",
|
||||
"left": 40, "top": 40, "width": 1400, "height": 860,
|
||||
"fillColor": "#0e1014",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"title-bar": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 40, "top": 40, "width": 1400, "height": 36,
|
||||
"fillColor": "#1c2028",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"title-bar-text": {
|
||||
"type": "Text",
|
||||
"parent": "title-bar",
|
||||
"left": 720, "top": 50,
|
||||
"text": "clide › clide ⌄ — focus mode",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
"esc-hint": {
|
||||
"type": "Text",
|
||||
"parent": "title-bar",
|
||||
"left": 1340, "top": 50,
|
||||
"text": "Esc to exit",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
|
||||
"claude-pane": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 40, "top": 76, "width": 1400, "height": 786,
|
||||
"fillColor": "#0e1014",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"claude-header": {
|
||||
"type": "Rectangle",
|
||||
"parent": "claude-pane",
|
||||
"left": 40, "top": 76, "width": 1400, "height": 36,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"claude-title": {
|
||||
"type": "Text",
|
||||
"parent": "claude-header",
|
||||
"left": 56, "top": 86,
|
||||
"text": "claude — primary",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 12
|
||||
},
|
||||
"claude-subtitle": {
|
||||
"type": "Text",
|
||||
"parent": "claude-header",
|
||||
"left": 56, "top": 100,
|
||||
"text": "tmux · clide-claude-var-mnt-data-projects-clide · focus",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 10
|
||||
},
|
||||
|
||||
"claude-banner-icon": {
|
||||
"type": "Text",
|
||||
"parent": "claude-pane",
|
||||
"left": 56, "top": 142,
|
||||
"text": "▰▰",
|
||||
"fontColor": "#d97757",
|
||||
"fontSize": 12
|
||||
},
|
||||
"claude-banner-name": {
|
||||
"type": "Text",
|
||||
"parent": "claude-pane",
|
||||
"left": 82, "top": 142,
|
||||
"text": "Claude Code v2.1.128",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 12
|
||||
},
|
||||
"claude-banner-meta": {
|
||||
"type": "Text",
|
||||
"parent": "claude-pane",
|
||||
"left": 82, "top": 158,
|
||||
"text": "Opus 4.7 (1M context) with high effort · Claude Max",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
|
||||
"claude-msg-1-prompt": {
|
||||
"type": "Text",
|
||||
"parent": "claude-pane",
|
||||
"left": 56, "top": 230,
|
||||
"text": "› switch to focus mode for the next push",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
"claude-msg-1-resp": {
|
||||
"type": "Text",
|
||||
"parent": "claude-pane",
|
||||
"left": 56, "top": 258,
|
||||
"text": "● Focus mode entered. Esc to exit. Sidebar and context\n panel are hidden — Claude pane fills the workspace.",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"claude-prompt-divider": {
|
||||
"type": "Rectangle",
|
||||
"parent": "claude-pane",
|
||||
"left": 40, "top": 800, "width": 1400, "height": 1,
|
||||
"fillColor": "#1c2028",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"claude-prompt-caret": {
|
||||
"type": "Text",
|
||||
"parent": "claude-pane",
|
||||
"left": 56, "top": 808,
|
||||
"text": "›",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 16
|
||||
},
|
||||
"claude-prompt-placeholder": {
|
||||
"type": "Text",
|
||||
"parent": "claude-pane",
|
||||
"left": 80, "top": 810,
|
||||
"text": "Try \"go full screen\"",
|
||||
"fontColor": "#5a6478",
|
||||
"fontSize": 12
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 61 KiB |
@@ -0,0 +1,447 @@
|
||||
{
|
||||
"name": "Main View — project loaded",
|
||||
"shapes": {
|
||||
"canvas": {
|
||||
"type": "Rectangle",
|
||||
"left": 40, "top": 40, "width": 1400, "height": 860,
|
||||
"fillColor": "#0e1014",
|
||||
"strokeColor": "#1c2028",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
|
||||
"title-bar": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 40, "top": 40, "width": 1400, "height": 36,
|
||||
"fillColor": "#1c2028",
|
||||
"strokeColor": "#1c2028",
|
||||
"corners": [4, 4, 0, 0]
|
||||
},
|
||||
"title-bar-text": {
|
||||
"type": "Text",
|
||||
"parent": "title-bar",
|
||||
"left": 720, "top": 50,
|
||||
"text": "clide › clide ⌄",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"sidebar": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 40, "top": 76, "width": 360, "height": 786,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"sidebar-search": {
|
||||
"type": "Rectangle",
|
||||
"parent": "sidebar",
|
||||
"left": 56, "top": 92, "width": 280, "height": 32,
|
||||
"fillColor": "#0e1014",
|
||||
"strokeColor": "#262a32",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"sidebar-search-icon": {
|
||||
"type": "Text",
|
||||
"parent": "sidebar-search",
|
||||
"left": 68, "top": 100,
|
||||
"text": "⌕",
|
||||
"fontColor": "#5a6478",
|
||||
"fontSize": 14
|
||||
},
|
||||
|
||||
"section-in-progress": {
|
||||
"type": "Text",
|
||||
"parent": "sidebar",
|
||||
"left": 56, "top": 142,
|
||||
"text": "▾ IN PROGRESS · 1",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"ticket-1": {
|
||||
"type": "Rectangle",
|
||||
"parent": "sidebar",
|
||||
"left": 56, "top": 166, "width": 328, "height": 64,
|
||||
"fillColor": "#0e1014",
|
||||
"strokeColor": "#262a32",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"ticket-1-id": {
|
||||
"type": "Text",
|
||||
"parent": "ticket-1",
|
||||
"left": 70, "top": 178,
|
||||
"text": "● T-21 ← T-4",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"ticket-1-title": {
|
||||
"type": "Text",
|
||||
"parent": "ticket-1",
|
||||
"left": 70, "top": 200,
|
||||
"text": "implement welcome screen per hi-fi design",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 13
|
||||
},
|
||||
|
||||
"section-ready": {
|
||||
"type": "Text",
|
||||
"parent": "sidebar",
|
||||
"left": 56, "top": 250,
|
||||
"text": "▾ READY · 3",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"ticket-2": {
|
||||
"type": "Rectangle",
|
||||
"parent": "sidebar",
|
||||
"left": 56, "top": 274, "width": 328, "height": 56,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"ticket-2-id": {
|
||||
"type": "Text",
|
||||
"parent": "ticket-2",
|
||||
"left": 70, "top": 282,
|
||||
"text": "● T-17 ← T-8",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"ticket-2-title": {
|
||||
"type": "Text",
|
||||
"parent": "ticket-2",
|
||||
"left": 70, "top": 302,
|
||||
"text": "add dart doc generation to CI",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
"ticket-3": {
|
||||
"type": "Rectangle",
|
||||
"parent": "sidebar",
|
||||
"left": 56, "top": 332, "width": 328, "height": 56,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"ticket-3-id": {
|
||||
"type": "Text",
|
||||
"parent": "ticket-3",
|
||||
"left": 70, "top": 340,
|
||||
"text": "● T-24 ← T-3",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"ticket-3-title": {
|
||||
"type": "Text",
|
||||
"parent": "ticket-3",
|
||||
"left": 70, "top": 360,
|
||||
"text": "secondary Claude pane UI wiring",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"section-backlog": {
|
||||
"type": "Text",
|
||||
"parent": "sidebar",
|
||||
"left": 56, "top": 408,
|
||||
"text": "▾ BACKLOG · 33",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"ticket-4": {
|
||||
"type": "Rectangle",
|
||||
"parent": "sidebar",
|
||||
"left": 56, "top": 432, "width": 328, "height": 50,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"ticket-4-id": {
|
||||
"type": "Text",
|
||||
"parent": "ticket-4",
|
||||
"left": 70, "top": 440,
|
||||
"text": "● T-7 ← T-7",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"ticket-4-title": {
|
||||
"type": "Text",
|
||||
"parent": "ticket-4",
|
||||
"left": 70, "top": 458,
|
||||
"text": "Tier 5 — canvas and graph view",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
"ticket-5": {
|
||||
"type": "Rectangle",
|
||||
"parent": "sidebar",
|
||||
"left": 56, "top": 484, "width": 328, "height": 50,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"ticket-5-id": {
|
||||
"type": "Text",
|
||||
"parent": "ticket-5",
|
||||
"left": 70, "top": 492,
|
||||
"text": "● T-23 ← T-4",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"ticket-5-title": {
|
||||
"type": "Text",
|
||||
"parent": "ticket-5",
|
||||
"left": 70, "top": 510,
|
||||
"text": "wire command palette keybinding",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"sidebar-rail": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 40, "top": 822, "width": 360, "height": 40,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"rail-icon-1": {
|
||||
"type": "Text",
|
||||
"parent": "sidebar-rail",
|
||||
"left": 68, "top": 832,
|
||||
"text": "▤",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 16
|
||||
},
|
||||
"rail-icon-2": {
|
||||
"type": "Text",
|
||||
"parent": "sidebar-rail",
|
||||
"left": 110, "top": 832,
|
||||
"text": "◇",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 16
|
||||
},
|
||||
"rail-icon-3": {
|
||||
"type": "Text",
|
||||
"parent": "sidebar-rail",
|
||||
"left": 152, "top": 832,
|
||||
"text": "▢",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 16
|
||||
},
|
||||
"rail-icon-4": {
|
||||
"type": "Text",
|
||||
"parent": "sidebar-rail",
|
||||
"left": 194, "top": 832,
|
||||
"text": "⑂",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 16
|
||||
},
|
||||
"rail-icon-5": {
|
||||
"type": "Text",
|
||||
"parent": "sidebar-rail",
|
||||
"left": 236, "top": 832,
|
||||
"text": "⌕",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 16
|
||||
},
|
||||
"rail-icon-6": {
|
||||
"type": "Text",
|
||||
"parent": "sidebar-rail",
|
||||
"left": 278, "top": 832,
|
||||
"text": "!",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 16
|
||||
},
|
||||
|
||||
"claude-pane": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 400, "top": 76, "width": 660, "height": 786,
|
||||
"fillColor": "#0e1014",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"claude-header": {
|
||||
"type": "Rectangle",
|
||||
"parent": "claude-pane",
|
||||
"left": 400, "top": 76, "width": 660, "height": 36,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"claude-title": {
|
||||
"type": "Text",
|
||||
"parent": "claude-header",
|
||||
"left": 416, "top": 86,
|
||||
"text": "claude — primary",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 12
|
||||
},
|
||||
"claude-subtitle": {
|
||||
"type": "Text",
|
||||
"parent": "claude-header",
|
||||
"left": 416, "top": 100,
|
||||
"text": "tmux · clide-claude-var-mnt-data-projects-clide",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 10
|
||||
},
|
||||
|
||||
"claude-logo-icon": {
|
||||
"type": "Text",
|
||||
"parent": "claude-pane",
|
||||
"left": 416, "top": 142,
|
||||
"text": "▰▰",
|
||||
"fontColor": "#d97757",
|
||||
"fontSize": 12
|
||||
},
|
||||
"claude-banner-name": {
|
||||
"type": "Text",
|
||||
"parent": "claude-pane",
|
||||
"left": 442, "top": 142,
|
||||
"text": "Claude Code v2.1.128",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 12
|
||||
},
|
||||
"claude-banner-model": {
|
||||
"type": "Text",
|
||||
"parent": "claude-pane",
|
||||
"left": 442, "top": 158,
|
||||
"text": "Opus 4.7 (1M context) with high effort · Claude Max",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"claude-banner-cwd": {
|
||||
"type": "Text",
|
||||
"parent": "claude-pane",
|
||||
"left": 442, "top": 174,
|
||||
"text": "/var/mnt/data/projects/clide",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
|
||||
"claude-message-1": {
|
||||
"type": "Text",
|
||||
"parent": "claude-pane",
|
||||
"left": 416, "top": 230,
|
||||
"text": "› wireframe the main view",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
"claude-response-1": {
|
||||
"type": "Text",
|
||||
"parent": "claude-pane",
|
||||
"left": 416, "top": 258,
|
||||
"text": "● Building wireframe from current implementation. Three\n columns: sidebar (tickets shown), Claude pane, context\n panel. Let me check the tab contributions first.",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"claude-mcp-warn": {
|
||||
"type": "Text",
|
||||
"parent": "claude-pane",
|
||||
"left": 416, "top": 720,
|
||||
"text": "1 MCP server failed · /mcp",
|
||||
"fontColor": "#d97757",
|
||||
"fontSize": 11
|
||||
},
|
||||
|
||||
"claude-prompt-divider": {
|
||||
"type": "Rectangle",
|
||||
"parent": "claude-pane",
|
||||
"left": 400, "top": 752, "width": 660, "height": 1,
|
||||
"fillColor": "#1c2028",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"claude-prompt-caret": {
|
||||
"type": "Text",
|
||||
"parent": "claude-pane",
|
||||
"left": 416, "top": 760,
|
||||
"text": "›",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 16
|
||||
},
|
||||
"claude-prompt-placeholder": {
|
||||
"type": "Text",
|
||||
"parent": "claude-pane",
|
||||
"left": 440, "top": 762,
|
||||
"text": "Try \"export the wireframes\"",
|
||||
"fontColor": "#5a6478",
|
||||
"fontSize": 12
|
||||
},
|
||||
"claude-prompt-meta": {
|
||||
"type": "Text",
|
||||
"parent": "claude-pane",
|
||||
"left": 416, "top": 794,
|
||||
"text": "[jeroenschweitzer@danoontje clide] | Opus 4.7 (1M context)",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 10
|
||||
},
|
||||
|
||||
"context-panel": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 1060, "top": 76, "width": 380, "height": 786,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"context-empty": {
|
||||
"type": "Text",
|
||||
"parent": "context-panel",
|
||||
"left": 1080, "top": 96,
|
||||
"text": "Select a ticket to view details.",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"context-rail": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 1060, "top": 822, "width": 380, "height": 40,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"context-rail-icon-1": {
|
||||
"type": "Text",
|
||||
"parent": "context-rail",
|
||||
"left": 1300, "top": 832,
|
||||
"text": "▤",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 16
|
||||
},
|
||||
"context-rail-icon-2": {
|
||||
"type": "Text",
|
||||
"parent": "context-rail",
|
||||
"left": 1340, "top": 832,
|
||||
"text": "◇",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 16
|
||||
},
|
||||
"context-rail-icon-3": {
|
||||
"type": "Text",
|
||||
"parent": "context-rail",
|
||||
"left": 1380, "top": 832,
|
||||
"text": "⌕",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 16
|
||||
},
|
||||
"context-rail-icon-4": {
|
||||
"type": "Text",
|
||||
"parent": "context-rail",
|
||||
"left": 1416, "top": 832,
|
||||
"text": "▢",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 16
|
||||
},
|
||||
|
||||
"status-branch": {
|
||||
"type": "Text",
|
||||
"parent": "canvas",
|
||||
"left": 56, "top": 880,
|
||||
"text": "⑂ main ↑5",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"status-app": {
|
||||
"type": "Text",
|
||||
"parent": "canvas",
|
||||
"left": 700, "top": 880,
|
||||
"text": "● application ok",
|
||||
"fontColor": "#5a8c5a",
|
||||
"fontSize": 11
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 119 KiB |
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"name": "Main View — sidebar collapsed",
|
||||
"shapes": {
|
||||
"canvas": {
|
||||
"type": "Rectangle",
|
||||
"left": 40, "top": 40, "width": 1400, "height": 860,
|
||||
"fillColor": "#0e1014",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"title-bar": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 40, "top": 40, "width": 1400, "height": 36,
|
||||
"fillColor": "#1c2028",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"title-bar-text": {
|
||||
"type": "Text",
|
||||
"parent": "title-bar",
|
||||
"left": 720, "top": 50,
|
||||
"text": "clide › clide ⌄",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"spine-left": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 40, "top": 76, "width": 12, "height": 786,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"spine-left-label": {
|
||||
"type": "Text",
|
||||
"parent": "spine-left",
|
||||
"left": 44, "top": 220,
|
||||
"text": "TICKETS",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 9
|
||||
},
|
||||
"spine-left-badge": {
|
||||
"type": "Ellipse",
|
||||
"parent": "spine-left",
|
||||
"left": 43, "top": 96, "width": 6, "height": 6,
|
||||
"fillColor": "#d97757",
|
||||
"strokeColor": "#d97757"
|
||||
},
|
||||
|
||||
"claude-pane": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 52, "top": 76, "width": 1008, "height": 786,
|
||||
"fillColor": "#0e1014",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"claude-header": {
|
||||
"type": "Rectangle",
|
||||
"parent": "claude-pane",
|
||||
"left": 52, "top": 76, "width": 1008, "height": 36,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"claude-title": {
|
||||
"type": "Text",
|
||||
"parent": "claude-header",
|
||||
"left": 68, "top": 86,
|
||||
"text": "claude — primary",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 12
|
||||
},
|
||||
"claude-subtitle": {
|
||||
"type": "Text",
|
||||
"parent": "claude-header",
|
||||
"left": 68, "top": 100,
|
||||
"text": "tmux · sidebar collapsed (⌘⇧1) — Tickets has activity",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 10
|
||||
},
|
||||
|
||||
"claude-banner": {
|
||||
"type": "Text",
|
||||
"parent": "claude-pane",
|
||||
"left": 68, "top": 142,
|
||||
"text": "Claude Code v2.1.128 · Opus 4.7 (1M context)",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"claude-msg": {
|
||||
"type": "Text",
|
||||
"parent": "claude-pane",
|
||||
"left": 68, "top": 200,
|
||||
"text": "› hide the sidebar, I want more room\n\n● Sidebar collapsed to a 12px spine. Activity badge\n on the spine indicates new ticket changes — click\n the spine or press ⌘⇧1 to expand.",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"claude-prompt-divider": {
|
||||
"type": "Rectangle",
|
||||
"parent": "claude-pane",
|
||||
"left": 52, "top": 800, "width": 1008, "height": 1,
|
||||
"fillColor": "#1c2028",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"claude-prompt": {
|
||||
"type": "Text",
|
||||
"parent": "claude-pane",
|
||||
"left": 68, "top": 808,
|
||||
"text": "› Try \"show the sidebar again\"",
|
||||
"fontColor": "#5a6478",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"context-panel": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 1060, "top": 76, "width": 380, "height": 786,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"context-section": {
|
||||
"type": "Text",
|
||||
"parent": "context-panel",
|
||||
"left": 1080, "top": 96,
|
||||
"text": "▾ VIEWER",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"context-empty": {
|
||||
"type": "Text",
|
||||
"parent": "context-panel",
|
||||
"left": 1080, "top": 134,
|
||||
"text": "Open a file to preview.",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"status-branch": {
|
||||
"type": "Text",
|
||||
"parent": "canvas",
|
||||
"left": 56, "top": 880,
|
||||
"text": "⑂ main ↑5",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"status-app": {
|
||||
"type": "Text",
|
||||
"parent": "canvas",
|
||||
"left": 700, "top": 880,
|
||||
"text": "● application ok",
|
||||
"fontColor": "#5a8c5a",
|
||||
"fontSize": 11
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 72 KiB |
@@ -0,0 +1,269 @@
|
||||
{
|
||||
"name": "Main View — ticket detail in context",
|
||||
"shapes": {
|
||||
"canvas": {
|
||||
"type": "Rectangle",
|
||||
"left": 40, "top": 40, "width": 1400, "height": 860,
|
||||
"fillColor": "#0e1014",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"title-bar": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 40, "top": 40, "width": 1400, "height": 36,
|
||||
"fillColor": "#1c2028",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"title-bar-text": {
|
||||
"type": "Text",
|
||||
"parent": "title-bar",
|
||||
"left": 720, "top": 50,
|
||||
"text": "clide › clide ⌄",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"sidebar": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 40, "top": 76, "width": 360, "height": 786,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"section": {
|
||||
"type": "Text",
|
||||
"parent": "sidebar",
|
||||
"left": 56, "top": 96,
|
||||
"text": "▾ IN PROGRESS · 1",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"ticket-selected": {
|
||||
"type": "Rectangle",
|
||||
"parent": "sidebar",
|
||||
"left": 56, "top": 122, "width": 328, "height": 64,
|
||||
"fillColor": "#1a1f28",
|
||||
"strokeColor": "#7c5cff",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"ticket-selected-id": {
|
||||
"type": "Text",
|
||||
"parent": "ticket-selected",
|
||||
"left": 70, "top": 134,
|
||||
"text": "● T-24 ← T-3",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 11
|
||||
},
|
||||
"ticket-selected-title": {
|
||||
"type": "Text",
|
||||
"parent": "ticket-selected",
|
||||
"left": 70, "top": 156,
|
||||
"text": "secondary Claude pane UI wiring",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 13
|
||||
},
|
||||
|
||||
"section-2": {
|
||||
"type": "Text",
|
||||
"parent": "sidebar",
|
||||
"left": 56, "top": 210,
|
||||
"text": "▾ READY · 3",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"ticket-other-1": {
|
||||
"type": "Text",
|
||||
"parent": "sidebar",
|
||||
"left": 70, "top": 240,
|
||||
"text": "● T-17 add dart doc generation to CI",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
"ticket-other-2": {
|
||||
"type": "Text",
|
||||
"parent": "sidebar",
|
||||
"left": 70, "top": 264,
|
||||
"text": "● T-21 implement welcome screen…",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"claude-pane": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 400, "top": 76, "width": 540, "height": 786,
|
||||
"fillColor": "#0e1014",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"claude-header": {
|
||||
"type": "Rectangle",
|
||||
"parent": "claude-pane",
|
||||
"left": 400, "top": 76, "width": 540, "height": 36,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"claude-title": {
|
||||
"type": "Text",
|
||||
"parent": "claude-header",
|
||||
"left": 416, "top": 86,
|
||||
"text": "claude — primary",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 12
|
||||
},
|
||||
"claude-msg": {
|
||||
"type": "Text",
|
||||
"parent": "claude-pane",
|
||||
"left": 416, "top": 142,
|
||||
"text": "› let's pick T-24 next\n\n● T-24 selected. Detail loaded in the right panel.\n This ticket wires up the secondary Claude pane UI —\n see D-41 for the spawn/close semantics.",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 12
|
||||
},
|
||||
"claude-prompt-divider": {
|
||||
"type": "Rectangle",
|
||||
"parent": "claude-pane",
|
||||
"left": 400, "top": 800, "width": 540, "height": 1,
|
||||
"fillColor": "#1c2028",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"claude-prompt": {
|
||||
"type": "Text",
|
||||
"parent": "claude-pane",
|
||||
"left": 416, "top": 808,
|
||||
"text": "› Try \"start it\"",
|
||||
"fontColor": "#5a6478",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"context-panel": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 940, "top": 76, "width": 500, "height": 786,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"ticket-id": {
|
||||
"type": "Text",
|
||||
"parent": "context-panel",
|
||||
"left": 956, "top": 96,
|
||||
"text": "T-24 · task · ready",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"ticket-title": {
|
||||
"type": "Text",
|
||||
"parent": "context-panel",
|
||||
"left": 956, "top": 118,
|
||||
"text": "secondary Claude pane UI wiring",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 18
|
||||
},
|
||||
|
||||
"field-parent": {
|
||||
"type": "Text",
|
||||
"parent": "context-panel",
|
||||
"left": 956, "top": 162,
|
||||
"text": "Parent",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"field-parent-val": {
|
||||
"type": "Text",
|
||||
"parent": "context-panel",
|
||||
"left": 1080, "top": 162,
|
||||
"text": "T-3 — Tier 1 — Claude in xterm pane, PTY, session",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
"field-decision": {
|
||||
"type": "Text",
|
||||
"parent": "context-panel",
|
||||
"left": 956, "top": 188,
|
||||
"text": "Decision",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"field-decision-val": {
|
||||
"type": "Text",
|
||||
"parent": "context-panel",
|
||||
"left": 1080, "top": 188,
|
||||
"text": "D-41 — Claude panes — one primary per repo, tmux-backed",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
"field-priority": {
|
||||
"type": "Text",
|
||||
"parent": "context-panel",
|
||||
"left": 956, "top": 214,
|
||||
"text": "Priority",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"field-priority-val": {
|
||||
"type": "Text",
|
||||
"parent": "context-panel",
|
||||
"left": 1080, "top": 214,
|
||||
"text": "medium",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
"field-created": {
|
||||
"type": "Text",
|
||||
"parent": "context-panel",
|
||||
"left": 956, "top": 240,
|
||||
"text": "Created",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"field-created-val": {
|
||||
"type": "Text",
|
||||
"parent": "context-panel",
|
||||
"left": 1080, "top": 240,
|
||||
"text": "2026-04-22",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"desc-label": {
|
||||
"type": "Text",
|
||||
"parent": "context-panel",
|
||||
"left": 956, "top": 296,
|
||||
"text": "DESCRIPTION",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"desc-body": {
|
||||
"type": "Text",
|
||||
"parent": "context-panel",
|
||||
"left": 956, "top": 324,
|
||||
"text": "Wire the secondary Claude pane spawn flow into the\nUI: a “new Claude session” affordance in the pane\nchrome, secondary numbering (-1, -2 …), close-to-\nprimary focus collapse, and the visual distinction\nbetween primary and secondary in the pane header.",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"actions": {
|
||||
"type": "Rectangle",
|
||||
"parent": "context-panel",
|
||||
"left": 956, "top": 760, "width": 468, "height": 36,
|
||||
"fillColor": "#0e1014",
|
||||
"strokeColor": "#262a32",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"action-start": {
|
||||
"type": "Text",
|
||||
"parent": "actions",
|
||||
"left": 974, "top": 770,
|
||||
"text": "▶ Start (status → in_progress)",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"status-branch": {
|
||||
"type": "Text",
|
||||
"parent": "canvas",
|
||||
"left": 56, "top": 880,
|
||||
"text": "⑂ main ↑5",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 122 KiB |
@@ -0,0 +1,362 @@
|
||||
{
|
||||
"name": "Welcome Screen",
|
||||
"shapes": {
|
||||
"canvas": {
|
||||
"type": "Rectangle",
|
||||
"left": 40, "top": 40, "width": 1280, "height": 800,
|
||||
"fillColor": "#0e1014",
|
||||
"strokeColor": "#1c2028",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
|
||||
"title-bar": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 40, "top": 40, "width": 1280, "height": 36,
|
||||
"fillColor": "#1c2028",
|
||||
"strokeColor": "#1c2028",
|
||||
"corners": [4, 4, 0, 0]
|
||||
},
|
||||
"title-bar-text": {
|
||||
"type": "Text",
|
||||
"parent": "title-bar",
|
||||
"left": 660, "top": 50,
|
||||
"text": "clide ⌄",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"spine-left": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 40, "top": 76, "width": 12, "height": 700,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
"spine-right": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 1308, "top": 76, "width": 12, "height": 700,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028"
|
||||
},
|
||||
|
||||
"logo": {
|
||||
"type": "Text",
|
||||
"parent": "canvas",
|
||||
"left": 250, "top": 240,
|
||||
"text": "<>",
|
||||
"fontColor": "#7c8896",
|
||||
"fontSize": 96
|
||||
},
|
||||
"logo-accent": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 254, "top": 332, "width": 60, "height": 4,
|
||||
"fillColor": "#d97757",
|
||||
"strokeColor": "#d97757"
|
||||
},
|
||||
|
||||
"wordmark": {
|
||||
"type": "Text",
|
||||
"parent": "canvas",
|
||||
"left": 396, "top": 248,
|
||||
"text": "clide",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 64
|
||||
},
|
||||
"subtitle": {
|
||||
"type": "Text",
|
||||
"parent": "canvas",
|
||||
"left": 396, "top": 322,
|
||||
"text": "IDE for Claude Code CLI",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 16
|
||||
},
|
||||
|
||||
"start-label": {
|
||||
"type": "Text",
|
||||
"parent": "canvas",
|
||||
"left": 250, "top": 426,
|
||||
"text": "START",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"row-open": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 250, "top": 458, "width": 380, "height": 36,
|
||||
"fillColor": "#0e1014",
|
||||
"strokeColor": "#0e1014"
|
||||
},
|
||||
"row-open-icon": {
|
||||
"type": "Text",
|
||||
"parent": "row-open",
|
||||
"left": 262, "top": 466,
|
||||
"text": "▢",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 14
|
||||
},
|
||||
"row-open-label": {
|
||||
"type": "Text",
|
||||
"parent": "row-open",
|
||||
"left": 296, "top": 468,
|
||||
"text": "Open folder…",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 14
|
||||
},
|
||||
"row-open-key": {
|
||||
"type": "Text",
|
||||
"parent": "row-open",
|
||||
"left": 590, "top": 470,
|
||||
"text": "⌘O",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"row-clone": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 250, "top": 498, "width": 380, "height": 36,
|
||||
"fillColor": "#0e1014",
|
||||
"strokeColor": "#0e1014"
|
||||
},
|
||||
"row-clone-icon": {
|
||||
"type": "Text",
|
||||
"parent": "row-clone",
|
||||
"left": 262, "top": 506,
|
||||
"text": "⑂",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 14
|
||||
},
|
||||
"row-clone-label": {
|
||||
"type": "Text",
|
||||
"parent": "row-clone",
|
||||
"left": 296, "top": 508,
|
||||
"text": "Clone from git…",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 14
|
||||
},
|
||||
"row-clone-key": {
|
||||
"type": "Text",
|
||||
"parent": "row-clone",
|
||||
"left": 590, "top": 510,
|
||||
"text": "⌘G",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"row-claude": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 250, "top": 538, "width": 380, "height": 36,
|
||||
"fillColor": "#0e1014",
|
||||
"strokeColor": "#0e1014"
|
||||
},
|
||||
"row-claude-icon": {
|
||||
"type": "Text",
|
||||
"parent": "row-claude",
|
||||
"left": 262, "top": 546,
|
||||
"text": "◯",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 14
|
||||
},
|
||||
"row-claude-label": {
|
||||
"type": "Text",
|
||||
"parent": "row-claude",
|
||||
"left": 296, "top": 548,
|
||||
"text": "Start a Claude session",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 14
|
||||
},
|
||||
"row-claude-key": {
|
||||
"type": "Text",
|
||||
"parent": "row-claude",
|
||||
"left": 590, "top": 550,
|
||||
"text": "⌘C",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"recent-label": {
|
||||
"type": "Text",
|
||||
"parent": "canvas",
|
||||
"left": 700, "top": 426,
|
||||
"text": "RECENT",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"recent-row": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 700, "top": 458, "width": 410, "height": 56,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"recent-name": {
|
||||
"type": "Text",
|
||||
"parent": "recent-row",
|
||||
"left": 716, "top": 468,
|
||||
"text": "clide",
|
||||
"fontColor": "#e8ecf2",
|
||||
"fontSize": 14
|
||||
},
|
||||
"recent-meta": {
|
||||
"type": "Text",
|
||||
"parent": "recent-row",
|
||||
"left": 716, "top": 490,
|
||||
"text": "/var/mnt/data/projects/clide · ⑂ main",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 12
|
||||
},
|
||||
"recent-time": {
|
||||
"type": "Text",
|
||||
"parent": "recent-row",
|
||||
"left": 1056, "top": 480,
|
||||
"text": "just now",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"tips-card": {
|
||||
"type": "Rectangle",
|
||||
"parent": "canvas",
|
||||
"left": 250, "top": 612, "width": 860, "height": 110,
|
||||
"fillColor": "#13161c",
|
||||
"strokeColor": "#1c2028",
|
||||
"corners": [6, 6, 6, 6]
|
||||
},
|
||||
"tips-label": {
|
||||
"type": "Text",
|
||||
"parent": "tips-card",
|
||||
"left": 274, "top": 628,
|
||||
"text": "TIPS",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
|
||||
"tip-1-label": {
|
||||
"type": "Text",
|
||||
"parent": "tips-card",
|
||||
"left": 274, "top": 660,
|
||||
"text": "Quick open",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 13
|
||||
},
|
||||
"tip-1-key": {
|
||||
"type": "Text",
|
||||
"parent": "tips-card",
|
||||
"left": 488, "top": 662,
|
||||
"text": "⌘P",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
"tip-2-label": {
|
||||
"type": "Text",
|
||||
"parent": "tips-card",
|
||||
"left": 558, "top": 660,
|
||||
"text": "Command palette",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 13
|
||||
},
|
||||
"tip-2-key": {
|
||||
"type": "Text",
|
||||
"parent": "tips-card",
|
||||
"left": 766, "top": 662,
|
||||
"text": "⌘⇧P",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
"tip-3-label": {
|
||||
"type": "Text",
|
||||
"parent": "tips-card",
|
||||
"left": 836, "top": 660,
|
||||
"text": "Toggle sidebar",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 13
|
||||
},
|
||||
"tip-3-key": {
|
||||
"type": "Text",
|
||||
"parent": "tips-card",
|
||||
"left": 1056, "top": 662,
|
||||
"text": "⌘B",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"tip-4-label": {
|
||||
"type": "Text",
|
||||
"parent": "tips-card",
|
||||
"left": 274, "top": 690,
|
||||
"text": "Toggle context",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 13
|
||||
},
|
||||
"tip-4-key": {
|
||||
"type": "Text",
|
||||
"parent": "tips-card",
|
||||
"left": 488, "top": 692,
|
||||
"text": "⌘J",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
"tip-5-label": {
|
||||
"type": "Text",
|
||||
"parent": "tips-card",
|
||||
"left": 558, "top": 690,
|
||||
"text": "Switch theme",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 13
|
||||
},
|
||||
"tip-5-key": {
|
||||
"type": "Text",
|
||||
"parent": "tips-card",
|
||||
"left": 754, "top": 692,
|
||||
"text": "⌘K ⌘T",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
"tip-6-label": {
|
||||
"type": "Text",
|
||||
"parent": "tips-card",
|
||||
"left": 836, "top": 690,
|
||||
"text": "New Claude session",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 13
|
||||
},
|
||||
"tip-6-key": {
|
||||
"type": "Text",
|
||||
"parent": "tips-card",
|
||||
"left": 1054, "top": 692,
|
||||
"text": "⌘⇧C",
|
||||
"fontColor": "#a0a8b8",
|
||||
"fontSize": 12
|
||||
},
|
||||
|
||||
"status-version": {
|
||||
"type": "Text",
|
||||
"parent": "canvas",
|
||||
"left": 940, "top": 798,
|
||||
"text": "clide 2.0.0-dev",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
},
|
||||
"status-app": {
|
||||
"type": "Text",
|
||||
"parent": "canvas",
|
||||
"left": 1058, "top": 798,
|
||||
"text": "· application ok",
|
||||
"fontColor": "#5a8c5a",
|
||||
"fontSize": 11
|
||||
},
|
||||
"status-theme": {
|
||||
"type": "Text",
|
||||
"parent": "canvas",
|
||||
"left": 1180, "top": 798,
|
||||
"text": "· theme: clide",
|
||||
"fontColor": "#7a8294",
|
||||
"fontSize": 11
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 80 KiB |
@@ -5,24 +5,13 @@ import 'dart:io';
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
import 'package:clide/src/terminal/terminal.dart';
|
||||
|
||||
import 'session_naming.dart';
|
||||
import 'tmux_session.dart' as tmux;
|
||||
|
||||
/// Claude pane. Opinionated per D-041:
|
||||
///
|
||||
/// - [isPrimary]=true: the session name is stable per repo
|
||||
/// (`clide-claude-<hash>`) so reopening the app re-attaches to a
|
||||
/// running `claude` under tmux. No close button rendered —
|
||||
/// close-gestures (tab × on the header) minimise, not kill.
|
||||
/// - [isPrimary]=false: session name includes a `-N` suffix for
|
||||
/// this clide run. Closes normally; `pane.close` kills the tmux
|
||||
/// session.
|
||||
///
|
||||
/// Requires `tmux` on the daemon's PATH. If it isn't there, the pane
|
||||
/// falls back to spawning `claude` directly and loses persistence —
|
||||
/// an explicit state message lands in the header subtitle.
|
||||
class ClaudePane extends StatefulWidget {
|
||||
const ClaudePane({
|
||||
super.key,
|
||||
@@ -33,8 +22,6 @@ class ClaudePane extends StatefulWidget {
|
||||
|
||||
final bool isPrimary;
|
||||
final bool showChrome;
|
||||
|
||||
/// 1-based secondary-session index. Ignored when [isPrimary].
|
||||
final int? secondaryIndex;
|
||||
|
||||
@override
|
||||
@@ -42,7 +29,8 @@ class ClaudePane extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ClaudePaneState extends State<ClaudePane> {
|
||||
static const _maxLines = 5000;
|
||||
static const _maxLines = 50000;
|
||||
static String? _tmuxConfPath;
|
||||
|
||||
late final Terminal _terminal;
|
||||
StreamSubscription<DaemonEvent>? _eventSub;
|
||||
@@ -57,8 +45,8 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_terminal = Terminal(maxLines: _maxLines);
|
||||
_terminal.onOutput = _onOutput;
|
||||
_terminal.onResize = _onResize;
|
||||
_terminal.onOutput = _onTerminalOutput;
|
||||
_terminal.onResize = _onTerminalResize;
|
||||
// Don't spawn here — wait for the first onResize from TerminalView
|
||||
// so the PTY gets real dimensions, not 80x24 defaults.
|
||||
}
|
||||
@@ -70,24 +58,49 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
_eventSub?.cancel();
|
||||
_eventSub = null;
|
||||
final id = _paneId;
|
||||
final sessionName = _sessionName;
|
||||
_paneId = null;
|
||||
// Secondary panes own their tmux session — close on dispose.
|
||||
// Primary panes leave the tmux session alive so the next launch
|
||||
// re-attaches via `tmux new-session -A` (D-41).
|
||||
//
|
||||
// pane.close kills the ptyc-spawned tmux *client*; the tmux server
|
||||
// keeps the session alive. We need an explicit kill-session for
|
||||
// secondaries to actually disappear (D-41 close semantics).
|
||||
if (id != null && !widget.isPrimary) {
|
||||
// Secondary: killing the pane kills the tmux session too —
|
||||
// that's the D-041 policy ("closing a secondary pops back to
|
||||
// primary"). The daemon's pane.close is idempotent.
|
||||
unawaited(_ipc()?.request('pane.close', args: {'id': id}));
|
||||
if (sessionName != null) {
|
||||
unawaited(tmux.killSession(sessionName));
|
||||
}
|
||||
}
|
||||
// Primary: don't close on dispose. The next time this pane is
|
||||
// rebuilt (next app launch, or tab reopen), tmux new-session -A
|
||||
// re-attaches to the same running claude.
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// -- tmux config extraction -----------------------------------------------
|
||||
|
||||
static Future<String?> _ensureTmuxConf() async {
|
||||
if (_tmuxConfPath != null) return _tmuxConfPath;
|
||||
try {
|
||||
final content = await rootBundle.loadString('assets/clide.tmux.conf');
|
||||
final dir = Directory(
|
||||
'${Platform.environment['HOME'] ?? '/tmp'}/.config/clide',
|
||||
);
|
||||
if (!dir.existsSync()) dir.createSync(recursive: true);
|
||||
final file = File('${dir.path}/tmux.conf');
|
||||
file.writeAsStringSync(content);
|
||||
_tmuxConfPath = file.path;
|
||||
return _tmuxConfPath;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// -- spawn ----------------------------------------------------------------
|
||||
|
||||
Future<void> _spawnWhenReady() async {
|
||||
if (!mounted) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
if (!kernel.project.isOpen) {
|
||||
// Wait for a project to open before spawning.
|
||||
final c = Completer<void>();
|
||||
late final StreamSubscription<ProjectOpened> sub;
|
||||
sub = kernel.events.on<ProjectOpened>().listen((_) {
|
||||
@@ -106,28 +119,28 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
if (!mounted) return;
|
||||
final ipc = _ipc();
|
||||
if (ipc == null || !ipc.isConnected) {
|
||||
setState(() => _error = 'Daemon not connected. Start `clide --daemon`.');
|
||||
setState(() => _error = 'Daemon not connected.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve repo root via files.root. If that fails (no daemon, no
|
||||
// git root), fall back to cwd — the session name will just be
|
||||
// based on wherever the daemon is running.
|
||||
String repoRoot = Directory.current.path;
|
||||
final rootResp = await ipc.request('files.root');
|
||||
if (rootResp.ok) {
|
||||
repoRoot = (rootResp.data['path'] as String?) ?? repoRoot;
|
||||
}
|
||||
|
||||
_sessionName = widget.isPrimary ? primarySessionName(repoRoot) : secondarySessionName(repoRoot, widget.secondaryIndex!);
|
||||
_sessionName = widget.isPrimary
|
||||
? primarySessionName(repoRoot)
|
||||
: secondarySessionName(repoRoot, widget.secondaryIndex!);
|
||||
|
||||
// tmux-wrapped session for persistence (D-041).
|
||||
// -x/-y set the initial window size; without them tmux defaults
|
||||
// to a huge size when running inside a PTY without a real terminal.
|
||||
final tmuxConf = await _ensureTmuxConf();
|
||||
final cols = _terminal.viewWidth;
|
||||
final rows = _terminal.viewHeight;
|
||||
|
||||
var argv = <String>[
|
||||
'tmux',
|
||||
'-L', 'clide',
|
||||
if (tmuxConf != null) ...['-f', tmuxConf],
|
||||
'new-session',
|
||||
'-A',
|
||||
'-s',
|
||||
@@ -136,33 +149,37 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
'$cols',
|
||||
'-y',
|
||||
'$rows',
|
||||
'claude',
|
||||
];
|
||||
print('[spawn] cols=${_terminal.viewWidth} rows=${_terminal.viewHeight}');
|
||||
|
||||
// CLAUDE_CODE_NO_FLICKER=1 enables claude's fullscreen TUI mode:
|
||||
// input box pinned to the bottom of the alt-screen, claude owns
|
||||
// its own scrollback. Removes the need for tmux scroll forwarding.
|
||||
final env = {'CLAUDE_CODE_NO_FLICKER': '1'};
|
||||
|
||||
var resp = await ipc.request('pane.spawn', args: {
|
||||
'argv': argv,
|
||||
'kind': PaneKind.claude.wire,
|
||||
'cwd': repoRoot,
|
||||
'cols': _terminal.viewWidth,
|
||||
'rows': _terminal.viewHeight,
|
||||
'cols': cols,
|
||||
'rows': rows,
|
||||
'title': _sessionName,
|
||||
'env': env,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
// tmux probably missing — try bare claude so the pane still
|
||||
// works, at the cost of persistence.
|
||||
argv = ['claude'];
|
||||
resp = await ipc.request('pane.spawn', args: {
|
||||
'argv': argv,
|
||||
'kind': PaneKind.claude.wire,
|
||||
'cwd': repoRoot,
|
||||
'cols': _terminal.viewWidth,
|
||||
'rows': _terminal.viewHeight,
|
||||
'cols': cols,
|
||||
'rows': rows,
|
||||
'title': _sessionName,
|
||||
'env': env,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
setState(() {
|
||||
_error = resp.error?.message ?? 'spawn failed';
|
||||
});
|
||||
setState(() => _error = resp.error?.message ?? 'spawn failed');
|
||||
return;
|
||||
}
|
||||
setState(() => _statusLine = 'no-tmux · fresh every launch');
|
||||
@@ -172,11 +189,12 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
|
||||
if (!mounted) return;
|
||||
_paneId = resp.data['id'] as String?;
|
||||
// PID available in resp.data['pid'] if needed for debugging.
|
||||
_subscribe();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
// -- output batching ------------------------------------------------------
|
||||
|
||||
final _outputBuf = StringBuffer();
|
||||
Timer? _flushTimer;
|
||||
|
||||
@@ -197,30 +215,23 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
final b64 = e.data['bytes_b64'];
|
||||
if (b64 is String) {
|
||||
_outputBuf.write(utf8.decode(base64Decode(b64), allowMalformed: true));
|
||||
// Batch all output from the current event loop turn into one
|
||||
// terminal.write() call. scheduleMicrotask runs after all
|
||||
// pending events but before the next frame, so split escape
|
||||
// sequences within the same event batch are reunited.
|
||||
if (_flushTimer == null) {
|
||||
_flushTimer = Timer(Duration.zero, _flushOutput);
|
||||
}
|
||||
}
|
||||
case 'pane.exit':
|
||||
if (widget.isPrimary) {
|
||||
// Primary exiting is unusual — tmux sessions survive
|
||||
// normal disconnects. Surface it but don't auto-respawn;
|
||||
// the user decides.
|
||||
setState(() => _statusLine = 'session exited — restart clide to retry');
|
||||
} else {
|
||||
setState(() => _statusLine = 'session exited');
|
||||
}
|
||||
setState(() => _statusLine = widget.isPrimary
|
||||
? 'session exited — restart clide to retry'
|
||||
: 'session exited');
|
||||
case 'pane.closed':
|
||||
_paneId = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onOutput(String text) {
|
||||
// -- terminal callbacks ---------------------------------------------------
|
||||
|
||||
void _onTerminalOutput(String text) {
|
||||
final id = _paneId;
|
||||
if (id == null) return;
|
||||
_ipc()?.request('pane.write', args: {'id': id, 'text': text});
|
||||
@@ -228,29 +239,30 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
|
||||
Timer? _resizeTimer;
|
||||
|
||||
void _onResize(int cols, int rows, int _, int __) {
|
||||
print('[onResize] cols=$cols rows=$rows spawned=$_spawned paneId=$_paneId');
|
||||
void _onTerminalResize(int cols, int rows, int _, int __) {
|
||||
if (!_spawned) {
|
||||
// First resize — TerminalView has real dimensions now.
|
||||
_spawned = true;
|
||||
_spawnWhenReady();
|
||||
return;
|
||||
}
|
||||
// Debounce resize — rapid SIGWINCH during window drag corrupts
|
||||
// the terminal rendering. Wait for the resize to settle.
|
||||
_resizeTimer?.cancel();
|
||||
_resizeTimer = Timer(const Duration(milliseconds: 150), () {
|
||||
final id = _paneId;
|
||||
if (id == null) return;
|
||||
_ipc()?.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows});
|
||||
// tmux sizes windows by client, not PTY winsize. Explicitly
|
||||
// resize the tmux window to match the TerminalView dimensions.
|
||||
if (_sessionName != null) {
|
||||
Process.run('tmux', ['resize-window', '-t', _sessionName!, '-x', '$cols', '-y', '$rows']);
|
||||
Process.run('tmux', [
|
||||
'-L', 'clide', 'resize-window',
|
||||
'-t', _sessionName!,
|
||||
'-x', '$cols',
|
||||
'-y', '$rows',
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -- helpers --------------------------------------------------------------
|
||||
|
||||
DaemonClient? _ipc() => _kernel()?.ipc;
|
||||
|
||||
KernelServices? _kernel() {
|
||||
@@ -261,15 +273,20 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
}
|
||||
}
|
||||
|
||||
// -- build ----------------------------------------------------------------
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = widget.isPrimary ? 'claude — primary' : 'claude — secondary ${widget.secondaryIndex}';
|
||||
final title = widget.isPrimary
|
||||
? 'claude — primary'
|
||||
: 'claude — secondary ${widget.secondaryIndex}';
|
||||
|
||||
final body = _error != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ClideText(_error!, muted: true),
|
||||
)
|
||||
: ClidePtyView(terminal: _terminal, label: title);
|
||||
: ClidePtyView(terminal: _terminal, label: title, autofocus: true);
|
||||
|
||||
if (!widget.showChrome) return body;
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'claude_pane.dart';
|
||||
|
||||
/// Hosts the primary Claude pane plus N user-spawned secondary
|
||||
/// sessions per D-41. Uses [MultitabPane] for the tab strip
|
||||
/// (drag-reorder, close ×, + button) and [IndexedStack]-mode
|
||||
/// keep-alive so switching tabs doesn't tear down the underlying
|
||||
/// PTY-backed terminal.
|
||||
class ClaudeSessionHost extends StatefulWidget {
|
||||
const ClaudeSessionHost({super.key});
|
||||
|
||||
@@ -12,175 +16,67 @@ class ClaudeSessionHost extends StatefulWidget {
|
||||
}
|
||||
|
||||
class ClaudeSessionHostState extends State<ClaudeSessionHost> {
|
||||
final List<_Session> _sessions = [];
|
||||
int _activeIndex = 0;
|
||||
static const _primaryId = 'primary';
|
||||
|
||||
late final MultitabController<_Session> _controller;
|
||||
int _nextSecondary = 1;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_sessions.add(_Session(isPrimary: true, label: 'primary'));
|
||||
_controller = MultitabController<_Session>(
|
||||
initial: [
|
||||
MultitabEntry<_Session>(
|
||||
id: _primaryId,
|
||||
title: 'primary',
|
||||
payload: const _Session(isPrimary: true),
|
||||
// Primary persists across clide restarts and never gets a
|
||||
// close affordance (D-41).
|
||||
closeable: false,
|
||||
reorderable: false,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Public entry point used by the `claude.new-secondary` command.
|
||||
void addSecondary() {
|
||||
final index = _nextSecondary++;
|
||||
setState(() {
|
||||
_sessions.add(_Session(isPrimary: false, secondaryIndex: index, label: 'session $index'));
|
||||
_activeIndex = _sessions.length - 1;
|
||||
});
|
||||
}
|
||||
|
||||
void _close(int index) {
|
||||
if (index < 0 || index >= _sessions.length) return;
|
||||
if (_sessions[index].isPrimary) return;
|
||||
setState(() {
|
||||
_sessions.removeAt(index);
|
||||
if (_activeIndex >= _sessions.length) _activeIndex = _sessions.length - 1;
|
||||
if (_activeIndex < 0) _activeIndex = 0;
|
||||
});
|
||||
_controller.add(MultitabEntry<_Session>(
|
||||
id: 'secondary-$index',
|
||||
title: 'session $index',
|
||||
payload: _Session(isPrimary: false, secondaryIndex: index),
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final showTabs = _sessions.length > 1;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (showTabs)
|
||||
_TabRow(
|
||||
sessions: _sessions,
|
||||
activeIndex: _activeIndex,
|
||||
tokens: tokens,
|
||||
onSelect: (i) => setState(() => _activeIndex = i),
|
||||
onClose: _close,
|
||||
onAdd: addSecondary,
|
||||
),
|
||||
Expanded(
|
||||
child: IndexedStack(
|
||||
index: _activeIndex,
|
||||
children: [
|
||||
for (final s in _sessions)
|
||||
ClaudePane(
|
||||
key: s.key,
|
||||
isPrimary: s.isPrimary,
|
||||
secondaryIndex: s.secondaryIndex,
|
||||
showChrome: !showTabs,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
return MultitabPane<_Session>(
|
||||
controller: _controller,
|
||||
keepAlive: true,
|
||||
onAddRequested: addSecondary,
|
||||
bodyBuilder: (ctx, entry) {
|
||||
final s = entry.payload;
|
||||
return ClaudePane(
|
||||
isPrimary: s.isPrimary,
|
||||
secondaryIndex: s.secondaryIndex,
|
||||
// The MultitabPane already provides the tab strip header;
|
||||
// suppressing the ClaudePane's own chrome avoids a double row.
|
||||
showChrome: false,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Session {
|
||||
_Session({required this.isPrimary, this.secondaryIndex, required this.label}) : key = GlobalKey();
|
||||
const _Session({required this.isPrimary, this.secondaryIndex});
|
||||
final bool isPrimary;
|
||||
final int? secondaryIndex;
|
||||
final String label;
|
||||
final GlobalKey key;
|
||||
}
|
||||
|
||||
class _TabRow extends StatelessWidget {
|
||||
const _TabRow({
|
||||
required this.sessions,
|
||||
required this.activeIndex,
|
||||
required this.tokens,
|
||||
required this.onSelect,
|
||||
required this.onClose,
|
||||
required this.onAdd,
|
||||
});
|
||||
|
||||
final List<_Session> sessions;
|
||||
final int activeIndex;
|
||||
final SurfaceTokens tokens;
|
||||
final ValueChanged<int> onSelect;
|
||||
final ValueChanged<int> onClose;
|
||||
final VoidCallback onAdd;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onDoubleTap: onAdd,
|
||||
child: Container(
|
||||
height: 28,
|
||||
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: tokens.dividerColor))),
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < sessions.length; i++)
|
||||
_Tab(
|
||||
session: sessions[i],
|
||||
active: i == activeIndex,
|
||||
tokens: tokens,
|
||||
onTap: () => onSelect(i),
|
||||
onClose: sessions[i].isPrimary ? null : () => onClose(i)),
|
||||
const SizedBox(width: 4),
|
||||
_AddButton(tokens: tokens, onTap: onAdd),
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Tab extends StatelessWidget {
|
||||
const _Tab({required this.session, required this.active, required this.tokens, required this.onTap, this.onClose});
|
||||
final _Session session;
|
||||
final bool active;
|
||||
final SurfaceTokens tokens;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onClose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: hovered && !active ? tokens.tabInactive : null,
|
||||
border: Border(bottom: BorderSide(color: active ? tokens.tabActiveBorder : const Color(0x00000000), width: 2)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideText(
|
||||
session.label,
|
||||
fontSize: 12,
|
||||
color: active ? tokens.tabActiveForeground : tokens.tabInactiveForeground,
|
||||
fontFamily: clideMonoFamily,
|
||||
),
|
||||
if (onClose != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
GestureDetector(
|
||||
onTap: onClose,
|
||||
child: ClideIcon(PhosphorIcons.xMark, size: 10, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddButton extends StatelessWidget {
|
||||
const _AddButton({required this.tokens, required this.onTap});
|
||||
final SurfaceTokens tokens;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
tooltip: 'New session',
|
||||
builder: (context, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: ClideText('+', fontSize: 14, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_session_host.dart';
|
||||
import 'package:clide/builtin/claude/src/session_naming.dart';
|
||||
import 'package:clide/builtin/claude/src/tmux_session.dart' as tmux;
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -48,29 +50,63 @@ class ClaudeExtension extends ClideExtension {
|
||||
@override
|
||||
Future<void> activate(ClideExtensionContext ctx) async {
|
||||
_ctx = ctx;
|
||||
// Cold-start reap: kill any leftover secondary tmux sessions from
|
||||
// a previous run. D-41's "secondary numbering resets between
|
||||
// clide runs" only holds if the leftovers are gone before the new
|
||||
// run starts. Doing this in activate (rather than the previous
|
||||
// run's deactivate) guarantees cleanup even after an abrupt exit
|
||||
// — Flutter's deactivate hook only fires on explicit extension
|
||||
// teardown, not on app quit / kill -9 / OOM.
|
||||
final primary = await _primarySessionName();
|
||||
if (primary != null) await tmux.reapSecondaries(primary);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deactivate() async {
|
||||
await _killAllSessions([]);
|
||||
// Best-effort cleanup on explicit extension teardown. The cold-
|
||||
// start reap in activate is the actual safety net.
|
||||
final primary = await _primarySessionName();
|
||||
if (primary != null) await tmux.reapSecondaries(primary);
|
||||
}
|
||||
|
||||
/// Hard-reset command: kill every clide-claude tmux session for this
|
||||
/// repo, primary included. The user invokes this when they want to
|
||||
/// start over — typically after a tmux/Claude wedge.
|
||||
Future<IpcResponse> _killAllSessions(List<String> args) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return IpcResponse.ok(id: '', data: const {});
|
||||
|
||||
// Close the UI panes first so they don't try to talk to a tmux
|
||||
// server that's about to lose their sessions.
|
||||
final resp = await ctx.ipc.request('pane.list');
|
||||
if (!resp.ok) return resp;
|
||||
final panes = resp.data['panes'];
|
||||
if (panes is List) {
|
||||
for (final p in panes) {
|
||||
if (p is Map && p['kind'] == 'claude') {
|
||||
final id = p['id'] as String?;
|
||||
if (id != null) {
|
||||
await ctx.ipc.request('pane.close', args: {'id': id});
|
||||
if (resp.ok) {
|
||||
final panes = resp.data['panes'];
|
||||
if (panes is List) {
|
||||
for (final p in panes) {
|
||||
if (p is Map && p['kind'] == 'claude') {
|
||||
final id = p['id'] as String?;
|
||||
if (id != null) {
|
||||
await ctx.ipc.request('pane.close', args: {'id': id});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then kill the server-side sessions, primary included.
|
||||
final primary = await _primarySessionName();
|
||||
if (primary != null) await tmux.killAllForRepo(primary);
|
||||
|
||||
return IpcResponse.ok(id: '', data: const {'status': 'killed'});
|
||||
}
|
||||
|
||||
Future<String?> _primarySessionName() async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return null;
|
||||
final resp = await ctx.ipc.request('files.root');
|
||||
if (!resp.ok) return null;
|
||||
final root = resp.data['path'] as String?;
|
||||
if (root == null) return null;
|
||||
return primarySessionName(root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/// tmux server interactions for Claude panes (D-41 lifecycle).
|
||||
///
|
||||
/// `pane.close` only kills the ptyc-spawned tmux *client*; tmux is
|
||||
/// client/server, so the server-side session keeps running after the
|
||||
/// client disconnects. To honour D-41 ("closing a secondary kills that
|
||||
/// tmux session" + "secondary numbering resets between clide runs"),
|
||||
/// we need explicit `tmux kill-session` calls — that's what lives here.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
/// Override-able runner so tests don't shell out for real.
|
||||
typedef TmuxRunner = Future<ProcessResult> Function(List<String> args);
|
||||
|
||||
TmuxRunner tmuxRunner = _defaultRunner;
|
||||
|
||||
Future<ProcessResult> _defaultRunner(List<String> args) =>
|
||||
Process.run('tmux', args);
|
||||
|
||||
const _socket = ['-L', 'clide'];
|
||||
|
||||
/// Kill the named tmux session on the clide socket. No-op if the
|
||||
/// session does not exist (kill-session exits non-zero — we ignore it).
|
||||
Future<void> killSession(String name) async {
|
||||
await tmuxRunner([..._socket, 'kill-session', '-t', name]);
|
||||
}
|
||||
|
||||
/// Return the names of all sessions currently alive on the clide
|
||||
/// socket. Empty list if the server is not running.
|
||||
Future<List<String>> listClideSessions() async {
|
||||
final r = await tmuxRunner([..._socket, 'list-sessions', '-F', '#{session_name}']);
|
||||
if (r.exitCode != 0) return const [];
|
||||
return (r.stdout as String)
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Kill every secondary clide-claude session whose name begins with
|
||||
/// [primaryName] and ends with `-<digits>`. Leaves the primary itself
|
||||
/// alive (D-41).
|
||||
Future<void> reapSecondaries(String primaryName) async {
|
||||
final pattern = RegExp('^${RegExp.escape(primaryName)}-\\d+\$');
|
||||
for (final s in await listClideSessions()) {
|
||||
if (pattern.hasMatch(s)) {
|
||||
await killSession(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill every clide-claude session for [primaryName], including the
|
||||
/// primary itself. Used by the explicit `claude.kill-all-sessions`
|
||||
/// command when the user wants a hard reset.
|
||||
Future<void> killAllForRepo(String primaryName) async {
|
||||
for (final s in await listClideSessions()) {
|
||||
if (s == primaryName || s.startsWith('$primaryName-')) {
|
||||
await killSession(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
import 'package:clide/src/terminal/terminal.dart';
|
||||
|
||||
/// General-purpose terminal pane. Spawns the user's `$SHELL` under the
|
||||
/// daemon's PTY (via `pane.spawn`), feeds the `pane.output` event
|
||||
|
||||
@@ -12,35 +12,109 @@ class WelcomeView extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 850),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_Header(tokens: tokens),
|
||||
const SizedBox(height: 56),
|
||||
Row(
|
||||
return LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
// Tips card sits below the START/RECENT row when there's room
|
||||
// for it; on shorter viewports the two centered columns win
|
||||
// and the tips drop out cleanly.
|
||||
final showTips = c.maxHeight > 640;
|
||||
return Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 850),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _StartColumn(tokens: tokens, kernel: kernel)),
|
||||
const SizedBox(width: 56),
|
||||
Expanded(child: _RecentColumn(tokens: tokens, kernel: kernel)),
|
||||
_Header(tokens: tokens),
|
||||
const SizedBox(height: 56),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _StartColumn(tokens: tokens, kernel: kernel)),
|
||||
const SizedBox(width: 56),
|
||||
Expanded(child: _RecentColumn(tokens: tokens, kernel: kernel)),
|
||||
],
|
||||
),
|
||||
if (showTips) ...[
|
||||
const SizedBox(height: 48),
|
||||
_TipsCard(tokens: tokens),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 64,
|
||||
right: 64,
|
||||
bottom: 24,
|
||||
child: _StatusLine(tokens: tokens, kernel: kernel),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TipsCard extends StatelessWidget {
|
||||
const _TipsCard({required this.tokens});
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
static const _tips = <(String, String)>[
|
||||
('Quick open', '⌘P'),
|
||||
('Command palette', '⌘⇧P'),
|
||||
('Toggle sidebar', '⌘B'),
|
||||
('Toggle context', '⌘J'),
|
||||
('Switch theme', '⌘K ⌘T'),
|
||||
('New Claude session', '⌘⇧C'),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Split tips into two rows of three so the card lays out as a
|
||||
// 3-column grid matching the START/RECENT proportions above.
|
||||
final firstRow = _tips.sublist(0, 3);
|
||||
final secondRow = _tips.sublist(3);
|
||||
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelBackground,
|
||||
border: Border.all(color: tokens.panelBorder),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText('TIPS', fontSize: 12, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
const SizedBox(height: 14),
|
||||
_tipRow(firstRow),
|
||||
const SizedBox(height: 8),
|
||||
_tipRow(secondRow),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tipRow(List<(String, String)> tips) {
|
||||
return Row(
|
||||
children: [
|
||||
for (var i = 0; i < tips.length; i++) ...[
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: ClideText(tips[i].$1, fontSize: 13, color: tokens.globalTextMuted)),
|
||||
ClideText(tips[i].$2, fontSize: 12, color: tokens.globalForeground, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 64,
|
||||
right: 64,
|
||||
bottom: 24,
|
||||
child: _StatusLine(tokens: tokens, kernel: kernel),
|
||||
),
|
||||
if (i < tips.length - 1) const SizedBox(width: 24),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -61,7 +135,7 @@ class _Header extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText('clide', fontSize: 52, fontWeight: FontWeight.w300, color: tokens.globalForeground),
|
||||
ClideText('Flutter desktop IDE for Claude Code', muted: true, fontSize: 16),
|
||||
ClideText('IDE for Claude Code CLI', muted: true, fontSize: 16),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/// Manages the backend isolate lifecycle.
|
||||
///
|
||||
/// Two-phase boot:
|
||||
/// 1. [spawn] — starts the isolate, resolves toolchain (binary checks only).
|
||||
/// 2. [openWorkspace] — initializes services for a specific project root.
|
||||
/// 1. [Backend.spawn] — starts the isolate, resolves toolchain (binary checks only).
|
||||
/// 2. [Backend.openProject] — initializes services for a specific project root.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
@@ -34,7 +34,7 @@ class Backend {
|
||||
int _validateId = 0;
|
||||
|
||||
/// Spawn the backend isolate. Returns when the toolchain is resolved.
|
||||
/// No services are active yet — call [openWorkspace] to activate.
|
||||
/// No services are active yet — call [openProject] to activate.
|
||||
static Future<Backend> spawn({
|
||||
required IsolateClient Function(SendPort backendPort) clientFactory,
|
||||
String? hintRoot,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"title": { "translation": "clide" },
|
||||
"subtitle": { "translation": "Flutter desktop IDE for Claude Code" },
|
||||
"subtitle": { "translation": "IDE for Claude Code CLI" },
|
||||
"open-project": { "translation": "Open project" },
|
||||
"open-project.hint": { "translation": "Pick a git repository to open as the workspace" },
|
||||
"tab.title": { "translation": "Welcome" }
|
||||
|
||||
@@ -136,7 +136,8 @@ class I18n extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// [string] + naive `replaceAll` interpolation per replacer.
|
||||
/// Matches fframe: replacers whose [from] isn't present are silent no-ops.
|
||||
/// Matches fframe: replacers whose [I18nReplacer.from] isn't present
|
||||
/// are silent no-ops.
|
||||
String interpolated(
|
||||
String key, {
|
||||
required String namespace,
|
||||
|
||||
@@ -8,10 +8,14 @@ class InProcessClient extends DaemonClient {
|
||||
InProcessClient({
|
||||
required Logger log,
|
||||
required DaemonBus events,
|
||||
required this.dispatcher,
|
||||
}) : super(socketPath: '', log: log, events: events);
|
||||
required DaemonDispatcher dispatcher,
|
||||
}) : _dispatcher = dispatcher,
|
||||
super(socketPath: '', log: log, events: events);
|
||||
|
||||
final DaemonDispatcher dispatcher;
|
||||
DaemonDispatcher _dispatcher;
|
||||
|
||||
DaemonDispatcher get dispatcher => _dispatcher;
|
||||
set dispatcher(DaemonDispatcher d) => _dispatcher = d;
|
||||
int _nextReqId = 0;
|
||||
|
||||
@override
|
||||
@@ -27,6 +31,6 @@ class InProcessClient extends DaemonClient {
|
||||
Future<IpcResponse> request(String cmd, {Map<String, Object?> args = const {}}) {
|
||||
final id = '${_nextReqId++}';
|
||||
final req = IpcRequest(id: id, cmd: cmd, args: args);
|
||||
return dispatcher.dispatch(req);
|
||||
return _dispatcher.dispatch(req);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,14 +68,11 @@ class PanelRegistry extends ChangeNotifier {
|
||||
List<TabContribution> tabsFor(SlotId id) {
|
||||
final tabs = contributionsFor(id).whereType<TabContribution>().toList();
|
||||
final order = _order[id];
|
||||
if (order == null || order.isEmpty) {
|
||||
tabs.sort((a, b) => a.priority.compareTo(b.priority));
|
||||
return tabs;
|
||||
}
|
||||
if (order == null || order.isEmpty) return tabs;
|
||||
tabs.sort((a, b) {
|
||||
final ai = order.indexOf(a.id);
|
||||
final bi = order.indexOf(b.id);
|
||||
if (ai < 0 && bi < 0) return a.priority.compareTo(b.priority);
|
||||
if (ai < 0 && bi < 0) return 0;
|
||||
if (ai < 0) return 1;
|
||||
if (bi < 0) return -1;
|
||||
return ai.compareTo(bi);
|
||||
|
||||
@@ -6,8 +6,8 @@ import 'package:flutter/foundation.dart';
|
||||
///
|
||||
/// The token surface grows as features need more of it. Every token
|
||||
/// declared here must have a default resolution in
|
||||
/// [DefaultSurfaceMap] so legacy palette-only themes produce a complete
|
||||
/// SurfaceTokens without declaring the full surface.
|
||||
/// `_defaultSurfaceMap` (resolver.dart) so legacy palette-only themes
|
||||
/// produce a complete SurfaceTokens without declaring the full surface.
|
||||
@immutable
|
||||
class SurfaceTokens {
|
||||
const SurfaceTokens({
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/// Centralized binary resolution for external tools.
|
||||
///
|
||||
/// Resolution runs in a background isolate via [resolvePaths] to avoid
|
||||
/// blocking the merged UI/platform thread on macOS. The result is
|
||||
/// applied on the main thread via [applyResolved].
|
||||
/// Resolution runs in a background isolate via [Toolchain.resolvePaths]
|
||||
/// to avoid blocking the merged UI/platform thread on macOS. The result
|
||||
/// is applied on the main thread via [Toolchain.applyResolved].
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
@@ -54,7 +54,6 @@ class Toolchain extends ChangeNotifier {
|
||||
if (_git == null) 'git',
|
||||
if (_pql == null) 'pql',
|
||||
if (_tmux == null) 'tmux',
|
||||
if (_ptyc == null) 'ptyc',
|
||||
];
|
||||
|
||||
/// Returns a Future that completes when resolution finishes.
|
||||
|
||||
@@ -71,6 +71,25 @@ Future<void> main() async {
|
||||
toolchain.applyResolved(resolveToolchainPaths(root));
|
||||
}
|
||||
|
||||
InProcessClient? ipcClient;
|
||||
DaemonBus? daemonBus;
|
||||
|
||||
DaemonDispatcher _buildDispatcher(DaemonBus events, Toolchain tc, Directory workRoot) {
|
||||
final dispatcher = DaemonDispatcher();
|
||||
final eventSink = _BusEventSink(events);
|
||||
final paneRegistry = PaneRegistry(events: eventSink);
|
||||
registerPaneCommands(dispatcher, paneRegistry);
|
||||
final filesService = FilesService(root: workRoot, events: eventSink);
|
||||
registerFilesCommands(dispatcher, filesService);
|
||||
final editorRegistry = EditorRegistry(events: eventSink, workspaceRoot: workRoot);
|
||||
registerEditorCommands(dispatcher, editorRegistry);
|
||||
final gitClient = GitClient(toolchain: tc, workDir: workRoot);
|
||||
registerGitCommands(dispatcher, gitClient, eventSink);
|
||||
final pql = PqlClient(workDir: workRoot, toolchain: tc);
|
||||
registerPqlCommands(dispatcher, pql);
|
||||
return dispatcher;
|
||||
}
|
||||
|
||||
final services = await KernelServices.boot(
|
||||
appDir: appDir,
|
||||
bundledThemes: themes,
|
||||
@@ -81,20 +100,17 @@ Future<void> main() async {
|
||||
daemonClientFactory: kIsWeb
|
||||
? null
|
||||
: (log, events) {
|
||||
final dispatcher = DaemonDispatcher();
|
||||
final eventSink = _BusEventSink(events);
|
||||
final filesService = FilesService.atCwd(events: eventSink);
|
||||
final workRoot = filesService.root;
|
||||
final paneRegistry = PaneRegistry(events: eventSink);
|
||||
registerPaneCommands(dispatcher, paneRegistry);
|
||||
registerFilesCommands(dispatcher, filesService);
|
||||
final editorRegistry = EditorRegistry(events: eventSink, workspaceRoot: workRoot);
|
||||
registerEditorCommands(dispatcher, editorRegistry);
|
||||
final gitClient = GitClient(toolchain: toolchain, workDir: workRoot);
|
||||
registerGitCommands(dispatcher, gitClient, eventSink);
|
||||
final pql = PqlClient(workDir: workRoot, toolchain: toolchain);
|
||||
registerPqlCommands(dispatcher, pql);
|
||||
return InProcessClient(log: log, events: events, dispatcher: dispatcher);
|
||||
daemonBus = events;
|
||||
final workRoot = FilesService.atCwd(events: _BusEventSink(events)).root;
|
||||
final dispatcher = _buildDispatcher(events, toolchain, workRoot);
|
||||
ipcClient = InProcessClient(log: log, events: events, dispatcher: dispatcher);
|
||||
return ipcClient!;
|
||||
},
|
||||
onProjectOpen: kIsWeb
|
||||
? null
|
||||
: (path) async {
|
||||
if (ipcClient == null || daemonBus == null) return;
|
||||
ipcClient!.dispatcher = _buildDispatcher(daemonBus!, toolchain, Directory(path));
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -9,8 +9,11 @@
|
||||
/// one-to-one onto these in `bin/clide.dart`.
|
||||
library;
|
||||
|
||||
import 'dart:io' show FileSystemException;
|
||||
|
||||
import '../editor/registry.dart';
|
||||
import '../ipc/envelope.dart';
|
||||
import '../ipc/errno_mapping.dart';
|
||||
import '../ipc/schema_v1.dart';
|
||||
import 'dispatcher.dart';
|
||||
|
||||
@@ -64,6 +67,22 @@ Future<IpcResponse> _open(IpcRequest req, EditorRegistry r) async {
|
||||
try {
|
||||
final buf = await r.open(path);
|
||||
return IpcResponse.ok(id: req.id, data: buf.toJson());
|
||||
} on FileSystemException catch (e) {
|
||||
final errno = e.osError?.errorCode;
|
||||
if (errno != null) {
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
error: errnoToIpcError(errno: errno, op: 'editor.open', target: path),
|
||||
);
|
||||
}
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
error: IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'editor.open failed: ${e.message}',
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'dart:io';
|
||||
|
||||
import '../files/ignore.dart';
|
||||
import '../files/listing.dart';
|
||||
import '../files/path_safety.dart';
|
||||
import '../files/watcher.dart';
|
||||
import '../ipc/envelope.dart';
|
||||
import '../ipc/schema_v1.dart';
|
||||
@@ -71,7 +72,13 @@ void registerFilesCommands(DaemonDispatcher d, FilesService files) {
|
||||
if (path == null || path.isEmpty) {
|
||||
return IpcResponse.err(id: req.id, error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'files.read requires a path'));
|
||||
}
|
||||
final file = File('${files.root.absolute.path}/$path');
|
||||
final String absPath;
|
||||
try {
|
||||
absPath = resolveUnderRoot(files.root, path);
|
||||
} on PathOutsideRoot {
|
||||
return IpcResponse.err(id: req.id, error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'path outside workspace: $path'));
|
||||
}
|
||||
final file = File(absPath);
|
||||
if (!file.existsSync()) {
|
||||
return IpcResponse.err(id: req.id, error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'file not found: $path'));
|
||||
}
|
||||
@@ -81,6 +88,13 @@ void registerFilesCommands(DaemonDispatcher d, FilesService files) {
|
||||
|
||||
d.register('files.ls', (req) async {
|
||||
final dir = (req.args['path'] as String?) ?? '';
|
||||
if (dir.isNotEmpty) {
|
||||
try {
|
||||
resolveUnderRoot(files.root, dir);
|
||||
} on PathOutsideRoot {
|
||||
return IpcResponse.err(id: req.id, error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'path outside workspace: $dir'));
|
||||
}
|
||||
}
|
||||
final entries = await listDir(
|
||||
root: files.root,
|
||||
dir: dir,
|
||||
|
||||
@@ -12,9 +12,11 @@ library;
|
||||
import 'dart:convert';
|
||||
|
||||
import '../ipc/envelope.dart';
|
||||
import '../ipc/errno_mapping.dart';
|
||||
import '../ipc/schema_v1.dart';
|
||||
import '../panes/pane.dart';
|
||||
import '../panes/registry.dart';
|
||||
import '../pty/errors.dart';
|
||||
import 'dispatcher.dart';
|
||||
|
||||
void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry) {
|
||||
@@ -84,6 +86,26 @@ Future<IpcResponse> _spawn(IpcRequest req, PaneRegistry registry) async {
|
||||
title: args['title'] as String?,
|
||||
);
|
||||
return IpcResponse.ok(id: req.id, data: pane.toJson());
|
||||
} on PtyException catch (e) {
|
||||
final errno = e.errno;
|
||||
if (errno != null) {
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
error: errnoToIpcError(
|
||||
errno: errno,
|
||||
op: 'pane.spawn',
|
||||
target: argv.isNotEmpty ? argv.first : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
error: IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'pane.spawn failed: ${e.message}',
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/// Workspace-relative path validation. Rejects paths that resolve
|
||||
/// outside the workspace root (path traversal via `..`, absolute
|
||||
/// paths, symlink-out attempts).
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
class PathOutsideRoot implements Exception {
|
||||
PathOutsideRoot(this.requested, this.resolved, this.root);
|
||||
final String requested;
|
||||
final String resolved;
|
||||
final String root;
|
||||
|
||||
@override
|
||||
String toString() => 'path outside workspace root: $requested → $resolved (root: $root)';
|
||||
}
|
||||
|
||||
/// Resolve [relative] against [root] and verify the result is
|
||||
/// contained within [root]. Returns the absolute, normalized path.
|
||||
/// Throws [PathOutsideRoot] on traversal attempts.
|
||||
String resolveUnderRoot(Directory root, String relative) {
|
||||
final rootPath = _normalize(root.absolute.path);
|
||||
final joined = _normalize('$rootPath${Platform.pathSeparator}$relative');
|
||||
|
||||
// Containment check: joined must equal rootPath, or start with
|
||||
// rootPath + separator. Equality covers `relative == ''` (the
|
||||
// root itself); the separator check prevents `/repo` matching
|
||||
// `/repository`.
|
||||
if (joined != rootPath && !joined.startsWith('$rootPath${Platform.pathSeparator}')) {
|
||||
throw PathOutsideRoot(relative, joined, rootPath);
|
||||
}
|
||||
return joined;
|
||||
}
|
||||
|
||||
String _normalize(String path) {
|
||||
// Use Uri to collapse `..` and `.` segments without hitting the
|
||||
// filesystem (Directory(...).resolveSymbolicLinksSync would also
|
||||
// resolve symlinks, which we don't want here — symlink handling
|
||||
// belongs at the filesystem-access layer, not the path layer).
|
||||
final segments = <String>[];
|
||||
for (final raw in path.split(Platform.pathSeparator)) {
|
||||
if (raw.isEmpty || raw == '.') continue;
|
||||
if (raw == '..') {
|
||||
if (segments.isNotEmpty) segments.removeLast();
|
||||
continue;
|
||||
}
|
||||
segments.add(raw);
|
||||
}
|
||||
final prefix = path.startsWith(Platform.pathSeparator) ? Platform.pathSeparator : '';
|
||||
return '$prefix${segments.join(Platform.pathSeparator)}';
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/// Map POSIX errno values to IPC error envelopes with actionable
|
||||
/// messages. Used by command handlers that wrap syscall-backed work
|
||||
/// (PTY spawn, file open) so the client can distinguish "binary not
|
||||
/// found" from "permission denied" from "system limit hit" instead
|
||||
/// of seeing the same generic `tool_error: foo failed`.
|
||||
library;
|
||||
|
||||
import 'envelope.dart';
|
||||
import 'schema_v1.dart';
|
||||
|
||||
/// Selected POSIX errno values we map specially. Others fall through
|
||||
/// to a generic toolError. Values match Linux glibc and macOS Darwin
|
||||
/// (the two platforms that share the same numbers for these entries).
|
||||
abstract class PosixErrno {
|
||||
static const int eperm = 1;
|
||||
static const int enoent = 2;
|
||||
static const int esrch = 3;
|
||||
static const int eio = 5;
|
||||
static const int ebadf = 9;
|
||||
static const int eagain = 11;
|
||||
static const int enomem = 12;
|
||||
static const int eacces = 13;
|
||||
static const int eexist = 17;
|
||||
static const int enotdir = 20;
|
||||
static const int eisdir = 21;
|
||||
static const int emfile = 24;
|
||||
static const int enfile = 23;
|
||||
static const int epipe = 32;
|
||||
}
|
||||
|
||||
/// Build an [IpcError] from a POSIX [errno] for an operation [op]
|
||||
/// (e.g. `pane.spawn`, `editor.open`) on optional [target] (a path,
|
||||
/// command name, etc.). The returned error uses `notFound`,
|
||||
/// `userError`, or `toolError` based on what's actionable.
|
||||
IpcError errnoToIpcError({
|
||||
required int errno,
|
||||
required String op,
|
||||
String? target,
|
||||
String? raw,
|
||||
}) {
|
||||
final what = target != null ? ' ($target)' : '';
|
||||
switch (errno) {
|
||||
case PosixErrno.enoent:
|
||||
return IpcError(
|
||||
code: IpcExitCode.notFound,
|
||||
kind: IpcErrorKind.notFound,
|
||||
message: '$op: not found$what',
|
||||
);
|
||||
case PosixErrno.eacces:
|
||||
case PosixErrno.eperm:
|
||||
return IpcError(
|
||||
code: IpcExitCode.userError,
|
||||
kind: IpcErrorKind.userError,
|
||||
message: '$op: permission denied$what',
|
||||
hint: 'check file permissions or run with appropriate access',
|
||||
);
|
||||
case PosixErrno.eisdir:
|
||||
return IpcError(
|
||||
code: IpcExitCode.userError,
|
||||
kind: IpcErrorKind.userError,
|
||||
message: '$op: is a directory$what',
|
||||
);
|
||||
case PosixErrno.enotdir:
|
||||
return IpcError(
|
||||
code: IpcExitCode.userError,
|
||||
kind: IpcErrorKind.userError,
|
||||
message: '$op: not a directory$what',
|
||||
);
|
||||
case PosixErrno.eexist:
|
||||
return IpcError(
|
||||
code: IpcExitCode.conflict,
|
||||
kind: IpcErrorKind.conflict,
|
||||
message: '$op: already exists$what',
|
||||
);
|
||||
case PosixErrno.emfile:
|
||||
case PosixErrno.enfile:
|
||||
return IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: '$op: too many open files',
|
||||
hint: 'system or per-process file descriptor limit reached',
|
||||
);
|
||||
case PosixErrno.enomem:
|
||||
return IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: '$op: out of memory',
|
||||
);
|
||||
case PosixErrno.eagain:
|
||||
return IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: '$op: resource temporarily unavailable',
|
||||
hint: 'retry may succeed',
|
||||
);
|
||||
default:
|
||||
return IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: '$op failed${raw != null ? ': $raw' : ' (errno=$errno)'}',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,12 @@ import 'package:clide/src/ipc/envelope.dart';
|
||||
|
||||
typedef RequestDispatcher = Future<IpcResponse> Function(IpcRequest request);
|
||||
|
||||
/// Default per-request timeout. A handler that doesn't return within
|
||||
/// this window gets a `tool_error` response so the connection's read
|
||||
/// pipeline isn't blocked indefinitely. Long-running commands (git
|
||||
/// pull/push, large pql queries) can override per-command later.
|
||||
const Duration _kDefaultRequestTimeout = Duration(seconds: 60);
|
||||
|
||||
/// Unix-socket JSON-lines server. Each connection is an independent
|
||||
/// bidirectional line-framed stream: client writes requests, daemon
|
||||
/// writes responses + events on the same socket.
|
||||
@@ -13,26 +19,27 @@ class DaemonServer {
|
||||
DaemonServer({
|
||||
required this.socketPath,
|
||||
required this.dispatch,
|
||||
});
|
||||
Duration requestTimeout = _kDefaultRequestTimeout,
|
||||
}) : _requestTimeout = requestTimeout;
|
||||
|
||||
final String socketPath;
|
||||
final RequestDispatcher dispatch;
|
||||
final Duration _requestTimeout;
|
||||
|
||||
ServerSocket? _server;
|
||||
final Set<Socket> _clients = {};
|
||||
|
||||
/// Broadcast [event] to every currently-connected client.
|
||||
///
|
||||
/// Future tuning: per-client subsystem/id filter (`tail --filter
|
||||
/// pane:p_7`). For Tier 1 every client sees everything. Sockets
|
||||
/// that error on write are silently dropped; the client's read side
|
||||
/// will notice the close.
|
||||
/// Broadcast [event] to every currently-connected client. Sockets
|
||||
/// that error on write are dropped — the client's read side will
|
||||
/// notice the close. Errors are logged so silent event loss is
|
||||
/// debuggable.
|
||||
void broadcast(IpcEvent event) {
|
||||
final line = event.encode();
|
||||
for (final c in List<Socket>.from(_clients)) {
|
||||
try {
|
||||
c.writeln(line);
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
stderr.writeln('clide daemon: broadcast write failed (${event.subsystem}.${event.kind}): $e');
|
||||
_clients.remove(c);
|
||||
}
|
||||
}
|
||||
@@ -43,7 +50,19 @@ class DaemonServer {
|
||||
try {
|
||||
_server = await ServerSocket.bind(addr, 0);
|
||||
} on SocketException {
|
||||
// stale socket from a prior crash — unlink and retry once
|
||||
// Either a stale socket from a prior crash, or a live daemon.
|
||||
// Probe by trying to connect — if a live peer answers, refuse
|
||||
// to start so we don't rip its socket out.
|
||||
try {
|
||||
final probe = await Socket.connect(addr, 0)
|
||||
.timeout(const Duration(milliseconds: 200));
|
||||
await probe.close();
|
||||
throw StateError('clide daemon already running at $socketPath');
|
||||
} on TimeoutException {
|
||||
// No one answered — proceed to unlink and rebind.
|
||||
} on SocketException {
|
||||
// No one listening — proceed to unlink and rebind.
|
||||
}
|
||||
try {
|
||||
await File(socketPath).delete();
|
||||
} catch (_) {}
|
||||
@@ -92,7 +111,17 @@ class DaemonServer {
|
||||
if (msg is! IpcRequest) return;
|
||||
IpcResponse resp;
|
||||
try {
|
||||
resp = await dispatch(msg);
|
||||
resp = await dispatch(msg).timeout(_requestTimeout);
|
||||
} on TimeoutException {
|
||||
stderr.writeln('clide daemon: dispatch timeout for ${msg.cmd} (${_requestTimeout.inSeconds}s)');
|
||||
resp = IpcResponse.err(
|
||||
id: msg.id,
|
||||
error: IpcError(
|
||||
code: 2,
|
||||
kind: 'tool_error',
|
||||
message: 'request timed out after ${_requestTimeout.inSeconds}s: ${msg.cmd}',
|
||||
),
|
||||
);
|
||||
} catch (e, st) {
|
||||
stderr.writeln('clide daemon: dispatch error for ${msg.cmd}: $e\n$st');
|
||||
resp = IpcResponse.err(
|
||||
@@ -104,6 +133,13 @@ class DaemonServer {
|
||||
),
|
||||
);
|
||||
}
|
||||
client.writeln(resp.encode());
|
||||
try {
|
||||
client.writeln(resp.encode());
|
||||
} catch (e) {
|
||||
// Client disconnected mid-dispatch — drop it so future events
|
||||
// don't try to write to a dead socket.
|
||||
stderr.writeln('clide daemon: response write failed (${msg.cmd}): $e');
|
||||
_clients.remove(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:ffi/ffi.dart';
|
||||
|
||||
import 'errors.dart';
|
||||
import 'ffi/libc.dart' as libc;
|
||||
|
||||
// -- structs ----------------------------------------------------------------
|
||||
|
||||
final class _Winsize extends ffi.Struct {
|
||||
@@ -87,6 +90,13 @@ class NativePty {
|
||||
final _out = StreamController<Uint8List>.broadcast();
|
||||
bool _dead = false;
|
||||
|
||||
/// Tracks the reader isolate's spawn — close() awaits this before
|
||||
/// tearing down so we never race a still-spawning isolate.
|
||||
Future<void>? _readerReady;
|
||||
Isolate? _readerIsolate;
|
||||
ReceivePort? _readerPort;
|
||||
Completer<void>? _readerExited;
|
||||
|
||||
NativePty._(this._fd, this.pid);
|
||||
|
||||
/// Byte stream of data produced by the child.
|
||||
@@ -125,6 +135,17 @@ class NativePty {
|
||||
final execve = _execve;
|
||||
final chdir = _chdir;
|
||||
final exit = _exit_;
|
||||
final writeFn = _nativeWrite;
|
||||
|
||||
// Pre-allocate error envelopes the child will write to its stdout
|
||||
// (slave PTY → parent's master fd) before _exit, so the parent's
|
||||
// reader sees a real diagnostic instead of an indistinguishable EOF.
|
||||
final chdirErr = 'clide: chdir failed: $workingDirectory\n'
|
||||
.toNativeUtf8(allocator: malloc);
|
||||
final chdirErrLen = chdirErr.length;
|
||||
final execveErr = 'clide: exec failed: $executable\n'
|
||||
.toNativeUtf8(allocator: malloc);
|
||||
final execveErrLen = execveErr.length;
|
||||
|
||||
// Allocate ALL native memory before fork.
|
||||
final shellN = executable.toNativeUtf8(allocator: malloc).cast<ffi.Char>();
|
||||
@@ -153,20 +174,33 @@ class NativePty {
|
||||
final pid = _forkpty(fdOut, ffi.nullptr, ffi.nullptr, ws);
|
||||
|
||||
if (pid == -1) {
|
||||
// Capture errno BEFORE _freeAll — free() can clobber errno.
|
||||
final err = libc.errno;
|
||||
_freeAll(shellN, argvN, allArgs.length, envpN, envList.length, wdN, fdOut, ws);
|
||||
throw StateError('forkpty() failed');
|
||||
malloc.free(chdirErr);
|
||||
malloc.free(execveErr);
|
||||
throw PtyException('forkpty', 'forkpty() failed', errno: err);
|
||||
}
|
||||
|
||||
if (pid == 0) {
|
||||
// CHILD — only pre-resolved FFI calls, no Dart heap.
|
||||
chdir(wdN);
|
||||
// After forkpty(), fd 1 is the slave PTY connected back to the
|
||||
// parent's master fd, so write(1, ...) lands as readable output.
|
||||
if (chdir(wdN) != 0) {
|
||||
writeFn(1, chdirErr.cast(), chdirErrLen);
|
||||
exit(1);
|
||||
}
|
||||
execve(shellN, argvN, envpN);
|
||||
// execve only returns on failure.
|
||||
writeFn(1, execveErr.cast(), execveErrLen);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// PARENT
|
||||
final fd = fdOut.value;
|
||||
_freeAll(shellN, argvN, allArgs.length, envpN, envList.length, wdN, fdOut, ws);
|
||||
malloc.free(chdirErr);
|
||||
malloc.free(execveErr);
|
||||
|
||||
final pty = NativePty._(fd, pid);
|
||||
pty._spawnReader();
|
||||
@@ -195,13 +229,32 @@ class NativePty {
|
||||
|
||||
// -- I/O ------------------------------------------------------------------
|
||||
|
||||
void _spawnReader() async {
|
||||
void _spawnReader() {
|
||||
_readerReady = _spawnReaderAsync();
|
||||
}
|
||||
|
||||
Future<void> _spawnReaderAsync() async {
|
||||
final rp = ReceivePort();
|
||||
await Isolate.spawn(_readLoop, (rp.sendPort, _fd));
|
||||
_readerPort = rp;
|
||||
_readerExited = Completer<void>();
|
||||
try {
|
||||
_readerIsolate = await Isolate.spawn(_readLoop, (rp.sendPort, _fd));
|
||||
} catch (e) {
|
||||
// Surface the spawn failure instead of leaving the PTY in a
|
||||
// half-alive state where output never flows but isClosed=false.
|
||||
_dead = true;
|
||||
if (!_out.isClosed) _out.addError(PtyException('reader-spawn', '$e'));
|
||||
rp.close();
|
||||
_readerPort = null;
|
||||
if (!_readerExited!.isCompleted) _readerExited!.complete();
|
||||
return;
|
||||
}
|
||||
rp.listen((msg) {
|
||||
if (msg == null) {
|
||||
if (!_out.isClosed) _out.close();
|
||||
rp.close();
|
||||
_readerPort = null;
|
||||
if (!_readerExited!.isCompleted) _readerExited!.complete();
|
||||
_reap();
|
||||
} else {
|
||||
if (!_out.isClosed) _out.add(msg as Uint8List);
|
||||
@@ -240,26 +293,52 @@ class NativePty {
|
||||
port.send(null);
|
||||
}
|
||||
|
||||
/// Write bytes to the child's stdin.
|
||||
/// Write bytes to the child's stdin. Loops on short writes; throws
|
||||
/// [PtyException] (with errno) on failure. Returns the total bytes
|
||||
/// written, which is always [bytes.length] on success.
|
||||
int write(List<int> bytes) {
|
||||
if (_dead || bytes.isEmpty) return 0;
|
||||
final buf = malloc<ffi.Uint8>(bytes.length);
|
||||
for (var i = 0; i < bytes.length; i++) buf[i] = bytes[i];
|
||||
final n = _nativeWrite(_fd, buf.cast(), bytes.length);
|
||||
malloc.free(buf);
|
||||
return n;
|
||||
try {
|
||||
for (var i = 0; i < bytes.length; i++) buf[i] = bytes[i];
|
||||
var written = 0;
|
||||
while (written < bytes.length) {
|
||||
final n = _nativeWrite(
|
||||
_fd,
|
||||
buf.elementAt(written).cast(),
|
||||
bytes.length - written,
|
||||
);
|
||||
if (n < 0) {
|
||||
final err = libc.errno;
|
||||
if (err == 4 /* EINTR */) continue;
|
||||
if (err == 9 /* EBADF */ || err == 32 /* EPIPE */) _dead = true;
|
||||
throw PtyException('write', 'write to PTY failed', errno: err);
|
||||
}
|
||||
if (n == 0) break;
|
||||
written += n;
|
||||
}
|
||||
return written;
|
||||
} finally {
|
||||
malloc.free(buf);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize the terminal.
|
||||
/// Resize the terminal. Silently no-ops if the fd is already
|
||||
/// closed; flips [_dead] on EBADF so subsequent calls short-circuit.
|
||||
void resize({required int cols, required int rows}) {
|
||||
if (_dead) return;
|
||||
final ws = calloc<_Winsize>()
|
||||
..ref.wsRow = rows
|
||||
..ref.wsCol = cols;
|
||||
_ioctl(_fd, _kTiocsWinsz, ws);
|
||||
final rc = _ioctl(_fd, _kTiocsWinsz, ws);
|
||||
calloc.free(ws);
|
||||
if (rc < 0 && libc.errno == 9 /* EBADF */) {
|
||||
_dead = true;
|
||||
return;
|
||||
}
|
||||
// Explicitly signal the child to re-query its terminal size.
|
||||
_nativeKill(pid, 28); // SIGWINCH = 28 on macOS/Linux
|
||||
// SIGWINCH = 28 on both macOS and Linux.
|
||||
_nativeKill(pid, 28);
|
||||
}
|
||||
|
||||
/// Send a signal to the child.
|
||||
@@ -277,12 +356,39 @@ class NativePty {
|
||||
}
|
||||
|
||||
/// Kill the child and release resources.
|
||||
///
|
||||
/// Order matters: kill the child first so its slave PTY closes,
|
||||
/// causing the master fd to return EOF. The reader isolate sees
|
||||
/// EOF and exits cleanly. Only then do we close the master fd —
|
||||
/// closing it before the isolate exits creates a window where the
|
||||
/// fd number could be reused and the isolate would briefly poll
|
||||
/// the wrong file.
|
||||
Future<void> close() async {
|
||||
if (_dead) return;
|
||||
_dead = true;
|
||||
_nativeClose(_fd);
|
||||
|
||||
// Make sure the reader is fully spawned before we tear it down —
|
||||
// otherwise close() racing with start() leaves an orphan isolate.
|
||||
await _readerReady;
|
||||
|
||||
_nativeKill(pid, _kSighup);
|
||||
_nativeKill(pid, 9);
|
||||
|
||||
// Wait for the isolate to send `null` (EOF) — confirms it has
|
||||
// exited its poll loop and won't touch the fd again.
|
||||
if (_readerExited != null) {
|
||||
await _readerExited!.future.timeout(
|
||||
const Duration(milliseconds: 500),
|
||||
onTimeout: () {},
|
||||
);
|
||||
}
|
||||
|
||||
_nativeClose(_fd);
|
||||
_readerIsolate?.kill(priority: Isolate.immediate);
|
||||
_readerIsolate = null;
|
||||
_readerPort?.close();
|
||||
_readerPort = null;
|
||||
|
||||
final s = calloc<ffi.Int32>();
|
||||
_waitpid(pid, s, 0);
|
||||
calloc.free(s);
|
||||
|
||||
@@ -132,36 +132,51 @@ class PtySession {
|
||||
// Receive the master fd over the parent side of the socketpair.
|
||||
// recvFd blocks until ptyc sends — run in a child isolate so the
|
||||
// calling isolate's event loop stays responsive.
|
||||
final masterFd = await _recvFdAsync(parentSock);
|
||||
|
||||
// Apply initial winsize (ptyc already did this, but doing it
|
||||
// again from Dart confirms the wire + gives a place to call it
|
||||
// when resize() lands).
|
||||
libc.setWinsize(masterFd, cols, rows);
|
||||
|
||||
// Drain ptyc's stdout to parse the success envelope. We don't
|
||||
// strictly need it — the fd arriving is proof-of-life — but
|
||||
// draining avoids a PIPE accumulating.
|
||||
final stdoutLine = await proc.stdout.transform(const Utf8Decoder()).transform(const LineSplitter()).first.timeout(const Duration(seconds: 5));
|
||||
final pid = _extractPid(stdoutLine);
|
||||
|
||||
final code = await proc.exitCode;
|
||||
if (code != 0) {
|
||||
final stderr = await proc.stderr.transform(const Utf8Decoder()).join();
|
||||
libc.close(masterFd);
|
||||
throw PtyException('ptyc', 'ptyc exited with code $code: $stderr');
|
||||
final int masterFd;
|
||||
try {
|
||||
masterFd = await _recvFdAsync(parentSock);
|
||||
} catch (_) {
|
||||
proc.kill();
|
||||
rethrow;
|
||||
}
|
||||
|
||||
return PtySession._(pid: pid, masterFd: masterFd);
|
||||
// Once we own masterFd, every error path below must close it
|
||||
// before rethrowing. Wrap the rest of the spawn in its own
|
||||
// try/catch so the cleanup is centralized.
|
||||
try {
|
||||
libc.setWinsize(masterFd, cols, rows);
|
||||
|
||||
final stdoutLine = await proc.stdout
|
||||
.transform(const Utf8Decoder())
|
||||
.transform(const LineSplitter())
|
||||
.first
|
||||
.timeout(const Duration(seconds: 5));
|
||||
final pid = _extractPid(stdoutLine);
|
||||
|
||||
final code = await proc.exitCode;
|
||||
if (code != 0) {
|
||||
final stderr = await proc.stderr.transform(const Utf8Decoder()).join();
|
||||
libc.close(masterFd);
|
||||
throw PtyException('ptyc', 'ptyc exited with code $code: $stderr');
|
||||
}
|
||||
|
||||
return PtySession._(pid: pid, masterFd: masterFd);
|
||||
} catch (_) {
|
||||
libc.close(masterFd);
|
||||
rethrow;
|
||||
}
|
||||
} finally {
|
||||
// parent keeps its own fd until the session is closed; ptyc-side
|
||||
// fd is released either way (ptyc has exited by now).
|
||||
if (childSock >= 0) libc.close(childSock);
|
||||
if (parentSock >= 0) libc.close(parentSock);
|
||||
pkg_ffi.calloc.free(sv);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send bytes to the child's stdin.
|
||||
/// Send bytes to the child's stdin. Loops on short writes; throws
|
||||
/// [PtyException] (with errno) on failure. Returns total bytes
|
||||
/// written, which equals [bytes.length] on success.
|
||||
int write(List<int> bytes) {
|
||||
if (isClosed) return 0;
|
||||
final buf = pkg_ffi.calloc<ffi.Uint8>(bytes.length);
|
||||
@@ -169,7 +184,22 @@ class PtySession {
|
||||
for (var i = 0; i < bytes.length; i++) {
|
||||
buf[i] = bytes[i];
|
||||
}
|
||||
return libc.write(_masterFd, buf, bytes.length);
|
||||
var written = 0;
|
||||
while (written < bytes.length) {
|
||||
final n = libc.write(
|
||||
_masterFd,
|
||||
buf.elementAt(written),
|
||||
bytes.length - written,
|
||||
);
|
||||
if (n < 0) {
|
||||
final err = libc.errno;
|
||||
if (err == 4 /* EINTR */) continue;
|
||||
throw PtyException('write', 'write to PTY failed', errno: err);
|
||||
}
|
||||
if (n == 0) break;
|
||||
written += n;
|
||||
}
|
||||
return written;
|
||||
} finally {
|
||||
pkg_ffi.calloc.free(buf);
|
||||
}
|
||||
@@ -237,12 +267,16 @@ class PtySession {
|
||||
/// stall the calling isolate's event loop.
|
||||
static Future<int> _recvFdAsync(int socketFd) async {
|
||||
final port = ReceivePort();
|
||||
final iso = await Isolate.spawn(_recvFdEntry, _RecvFdArgs(socketFd, port.sendPort));
|
||||
final result = await port.first;
|
||||
iso.kill(priority: Isolate.immediate);
|
||||
port.close();
|
||||
if (result is int) return result;
|
||||
throw PtyException('recvFd', '$result');
|
||||
Isolate? iso;
|
||||
try {
|
||||
iso = await Isolate.spawn(_recvFdEntry, _RecvFdArgs(socketFd, port.sendPort));
|
||||
final result = await port.first;
|
||||
if (result is int) return result;
|
||||
throw PtyException('recvFd', '$result');
|
||||
} finally {
|
||||
iso?.kill(priority: Isolate.immediate);
|
||||
port.close();
|
||||
}
|
||||
}
|
||||
|
||||
static void _recvFdEntry(_RecvFdArgs args) {
|
||||
@@ -271,7 +305,19 @@ class PtySession {
|
||||
Isolate.spawn<_ReaderArgs>(
|
||||
_readerEntrypoint,
|
||||
_ReaderArgs(fd: _masterFd, sendPort: port.sendPort),
|
||||
).then((iso) => _readerIsolate = iso);
|
||||
).then(
|
||||
(iso) => _readerIsolate = iso,
|
||||
onError: (Object e) {
|
||||
// Spawn failure leaves the session unable to ever produce
|
||||
// output. Surface the error and mark the controller closed
|
||||
// so consumers don't hang waiting on the stream.
|
||||
if (!_outputCtrl.isClosed) {
|
||||
_outputCtrl.addError(PtyException('reader-spawn', '$e'));
|
||||
_outputCtrl.close();
|
||||
}
|
||||
if (!_readerExited.isCompleted) _readerExited.complete();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// -- request builder ------------------------------------------------------
|
||||
@@ -370,7 +416,7 @@ class _ReaderArgs {
|
||||
/// each chunk back to the main isolate as a `Uint8List`. Exits on
|
||||
/// EOF, close, or error.
|
||||
void _readerEntrypoint(_ReaderArgs args) {
|
||||
const chunk = 4096;
|
||||
const chunk = 65536;
|
||||
final buf = pkg_ffi.calloc<ffi.Uint8>(chunk);
|
||||
try {
|
||||
while (true) {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
clide terminal — derivative work based on xterm.dart v4.0.0.
|
||||
|
||||
The terminal emulator code in this directory is derived from xterm.dart
|
||||
(https://github.com/TerminalStudio/xterm.dart) and has been modified.
|
||||
Changes include: Scrollable removal, quiver dependency replaced with
|
||||
hand-rolled LRU, zmodem/debugger/suggestion modules stripped, scroll
|
||||
event forwarding rewritten, imports restructured.
|
||||
|
||||
The original xterm.dart code is copyright (c) 2020 xuty and licensed
|
||||
under the MIT License, reproduced below. This derivative work is part
|
||||
of clide and is distributed under clide's MIT license.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2020 xuty
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,43 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
import 'package:clide/src/terminal/src/base/event.dart';
|
||||
|
||||
mixin Disposable {
|
||||
final _disposables = <Disposable>[];
|
||||
|
||||
bool get disposed => _disposed;
|
||||
bool _disposed = false;
|
||||
|
||||
Event get onDisposed => _onDisposed.event;
|
||||
final _onDisposed = EventEmitter();
|
||||
|
||||
void register(Disposable disposable) {
|
||||
assert(!_disposed);
|
||||
_disposables.add(disposable);
|
||||
}
|
||||
|
||||
void registerCallback(void Function() callback) {
|
||||
assert(!_disposed);
|
||||
_disposables.add(_DisposeCallback(callback));
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
for (final disposable in _disposables) {
|
||||
disposable.dispose();
|
||||
}
|
||||
_onDisposed.emit(null);
|
||||
}
|
||||
}
|
||||
|
||||
class _DisposeCallback with Disposable {
|
||||
final void Function() callback;
|
||||
|
||||
_DisposeCallback(this.callback);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
callback();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
import 'package:clide/src/terminal/src/base/disposable.dart';
|
||||
|
||||
typedef EventListener<T> = void Function(T event);
|
||||
|
||||
class Event<T> {
|
||||
final EventEmitter<T> emitter;
|
||||
|
||||
Event(this.emitter);
|
||||
|
||||
void call(EventListener<T> listener) {
|
||||
emitter(listener);
|
||||
}
|
||||
}
|
||||
|
||||
class EventEmitter<T> {
|
||||
final _listeners = <EventListener<T>>[];
|
||||
|
||||
EventSubscription<T> call(EventListener<T> listener) {
|
||||
_listeners.add(listener);
|
||||
return EventSubscription(this, listener);
|
||||
}
|
||||
|
||||
void emit(T event) {
|
||||
for (final listener in _listeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
Event<T> get event => Event(this);
|
||||
}
|
||||
|
||||
class EventSubscription<T> with Disposable {
|
||||
final EventEmitter<T> emitter;
|
||||
final EventListener<T> listener;
|
||||
|
||||
EventSubscription(this.emitter, this.listener);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
emitter._listeners.remove(listener);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
mixin Observable {
|
||||
final listeners = <void Function()>{};
|
||||
|
||||
void addListener(void Function() listener) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
void removeListener(void Function() listener) {
|
||||
listeners.remove(listener);
|
||||
}
|
||||
|
||||
void notifyListeners() {
|
||||
for (var listener in listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
import 'dart:math' show max, min;
|
||||
|
||||
import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart';
|
||||
import 'package:clide/src/terminal/src/core/buffer/line.dart';
|
||||
import 'package:clide/src/terminal/src/core/buffer/range_line.dart';
|
||||
import 'package:clide/src/terminal/src/core/buffer/range.dart';
|
||||
import 'package:clide/src/terminal/src/core/charset.dart';
|
||||
import 'package:clide/src/terminal/src/core/cursor.dart';
|
||||
import 'package:clide/src/terminal/src/core/reflow.dart';
|
||||
import 'package:clide/src/terminal/src/core/state.dart';
|
||||
import 'package:clide/src/terminal/src/utils/circular_buffer.dart';
|
||||
import 'package:clide/src/terminal/src/utils/unicode_v11.dart';
|
||||
|
||||
class Buffer {
|
||||
final TerminalState terminal;
|
||||
|
||||
final int maxLines;
|
||||
|
||||
final bool isAltBuffer;
|
||||
|
||||
/// Characters that break selection when calling [getWordBoundary]. If null,
|
||||
/// defaults to [defaultWordSeparators].
|
||||
final Set<int>? wordSeparators;
|
||||
|
||||
Buffer(
|
||||
this.terminal, {
|
||||
required this.maxLines,
|
||||
required this.isAltBuffer,
|
||||
this.wordSeparators,
|
||||
}) {
|
||||
for (int i = 0; i < terminal.viewHeight; i++) {
|
||||
lines.push(_newEmptyLine());
|
||||
}
|
||||
|
||||
resetVerticalMargins();
|
||||
}
|
||||
|
||||
int _cursorX = 0;
|
||||
|
||||
int _cursorY = 0;
|
||||
|
||||
late int _marginTop;
|
||||
|
||||
late int _marginBottom;
|
||||
|
||||
var _savedCursorX = 0;
|
||||
|
||||
var _savedCursorY = 0;
|
||||
|
||||
final _savedCursorStyle = CursorStyle();
|
||||
|
||||
final charset = Charset();
|
||||
|
||||
/// Width of the viewport in columns. Also the index of the last column.
|
||||
int get viewWidth => terminal.viewWidth;
|
||||
|
||||
/// Height of the viewport in rows. Also the index of the last line.
|
||||
int get viewHeight => terminal.viewHeight;
|
||||
|
||||
/// lines of the buffer. the length of [lines] should always be equal or
|
||||
/// greater than [viewHeight].
|
||||
late final lines = IndexAwareCircularBuffer<BufferLine>(maxLines);
|
||||
|
||||
/// Total number of lines in the buffer. Always equal or greater than
|
||||
/// [viewHeight].
|
||||
int get height => lines.length;
|
||||
|
||||
/// Horizontal position of the cursor relative to the top-left cornor of the
|
||||
/// screen, starting from 0.
|
||||
int get cursorX => _cursorX.clamp(0, terminal.viewWidth - 1);
|
||||
|
||||
/// Vertical position of the cursor relative to the top-left cornor of the
|
||||
/// screen, starting from 0.
|
||||
int get cursorY => _cursorY;
|
||||
|
||||
/// Index of the first line in the scroll region.
|
||||
int get marginTop => _marginTop;
|
||||
|
||||
/// Index of the last line in the scroll region.
|
||||
int get marginBottom => _marginBottom;
|
||||
|
||||
/// The number of lines above the viewport.
|
||||
int get scrollBack => height - viewHeight;
|
||||
|
||||
/// Vertical position of the cursor relative to the top of the buffer,
|
||||
/// starting from 0.
|
||||
int get absoluteCursorY => _cursorY + scrollBack;
|
||||
|
||||
/// Absolute index of the first line in the scroll region.
|
||||
int get absoluteMarginTop => _marginTop + scrollBack;
|
||||
|
||||
/// Absolute index of the last line in the scroll region.
|
||||
int get absoluteMarginBottom => _marginBottom + scrollBack;
|
||||
|
||||
/// Writes data to the _terminal. Terminal sequences or special characters are
|
||||
/// not interpreted and directly added to the buffer.
|
||||
///
|
||||
/// See also: [Terminal.write]
|
||||
void write(String text) {
|
||||
for (var char in text.runes) {
|
||||
writeChar(char);
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a single character to the _terminal. Escape sequences or special
|
||||
/// characters are not interpreted and directly added to the buffer.
|
||||
///
|
||||
/// See also: [Terminal.writeChar]
|
||||
void writeChar(int codePoint) {
|
||||
codePoint = charset.translate(codePoint);
|
||||
|
||||
final cellWidth = unicodeV11.wcwidth(codePoint);
|
||||
if (_cursorX >= terminal.viewWidth) {
|
||||
index();
|
||||
setCursorX(0);
|
||||
if (terminal.autoWrapMode) {
|
||||
currentLine.isWrapped = true;
|
||||
}
|
||||
}
|
||||
|
||||
final line = currentLine;
|
||||
line.setCell(_cursorX, codePoint, cellWidth, terminal.cursor);
|
||||
|
||||
if (_cursorX < viewWidth) {
|
||||
_cursorX++;
|
||||
}
|
||||
|
||||
if (cellWidth == 2) {
|
||||
writeChar(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The line at the current cursor position.
|
||||
BufferLine get currentLine {
|
||||
return lines[absoluteCursorY];
|
||||
}
|
||||
|
||||
void backspace() {
|
||||
if (_cursorX == 0 && currentLine.isWrapped) {
|
||||
currentLine.isWrapped = false;
|
||||
moveCursor(viewWidth - 1, -1);
|
||||
} else if (_cursorX == viewWidth) {
|
||||
moveCursor(-2, 0);
|
||||
} else {
|
||||
moveCursor(-1, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Erases the viewport from the cursor position to the end of the buffer,
|
||||
/// including the cursor position.
|
||||
void eraseDisplayFromCursor() {
|
||||
eraseLineFromCursor();
|
||||
|
||||
for (var i = absoluteCursorY + 1; i < height; i++) {
|
||||
final line = lines[i];
|
||||
line.isWrapped = false;
|
||||
line.eraseRange(0, viewWidth, terminal.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Erases the viewport from the top-left corner to the cursor, including the
|
||||
/// cursor.
|
||||
void eraseDisplayToCursor() {
|
||||
eraseLineToCursor();
|
||||
|
||||
for (var i = 0; i < _cursorY; i++) {
|
||||
final line = lines[i + scrollBack];
|
||||
line.isWrapped = false;
|
||||
line.eraseRange(0, viewWidth, terminal.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Erases the whole viewport.
|
||||
void eraseDisplay() {
|
||||
for (var i = 0; i < viewHeight; i++) {
|
||||
final line = lines[i + scrollBack];
|
||||
line.isWrapped = false;
|
||||
line.eraseRange(0, viewWidth, terminal.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Erases the line from the cursor to the end of the line, including the
|
||||
/// cursor position.
|
||||
void eraseLineFromCursor() {
|
||||
currentLine.isWrapped = false;
|
||||
currentLine.eraseRange(_cursorX, viewWidth, terminal.cursor);
|
||||
}
|
||||
|
||||
/// Erases the line from the start of the line to the cursor, including the
|
||||
/// cursor.
|
||||
void eraseLineToCursor() {
|
||||
currentLine.isWrapped = false;
|
||||
currentLine.eraseRange(0, _cursorX, terminal.cursor);
|
||||
}
|
||||
|
||||
/// Erases the line at the current cursor position.
|
||||
void eraseLine() {
|
||||
currentLine.isWrapped = false;
|
||||
currentLine.eraseRange(0, viewWidth, terminal.cursor);
|
||||
}
|
||||
|
||||
/// Erases [count] cells starting at the cursor position.
|
||||
void eraseChars(int count) {
|
||||
final start = _cursorX;
|
||||
currentLine.eraseRange(start, start + count, terminal.cursor);
|
||||
}
|
||||
|
||||
void scrollDown(int lines) {
|
||||
for (var i = absoluteMarginBottom; i >= absoluteMarginTop; i--) {
|
||||
if (i >= absoluteMarginTop + lines) {
|
||||
this.lines[i] = this.lines[i - lines];
|
||||
} else {
|
||||
this.lines[i] = _newEmptyLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void scrollUp(int lines) {
|
||||
for (var i = absoluteMarginTop; i <= absoluteMarginBottom; i++) {
|
||||
if (i <= absoluteMarginBottom - lines) {
|
||||
this.lines[i] = this.lines[i + lines];
|
||||
} else {
|
||||
this.lines[i] = _newEmptyLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// https://vt100.net/docs/vt100-ug/chapter3.html#IND IND – Index
|
||||
///
|
||||
/// ESC D
|
||||
///
|
||||
/// [index] causes the active position to move downward one line without
|
||||
/// changing the column position. If the active position is at the bottom
|
||||
/// margin, a scroll up is performed.
|
||||
void index() {
|
||||
if (isInVerticalMargin) {
|
||||
if (_cursorY == _marginBottom) {
|
||||
if (marginTop == 0 && !isAltBuffer) {
|
||||
lines.insert(absoluteMarginBottom + 1, _newEmptyLine());
|
||||
} else {
|
||||
scrollUp(1);
|
||||
}
|
||||
} else {
|
||||
moveCursorY(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// the cursor is not in the scrollable region
|
||||
if (_cursorY >= viewHeight - 1) {
|
||||
// we are at the bottom
|
||||
if (isAltBuffer) {
|
||||
scrollUp(1);
|
||||
} else {
|
||||
lines.push(_newEmptyLine());
|
||||
}
|
||||
} else {
|
||||
// there're still lines so we simply move cursor down.
|
||||
moveCursorY(1);
|
||||
}
|
||||
}
|
||||
|
||||
void lineFeed() {
|
||||
index();
|
||||
if (terminal.lineFeedMode) {
|
||||
setCursorX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// https://terminalguide.namepad.de/seq/a_esc_cm/
|
||||
void reverseIndex() {
|
||||
if (isInVerticalMargin) {
|
||||
if (_cursorY == _marginTop) {
|
||||
scrollDown(1);
|
||||
} else {
|
||||
moveCursorY(-1);
|
||||
}
|
||||
} else {
|
||||
moveCursorY(-1);
|
||||
}
|
||||
}
|
||||
|
||||
void cursorGoForward() {
|
||||
_cursorX = min(_cursorX + 1, viewWidth);
|
||||
}
|
||||
|
||||
void setCursorX(int cursorX) {
|
||||
_cursorX = cursorX.clamp(0, viewWidth - 1);
|
||||
}
|
||||
|
||||
void setCursorY(int cursorY) {
|
||||
_cursorY = cursorY.clamp(0, viewHeight - 1);
|
||||
}
|
||||
|
||||
void moveCursorX(int offset) {
|
||||
setCursorX(_cursorX + offset);
|
||||
}
|
||||
|
||||
void moveCursorY(int offset) {
|
||||
setCursorY(_cursorY + offset);
|
||||
}
|
||||
|
||||
void setCursor(int cursorX, int cursorY) {
|
||||
var maxCursorY = viewHeight - 1;
|
||||
|
||||
if (terminal.originMode) {
|
||||
cursorY += _marginTop;
|
||||
maxCursorY = _marginBottom;
|
||||
}
|
||||
|
||||
_cursorX = cursorX.clamp(0, viewWidth - 1);
|
||||
_cursorY = cursorY.clamp(0, maxCursorY);
|
||||
}
|
||||
|
||||
void moveCursor(int offsetX, int offsetY) {
|
||||
final cursorX = _cursorX + offsetX;
|
||||
final cursorY = _cursorY + offsetY;
|
||||
setCursor(cursorX, cursorY);
|
||||
}
|
||||
|
||||
/// Save cursor position, charmap and text attributes.
|
||||
void saveCursor() {
|
||||
_savedCursorX = _cursorX;
|
||||
_savedCursorY = _cursorY;
|
||||
_savedCursorStyle.foreground = terminal.cursor.foreground;
|
||||
_savedCursorStyle.background = terminal.cursor.background;
|
||||
_savedCursorStyle.attrs = terminal.cursor.attrs;
|
||||
charset.save();
|
||||
}
|
||||
|
||||
/// Restore cursor position, charmap and text attributes.
|
||||
void restoreCursor() {
|
||||
_cursorX = _savedCursorX;
|
||||
_cursorY = _savedCursorY;
|
||||
terminal.cursor.foreground = _savedCursorStyle.foreground;
|
||||
terminal.cursor.background = _savedCursorStyle.background;
|
||||
terminal.cursor.attrs = _savedCursorStyle.attrs;
|
||||
charset.restore();
|
||||
}
|
||||
|
||||
/// Sets the vertical scrolling margin to [top] and [bottom].
|
||||
/// Both values must be between 0 and [viewHeight] - 1.
|
||||
void setVerticalMargins(int top, int bottom) {
|
||||
_marginTop = top.clamp(0, viewHeight - 1);
|
||||
_marginBottom = bottom.clamp(0, viewHeight - 1);
|
||||
|
||||
_marginTop = min(_marginTop, _marginBottom);
|
||||
_marginBottom = max(_marginTop, _marginBottom);
|
||||
}
|
||||
|
||||
bool get isInVerticalMargin {
|
||||
return _cursorY >= _marginTop && _cursorY <= _marginBottom;
|
||||
}
|
||||
|
||||
void resetVerticalMargins() {
|
||||
setVerticalMargins(0, viewHeight - 1);
|
||||
}
|
||||
|
||||
void deleteChars(int count) {
|
||||
final start = _cursorX.clamp(0, viewWidth);
|
||||
count = min(count, viewWidth - start);
|
||||
currentLine.removeCells(start, count, terminal.cursor);
|
||||
}
|
||||
|
||||
/// Remove all lines above the top of the viewport.
|
||||
void clearScrollback() {
|
||||
if (height <= viewHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
lines.trimStart(scrollBack);
|
||||
}
|
||||
|
||||
/// Clears the viewport and scrollback buffer. Then fill with empty lines.
|
||||
void clear() {
|
||||
lines.clear();
|
||||
for (int i = 0; i < viewHeight; i++) {
|
||||
lines.push(_newEmptyLine());
|
||||
}
|
||||
}
|
||||
|
||||
void insertBlankChars(int count) {
|
||||
currentLine.insertCells(_cursorX, count, terminal.cursor);
|
||||
}
|
||||
|
||||
void insertLines(int count) {
|
||||
if (!isInVerticalMargin) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCursorX(0);
|
||||
|
||||
// Number of lines from the cursor to the bottom of the scrollable region
|
||||
// including the cursor itself.
|
||||
final linesBelow = absoluteMarginBottom - absoluteCursorY + 1;
|
||||
|
||||
// Number of empty lines to insert.
|
||||
final linesToInsert = min(count, linesBelow);
|
||||
|
||||
// Number of lines to move up.
|
||||
final linesToMove = linesBelow - linesToInsert;
|
||||
|
||||
for (var i = 0; i < linesToMove; i++) {
|
||||
final index = absoluteMarginBottom - i;
|
||||
lines[index] = lines.swap(index - linesToInsert, _newEmptyLine());
|
||||
}
|
||||
|
||||
for (var i = linesToMove; i < linesToInsert; i++) {
|
||||
lines[absoluteCursorY + i] = _newEmptyLine();
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove [count] lines starting at the current cursor position. Lines below
|
||||
/// the removed lines are shifted up. This only affects the scrollable region.
|
||||
/// Lines outside the scrollable region are not affected.
|
||||
void deleteLines(int count) {
|
||||
if (!isInVerticalMargin) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCursorX(0);
|
||||
|
||||
count = min(count, absoluteMarginBottom - absoluteCursorY + 1);
|
||||
|
||||
final linesToMove = absoluteMarginBottom - absoluteCursorY + 1 - count;
|
||||
|
||||
for (var i = 0; i < linesToMove; i++) {
|
||||
final index = absoluteCursorY + i;
|
||||
lines[index] = lines[index + count];
|
||||
}
|
||||
|
||||
for (var i = 0; i < count; i++) {
|
||||
lines[absoluteMarginBottom - i] = _newEmptyLine();
|
||||
}
|
||||
}
|
||||
|
||||
void resize(int oldWidth, int oldHeight, int newWidth, int newHeight) {
|
||||
// 1. Adjust the height.
|
||||
if (newHeight > oldHeight) {
|
||||
// Grow larger
|
||||
for (var i = 0; i < newHeight - oldHeight; i++) {
|
||||
if (newHeight > lines.length) {
|
||||
lines.push(_newEmptyLine(newWidth));
|
||||
} else {
|
||||
_cursorY++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Shrink smaller
|
||||
for (var i = 0; i < oldHeight - newHeight; i++) {
|
||||
if (_cursorY > newHeight - 1) {
|
||||
_cursorY--;
|
||||
} else {
|
||||
lines.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure cursor is within the screen.
|
||||
_cursorX = _cursorX.clamp(0, newWidth - 1);
|
||||
_cursorY = _cursorY.clamp(0, newHeight - 1);
|
||||
|
||||
// 2. Adjust the width.
|
||||
if (newWidth != oldWidth) {
|
||||
if (terminal.reflowEnabled && !isAltBuffer) {
|
||||
final reflowResult = reflow(lines, oldWidth, newWidth);
|
||||
|
||||
while (reflowResult.length < newHeight) {
|
||||
reflowResult.add(_newEmptyLine(newWidth));
|
||||
}
|
||||
|
||||
lines.replaceWith(reflowResult);
|
||||
} else {
|
||||
lines.forEach((item) => item.resize(newWidth));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new [CellAnchor] at the specified [x] and [y] coordinates.
|
||||
CellAnchor createAnchor(int x, int y) {
|
||||
return lines[y].createAnchor(x);
|
||||
}
|
||||
|
||||
/// Create a new [CellAnchor] at the specified [x] and [y] coordinates.
|
||||
CellAnchor createAnchorFromOffset(CellOffset offset) {
|
||||
return lines[offset.y].createAnchor(offset.x);
|
||||
}
|
||||
|
||||
CellAnchor createAnchorFromCursor() {
|
||||
return createAnchor(cursorX, absoluteCursorY);
|
||||
}
|
||||
|
||||
/// Create a new empty [BufferLine] with the current [viewWidth] if [width]
|
||||
/// is not specified.
|
||||
BufferLine _newEmptyLine([int? width]) {
|
||||
final line = BufferLine(width ?? viewWidth);
|
||||
return line;
|
||||
}
|
||||
|
||||
static final defaultWordSeparators = <int>{
|
||||
0,
|
||||
r' '.codeUnitAt(0),
|
||||
r'.'.codeUnitAt(0),
|
||||
r':'.codeUnitAt(0),
|
||||
r'-'.codeUnitAt(0),
|
||||
r'\'.codeUnitAt(0),
|
||||
r'"'.codeUnitAt(0),
|
||||
r'*'.codeUnitAt(0),
|
||||
r'+'.codeUnitAt(0),
|
||||
r'/'.codeUnitAt(0),
|
||||
r'\'.codeUnitAt(0),
|
||||
};
|
||||
|
||||
BufferRangeLine? getWordBoundary(CellOffset position) {
|
||||
var separators = wordSeparators ?? defaultWordSeparators;
|
||||
if (position.y >= lines.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var line = lines[position.y];
|
||||
var start = position.x;
|
||||
var end = position.x;
|
||||
|
||||
do {
|
||||
if (start == 0) {
|
||||
break;
|
||||
}
|
||||
final char = line.getCodePoint(start - 1);
|
||||
if (separators.contains(char)) {
|
||||
break;
|
||||
}
|
||||
start--;
|
||||
} while (true);
|
||||
|
||||
do {
|
||||
if (end >= viewWidth) {
|
||||
break;
|
||||
}
|
||||
final char = line.getCodePoint(end);
|
||||
if (separators.contains(char)) {
|
||||
break;
|
||||
}
|
||||
end++;
|
||||
} while (true);
|
||||
|
||||
if (start == end) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return BufferRangeLine(
|
||||
CellOffset(start, position.y),
|
||||
CellOffset(end, position.y),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get the plain text content of the buffer including the scrollback.
|
||||
/// Accepts an optional [range] to get a specific part of the buffer.
|
||||
String getText([BufferRange? range]) {
|
||||
range ??= BufferRangeLine(
|
||||
CellOffset(0, 0),
|
||||
CellOffset(viewWidth - 1, height - 1),
|
||||
);
|
||||
|
||||
range = range.normalized;
|
||||
|
||||
final builder = StringBuffer();
|
||||
|
||||
for (var segment in range.toSegments()) {
|
||||
if (segment.line < 0 || segment.line >= height) {
|
||||
continue;
|
||||
}
|
||||
final line = lines[segment.line];
|
||||
if (!(segment.line == range.begin.y ||
|
||||
segment.line == 0 ||
|
||||
line.isWrapped)) {
|
||||
builder.write("\n");
|
||||
}
|
||||
builder.write(line.getText(segment.start, segment.end));
|
||||
}
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
/// Returns a debug representation of the buffer.
|
||||
@override
|
||||
String toString() {
|
||||
final builder = StringBuffer();
|
||||
final lineNumberLength = lines.length.toString().length;
|
||||
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
final line = lines[i];
|
||||
|
||||
builder.write('${i.toString().padLeft(lineNumberLength)}: |${lines[i]}|');
|
||||
|
||||
if (line.isWrapped) {
|
||||
builder.write(' (⏎)');
|
||||
}
|
||||
|
||||
builder.write('\n');
|
||||
}
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
abstract class CellFlags {
|
||||
static const bold = 1 << 0;
|
||||
static const faint = 1 << 1;
|
||||
static const italic = 1 << 2;
|
||||
static const underline = 1 << 3;
|
||||
static const blink = 1 << 4;
|
||||
static const inverse = 1 << 5;
|
||||
static const invisible = 1 << 6;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
import 'package:clide/src/terminal/src/core/buffer/range.dart';
|
||||
|
||||
class CellOffset {
|
||||
final int x;
|
||||
|
||||
final int y;
|
||||
|
||||
const CellOffset(this.x, this.y);
|
||||
|
||||
bool isEqual(CellOffset other) {
|
||||
return other.x == x && other.y == y;
|
||||
}
|
||||
|
||||
bool isBefore(CellOffset other) {
|
||||
return y < other.y || (y == other.y && x < other.x);
|
||||
}
|
||||
|
||||
bool isAfter(CellOffset other) {
|
||||
return y > other.y || (y == other.y && x > other.x);
|
||||
}
|
||||
|
||||
bool isBeforeOrSame(CellOffset other) {
|
||||
return y < other.y || (y == other.y && x <= other.x);
|
||||
}
|
||||
|
||||
bool isAfterOrSame(CellOffset other) {
|
||||
return y > other.y || (y == other.y && x >= other.x);
|
||||
}
|
||||
|
||||
bool isAtSameRow(CellOffset other) {
|
||||
return y == other.y;
|
||||
}
|
||||
|
||||
bool isAtSameColumn(CellOffset other) {
|
||||
return x == other.x;
|
||||
}
|
||||
|
||||
bool isWithin(BufferRange range) {
|
||||
return range.contains(this);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'CellOffset($x, $y)';
|
||||
|
||||
@override
|
||||
int get hashCode => x.hashCode ^ y.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is CellOffset &&
|
||||
runtimeType == other.runtimeType &&
|
||||
x == other.x &&
|
||||
y == other.y;
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
import 'dart:math' show min;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart';
|
||||
import 'package:clide/src/terminal/src/core/cell.dart';
|
||||
import 'package:clide/src/terminal/src/core/cursor.dart';
|
||||
import 'package:clide/src/terminal/src/utils/circular_buffer.dart';
|
||||
import 'package:clide/src/terminal/src/utils/unicode_v11.dart';
|
||||
|
||||
const _cellSize = 4;
|
||||
|
||||
const _cellForeground = 0;
|
||||
|
||||
const _cellBackground = 1;
|
||||
|
||||
const _cellAttributes = 2;
|
||||
|
||||
const _cellContent = 3;
|
||||
|
||||
class BufferLine with IndexedItem {
|
||||
BufferLine(
|
||||
this._length, {
|
||||
this.isWrapped = false,
|
||||
}) : _data = Uint32List(_calcCapacity(_length) * _cellSize);
|
||||
|
||||
int _length;
|
||||
|
||||
Uint32List _data;
|
||||
|
||||
Uint32List get data => _data;
|
||||
|
||||
var isWrapped = false;
|
||||
|
||||
int get length => _length;
|
||||
|
||||
final _anchors = <CellAnchor>[];
|
||||
|
||||
List<CellAnchor> get anchors => _anchors;
|
||||
|
||||
int getForeground(int index) {
|
||||
return _data[index * _cellSize + _cellForeground];
|
||||
}
|
||||
|
||||
int getBackground(int index) {
|
||||
return _data[index * _cellSize + _cellBackground];
|
||||
}
|
||||
|
||||
int getAttributes(int index) {
|
||||
return _data[index * _cellSize + _cellAttributes];
|
||||
}
|
||||
|
||||
int getContent(int index) {
|
||||
return _data[index * _cellSize + _cellContent];
|
||||
}
|
||||
|
||||
int getCodePoint(int index) {
|
||||
return _data[index * _cellSize + _cellContent] & CellContent.codepointMask;
|
||||
}
|
||||
|
||||
int getWidth(int index) {
|
||||
return _data[index * _cellSize + _cellContent] >> CellContent.widthShift;
|
||||
}
|
||||
|
||||
void getCellData(int index, CellData cellData) {
|
||||
final offset = index * _cellSize;
|
||||
cellData.foreground = _data[offset + _cellForeground];
|
||||
cellData.background = _data[offset + _cellBackground];
|
||||
cellData.flags = _data[offset + _cellAttributes];
|
||||
cellData.content = _data[offset + _cellContent];
|
||||
}
|
||||
|
||||
CellData createCellData(int index) {
|
||||
final cellData = CellData.empty();
|
||||
final offset = index * _cellSize;
|
||||
_data[offset + _cellForeground] = cellData.foreground;
|
||||
_data[offset + _cellBackground] = cellData.background;
|
||||
_data[offset + _cellAttributes] = cellData.flags;
|
||||
_data[offset + _cellContent] = cellData.content;
|
||||
return cellData;
|
||||
}
|
||||
|
||||
void setForeground(int index, int value) {
|
||||
_data[index * _cellSize + _cellForeground] = value;
|
||||
}
|
||||
|
||||
void setBackground(int index, int value) {
|
||||
_data[index * _cellSize + _cellBackground] = value;
|
||||
}
|
||||
|
||||
void setAttributes(int index, int value) {
|
||||
_data[index * _cellSize + _cellAttributes] = value;
|
||||
}
|
||||
|
||||
void setContent(int index, int value) {
|
||||
_data[index * _cellSize + _cellContent] = value;
|
||||
}
|
||||
|
||||
void setCodePoint(int index, int char) {
|
||||
final width = unicodeV11.wcwidth(char);
|
||||
setContent(index, char | (width << CellContent.widthShift));
|
||||
}
|
||||
|
||||
void setCell(int index, int char, int witdh, CursorStyle style) {
|
||||
final offset = index * _cellSize;
|
||||
_data[offset + _cellForeground] = style.foreground;
|
||||
_data[offset + _cellBackground] = style.background;
|
||||
_data[offset + _cellAttributes] = style.attrs;
|
||||
_data[offset + _cellContent] = char | (witdh << CellContent.widthShift);
|
||||
}
|
||||
|
||||
void setCellData(int index, CellData cellData) {
|
||||
final offset = index * _cellSize;
|
||||
_data[offset + _cellForeground] = cellData.foreground;
|
||||
_data[offset + _cellBackground] = cellData.background;
|
||||
_data[offset + _cellAttributes] = cellData.flags;
|
||||
_data[offset + _cellContent] = cellData.content;
|
||||
}
|
||||
|
||||
void eraseCell(int index, CursorStyle style) {
|
||||
final offset = index * _cellSize;
|
||||
_data[offset + _cellForeground] = style.foreground;
|
||||
_data[offset + _cellBackground] = style.background;
|
||||
_data[offset + _cellAttributes] = style.attrs;
|
||||
_data[offset + _cellContent] = 0;
|
||||
}
|
||||
|
||||
void resetCell(int index) {
|
||||
final offset = index * _cellSize;
|
||||
_data[offset + _cellForeground] = 0;
|
||||
_data[offset + _cellBackground] = 0;
|
||||
_data[offset + _cellAttributes] = 0;
|
||||
_data[offset + _cellContent] = 0;
|
||||
}
|
||||
|
||||
/// Erase cells whose index satisfies [start] <= index < [end]. Erased cells
|
||||
/// are filled with [style].
|
||||
void eraseRange(int start, int end, CursorStyle style) {
|
||||
// reset cell one to the left if start is second cell of a wide char
|
||||
if (start > 0 && getWidth(start - 1) == 2) {
|
||||
eraseCell(start - 1, style);
|
||||
}
|
||||
|
||||
// reset cell one to the right if end is second cell of a wide char
|
||||
if (end < _length && getWidth(end - 1) == 2) {
|
||||
eraseCell(end - 1, style);
|
||||
}
|
||||
|
||||
end = min(end, _length);
|
||||
for (var i = start; i < end; i++) {
|
||||
eraseCell(i, style);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove [count] cells starting at [start]. Cells that are empty after the
|
||||
/// removal are filled with [style].
|
||||
void removeCells(int start, int count, [CursorStyle? style]) {
|
||||
assert(start >= 0 && start < _length);
|
||||
assert(count >= 0 && start + count <= _length);
|
||||
|
||||
style ??= CursorStyle.empty;
|
||||
|
||||
if (start + count < _length) {
|
||||
final moveStart = start * _cellSize;
|
||||
final moveEnd = (_length - count) * _cellSize;
|
||||
final moveOffset = count * _cellSize;
|
||||
for (var i = moveStart; i < moveEnd; i++) {
|
||||
_data[i] = _data[i + moveOffset];
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = _length - count; i < _length; i++) {
|
||||
eraseCell(i, style);
|
||||
}
|
||||
|
||||
if (start > 0 && getWidth(start - 1) == 2) {
|
||||
eraseCell(start - 1, style);
|
||||
}
|
||||
|
||||
// Update anchors, remove anchors that are inside the removed range.
|
||||
for (var i = 0; i < _anchors.length; i++) {
|
||||
final anchor = _anchors[i];
|
||||
if (anchor.x >= start) {
|
||||
if (anchor.x < start + count) {
|
||||
anchor.dispose();
|
||||
} else {
|
||||
anchor.reposition(anchor.x - count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts [count] cells at [start]. New cells are initialized with [style].
|
||||
void insertCells(int start, int count, [CursorStyle? style]) {
|
||||
style ??= CursorStyle.empty;
|
||||
|
||||
if (start > 0 && getWidth(start - 1) == 2) {
|
||||
eraseCell(start - 1, style);
|
||||
}
|
||||
|
||||
if (start + count < _length) {
|
||||
final moveStart = start * _cellSize;
|
||||
final moveEnd = (_length - count) * _cellSize;
|
||||
final moveOffset = count * _cellSize;
|
||||
for (var i = moveEnd - 1; i >= moveStart; i--) {
|
||||
_data[i + moveOffset] = _data[i];
|
||||
}
|
||||
}
|
||||
|
||||
final end = min(start + count, _length);
|
||||
for (var i = start; i < end; i++) {
|
||||
eraseCell(i, style);
|
||||
}
|
||||
|
||||
if (getWidth(_length - 1) == 2) {
|
||||
eraseCell(_length - 1, style);
|
||||
}
|
||||
|
||||
// Update anchors, move anchors that are after the inserted range.
|
||||
for (var i = 0; i < _anchors.length; i++) {
|
||||
final anchor = _anchors[i];
|
||||
if (anchor.x >= start + count) {
|
||||
anchor.reposition(anchor.x + count);
|
||||
|
||||
// Remove anchors that are now outside the buffer.
|
||||
if (anchor.x >= _length) {
|
||||
anchor.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void resize(int length) {
|
||||
assert(length >= 0);
|
||||
|
||||
if (length == _length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (length > _length) {
|
||||
final newBufferSize = _calcCapacity(length) * _cellSize;
|
||||
|
||||
if (newBufferSize > _data.length) {
|
||||
final newBuffer = Uint32List(newBufferSize);
|
||||
newBuffer.setRange(0, _data.length, _data);
|
||||
_data = newBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
_length = length;
|
||||
|
||||
for (var i = 0; i < _anchors.length; i++) {
|
||||
final anchor = _anchors[i];
|
||||
if (anchor.x > _length) {
|
||||
anchor.reposition(_length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the offset of the last cell that has content from the start of
|
||||
/// the line.
|
||||
int getTrimmedLength([int? cols]) {
|
||||
final maxCols = _data.length ~/ _cellSize;
|
||||
|
||||
if (cols == null || cols > maxCols) {
|
||||
cols = maxCols;
|
||||
}
|
||||
|
||||
if (cols <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (var i = cols - 1; i >= 0; i--) {
|
||||
var codePoint = getCodePoint(i);
|
||||
|
||||
if (codePoint != 0) {
|
||||
// we are at the last cell in this line that has content.
|
||||
// the length of this line is the index of this cell + 1
|
||||
// the only exception is that if that last cell is wider
|
||||
// than 1 then we have to add the diff
|
||||
final lastCellWidth = getWidth(i);
|
||||
return i + lastCellWidth;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Copies [len] cells from [src] starting at [srcCol] to [dstCol] at this
|
||||
/// line.
|
||||
void copyFrom(BufferLine src, int srcCol, int dstCol, int len) {
|
||||
resize(dstCol + len);
|
||||
|
||||
// data.setRange(
|
||||
// dstCol * _cellSize,
|
||||
// (dstCol + len) * _cellSize,
|
||||
// Uint32List.sublistView(src.data, srcCol * _cellSize, len * _cellSize),
|
||||
// );
|
||||
|
||||
var srcOffset = srcCol * _cellSize;
|
||||
var dstOffset = dstCol * _cellSize;
|
||||
|
||||
for (var i = 0; i < len * _cellSize; i++) {
|
||||
_data[dstOffset++] = src._data[srcOffset++];
|
||||
}
|
||||
}
|
||||
|
||||
static int _calcCapacity(int length) {
|
||||
assert(length >= 0);
|
||||
|
||||
var capacity = 64;
|
||||
|
||||
if (length < 256) {
|
||||
while (capacity < length) {
|
||||
capacity *= 2;
|
||||
}
|
||||
} else {
|
||||
capacity = 256;
|
||||
while (capacity < length) {
|
||||
capacity += 32;
|
||||
}
|
||||
}
|
||||
|
||||
return capacity;
|
||||
}
|
||||
|
||||
String getText([int? from, int? to]) {
|
||||
if (from == null || from < 0) {
|
||||
from = 0;
|
||||
}
|
||||
|
||||
if (to == null || to > _length) {
|
||||
to = _length;
|
||||
}
|
||||
|
||||
final builder = StringBuffer();
|
||||
for (var i = from; i < to; i++) {
|
||||
final codePoint = getCodePoint(i);
|
||||
final width = getWidth(i);
|
||||
if (codePoint != 0 && i + width <= to) {
|
||||
builder.writeCharCode(codePoint);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
CellAnchor createAnchor(int offset) {
|
||||
final anchor = CellAnchor(offset, owner: this);
|
||||
_anchors.add(anchor);
|
||||
return anchor;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
for (final anchor in _anchors) {
|
||||
anchor.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return getText();
|
||||
}
|
||||
}
|
||||
|
||||
/// A handle to a cell in a [BufferLine] that can be used to track the location
|
||||
/// of the cell. Anchors are guaranteed to be stable, retaining their relative
|
||||
/// position to each other after mutations to the buffer.
|
||||
class CellAnchor {
|
||||
CellAnchor(int offset, {BufferLine? owner})
|
||||
: _offset = offset,
|
||||
_owner = owner;
|
||||
|
||||
int _offset;
|
||||
|
||||
int get x {
|
||||
return _offset;
|
||||
}
|
||||
|
||||
int get y {
|
||||
assert(attached);
|
||||
return _owner!.index;
|
||||
}
|
||||
|
||||
CellOffset get offset {
|
||||
assert(attached);
|
||||
return CellOffset(_offset, _owner!.index);
|
||||
}
|
||||
|
||||
BufferLine? _owner;
|
||||
|
||||
BufferLine? get line => _owner;
|
||||
|
||||
bool get attached => _owner?.attached ?? false;
|
||||
|
||||
void reparent(BufferLine owner, int offset) {
|
||||
_owner?._anchors.remove(this);
|
||||
_owner = owner;
|
||||
_owner?._anchors.add(this);
|
||||
_offset = offset;
|
||||
}
|
||||
|
||||
void reposition(int offset) {
|
||||
_offset = offset;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_owner?._anchors.remove(this);
|
||||
_owner = null;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
if (attached) {
|
||||
return 'CellAnchor($x, $y)';
|
||||
} else {
|
||||
return 'CellAnchor($x, detached)';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart';
|
||||
import 'package:clide/src/terminal/src/core/buffer/segment.dart';
|
||||
|
||||
abstract class BufferRange {
|
||||
final CellOffset begin;
|
||||
|
||||
final CellOffset end;
|
||||
|
||||
const BufferRange(this.begin, this.end);
|
||||
|
||||
BufferRange.collapsed(this.begin) : end = begin;
|
||||
|
||||
bool get isNormalized {
|
||||
return begin.isBefore(end) || begin.isEqual(end);
|
||||
}
|
||||
|
||||
bool get isCollapsed {
|
||||
return begin.isEqual(end);
|
||||
}
|
||||
|
||||
BufferRange get normalized;
|
||||
|
||||
/// Convert this range to segments of single lines.
|
||||
Iterable<BufferSegment> toSegments();
|
||||
|
||||
/// Returns true if the given[position] is within this range.
|
||||
bool contains(CellOffset position);
|
||||
|
||||
/// Returns the smallest range that contains both this range and the given
|
||||
/// [range].
|
||||
BufferRange merge(BufferRange range);
|
||||
|
||||
/// Returns the smallest range that contains both this range and the given
|
||||
/// [position].
|
||||
BufferRange extend(CellOffset position);
|
||||
|
||||
@override
|
||||
operator ==(Object other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (other is! BufferRange) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return begin == other.begin && end == other.end;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => begin.hashCode ^ end.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => 'Range($begin, $end)';
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart';
|
||||
import 'package:clide/src/terminal/src/core/buffer/range.dart';
|
||||
import 'package:clide/src/terminal/src/core/buffer/segment.dart';
|
||||
|
||||
class BufferRangeBlock extends BufferRange {
|
||||
BufferRangeBlock(super.begin, super.end);
|
||||
|
||||
BufferRangeBlock.collapsed(super.begin) : super.collapsed();
|
||||
|
||||
@override
|
||||
bool get isNormalized {
|
||||
// A block range is normalized if begin is the top left corner of the range
|
||||
// and end the bottom right corner.
|
||||
return (begin.isBefore(end) && begin.x <= end.x) || begin.isEqual(end);
|
||||
}
|
||||
|
||||
@override
|
||||
BufferRangeBlock get normalized {
|
||||
if (isNormalized) {
|
||||
return this;
|
||||
}
|
||||
// Determine new normalized begin and end offset, such that begin is the
|
||||
// top left corner and end is the bottom right corner of the block.
|
||||
final normalBegin = CellOffset(min(begin.x, end.x), min(begin.y, end.y));
|
||||
final normalEnd = CellOffset(max(begin.x, end.x), max(begin.y, end.y));
|
||||
return BufferRangeBlock(normalBegin, normalEnd);
|
||||
}
|
||||
|
||||
@override
|
||||
Iterable<BufferSegment> toSegments() sync* {
|
||||
var begin = this.begin;
|
||||
var end = this.end;
|
||||
|
||||
if (!isNormalized) {
|
||||
end = this.begin;
|
||||
begin = this.end;
|
||||
}
|
||||
|
||||
final startX = min(begin.x, end.x);
|
||||
final endX = max(begin.x, end.x);
|
||||
for (var i = begin.y; i <= end.y; i++) {
|
||||
yield BufferSegment(this, i, startX, endX);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool contains(CellOffset position) {
|
||||
var begin = this.begin;
|
||||
var end = this.end;
|
||||
|
||||
if (!isNormalized) {
|
||||
end = this.begin;
|
||||
begin = this.end;
|
||||
}
|
||||
if (!(begin.y <= position.y && position.y <= end.y)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final startX = min(begin.x, end.x);
|
||||
final endX = max(begin.x, end.x);
|
||||
return startX <= position.x && position.x <= endX;
|
||||
}
|
||||
|
||||
@override
|
||||
BufferRangeBlock merge(BufferRange range) {
|
||||
// Enlarge the block such that both borders of the range
|
||||
// are within the selected block.
|
||||
return extend(range.begin).extend(range.end);
|
||||
}
|
||||
|
||||
@override
|
||||
BufferRangeBlock extend(CellOffset position) {
|
||||
// If the position is within the block, there is nothing to do.
|
||||
if (contains(position)) {
|
||||
return this;
|
||||
}
|
||||
// Otherwise normalize the block and push the borders outside up to
|
||||
// the position to which the block has to extended.
|
||||
final normal = normalized;
|
||||
final extendBegin = CellOffset(
|
||||
min(normal.begin.x, position.x),
|
||||
min(normal.begin.y, position.y),
|
||||
);
|
||||
final extendEnd = CellOffset(
|
||||
max(normal.end.x, position.x),
|
||||
max(normal.end.y, position.y),
|
||||
);
|
||||
return BufferRangeBlock(extendBegin, extendEnd);
|
||||
}
|
||||
|
||||
@override
|
||||
operator ==(Object other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (other is! BufferRangeBlock) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return begin == other.begin && end == other.end;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => begin.hashCode ^ end.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => 'Block Range($begin, $end)';
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart';
|
||||
import 'package:clide/src/terminal/src/core/buffer/range.dart';
|
||||
import 'package:clide/src/terminal/src/core/buffer/segment.dart';
|
||||
|
||||
class BufferRangeLine extends BufferRange {
|
||||
BufferRangeLine(super.begin, super.end);
|
||||
|
||||
BufferRangeLine.collapsed(super.begin) : super.collapsed();
|
||||
|
||||
@override
|
||||
BufferRangeLine get normalized {
|
||||
return isNormalized ? this : BufferRangeLine(end, begin);
|
||||
}
|
||||
|
||||
@override
|
||||
Iterable<BufferSegment> toSegments() sync* {
|
||||
final self = normalized;
|
||||
for (var i = self.begin.y; i <= self.end.y; i++) {
|
||||
var startX = i == self.begin.y ? self.begin.x : null;
|
||||
var endX = i == self.end.y ? self.end.x : null;
|
||||
yield BufferSegment(this, i, startX, endX);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool contains(CellOffset position) {
|
||||
final self = normalized;
|
||||
return self.begin.isBeforeOrSame(position) &&
|
||||
self.end.isAfterOrSame(position);
|
||||
}
|
||||
|
||||
@override
|
||||
BufferRangeLine merge(BufferRange range) {
|
||||
final self = normalized;
|
||||
final begin = self.begin.isBefore(range.begin) ? self.begin : range.begin;
|
||||
final end = self.end.isAfter(range.end) ? self.end : range.end;
|
||||
return BufferRangeLine(begin, end);
|
||||
}
|
||||
|
||||
@override
|
||||
BufferRangeLine extend(CellOffset position) {
|
||||
final self = normalized;
|
||||
final begin = self.begin.isAfter(position) ? position : self.begin;
|
||||
final end = self.end.isBefore(position) ? position : self.end;
|
||||
return BufferRangeLine(begin, end);
|
||||
}
|
||||
|
||||
@override
|
||||
operator ==(Object other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (other is! BufferRangeLine) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return begin == other.begin && end == other.end;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => begin.hashCode ^ end.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => 'Line Range($begin, $end)';
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart';
|
||||
import 'package:clide/src/terminal/src/core/buffer/range.dart';
|
||||
|
||||
/// A BufferSegment represents a range within a line.
|
||||
class BufferSegment {
|
||||
/// The range that this segment belongs to.
|
||||
final BufferRange range;
|
||||
|
||||
/// The line that this segment resides on.
|
||||
final int line;
|
||||
|
||||
/// The start position of this segment. [null] means the start of the line.
|
||||
final int? start;
|
||||
|
||||
/// The end position of this segment. [null] means the end of the line.
|
||||
/// Should be greater than or equal to [start].
|
||||
final int? end;
|
||||
|
||||
const BufferSegment(this.range, this.line, this.start, this.end)
|
||||
: assert((start != null && end != null) ? start <= end : true);
|
||||
|
||||
bool isWithin(CellOffset position) {
|
||||
if (position.y != line) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (start != null && position.x < start!) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (end != null && position.x > end!) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final start = this.start != null ? this.start.toString() : 'start';
|
||||
final end = this.end != null ? this.end.toString() : 'end';
|
||||
return 'Segment($line, $start -> $end)';
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
range.hashCode ^ line.hashCode ^ start.hashCode ^ end.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is BufferSegment &&
|
||||
runtimeType == other.runtimeType &&
|
||||
range == other.range &&
|
||||
line == other.line &&
|
||||
start == other.start &&
|
||||
end == other.end;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
import 'package:clide/src/terminal/src/utils/hash_values.dart';
|
||||
|
||||
class CellData {
|
||||
CellData({
|
||||
required this.foreground,
|
||||
required this.background,
|
||||
required this.flags,
|
||||
required this.content,
|
||||
});
|
||||
|
||||
factory CellData.empty() {
|
||||
return CellData(
|
||||
foreground: 0,
|
||||
background: 0,
|
||||
flags: 0,
|
||||
content: 0,
|
||||
);
|
||||
}
|
||||
|
||||
int foreground;
|
||||
|
||||
int background;
|
||||
|
||||
int flags;
|
||||
|
||||
int content;
|
||||
|
||||
int getHash() {
|
||||
return hashValues(foreground, background, flags, content);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'CellData{foreground: $foreground, background: $background, flags: $flags, content: $content}';
|
||||
}
|
||||
}
|
||||
|
||||
abstract class CellAttr {
|
||||
static const bold = 1 << 0;
|
||||
static const faint = 1 << 1;
|
||||
static const italic = 1 << 2;
|
||||
static const underline = 1 << 3;
|
||||
static const blink = 1 << 4;
|
||||
static const inverse = 1 << 5;
|
||||
static const invisible = 1 << 6;
|
||||
static const strikethrough = 1 << 7;
|
||||
}
|
||||
|
||||
abstract class CellColor {
|
||||
static const valueMask = 0xFFFFFF;
|
||||
|
||||
static const typeShift = 25;
|
||||
static const typeMask = 3 << typeShift;
|
||||
|
||||
static const normal = 0 << typeShift;
|
||||
static const named = 1 << typeShift;
|
||||
static const palette = 2 << typeShift;
|
||||
static const rgb = 3 << typeShift;
|
||||
}
|
||||
|
||||
abstract class CellContent {
|
||||
static const codepointMask = 0x1fffff;
|
||||
|
||||
static const widthShift = 22;
|
||||
// static const widthMask = 3 << widthShift;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
typedef CharsetTranslator = int Function(int);
|
||||
|
||||
final _charsets = <int, CharsetTranslator>{
|
||||
'0'.codeUnitAt(0): decSpecGraphicsTranslator,
|
||||
'B'.codeUnitAt(0): asciiTranslator,
|
||||
};
|
||||
|
||||
class Charset {
|
||||
var _charsetMap = <int, CharsetTranslator>{};
|
||||
var _currentIndex = 0;
|
||||
|
||||
var _savedCharsetMap = <int, CharsetTranslator>{};
|
||||
var _savedIndex = 0;
|
||||
|
||||
var _cached = asciiTranslator;
|
||||
|
||||
void _updateCache() {
|
||||
_cached = _charsetMap[_currentIndex] ?? asciiTranslator;
|
||||
}
|
||||
|
||||
int translate(int codePoint) {
|
||||
return _cached(codePoint);
|
||||
}
|
||||
|
||||
void designate(int index, int name) {
|
||||
final charset = _charsets[name];
|
||||
if (charset != null) {
|
||||
_charsetMap[index] = charset;
|
||||
_updateCache();
|
||||
}
|
||||
}
|
||||
|
||||
void use(int index) {
|
||||
_currentIndex = index;
|
||||
_updateCache();
|
||||
}
|
||||
|
||||
void save() {
|
||||
_savedCharsetMap = Map.from(_charsetMap);
|
||||
_savedIndex = _currentIndex;
|
||||
}
|
||||
|
||||
void restore() {
|
||||
_charsetMap = _savedCharsetMap;
|
||||
_currentIndex = _savedIndex;
|
||||
_updateCache();
|
||||
}
|
||||
}
|
||||
|
||||
const decSpecGraphics = <int, int>{
|
||||
0x5f: 0x00A0, // NO-BREAK SPACE
|
||||
0x60: 0x25C6, // BLACK DIAMOND
|
||||
0x61: 0x2592, // MEDIUM SHADE
|
||||
0x62: 0x2409, // SYMBOL FOR HORIZONTAL TABULATION
|
||||
0x63: 0x240C, // SYMBOL FOR FORM FEED
|
||||
0x64: 0x240D, // SYMBOL FOR CARRIAGE RETURN
|
||||
0x65: 0x240A, // SYMBOL FOR LINE FEED
|
||||
0x66: 0x00B0, // DEGREE SIGN
|
||||
0x67: 0x00B1, // PLUS-MINUS SIGN
|
||||
0x68: 0x2424, // SYMBOL FOR NEWLINE
|
||||
0x69: 0x240B, // SYMBOL FOR VERTICAL TABULATION
|
||||
0x6a: 0x2518, // BOX DRAWINGS LIGHT UP AND LEFT
|
||||
0x6b: 0x2510, // BOX DRAWINGS LIGHT DOWN AND LEFT
|
||||
0x6c: 0x250C, // BOX DRAWINGS LIGHT DOWN AND RIGHT
|
||||
0x6d: 0x2514, // BOX DRAWINGS LIGHT UP AND RIGHT
|
||||
0x6e: 0x253C, // BOX DRAWINGS LIGHT VERTICAL AND HORIZONTAL
|
||||
0x6f: 0x23BA, // HORIZONTAL SCAN LINE-1
|
||||
0x70: 0x23BB, // HORIZONTAL SCAN LINE-3
|
||||
0x71: 0x2500, // BOX DRAWINGS LIGHT HORIZONTAL
|
||||
0x72: 0x23BC, // HORIZONTAL SCAN LINE-7
|
||||
0x73: 0x23BD, // HORIZONTAL SCAN LINE-9
|
||||
0x74: 0x251C, // BOX DRAWINGS LIGHT VERTICAL AND RIGHT
|
||||
0x75: 0x2524, // BOX DRAWINGS LIGHT VERTICAL AND LEFT
|
||||
0x76: 0x2534, // BOX DRAWINGS LIGHT UP AND HORIZONTAL
|
||||
0x77: 0x252C, // BOX DRAWINGS LIGHT DOWN AND HORIZONTAL
|
||||
0x78: 0x2502, // BOX DRAWINGS LIGHT VERTICAL
|
||||
0x79: 0x2264, // LESS-THAN OR EQUAL TO
|
||||
0x7a: 0x2265, // GREATER-THAN OR EQUAL TO
|
||||
0x7b: 0x03C0, // GREEK SMALL LETTER PI
|
||||
0x7c: 0x2260, // NOT EQUAL TO
|
||||
0x7d: 0x00A3, // POUND SIGN
|
||||
0x7e: 0x00B7, // MIDDLE DOT
|
||||
};
|
||||
|
||||
int asciiTranslator(int codePoint) {
|
||||
return codePoint;
|
||||
}
|
||||
|
||||
int decSpecGraphicsTranslator(int codePoint) {
|
||||
if (codePoint >= 127) {
|
||||
return codePoint;
|
||||
}
|
||||
|
||||
return decSpecGraphics[codePoint] ?? codePoint;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
abstract class NamedColor {
|
||||
static const black = 0;
|
||||
static const red = 1;
|
||||
static const green = 2;
|
||||
static const yellow = 3;
|
||||
static const blue = 4;
|
||||
static const magenta = 5;
|
||||
static const cyan = 6;
|
||||
static const white = 7;
|
||||
|
||||
static const brightBlack = 8;
|
||||
static const brightRed = 9;
|
||||
static const brightGreen = 10;
|
||||
static const brightYellow = 11;
|
||||
static const brightBlue = 12;
|
||||
static const brightMagenta = 13;
|
||||
static const brightCyan = 14;
|
||||
static const brightWhite = 15;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
import 'package:clide/src/terminal/src/core/cell.dart';
|
||||
|
||||
class CursorStyle {
|
||||
int foreground;
|
||||
|
||||
int background;
|
||||
|
||||
int attrs;
|
||||
|
||||
CursorStyle({this.foreground = 0, this.background = 0, this.attrs = 0});
|
||||
|
||||
static final empty = CursorStyle();
|
||||
|
||||
void setBold() {
|
||||
attrs |= CellAttr.bold;
|
||||
}
|
||||
|
||||
void setFaint() {
|
||||
attrs |= CellAttr.faint;
|
||||
}
|
||||
|
||||
void setItalic() {
|
||||
attrs |= CellAttr.italic;
|
||||
}
|
||||
|
||||
void setUnderline() {
|
||||
attrs |= CellAttr.underline;
|
||||
}
|
||||
|
||||
void setBlink() {
|
||||
attrs |= CellAttr.blink;
|
||||
}
|
||||
|
||||
void setInverse() {
|
||||
attrs |= CellAttr.inverse;
|
||||
}
|
||||
|
||||
void setInvisible() {
|
||||
attrs |= CellAttr.invisible;
|
||||
}
|
||||
|
||||
void setStrikethrough() {
|
||||
attrs |= CellAttr.strikethrough;
|
||||
}
|
||||
|
||||
void unsetBold() {
|
||||
attrs &= ~CellAttr.bold;
|
||||
}
|
||||
|
||||
void unsetFaint() {
|
||||
attrs &= ~CellAttr.faint;
|
||||
}
|
||||
|
||||
void unsetItalic() {
|
||||
attrs &= ~CellAttr.italic;
|
||||
}
|
||||
|
||||
void unsetUnderline() {
|
||||
attrs &= ~CellAttr.underline;
|
||||
}
|
||||
|
||||
void unsetBlink() {
|
||||
attrs &= ~CellAttr.blink;
|
||||
}
|
||||
|
||||
void unsetInverse() {
|
||||
attrs &= ~CellAttr.inverse;
|
||||
}
|
||||
|
||||
void unsetInvisible() {
|
||||
attrs &= ~CellAttr.invisible;
|
||||
}
|
||||
|
||||
void unsetStrikethrough() {
|
||||
attrs &= ~CellAttr.strikethrough;
|
||||
}
|
||||
|
||||
bool get isBold => (attrs & CellAttr.bold) != 0;
|
||||
|
||||
bool get isFaint => (attrs & CellAttr.faint) != 0;
|
||||
|
||||
bool get isItalis => (attrs & CellAttr.italic) != 0;
|
||||
|
||||
bool get isUnderline => (attrs & CellAttr.underline) != 0;
|
||||
|
||||
bool get isBlink => (attrs & CellAttr.blink) != 0;
|
||||
|
||||
bool get isInverse => (attrs & CellAttr.inverse) != 0;
|
||||
|
||||
bool get isInvisible => (attrs & CellAttr.invisible) != 0;
|
||||
|
||||
void setForegroundColor16(int color) {
|
||||
foreground = color | CellColor.named;
|
||||
}
|
||||
|
||||
void setForegroundColor256(int color) {
|
||||
foreground = color | CellColor.palette;
|
||||
}
|
||||
|
||||
void setForegroundColorRgb(int r, int g, int b) {
|
||||
foreground = (r << 16) | (g << 8) | b | CellColor.rgb;
|
||||
}
|
||||
|
||||
void resetForegroundColor() {
|
||||
foreground = 0; // | CellColor.normal;
|
||||
}
|
||||
|
||||
void setBackgroundColor16(int color) {
|
||||
background = color | CellColor.named;
|
||||
}
|
||||
|
||||
void setBackgroundColor256(int color) {
|
||||
background = color | CellColor.palette;
|
||||
}
|
||||
|
||||
void setBackgroundColorRgb(int r, int g, int b) {
|
||||
background = (r << 16) | (g << 8) | b | CellColor.rgb;
|
||||
}
|
||||
|
||||
void resetBackgroundColor() {
|
||||
background = 0; // | CellColor.normal;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
foreground = 0;
|
||||
background = 0;
|
||||
attrs = 0;
|
||||
}
|
||||
}
|
||||
|
||||
class CursorPosition {
|
||||
int x;
|
||||
|
||||
int y;
|
||||
|
||||
CursorPosition(this.x, this.y);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
class EscapeEmitter {
|
||||
const EscapeEmitter();
|
||||
|
||||
String primaryDeviceAttributes() {
|
||||
return '\x1b[?1;2c';
|
||||
}
|
||||
|
||||
String secondaryDeviceAttributes() {
|
||||
const model = 0;
|
||||
const version = 0;
|
||||
return '\x1b[>$model;$version;0c';
|
||||
}
|
||||
|
||||
String tertiaryDeviceAttributes() {
|
||||
return '\x1bP!|00000000\x1b\\';
|
||||
}
|
||||
|
||||
String operatingStatus() {
|
||||
return '\x1b[0n';
|
||||
}
|
||||
|
||||
String cursorPosition(int x, int y) {
|
||||
return '\x1b[$y;${x}R';
|
||||
}
|
||||
|
||||
String bracketedPaste(String text) {
|
||||
return '\x1b[200~$text\x1b[201~';
|
||||
}
|
||||
|
||||
String size(int rows, int cols) {
|
||||
return '\x1b[8;$rows;${cols}t';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
import 'package:clide/src/terminal/src/core/mouse/mode.dart';
|
||||
|
||||
abstract class EscapeHandler {
|
||||
void writeChar(int char);
|
||||
|
||||
/* SBC */
|
||||
|
||||
void bell();
|
||||
|
||||
void backspaceReturn();
|
||||
|
||||
void tab();
|
||||
|
||||
void lineFeed();
|
||||
|
||||
void carriageReturn();
|
||||
|
||||
void shiftOut();
|
||||
|
||||
void shiftIn();
|
||||
|
||||
void unknownSBC(int char);
|
||||
|
||||
/* ANSI sequence */
|
||||
|
||||
void saveCursor();
|
||||
|
||||
void restoreCursor();
|
||||
|
||||
void index();
|
||||
|
||||
void nextLine();
|
||||
|
||||
void setTapStop();
|
||||
|
||||
void reverseIndex();
|
||||
|
||||
void designateCharset(int charset, int name);
|
||||
|
||||
void unkownEscape(int char);
|
||||
|
||||
/* CSI */
|
||||
|
||||
void repeatPreviousCharacter(int n);
|
||||
|
||||
void setCursor(int x, int y);
|
||||
|
||||
void setCursorX(int x);
|
||||
|
||||
void setCursorY(int y);
|
||||
|
||||
void sendPrimaryDeviceAttributes();
|
||||
|
||||
void clearTabStopUnderCursor();
|
||||
|
||||
void clearAllTabStops();
|
||||
|
||||
void moveCursorX(int offset);
|
||||
|
||||
void moveCursorY(int n);
|
||||
|
||||
void sendSecondaryDeviceAttributes();
|
||||
|
||||
void sendTertiaryDeviceAttributes();
|
||||
|
||||
void sendOperatingStatus();
|
||||
|
||||
void sendCursorPosition();
|
||||
|
||||
void setMargins(int i, [int? bottom]);
|
||||
|
||||
void cursorNextLine(int amount);
|
||||
|
||||
void cursorPrecedingLine(int amount);
|
||||
|
||||
void eraseDisplayBelow();
|
||||
|
||||
void eraseDisplayAbove();
|
||||
|
||||
void eraseDisplay();
|
||||
|
||||
void eraseScrollbackOnly();
|
||||
|
||||
void eraseLineRight();
|
||||
|
||||
void eraseLineLeft();
|
||||
|
||||
void eraseLine();
|
||||
|
||||
void insertLines(int amount);
|
||||
|
||||
void deleteLines(int amount);
|
||||
|
||||
void deleteChars(int amount);
|
||||
|
||||
void scrollUp(int amount);
|
||||
|
||||
void scrollDown(int amount);
|
||||
|
||||
void eraseChars(int amount);
|
||||
|
||||
void insertBlankChars(int amount);
|
||||
|
||||
void unknownCSI(int finalByte);
|
||||
|
||||
/* Modes */
|
||||
|
||||
void setInsertMode(bool enabled);
|
||||
|
||||
void setLineFeedMode(bool enabled);
|
||||
|
||||
void setUnknownMode(int mode, bool enabled);
|
||||
|
||||
/* DEC Private modes */
|
||||
|
||||
void setCursorKeysMode(bool enabled);
|
||||
|
||||
void setReverseDisplayMode(bool enabled);
|
||||
|
||||
void setOriginMode(bool enabled);
|
||||
|
||||
void setColumnMode(bool enabled);
|
||||
|
||||
void setAutoWrapMode(bool enabled);
|
||||
|
||||
void setMouseMode(MouseMode mode);
|
||||
|
||||
void setCursorBlinkMode(bool enabled);
|
||||
|
||||
void setCursorVisibleMode(bool enabled);
|
||||
|
||||
void useAltBuffer();
|
||||
|
||||
void useMainBuffer();
|
||||
|
||||
void clearAltBuffer();
|
||||
|
||||
void setAppKeypadMode(bool enabled);
|
||||
|
||||
void setReportFocusMode(bool enabled);
|
||||
|
||||
void setMouseReportMode(MouseReportMode mode);
|
||||
|
||||
void setAltBufferMouseScrollMode(bool enabled);
|
||||
|
||||
void setBracketedPasteMode(bool enabled);
|
||||
|
||||
void setUnknownDecMode(int mode, bool enabled);
|
||||
|
||||
void resize(int cols, int rows);
|
||||
|
||||
void sendSize();
|
||||
|
||||
/* Select Graphic Rendition (SGR) */
|
||||
|
||||
void resetCursorStyle();
|
||||
|
||||
void setCursorBold();
|
||||
|
||||
void setCursorFaint();
|
||||
|
||||
void setCursorItalic();
|
||||
|
||||
void setCursorUnderline();
|
||||
|
||||
void setCursorBlink();
|
||||
|
||||
void setCursorInverse();
|
||||
|
||||
void setCursorInvisible();
|
||||
|
||||
void setCursorStrikethrough();
|
||||
|
||||
void unsetCursorBold();
|
||||
|
||||
void unsetCursorFaint();
|
||||
|
||||
void unsetCursorItalic();
|
||||
|
||||
void unsetCursorUnderline();
|
||||
|
||||
void unsetCursorBlink();
|
||||
|
||||
void unsetCursorInverse();
|
||||
|
||||
void unsetCursorInvisible();
|
||||
|
||||
void unsetCursorStrikethrough();
|
||||
|
||||
void setForegroundColor16(int color);
|
||||
|
||||
void setForegroundColor256(int index);
|
||||
|
||||
void setForegroundColorRgb(int r, int g, int b);
|
||||
|
||||
void resetForeground();
|
||||
|
||||
void setBackgroundColor16(int color);
|
||||
|
||||
void setBackgroundColor256(int index);
|
||||
|
||||
void setBackgroundColorRgb(int r, int g, int b);
|
||||
|
||||
void resetBackground();
|
||||
|
||||
void unsupportedStyle(int param);
|
||||
|
||||
/* OSC */
|
||||
|
||||
void setTitle(String name);
|
||||
|
||||
void setIconName(String name);
|
||||
|
||||
void unknownOSC(String code, List<String> args);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
import 'package:clide/src/terminal/src/core/input/keys.dart';
|
||||
import 'package:clide/src/terminal/src/core/input/keytab/keytab.dart';
|
||||
import 'package:clide/src/terminal/src/core/state.dart';
|
||||
import 'package:clide/src/terminal/src/core/platform.dart';
|
||||
|
||||
/// The key event received from the keyboard, along with the state of the
|
||||
/// modifier keys and state of the terminal. Typically consumed by the
|
||||
/// [TerminalInputHandler] to produce a escape sequence that can be recognized
|
||||
/// by the terminal.
|
||||
///
|
||||
/// See also:
|
||||
/// - [TerminalInputHandler]
|
||||
class TerminalKeyboardEvent {
|
||||
final TerminalKey key;
|
||||
|
||||
final bool shift;
|
||||
|
||||
final bool ctrl;
|
||||
|
||||
final bool alt;
|
||||
|
||||
final TerminalState state;
|
||||
|
||||
final bool altBuffer;
|
||||
|
||||
final TerminalTargetPlatform platform;
|
||||
|
||||
TerminalKeyboardEvent({
|
||||
required this.key,
|
||||
required this.shift,
|
||||
required this.ctrl,
|
||||
required this.alt,
|
||||
required this.state,
|
||||
required this.altBuffer,
|
||||
required this.platform,
|
||||
});
|
||||
|
||||
TerminalKeyboardEvent copyWith({
|
||||
TerminalKey? key,
|
||||
bool? shift,
|
||||
bool? ctrl,
|
||||
bool? alt,
|
||||
TerminalState? state,
|
||||
bool? altBuffer,
|
||||
TerminalTargetPlatform? platform,
|
||||
}) {
|
||||
return TerminalKeyboardEvent(
|
||||
key: key ?? this.key,
|
||||
shift: shift ?? this.shift,
|
||||
ctrl: ctrl ?? this.ctrl,
|
||||
alt: alt ?? this.alt,
|
||||
state: state ?? this.state,
|
||||
altBuffer: altBuffer ?? this.altBuffer,
|
||||
platform: platform ?? this.platform,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// TerminalInputHandler contains the logic for translating a [TerminalKeyboardEvent]
|
||||
/// into escape sequences that can be recognized by the terminal.
|
||||
abstract class TerminalInputHandler {
|
||||
/// Translates a [TerminalKeyboardEvent] into an escape sequence. If the event
|
||||
/// cannot be translated, null is returned.
|
||||
String? call(TerminalKeyboardEvent event);
|
||||
}
|
||||
|
||||
/// A [TerminalInputHandler] that chains multiple handlers together. If any
|
||||
/// handler returns a non-null value, it is returned. Otherwise, null is
|
||||
/// returned.
|
||||
class CascadeInputHandler implements TerminalInputHandler {
|
||||
final List<TerminalInputHandler> _handlers;
|
||||
|
||||
const CascadeInputHandler(this._handlers);
|
||||
|
||||
@override
|
||||
String? call(TerminalKeyboardEvent event) {
|
||||
for (var handler in _handlers) {
|
||||
final result = handler(event);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// The default input handler for the terminal. That is composed of a
|
||||
/// [KeytabInputHandler], a [CtrlInputHandler], and a [AltInputHandler].
|
||||
///
|
||||
/// It's possible to override the default input handler behavior by chaining
|
||||
/// another input handler before or after the default input handler using
|
||||
/// [CascadeInputHandler].
|
||||
///
|
||||
/// See also:
|
||||
/// * [CascadeInputHandler]
|
||||
const defaultInputHandler = CascadeInputHandler([
|
||||
KeytabInputHandler(),
|
||||
CtrlInputHandler(),
|
||||
AltInputHandler(),
|
||||
]);
|
||||
|
||||
/// A [TerminalInputHandler] that translates key events according to a keytab
|
||||
/// file. If no keytab is provided, [Keytab.defaultKeytab] is used.
|
||||
class KeytabInputHandler implements TerminalInputHandler {
|
||||
const KeytabInputHandler([this.keytab]);
|
||||
|
||||
final Keytab? keytab;
|
||||
|
||||
@override
|
||||
String? call(TerminalKeyboardEvent event) {
|
||||
final keytab = this.keytab ?? Keytab.defaultKeytab;
|
||||
|
||||
final record = keytab.find(
|
||||
event.key,
|
||||
ctrl: event.ctrl,
|
||||
alt: event.alt,
|
||||
shift: event.shift,
|
||||
newLineMode: event.state.lineFeedMode,
|
||||
appCursorKeys: event.state.appKeypadMode,
|
||||
appKeyPad: event.state.appKeypadMode,
|
||||
appScreen: event.altBuffer,
|
||||
macos: event.platform == TerminalTargetPlatform.macos,
|
||||
);
|
||||
|
||||
if (record == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = record.action.unescapedValue();
|
||||
result = insertModifiers(event, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
String insertModifiers(TerminalKeyboardEvent event, String action) {
|
||||
String? code;
|
||||
|
||||
if (event.shift && event.alt && event.ctrl) {
|
||||
code = '8';
|
||||
} else if (event.ctrl && event.alt) {
|
||||
code = '7';
|
||||
} else if (event.shift && event.ctrl) {
|
||||
code = '6';
|
||||
} else if (event.ctrl) {
|
||||
code = '5';
|
||||
} else if (event.shift && event.alt) {
|
||||
code = '4';
|
||||
} else if (event.alt) {
|
||||
code = '3';
|
||||
} else if (event.shift) {
|
||||
code = '2';
|
||||
}
|
||||
|
||||
if (code != null) {
|
||||
return action.replaceAll('*', code);
|
||||
}
|
||||
|
||||
return action;
|
||||
}
|
||||
}
|
||||
|
||||
/// A [TerminalInputHandler] that translates ctrl + key events into escape
|
||||
/// sequences. For example, ctrl + a becomes ^A.
|
||||
class CtrlInputHandler implements TerminalInputHandler {
|
||||
const CtrlInputHandler();
|
||||
|
||||
@override
|
||||
String? call(TerminalKeyboardEvent event) {
|
||||
if (!event.ctrl || event.shift || event.alt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final key = event.key;
|
||||
|
||||
if (key.index >= TerminalKey.keyA.index &&
|
||||
key.index <= TerminalKey.keyZ.index) {
|
||||
final input = key.index - TerminalKey.keyA.index + 1;
|
||||
return String.fromCharCode(input);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// A [TerminalInputHandler] that translates alt + key events into escape
|
||||
/// sequences. For example, alt + a becomes ^[a.
|
||||
class AltInputHandler implements TerminalInputHandler {
|
||||
const AltInputHandler();
|
||||
|
||||
@override
|
||||
String? call(TerminalKeyboardEvent event) {
|
||||
if (!event.alt || event.ctrl || event.shift) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (event.platform == TerminalTargetPlatform.macos) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final key = event.key;
|
||||
|
||||
if (key.index >= TerminalKey.keyA.index &&
|
||||
key.index <= TerminalKey.keyZ.index) {
|
||||
final charCode = key.index - TerminalKey.keyA.index + 65;
|
||||
final input = [0x1b, charCode];
|
||||
return String.fromCharCodes(input);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||